(Swift) 복사/붙여넣기 가능한 커스텀 클래스

복사/붙여넣기를 지원하는 커스텀 클래스 만들기

복습

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

NSPasteboardWritingNSPasteboardReading은 클립보드에 쓰고, 읽기 위해 준수해야하는 프로토콜이지만, 많은 경우에 이 둘을 모두 지원해야 할 필요는 없다. 대신에 어떤 타입의 데이터를 클립보드에게 쓸 수 있는지, 그리고 특정한 타입의 데이터를 클립보드가 가지고 있을 때, 그 데이터로 해당 클래스의 인스턴스를 생성하게 할 것인지를 결정해주면 구현해야할 범위와 방법이 자연스럽게 결정된다.

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

UTI

클립보드에게 복사될 데이터의 타입을 알려줄 때는 클래스를 그대로 쓸 수 없다. 왜냐하면 해당 데이터를 받아들이는 앱이 제3자 앱인 경우라면, 해당 클래스에 대한 정보를 알 턱이 없기 때문이다. 따라서 “타입” 그 자체에 대한 구분은 특정한 코드 베이스에 의존하지 않는 독립적인 성격의 것이어야 한다.

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

만약 JPEG 이미지 데이터를 표현하고자한다면 다음과 같은 형식을 UTI로 쓸 수 있다. (문자열 그대로를 쓰면 된다.)

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

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

만약 커스텀 타입이라면 별도의 UTI를 만들어서 써야하며, 이는 com.company.type의 꼴로 만들면 된다. 보통 문서 기반 앱을 만들 때, 문서 타입을 정의할 때 쓰는 것과 동일하며, 반드시 앱 번들에 등록해야 할 필요는 없다. 만약 다른 제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 객체를 클립보드로 복사하면, 해당 객체의 데이터 뿐만 아니라 문자열 타입의 데이터도 함께 복사될 수 있도록 할 것이다. 우선 해당 객체가 스스로 인코딩되는 방법을 알 필요가 있으니, 맨 먼저 NSCoding을 따르도록 필요한 메소드들을 정의하자.

NSCoding

NSCoding은 NSArchiver에 의해서 키 기반 직렬화를 통해서 이진데이터로 변환하거나 반대로 이진데이터로부터 객체 그래프를 복원하는데 필요한 동작을 정의한 프로토콜이다. 실제 인코딩 과정 자체는 Foundation 프레임워크에 의해서 핸들링되며, 객체 자신은 자신을 구성하는데 필수적인 프로퍼티들을 키-값 쌍들과 맵핑해준다고 보면 된다.

class Person: NSObject, 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
        super.init()
    }

    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. 먼저 해당 객체의 클래스에게 어떤 타입의 데이터를 줄 수 있는지 물어본다.
  2. 1에서 클래스가 응답한 객체중에 첫번째 데이터를 요청한다.
  3. 만약 붙여넣는 시점에 다른 타입의 데이터를 요청받으면, 그 때 원본 객체에게 다른 유형의 데이터를 달라고 요청한다.

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

  • +writableTypes(for:) : 타입의 목록을 클립보드에 알려준다. 이는 해당 클래의 모든 객체에 공통적으로 적용되기 때문에 클래스 메소드이다.
  • pasteboardPropertyList(forType:) : 클립보드가 특정 타입에 대한 데이터를 요청할 때 해당 타입의 데이터를 만들어 제공한다.

제공 타입 정의

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

  • com.sooop.person-data
  • NSPastboardTypeString

그리고 타입별 데이터는 com.sooop.person-data 인 경우에는 해당 인스턴스를 인코딩한 Data 타입 값을 넘겨주면 되고, 문자열 타입인 경우에는 문자열 값을 그대로 주면 된다.  이 내용을 그대로 코드로 옮겨보자.

extension Person: NSPasteboradWriting {
    static let pasteboardType = "com.sooop.person-data"

    static func writableTypes(for pasteboard: NSPasteboard) -> [String]
    {
        return [Person.pasteboardType, NSPasteboardTypeString]
    }

    func pasteboardPropertyList(forType: String) -> Any? 
    {
        switch type {
        case Person.pasteboardType:
            return NSKeyedArchiver.archivedData(withRootObject: self)
        case NSPasteboardTypeString:
            return "\(firstName) \(lastName)(age: \(age))"
        default:
            return nil
        }
    }
}

NSPasteboardReading

클립보드로 자기 자신의 타입을 써넣을 수 있도록 했으므로, 반대로 붙여넣기에서 이를 이용할 수 있도록 NSPasteboardReading도 구현한다. 만약, 복사할 때 그냥 문자열만 복사하는 식으로 구현했다면, 클립보드에는 com.sooop.person-data 라는 타입 자체가 들어갈 일이 없으므로 이 프로토콜은 적용하지 않아도 상관없다.

구현전에 클립보드에서 붙여넣기에 대한 시나리오를 확인해보자.

  1. 붙여넣기 하는 앱 혹은 뷰에서는 특정한 타입의 클래스를 요구할 것이다. 그리고 이 클래스가 NSPasteboardReading을 따르고 있을 것이다.
  2. 붙여넣는 주체는 클립보드에게 특정한 클래스의 객체를 요청한다. (실제로 클래스값의 배열을 넘겨준다.)
  3. 클립보드는 각 클래스에게, 해당 클래스를 생성해줄 수 있는 UTI가 무엇인지 확인한다.
  4. 각 클래스가 대답해주는 UTI중에서 클립보드가 지원하는 UTI가 있다면, 원본으로부터 해당 타입의 데이터를 가져온다.
  5. 클립보드는 해당 클래스에게 클립보드데이터와 타입을 주고, 인스턴스를 생성할 것을 요청한다.
  6. 생성된 객체를 리턴한다.

따라서 이 과정에서 클립보드에게 내놓으라고 할 수 있는 클래스 자신을 생성할 수 있는 데이터 타입과, 타입에 따른 생성 방법을 알고 있어야 한다.  우리의 Person 클래스는 클립보드에 기록될 때에는 스스로를 인코딩한 데이터와 문자열을 제공하지만, 문자열로부터 스스로를 만들 필요는 없다. 오로지 com.sooop.person-data 타입인 경우에만 스스로를 만들게 된다.

NSPasteboardReading은 세 가지 메소드를 정의하고 있다.

  • +readableTypes(for:) : 클립보드로부터 읽을 수 있는 타입의 종류
  • init?(pasteboardPropertyList: ofType:) : 클립보드 데이터로부터 읽어들여서 초기화홤
  • +readingOptions(forType: pasteboard:) : 특정한 UTI에 대한 클립보드 데이터를 읽는 방식

여기서 흥미로운 부분은 readingOptions(forType: pasteboard)인데, 이는 기본적으로 .asData를 리턴하게끔 기본 구현이 제공된다. 우리는 Person을 키 기반 아카이브를 썼으므로 .asKeyedArchived 를 리턴하도록 수정한다. 그러면 클립보드는 init?(pasteboardPropertyList:ofType:) 대신에 init?(coder:)를 호출할 것이다. 그렇다고 하더라도 이니셜라이저 자체는  required 이기 때문에 생략할 수는 없고 적당히 nil을 리턴하거나 하는식으로 대충 때워주면 된다. 아니면 정직하게 아래와 같이 구현하거나…

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

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

    required init?(pasteboardPropertyList propetyList: Any, ofType type: String) {
        guard type == Person.pasteboardType,
        let data = propertyList as? Data
            else { return nil }
        let obj = NSUnarchiver.unarchivedObject(with: data) as! Person
        self.firstName = obj.firstName
        self.lastName = obj.lastName
        self.age = obj.age
        super.init()
    }
}

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

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을 구현해야하는 것은 아니다.  예를들어 클래스를 수정할 수 없는 제3자 클래스를 쓰는 경우에, 해당 클래스의 정보로부터 복사용 데이터를 만들 수 있음을 알고 있다면, 해당 클래스를 확장 혹은 서브클래싱할 필요 없이 클립보드에 쓰는 방법이 있다. 그것은 NSPasteboardWriting, NSPasteboardReading 프로토콜을 이미 준수하는 래퍼에 데이터를 실어주는 것이다.

NSPasteboardItem

NSPastebordItem 은 NSPasteboardWriting을 따르지 않는 클래스의 객체가 가지고 있는 ‘복사할 수 있을 법한 값’을 클립보드로 복사하는데 유용하게 쓰일 수 있다.  클립보드에 데이터를 쓰는 것은 타입이름과 그 타입에 관한 클립보드용 데이터 (문자열, 프로퍼티 리스트, 바이너리 데이터 블롭 중 하나)를 같이 제공하면 되는 것이다. NSPasteboardItem은 클립보드에 필요한 타입명과 데이터 쌍들을 받아서 클립보드에 복사해주는 일을 대행해주는 클래스이다.

위에서 작성한 Person 클래스가 외부 프레임워크에 정의되었으며, 우리는 단지 해당 클래스가 NSCoding을 따르고 있다는 사실만 알고 있다고 가정하자. 이 경우에도 우리는 위와 똑같이 복사하는 효과를 만들 수 있다.  NSPasteboardItem은 다음과 같은 메소드들을 가지고 있다. 따라서 데이터의 종류에 따라 그에 맞는 메소드를 이용하면 된다.

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

이를 이용해서 Person 인스턴스를 클립보드에 복사하는 예는 다음과 같다.

let item = NSPasteboardItem()
item.setData(NSKeyedArchiver.archivedData(withRootObject:person), forType:"com.sooop.person-data")
item.setString("\(person.firstName) \(person.lastName) is \(person.age) years old.", forType: NSPasteboardTypeString)

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

사실, 클립보드에 들어간 데이터들은 NSPasteboradItem의 형태로 쉽게 얻을 수 있다. 클립보드는 pasteboardItems라는 프로퍼티를 가지고 있어서 NSPasteboardItem의 배열을 얻을 수 있다.  클립보드로부터 특정 클래스의 객체를 바로 얻어내는 대신에,  string(forType:), data(forType:) 등을 이용해서 복사시에 추출한 데이터를 이용해서 해당 객체를 직접 재구성하면 된다. (하지만 보통, 이런 경우에는 문자열등의 데이터를 얻어서 바로 사용하는 용도로 많이 쓸 것이다.) 따라서 결론적으로 페이스트 보드 아이템을 사용하는 방식은 굳이 NSPasteboardReading을 구현할 필요가 없는 클래스에 대해서 NSPasteboardWriting 프로토콜도 구현하지 않고 간단하게 쓰기 위한 용도로 이해하면 된다.

let pb = NSPasteboard.general
let items = pb.pasteboardItems!
let wantedTypes = ["com.sooop.person-data", NSPasteboardTypeString]
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)
}

NSPasteboardItemDataProvider

NSPasteboardItem을 이용하면 복사하기 전에 지원가능한 모든 타입의 데이터를 모두 주입하여 한 번에 실어보내게 된다. 따라서 복사해야할 데이터의 종류와 그 양이 많거나 데이터를 준비하는데 비용이 크게 든다면, 가능한 느긋하게 데이터를 만들어서 보내는 것이 좋을 것이다. 그런 경우에는 NSPasteboardItemDataProvider 객체를 이용하면 된다. 이 역시 프로토콜이면서 느긋한 복사를 가능하게 만드는 기술이다.  페이스트보드 아이템을 생성하고 setDataProvider(_: forTypes:)를 이용해서 데이터 제공자를 지정해주면 복사 준비가 끝난다. 이 때 중요한 것은 데이터 제공자 객체가 반드시 복사될 객체의 클래스여야 할 필요가 없다는 것이다. 따라서 중계 클래스가 이 프로토콜을 따르도록 디자인하여, 제3자 클래스로부터 얻을 수 있는 데이터를 준비하고 제공하도록 하면 된다.

이 프로토콜은 두 개의 메소드를 정의하고 있다.

  • func pasteboard(_: NSPasteboard, item: NSPastebordItem, provideDataType: String) : 수신(붙여넣는) 측에서 NSPasteboardItem으로부터 특정한 타입을 꺼내려 요청할 때 호출받는 메소드이다. 따라서 문자열로 전해지는 타입에 따라서 데이터를 인코딩하여 전달된 item에 밀어넣어주는 식으로 구현한다. 사실상 NSPasteboardWriting과 다를바 없는 구조이다.
  • func pasteboardFinishedWithDataProvider(_: NSPasteboard) ; 이 메소드는 약속된 모든 타입의 데이터를 제공하였거나, 혹은 클립보드의 내용이 비워지면서 더 이상 데이터를 제공해줄 의무가 없을 때 호출받는다. 따라서 전송해주기 위해 준비하던 데이터를 정리하거나 하는 등의 작업을 여기서 할 수 있다.

참고자료


  1. 원래는 “페이스트보드”(pasteboard)이지만, 왠지 입에 붙는 거 같이 않아서 이 글에서는 그냥 클립보드로 통칭하도록 한다. 
  2. 사실 그대로 읽어들였을 때, [Any]?로 받기 때문에 Swift에서는 옵셔널을 처리해버리면 되므로 꼭 필요한 과정은 아니다. 
  3. class func writableTypes(for: NSPasteboard) -> [String]으로 특정한 클래스가 자신이 클립보드에 쓸 수 있는 타입을 UTI 문자열의 배열로 리턴하게끔한다. NSPasteboardWriting에 정의되어 있다. 
  4. func pasteboardPropertyList(forType:pasteboard:) -> Any?는 특정한 타입 UTI 문자열에 대해서 해당 클래스가 생성해낼 수 있는 프로퍼티리스트 데이터를 만들어낸다. NSPasteboardWriting에 정의되어 있다. 
  5. class func readableTypes(for: NSPastebord) -> [String]으로 해당 클래스가 어떤 UTI 타입이 지정한 포맷의 데이터로부터 생성될 수 있는지를 시스템에 알려준다. NSPasteboardReading에 정의되어 있다. 
  6. 페이스트보드로부터 받아온 데이터와 UTI 타입 쌍으로부터 객체를 복원한다. NSPasteboardReading에 정의되어 있다. 
  7. 만약 readingOptions(forType:pasteboard:)에서 .asKeyedArchive를 리턴했다면 init(coder:)가 호출될 것이다. 
  8. 적용가능한 모든 표현형에 대응하는 데이터 전체를 복사 시점에 생성하는 것은 메모리나 CPU를 낭비하는 일이다. 따라서 가장 범용적이거나, 비싼 데이터를 맨 먼저 만들고 그외 다른 포맷들은 붙여넣는 측에서 요청하면 그 때 생성해주는 구조로 되어 있다.