[Cocoa] Pasteboard를 사용하여 복사/붙여넣기

페이스트 보드 사용하기

임의의 클래스를 페이스트 보드로 복사하고 또 읽어와서 붙여넣기를 하려면 객체의 데이터를 직렬화된 데이터나 프로퍼티 리스트로 변경할 수 있어야 한다. 따라서 이 글을 읽기 전에 이와 관련된 문서들을 먼저 읽을 것을 권한다.

페이스트 보드

복사/붙여넣기를 하면 컴퓨터 메모리의 특정한 영역에 해당 데이터를 복사해두고 필요시에 (심지어는 다른 애플리케이션에서도) 이 데이터를 언제든 꺼내어 쓸 수 있다. 이 영역은 페이스트보드 서버에 의해 관리되는데, 코코아 앱에서는 NSPasteboard 객체를 통해 이 서버의 서비스에 접근할 수 있다. 페이스트 보드는 단순히 복사/붙여넣기 작업에만 관련되지 않고 드래그앤드롭이나 시스템 서비스 호출 시 데이터를 넘겨주는 데에 사용될 수 있는데, 이를 위해 페이스트 보드 서버는 몇 가지 종류의 페이스트 보드를 마련해두고 있으며, 일반적으로 많이 쓰이는 페이스트 보드에 대해서는 고유한 이름이 붙어 있다.

  • NSGeneralPboard – 일반적으로 복사/붙여넣기에 사용됨
  • NSRulerPboard – ruler 객체를 복사/붙여넣기할 때 쓰임
  • NSFontPboard – 폰트 객체를 복사/붙여넣기 할 때 쓰임
  • NSFindPboard – 찾기 패널에 사용됨
  • NSDragPboard – 드래그앤드롭에 사용됨

페이스트 보드에 쓰기

페이스트 보드에 객체를 복사하는 것은 다음의 절차를 통해서 이루어진다.

  1. 페이스트 보드 얻기
  2. 페이스트 보드의 내용 지우기
  3. 페이스트 보드에 내용 쓰기

다음의 간단한 예를 보도록 하자.

NSPasteboard *pboard = [NSPasteboard generalPasteboard];
NSString *stringToCopy = @"This will be copied.";
[pboard clearContents];
[stringToCopy writeToPasteboard];

음? 너무 간단한데? 사실 이 코드는 상당히 tricky 하다. 왜냐면 페이스트보드의 메소드가 아니라 문자열 객체의 메소드를 통해 문자열을 복사했기 때문이다. 보다 일반적인 형태는 다음과 같다고 할 수 있다.

NSPasteboard *pboard = [NSPasteboard generalPasteboard];
NSString *stringToCopy = @"This will be copied.";
[pboard clearContents];
NSArray *objectsToCopy = @[stringToCopy];
[pboard writeObjects:objectsToCopy];

페이스트 보드에 객체를 복사할 때는 writeObjects: 메소드를 사용한다. 이 원형은 다음과 같다.

-(BOOL)writeObjects:(NSArray *)items

여기서 주목할 점은 바로 배열에 담은 형태로 페이스트보드에 쓰게 된다는 것이다.

예를 들어 사파리에서 이미지를 포함한 본문 영역을 긁어서 복사한 후 Mail 앱의 작성 화면에 붙여넣으면 복사한 내용이 거의 그대로 들어가게 된다. (이미지를 포함해서) 만약 plain text 편집기에 이를 붙여넣으면 이미지를 제외하고 서식이나 링크가 빠진채로 텍스트 문자열만 붙여넣기가 될 것이다. 이는 웹페이지의 본문을 복사할 때는 이미지와 텍스트를 따로 따로 복사하고 이를 사용하는 앱에서는 자신이 쓸 수 있는 객체만 읽어와서 붙여넣는다는 의미이다.

또한 여기에는 한가지 비밀이 더 숨어 있는데, 페이스트보드에 복사하기 위해서는 대상이 되는 객체가 NSPasteboardWriting 프로토콜을 지원해야 한다. 코코아의 몇몇 클래스들은 기본적으로 이 프로토콜을 따르고 있는데, NSString도 그 중하나이다. 만약 커스텀 클래스를 만들고 이를 페이스트 보드에 복사하려면 NSPasteboardWriting 프로토콜이 요구하는 메소드들을 구현해야 한다. (다른 방법도 있는데 이는 뒤에서 다시 자세히 살펴보도록 한다.)

페이스트 보드로부터 읽기

페이스트 보드에 쓰기는 무척 간단했는데, 그로부터 객체를 읽어오는 것은 좀 더 복잡하다. 왜냐햐면 페이스트 보드에 저장돼 있는 컨텐츠가 읽어오는 측에서 다룰 수 있는 데이터인지 알 수 없기 때문에 항상 "이런 이런 타입의 객체를 내놓아라"라고 해야 하기 때문이다. (위에서 예로든 케이스를 생각해보자. 텍스트 편집기는 텍스트 객체만을 요청해서 받아올 수 있었다.)

다음의 예를 보자

NSPasteboard *pboard = [NSPasteboard generalPasteboard];
NSArray *classes = @[[NSString class]];
NSDictionary *options = [NSDictionary dictionary];
NSArray *copiedObjects = [pboard readObjectsForClasses:classes options:options];
NSString *copiedString = [copiedObjects objectAtIndex:0];

객체를 읽어오는 메소드는 readObjectsForClasses:options: 이다. 이 메소드의 원형을 한 번 살펴보자.

-(NSArray *)readObjectsForClasses:(NSArray *)classArray options:(NSDictionary *)options

에 메소드는 넘겨진 배열에 들어있는 타입에 맞는 객체들을 골라서 배열로 만들어서 되돌려 준다. classArray는 가져올 타입의 클래스들을 넣은 배열이며 options는 읽기 옵션을 사전으로 지정한 객체이다. 이는 NSURL 객체일 때만 사용하는데, 나중에 다시 언급하기로 한다.

여기서도 한 가지 비밀이 있는데, 이렇게 읽어오는 클래스들은 모두 NSPasteboardReading 프로토콜을 따라야 한다. 역시 코코아의 기본 클래스 중 몇몇은 이 프로토콜을 따르고 있어서 별다른 코드 없이 페이스트보드에 복사하고 꺼내오는게 가능하다. 읽기 쓰기와 관련된 이들 프로토콜에 대해서는 이어서 살펴보도록 하겠다.

임의의 데이터를 복사하기

코코아에서 제공하는 기본적인 클래스들은 NSPasteboardWriting , NSPasteboardReading 프로토콜을 따르고 있다. 만약 커스텀 클래스를 만들면서 이 클래스읙 객체가 페이스트 보드를 통해서 넘겨질 수 있게 하려면 이들 프로토콜을 따르도록 해야 한다. 혹은 다른 방법으로는 커스텀 객체를 NSPasteboardItem 객체로 감싸는 방법이 있을 수 있다. NSPasteboardItem은 페이스트보드에 들어가는 객체로 다른 객체를 감싸는 역할을 하여 임의의 객체를 넣을 수 있다. 물론 이 때도 감싸는 객체가 NSPasteboardItemDataProvider 프로토콜을 따라야 한다.

NSPasteboardWriting 프로토콜

이 프로토콜은 객체가 페이스트보드로 복사될 수 있도록 하는 규약을 정의한다. 코코아의 NSString, NSAttributedString, NSURL, NSSound, NSColor, NSImage 등이 이 프로토콜을 따르고 있다. 이 프로토콜은 세 개의 메소드로 구성되며 이중 두 가지가 필수로 구현해야 하는 메소드이다.

  • -writeableTypesForPasteboard: (필수)
  • -pasteboardPropertyListForType: (필수)
  • -writingOptionsForType:pasteboard: (옵션)

먼저 -writeableTypesForPasteboard:는 페이스트보드에 복사할 데이터의 타입들을 알려주는 메소드이다. 이 메소드는 배열을 하나 반환하는데, 이 배열은 문자열(NSString)들로 구성되며, 각각의 문자열은 데이터의 타입을 나타낸다. 페이스트보드 객체를 인자로 받으므로 페이스트보드의 종류에 따라서 다른 타입들을 사용하도록 할 수도 있다.

복사하는 데이터의 타입은 UTI (Unified Type Identifier)라고 하는데, 이는 그냥 문자열로 데이터 타입을 표현한 것일 뿐이다. 사용자 정의형인 경우에는 임의의 고유한 문자열이기만하면 되며, 코코아에서는 몇가지 타입이 다음과 같이 정의 되어 있다.

`NSString *const NSPasteboardTypeString;`
`NSString *const NSPasteboardTypePDF;`
`NSString *const NSPasteboardTypeTIFF;`
`NSString *const NSPasteboardTypePNG;`
`NSString *const NSPasteboardTypeRTF;`
`NSString *const NSPasteboardTypeRTFD;`
`NSString *const NSPasteboardTypeHTML;`
`NSString *const NSPasteboardTypeTabularText;`
`NSString *const NSPasteboardTypeFont;`
`NSString *const NSPasteboardTypeRuler;`
`NSString *const NSPasteboardTypeColor;`
`NSString *const NSPasteboardTypeSound;`
`NSString *const NSPasteboardTypeMultipleTextSelection;`
`NSString *const NSPasteboardTypeFindPanelSearchOptions;`

pasteboardPropertyListForType:은 앞서 페이스트보드에 알려준 타입에 대해서 각 타입에 어떤 데이터가 전달될 것인지를 실제로 구현하는 부분이다.

-(id)pasteboardPropertyListForType:(NSString*)type

type으로 전달되는 인자는 UTI값인 문자열이고, 각 타입에 따라서 객체나 데이터들을 직렬화하는 등의 방법으로 프로퍼티 리스트로 만들어 리턴한다. 통상 이 메소든느 NSData를 리턴하게 된다. 하지만 문자열을 바로 리턴할 수도 있고 그 외의 프로퍼티 리스트 타입을 반환해도 되는데, 페이스트보드는 그런 경우에 이 리턴 값을 알아서 변환한 다음 저장하게 된다.

-writingOptionsForType:pasteboard:는 각 타입에 대해 쓰기 옵션을 지정해주는데, 기본적으로는 0을 리턴하면 된다. 0을 리턴하는 것은 즉시 값을 페이스트 보드에 복사한다는 의미이고, 다른 대안으로는 NSPasteboardWritingPromised를 리턴하는데 이는 즉시 페이스트보드로 복사하는 것이 아니라 요청시에 데이터를 전달해주게 된다. -writeableTypesForPasteboard: 메소드에서 리턴하는 배열의 첫번째 타입은 통상 즉시 복사되고 나머지는 NSPasteboardWritingPromised로 처리되는데, 이 메소드에서 그 옵션을 바꿔줄 수 있다.

NSPasteboardReading 프로토콜

이 프로토콜은 페이스트보드로부터 데잍를 읽어와서 객체를 만들 수 있는 메소드들을 정의한다. 각 타입(UTI)에 대해 어떤 형태로 페이스트 보드가 데이터를 전달해 줄 것인지 (직렬화한 NSData나 문자열, 프로퍼티 리스트 등으로 줄 수 있다)를 페이스트 보드에게 알려준다. 페이스트 보드에서 객체를 꺼내오는 것은 결국 데이터를 가져와서 그 데이터로 객체를 생성하는 것이기 때문에 데이터로 객체를 초기화하는 메소드가 필요하다.

이 프로톨 역시 세 개의 메소드로 구성되어 있다.

  • +readableTypeForPasteboard:
  • +readingOptionsForType:pasteboard:
  • -initWithPasteboardPropertyList:ofType:

readableTypesForPasteboard:는 페이스트 보드로부터 읽어올 수 있는 UTI 타입들의 배열을 리턴한다. 기본적으로 이 메소드가 리턴하는 타입들은 initWithPasteboardPropertyList:ofType:으로 넘겨져 호출된다.

+(NSArray *)readableTypesForPasteboard:(NSPasteboard *)pasteboard

이들 타입에 대해서 어떤 형태로 데이터를 받아올 것인지에 대해서는 readingOptionsForType:pasteboard:를 통해서 정의한다.

+(NSPasteboardReadingOptions)readingOptionsForType:(NSString *)type pasteboard:(NSPasteboard *)pasteboard

이 반환값은 다음과 같이 상수로 정의되어 있다.

enum {
   NSPasteboardReadingAsData         = 0,
   NSPasteboardReadingAsString       = 1 << 0,
   NSPasteboardReadingAsPropertyList = 1 << 1,
   NSPasteboardReadingAsKeyedArchive = 1 << 2
};
typedef NSUInteger NSPasteboardReadingOptions;

끝으로 전달된 데이터를 통해 객체를 초기화하는 메소드이다.

-(id)initWithPasteboardPropertyList:(id)propertyList ofType:(NSString *)type

만약 한 종류의 UTI만을 사용하고, 읽기 옵션이 NSPasteboardReadingAsKeyedArchive로 리턴된다면, 이메소드는 initWithCoder:로 대체될 수 있다.

커스텀 데이터의 복사/붙여넣기 예제

그렇다면 실제 이 프로토콜에 따라 복사/붙여넣기가 가능한 임의의 클래스를 만들어보자. 클래스의 이름은 MyPerson 이고 이는 lastName, firstName, message의 세 개의 프로퍼티를 가지고 있다. 각각은 문자열이지만, 개별 문자열을 복사하는 것이 아니라 MyPerson 객체 전체를 복사할 수 있도록 만드는 것이 목적이다. 이 클래스는 다음 세가지 프로토콜을 따라야 한다.

  1. 객체의 각 프로퍼티는 직렬화하여 데이터로 변경할 것이다. 그리고 붙여넣기시에는 데이터를 통해 객체를 초기화해야 하므로 NSCoding 프로토콜을 따른다.
  2. 페이스트 보드로 복사되기 위해서 NSPasteboardWriting 프로토콜을 준수한다.
  3. 페이스트 보드에서 읽기 위해 NSPasteboardReading 프로토콜을 따른다. 단 객체의 초기화는 NSCoding이 있으니 initWithPasteboardPropertyList:ofType:은 별도로 구현하지 않는다.

MyPerson.h

#import <Foundation/Foundation.h>

@interface MyPerson : NSObject <NSCoding, NSPasteboardWriting, NSPasteboardWriting>
{
    NSString *_firstName, *_lastName, *_message;
}
@property (copy, nonatomic) NSString *firstName;
@property (copy, nonatomic) NSString *lastName;
@property (copy, nonatomic) NSString *message;
@end

MyPerson.m

#import "MyPerson.h"
@implementation MyPerson
@synthesize firstName = _firstName, lastName = _lastName, message = _message;
NSString * const kPersonUTI = @"com.sooop.person";
#pragma mark - NSCoding
/*
    NSCoder를 사용하여 객체 그래프를 인코딩, 디코딩함
*/
- (id) initWithCoder:(NSCoder *)aDecoder
{
    self = [super init];
    if(self) {
        _firstName = [aDecoder decodeObjectForKey:@"firstName"];
        _lastname = [aDecoder decodeObjectForKey:@"lastName"];
        _message = [aDecoder decodeObjectForKey:@"message"];
    }
    return self;
}
- (void) encodeWithCoder:(NSCoder *)aCoder 
{
    [aCoder encodeObject:self.firstName forKey:@"firstName"];
    [aCoder encodeObject:self.lastName forKey:@"lastName"];
    [aCoder encodeObject:self.message forKey:@"message"];
}
# pragma mark - Pasteboard Writing
/* 
    페이스트 보드에 복사할 수 있는 타입은 kPersonUTI 밖에 없음 
*/
- (NSArray *) writeableTypesForPasteboard:(NSPasteboard *)pasteboard 
{
    return @[kPersonUTI];
}
- (id) pasteboardPropertyListForType : (NSString *)type
{
    if([type isEqualToString:kPersonUTI]) {
        return [NSKeyedArchiver archivedDataWithRootObject:self];
    }
    return nil;
}
# pragma mark - Pasteboard Reading
+ (NSArray *)readableTypesForPasteboard:(NSPasteboard *)pasteboard 
{
    return @[kPersonUTI];
}

+(NSPasteboardReadingOption)readingOptionsForType:(NSString *)type
{
    return NSPasteboardReadingAsKeyedArchive;
}

@end

이 예제를 약간만 수정하면 객체를 복사하는 것 대신, 객체의 message 프로퍼티만을 따로 복사할 수 있다. 즉, 이 객체는 텍스트 편집기에서 붙여넣을 수 없지만, 객체의 message 값만이라도 복사되어 붙여넣을 수 있게 할 수 있다. 어차피 텍스트 편집기는 이 클래스를 알지 못하고 NSPasteboardTypeString을 요청할 것이니, 해당 타입의 쓰기가 가능하며, 타입에 따라 전달되는 데이터를 달리 보내주면 된다.

NSPasteboardItem을 사용하여 복사하기

NSPasteboardItem은 페이스트 보드 내부에 저장되는 객체 형식으로 자체적으로 NSPasteboardWriting, NSPasteboardReading 프로토콜을 준수하고 있다. 이 두 프로토콜을 명시적으로 지원하지 않는 클래스를 사용하면서 페이스트 보드에 복사를 하고 싶다면 이 객체를 이용하면 된다. 이 때 절차는 다음과 같다.

  1. 임의의 클래스를 작성시, NSPasteboardItemDataProvider 프로토콜을 지원하도록 한다. 필수적으로 구현할 메소드는 오직 하나 밖에 없다.
  2. 해당 클래스의 객체를 복사하려는 쪽에서는 NSPasteboardItem 객체를 하나 생성하고 (이 객체는 오토릴리즈 객체로 만드는 것이 좋다.) 이 아이템 객체에게 setDataProvider:forTypes: 메시지를 보내어 데이터 제공자 객체로 1.의 객체를 넘겨준 후, 이를 페이스트 보드에 복사한다.
  3. 붙여넣기를 하는 쪽에서는 NSPasteboardItem으로 객체를 받아온다. 이 객체에게 dataForType: 메소드를 보내면 원 객체가 제공한 데이터를 얻을 수 있다. 따라서 원 객체는 이 데이터를 생성하고, 다시 데이터로부터 객체 그래프를 재구성하는 인코딩/디코딩같은 메소드들을 구현해두어야 한다.

NSPasteboardItemDataProvider 프로토콜은 2개의 메소드가 있는데 이중 pasteboard:item:provideDataForType: 메소드를 필수적으로 구현해야 한다.

- (void) pasteboard:(NSPasteboard *)pasteboard item:(NSPasteboardItem *)item provideDataForType:(NSString *)type

이 메소드는 NSPasteboardItem 객체가 데이터 제공자에게 페이스트 보드에 복사할 데이터를 요청하면서 보내는 메시지이다. 이 메소드에서는 인자로 제공되는 item 객체 (메시지를 보낸 주체가 된다)에게 setData:forType: 메시지를 보내어 해당하는 타입에 맞는 데이터를 넘겨준다. 위 예제의 클래스가 이 프로토콜을 따른 다면 다음과 같은 형식으로 구현하면 된다.

- (void) pasteboard:(NSPasteboard *)pasteboard item:(NSPasteboardItem *)item provideDataForType:(NSString *)type
{
    if ([type isEqualToString:kPersonUTI]) {
        [item setData:[NSKeyedArchiver archivedDataWithRootObject:self] forType:type];
    }
}

이 때 넘겨주는 데이터는 개발자가 임의로 구성하면 되지만, 이 데이터로부터 객체를 재구성하는 방법 역시 마련해 놓아야 한다. 이 객체는 "쓰기"를 위한 임시객체이지만, 읽기시에도 사용할 수 있다. 만약 붙여넣는 측에서 이미지를 처리할 수 없더라도, NSPasteboardItem 타입으로 컨텐츠를 꺼내오면 페이스트보드 내에 있는 전체 데이터 셋을 얻을 수 있고, 각각의 아이템은 types 프로퍼티를 통해 가용한 타입이 무엇인지 알려준다.