복사-붙여넣기가 지원되는 클래스 작성하기 – Cocoa, Swift

복습

이전 시간에 macOS에서 클립보드를 통해서 복사와 붙여넣기를 통해서 뷰 혹은 앱간의 데이터를 교환하는 과정에 대해서 살짝 살펴보았다. 어떤 클래스의 객체가 클립보드로 복사되려면, 해당 클래스는 클립보드에게 어떤 타입의 데이터들을 넣어줄 수 있음을 알리고, 각 타입에 대한 데이터를 생성하여 클립보드에게 전달해주는 동작을 수행해야 하며, 이 동작은 NSPasteboardWriting에 정의되어 있다. 그리고 붙여넣는 시점에 특정한 클래스의 객체를 클립보드에 요청하면, 클립보드는 해당 클래스의 객체를 생성할 수 있는 타입들을 조사한 후, 조달 가능한 타입에 대응하는 데이터를 이용해서 해당 클래스 객체를 생성해 붙여넣는 쪽으로 전달해주게 된다. 즉 클립보드로부터 생성할 수 있는 클래스는, 객체를 생성할 수 있는 데이터의 타입과, 타입별 데이터를 전달받아 인스턴스를 만드는 방법을 알고 있어야 한다. 이 내용은 NSPasteboardReading에 정의된다.

복사/붙여넣기의 동작은 어떤 클래스로부터 이진데이터를 만들어서 클립보드에 올려놓고, 또 클립보드 데이터로부터 어떤 클래스를 만드는 일과 관련된다. 하지만 이것은 직렬화/역직렬화와는 다르다. 왜냐하면 복사/붙여넣기 동작은 앱과 앱 사이에서도 발생할 수 있는 일이기 때문이다. 따라서 데이터 공급과 소비의 관점에서 이해해야 하며, 이 때 오가는 데이터는 1가지 이상의 타입일 수 있다.

보통의 복사/붙여넣기 동작과 관련해서는 특수한 타입의 데이터보다는 일반적인 표준 클래스의 데이터를 더 많이 쓰게되므로 실질적으로 커스텀 클래스를 클립보드에 복사해넣는 일은 그리 많지 않다. 하지만 궁금해하지 말라는 법은 없으니, 복사/붙여넣기를 지원하는 커스텀 클래스를 어떻게 작성할 수 있는지에 대해서 알아보도록 하자. 

UTI

클립보드는 단일 앱 내에서만 동작하는게 아니라 시스템 전체에서 사용되는 서비스이므로, 클립보드에게 복사될 데이터의 타입을 알려줄 때는 클래스를 사용할 수 없다. 따라서 “타입” 그 자체에 대한 구분은 특정한 코드 베이스에 의존하지 않는 독립적이며, 범용적인 성격의 것이어야 한다.

macOS 시스템에서는 여러 표준/비표준 데이터 타입을 구분하기 위한 구분자로 UTI라는 것을 사용한다. 이는 “범용 타입 식별자(Univeral Type Identifier)”의 줄임말로 특정한 데이터 타입을 도메인 형태의 문자열로 구분짓는 것이다. 이 UTI 문자열은 시스템 내부 적으로는 계층구조를 가지기도 하고, 잘 알려진 파일의 경우 확장자,  표준 MIME Type 및 OS내에서 데이터 타입을 식별하는 OSTYPE 식별자와도 호환된다. (정확히는 널리 알려진 타입 식별 방법들은 해당 타입의 UTI로 변환하는 내부적인 방법을 가지고 있다)

만약 JPEG 이미지 데이터 타입을 말하고자 할 때, 다음과 같은 형식을 UTI로 쓸 수 있다. (문자열 그대로를 쓰면 된다.) 첫번째 값이 UTI의 정의이며, 그 외의 것은 확장자, 파일타입 및 MIME 타입 이름이다.

  • public.jpeg
  • .jpg
  • .jpeg
  • image/jpeg
  • JPEG

또한 표준 데이터 타입이라 할 수 있는 타입은 NSPasteboardType*으로 시작하는 형태의 상수로 프레임워크에 정의되어 있다. 대표적으로 NSPasteboardTypeString, NSPasteboardTypePDF, NSPasteboardTypePNG 등이 있다.

만약 커스텀 타입이라면 별도의 UTI를 만들어서 써야하며, 이는 com.company.type의 꼴로 만들면 된다. 보통 문서 기반 앱을 만들 때, 문서 타입을 정의할 때 쓰는 것과 동일하며, 복사/붙여넣기를 위해서만 사용한다면 반드시 앱 번들에 등록해야 할 필요는 없다. (앱번들에 등록하는 것은 이 앱이 해당 UTI 가 가리키는 데이터나 파일을 다룰 수 있음을 시스템에 알려주는 것이다.)

만약 다른 제3자 앱에서 붙여넣어 사용할 수 있게 할 목적이라면, exported UTI로 등록해주어야 할 것인데, 이와 관련된 내용은 본 포스팅의 범위를 넘어서는 것으로 여기서는 생략하며, 관심있는 사람은 해당 가이드문서를 참고하기 바란다.

커스텀 클래스 만들기

간단한 클래스를 하나 만들고, 해당 클래스의 인스턴스가 클립보드로 복사되거나, 클립보드로부터 생성되어 나올 수 있도록 만들어보자. 간단하게 사람의 성과 이름 그리고 나이를 갖는 클래스를 만들 것이다. 이름은 Person으로 하며, 기본적인 프로퍼티와 이니셜라이저를  다음과 같이 정의했다.

class Person: NSObject {
    var firstName: String
    var lastName: String
    var age: Int

    init(firstName: String, lastName: String, age: Int) {
        self.firstName = firstName
        self.lastName = lastName
        self.age = age
        super.init()
    }
}

어떤 타입을 복사할 때 흥미로운 점은, 그 타입의 객체가 그대로 복사되는 경우가 드물다는 것이다. 즉 우리는 이 클래스가 복사를 지원하게 할 때, “무엇을 복사해줄 것인지”를 먼저 결정해야 한다.

이 클래스 스스로를 데이터로 직렬화해서 복사하는 방법도 있을 것이지만, 이는 앞에서도 이야기했듯이 그 데이터를 다시 복원할 상황은 그리 일반적인 경우는 아니다. (자신의 앱 내에서 필요한 경우에 그렇게 할 수 있다는 것이지.) Person 클래스의 경우, 그냥 풀 네임을 문자열로 복사해줄 수도 있다. 말하자면 우리가 만드는 커스텀 앱에서 Person 객체를 복사한 상태에서, 텍스트 편집기에서 붙여넣기를 하면 텍스트 편집기는 그저 텍스트 데이터만을 다룰 것이므로, 해당 객체가 가리키는 사람의 풀네임이 붙여넣어지는 식이다.

어쨌든 우리는 ‘붙여넣기’가 가능한 부분까지 구현하기로 했으니, 이 부분부터 살펴보자. ‘붙여넣는다’는 말은 기본적으로는 클립보드로부터 해당 객체를 만들어낼 수 있다는 뜻이다. 이렇게 클립보드로부터 만들어질 수 있는 타입들은 모두 NSPasteboardReading 이라는 프로토콜로 구별된다. 즉 PersonNSPasteboardReading을 적용해야 한다.

붙여넣을 준비하기 – NSCoding/NSSecureCoding

클립보드에 어떤 객체를 복사한다고 할 때 생각할 수 있는 가장 기본적인 모양은 직렬화된 이진데이터이고, 반대로 붙여넣을 때에도 이진데이터의 형태로부터 객체를 복원해야 한다. 이런 동작을 지원하는 객체는 NSCoding 프로토콜을 따르며, 우리의 Person도 이 프로토콜을 지원해야 한다.

Xcode 10에서부터 NSCoding 의 보안 관련 이슈로 이 프로토콜은 사용하면 안된다. (동작은 하는데 앱 내부에서 예외가 발생할 수 있다.) 대신에 NSSecureCoding이라는 새로운 프로토콜을 적용해야 한다.

class Person: NSObject, NSSecureCoding {
    ...
    // NSSecureCoding 적용을 위한 stub. 그외 구현은 NSCoding과 동일
    static var supportSecureCoding: Bool = true

    // Conforming NSCoding
    required init?(coder aDecoder: NSCoder) {
        guard let firstName = aDecoder.decodeObject(forKey: "firstName") as? String,
        let lastName = aDecoder.decodeObject(forKey: "lastname") as? String,
        let age = aDecoder.decodeObject(forKey: "age") as? Int
            else { return nil }
        self.firstName = firstName
        self.lastName = lastName
        self.age = age
    }

    func encode(with aCoder: NSCoder) {
        aCoder.encode(firstName, forKey: "firstName")
        aCoder.encode(lastName, forKey: "lastName")
        aCoder.encode(NSNumber(value: age), forKey: "age")
    }
}

참고로, NSCoding은 init?(coder:)라는 생성자(initializer)를 정의하고 있는데, 이는 프로토콜 내에서 정의된 지정 이니셜라이저(designated initializer)이기 때문에, 클래스에 적용될 때에는 required 접두어를 붙여서 정의해야 한다. 구현자체는 성, 이름, 나이에 대한 키에 대해서 이를 각각 인코딩하고, 동일한 키로 디코딩하여 스스로를 초기화하면 된다.

NSPasteboardWriting – 복사할 수 있는 타입

이전 글에서 잠깐 살펴본 바와 같이, 특정 객체를 클립보드에 쓰려면 그 객체의 클래스는 NSPasteboardWriting이라는 프로토콜을 따라야 한다고 했다. 클립보드에 특정한 객체를 쓰려고 하면 클립보드는 다음과 같은 동작을 한다.

  1. 먼저 복사해넣으려는 객체에게 “너는 어떤 타입의 데이터를 줄 수 있는지”라고 물어본다. Person 객체는 일반텍스트와 자기자신의 UTI 타입을 보고한다.
  2. 1에서 클래스가 응답한 객체중에 첫번째 데이터를 요청한다. (첫번째 타입의 데이터는 즉시 복사되고, 나머지 데이터는 필요시 요청된다)
  3. 만약 붙여넣는 시점에 클립보드가 Person 타입 데이터를 요청받으면, 그 때 클립보드 서비스는 원본 객체에게 해당 타입의 데이터를 달라고 요청한다.

따라서 복사의 대상이 되는 객체는 자신이 제공가능한 타입을 알려주고, 각 타입에 대한 클립보드용 데이터를 제공해야 한다. 그래서 NSPasteboardWriting은 두 개의 메소드를 정의하고 있다.

  • writableTypes(for:) : 타입의 목록을 클립보드에 알려준다. 이는 복사될 객체에게 직접 물어보는 것이기 때문에 인스턴스 메소드이다.
  • pasteboardPropertyList(forType:) : 클립보드가 특정 타입에 대한 데이터를 요청할 때 해당 타입의 데이터를 만들어 제공한다.

제공 타입 정의

먼저 Person 클래스는 클립보드로 복사되면 자기 자신의 타입과 사람의 풀네임을 복사할 수 있는 것으로 정하자. 그러면 임의의 커스텀 타입 하나와 문자열 타입을 클립보드에 제공한다고 알려주면 된다.

이전 API는 Objective-C 로 제작된 API를 Swift로 변환해서 쓰기 때문에 UTI 문자열을 그대로 썼지만, Swift API가 독립적으로 만들어진 후에는 타입 정보는 NSPasteboard.PasteboardType 이라는 별도 타입으로 정해진다. (따라서 문자열을 넘겨줄 수 없다.) 커스텀 타입은 UTI 값을 사용해서 해당 타입을 위한 값을 만들거나 해당 타입을 확장해서 사용하며, 문자열을 제공하려는 경우에는 .string 값을 넘겨준다.

  • NSPasteboard.PasteboardType("com.sooop.person")
  • NSPastboard.PastboardType.string (타입이 정해져있으므로 .string 만 쓴다.)
extension Person: NSPasteboradWriting {
    static let pasteboardType = NSPasteboard.PasteboardType("com.sooop.person")

    /// Person이 클립보드에 써넣을 수 있는 데이터 종류
    static func writableTypes(for pasteboard: NSPasteboard) -> [NSPasteboard.PasteboardType]
    {
        // 순서가 중요하다. 첫번째 타입의 데이터만 먼저 클립보드에 기록되고, 
        // 나머지 타입들은 붙여넣을 때 요청받아서 전달된다.
        return [.string, pasteboardType]
    }

타입별 데이터 제공하기

제공 가능한 타입에 대해서는 정리했고, 이제 각 타입을 요청받을 때, 각각의 타입에 맞는 데이터를 전달해주는 일만 남았다. 이 일은 pasteboardPropertyList(forType:) 메소드를 작성하여 구현하게 된다.

복사를 하는 시점에 앞서 작성한 writableTypes(for:) 에서 리턴한 배열 중 첫번째 타입은 클립보드가 즉시 요청할 것이다. 그리고 나머지 타입들에 대해서는 실제로 해당 타입을 붙여넣고자하는 액션이 어디선가 발생했을 때 이 메소드가 호출된다.

extension Person: NSPasteboradWriting {
    ...
    func pasteboardPropertyList(forType type: NSPasteboard.PasteboardType) -> Any? 
    {
        switch type {
        case .string:
            return "\(firstName) \(lastName)(age: \(age))"
        case pasteboardType: 
            // 전체타입을 요구받는경우
            // Data로 변환해서 리턴해준다.
            return NSKeyedArchiver.archivedData(withRootObject: self)
        default:
            return nil
        }
    }
}

NSPasteboardReading

붙여넣기가 되려면, 반대로 클립보드로부터 어떤 데이터를 받아서 원래의 객체를 복원할 수 있어야 한다. 어떤 데이터를 받게 될 것인지는 이미 앞에서 정의했으니 (keyed archived 데이터) 답은 간단할 것 같다.

붙여넣을 수 있는 타입들은 NSPasteboardReading을 따르므로 이 프로토콜을 구현하면 되겠다. 먼저 이 타입들을 붙여넣는 상황의 시나리오를 다시 한 번 상기해보자.

  1. 붙여넣기를 수행하려는 모종의 컨텍스트에서 클립보드에게 Person 클래스의 객체를 요청한다. (혹은 해당 클래스의 값을 줄 수 있는지 확인한다.)
  2. 클립보드는 Person 클래스가 어떤 타입의 데이터로부터 생성가능한지 알 턱이 없다. 따라서 Person 클래스 자체에게 “어떤 데이터 줄까”하고 물어보게 된다.
  3. Person은 아마도 "com.sooop.person" 에 해당하는 데이터를 달라고 할 것이다.
  4. 직전에 복사된 객체가 Person 클래스라면 클립보드는 해당 타입의 데이터를 줄 수 있다는 사실을 알 수 있다.(writableTypes(for:)).
  5. 그리고 그 데이터를 Person 클래스에게 제공한다. 클래스는 그 데이터를 가지고 객체 인스턴스를 생성하는 법을 알아야 한다.
  6. 이제 붙여넣으려는 쪽에서는 사본을 갖게 되었다.

이 과정을 살펴보면 붙여넣어지는 클래스는 두 가지를 알고 있어야 하는데, 우선 자신이 만들어질 수 있는 데이터의 타입과, 그 타입의 데이터로 스스로를 만드는 방법이다. 이 방법들에 관련된 동작이 NSPasteboardReading 프로토콜에 정의되어 있으며, 다음의 세 가지 종류이다.

  • +readableTypes(for:) : 클립보드로부터 읽을 수 있는 타입의 종류. 객체가 없는 상태에서 알아내야 하므로 클래스 메소드로 정의된다.
  • init?(pasteboardPropertyList: ofType:) : 각 타입의 데이터로부터 객체 인스턴스를 복원함
  • +readingOptions(forType: pasteboard:) : 특정한 UTI에 대한 클립보드 데이터 처리 방식을 알려준다.

여기서 흥미로운 부분은 readingOptions(forType: pasteboard)인데, 이는 기본적으로 .asData를 리턴하게끔 기본 구현이 제공된다. 우리는 Person을 키 기반 아카이빙으로 직렬화하였다. 그 결과가 ‘Data’인 것은 사실이나, .asKeyedArchived 를 리턴하도록 수정해주면 조금 특별하게 동작한다.

키 기반 아카이빙 데이터는 NSCoding에 의해 바로 생성될 수 있지 않던가. 따라서 init?(pasteboardPropertyList: ofType:)을 굳이 구현할 필요는 없다. 그렇다고 하더라도 이 이니셜라이저 자체는  필수적이라고 명시되기 때문에 작성 자체를 아예 생략할 수는 없고 적당히 nil을 리턴하거나 하는식으로 대충 때워주면 된다.

보통은 다음과 같이 간단히 구현한다.

class Person: NSObject, NSCoding, NSPasteboardWriting, NSPasteboardReading {
    ...

    class func readableTypes(for pasteboard: NSPasteboard) -> [String] {
        return [Person.pasteboardType]
    }

    class func readingOptions(forType type: String) -> NSPasteboardReadingOptions {
        return .asKeyedArchive
    }

    // 이미 `init?(coder:)`가 있으니 구현할 필요 없다.
    required init?(pasteboardPropertyList propertyList: Any, ofType type: String) {
        return nil
    }
}

여기까지 구현하면 Person 클래스는 앱 내에서 복사하거나 붙여넣을 수 있는 데이터가 된다.

복사/붙여넣기에 있어서 원본과 사본측은 타입 측면에서 비대칭적이다. 따라서 사실 모든 커스텀 클래스가 클립보드에 복사되기 위해서 NSPasteboardWriting을 구현해야하는 것은 아니다.  클립보드에 넘겨줄 수 있는 복사용 데이터를 얻는 방법만 알고 있다면 된다.

NSPasteboardItem

클립보드에 복사하거나, 붙여넣기 하는 동작의 핵심은 “타입별 데이터”가 클립보드 서비스를 통해서 오간다는 점이다. 결국 타입(UTI)정보와 그 타입의 데이터가 키-값 쌍의 형식으로 모여있는 추상화된 데이터 구조가 있고, 복사/붙여넣기를 지원하는 클래스들은 이러한 구조를 지원하도록 설계된다.

이 관점을 이용하면 특정 클래스가 굳이 NSPasteboardWriting/Reading을 지원하지 않더라도 클립보드를 쓸 수 있게 해준다. 이것을 가능하게 해주는 클래스가 바로 NSPastebordItem 이다.

이것은 일종의 사전과 비슷하게 동작하는데, 앞에서 말한 바와 같이 타입-데이터의 쌍을 가지고 있는 것처럼 동작한다. 따라서 빈 객체를 하나 만들어서 데이터의 타입과 종류에 따라 구미에 맞는 메소드를 호출하여 여기에 데이터를 써넣고, 다시 이 아이템 객체를 클립보드에 집어넣으면 된다. 다음의 세 메소드가 데이터를 쓰는데 사용된다.

  • setData(_: forType:)
  • setString(_: forType:)
  • setPropertyList(_: forType:)

이를 이용해서 Person 객체는 NSCoding만 적용된 상태에서도 클립보드에 복사할 수 있다.

let item = NSPasteboardItem()

item.setData(
   NSKeyedArchiver.archivedData(withRootObject:person), 
   forType:NSPasteboard.PasteboardType("com.sooop.person"))

item.setString(
   "\(person.firstName) \(person.lastName", forType: .string)

let pb = NSPasteboard.general
pb.clearContents()
pb.writeObjects([item])

NSPasteboard는 pasteboardItems라는 프로퍼티를 가지고 있어서 내부에 쓰여진 NSPasteboardItem의 배열을 바로 얻을 수 있다.  

클립보드로부터 특정 클래스의 객체를 바로 얻어내는 대신에,  string(forType:), data(forType:) 등을 이용해서 복사시에 추출한 데이터를 이용해서 해당 객체를 직접 재구성하면 된다. (하지만 보통, 이런 경우에는 문자열등의 데이터를 얻어서 바로 사용하는 용도로 많이 쓸 것이다.)

따라서 결론적으로 페이스트 보드 아이템을 사용하는 방식은 굳이 NSPasteboardReading을 구현할 필요가 없는 클래스에 대해서 NSPasteboardWriting 프로토콜도 구현하지 않고 간단하게 쓰기 위한 용도로 이해하면 된다.

다음 예는 앞에서 복사한 객체를 pasteboardItems를 이용해서 붙여넣는 과정을 보여준다. 원본 객체를 복사했는지, 래퍼를 통해 복사했는지에 관계없이 동일하게 동작하는 코드이다.

let pb = NSPasteboard.general
let items = pb.pasteboardItems!
let wantedTypes = [Person.pasteboardType, .string]
let pastedItems = items.filter{ $0.availableType(from: wantedTypes) != nil }
for item in pastedItems {
    if let personData = item.data(forType:"com.sooop.person-data") {
        let newPerson = NSKeyedUnarchiver.unarchiveObject(with: personData) as! Person
        ...
    } else if let string = item.string(forType: NSPasteboardTypeString) {
    NSLog(string)
}

좀 더 느긋한 복사를 위한 도우미

NSPasteboardItem을 이용하면 복사하기 전에 지원가능한 모든 타입의 데이터를 모두 주입하여 한 번에 실어보내게 된다. 따라서 복사해야할 데이터의 종류와 그 양이 많거나 데이터를 준비하는데 비용이 크게 든다면, 복사 자체가 무거운 작업이 될 것이다. 따라서 가능하면 가볍게, 최대한 느긋하게 동작할 수 있는 방식이 더 선호될 것이다.

그런 경우에 사용할 수 있는 방법이 NSPasteboardItemDataProvider 프로토콜이다. 이는 일종의 델리게이트인데, NSPasteboardItem이 직접 클립보드로 들어간다면, 실제로 특정한 타입에 대한 데이터를 보내주는 것을 다른 객체에게 위임하는 것이다.

페이스트보드 아이템을 생성하고 setDataProvider(_: forTypes:)를 이용해서 데이터 제공자를 지정해주면 복사 준비가 끝난다. 이 프로토콜은 두 개의 메소드를 정의하고 있다.

  • func pasteboard(_: NSPasteboard, item: NSPastebordItem, provideDataType: NSPasteboard.PasteboardType) : 특정한 타입을 꺼내려 요청할 때 호출 받으며, 인자로 넘겨진 item에게 그 타입의 데이터를 전달해준다.
  • func pasteboardFinishedWithDataProvider(_: NSPasteboard) ; 이 메소드는 약속된 모든 타입의 데이터를 제공하였거나, 혹은 클립보드의 내용이 비워지면서 더 이상 데이터를 제공해줄 의무가 없을 때 호출받는다. 따라서 전송해주기 위해 준비하던 데이터를 정리하거나 하는 등의 작업을 여기서 할 수 있다.

정리

어떤 클래스데이터와 복사/붙여넣기와의 관계에 대해서 다음과 같이 정리할 수 있겠다.

  1. 복사/붙여넣기 되는 것은 “클래스”나 “객체”가 아니라 데이터이다.
  2. 어떤 클래스가 꼭 자신을 직렬화한 데이터를 복사하지 않아도 된다.
  3. 하나의 클래스는 2가지 이상의 타입의 데이터를 제공할 수 있다.
  4. 어떤 타입의 데이터를 붙여넣기 할 것인가는 붙여넣는 앱에서 결정하고 요청한다. 이 앱은 복사가 발생한 앱이 아닐 수 있다.
  5. 따라서 어떤 클래스는 자신이 제공할 수 있는 데이터의 타입들과, 각 타입에 대한 데이터를 제공하면 된다.
  6. 붙여넣기는 복사와 짝을 이루는 오퍼레이션이 아니다.

참고자료