Wireframe

(Cocoa | Swift) 문서기반 앱

NSDocument

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

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

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

도큐먼트

문서는 기본적으로 파일(혹은 번들)에 저장되는 정보들을 담는 컨테이너이다. 문서의 내용이 되는 데이터는 미리 정의된 데이터 구조로 모델링되어 있다. 사용자의 시점에서 문서는 앱에서 작성하고 편집되는 정보들이며 이는 NSDocument 혹은 그 서브 클래스의 인스턴스로 표현된다.
NSDocument 및 Xcode의 문서기반 앱 프로젝트 템플릿은 문서 기반 앱을 쉽게 만들 수 있게끔 많은 공통기능들을 미리 구현해두었고, 이를 통해서 다음과 같은 기능들을 거의 자동으로 지원하게 된다.

도큐먼트 기반 앱의 구조

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

도큐먼트 컨트롤러

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

도큐먼트

도큐먼트는 사용자 문서 내의 데이터를 관리하는 역할을 담당한다. 앱마다 사용하는 데이터의 모델이 다르고, 또 도큐먼트 자신은 스스로의 데이터를 저장하고 읽어오는 방법을 알고 있어야 하기 때문에 모든 프로젝트는 NSDocument를 서브 클래싱하여 자신만의 도큐먼트를 작성해야 한다.
도큐먼트 클래스를 디자인할 때는 데이터처리와 관련된 부분과 시각화 부분을 세심하게 고려하여 잘 분리하는 것이 중요하다. 도큐먼트는 MVC 패턴에서 모델 컨트롤러의 역할을 담당한다. 여기에는 다음과 같은 작업을 수행하는 일이 포함된다.

윈도 컨트롤러

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

윈도 컨트롤러는 도큐먼트 클래스가 직접 생성하기 보다는 nib 파일을 통해서 정의되고 만들어진다. 따라서 도큐먼트와 윈도 컨트롤러는 서로 의존하지 않는다.

 

서브클래싱

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

UTI 세팅

UTI란 macOS에서 특정한 도큐먼트의 데이터 포맷과 타입을 정의하는 식별자이다. 기본적으로 OS에서 정의해놓은 타입들 (범용 데이터 포맷이나 Apple이 정의해놓은 타입들)이 있는데, 만약 여러분의 앱이 고유한 별도의 포맷을 사용하게 된다면, 자신만의 UTI를 정의해야 한다. 도큐먼트 타입 정보에는 UTI 문자열과 사용하는 파일의 확장자, 해당 UTI가 적용되는 문서의 클래스, 아이콘 정보등이 수록된다. 서드파티 앱이나 플러그인에서 해당 데이터를 사용하게 하고 싶다면, 추가적으로 이를 Exported UTI로 정의해야 한다. 예를 들어 파인더에서 해당 문서 파일을 더블 클릭하여 앱을 실행하고 싶다면 Exported UTI를 프로젝트 세팅에서 설정해주어야 한다.
이러한 정보는 Info.plist 파일에 저장된다.
참고로 이 식별 과정은 Objective-C 런타임에서 해석되므로, Swift로 NSDocument 의 서브 클래스를 만드는 경우에는 @objc(클래스이름) 변경자를 명시하여 정확한 클래스명을 찾을 수 있도록 한다.

도큐먼트 클래스 만들기

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

특히 문서를 읽고 쓰는 기능을 구현하는 것이 가장 기본적으로 필요한 기능이다. 이는 상황에 따라서 어떤 메소드를 구현해야 하는 것이 달라질 수 있는데, 기본적으로는 read(from:ofType:)data(ofType:)이다. 이 메소드들은 문서의 데이터를 직렬화/역직렬화한다. 그 외의 로딩이나 저장에 관한 메소드가 NSDocument 클래스에서 찾아볼 수 없는 이유는, 실제로 디스크를 액세스하는 부분은 자동으로 처리된다.

읽어들인 데이터로 문서 구성하기 – read(from:ofType:)

read(from:ofType:)Data 객체와 타입정보 (UTI 문자열)를 받아서 주어진 데이터로부터 UTI가 지정한 타입(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
}

만약 문서의 콘텐츠정보가 별도의 클래스로 구성되어 있다면, Data로부터 해당 클래스를 복원해야 한다. 보통은 모델 클래스를 NSCoding을 따르도록 한 다음, 다음과 같이  표준 keyed archiver를 사용할 것이다.

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:)등을 대신 오버라이딩한다. 이름에서 알 수 있듯이 데이터로 바꿔서 URL에 기록하는 부분까지를 직접 구현하면 된다.
읽기와 마찬가지로 canAsynchronouslyWrite(to:ofType:for:)를 오버라이딩하여 특정 타입의 도큐먼트는 디스크에 비동기식으로 쓰도록 하는 것도 가능하다.

문서의 초기화

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

그 외의 서브 클래싱

윈도우 컨트롤러 생성

NSDocument는 윈도우 컨트롤러를 자동으로 생성하는데, 만약 nib 파일로부터 윈도우를 로딩할거라면 windowNibName 메소드를 오버라이드한다.(Xcode9 기준으로 문서기반 앱을 시작하면 이 메소드가 기본적으로 오버라이딩되어 있다.) 만약 커스텀 타입의 윈도 컨트롤러를 사용하고 싶다면 makeWindowControllers를 이용해서 생성하자. 특히 이 메소드 내에서 윈도우 컨트롤러를 생성했다면 반드시 addWindowController:를 호출해서 문서가 윈도 컨트롤러를 소유하게(강한 참조를 갖도록)해야 한다. 안그러면 ARC에 의해서 파괴된다.

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

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

물론, 가장 맘편한 방법은 Document.xib 파일 안에 윈도 컨트롤러를 만들어 놓고 클래스를 지정해줘버리는 것이다.

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

windowControllerWillLoadNib:, windowControllerDidLoadNib:은 윈도 컨트롤러가 별도의 nib 파일에 정의되어 있을 때, 자신의 nib 파일을 읽고나서 도큐먼트에게 보고하는 메소드이다.  컨트롤러가 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으로 바뀐다. 또한 자동 저장 중에서도 버전을 바꿔서 저장하기도 한다. 이후에 사용자는 보고 있거나 편집중인 문서에 대해서 저장된 모든 버전을 브라우징하면서 이전 시점의 버전 어느것으로 자유롭게 돌아갈 수 있다. (혹은 이전 버전으로부터 사본을 생성할 수도 있다.) 버전으로 관리되는 문서 아키텍처가 제공하는 편리함 중의 하나는 앱의 상태저장과 되돌리기를 공짜로 얻을 수 있다는 점이다. 문서를 연 상태에서 앱을 종료한 후에 다시 앱을 시작하면 앱은 최종적으로 편집하던 문서를 열고, 창의 크기나 위치까지 그대로 복원한다. 그외에 타임머신처럼 이전 버전들을 비교하면서 이전의 콘텐츠 리비전으로 언제든지 되돌아갈 수 있다.

대안적 디자인

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

 

Exit mobile version