(Cocoa | Swift) 문서기반 앱

NSDocument

코코아에서 도큐먼트 기반 앱은 Pages 등의 프로그램과 같이 단일 문서당 개별 윈도를 갖고 구동되는 앱이다. 이러한 앱은 다음의 특성을 가진다.

  1. 한 애플리케이션에서 문서의 개수만큼의 윈도를 열 수 있다.
  2. 앱은 여러 문서들을 관리하게 된다.
  3. 문서는 문서의 데이터 구조를 관리하며, 이러한 데이터를 표시하는 윈도우를 관리한다.
  4. 문서는 디스크에 문서의 데이터를 저장하고, 읽고, 새 문서를 시작하는 기능을 담당한다. 또한 iCloud에 문서를 저장하거나 옮기는 기능도 담당하게 된다.

도큐먼트 기반 앱은 단순한 유틸리티나 슈박스 앱과는 다른 구조를 가지고 있으며, 그만큼 복잡하고 많은 기능들을 제공해야 한다. 코코아는 NSDocument라는 클래스를 제공하여, 도큐먼트 기반 앱을 만들 때 이 클래스를 서브 클래싱하게끔 하고 있으며 이 클래스는 도큐먼트 기반 앱에서 처리해야 하는 공통 기능의 대부분을 미리 구현하여 제공하고 있다.

도큐먼트

문서는 기본적으로 파일(혹은 번들)에 저장되는 정보들을 담는 컨테이너이다. 문서의 내용이 되는 데이터는 미리 정의된 데이터 구조로 모델링되어 있다. 사용자의 시점에서 문서는 앱에서 작성하고 편집되는 정보들이며 이는 NSDocument 혹은 그 서브 클래스의 인스턴스로 표현된다.

NSDocument 및 Xcode의 문서기반 앱 프로젝트 템플릿은 문서 기반 앱을 쉽게 만들 수 있게끔 많은 공통기능들을 미리 구현해두었고, 이를 통해서 다음과 같은 기능들을 거의 자동으로 지원하게 된다.

  • 새 문서를 생성한다.
  • 파일로부터 문서을 읽어들인다.
  • 자동으로 문서를 저장한다.
  • 비동기방식으로 문서 데이터를 읽고 쓸 수 있다.
  • 문서의 버전을 관리한다.
  • 문서를 출력한다.
  • 변경사항을 추적하고 문서의 편집 상태를 관리한다. 문서는 NSUndoManager를 사용하여 편집 작업을 되돌리기 스택에 추가하게 된다.
  • 문서는 편집 상태와 특정한 액션의 사용 가능 여부를 판단하여 자동으로 메뉴 항목의 유효성을 점검한다.
  • 앱과 윈도의 델리게이션을 담당한다.

도큐먼트 기반 앱의 구조

도큐먼트 기반 앱을 이루는 객체들은 다음과 같은 것들이 있다.

  • NSApplication : 앱의 본체가 되는 클래스. 앱의 동작 환경을 생성하고 런루프를 시작한다.
  • NSDocumentController : 새로운 문서를 생성하거나, 파일로부터 읽어들인 데이터로 새 문서를 시작할 수 있게 하는 등, 앱에서 사용하는 문서를 관리한다.
  • NSDocument : 문서를 새로 시작하거나 파일을 열면 새로운 문서 객체가 생성된다. 문서들은 NSDocumentController에 의해 관리되며, 이 객체는 기본적으로는 문서의 데이터를 관리하고, 문서 내용을 표시할 창을 준비한다.
  • NSWindowController : 문서는 하나 이상의 창 관리자를 생성한다. (기본적으로 메인 윈도는 자동으로 생성한다.) 윈도 컨트롤러는 자신이 관리하는 창의 라이프 사이클 및 내부에서 발생하는 이벤트들을 처리한다. 창 내의 뷰 계층 구조가 복잡하고, 많은 기능을 구현해야 하는 경우에는 주요 뷰 별로 뷰 컨트롤러들을 다시 생성할 수 있다.
  • NSViewController : 창 내의 특정 뷰가 많은 기능을 수행해야 하는 경우에, 윈도우 컨트롤러가 너무 비대해지는 것을 막고, 코드의 재사용성을 높이기 위해서 일부 MVC 구성을 뷰 컨트롤러를 이용해 분할할 수 있다.

도큐먼트 컨트롤러

문서 기반 앱이 만들어지면, 앱은 NSDocumentController를 기본적으로 하나 생성한다. 이 객체는 기본적으로 자동으로 거의 모든 기능을 수행하게끔 설정되어 있으며, 이 객체 자체를 직접 액세스해야 하는 경우는 별로 없다. 도큐먼트 컨트롤러는 문서들을 관리하는 역할을 담당하며 다음의 일을 처리한다.

  • 새 문서를 만든다.
  • 파일을 읽어 문서를 연다.
  • 열린 문서들을 관리하고 추적한다.
  • Open Recent와 같은 문서들과 관련된 메뉴 아이템을 다룬다.

도큐먼트

도큐먼트는 사용자 문서 내의 데이터를 관리하는 역할을 담당한다. 앱마다 사용하는 데이터의 모델이 다르고, 또 도큐먼트 자신은 스스로의 데이터를 저장하고 읽어오는 방법을 알고 있어야 하기 때문에 모든 프로젝트는 NSDocument를 서브 클래싱하여 자신만의 도큐먼트를 작성해야 한다.

도큐먼트 클래스를 디자인할 때는 데이터처리와 관련된 부분과 시각화 부분을 세심하게 고려하여 잘 분리하는 것이 중요하다. 도큐먼트는 MVC 패턴에서 모델 컨트롤러의 역할을 담당한다. 여기에는 다음과 같은 작업을 수행하는 일이 포함된다.

  • 창에 데이터를 표시하고, 캡쳐하는 일
  • 영구저장소로부터 문서의 데이터를 로드하는 일
  • 저장, 프린트, 복원, 닫기 등의 메뉴에 반응하는 일
  • 저장과 출력 대화상자를 관리하는 일

윈도 컨트롤러

도큐먼트는 콘텐츠 데이터를 표시하기 위한 UI가 필요하며, 문서 기반 앱에서 하나의 도큐먼트는 (보통) 하나의 창으로 표현된다. 따라서 각각의 도큐먼트는 메인 윈도우를 필요로하며, 따라서 기본적으로 자동으로 NSWindowController를 생성하고 이를 통해서 메인 윈도우를 관리하게 된다. 윈도우 컨트롤러는 자신을 관리하는 도큐먼트 객체의 요청을 받아서 nib 파일을 읽고 윈도우 객체를 로딩하여 화면에 표시하며, nib 파일의 소유주를 자신으로 세팅한다.

윈도우가 표시하고 처리하는 내용이 간단하다면 기본적으로 제공되는 윈도우 컨트롤러를 그대로 사용하면 되지만, 그렇지 않은 경우에는 커스텀 컨트롤러를 사용하는 경우도 있다.

서브클래싱

도큐먼트 기반 앱을 작성할 때는 다음 세 클래스를 서브클래싱한다.

  • NSDocument : 필수적으로 서브 클래싱해야 한다.
  • NSWindowController : 필요한 경우에 서브 클래싱할 수 있다.
  • NSDocumentController : 꼭 필요한 경우에 서브 클래싱할 수 있으나, 보통은 권장되지 않는다.

UTI 세팅

UTI란 macOS에서 특정한 도큐먼트의 데이터 포맷과 타입을 정의하는 식별자이다. 기본적으로 OS에서 정의해놓은 타입들 (범용 데이터 포맷이나 Apple이 정의해놓은 타입들)이 있는데, 만약 여러분의 앱이 고유한 별도의 포맷을 사용하게 된다면, 자신만의 UTI를 정의해야 한다. 도큐먼트 타입 정보에는 UTI 문자열과 사용하는 파일의 확장자, 해당 UTI가 적용되는 문서의 클래스, 아이콘 정보등이 수록된다.

서드파티 앱이나 플러그인에서 해당 데이터를 사용하게 하고 싶다면, 추가적으로 이를 Exported UTI로 정의해야 한다.

이러한 정보는 Info.plist 파일에 저장된다.

참고로 이 식별 과정은 Objective-C 런타임에서 해석되므로, Swift로 NSDocument 의 서브 클래스를 만드는 경우에는 @objc(클래스이름) 변경자를 명시하여 정확한 클래스명을 찾을 수 있도록 한다.

도큐먼트 클래스 만들기

도큐먼트 클래스는 NSDocument를 서브 클래싱하여 만든다. 여기에는 문서가 저장하는 데이터 구조를 기본적으로 정의하며, 도큐먼트 기반 앱 아키텍쳐에 필요한 몇 가지 메소드들을 오버라이딩하여 작성해야 한다. 꼭 오버라이드해야하는 메소드들은 다음과 같은 기능들을 관장하는 것들이다.

  • 파일로부터 문서을 읽는데 필요한 기능
  • 파일에 문서 데이터를 쓰는 기능
  • 새 윈도를 초기화하는 기능
  • 문서를 iCloud에 올리거나, iCloud로부터 제거하는 기능

특히 문서를 읽고 쓰는 기능을 구현하는 것이 가장 기본적으로 필요한 기능이다. 이는 상황에 따라서 어떤 메소드를 구현해야 하는 것이 달라질 수 있는데, 기본적으로는 read(from:ofType:)data(ofType:)이다.

read(from:ofType:)

read(from:ofType:)Data 객체와 타입정보 (UTI 문자열)를 받아서 해당 데이터로부터 문서 객체를 복원하는 기능을 수행한다. 만약 문서 클래스가 NSCoding을 따르도록 설계되어 있다면, NSKeyedUnarchiver를 사용하여 문서를 복원하면 된다.

다음은 텍스트 기반의 어떤 문서 앱에서 데이터로부터 문서 내용을 읽어들이는데 필요한 read(from:ofType)을 구현한 것이다.

override func read(from data: Data, ofType type: String) throws {
    let fileContents = try NSAttributedString(data: data, options: [], documentAttributes:nil)
    self.mString = fileContents
}

혹은 모델 클래스 프로퍼티를 가지고 있다면 보통 다음과 같은 식으로 처리한다. (DocumentError는 별도로 정의했다고 가정한다.)

override func read(from data: Data, ofType type: String) throws {
    guard let contentsObj = NSKeyedUnarchiver.unarchiveObject(with: data) as? DocData
    else { throw DocumentError.badFormatError }
    contents = contentsObj
}

파일로부터 읽어들인 데이터로 문서의 내용을 복원하는 부분만 구현하면 되고, 그외에 파일을 선택하고 해당 경로로부터 파일을 읽어들이는 부분은 모두 문서기반 앱 아키텍쳐 내에 미리 구현된 기능들이 모두 알아서 처리해준다.

이 절차는 파일을 읽고 쓰는 작업과는 완전히 분리되어 있다. 만약 문서 파일의 경로를 필요로 하는 어떤 작업을 해야하거나, 파일 패키지로부터 읽어들이는 작업을 직접 처리하고 싶다면, 이 메소드가 아닌 다른 메소드들을 오버라이드해야 한다. 이 부분은 나중에 다시 다루도록 하겠다.

참고로 canConcurrentlyReadDocuments(ofType:)을 오버라이딩하여 특정한 타입의 문서는 별도의 스레드에서 읽고, 그 동안 앱이 블럭킹되지 않도록 할 수 있다. (기본적으로 모든 타입은 동기식으로 읽게끔 하고 있다.) 만약 문서 타입의 구조가 외부의 특정 데이터에 의존해야 한다면 병렬 읽기를 못하도록 해야 할 것이다.

data(ofType:)

data(ofType:)은 특정 UTI 포맷으로 데이터를 파일에 저장하기 위한 사전 작업으로 실제 파일에 쓸 Data 객체를 생성하는 일이다. 역시 이 데이터를 생성한 이후에 파일에 내용을 쓰는 작업은 도큐먼트 객체가 알아서 처리하게 된다.

보통 다음과 같은 식으로 구현하면 된다. (데이터 콘텐츠를 나타내는 클래스는 NSCoding을 구현했다고 가정)

override func data(ofType type: String) throws -> Data {
    if type == "com.sooop.mydocumentdata" {
        return NSKeyedArchiver.archivedData(withRootObject: self.contents)
    }
    throw DocumentError.brokenData
}

역시 쓰는 과정을 컨트롤 하고 싶다면 write(to:ofType:) , save(to:ofType:for:completionHandler:)등을 대신 오버라이딩한다.

읽기와 마찬가지로 canAsynchronouslyWrite(to:ofType:for:)를 오버라이딩하여 특정 타입의 도큐먼트는 비동기식으로 쓰도록 하는 것도 가능하다.

문서의 초기화

기본적으로 init 이니셜라이저가 designated initializer이고, 특별한 초기화 과정이 필요없다면 (모든 새로운 프로퍼티의 기본값이 있는 등) 별도의 오버라이딩은 필요 없다. 만약 읽기가 아닌 빈 문서를 만드는 과정에서만 초기화가 필요하다면 init(type:) 을 오버라이딩하면 되고, 반대로 읽기 시에만 별도의 초기화가 필요하다면 init(contentsOf:ofType)을 오버라이딩한다. 이 때 반드시 수퍼 클래스의 초기화 메소드를 호출해야 한다. (이 과정은 Swift에서는 컴파일러가 체크하여 강제한다.)

그 외의 서브 클래싱

윈도우 컨트롤러 생성

NSDocument는 윈도우 컨트롤러를 자동으로 생성하는데, 만약 nib 파일로부터 윈도우를 로딩할거라면 windowNibName 메소드를 오버라이드한다. 이는 표준 NSWindowController를 생성하고 사용하게 된다. 만약 커스텀 컨트롤러를 사용하고 싶다면 makeWindowControllers를 이용해서 생성하자.

특히 이 메소드 내에서 윈도우 컨트롤러를 생성했다면 반드시 addWindowController:를 호출해서 프로퍼티에 추가해주어야 한다. (안그러면 ARC에 의해서 파괴된다.)

그렇다면 nib 도 쓰고, 커스텀 컨트롤러를 쓸 거라면? > 애초에 윈도우 컨트롤러를 초기화할 때, 다음의 이니셜라이저를 사용하는 옵션들이 있는데…

  • init(window: NSWindow)
  • init(windowNibName: String)
  • init(windowNibName: String, owner: Any)
  • init(windowNibPath: String: owner: Any)

nib 파일로부터 윈도우를 로딩하면

windowControllerWillLoadNib:, windowControllerDidLoadNib:을 이용하면 각 윈도우 컨트롤러가 nib 파일을 로딩 완료했을 때의 UI의 초기화 처리를 여기서 할 수 있다. (예를 들어 User Defaults에 저장해놓은 배경색을 뷰에 적용한다거나…)

출력과 페이지 레이아웃

프린트와 관련된 내용도 한 번 다뤄야 할텐데, printOperationWithSettings:를 오버라이드하여야 문서를 출력할 수 있다.

문서 관련 기능들

macOSX 10.7부터는 사용자들이 명시적으로 문서를 저장하면서 데이터를 유실할 염려를 하지 않아도 된다. 대신에 필요한 경우에 시스템이 알아서 자동적으로 데이터를 디스크에 기록한다. NSDocument 서브클래스는 autosavesInPlace 클래스 메소드를 true를 리턴하도록 오버라이드하여 이 기능을 선택하기만 하면 된다. 그렇게함으로써 화면에 표시되는 데이터가 디스크에 기록된 데이터와 동일하다는 것을 항상 보장받을 수 있다. 실질적으로는 모든 변경에 대해 실시간으로 데이터를 기록하지는 못하지만, 시스템은 충분히 자주 적절한 시점에 데이터를 보존하게 된다.

제자리 자동저장과 백업 생성

자동 저장은 “제자리 자동저장” 방식으로 구현된다. 자동 저장의 방식은 크게 두 가지 개념으로 나눠볼 수 있는데 그 중 제자리 자동 저장은 매번 기존 파일을 덮어쓴다. 이 방식은 일단 변경이 발생했을 때 임시 파일에 기록하고, 이보다 덜 빈번한 주기로 임시파일을 원본과 교체한다. 교체가 발생하는 동안에는 다른 애플리케이션이 문서 파일을 읽는 것이 차단된다.

원본이 없는 새 문서의 경우에는 autosaving elsewhere라는 방식을 사용한다. 새 문서의 원본 파일은 최초로 해당 문서를 저장하는 시점에 생성되므로, 그 전가지는 별도의 공간(~/Libaray/Autosave Information)에 임시파일의 버전들을 저장한다. 따라서 새 문서를 편집하는 중간에도 사용자는 이전 기록으로 되돌아갈 수 있다.

성능 문제

한번에 기록되는 데이터가 많지 않아서 데이터 저장에 시간이 별로 걸리지 않는다면 자동 저장을 마다할 이유가 없다. 하지만 이것이 느릴 때는 사용자 인터페이스가 주기적으로 블럭되거나 하는 일이 발생할 수 있다. 따라서 자동 저장을 하기로 결정하기 전에 도큐먼트 모델이나 저장 로직에 대해 점검해볼 필요는 있다.

의도하지 않은 편집

자동 저장은 사용자가 느끼지 못하는 사이에 발생한다. 따라서 의도치 않은 편집이 디스크에 저장될 염려도 있고 이는 잠재적인 데이터 손실로 이어질 수 있다. 이 문제를 해결하기 위해 NSDocument는 세이프티 체크를 수행한다. 예를 들어 문서가 일정 기간 이상 편집되지 않았다면, 이 문서 파일은 잠김 상태가 되고, 사용자는 편집이 아닌 읽기 모드로 파일을 열게 된다. 또한 사용자가 문서를 편집하는데 사용되지 않는 다운로드 폴더 같은 곳에서 파일을 열었는지 등의 여부도 본다.

비동기 저장

자동 저장의 잦은 디스크 액세스가 UI를 블럭하는 것을 방지하기 위해서 NSDocument는 디스크에 저장하는 것을 백그라운드 스레드에서 비동기로 수행할 수 있다. 이는 canAsynchronouslyWrite(to:ofType:for:)를 오버라이드하여 특정 상황과 타입등을 고려하여 비동기 저장을 허락할 수 있다. 하지만 실제로는 해당 스레드가 unblockUserInterface()를 호출하기 전까지 메인 스레드의 UI는 블럭된다. 따라서 블럭을 최소화 하기 위해서실제 저장 후가 아닌 저장해야 하는 데이터의 스냅샷 수집이 완료되면 이 메소드가 호출되고, 사용자가 체감할 수 있는 블럭은 거의 발생하지 않는다.

버전

사용자가 문서를 저장하고 나면 Save 메뉴 항목은 Save a version으로 바뀐다. 또한 자동 저장 중에서도 버전을 바꿔서 저장하기도 한다. 이후에 사용자는 보고 있거나 편집중인 문서에 대해서 저장된 모든 버전을 브라우징하면서 이전 시점의 버전 어느것으로 자유롭게 돌아갈 수 있다. (혹은 이전 버전으로부터 사본을 생성할 수도 있다.)

상태 저장과 되돌리기

문서 기반 앱의 아키텍쳐가 제공하는 편리함 중의 하나는 앱의 상태저장과 되돌리기를 공짜로 얻을 수 있다는 점이다. 문서를 연 상태에서 앱을 종료한 후에 다시 앱을 시작하면 앱은 최종적으로 편집하던 문서를 열고, 창의 크기나 위치까지 그대로 복원한다.

대안적 디자인

통상적인 문서 기반 앱들의 기능은 대충 설명되었다. 그러나 어떤 상황에서는 대안적인 기술이 필요한 경우도 있다.

  • read(from:Data, ofType: String) 대신에 read(from: FileWrapper, ofType: String)를 쓸 수 있다. 특히 문서가 개별 파일로 나타낼 수 있는 타입들의 복합체인 경우 (아카이빙 + 이미지 파일들)에는 하나의 큰 파일을 유지하는 것보다는 문서를 패키지로 만드는 편이 빠르고 효율적으로 변경할 수 있을 것이다. (변경이 발생한 파일만 새로 고치면 되니까) 마찬가지로 이 경우에는 write(to:URL, ofType: String) 이나 fileWrapper(ofType:) 같은 메소드들을 저장을 위해 오버라이드 해야 한다.
  • 문서가 매우 큰 데이터 셋이라면 한꺼번에 읽어들이기보다는 패키지로 만들어서 쪼개어 차등적으로 로딩하는 것이 좋다. 혹은 코어데이터를 쓰는 것도 나쁘지 않은 대안이다. (이 경우 NSPersistentDocument를 쓰는데 코어데이터와 관련된 거의 모든 동작을 다 구현해주고 있다. managedObjectContext만 가져다가 잘 쓰면 그만.