코어데이터에서 커스텀 타입 속성을 사용하기

코어데이터 내의 엔티티의 속성은 문자열, 숫자값, 날짜, 바이너리 데이터등의 기본적인 타입을 지정할 수 있다. 하지만 어떤 경우에는 이런 타입 이외의 커스텀 타입을 사용해야 하는 경우가 있을 수 있다. 예를 들어 NSColor라든지, 혹은 CGRect, CGPoint, CGSize와 같은 C 구조체로 된 정보 또, 아예 직접 작성한 커스텀 타입인 경우도 있을 수 있다. 이러한 커스텀 타입을 엔티티의 속성으로 사용하는 방법에 대해서 알아보자.

코어데이터 모델 편집기에서 엔티티 내 속성(attribute)의 타입을 선택하기 위한 팝업 메뉴 중에는 Transformable 이라는 타입이 존재한다. 이 타입은 저장소에 저장될 때는 바이너리 데이터로 저장되지만 컨텍스트로 올라갈 때는 다른 형식으로 변환되어 올라가도록 동작하게 된다.

속성의 타입을 Transformable로 선택한 후 오른쪽 데이터 모델 인스펙터를 보면 그림과 같이 Value Transformer를 설정하거나 Custom Class를 설정하는 부분이 있다. 그림의 예는 NSColor를 코어데이터에 저장하기 위한 설정을 보여주고 있다.

커스텀 클래스에 NSColor를 기입하고, Value Transformer는 비워둔다. 이 상태로 해당 엔티티에 대한 클래스를 생성하면, NSColor 타입의 프로퍼티가 생성되어 있는 것을 확인할 수 있다. (만약 커스텀 클래스 란을 비워두면 타입이 id로 정의된다.)

이때, NSColorNSCoding을 따르는 클래스이다. 따라서 별도의 트랜스포머 없이 데이터와 컬러 객체간의 변경이 가능하다. 즉, 커스텀 클래스를 만들어서 이 타입을 엔티티 속성에 사용한다고 하면, 해당 클래스가 NSCoding을 준수하도록만 해주고 모델 편집기에서는 Transformable로 타입을 설정해주면 된다.

NSCoding을 따르지 않는 클래스를 사용하는 방법으로는 트랜스포머를 사용하는 방법이 있다. 예를 들면 NSImage 같은 것을 코어데이터에 어떻게 저장할 것인가 하는 부분이다. (코코아 바인딩을 사용한다면 이미지뷰나 이미지웰의 data 항목을 사용하면 굳이 NSImage 인스턴스가 아닌 NSData만으로도 구현은 가능하다.)

NSImageNSCoding을 따르지 않으므로 트랜스포머를 만들어야 한다. 이 때 주의할점은 앱 -> 코어데이터로의 방향이 정방향이라는 점이며, 따라서 transformedValueClass는 항상 NSData여야 한다는 점이다.

/// PhotoTransformer for NSImage into Core Data

@interface PhotoTransformer: NSValueTransformer
@end

@implementation PhotoTransformer

+ (Class)transformedValueClass {
  return [NSData class];
}

+ (BOOL)allowsReverseTransformation
{
  return YES;
}

- (id)transformedValue:(id)value
{
  // NSImage -> NSData 변환
  NSImage* image = (NSImage*)value;
  return [image TIFFRepresentation];
}

- (id)reverseTransformedValue:(id)value
{
  NSData* data = (NSData*)value;
  NSImage* image = [[NSImage alloc] initWithData:data];
  return image;
}

// 트랜스포머를 등록하기. 이 부분은 여기보다는 앱 델리게이트에서 해주는 것이 좋다.
+ (void)initialize
{ 
  PhotoTransformer *pt = [[PhotoTransformer alloc] init];
  [NSValueTransformer setValueTransformer:pt
                      forName:@"PhotoTransformer"];
}
@end

Swift 버전

일전에 값 트랜스포머 관련해서 포스팅을 한 번 했을 때에도 언급했던 것 같은데, Swift를 써서 값 트랜스포머를 쓸 때에는 몇 가지 다른 점이 있다.

  1. Swift에서 ValueTransformer 클래스는 NSObject의 서브 클래스가 아니다. 따라서 initialize를 오버라이딩하는 부분을 만들 수 없다. 따라서 이 부분은 앱 델리게이트의 메소드를 오버라이딩해서 호출되도록 해야 한다.
  2. ValueTransformer의 이름은 단순 문자열 타입이 아니라 NSValueTransformerName 이라는 별도 타입으로 정의된다.  보통 서브클래스를 만든 후에 ValueTranformer를 확장하여 해당이름을 클래스 속성으로 추가한다.

동일한 클래스를 Swift로 작성하면 다음과 같다.

@objc(PhotoTransformer)
class PhotoTransformer: ValueTransformer {
  override class func transformedValueClass() -> AnyClass { return NSData.self }
  override class func allowsReverseTransformation() -> Bool { return true }

  override func transformedValue(_ value: Any?) -> Any? {
    // 
    if let image = value as? NSImage {
      return image.tiffRepresentation
    }
    return nil;
  }
  
  override func reverseTransformedValue(_ value: Any?) -> Any? {
    if let data = value as? Data {
      return NSImage(data: data)
    }
    return nil
  }
}

// 이름을 추가 등록
extension ValueTransformer {
  static let photoTransformerName = NSValueTransformerName(rawValue: "PhotoTransformer")
}

샘플 프로젝트

간단한 실증용 프로젝트를 만들어보자. (Objective-C로 진행했다.) 프로젝트를 하나 생성한다. 시작 시 코어데이터에 체크하여 코어데이터 스택이 미리 준비되도록 한다.

가장 먼저할 일은 코어데이터 모델 파일을 열어서 엔티티를 추가하는 것이다. 다음과 같이 간단하게 3개의 속성만을 정의한다. photo는 사진 속성으로 Transformable 타입으로 선택한다.

photo 속성을 선택하고 데이터 모델 인스펙터를 통해서 몇 가지 세부 사항을 정의한다.  커스텀 클래스는 NSImage로 기입하고, Value Transformer Name에는 “Photo2DataTransformer”라고 쓴다. 값 트랜스포머를 만든 후에 반드시 이 이름으로 등록해야한다

데이터 모델에 대한 편집은 이것으로 끝났다. 다음은 Photo2DataTransformer를 작성할 시간이다. 새 파일을 추가하고 Cocoa Class를 선택한다. NSValueTransformer의 서브 클래스를 만든다고 설정하면 헤더에 Foundation.h를 임포트하게 되는데, 이 상황에서는 NSImage가 노출되지 않으니, 이 부분을 <Cocoa/Cocoa.h>로만 변경해주면 된다. 이후 소스는 위에서 설명한 내용과 동일하다.

다음은 등록을 위해서 앱 델리게이트를 편집할 차례이다. 앱 델리게이트에서는 두 가지 작업을 처리해야 한다.

  1. UI는 코코아 바인딩으로 설정할 것이므로 NSManagedObjectContext에 접근할 수 있는 접근자를 준비한다.
  2. 앞서 작성한 Photo2DataTransformer를 등록한다.

 

/// in AppDelegate.m

#import "Photo2DataTransformer.h"  // 1

@interface AppDelegate ()
...
- (NSManagedObjectContext *)context; // 2
@end

@implementation AppDelgate
...

+ (void)initialize //3
{
  [super initialize];
  Photo2DataTransformer *pt = [[Photo2DataTransformer alloc] init];
  [NSValueTransformer setValueTransformer:pt
                      forName: @"Photo2DataTransformer"];
}

- (NSManagedObjectContext*)context // 4
{
  return self.persistentContainer.viewContext;
}
...
  1. Photo2DataTransformer.h 헤더 반입
  2. 컨텍스트 접근자 선언
  3. 값 트랜스포머를 등록한다.
  4. 컨텍스트 접근자 구현

모든 코드 작업은 끝났고, 이제 UI를 만들 차례이다. 만들어질 UI의 모양은 다음과 같다.

  • 테이블 뷰는 뷰 기반 테이블 뷰로 1개 칼럼을 가진다.
  • 테이블 뷰 셀 뷰 내에는 1개의 이미지 뷰와 2 개의 텍스트레이블을 위치시켰다.
  • Add 버튼을 하나 추가한다.
  • Box를 하나 추가하고 그 속에, 두 개의 텍스트레이블+필드 쌍과 이미지웰(ImageWell)을 추가했다.
  • 참고로 ImageWell은 선택해서 Editable 속성에 체크해주어야 한다.

그리고 ArrayController 하나를 추가한다. 이름을 EmplyeesController라고 짓고, 다음과 같이 엔티티 모드로 하고 엔티티 이름을 준다. 그리고 앱이 시작했을 때 저장된 내용을 읽어와 보여주도록하려면 Prepares Content에도 체크한다.

다음은 테이블 뷰를 선택해서 바인딩 Table Content를 배열컨트롤러의 컨트롤러 키 “arrangedObject”에 바인딩한다.  그리고 테이블 뷰의 선택한 행과 배열컨트롤러의 선택된 값을 동기화하기 위해서 테이블 뷰의 바인딩 Selection Indexes를 배열 컨트롤러의 컨트롤러 키 “selectionIndexes”와 바인딩한다.

  • 테이블 뷰 바인딩 설정
    • 바인딩: Table Content
      • 대상 : EmployeesController
      • 컨트롤러 키 : arrangedObjects
    • 바인딩: Selection Indexes
      • 대상: EmplyeesController
      • 컨트롤러 키 : selectionIndexes

다음은 테이블 뷰 셀 내부의 뷰들에 대해 바인딩한다. 테이블 뷰 셀 내부의 컨트롤들은 모두 테이블 뷰 셀과 바인딩하면서 셀의 “objectValue”의 하위 키패스와 바인딩하면 된다.

  • 이미지 뷰 (테이블 뷰 셀 내) 바인딩
    • 바인딩: Value (주의: Data가 아님)
      • 대상 : Table Cell View
      • 모델 키 : objectValue.photo
  • 텍스트 필드 두 개
    • 바인딩 : value
      • 대상 : Table Cell View
      • 모델 키 : objectValue.firstName
        (아래쪽 텍스트 필드는 모델 키만 objectValue.lastName을 쓰고 나머지는 동일하다.)

버튼은 바인딩이 아니라 액션 메시지를 연결한다. EmployeesController의 add: 와 연결해준다.

다음 아래쪽 박스 내부의 UI는 테이블 뷰에서 선택한 데이터의 세부 내용이다. 두 개의 텍스트 필드와 이미지 뷰를 배열 컨트롤러의 “selection”과 연결해주면 된다

  • 이미지 뷰 (Image Well)
    • 바인딩: Value
      • 대상 : EmployeesController
      • 컨트롤러 키 : selection
      • 모델 키 : photo
  • 텍스트 필드 2 개
    • 바인딩 : value
      • 대상 : EmployeesController
      • 컨트롤러 키 : selection
      • 모델 키 : firstName, lastName

끝으로 메뉴의 Save… 항목을 앱 델리게이트의 saveAction: 과 연결해주면 완성이다.

아래 표는 바인딩 전체 정보에 대한 요약이다.

샘플 프로젝트 다운로드 : https://app.box.com/s/r5arwd1g4uhq1wd3z9p5elexli7gpun1

정리

코어데이터에서 기본적으로 지원되지 않는 타입을 사용하는 경우 다음의 방법을 쓰면 된다.

  1. 커스텀 클래스가 NSCoding을 지원하면 해당 속성을 Transformable로 설정하면 된다.
  2. 커스텀 클래스가 NSCoding을 지원하지 못하는 경우, Transformable 타입으로 설정하고 값 트랜스포머를 만들어서 설정해준다.

 

참고자료

20111207 :: [iOS] 저장이 가능한 간단 메모장

iOS 앱이 데이터를 저장하는 방법

많은 튜토리얼에서 간단한 아이폰 앱을 만드는 방법을 설명하고 있는데, 이런 튜토리얼을 따라서 이것 저것 만들어 보는 것 또한 재미도 있고, 또 여러가지 테크닉을 익힐 수 있지만 정작 활용이 가능한 앱을 만드는 것은 쉽지 않다. 이 글 (과 아마도 이어질 글들)에서는 메모장과 같이 간단히 입력한 텍스트를 저장하는 앱을 만들어 보는 것을 함께 알아보고자 한다. 혼자 삽질과 염탐(?)을 거듭하여 알아낸 내용들을 정리하는 차원이기도 하니 아주 자세하게는 아니지만 소상히 쓰려고 노력할 것이다.

iOS 앱이 어떤 데이터를 영구적으로 보관하는 방법에는 사실 여러가지가 있는데 대략 다음과 같은 방법들이 있다.

  • 코어데이터
  • SQLite
  • 아카이빙(직렬화)
  • 프로퍼티 리스트

코어데이터

코어데이터는 데이터를 저장소에 읽고 쓰고 또 관리하는 전반적인 기능을 제공하는 프레임워크이다. 코어데이터는 저장 방법을 아카이브 또는 데이터베이스(SQLite)로 사용할 수 있고, 변경 사항을 쉽게 추적하여 저장하고 또 저장된 객체를 불러와서 자동으로 관리해주는 다양한 기능들을 제공한다. 이를 잘 사용하면 엄청나게 많은 양의 코드를 손쉽게 줄일 수도 있다. 하지만 이 프레임워크는 결정적으로 초보자가 사용하기에는 조금 어렵다. 코어데이터 프레임워크 자체의 개념 자체가 초보자에게는 낯설기 때문이나, 실질적으로 예제를 만들어보면 허무할만큼이나 간단하게 데이터를 처리해준다.  예를 들어 사용자가 편집한 변경사항을 자동으로 기록한다거나 하는 것들을 지원한다.

코어데이터는 OSX용 앱을 제작할 때 코코아 바인딩과 결합하면 “한 줄의 코드도 작성하지 않고” 데이터를 불러오고 편집하고 저장하는 앱을 만드는 것이 가능할 정도의 모든 기능을 제공해주므로 시간이 될 때 꼭 공부해볼 것을 권장하며, 이 연재의 마지막에는 코어데이터를 사용하여 메모앱을 작성하는 방법을 살펴볼 것이다.

SQLite

SQLite는 아이폰에도 내장되어 있는 경량 데이터베이스 엔진이다. 개인적으로는 초보자에게 차라리 코코아 프레임워크를 권하고 싶다. 코코아터치에 포함되어 있는  SQLite3 프레임워크는 대부분 C 형식의 함수를 사용하고 있고 데이터 입출력시에는 일일이 쿼리 문을 작성해야 하므로 SQL에 대한 지식도 알고 있어야 하는 데 난점이 있다. 단, 사전이나 레퍼런스와 같은 형태의 앱을 작성할 때 미리 자료를 정제하여 DB 형태로 만들기가 쉬우므로 이 경우에는 SQLite를 쓰는 것이 좋을 수도 있다.

아카이빙

아카이빙 역시 초보자들에게는 조금 어려운 개념일 수 있으나, 대부분의 표준 코코아터치 클래스들은 스스로를 인코딩하는 방법을 알고 있기 때문에 의외로 손쉽게 접근할 수 있는 방법이나, SQLite에 밀려서 많이 쓰이고 있지 않다. 이 연재에서 우선적으로 다뤄볼 기법이기도 하다.

프로퍼티 리스트

프로퍼티 리스트는 일종의 XML의 포맷으로 키-값 의 짝을 기록하는 방법이다. 이 방법으로 생성된 데이터는 별도의 프로그램 없이 사람이 확인할 수 있다는 장점이 있다. 하지만 나는 잘 모르므로 패스하겠다.

아카이빙

아카이빙은 앱이 실행될 때 서로 서로 관계를 맺고 있는 객체들을 직렬화하여 데이터 스트림으로 만드는 기법이다. 이 데이터 스트림을 파일에 저장하고 또 읽어 들이는 방법으로 사용자 데이터를 보존할 수 있다.

다행히도 많은 표준 코코아터치 클래스들은 스스로를 아카이빙하는 방법을 알고 있다. 이러한 객체들은 NSCoding 프로토콜을 따르는데, 이 프로토콜을 따르는 객체는 스스로를 아카이빙하면서 자신과 연관을 맺고 있는 모든 객체에 대해 아카이빙하라는 메시지를 보낸다. 따라서 우리는 각각의 객체가 NSCoding 프로토콜을 따르도록하고 루트 객체를 아카이빙하면 필요한 모든 객체를 아카이빙할 수 있게 된다. (이와 관련해서는 나중에 다시 살펴볼 계획이다.) 이 방식의 강점은 여러 객체를 저장하기 위해서는 이를 단순히 배열에 담고 그 배열 객체를 한 번만 아카이빙하면 마치 전염병처럼 메시지가 번져나가 모든 객체를 아카이빙 할 수 있다는 것이다. 또한 단순한 값이 아닌 바이너리 데이터도 파일에 함께 그대로 저장할 수 있다는 강점이 있다.

객체를 아카이빙하기 위해서는 NSCoder 클래스를 사용한다. 하지만 NSCoder는 추상 클래스로 프로그래머가 직접 NSCoder 클래스의 인스턴스를 만드는 일은 거의 없다, 대신에 이 클래스에서 구체화된 NSKeyedArchiverNSKeyedUnarchiver 클래스를 사용한다.

예제 1 – 입력된 내용을 저장하는 메모판

이 모든 이야기는 예제를 통해 확인하면 보다 쉽게 이해될 수 있다. Xcode에서 새로운 프로젝트를 만든다. 프로젝트 이름이야 다들 알아서 하시고 iOS Application에서 single view 기반의 프로젝트를 생성하자. 이는 Xcode의 버전에 따라서는 window-based 앱일 수도 있다.

프로젝트가 생성되면 스토리보드 (혹은 MainWindow) 파일이 있는데 여기에 텍스트뷰를 하나 집어 넣고 사용자가 내용을 입력할 수 있도록 속성창에서 editable에 체크를 해 준다.  또한 저장하는 액션을 호출하기 위한 버튼도 하나 달아준다. 버튼은 텍스트뷰 위에 달아도 되고 아래에 달아도 된다. 디자인은 각자 알아서 하면 될 거 같다.

이제 RootViewController.h 에는 다음의 내용에 다음 코드를 추가하자

#import <UIKit/UIKit.h>

@interface RootViewController : UIViewController
{
    NSString *dataFilePath;
}
@property (nonatomic, strong) IBOutlet UITextView *memo;
-(IBAction)saveData:(id)sender;
@end

dataFilePath는 데이터를 저장할 파일의 경로를 담는 변수가 될 것이며, memo는 인터페이스 빌더에서 추가한 텍스트뷰의 아울렛이다. 파일을 저장하고 인터페이스 빌더에서 해당 텍스트뷰에 아울렛을 지정해 준다. (지정이 끝나면 nib 파일을 꼭 저장하라)

이제 이 클래스의 구현부이다. 프로퍼티를 선언했으니 맨 먼저 해줘야 하는 일이 있겠지…  @implementation RootViewController 라고 써 있는 줄 바로 아래에 다음 문장을 추가한다.

@synthesize memo;

다음은 앱이 실행되어 뷰가 로드되었을 때 바로 파일을 저장할 경로를 준비해두는 작업을 해보자. 1viewDidLoad` 매서드를 찾아 다음 코드를 추가한다.

NSString *docsDir;
NSArray *dirPaths;
dirPaths = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory
        , NSUserDomainMask, YES);
docsDir = [dirPaths objectAtIndex:0];
dataFilePath = [[NSString alloc] initWithString:[docsDir
stringByAppendingPathComponent:@"data.archive"]];

먼저 이 구문은 그냥 그대로 쓰는 거라고 외워두면 될 정도이다. 맨 마지막 줄의 파일 이름은 꼭 data.archive로 할 필요 없고 취향에 맞게 쓰면 된다. 이 코드는 앱의 하위 디렉토리 중에서 사용자 데이터 파일이 저장되는 Documents 디렉터리를 찾고 이 속에 파일이 저장될거라고 미리 파일의 경로를 생성해 두었다는 정도가 된다. 다행히 NSString은 파일 경로를 쉽게 다룰 수 있는 메서드를 이미 가지고 있으니 천만다행.

이제 실제 저장되는 부분을 보도록 하자.

-(IBAction)saveData:(id)sender
{
    [NSKeyedArchiver archiveRootObject:[memo text] toFile:dataFilePath];
}

좀 허무하리 만치 간단하지 않은가? NSKeyedArchiver 객체가 저장할 데이터를 아카이빙하고 파일에 저장하는 작업을 모두 처리해 준다.

이제 다시 인터페이스 빌더에서 저장 버튼을 뷰컨트롤러의 -saveData 메서드에 연결해준다.

뭔가 한가지 빠진게 있는데, 저장은 했다손 치더라도 그럼 앱이 다시 열렸을 때 저장된 내용을 복원해줘야 제맛 아니겠는가. 지금은 저장만 처리했지 데이터를 읽어오는 내용은 처리하지 않았다. 그럼 다시 RootViewController.m 에서 viewDidLoad 메서드의 끝 부분에 로딩에 관련된 코드를 추가하자.

로딩과 관련해서는 다음의 액션을 취한다.

  1. 먼저 데이터 파일 경로에 파일이 있는지 확인한다음,
  2. 파일이 있으면 파일을 읽어 들인다.
  3. 읽어들인 내용을 텍스트뷰에 옮겨준다.

코드는 다음과 같다.파일을 처리하기 위해서 NSFileManager를 사용한다.

// viewDidLoad의 마지막부분
NSFileManager *fileManager = [NSFileManager defaultManager];
if( [fileManager fileExistsAtPath:dataFilePath])
{
    NSString *memoData = [NSKeyedUnarchiver unarchiveObjectWithFile:dataFilePath];
    memo.text = memoData;
}

사실 여기까지하면 끝인데, 한 가지만 더 추가하자. 만약 텍스트 뷰 아래에 저장 버튼을 추가했다면, 키보드가 올라오는 바람에 저장 버튼을 누를 수 없는 경우가 있을 수 있는데, 뷰의 배경을 클릭해서 키보드를 닫도록 하자. 역시 뷰컨트롤러의 구현부에 추가한다.


-(void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event
{
    if( [memo isFirstResponder] ){
        [memo resignFirstResponder];
    }
}

끝이다. 빌드하고 실행해본다. 에뮬레이터에서 앱을 종료한 후 다시 실행해보아도 입력했던 내용이 여전히 남아 있는 것을 확인할 수 있을 것이다.

그런데 이 예제만 만들고 나면 왠지 좀 속는 기분이 든다. 만약 뷰에서 다루는 데이터가 여러 종류이고, 이를 함께 저장하려면 어떻게 하는가?  그럼 이 메모장에서 제목란을 추가해서 한 번 수정해 보도록 하자.

인터페이스 빌더에서 루트 뷰 컨트롤러의 텍스트 뷰 크기를 좀 조정해서 빈칸을 만든 다음, 여기에 텍스트 필드를 하나 추가한다. 그리고 뷰컨트롤러의 헤더에 이 텍스트 필드에 대한 아울렛을 지정하는 코드를 만들자.

@property (nonatomic, strong) IBOutlet UITextField *title;

헤더 파일을 저장한 다음, 인터페이스 빌더에서 이 아울렛을 실제 텍스트 필드와 연결한 후 저장한다. 다시 구현부로 간다. 추가한 property에 대해 synthesize 구문을 추가해준다.

@synthesize title;

이제 -saveData: 메서드를 바꾼다. title과 memo의 내용을 한 번에 저장할 것이기 때문에 NSString을 아카이빙하는 것이 아니라 NSDictionary로 만들어서 저장한다.

-(IBAction)saveData:(id)sender
{
    NSMutableDictionary *dataDictionary = [[NSMutableDictionary alloc]
        initWithObjectsAndKeys:title.text,@"title",memo.text,@"memo", nil];
    [NSKeyedArchiver archiveRootObject:dataDictionary toFile:dataFilePath];
}

저장하는 방법을 바꿨으니, 불러오는 방법도 바꿔야겠지. -viewDidLoad 의 마지막 부분을 아래와 같이 바꾸면 된다. NSKeyedUnarchiver를 사용하여 NSDictionary를 복원한다음, 각 키 값을 사용하여 title, memo의 내용을 복원해주면 된다.

 
if([filemanager fileExistsAtPath:dataFilePath])
{
    NSDictionary *dataDictionary;
    dataDictionary = [NSKeyedUnarchiver unarchiveObjectWithFile:dataFilePath];
    memo.text = [dataDictionary objectForKey:@"memo"];
    title.text = [dataDictionary objectForKey:@"title"];
}

자, 이래도 아카이빙이 어려운가?   다음 글에서는 메모 한 개가 아니라 여러 개를 만들 수 있는 앱을 어떻게 만들 것인지 알아보도록 하겠다. 긴 글 읽으시느라 고생많았다.

이어지는 글 : 저장이 가능한 간단 메모장 2 (1/2)