Swift4의 키패스 표현

키패스는 어떤 객체의 프로퍼티 혹은 프로퍼티의 프로퍼티 체인에 대해서 그 이름을 통해서 값을 찾아나가는 표현을 말한다. Objective-C에서 키패스는 키패스 경로를 나타내는 문자열을 사용해서 특정 객체의 속성을 액세스하기 때문에 컴파일 타임이 아닌 런타임에 액세스해야 할 프로퍼티를 결정하는 동적 기능으로 키밸류코딩과 키밸류 옵저빙에 사용된다. Swift2까지는 Swift 내에 키패스에 대한 기능이 별도로 마련되지 않았고, NSObject의 value(forKey:)setValue(_:forKey:)를 사용하면서 문자열을 그대로 사용했다.

문자열을 통해서 키패스를 사용하는 것은 편리할 수는 있으나, 컴파일 타임에서 오타에 의해 존재하지 않는 키패스를 참조하는 것을 체크할 방법이 없어서 디버깅이 곤란한 부분이 있었다. 이 부분을 개선하기 위해 Swift3에서 #keyPath() 문법 표현이 추가되었는데, 이 문법은 코딩 시점에는 컴파일러의 도움을 받아 올바른 키패스를 확인할 수 있고, #keyPath() 표현을 통해 해당 키패스값을 문자열로 안전하게 변환할 수 있었다.

하지만 키패스를 문자열로 치환하는 이와 같은 방법은 Swift의 디자인 관점에서는 몇 가지 한계를 갖는다. 키패스 자체는 프로퍼티를 찾아가는 경로만을 정의하므로 타입 정보를 잃고 그 결과가 Any가 되어버린다든지, 파싱이 느리고 NSObject 기반의 클래스에서만 사용할 수 있었다. Swift4에서는 이러한 단점을 보완하고 클래스외의 모든 Swift 타입에서 키패스를 통해서 프로퍼티를 참조할 수 있는 범용적인 키패스 문법(과 키패스를 위한 코어 타입)이 추가되었다.

Swift4의 키패스 문법

Swift4의 키패스 문법은 단순히 백슬래시(\)로 시작하는 키패스 값을 말한다. Objective-C와 달리 self 대신에 타입 이름을 쓰거나, 타입이 분명한 경우, 타입 이름을 생략하고 바로 . 으로 시작하는 키패스를 사용할 수 있다. 다음은 Swift Evolution의 새로운 키패스 제안서에 실린 예제이다.

class Person {
  var name: String
  var friends: [Person] = []
  var bestFriend: Person? = nil
  init(name: String) {
    self.name = name
  }
}

var han = Person(name: "Han Solo")
var luke = Person(name: "Luke Skywalker")
luke.friends.append(han)

// 키패스 객체를 생성한다. 
let firstFriendsNameKeyPath = \Person.friends[0].name
// 생성한 키패스를 사용해서 프로퍼티를 액세스한다.
let firstFriend = luke[keyPath: firstFriendsNameKeyPath] // "Han Solo"

// 항상 . 으로 시작해야 한다. 이는 배열의 요소 참조시에도 마찬가지이다.
luke.friends[keyPath: \.[0].name]
luke.friends[keyPath: \[Person].[0].name] 

// 옵셔널 프로퍼티는 ?를 붙여서 액세스해야 한다.
let bestFriendsNameKeyPath = \Person.bestFriend?.name
let bestFriendsName = luke[Keypath: bestFriendsNameKeyPath] // nil

키 패스 타입

Swift4의 키패스는 KeyPath라는 타입에 의해서 관리된다. 이 타입은 하위 속성을 참조하기 위해서 다른 키패스를 이어 붙이는 것이 가능하고, 또한 루트 타입과 키패스가 가리키는 속성의 타입을 그 인자로 가질 수 있다. 이 말은 위 예제에서와 같은 표현으로 키패스를 생성하는 경우, luke의 타입인 Personname 속성의 타입인 String 에 대한 정보가 키패스 내부에 내제된다는 것이다. 따라서 위 예제에서 bestFriend 변수의 타입은 String이 라는 점을 컴파일러는 알 수 있다.

코코아 앱 따라하기 – 3

지난 시간까지 macOS용 TTS앱을 간단하게 구현하고, 현재 상태에 따라 UI를 업데이트하도록 다듬고, 테이블 뷰를 사용하는 등의 기본적인 앱 구현에 관한 내용을 살펴보았다. 인터페이스 빌더와 코드 편집기를 오가며 앱을 만드는 방법에 있어서 macOS앱이나 iOS앱은 큰 차이가 없는 듯 보였다. 이번 시간에는 macOS용 앱을 만들 때만 사용 가능한 코코아 바인딩이라는 기술을 통해서 이 앱을 처음부터 새로 작성하는 과정을 살펴볼 것이다.

이전 글들에서 아웃렛과 액션을 통해서 컨트롤러와 뷰사이를 연결하는 작업을 해본 바 있다. UI 컨트롤을 사용자가 조작했을 때, 어떠한 동작을 실행하고 싶다면 컨트롤러에 IBAction 이라는 힌트가 붙은 메소드를 작성하고 이를 인터페이스 빌더에서 연결해야 한다. 반대로 컨트롤러가 특정한 UI 요소의 값을 액세스하거나 조작하고 싶다면 IBOutlet이라는 힌트가 붙은 프로퍼티를 만들어서 코드상에서 이를 제어할 수 있게 된다.

컨트롤러는 모델의 데이터와 뷰 사이를 연결하는 일을 담당하는 위치에 있고, 대부분 이 일은 뷰와 모델을 연결하는 단순한 접착코드(glue code)인 경우가 많다. iOS와 달리 macOS앱은 그 환경이 환경인만큼 화면 상에 상당히 많은 UI 요소가 탑재될 수 있는데, 그에 대해서 일일이 이러한 코드를 양방향으로 작성해주는 것은 매우 지루하고 고단한 일이 될 수 있다. 코코아 바인딩은 이러한 코드를 작성하지 않고 UI 컨트롤에 제공되는 바인딩 요소를 인터페이스 빌더에서 설정하는 것으로 뷰의 상태와 데이터 값을 양방향으로 동기화할 수 있게 해주어, 단순한 상태값 변경에 관한 코드 대부분을 제거할 수 있다.

프로젝트를 제거하고 처음부터 새로 만들어볼 것이다. 새 프로젝트를 시작하고 앱 컨트롤러 클래스를 추가하는 것 까지는 지난 글과 동일한 과정을 따른다.  앱 컨트롤러 클래스를 프로젝트에 추가하였으면 해당 파일을 열고 편집을 시작하자.

앱 컨트롤러 만들기

앱 컨트롤러의 인터페이스는 기존과 같이 Foundation 대신에 Cocoa를 사용하도록 수정한다. 그리고 테이블 뷰와 관련된 프로토콜은 따르지 않아도 상관없다. 테이블 뷰를 다루는 모든 조작은 인터페이스 빌더 상에서 일어날 것이다.

헤더 편집

#import <Cocoa/Cocoa.h>

@interface AppController: NSObject <NSSpeechSynthesizerDelegate>
@end

액션 메소드와 프로퍼티 정의

AppController.m 파일로 이동해서, 내부 인터페이스를 아래와 같이 작성한다. 버튼이나 텍스트 필드에 대한 아웃렛은 모두 제거된다. 대신에 앱 컨트롤러는 사용자가 입력한 텍스트를 저장하는 words라는 NSString 타입 프로퍼티를 하나 가져야 한다. 그리고 UI의 상태를 좌우할 canSpeak라는 프로퍼티도 갖는다. 그외에 테이블 뷰의 선택 영역 정보를 동기화할 selectionIndexes라는 프로퍼티도 추가로 선언한다. 한꺼번에 써서 많아 보이지만, 실제로 지난 버전과 크게 다르지 않은 분량이다.

#import "AppController.h"

@interface AppController ()
@property (strong, nonatomic) NSSpeechSynthesizer* speech;
@property (copy, nonatomic) NSString* words;
@property (nonatomic) BOOL canSpeak;
@property (copy, nonatomic) NSIndexSet* selectionIndexes;
@property (weak, nonatomic) IBOutlet NSTableView* tableView;
@property (strong, nonatomic) NSArray* allVoices;
- (IBAction)startSpeaking:(id)sender;
- (IBAction)stop:(id)sender;
@end

선언한 프로퍼티들 실제로 초기화 코드가 필요한 것은 speechallVoices 뿐인데, 그 코드는 기존의 것과 동일하다. 참고로 이번 버전에서는 목소리의 이름만 뽑아낸 별도의 리스트를 쓰지 않을 예정인데, 어떻게 할건지는 조금 나중에 설명하겠다.

#pragma mark - Properties
- (NSSpeechSynthesizer*)speech
{
  if(!_speech) {
    _speech = [[NSSpeechSynthesizer alloc] init];
    _speech.delegate = self;
  }
  return _speech;
}

- (NSArray*)allVoices
{
  if(!_allVoices) {
    _allVoices= [[NSSpeechSynthesizer availableVoices] copy];
  }
  return _allVoices;
}

선택영역

selectionIndexes는 테이블 뷰에서 선택된 항목의 행번호를 가리키기 위한 프로퍼티이다. 이후에 테이블 뷰에서 “선택이 변경되면” 이전 버전과 달리 이 값이 자동으로 변경되게 될텐데, 이 때 선택한 행에 대응하는 목소리로 변경해야 한다. setter를 다음과 같이 커스텀한다. 내용은 현재값과 다른 인덱스 값으로 바뀌려한다면 목소리를 그에 대응하는 것으로 교체해주는 부분을 담고 있다. 바인딩된 객체의 요소값이 변경되면 이 값이 setter를 호출하는 방식을 통해서 변경될 것이므로, 이 때 목소리를 바꾸게 된다.

- (void)setSelectionIndexes:(NSIndexSet*)selectionIndexes
{
  if(![_selectionIndexes isEqualToIndexSet:selectionIndexes) {
    _selectionIndexes = [selectionIndexes copy];
    [self.speech setVoice:[self.allVoicesobjectAtIndex:[_selectionIndexes firstIndex]]];
  }
}

그리고 canSpeak는 start 버튼을 누를 수 있는지를 나타내는 값이다. 지난 버전에서 각 버튼이나 텍스트 필드들의 활성화여부는 특정 액션 및 이벤트에서 모두 개별적으로 컨트롤했다. 이것을 컨트롤러의 상태값이 각 UI 컨트롤의 상태값에 반영되도록 할 것이다. 이렇게해서 코드 군데군데에서 UI 요소들의 enabled 상태값을 변경하는 코드와 많은 아웃렛들을 배제할 수 있게 되었다.

액션 메소드들

이 상태가 변하는 것은 읽기를 시작하는 시점과 읽기가 끝나는 (혹은 중지되는) 시점이다. 따라서 -startSpeaking:-stop: 은 다음과 같이 수정하면 된다. 버튼의 상태가 아닌 canSpeak 값을 조작하는 것으로 정리된다.

#pragma mark - Actions
- (void)startSpeaking:(id)sender
{
  if([self.words length] > 0) {
    [self.speech startSpeakString:self.words];
    self.canSpeak = NO;
  }
}

- (void)stop:(id)sender
{
  [self.speech stopSpeaking];
}

# pragma mark - NSSpeechSynthesizer Delegate
- (void)speechSynthesizerDidFinishSpeaking:(id)sender
{
  self.canSpeak = YES;
}

포매터 추가하기

목소리 정보는 macOS의 식별자 형식을 취하기 때문에 사람이 알아보기 불편한 문자열로 해석된다. 따라서 이전 버전에서는 목소리를 담고있는 배열외에 목소리의 이름을 담고 있는 배열을 추가로 만들었었는데, 이번에는 목소리 식별자를 목소리 이름 문자열로 변환해주는 무언가가 필요할 것이다. 이런 비슷한 일을 하는 것으로 NSFormatter가 있는데, 이 클래스의 자식 중 하나이면서 가장 널리쓰이는 포매터인 NSNumberFormatter는 정수나 실수의 숫자값을 “숫자 문자열”로 변환해주는 장치이다. 우리는 목소리 식별자를 이용해서 그 이름 문자열을 얻으려 하고 있으니, NSFormatter를 서브 클래싱하여 VoiceNameFormatter를 작성해보자.

cmd + N 키를 눌러서 새 코코아 클래스를 추가하고 그 이름을 VoiceNameFormatter라고 한다.  이 때, 그 부모 클래스는 NSObject가 아니라 NSFormatter로 한다. (만들 때 실수했더라도 나중에 코드에서 바로잡으면 되니까 상관없다.)

아래는 해당 클래스의 소스 코드이다. 헤더에서는 Foundation 대신에 Cocoa를 임포트하도록 수정하면 되며, 구현부에서는 -stringForValueObject: 라는 메소드를 구현해주면 된다. 목소리 값으로부터 이름을 구하는 부분은 지난 버전에 사용했던 코드와 동일하다.

/// VoiceNameFormatter.h

#import <Cocoa/Cocoa.h>  // <--  수정
@interface VoiceNameFormatter: NSFormatter
@end

/// VoiceNameFormatter.m
#import "VoiceNameFormatter.h"

@implementation VoiceNameFormatter
- (NSString*)stringForObjectValue:(id)obj
{
  return [[NSSpeechSynthesizer attributesForVoice:obj] objectForKey:NSVoiceName];
}
@end

놀랍게도 이제 작성해야 할 코드의 대부분을 완료했다. 이후에 남은 부분은 테이블 뷰의 초기 상태 변경해주는 부분(지난 글에서 awakeFromNib 작성)인데, 이 부분은 뒤에 추가하기로 하고 바로 인터페이스 구성으로 넘어가도록 하겠다.

UI 구성

이제 인터페이스 빌더를 열고 AppController를 독에 추가한 후, 메인 윈도 위에 텍스트 필드와 버튼, 테이블 뷰등을 배치한다.  이 때 이전 버전과 다른 점은 다음과 같다.

  1. 텍스트 필드의 속성 중에 Countinous에 체크한다.
  2. Stop 버튼에 대해서는 Enabled 체크를 그대로 둔다. 이 상태는 canSpeak에 의존하게 될 것이다.
  3. 테이블 뷰는 1컬럼으로 조절하되, 데이터 모드는 View Based 를 그대로 유지한다.

그리고 아웃렛을 연결한다. 연결해야하는 아웃렛과 액션은 총 3개이다.

  • AppController  > NSTableView (tableView)
  • Start > AppController (-startSpeaking:)
  • Stop > AppController (-stop:)

바인딩 설정하기

이제 이 글에서 가장 중요한 부분인, UI 요소들에 대한 바인딩을 연결할 차례이다. 텍스트 필드를 선택한 상태에서 인스펙터 중, 바인딩 인스펙터(opt + cmd + 7)를 선택한 다음, value 항목을 펼쳐보자. 다음과 같은  UI가 표시될텐데, 여기서 bind to 항목에서 AppController를 선택하고, model keyPath 항목에 words를 기입한 후 엔터한다. (model keyPath 항목은 디폴트로 self라고 되어 있는데, self.words라 해도 되고, words라고만 해도 상관없다.)

그리고 아래 옵션 중에서 Continuous updates Value 에도 체크해준다. 이렇게하면 글자 하나를 타이핑하거나 삭제할 때마다 AppController의 words 값이 동기화될 수 있다.

일단 여기까지 하고 앱을 빌드하고 실행해본다.  앱 컨트롤러가 텍스트 필드에 대한 아웃렛참조를 가질 필요 없이, 텍스트 필드에 입력된 내용을 읽을 수 있음을 확인할 수 있다.

UI의 활성화 상태 동기화하기

다시 인터페이스 빌더에서 텍스트필드를 선택한 상태에서, 바인딩 중에서 Enabled 항목을 살펴보자. 이 때에도 bind to: AppController 이고, model key path는 canSpeak가 되어야 한다.

이번에는 테이블뷰와 버튼에 대해서도 동일하게 Enabled 항목을 설정한다. AppController의 canSpeak 항목에 바인딩하면 된다. 이 때 중요한 것은 Stop 버튼인데, Stop 버튼은 canSpeak가 NO일 때만 활성화되어야 한다. Enabled 바인딩 옵션 중에 하단에 Value Transformer가 있다. 여기에 선택 박스 중에서 NSNegateBoolean을 선택해준다. 이 값은 BOOL 값을 반전하여 적용한다는 것이다.

다시 앱을 빌드해보자. 어? 그런데 처음 앱이 실행된 시점에 Stop 버튼만 활성화되어 있다. 왜 그럴까? LLVM 컴파일러는 프로퍼티를 선언하면 그에 대한 backing storage 변수를 자동으로 선언하고 그 값을 NULL로 초기화한다. 이것이 객체인 경우에는 nil이 되고 그외 값은 0 혹은 NO가 된다는 의미이다. 따라서 우리는 앱 컨트롤러의 canSpeak 값을 처음에 YES로 만든 상태에서 시작해야 한다.

AppController의 초기화 메소드나 -awakeFromNib을 수정하는 방법도 있지만, 여기서는 인터페이스 빌더에서 초기 값을 설정해보자. 왼쪽 독에서 AppController를 설정한 상태에서 identity 인스펙터를 보자. 이 때 사용자 설정 런타임 속성 (User Defined Runtime Attributes) 설정하는 작은 테이블이 보일 것이다. 여기에 + 버튼을 클릭한 다음, 키 이름을 canSpeak 이라고 입력하고 타입은 BOOL을 선택한다. 여기에 체크가 되어 있으면 이 값이 YES로 설정된 상태가 된다.

다시 앱을 빌드하고 실행해보자. 모든 UI 요소의 활성/비활성 상태가 자동으로 적절하게 변경되는 것을 확인할 수 있다.

코코아 바인딩으로 테이블 뷰를 설정하기

이제 테이블 뷰에 목소리 목록이 표시되도록 만들 차례이다. 조금 전 AppController의 코드에서 봤겠지만, 델리게이트나 데이터소스에 대한 어떤 코드도 AppController에는 포함되어 있지 않다. (IBOutlet을 하나 만들었지만, 이것은 선택영역으로 스크롤하기 위해 필요한 것이다.)

테이블 뷰는 코드(데이터소스 + 델리게이트)가 아닌 바인딩으로도 데이터를 populating할 수 있다. 이미 AppController는 여기에 필요한 데이터를 가지고 있고, 그것은 allVoices라는 키로 정의되어 있다.

가장 먼저해야 할 것은 배열 컨트롤러를 독에 추가하는 것이다. 객체 팔레트에서 Array라고 검색해서 Array Controller를 선택해서 독에 추가한다. 그런다음에 identity 인스펙터에서 Label 영역에서 이름을 바꿔주자. 목소리 목록을 제어하는 역할이니 Voices Controller라고 이름을 바꾼다.

바인딩 설정하기

Voices Controller에서는 두 개의 바인딩을 연결한다.

바인딩 이름 바인딩대상(bind to:) 모델 키
Content Array AppController voices
Selection Indexes AppController selectionIndexes

위 내용은 아래의 그림과 같이 설정 된다는 의미이다.

이번에는 테이블 뷰와 Voices Controller 사이의 바인딩이다. 참고로 바인딩을 연결하는 대상이 컨트롤러인 경우에는 모델 키가 아닌 컨트롤러 키에 키 이름을 입력해야 한다.

바인딩 이름 바인딩 대상(bind to:) 컨트롤러 키
Contents Voices Controller arrangedObjects
Selection Indexes Voices Controller selectionIndexes

이렇게 한다고 해서 당장 빌드하고 실행해보면 테이블 뷰에 내용이 나타나지는 않는다. 왜냐하면 테이블 내의 각 셀의 뷰가 표시해야 할 내용을 아직 받지 못했기 때문이다. 셀 뷰 내에는 기본적으로 NSTextField가 하나 포함되는데, 이 텍스트 필드가 셀 뷰의 objectValue값을 표현하도록 텍스트 필드와 테이블 셀 뷰 사이의 바인딩을 만들어야한다. 따라서 독에서 테이블 뷰 하위의 단계를 내려가서 Table Cell View 아래에 있는 Text Field를 선택한다. 바인딩 대상은 Table Cell View를 하고 모델 키는 objectValue를 쓰면 된다.

여기까지의 연결 상태를 보자. 테이블 뷰는 Voice Controller를 통해서 다시 AppController.voices의 값을 그 콘텐츠로 연결하고, 각 행의 셀 뷰에 대해서 대응하는 인덱스의 객체값을 분배해줄 것이다. 그리고 테이블 뷰에서 행을 선택해서 선택 영역을 변경하면, 이 역시 Voice Controller를 통해서 AppController.selectionIndexes를 업데이트할 것이다. 그리고 이때의 setter에 의해서 목소리가 변경될 것이다.

앱을 빌드하고 실행하기 전에, 테이블 뷰에서 표시될 내용에 포매터를 추가해야 한다. 객체 팔레트로부터 Formatter를 골라서 테이블 뷰 위의 텍스트 필드에 추가하자. 이 때에는 캔버스 상에서 하면 자동으로 텍스트 필드에 매칭이 될 것이다. (잘 모르겠으면 역시 독으로 가져가서 TextField > TextField Cell 아래에 놓자.) 포매터를 선택한 상태에서 Identity 인스펙터에서 그 클래스를 VoiceNameFormatter로 변경해준다.

이제 앱을 빌드하고 실행해보자. 실행에 성공했다면 거의 다온 것이다. 첫 서두에서 구현하지 않았던 마무리 작업을 마저 하러 AppController.m 파일로 이동할 차례이다.

마무리

이제 마무리 단계로, 앞선 버전과 같이 실행 시에 디폴트 목소리가 선택된 상태로 표시되도록 한다. -awakeFromNib을 다음과 같이 추가해준다. 이전버전과 같이 디폴트 목소리를 구하고 그 인덱스로 selectionIndexes 값을 만들어주면 된다. 그러면 바인딩에 의해 테이블 뷰가 해당 행을 선택한 상태가 되고, 스크롤만 더해주면 끝이다.

#pragma mark - ETC
- (void)awakeFromNib
{
  NSInteger i = [self.voices indexOfObject:[NSSpeechSynthesizer defaultVoice]];
  self.selectionIndexes = [NSIndexSet indexSetWithIndex:i];
  [self.tableView scrollRowToVisible:i];
}

이제 빌드하고 실행해보자. 이전 버전보다 간결한 코드를 사용해서 UI의 상태를 적절하게 업데이트하고 테이블 뷰의 데이터를 제대로 표시하는 부분까지 코드 없이 해냈다. 이처럼 코코아 바인딩을 사용하면 UI 컨트롤과 모델의 값 속성을 양방향으로 연결하고 한쪽에서 변경이 발생할 때 다른 한 쪽이 함께 변경되도록 할 수 있는데, 어느 수준까지 코코아 바인딩을 사용할 것인지는 본인의 선택에 달렸다. 간단한 UI 글루 코드를 없애는 수준에서만 사용해도 되며, 코코아 바인딩을 사용한다고 해서 아웃렛을 쓰지 말라는 법도 없으니 적절한 수준에서 사용하면 된다. 물론 이 글에서는 코코아 바인딩을 최대한 많이 써서 이렇게 할 수 있다는 것만 보였는데, 코코아 바인딩은 설정에 실수가 있으면 가차없이 앱이 실행조차 되지 않기 때문에 디버깅이 매우 번거롭다는 단점은 있다.

참고자료

 

코코아 앱 따라하기 – 2

지난 글에서 만든 TTS 앱을 조금 개선해보자. 이 앱에는 Start, Stop 두 개의 버튼이 있다. 그런데 두 버튼은 모두 활성화된 상태가 된다. 따라서 앱이 텍스트를 읽고 있지 않은 경우에도 stop 버튼을 클릭할 수 있고, 또 앱이 텍스트를 읽고 있는 중간에 start 버튼을 반복적으로 클릭할 수 있게 된다.

이 상황을 멋지게 해결하기 위해서는 버튼의 활성화 상태를 그 때 그 때 바꿔주는 것이다. 즉 앱이 읽는 중간에는 start 버튼이 비활성화 되어야 하고, 앱이 읽고 있지 않을 때에는 반대로 stop 버튼이 비활성화 되는 것이다.

버튼의 활성화 상태

버튼의 활성화 상태를 변경하는 방법에는 두 가지 방법이 있다.  우리가 앱에 추가한 Push Button은 NSButton 객체이다. 버튼 클래스는 enabled라는 프로퍼티를 가지고 있다.(BOOL 타입이다.) 따라서 -setEnabled: 를 호출해서 코드 상에서 활성화 상태를 변경하거나, 인터페이스 빌더의 속성 인스펙터에서 Enabled 값을 변경해줄 수 있다. 단, 인터페이스 빌더에서의 조작은 앱의 초기 상태만 결정할 뿐, 런타임에 동적으로 속성값을 변경하기 위해서는 아웃렛을 통한 프로퍼티 액세스를 해야한다.

stop 버튼의 초기상태

일단 stop 버튼의 초기 상태는 비활성화 상태여야 한다. 프로젝트를 열고 MainMenu.xib 파일을 선택하자. stop 버튼을 클릭하고 오른쪽 인스펙터에서 그림과 같이 속성 인스펙터 탭을 선택하고 Enabled 값을 체크 해제한다.

이 변경의 효과는 인터페이스 빌더 내에서 바로 확인할 수 있다. 그리고 AppController의 코드 상에서 이 값을 제어하기 위해서는 무엇이 필요할까? 바로 해당 클래스 내에서 두 버튼에 대한 참조점, 즉 아웃렛이 필요하다.

AppController.m 파일로 가서 @interface 부분에, 이전 시간에 했던 것과 같이 두 개의 NSButton에 대한 IBOutlet 을 정의한다.

@property (weak, nonatomic) IBOutlet NSButton* startButton; @property (weak, nonatomic) IBOutlet NSButton* stopButton;

그리고 -startSpeaking:-stopSpeaking:이 호출될 때 각각의 현재 상황에 맞게 버튼의 활성화 여부를 업데이트한다.

- (IBAction)startSpeaking:(id)sender
{
  NSString *words = [self.field stringValue];
  if([words length] > 0) {
    [self.speech startSpeaking:words];
    [self.startButton setEnabled:NO];
    [self.stopButton setEnabled:YES];
  }
}

- (IBAction)stopSpeaking:(id)sender
{
  [self.speech stopSpeaking];
  [self.startButton setEnabled:YES];
  [self.stopButton setEnabled:NO];
}

그리고, 잊지 말아야 할 것. 새로 추가한 아웃렛이 있으면 반드시 인터페이스 빌더에서 연결하는 처리를 해야한다. 연결하는 방법은 지난 시간에 언급했으니 생략하겠다. 연결까지 마무리했다면 다시 빌드하고 실행해보자.

읽기가 끝나는 시점을 알아내기

문제가 있다. 텍스트를 입력하고  Start 버튼을 누르면, 의도했던 대로 두 버튼의 상태가 변경되면서 음성이 흘러나온다. 그런데 문제는 stop  버튼을 클릭하지 않으면 읽기가 끝나도 버튼의 상태가 원래대로 돌아오지 않는 것이다.

음성합성기가 읽기를 끝내는 시점에 AppController에게 무언가 메시지를 보내어 읽기가 끝났다는 것을 알려주어야 할 것이다. 그런데 음성 합성기는 애플이 만든 클래스이고, 이 클래스는 우리가 만든 AppController라는 클래스를 본적도 없을 것이다. 그러면 어떻게 이 문제를 해결할 수 있을까?

애플은 이런 문제를 해결할 수 있도록 델리게이트 패턴1을 사용하라고 알려준다. 음성합성기인 NSSpeechSynthesizer 클래스도 delegate라는 프로퍼티를 가지고 있다. 이 프로퍼티의 타입은 id<NSSynthesizerDelegate> 인데, id는 타입에 무관한 클래스를 의미하므로, “타입이 무엇이든 NSSynthesizerDelegate라는 프로토콜을 따르고 있는 클래스“이면 된다는 것이다. 프로토콜은 실제 구현없이, 어떠한 메소드를 가지고 있을 것이라는 약속이므로 음성합성기는 그 자신의 델리게이트의 구체적인 클래스를 알지 못하더라도 특정한 이벤트가 발생하는 시점에 델리게이트에게 메시지를 보내는 것이다.

NSSpeechSynthesizerDelegate에는 음성 합성기가 말하기를 완료하거나 중단했을 때 호출받는 -speechSynthesizer:didFinishSpeaking: 이라는 메소드가 정의되어 있다.  AppController가  이 프로토콜을 따르도록 하고 해당 메소드를 구현하여 이 문제를 해결해 보자.

먼저 AppController.h 파일을 열고 다음과 같이 클래스 선언 부분 뒤에 <NSSpeechSynthesizerDelegate>를 추가해준다.

@interface AppController: NSObject <NSSpeechSynthesizerDelegate>
//                                 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

그리고 다시 AppController.m 파일로 돌아와서, @implementation 블럭 안에 다음과 같이 해당 메소드를 구현해주면 된다.

#pragma mark - NSSpeechSynthesizerDelegate
- (void)speechSynthesizer:(NSSpeechSynthesizer*)sender 
        didFinishSpeaking:(BOOL)finishedSpeaking
{
  // 이 때 finishedSpeaking이 YES이면 완료, NO이면 중단인데
  // 여기서는 굳이 따질 필요가 없다.
  [self.startButton setEnabled:YES];
  [self.stopButton setEnabled:NO];
}

그리고 이 메소드를 작성했다면 -stopSpeaking:에서 버튼의 활성화 여부를 조정하기 위해 추가한 코드는 삭제하도록 한다. 이제 다시 앱을 빌드하고 실행해보자.  stop버튼을 눌러서 중단하거나, 읽기가 끝났을 때에는 항상 위 델리게이트 메소드를 호출받기 때문에 항상 자동으로 버튼의 상태가 원래대로 돌아오게 된다.

목소리 변경하기

목소리를 변경하는 기능을 어떻게 넣을 것인지를 상상해보자. 예를 들어 기존에 만들어 놓은 UI 하단에 목소리 리스트가 쭉 표시되고, 여기서 원하는 목소리를 클릭한 후에 Start 버튼을 클릭하면 그 목소리로 텍스트를 읽어주도록 하는 것이다. 목소리의 리스트를 표시하고, 특정한 행을 클릭했을 때 목소리를 바꾸는 일 역시 테이블 뷰(NSTableView)를 사용하면 구현할 수 있다. 그리고 테이블 뷰는 조금 전 사용한 델리게이트 패턴을 적극적으로 사용하는 코코아 컴포넌트의 전형적인 예이다.

이 기능을 추가하기 위해서 사용가능한 목소리의 목록을 얻어야 하고, 또 음성 합성기의 목소리를 변경하는 기능에 대해서도 조사해보아야 한다. NSSpeechSynthesizer의 클래스 레퍼런스 문서를 보면 다음의 두 메소드를 찾을 수 있다.

  • +availableVoices :: 사용가능한 모든 목소리의 식별자를 NSArray 타입으로 반환한다.
  • -setVoice: :: 목소리를 주어진 식별자의 것으로 변경한다.

이 때 주의해야 할 점은 목소리의 식별자는 내부적으로 문자열이기는 하지만, 범용 식별자 타입으로 macOS의 환경 설정에서 볼 수 있는 목소리 이름으로 표시되지는 않는다. 목소리의 식별자로부터 해당 목소리의 이름을 구하기 위해서는 +attributesForVoice: 를 사용해서 해당 식별자의 목소리의 속성이 담긴 사전을 얻고, 여기에서 NSVoiceName 이라는 키를 사용해서 목소리의 표시용 이름을 얻을 수 있다.

이러한 정보들은 모두 해당 클래스 레퍼런스를 통해서 알 수 있는 것들이니, 가능하면 직접 레퍼런스 문서를 항상 찾아보는 습관을 들이도록 하자.

사용 가능한 목소리의 리스트

AppController에 사용가능한 전체 목소리의 리스트를 참조하는 allVoices 라는 NSArray 타입의 프로퍼티를 하나 만들도록 한다. 그리고 표시용 이름을 위한 별도의 리스트 프로퍼티를 하나 만들도록 하자. AppController.m 의 @interface 부분에 다음을 추가한다.

이 부분은 사실 만드는 사람 마음인데, 테이블 뷰 데이터 소스 메소드에서 매번 표시용 이름을 만들어주어도 되고, 미리 표시용 이름 리스트를 만들어주어도 된다.

@property (copy, nonatomic) NSArray* allVoices;
@property (copy, nonatomci) NSArray<NSString*>* voiceNames;

각각의 프로퍼티의 초기화 코드는 다음과 같다.

- (NSArray*) allVoices
{
  if(!_allVoices) {
    _allVoices = [NSSpeechSynthesizer availableVoices];
  }
  return _allVoices;
}

- (NSArray<NSString*>*) voiceNames
{
  if(!_voiceNames) {
    NSMutableArray *vs = [NSMutableArray arrayWithCapacity:[self.voices count]];
    for (id voice in self.allVoices) {
      [vs addObject:[[NSSpeechSynthesizer attributesForVoice: voice] objectForKey:NSVoiceName]];
    }
    _voiceNames = [vs copy];
  }
  return _voiceNames;
}

테이블 뷰의 데이터소스/델리게이트

이제 AppController를 테이블 뷰의 데이터소스와 델리게이트가 되도록 준비하자. 먼저 AppController.h 파일로 이동해서 클래스 정의 부분에 NSSpeechSynthesizerDelegate외에 NSTableViewDataSource와 NSTableViewDelegate 프로토콜을 추가한다. 상속과 달리 프로토콜은 하나의 클래스가 여러 개의 프로토콜을 따르는 것이 가능하며, < … > 속에 각 프로토콜을 컴마로 구분해서 넣어주면 된다.

/// AppController.h
@interface AppController : NSObject <NSSpeechSynthesizer, 
  NSTableViewDataSource, NSTableViewDelegate>

데이터소스 필수 메소드

데이터소스는 테이블 뷰의 각 셀에 표시될 데이터를 제공해주는데 필요한 메소드들을 정의하고 있다. 여기에는 여러 메소드들이 있지만, 필수적으로 다음 두 개의 메소드가 필요하다. 참고로, NSTableView는 iOS의 UITableView와 그 원리는 거의 비슷한데 몇 가지 다른 점이 있다. 기본적으로 하나의 테이블에 여러 개의 칼럼이 존재할 수 있고, 각 셀을 구성하는 뷰가 아닌, 셀의 데이터가 표시해야 하는 값을 제공한다.

현재는 NSTableView도 UITableView처럼 각각의 셀을 뷰 기반으로 구성할 수 있다. 이 때 iOS와 같이 각 셀을 표현할 뷰를 제공하는 역할은 데이터소스가 아닌 델리게이트가 담당한다.

전통적으로 NSTableView는 레이아웃 용도가 아닌 엑셀과 비슷한 값 위주의 표를 구현하는데, 사용되어 왔음을 염두에 두자. 이 예제에서는 뷰 기반이 아닌 셀 기반의 테이블 뷰를 사용할 것이다.

  • -numberOfRowsInTableView:  : 테이블 뷰 내의 행의 수를 결정한다.
  • -tableView:objectValueForTableColumn:row: :각 열과 행에서 표시할 값을 제공한다.

이제, 테이블 뷰 데이터소스 메소드들을 작성해보자. 테이블 뷰의 행 수는 allVoices 배열의 원소의 개수와 같으며, 여기서 사용할 테이블 뷰는 단일 행이므로 칼럼에 상관없이 row에 맞춰서 해당 위치의 voiceNames 원소를 리턴하면 된다.

#pragma mark - TableView DataSource

- (NSInteger)numberOfRowsInTableView:(NSTableView*)tableView
{
  return [self.allVoices count];
}

- (id)tableView:(NSTableView*)tableView
  objectValueForTableColumn:(NSTableColumn*)tableColumn
  row:(NSInteger)row
{
  return [self.voiceNames objectAtIndex:row];
}

이제 화면에 테이블 뷰를 추가해보자. 인터페이스 빌더에서 윈도 크기를 아래로 늘리고 테이블 뷰를 하나 윈도 위로 드래그해서 추가한다.

위와 같이 대략적인 크기를 조절한 다음에, 테이블 뷰를 선택한 상태에서 속성 인스펙터를 보자. 이 때 주의해야할 점이 있다. 기술적으로 테이블 뷰는 스크롤뷰와 클립뷰 내부에 들어있는 상태이다. 따라서 테이블 뷰를 선택하기 위해서는 테이블 뷰 영역을 클릭하여 선택하는 것으로는 (클릭을 해 나가면서 그 내부의 요소를 점차 선택하게 되지만 알아보기 힘들다.) 조작이 어렵기 때문에 화면 왼쪽의 독을 늘려서 독에서 계층 구조를 통해서 테이블 뷰를 정확하게 선택해야 한다.

테이블 뷰를 제대로 선택했다면 속성 인스펙터는 아래와 같이 보일 것이다. 이 중에서 Content Mode 값은 “Cell Based”로 변경하고 열의 수는 1로 조정한다. 그런 다음 테이블 뷰에서 첫 번째 열의 폭을 충분히 큰 크기로 변경해둔다.

그리고 테이블 뷰의 칼럼 헤더 부분을 더블클릭해서 레이블을 Voice라고 변경해주자.

 

이제 아웃렛연결이 필요하다. NSTableView에는 delegatedataSource가 이미 IBOutlet으로 정의되어 있다. 따라서 테이블 뷰로부터 AppController로 연결을 만들어야 한다. 이 역시 캔버스 상에서는 선택이 어려우므로 독을 이용한다. 독에서도 ctrl + 드래그를 이용해서 아래와 같이 AppController로 연결할 수 있다. 이 때 delegate와 dataSource를 모두 연결해야 하므로 두 번 연결해야 한다.

이제 앱을 빌드하고 실행해보자. macOS가 지원하는 음성의 종류가 이렇게 많았다니!하면서 깜짝 놀랄 것이다. 하지만 모든 목소리가 한국어를 말하지는 않는다. 한국어로 말하는 음성은 Yuna를 비롯한 세 개 음성이며, 그외에는 영어 및 그외 각 언어에 대응한다. (숫자를 써서 읽도록 해보면 그 목소리에 해당하는 언어로 읽는다.)

하지만 아직 목소리를 변경하는 기능은 당연히 동작하지 않는다. 어떻게 이것을 처리할 수 있을까? 테이블 뷰에서 특정한 행을 클릭하면 해당 행이 선택된다. 이 것은 테이블 뷰의 ‘선택된 행(selection)이 변경되는 사건‘이다. 테이블 뷰는 역사적인 여러 이유로 이 이벤트의 동작은 타깃 액션이 아닌 노티피케이션 기반으로 처리한다. 즉 AppController가 특정한 테이블 뷰의 델리게이트가 되면 테이블 뷰는 자동으로 자신의 델리게이트가 테이블 뷰가 발송하는 노티피케이션을 수신하도록 등록하게 된다.

따라서 테이블 뷰의 선택영역이 변경되면 델리게이트에서는 -tableViewSelectionDidChanage: 라는 메소드가 호출된다. 이 메소드의 인자는 노티피케이션 객체인데 이 객체의 object라는 프로퍼티가 바로 노티피케이션을 발송한, 즉 클릭이 일어난 테이블 뷰가 된다. 이를 이용해서 테이블 뷰를 판별하는 방법도 있지만, 보통은 AppController가 테이블 뷰에 대한 아웃렛을 가지도록 하는 것이 더 선호된다. (어차피 object 가 바로 그 테이블 뷰인지 확인하는 절차가 필요하기 때문이다.)

AppController.m 상단의 인터페이스 부분에 다음과 같이 아웃렛을 하나 더 추가한다.

@property (weak, nonatomic) NSTableView* tableView;

그리고 다음과 같이 델리게이트 메소드를 작성하자.

#pragma mark - TableView Delegate

-tableViewSelectionDidChange:(NSNotification*)notification
{
  NSInteger i = [self.tableView selectedRow];
  [self.speech setVoice:[self.allVoices objectAtIndex:i]];
}

그리고 절대 빼먹으면 안되는 과정! AppController → NSTableView 로의 아웃렛 연결이다. 조금 전과 동일하게 독에서 연결해준다. 연결까지 끝났으면 다시 빌드하고 실행해보자. 이번에는 텍스트를 입력하고 start를 클릭해보고, 다시 다른 목소리를 선택해서 start해보자. 목소리가 바뀌는 것이 확인되는가?

기본 목소리가 보이게 하기

이 앱에서 한가지 문제가 있는데, 만약 여러분의 맥이 한국어로 설정되어 있다면 기본 목소리는 Yuna가 된다. 그런데 목소리의 리스트는 기본적으로 알파벳순으로 정렬되어 있다. (참고로 이건 테이블 뷰가 자동으로 정렬해주는 것이 아니라, 처음부터 목소리 리스트가 이 순서대로 만들어져 있기 때문이다.) 문제는 다른 목소리를 선택해보기 전까지는 테이블 뷰내의 Yuna 항목은 선택되어 있지를 않다. 또, Yuna는 알파벳순으로 매우 뒤쪽에 위치하기 때문에 처음부터 보이질 않는다.

앱이 시작되었을 때 기본 목소리가 선택된 상태로 테이블 뷰 내에 보인다면 좋겠다. 이 부분을 처리해보겠다.

앱이 런칭하는 과정에 nib 파일을 로딩하는 과정이 포함된다고 했다. nib 파일 로딩이 끝나서, 해당 파일 내의 객체 그래프 복원이 완료되면, 인터페이스 빌더에서 추가된 모든 객체는 코코아 런타임으로부터 -awakeFromNib 이라는 메시지를 받게 된다.우리의 AppController 역시 인터페이스 빌더를 통해서 생성/초기화되기 때문에 화면이 만들어진 직후에 이 메소드를 호출받을 것이다. 따라서 이 메소드를 이용해서 테이블 뷰에서 기본 목소리가 선택된 상태로 있게 하고, 화면에 보이게도 해보자.

  • nib 파일 내의 모든 객체는 화면이 구성되는 시점에 -awakeFromNib 이라는 메소드를 받게 된다.
  • 음성합성기의 기본 목소리는 defaultVoice 라는 클래스 메소드에 정의되어 있다.
  • NSTableView는 -selectRowIndexes:byExtendingSelection: 이라는 메소드를 통해서 선택 영역을 코드상에서 변경할 수 있다.
  • -scrollRowToVisible: 을 통해서 n 번째 행이 화면내에 들어오도록 스크롤 할 수 있다.

이 내용을 종합해서 아래와 같은 코드를 추가해본다.

#pragma mark - ETC
- (void)awakeFromNib
{
  id currentVoice = [NSSpeechSynthesizer defaultVoice];
  NSInteger i = [self.allVoices indexOfObject:currentVoice];
  NSIndexSet *indexes = [NSIndexSet indexSetWithIndex:i];
  // 테이블뷰에서 행 선택
  [self.tableView selectRowIndexes:indexes byExtendingSelection:NO];
  // 스크롤
  [self.tableView scrollRowToVisible:i];
}

자 이제 다시 앱을 빌드하고 실행해보자. 아래와 같이 Yuna 의 목소리가 선택된 것이 보일 것이다.

여기까지해서 기본적인 코코아 앱을 만들고, 컨트롤러를 작성해서 상태에 따라 UI를 제어하는 기본적인 동작과, 테이블 뷰를 기본적인 레벨에서 구성하는 방법을 살펴보았다. 특히 델리게이트 패턴은 이렇게 기본적인 앱에서도 여러차례 등장할 만큼 중요한 패턴이라는 점도 알게되었다.

연재를 마무리하는 다음 글에서는 이 TTS앱을 다시 처음부터 재작성해 볼 것이다. 그러면서 iOS에는 없는 코코아바인딩이라는 기술을 사용해서 컨트롤러의 상태와 UI를 양방향으로 코드 없이 연결하는 방법에 대해서 살펴볼 예정이다. 코코아 바인딩을 사용하면 버튼의 상태를 제어하기 위한 메소드 호출 및 프로퍼티 선언을 생략할 수 있으며, 극단적으로는 테이블 뷰와 관련된 코드 역시 대부분 제거할 수 있음을 보일 예정이다.

 


  1. 프로토콜에 관한 글을 참고하자. 

코코아 앱 따라하기 – 1

iOS용 앱을 만드려는 사람들은  Objective-C나 Swift를 배워야 한다. Swift는 신생언어이니 좀 예외적이긴 하지만, iOS 용 앱을 만들고자 하는 사람들의 수요가 늘어나면서 국내에도 코코아터치에 관한 책들은 정말 많이 발간되었다. 다만 이런 책들은 대부분 iOS앱에 초점을 맞추다보니 사실 국내에는 제대로 된 Objective-C 용 교재나 macOS용 앱을 위한 코코아 관련 책이 거의 전무한 실정이다. 애론 힐리가스 아저씨가 쓴 코코아 프로그래밍 책이 그나마 괜찮은 편이지만, 워낙 오래된 책이라1 마운틴 라이언이 출시되면서 앱킷의 API 가 대거 변경되었는데, 이러한 내용이 반영되지 못하고 있다.

이런 좋은 책들을 대체하려는 글을 쓰려는 것은 아니고, 아주 간단한 코코아 앱을 만드는 과정을 따라가면서 macOS용 앱을 만드는 것은 iOS용 앱을 만드는 것과 어떻게 다른지를 살펴보고, 코코아 앱의 핵심 개념이나 MVC, 델리게이트와 같은 디자인 패턴들이 어떻게 적용되는지를 살펴보겠다. 이 강좌는 우선 Objective-C를 기준으로 설명되며(맥북이 너무 오래돼서 성능이 매우 좋지 않아서….) 스토리보드,  Swift 를 사용하는 버전에 대해서는 다음에 내가 하고 싶을 때 추가로 작성해 볼 생각이다.

만들어보고자 하는 앱은 아주 간단한 TTS 앱으로, 사용자가 입력한 텍스트를 소리 내어 읽어주는 앱이다. 뭔가 대단하고 화려한 기술을 펼칠 것은 아니고, 사실 코코아에서 음성합성기를 기본적으로 제공하기 때문에, 이걸 살짝 포장하는 수준의 간단한 앱이다. 우리는 이 음성합성기를 우선 가장 기본적인 구성 요소로 앱을 만들어 보고, UI를 조금 더 충실하게 다듬은 다음, 사용자가 목소리를 변경할 수 있게 하는 기능을 추가해볼 예정이다. 그리고 그 이후에는 그 때까지 만들었던 내용을 다 뒤집어 엎어서 아예 새로운 방식으로 접근하는 방법도 알아볼 것이다.

여기서 사용된 Xcode 버전은 9.0이다.

프로젝트 만들기

Xcode를 실행하고 새로운 Cocoa App 프로젝트를 생성한다. 프로젝트의 이름은 SpeechSynth-v1 으로 한다. 언어는 Objective-C 를 선택하고 스토리보드나 코어데이터를 사용하지 않을 것이며 그 외에 어떤 것도 추가할 필요가 없다. 따라서 아래와 같이 모든 선택 사항에 대해 체크를 해제한 채로 프로젝트를 생성하자.

 

기본 UI 구성 및 앱 컨트롤러 추가하기

프로젝트를 생성하면, 프로젝트 내에는 기본적으로 필요한 몇 개의 파일이 미리 생성되어 있다. 대표적으로 AppDelegate와 (Objective-C 에서 클래스 1개는 보통 헤더파일(.h)과 구현부파일(.m)이 한 쌍을 이룬다.) MainMenu.xib 라는 인터페이스 빌더용 데이터 파일이 생성되어 있다.

앱 델리게이트는 앱의 구동과 종료시에 시스템이 애플리케이션에게 결정을 요구하거나, 혹은 특정 라이프 사이클 이벤트에 대해 이벤트 핸들러 메소드를 호출하는데, 이러한 처리를 대행해주는 클래스이다. 인터넷에서 찾아 볼 수 있는 많은 튜토리얼들은 (아마도 귀찮아서 그런지) 별도의 앱 컨트롤러를 만들지 않고 델리게이트가 앱 컨트롤러 역할을 담당하는 경우가 많은데, 별로 권장하지 않는다. 우리는 여기서는 일단 앱 델리게이트는 건드리지 않을 것이다. (그렇다고 필요없는 클래스는 아니니 절대 삭제해서는 안된다.)

 

프로젝트가 시작되면 Xcode 맨 왼쪽에 프로젝트 네비게이터에서 MainMenu.xib를 선택한다. 그러면 Xcode의 화면이 인터페이스 빌더로 전환된다.

 

인터페이스 빌더의 화면 구성은 위와 같다. 맨 왼쪽에는 네비게이터 패널이 있다. 그 왼쪽에는 nib 파일에 추가되어 있는 클래스들이 표시되는 독(Dock)이 있다. 화면 가운데는 UI 요소들을 편집하는 영역이다. 화면의 오른쪽에는 인스펙터와 오브젝트 팔레트가 있다. 인스펙터는 선택된 객체의 여러 속성값들을 조절하는 곳이고, 오브젝트 팔레트는 미리 정의되어 있는 Cocoa UI 클래스들을 검색하고 드래그 앤 드롭으로 인터페이스 빌더에 추가할 수 있는 곳이다.

버튼 추가하기

기본적으로 인터페이스 빌더에는 메인 메뉴가 덜렁, 표시되는데 독을 잘 보면 아래에 window가 있다. 이것을 선택하면 메인 윈도우가 짠 하고 화면에 나타난다.

 

오른쪽 팔레트에서 Push Button을 드래그해서 윈도우 안으로 끌고 가자. 이렇게 드래그하면 반투명 파란색으로 특정 객체가 표시되는데, 지금은 윈도의 메인 뷰에 버튼이 추가된다는 것을 알려주는 것이다. 이 상태에서 버튼을 드롭하면 윈도 안에 버튼이 추가된다.

추가된 버튼을 더블 클릭하면 레이블 이름을 변경할 수 있다. 두 개의 버튼을 추가하고 (혹은 한 개를 추가한 다음, opt + 드래그로 복제해도 된다.) 각각의 이름을 Start, Stop으로 변경해준다.

텍스트 필드 추가하기

앱이 읽어줄 텍스트를 직접 입력할 텍스트 필드도 추가해보자. 객체 팔레트의 아래쪽에는 검색창이 달려있다. 여기에 text라고 입력하면 자동으로 팔레트의 내용이 필터링된다.

그 중에서 Text Field를 선택하고 윈도에 드래그해서 추가한다. 그리고 각각의 크기와 위치를 적절하게 조정해서 아래와 같이 정돈해보자.

앱 컨트롤러 추가

UI 상에서 버튼을 클릭하는 등의 액션을 처리하고, UI 요소의 상태를 제어할 컨트롤러는 별도의 클래스로 추가할 것이다. cmd + N 을 눌러서 새로운 파일을 추가한다. 파일의 종류는 Cocoa Class를 선택한다.

아래와 같이 클래스의 이름은 AppController로 하고 NSObject의 서브 클래스가 되도록 한다. 선택을 마친 후 저장 위치는 기본 위치 (프로젝트 폴더 내)에서 생성하도록 하자. 참고로 클래스를 이렇게 추가하면, 프로젝트 네비게이터에서는 폴더 위치와 상관없이 파일이 나타난다. (네비게이터 상의 계층 구조는 실제 디렉토리/파일구조와 다른, 프로젝트 상의 파일들의 구성이다.)

 

헤더 편집

새로운 클래스 파일을 생성하고나면, AppController.m 파일이 편집기에서 자동으로 열린다. 먼저 헤더쪽에서 수정해야 하는 부분이 있으니, 네비게이터에서 AppController.h 파일을 선택해서 헤더 파일을 먼저 편집하자. 헤더 파일은 아래와 같이 3줄의 코드가 들어있다.

#import <Foundation/Foundation.h>
@interface AppController: NSObject
@end

여기서 <Foundation/Foundation.h> 부분을 <Cocoa/Cocoa.h> 로 변경한다. 우리가 곧 사용하게 될 음성 합성기 클래스는 파운데이션이 아닌 코코아 클래스이기 때문에, 프레임워크 헤더를 변경해주어야 한다. 파일을 저장하고 (나중에 빌드할 때 자동으로 저장해주므로 굳이 저장할 필요는 없긴 하다만, 분야를 막론하고 중간 저장은 꼭 습관화해야 한다.) 헤더파일은 여기까지 하면 됐고, 다시 AppController.m 파일로 돌아가자. 이 파일에는 다시 아래와 같은 3줄의 코드가 있다.

#import "AppController.h"
// <- 요 위치에 편집 시작
@implementation AppController
@end

내부인터페이스 – 프로퍼티 작성

구현의 가장 첫 단계는 앱 컨트롤러가 해야 하는 일을 정의하는 것이다. 컨트롤러는 기본적으로 다음의 일을 처리해야 한다.

  1. Start 버튼이 클릭되면 입력된 문자열을 음성합성기에 넘겨서 읽도록 한다.
  2. Stop 버튼이 클릭되면 읽던 동작을 멈추도록 한다.

따라서 앱 컨트롤러는 음성합성기를 하나 생성해서 가지고 있어야 한다. 그리고 사용자가 입력한 텍스트를 얻기 위해서 UI상에 추가한 텍스트 필드에 대한 참조점을 가지고 있어야 한다. 이 둘은 모두 선언 프로퍼티로 작성될 텐데, 프로퍼티 선언은 @interface  영역에서 한다. 그런데 이 부분은 아까 헤더 파일에 들어있던 것 아닌가? 새로운 프로퍼티를 만들어야 하거나 편집해야 할 때마다 헤더와 m파일을 오가는 것이 귀찮기도 하고해서 하나의 파일 내에서 내부 인터페이스를 정의하기로 한다. (기술적으로 이것은 익명 카테고리를 사용하는 것이다.) 이 부분을 정의하기 위해서 @implentaion 위쪽에 다음의 코드를 추가하자.

@interface AppController ()

@end

다시 이 @interface ~ @end 사이에 아래 두 프로퍼티 선언과 액션메서드 선언을 추가한다.

@property (weak, nonatomic) IBOutlet NSTextField* field;
@property (strong, readonly, nonatomic) NSSpeechSynthesizer* speech;
- (IBAction)startSpeaking:(id)sender;
- (IBAction)stopSpeaking:(id)sender;

이 두 프로퍼티는 각각 다음과 같이 설명된다.

  1. field는  인터페이스 빌더 상에서 추가한 텍스트 필드를 참조할 이름이다.
  2. speechfield에 입력된 텍스트를 읽어주는 일을 담당할 객체이다.

보통 특정 클래스의 프로퍼티를 초기화할 때에는 -init 메소드에서 초기화하는데, 그러기보다는 getter 메소드가 맨 처음 호출될 때 초기화하는 전략을 쓰도록 하겠다. 다음의 코드를 @implementation ~ @end 사이의 영역에 추가한다.

#pragma mark - Properties
- (NSSpeechSynthesizer *)speech {
  if(!_speech) {
    _speech = [[NSSpeechSynthesizer alloc] init];
  }
  return _speech;
}

그런데 _speech는 어디서 튀어나온 것일까? 프로퍼티는 일반적으로 getter/setter 두 개의 접근자 메소드와 객체나 값을 저장할 스토리지 변수로 구성되는데, LLVM 컴파일러는 프로퍼티에 대해서 자동으로 그 이름 앞에 언더스코어를 붙인 변수를 자동으로 생성하고 nil로 초기화해준다.2 따라서 해당 인스턴스 변수가 nil 이면 초기화 작업을 진행하고 그 변수의 값을 리턴하도록 하는 것이다. 이러한 방식으로 프로퍼티를 작성하면 해당 프로퍼티는 느긋한(Lazy) 프로퍼티가 되는데, 예를 들어 자주 쓰이지 않거나 초기화하는데 많은 자원이 들어가는 프로퍼티라면 가능한 해당 프로퍼티가 필요한 시점에 초기화되도록하여 앱의 초기 구동 시간을 단축하는데 도움이 될 수 있다.

그리고, field는 별도로 초기화하지 않는다. 이 프로퍼티의 초기화는 코드가 아닌 인터페이스 빌더를 통해서 하게되니 조금 기다리자.

액션 메소드 구현하기

다음은 액션 메소드를 작성할 차례이다. 다음 두 개의 메소드를 역시 @implementation ~ @end  사이에 추가한다.

#pragma mark - Actions
- (IBAction)startSpeaking: (id)sender
{
  NSString *words = [self.field stringValue];
  if([words length] > 0) {
    [self.speech startSpeaking:words];
  }
}

- (IBAction)stopSpeaking: (id)sender
{
  [self.speech stopSpeaking];
}

-startSpeaking: 메소드는 나중에 Start 버튼이 클릭되면 호출될 메소드이다. 텍스트 필드에 입력된 문자열을 가져와서 그 길이가 0보다 크면 해당 텍스트를 음성합성기로 전달해서 읽도록 한다.  -stopSpeaking:은 Stop 버튼이 클릭되면 호출되며, 읽기를 중단하는 기능을 수행한다.

IBAction 이라는 키워드는 void와 같은 뜻이다. 다만, 이 키워드를 사용한 메소드는 Xcode에 의해서 인터페이스 빌더와 연결가능한 액션 메소드라는 의미가 된다.

컨트롤러와 UI 요소 연결하기

이상으로 작성해야할 코드는 다 작성했다. 하지만 아직 끝난 것은 아니다. 가장 중요한 과정이 남아있다. 바로 컨트롤러의 IBOutlet, IBAction을 실제로 인터페이스 빌더에서 연결해주는 작업을 해야한다. 초보자들이 가장 많이 하는 실수가 이 부분이다. 앱을 빌드하고 실행했을 때 버튼 클릭에 반응이 없거나, UI 요소가 제대로 값을 표현하지 못한다면 가장 먼저 이 부분이 제대로 수행되었는지를 반드시 확인하자.

다시 MainMenu.xib 파일을 선택해서 인터페이스 빌더를 연다. 오른쪽 팔레트에서 Object를 입력해서 Object 객체를 찾는다. 이 객체는 메인 윈도가 아닌 왼쪽의 독으로 가져가야 한다.

 

추가한 Object를 선택하고 다시 오른쪽 위의 인스펙터를 보자. 세번째 탭이 Identity Inspector인데, 여기서 클래스의 종류를 정의해줄 수 있다. AppController라고 클래스의 이름을 입력해준다. 이렇게 되면 방금 독에 추가한 객체의 클래스 종류가 확인되면서, 인터페이스 빌더는 해당 클래스 객체의 IBAction 메소드나 IBOutlet 프로퍼티를 인식하여 연결할 수 있는 부분들을 자동으로 감지할 수 있게 된다.

 

우리가 작성했던 코드를 잠깐 생각해보자. 코드 내에서 앱 컨트롤러의 두 프로퍼티 중에서 speech는 직접 최초 액세스시에 생성되도록 초기화 코드를 작성했다. 그런데 speech에 대해서는 별도의 초기화 메소드를 작성하지 않았다. 그런데 잘 생각해보면 프로젝트 어디에도 AppController 클래스의 인스턴스를 생성하는 코드가 들어있지 않다. 그렇다면 이 앱 컨트롤러는 어디서 생성되어 초기화되는 것일까?

정답은 nib 파일이다. 프로젝트를 컴파일하고 만들어진 앱을 실행하면, 앱은 런칭 시점에 자동으로 초기 nib 파일을 로딩하고, 그 내부에 정의된 객체들을 읽어들여서 인스턴스를 만들고 연결 등의 초기 세팅을 설정한다. 방금과 같이 AppController도 이제 nib 파일에 들어가 있게 되니 앱이 로딩될 때 자동으로 nib 파일로부터 로딩되어 인스턴스가 생성될 것이다.

아웃렛과 액션 연결

왼쪽의 Dock에서 AppController를 마우스 우클릭으로 드래그 (control 키를 누른 채로 왼쪽 드래그를 해도 같은 동작이다.)하면 파란색 선이 끌려온다. 이 선을 텍스트 필드로 가져가면 아래와 같은 상태가 된다.

 

이 때 마우스를 놓으면 까만색 작은 팝업이 뜨고, 여기서 field 를 선택하고 클릭한다. 그러면  앱 컨트롤러의 field 프로퍼티가 인터페이스 빌더 내의 텍스트 필드와 연결된 것이다. 이는 인터페이스 빌더 내에서 앱 컨트롤러의 field 라는 프로퍼티에 대해서 실제로 화면에 그려놓은 텍스트 필드 객체로 초기화하는 것과 동일한 의미가 된다.

액션 메소드 연결은 반대 방향이어야 한다. 같은 방식으로 Start버튼에서 control + 드래그해서 AppController로 마우스를 가져가보자.

 

이번에는 팝업에 두 개의 메소드가 표시될 것이다. 그 중에서 -startSpeaking:을 선택한다. 같은 방법으로 Stop 버튼을 AppController의 -stopSpeaking: 과 연결해주자.

이제 이 번 시간에 해볼 모든 과정이 끝났다. 연결까지 제대로 성공했다면 상단에 있는 플레이(?) 버튼을 클릭해서 앱을 빌드하고 실행해보자.

정리

가장 기본적인 코코아 앱을 구성하는 방법을 살펴보았다. 컨트롤러가 UI요소로부터 어떤 값을 액세스하거나 상태를 변경해주기 위해서는 프로퍼티 아웃렛을 작성해야 하고, 또 UI 요소에서 발생한 이벤트를 처리하기 위해서는 액션 메소드를 작성해야 한다. 그리고 이렇게 작성된 아웃렛과 액션은 인터페이스 빌더상에서 해당 UI 요소와 연결되어야 하기 때문에 앱 컨트롤러 객체도 인터페이스 빌더에 추가되어야 하고, 그 내부에서 아웃렛/액션 연결을 만들어주었다.

사실 여기까지의 과정은 iOS용 앱을 만드는 과정과 크게 다르지 않다. iOS 앱은 초기화면이 되는 root view가 있고, 이를 제어할 rootViewController가 프로젝트에서 기본적으로 주어지는데, 이 뷰 컨트롤러가 해당 뷰에서의 모든 기능을 컨트롤하는 역할을 한다. macOS는 환경의 차이로 큰 화면 내에서 앱의  UI는 하나의 뷰가 아닌 여러 개의 뷰로 쪼개어질 수 있기 때문에 앱의 전반적인 관리를 수행할 앱 컨트롤러가 필요한 것이다. (물론 각 윈도우마다 윈도 컨트롤러를 만들거나, 뷰 마다 뷰 컨트롤러를 붙이는 것도 가능하다.)

다음 글에서는 이 앱을 조금 다듬고, 목소리를 변경할 수 있는 기능을 추가하는 방법에 대해서 알아보도록 하자.

참고자료


  1. 국내 번역본 기준으로 Xcode 3 일 때 번역서가 출간되었다. (그리고 곧 Xcode 4가 나오게 되고….) 
  2. 선언 프로퍼티 참고. 

NSPersistentContainer를 통한 코어데이터 스택생성하기

macOS Sierra로 업데이트되면서 코어데이터에 NSPersistentContainer 클래스가 추가되었다. 이 클래스를 사용하면 코어데이터 스택을 셋업하는 여러 귀찮은 과정을 생략하고 간단하게 처리할 수 있다. 사실 코어데이터 스택을 수동으로 셋업하는 과정에서 필요한 정보는 코어데이터 모델 파일의 이름과, 저장소 파일을 생성할 위치 정도이며, 그외의 대부분의 코드는 보일러 플레이트라 할 수 있다.  저장소 파일 위치는 적당한이름(?)으로 사용자 라이브러리 내에 만들어지므로 결국 최소한으로 필요한 정보는 데이터 모델 파일 이름이 된다.

수동 셋업 과정은 다음과 같다.

/// Objective-C
@interface AppController: NSObject
@property (readonly, strong) NSPersistentContainer* persistentContainer;
@end

@implmentation AppController
@synthesize persistentConatainer=_persistentContainer;

- (NSPersistentContainer*)persistentContainer
{
  if(!_persistentContainer) {
    _persistentContainer = [[NSPersistentContainer alloc]
                            initWithName: @"MyDataModel"]; // 이름에 확장자는 붙이지 않는다.
    [_persistentContainer loadPersistentStoresWithCompletionHandler:^(NSPersistentStoreDescription *desc, NSError *error){
       if(!error) {
          /// 저장소 로딩 중 에러가 발생
          NSLog(@"Unresolved error %@, %@", error, error.userInfo);
       }
    }];
  }
  return _persistentContainer;
}

매우 간단하게 모든 설정이 완료됐다. 관리객체모델과 컨텍스트는 managedObjectModel, viewContext 프로퍼티로 액세스할 수 있다. 컨텍스트만 따로 외부로 노출하려한다면, 다음과 같이 프로퍼티를 선언한다.

@property (readonly, nonatomic) NSManagedObjectContext* context;
....
- (NSManagedObjectContext*) context { return self.persistentContainer.viewContext }

저장소 디스크립션을 사용하기

NSPersistentStoreCoordinator를 직접 셋업하는 경우에는 저장소 위치와 타입등의 정보를 설정해야 했다. 이러한 설정정보를 하나의 클래스로 묶은 것이 저장소 디스크립션으로 NSPersistentStoreDescription이라는 클래스로 만들어져있다. NSPersistentContainer는 이 디스크립션을 이용해서 “각각의 저장소들을” 생성한다. 기본적으로 아무런 정보가 주어지지 않으면 컨테이너는 SQLite 타입의 저장소를 라이브러리 디렉토리 내에 저장하게 된다. 만약 저장소 위치를 옮기고 싶거나, 타입을 바꾸고 싶으면 새로운 저장소 디스크립션을 생성해서 바꿔주면 된다. 대신 이 동작이 유효하려면 loadPersistentStores...:를 호출하기 전에 설정을 변경해두어야 한다.

/// 위치를 사용자 문서 폴더로 바꾸고 싶을 때
  NSURL* dirURL = [[[NSFileManager defaultManager] 
                       URLsForDirectory:NSDocumentDirectory
                       inDomains:NSUserDomainMasks] lastObject];
  NSURL* storeURL = [dirURL URLByAppendingPahtComponent:@"mydata.db"];
  NSPersistentStoreDescription* desc = [[NSPersistentStoreDescription alloc] initWithURL:storeURL];
  desc.type = NSSQLiteStoreType;
  [_persistentContainer setPersistentStoreDescriptions:@[desc]];
  [_persistentContainer loadPersistentStoresWithHandler:^ ...

NSPersistentStoreCoordinator를 사용하는 경우에도 -addPersistentStoreWithDescription:completionHandler:를 사용할 수 있으니 참고하자.

Swift 버전 코드

같은 내용인데, Swift 버전의 코드는 아래와 같다.

lazy var container: NSPersistentContainer = {
  let container = NSPersistentContainer(name:"MyDataModel")
  contaner.loadPersistentStore{ desc, error in 
    if error {
      fatalError("Fail to load : \(error)")
    }
}()

var context: NSManagedObjectContext {
  return self.container.viewContext
}

조금 더 깊이

Swift에서 init(name:) 은 편의 이니셜라이저이다. 만약 이 컨테이너를 서브 클래싱할 때 편의 이니셜라이저를 만드려면 지정 이니셜라이저를 호출해야한다. 컨테이너의 지정 이니셜라이저는 init(name:managedObjectModel:) 이므로 전달된 이름을 가지고 관리 객체 모델을 구해서 이를 호출해야 한다. 관리 객체 모델은 수동 셋업때와 같이 모델 파일로부터 로딩해서 생성하면 된다.

convenience init(completionHandler: @escaping () -> ()) {
  guard let mURL = Bundle.main.url(forResource:"MyDataModel", withExtension:"momd")
  else {
    fatalError("Can't load model from bundle.")
  }

  guard let mom = NSManagedObjectModel(contensOf:mURL) else { 
    fatalError("Error initializing MOM")
  }
  init(name:"MyDataModel", managedObjectModel:mom)
  completionHadler()
}

참고 자료