(Swift | Tutorial) copy and paste 구현해보기

복사/붙여넣기를 지원하기

페이스트보드, 흔히들 클립보드라고 말하는 이 물건은 요즘 컴퓨터를 쓰는 사람들에게는 어찌보면 공기중의 산소와 같은 것이라 하겠다. 거의 모든 컴퓨터 및 스마트기기 사용자들이 의식하지도 않은채 많이 쓰는 기능일 것이다. 복사와 붙여넣기는 사용자 측면에서는 숨쉬는 것처럼 자연스러운 기능이지만,시스템 측면에서는 사실 간단한 동작은 아니다. 복사/붙여넣기는 단순히 앱 내에서 데이터를 이동하는 것 뿐만 아니라 서로 다른 앱 간에 대해서도 이를 통해서 데이터를 전달할 수 있게 된다.

또 우리가 만약 A라는 데이터를 클립보드에 복사했다 하더라도, 이 복사된 데이터에 사용처에 따라서는 B 혹은 C나 D의 데이터로서 사용하게 되거나, 상황에 따라서는 복사된 데이터를 붙여넣을 수 없는 경우도 존재한다. 예를 들어 Preview 앱에서 그림의 일부분을 복사했을 때, 이 데이터는 Sublime Text와 같은 편집기에서는 붙여넣을 수 없지만, Pages로는 붙여넣을 수 있다. 또 반대로 Finder에서 이미지 파일을 복사했을 때, 텍스트 편집기로 붙여넣으면 파일의 이름이 텍스트 형식으로 들어가며, 다른 파인더 창에서는 파일 자체가 복사된다거나, 이미지 편집기에서는 해당 파일의 썸네일이 붙여넣어지는 식으로 동작할 수도 있다.

이처럼 클립보드는 사용되는 상황에 따라서 동작할 필요가 있고, 이런 모든 상황들을 고려한다면 매우 복잡한 시나리오를 모두 고려해야할 것 같은 어려움이 느껴진다. 하지만 macOS의 클립보드 API는 매우 세심하게 디자인되어 있으며, 생각보다 쉽고, 큰 고생없이 이런 여러 상황들에 유연하게 대응할 수 있게끔 만들어졌다.

macOS 가이드 문서에 있는 예제를 통해서 기본적인 클립보드의 사용법을 알아보자

예제

예제로 만드는 프로젝트는 간단히 이미지 뷰에 이미지를 표시하면서 그 이미지를 복사하거나, 다른곳에서 복사한 이미지를 붙여넣는 것이다. 몇 가지 편의를 위해서 프로젝트를 만들 때 문서 기반의 macOS용 앱으로 시작한다. (문서 기반 앱을 만들면 한 번에 여러 개의 창을 열 수 있기 때문에 테스트하기 용이하다.) 그리고 NSDocument 클래스의 서브 클래스는 다음과 같은 뼈대를 가지게끔 한다.

class MyDocument: NSDocument {
    @IBOutlet weak var imageView: NSImageView!
    @IBAction func copy(_ sender: Any?) {
        ...
    }
    @IBAction func paste(_ sender: Any?) {
        ...
    }
}

../Art/copyApplicationWindow.jpg다음은 UI를 만들어본다. 윈도에 이미지뷰를 올리고, 툴바를 추가해준다. 툴바에 Copy, Paste 버튼을 부착한다. 그리고 Copy, Paste 버튼을 위에서 정의한 copy: 와 paste: 에 연결한다.  이제 실제로 이 버튼들이 동작할 수 있는 코드를 작성할 차례이다.

 

copy 구현

copy(_:) 함수를 구현해보겠다. 이 함수는 이미지 뷰에 표시되고 있는 이미지를 클립보드로 복사한다. 클립보드에 데이터를 쓰는 표준적인(?) 방식은 엄청 당연하게도 다음과 같은 절차를 따른다. 이 절차는 너무나 당연하여 마치 파인만 알고리듬 같은데, 보통 애플 문서에서는 이런 당연한 사실(?)을 목록으로 일부러 나열하는데에는 뭔가 꿍꿍이가 있기 마련이다. 암튼 그 과정은 다음과 같다.

  1. 페이스트보드(클립보드)를 준비한다.
  2. 페이스트보드를 비운다.
  3. 데이터를 쓴다.

3번 과정을 보자. 페이스트보드에 쓰려는 데이터는 그 자체가 NSPasteboardWriting 프로토콜을 준수하는 타입이거나, NSPasteboardItem로 감싸진 객체여야 한다. 다행히도 대부분의 코코아의 표준 데이터 클래스들은 이 NSPastebardWriting 프로토콜을 따른다.(NSString, NSImage, NSURL, NSColor…) 즉 이미지는 곧바로 클립보드에 쓸 수 있는 타입이므로, 다음과 같이 이미지를 클립보드로 복사해보자. 이처럼 페이스트보드에 들어가는 혹은 들어가 있는 객체들을 페이스트보드 아이템이라고 통칭한다.

@IBAction func copy(_ sender: Any?) {
    guard let image = imageView.image else { return }
    let pasteboard = NSPasteboard.general()  // 1
    pasteboard.clearContents()               // 2
    pasteboard.writeObjects([image])         // 3
}

paste 동작 구현

이번에는 클립보드에 들어있는 이미지를 붙여넣는 기능을 만들어보자. 붙여넣기는 복사하기보다는 조금 복잡하다. 복사하기와 붙여넣기가 어떻게 다를지 몇 가지 케이스를 한 번 생각해보자. 물론 복사/붙여넣기와 관련된 환경/상황적 조건들은 여러분이 짐작하는 것보다도 훨씬 더 복잡할 수 있다.

  1. 어떤 데이터를 복사하는 것은 그 데이터가 복사가능한 타입이기만 하면 복사할 수 있다. 복사된 데이터를 어떻게 쓰느냐는 것은 이 시점에 고려할 것이 아니다.
  2. 붙여넣기를 하는 시점에는 클립보드에 데이터가 들어있는지 여부와 별개로, 그 데이터가 지금 사용할 수 있는 타입인지가 중요하다. 지금 데이터를 붙여넣고자하는 곳은 이미지 뷰인데, 클립보드에 텍스트나 URL정보가 복사되어 있다면 붙여넣을 수 없음이 당연한 것이다.
  3. 반대로 파인더에서 파일을 복사했을 때, 해당 파일이 이미지 파일이라면 이미지 뷰에 붙여넣을 수 있어야 한다.

이러한 다양한 상황을 어떻게 고려할 수 있을까? 우선은 두 번째 조건, 즉 “지금 이미지를 클립보드로부터 붙여넣으려고 하는” 상황에 집중해보기로 하자.

붙여넣기를 수행하기 이전에 원하는 데이터가 페이스트 보드에 있는지를 먼저 확인해야한다. 이는 페이스보드에 canReadObject(forClasses:[AnyClass], options:[String:Any]? = nil)을 호출하여 원하는 클래스의 데이터가 있는지를 확인해 보면 된다.

이때 페이스트보드로부터 읽어들이려는 데이터는 NSPasteboardReading 프로토콜을 따라야 한다.  물론 대부분의 표준 코코아 데이터 타입들은 이 프로토콜도 따르고 있다. 물론 이미지(NSImage)역시 이 프로토콜을 따르고 있기 때문에 바로 읽어들일 수 있다. 일반적으로 NSPasteboardReading 을 따르는 타입의 데이터를 클립보드로부터 읽어들이는 것은 다음과 같은 코드를 이용한다.

@IBAction func paste(_ sender: Any?) {
    let pasteboard = NSPasteboard.general()
    let classArray = [NSImage.self]
    if pasteboard.canReadObject(forClasses: classArray), 
    let objectToPaste = pasteboard.readObjects(forClasses: classArray) {
        // objectToPaste :: [Any]?
        let image = objectToPaste.first as? NSImage
        imageView.image = image
    }
}

이제 우리의 앱은 이미지에 대한 복사/붙여넣기를 지원하게 되었다. 앱을 빌드하고 실행한 다음 두 개의 문서를 만들어보자. 미리보기, 사파리에서 이미지를 복사하거나, 파인더에서 이미지 파일을 복사하여 앱에 붙여넣어 본다. 물론 앱에서도 데이터를 복사한 다음, 앱의 다른 창이나, 다른 이미지를 붙여넣을 수 있는 앱에 붙여넣어서 확인해보자.

보너스 : 클립보드 복사/붙여넣기를 지원하는 표준 코코아 데이터들

NSImage는 클립보드에 대한 복사/붙여넣기를 이미 지원하고 있다고 했다. 따라서 NSPasteboadReading을 지원하기 때문에 위와 같은 코드를 통해서 이미지를 읽어들일 수 있는데, 표준 코코아 타입이 클립보드를 지원하는 경우라면 init?(pasteboard:)라는 생성자를 갖추고 있기 때문에 곧장 다음과 같은 코드로도 이미지를 붙여넣을 수 있다.

@IBAction func paste(_ sender: Any?) {
    let pasteboard = NSPasteboard.general()
    let copiedImage = NSImage(pasteboard: pasteboard) // :NSImage?
    imageView.image = copiedImage
}

보너스2 : 툴바 유효성 검사

유저 인터페이스의 유효성은 NSUserInterfaceValidations 프로토콜에 정의되는데, 이는 UI 컨트롤의 유효성이 현재 문맥에서 유효한지를 판단할 수 있는 방법이다. 이는 validateUserInterfaceItem:이라는 단일 메소드로 이루어진다. 툴바 혹은 뷰 내의 어떠한 유저 인터페이스 디바이스에 대해서 유효성을 매순간 체크하는 일이 필요하다면 번거롭게 개별적으로 처리할 것이 아니라 이 프로토콜을 따르도록 필요한 요건을 구비해주면 시스템이 알아서 유효성을 검사하고 enabled 값을 자동으로 변경해준다.

MyDocument 클래스에 대해서 다음 내용을 추가해본다.

func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool {
    if item.action == #selector(MyDocument.paste(:)) {
        let pb = NSPasteboard.general
        return pb.canReadObject(forClasses:[NSImage.self])
    }
    return super.validateUserInterfaceItem(item)
}

이렇게하면 paste 버튼은 복사된 이미지가 있는 경우에만 활성화될 것이다.

조금 더 깊이 파보기

그런데 여기서 잠깐, 방금 파인더에서 파일을 선택하고 “복사”한 다음에 이미지 뷰에 붙여넣기를 했는데 이미지가 들어왔다. 우리는 부지불식 간에 이러한 동작을 많이 수행해봤기 때문에 별 의심없이 이를 받아들이고 있는데, 다음의 케이스들을 생각해보자.

  1. 파인더에서 이미지 파일을 복사한다.
  2. 이미지 뷰가 있는 앱에서 붙여넣기 한다 –> 이미지가 들어간다.
  3. 서식있는 텍스트 (RTFD)를 지원하는 편집기에서 붙여넣기 한다 –> 이미지가 들어간다.
  4. 서식없는 일반 텍스트만 지원하는 편집기에서 붙여넣기 한다 –> 이미지의 주소가 들어간다.

어떻게 같은 항목을 복사하였는데, 붙여넣는 곳에 따라서 다른 데이터가 들어갈 수 있는 것일까? 이 비밀은 NSPasteboardWriting 프로토콜에 있다.

클립보드에 올라가는 개별 데이터 조각을 아이템이라 한다. macOS의 클립보드는 한 번에 여러개의 아이템을 붙여둘 수 있으며, 각 앱은 원하는 만큼 많은 아이템을 붙이거나 읽어올 수 있다. 예를 들어 사파리에서 이미지와 텍스트가 함께 있는 페이지의 일부를 긁어서 복사하면 텍스트와 이미지의 데이터가 각각 클립보드에 저장된다. 이렇게 멀티 아이템이 저장된 클립보드에서 애플리케이션은 위에서 살펴본 것처럼 앱 자신이 지원하는 타입의 데이터만 읽어와서 사용할 수 있다.

이 때 복사하는 아이템이 여러개가 아니라 하나일 때에도 같은 식으로 동작하게끔 만들 수 있다.  즉 어떤 아이템을 클립보드에 복사할 때에는 이 데이터가 어디에 쓰일지 모른다고 했다. 단순히 코드와 데이터에 국한 시각이 아닌 일상적인 시각에서 바라볼 때, 복사된 데이터는 가능한 다양하고 넓은 영역에서 쓰이는 것이 좋을 것이다. 즉, 어떤 객체를 클립보드로 복사하게되면 단순히 그 객체를 인코딩한 데이터가 클립보드에 쓰여지는 것이 아니라, 해당 데이터가 지원할 수 있는 “덜 풍부한” 표현형을 함께 복사하는 것이다. 예를 들자면 폰트나 색상이 2가지 이상 쓰인 서식있는 문자열을 복사했을 때, 서식 있는 문자열 데이터를 그대로 붙여넣을 수 있기도 하지만, 경우에 따라서는 서식없는 일반 문자열만을 붙여넣게되는 경우가 있을 것이다. 이것은 “서식있는 문자열”을 표현하는 NSAttributedStringNSPasteboard를 구현할 때, 덜 풍부한 타입인 일반 문자열(NSString)을 클립보드에 추가적으로 쓰는 기능을 더불어서 구현한 것이다.

NSPasteboardWriting 프로토콜은 이를  따르는 단일 클래스가 “클립보드에 써 넣을 수 있는 모든 데이터를 클립보드에 쓰는 동작”을 정의하고 있다. 보다 정확히는 클립보드는 이 프로토콜을 따르는 객체에게 “니가 지원할 수 있는 모든 타입에 대해서 각각의 타입의 인코딩된 데이터를 내놔라”라고 요청할 수 있는 것이다.

복사/붙여넣기 매커니즘

클립보드에 복사가능한 클래스가 이미 코코아에는 존재하고 있지만, 이처럼 별도의 프로토콜이 정의되어 있다는 것은 커스텀 클래스를 복사가능하게끔 만들 수 있다는 것이다. 여기서 헷갈리지 말아야 하는 것은 NSCoding과는 달리, NSPastebardWriting은 반드시 해당 클래스가 온전한 자기 스스로를 인코딩한 데이터를 제공해야 하는 법은 아니라는 것이다. 만약 여러분이 학생들의 시험성적을 관리하는 앱을 만들고 있고, 특정 학생의 데이터를 복사할 수 있다고 할 때, 해당 클래스를 그 앱 내에서 곧장 붙여넣을 기능이 필요없다면 어떤 학생의 레코드를 복사했을 때 단순히 그 학생의 학번이나 이름만 복사되어도 무방하다는 것이다.

NSPasteboardWriting 은 크게 두 가지 동작을 정의하고 있다.

  1. 해당 클래스가 클립보드에 써넣을 수 있는 타입들이 무엇인지 클립보드에게 알려준다.
  2. 클립보드가 특정 타입을 제시했을 때, 해당 타입에 대응하는 인코딩된 데이터를 제공해준다.

즉 위에서 예로든 성적 관리 프로그램에서 학생 레코드를 복사하려할 때, 클립보드는 Student 클래스의 객체에게 니가 제공할 수 있는 타입이 뭔지를 물어보게되고, Student는 문자열이라고 대답한다. 그러면 클립보드는 그럼 문자열 데이터를 내놔라 할 수 있고, 역시 저 프로토콜에 정의된 내용대로 학생 객체는 이름 문자열을 클립보드에게 건내주는 것이다.

같은 예를 파인더에서 이미지 파일을 복사하는 예로 생각해보자.  클립보드는 파인더 내의 파일 객체에 대해서 너는 무슨 타입을 줄 수 있느냐고 물어볼 것이다. 그러면 파일은 아마도, 이미지와 텍스트를 줄 수 있다고 대답할 것이다. 그러면 클립보드는 일단 다른 건 됐고, 이미지 데이터부터 달라고 할 것이다.

이제 클립보드는 이미지와 텍스트라는 두가지 타입 정보와, 그 중 이미지에 해당하는 데이터를 가지고 있다. 그 상태에서 텍스트 편집기에 대해서 붙여넣기를 하는 과정을 다시 생각해보자. 텍스트 편집기 앱은 붙여넣기를 하는 시점에 클립보드에게 텍스트 데이터를 요청할 것이다. 그러면 클립보드는 지금 텍스트 데이터를 가지고 있지 않지만, 자신에게 데이터를 준 원본 객체가 이미지외에 텍스트를 제공한다는 사실을 알고 있기 때문에 다시 원본 객체에게 “텍스트를 내놓아라”라는 요청을 할 수 있다. 그렇게 텍스트 데이터를 구해온 클립보드는 다시 목표 앱인 텍스트편집기에게 방금 받아온 데이터를 진상할 수 있게 되는 것이다.

한번에 모든 데이터를 클립보드에 들고가지 않는 이유는, 쓸데없이 많은 데이터를 메모리에 보관하는 것이 (쓸지도 안쓸지도 모르는 상황에서) 매우 비효율적이기 때문이다.

이제 데이터를 붙여넣기 할 때, 클립보드로부터 어떻게 데이터를 읽어올까? 위에서 구현한 예에서 보듯이 클립보드로부터 데이터를 읽는 것이 아니라, 특정한 타입의 객체를 바로 얻을 수 있다. 이것은 클립보드에 들어있는 아이템이 NSPasteboardReading을 구현하고 있기 때문이다.

클립보드에 객체를 저장할 때, 저장되는 객체는 다음 중 한가지 방식으로 저장될 수 있다.

  1. 데이터 : NSData 객체로 저장된다.
  2. 문자열 : NSString 객체로 저장된다.
  3. 프로퍼티리스트 : 프로퍼티 리스트로 저장된다.
  4. 키 기반 아카이브: 결국 1번과 같다. 키 기반으로 인코딩된 바이너리 데이터가 저장된다.

즉 이 말은 우리가 어떤 객체를 클립보드에 쓴다는것인 그 객체 그대로를 클립보드에 쓰는 것을 보장한다는 것이 아니라는 뜻이다. 따라서 클립보드로부터 객체를 받아오는, 즉 붙여넣는 앱의 입장에서는 저장된 raw 데이터를 얻어서 해당 클래스를 재구성하는 것이 아니라, 클립보드에게 특정 타입의 객체를 바로 요구하게 된다.

어쨌든 클립보드는 저장된 raw데이터가 아닌 요청한 타입에 맞는 객체를 구해서 제공해주게 되는데, 정해지지 않은 임의의 클래스들을 지원할 수 있다는 것은, 클립보드 속에 들어있는 데이터들이 스스로가 객체로 변환되는 방법을 알고 있다는 뜻이다.

따라서 클립보드에 저장된 상태에서 객체 인스턴스로 변환되는 방법을 알고 있는 데이터만이 클립보드로부터 붙여넣기를 할 수 있는 클래스가 되며, 자연스럽게 NSPasteboardReading은 이와 관련된 내용을 정의하게 된다.

  1. 해당 클래스와 매칭될 수 있는 UTI들을 클립보드에게 알려준다.
  2. 특정 UTI 값과 클립보드데이터를 제공받아 자기 자신의 인스턴스를 만드는 법을 알고 있다.

기본적인 코코아의 데이터를 담는 클래스들인 NSImage, NSColor, NSFont, NSString 등의 타입은 기본적으로 이 프로토콜들을 따르고 있으므로 별다른 서브 클래싱이나 확장 없이 쉽게 클립보드에 복사해 넣고, 다시 꺼내어 붙여 넣기 할 수 있다.

만약 자신의 응용 프로그램을 위해 따로 설계한 클래스가 복사/붙여넣기를 지원하게 하고 싶다면 이 프로토콜을 따르도록 하거나, NSPasteboardItem 객체를 이용해서 데이터를 담아서 전송할 수 있다.

정리

클립보드 사용의 동작 원리를 정리해보자.

  • 클립보드(페이스트보드)는 시스템 서비스인 페이스트보드 서버와 통신하여 데이터를 주고 받는 인터페이스이다. 이를 이용하여 앱 내에서 혹은 서로 다른 앱 간에 데이터를 교환하는 손쉬운 방법이다.
  • 클립보드에 특정 객체를 복사한다는 표현은 잘못되었다. 특정 객체가 클립보드에 데이터를 써주는 시각으로 보는것이 받아들이기에 편하다.
  • 클립보드에 데이터를 줄 때는 클립보드가 데이터를 사용하는 시점을 고려해야한다. 따라서 데이터를 주기 이전에 어떤 타입의 데이터를 지원한다는 점을 알리는 것이 가장 중요하다.
  • 지원하는 타입들과, 매 타입에 대응하는 데이터를 주는 것이 복사가능한 객체의 임무이다.
  • 붙여넣기를 하는 시점에 데이터를 받는 앱이 받을 타입을 결정하며, 클립보드는 요구되는 타입을 지원할 수 있는 경우에만 데이터를 줄 수 있다.
  • 클립보드는 자신이 제공해줄 수 있는 데이터의 타입들은 모두 알고 있으며, 각 타입에 대응하여 필요한 데이터는 경우에 따라서 느긋하게 원본 소스에게 요청하여 전달할 수 있다.

결국 페이스트보드는 타입이름-데이터의 쌍으로 이루어진 집합을 저장해두는 저장소이다. 그리고 클립보드에 데이터를 읽고 쓰는데 관련되는 두 개의 프로토콜, NSPasteboardWritingNSPasteboardReading은 하나의 객체가 갖추어야 하는 인코딩-디코딩의 인터페이스가 아니라, 클립보드를 중심으로 서로 다른 타입들이 드나드는 인터페이스라고 이해하면 된다.

다음 글에서는 이들 프로토콜을 적용하여 커스텀 클래스로하여금 복사/붙여넣기를 할 수 있도록 하는 방법에 대해 알아볼 것이다. 아울러 해당 프로토콜을 적용하지 않고 NSPasteboardItem객체를 이용하여 기존 데이터를 클립보드로 감싸 넣는 방법에 대해서도 알아볼 것이다.

참고자료