코어 이미지를 통한 이미지 분석 예제

코어 이미지(Core Image)는 흔히 알려진 바와 같이 이미지에 대한 고성능 필터 효과 처리를 지원하는 프레임워크이면서 이미지에서 사람의 얼굴이나 QR코드, 텍스트를 탐지해내는 탐지 기능도 제공하고 있다. 코어 이미지가 제공하는 이미지 분석 기술을 제공하면 이러한 탐지를 빠르게 수행할 수 있을 뿐 아니라, 코어 이미지의 여러 필터 기능을 활용해서 찾아낸 부분을 하이라이트 처리하는 등의 기능을 손쉽게 구현할 수 있다. 이 글에서는 코어 이미지의 디텍터 클래스인 CIDetector를 사용하여 이미지에서 특정한 형상을 찾는 방법에 대해서 알아보고자 한다.

이미지에서 특정한 형상을 찾기

코어이미지가 제공하는 이미지 분석 기능을 사용하면 이미지 내에서 특정한 형상(feature)을 찾을 수 있다. 이 작업에서는 크게 두 가지 클래스를 다루게 된다.

  1. CIDetector : 이미지 분석처리를 담당한다.
  2. CIFeature : 분석된 결과에 대한 정보를 담는다.

CIDetector 클래스는 매우 간단한 API를 가지며, 다음의 절차를 거쳐 사용할 수 있다.

  1. init?(ofType: context: options:)를 통해서 새로운 디텍터를 생성한다.
  2. 생성된 디텍터에게 이미지를 전달하여 목표로 하는 형상을 탐지한다. 이 때 사용하는 메소드는 feature(in: options:) 이다.
  3. 탐지된 결과는 [CIFeature] 타입의 값으로 리턴된다.

CIFeature – 탐지된 결과에 대한 정보

CIFeature는 이미지 분석 결과에서 탐지된 매 항목에 대한 정보를 담고 있는데, 기본적으로 bounds 속성으로 이미지 내에서 해당 형상이 차지하는 부분을 알아 낼 수 있다. CIFeature는 여러 타입의 분석 결과에 대한 추상 클래스 타입이며, 어떤 것을 탐지하려고 했는가에 따라서 구체적인 서브 클래스를 사용하게 된다.

  1. CIRectangleFeature : 이미지 내에서 사각형을 탐지한 결과이다. 사각형은 딱 떨어지는 직사각형이 아니기 때문에 bottomLeft, bottomRight, topLeft, topRight 의 4개의 모서리 점 위치에 대한 정보를 추가적으로 가지고 있다.
  2. CITextFeature :  이미지에서 글자를 찾았을 때, 사용된다.
  3. CIQRCodeFeature : 이미지 내에서 QR코드를 탐지한 결과이다. QR코드를 디코딩한 문자열을 messageString 이라는 프로퍼티로 액세스할 수 있다.
  4. CIFaceFeature : 이미지 내에서 사람의 얼굴을 탐지한 결과이다.

이미지에서 사람 얼굴을 찾기

CIDetector를 사용하면 사람의 이미지 내에서 사람의 얼굴을 포착할 수 있다. 사람의 얼굴을 찾기 위해서는 CIDetector의 타입을 CIDetectorTypeFace로 주어 인스턴스를 생성한다. 이 때 컨텍스트는 기본적으로 nil을 전달할 수 있는데, 만약 소스로 주어지는 이미지와 연관된 CIContext 객체가 있다면 이를 넘겨줄 수 있다. (그렇게 하여 성능을 향상시킬 수 있다.) 주어진 이미지가 있을 때, 사람의 얼굴이 표현된 영역을 찾는 것은 다음과 같은 코드를 통해서 구현할 수 있다.

let image: CIImage = .... // #1
// #2
let detector = CIDetector(ofType: CIDetectorTypeFace,
                          context: nil,
                          options:
            [CIDetectorAccuracy: CIDetectorAccuracyHigh])!
// #3
if let features = detector.features(in: image) as? [CIFaceFeature] {
  let rects = features.map{ $0.bounds } // #4
  for rect in rects {
    // ... do something with face rect
  }
}
  1. 이미지는 이미 주어졌다고 가정한다.
  2. 디텍터를 생성한다. 디텍터 생성시 컨텍스트는 nil을 보낼 수 있고, 옵션은 정확도 수준을 지정할 수 있다.
  3. 이미지 내에 탐지되는 결과는 1개 이상일 수 있다. [CIFaceFeature]로 캐스팅한다.
  4. 각각의 CIFeature에 대해서 bounds 속성을 이용해서 이미지 내에 각 얼굴이 들어있는 영역을 지정할 수 있다.

얼굴이 들어간 부분을 하이라이트하기

얼굴을 찾아서 얼굴이 들어간 부분을 하이라이트하려면 어떻게 해야할까? CIImage 타입의 원본으로부터 이를 분석하여 얼굴의 영역들을 얻은 다음, 원본위에 하이라이트 영역을 칠한 결과물을 만드는 함수가 있다면 여기에 원본과 영역의 배열을 넘겨주어 최종 결과 이미지를 얻을 수 있다.

입력과 출력이 이렇게 결정되면 이 함수의 타입이 (CIImage, [CGRect]) -> CIImage라는 것을 알 수 있고, 이러한 함수를 구현하면 되는 것이다. 이를 구현하는 방법은 크게 코어 이미지를 이용해서 원본을 그린 후, 그 위에 하이라이트 영역을 그리는 방법이 있을 수 있고 또 하이라이트 영역에 대한 이미지를 만든 후에 이를 블렌드모드 필터를 이용해서 합성하는 방법이 있다. 여기서는 후자의 방법을 사용해보도록 하겠다.

/// 이미지의 특정 영역을 하이라이트 하기
func highlight(in source: CIImage, rects: [CGRect]) -> CIImage {
  let imageSize = source.extent.size
  let mask: CGImage = {
    let ctx = CGContext(data: nil, width: Int(imageSize.width), height: Int(imageSize.height),
                        bitsPerComponent: 8, bytesPerRow: 0,
                        space: CGColorSpaceCreateDeviceRGB(),
                        bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)!
    ctx.setFillColor(NSColor.yellow.cgColor)
    ctx.fill(rects)
    return ctx.makeImage()!
  }()

  // mask를 InputImage로 하고 source를 BackgroundImage로 해서 하이라이트된 이미지를 생성
  let inputImage = CIImage(cgImage: mask)
  return inputImage.applyingFilter("CIOverlayBlendMode", parameters:
         [kCIInputBackgroundImageKey: source])
}

사실 이 함수에서 이미지를 합성하는 방법만 약간 바꾸면, 어떤 사진 내에서 사람 얼굴만 모자이크 처리하는 익명화 프로그램을 만드는 도구를 간단히 생성할 수 있을 것이다. (원본을 모자이크/블러처리한 이미지를 만들고, 영역 내에 사각형을 그린 이미지와 컴포지팅하여 다시 원본에 덧그리는 방식이다. 이는 재미있는 토픽으로 보이니 조만간 살펴보도록 하겠다.)

QRCode를 찾기

이미지 분석에서 유용한 기술 중 하나는 QRCode를 탐지하고, QRCode내에 인코딩된 메시지를 얻는 것이다. 이는 위의 예제에서 Face 대신 QRCode만 넣으면 된다고 할 정도로 간단한 작업이다. QR코드 탐지 결과인 CIQRCodeFeaturemessageString이라는 프로퍼티를 통해서 인코딩된 메시지에 액세스할 수 있다.

다음 함수는 주어진 CIImage에 대해서 QRCode를 찾고, 그 속에 인코딩된 메시지를 완료 핸들러로 전달하는 함수이다.

/// QRCode를 찾아서 해석하기
func detectQRCode(in image: CIImage, completionHandler: ((String) -> Void)?) 
{
  let detector = CIDetector(ofType: CIDetectorTypeQRCode, context: nil,
                   options:[CIDetectorAccuracy: CIDetectorAccuracyHigh])!
  if let result = detector.features(in: image).first as? CIQRCodeFeature,
     let message = result.messageString
  {
    completionHandler?(message)
  }
}

정리

코어 이미지가 제공하는 이미지 분석 도구는 Vision에 비해서 정확도는 아주 약간 떨어질 수 있지만, 여전히 빠르고 쓸만하게 동작한다. 또 API의 디자인 역시 심플하고 쉽게 사용할 수 있기 때문에 이를 사용해서 여러가지 유용한 도구들을 만들 수 있다.

참고자료

코코아 앱 따라하기 – 2

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

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

코코아 앱 따라하기 – 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 클래스가 추가되었다. 이 클래스를 사용하면 코어데이터 스택을 셋업하는 여러 귀찮은 과정을 생략하고 간단하게 처리할 수 있다. 사실 코어데이터 스택을 수동으로 셋업하는 과정에서 필요한 정보는 코어데이터 모델 파일의 이름과, 저장소 파일을 생성할 위치 정도이며, 그외의 대부분의 코드는 보일러 플레이트라 할 수 있다.  저장소 파일 위치는 적당한이름(?)으로 사용자 라이브러리 내에 만들어지므로 결국 최소한으로 필요한 정보는 데이터 모델 파일 이름이 된다. 즉 관리 객체 모델의 이름만 있다면 코어데이터 스택의 초기화에 필요한 코드는 사실상 동일하다 하겠다.

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

코코아 바인딩의 기초

MVC 패턴에서 프로그래머가 가장 많이 작성하는 코드는 크게 두 가지인데, 하나는 모델 데이터에서 발생한 변경을 뷰에 반영하는 것이고, 다른 하나는 뷰에서 사용자의 조작에 의해서 변경된 값을 모델 데이터에 반영하는 것이다. 사실 이것이 MVC에서 컨트롤러가 수행하는 일이다. GUI프로그램을 작성할 때 가장 많이 하게되는 이러한 작업을 조금 더 간단하게 (가급적이면 코드를 작성하지 않고) 구현하는 방법이 있으면 제법 편하지 않을까? 이럴 때 사용하는 코코아 바인딩은 Cocoa에서 macOS에서 데이터 모델과 뷰 사이의 양방향 연결을 만드는 일종의 ‘마법’이다. 예를 들어 아래 그림과 같은 간단한 앱을 생각해보자.

코코아 바인딩 샘플 앱

이 앱은 매우 간단한 구조를 가지고 있다. 뷰에는 숫자를 표시하는 레이블하나와 슬라이더가 하나있다. 이 앱이 하는 일은 사용자가 슬라이더를 좌우로 옮길 때마다, 슬라이더의 값이 레이블에 숫자로 반영되는 것이다. 이를 위해서는 뷰 컨트롤러에 대해서 다음의 프로퍼티와 액션이 정의되고, 그에 대한 코드가 구현되면 된다.

  1. 뷰 컨트롤러는 숫자값 하나를 저장할 NSNumber타입의 프로퍼티를 하나 가지고 있다. (꼭 NSNumber가 아니어도 된다. 그냥 float이나 double 타입의 값이기만 해도 된다.)
  2. 뷰 컨트롤러는 텍스트레이블에 대한 아웃렛 연결을 가지게 된다. (이 아웃렛을 통해서 레이블을 참조하고 그 값을 변경할 수 있다.)
  3. 슬라이더는 사용자가 옮길 때마다 뷰 컨트롤러에게 액션 메시지를 보내게 된다. 이 메소드에서는 슬라이더의 값을 받아서 레이블의 값을 세팅하는 코드를 작성해주어야 할 것이다.

코코아 바인딩은 특정한 프로퍼티에 대해서 UI 컨트롤과의 양방향 연결을 구성하는 테크닉이다. 기본적으로 뷰 내의 컨트롤과 뷰 컨트롤러 사이의 연결을 만들 수 있지만, 반드시 뷰와 뷰 컨트롤러 사이의 관계일 필요는 없다. 샘플 프로젝트는 추가적인 클래스 없이 앱 델리게이트를 이용하기로 한다. 기본 macOS용 앱을 위한 프로젝트를 시작하고, 앱 델리게이트 파일을 다음과 같이 하나의 프로퍼티를 추가한다. (그외에 다른 아웃렛이나 액션을 선언하지 않아도 된다.)

Put Together

MainMenu.xib 파일을 열어서 메인 윈도우에 레이블 하나와 슬라이더 하나를 아래와 같이 배치한다. 참고로 슬라이더를 추가한 후에는 속성 인스펙터에서 Continuous에 체크한다. 슬라이더는 기본적으로 움직였다가 마우스를 릴리즈하는 시점에 타깃에게 액션 메시지를 보내는데, Continuous가 체크되면 슬라이더 노브를 움직이는 동안에 계속적으로 메시지가 전달된다. (즉 움직이는 중에 숫자값이 계속 바뀌는 것을 볼 수 있다.)

그리고 숫자값 프로퍼티 v에 대해 초기값을 설정해보자. 왼쪽 객체 목록에서 앱 델리게이트를 선택하고, 속성 인스펙터에서 User Defined Runtime Attributes 아래에 있는 + 버튼을 클릭한다. 키 패스에는 v를 입력하고 타입은 Number, 값은 50으로 입력한다.

참고로, nib 파일은 뷰, 컨트롤, 컨트롤러등의 여러 객체들이 어떠한 프로퍼티값을 가지고 있는 상태 그대로를 직렬화한 파일이다. 따라서 인터페이스 빌더에서 정의하는 크기와 위치(곧, 뷰의 frame 속성이다.)라던지 색상 및 컨트롤의 유형 등의 정보는 객체의 인스턴스를 만들어서 코드상에서 설정해야 하는 번거로움을 덜어주는 역할이라고 보면 된다.

이번에는 바인딩을 연결하자. 먼저 텍스트 레이블을 선택한다. 참고로 이 레이블에 대해서는 앱 델리게이트에서 어떤 아웃렛 연결이나 액션메시지 연결은 없었다는 점을 상기하자. 우측 인스펙터의 7번째 탭이 바인딩 탭이다. Value 라고 되어 있는 부분을 보면 몇 가지 값 속성이 있는데, 그 중 value 를 찾아서 세부 항목을 열어본다. 그러면 팝업박스를 통해서 어떤 객체에 연결할 것인지를 고를 수 있다.1 여기서 앱 델리게이트를 선택한다.

다음은 모델키패스(model keypath)를 입력해야 한다. 연결하고자 하는 숫자값은 앱델리게이트 내의 v 라는 이름을 가진 NSNumber이다. 이 키패스는 바인딩되는 객체를 기준으로, self.v 라고 입력한다. (self.을 생략하고 v만 입력해도 된다.)

다음은 슬라이더를 선택해서 똑같이, Bind to 는 Delegate로, 키 패스는 self.v 로 입력해준다.

앱 델리게이트의 프로퍼티와 텍스트레이블, 슬라이더를 바인딩으로 연결 완료했다. 재차 강조하지만 여기에는 프로퍼티 정의 외에는 어떠한 코드도 작성되지 않았다. 하지만 이것으로 앱은 완성이다.

이제 앱을 빌드하고 실행해보자. 화면에 나타나는 앱 윈도에서 슬라이더를 좌우로 움직여보자. 슬라이더를 움직이면 레이블의 숫자값이 쫘르르르 변하는 것을 볼 수 있다.

어떤 원리로 동작하나

코코아 바인딩은 과연 어떤 마법이길래, 이러한 뷰와 데이터 모델 간의 양방향 바인딩이 코드 한 줄 없이 자동으로 이루어질 수 있을까? 이는 기본적인 코코아의 기본 패턴/기능 몇 가지를 멋지게 조합한 결과물이다.

  • 컨트롤의 특정한 프로퍼티와 컨트롤러 내의 모델 프로퍼티는 그 이름을 기준으로 연결된다.
  • 컨트롤이 조작되면 바인딩이 연결된 객체와 키패스를 이용해서 해당 값을 업데이트한다.
  • 반대로 컨트롤러 내의 어떤 값이 변경되면 그 값과 연결된 컨트롤들이 통지를 받고 값을 업데이트한다.

먼저 샘플 프로젝트에서 사용된 예를 살펴보자. 여기에는 NSLabelNSSlider 가 각각 하나씩 사용되었는데, 이들은 모두 NSControl의 자식 클래스들이다. 이쯤에서 NSControl에 대해서 간략히 살펴보자

NSControl

NSControl은 화면상에 표시되는 여러 컨트롤 디바이스를 표현하는 클래스이다. 표면적으로는 셀 이라는 것을 이용해서 시각적으로 표현되는데, 여기서는 그게 중요한 게 아니고 컨트롤은 어떤 값을 조정/조작하는데 사용되는 클래스라는 점이다. 실질적으로는 아무런 값을 가지지 않는 컨트롤(버튼 등)도 있고, 텍스트를 표현하는 레이블이나, 텍스트를 편집할 수도 있는 텍스트 필드, 슬라이더 등등 화면상에 표현되는 표준 UI 컴포넌트들은 모두 NSControl이라고 볼 수 있다.

NSControl은 어떤 값을 조작하는 UI 단위이다. 이 값은 기본적으로 objectValue 라고 하는 id 타입 객체이며, 하나의 컨트롤은 그 포맷에 따라 doubleValue, intValue, stringValue  등의 여러 타입의 프로퍼티를 가지고 있다. 하지만 이들은 모두 컨트롤 내부에서 추상클래스와 포매터를 기반으로 하나의 값의 다른 측면을 바라보는 것이다.  따라서 여러 프로퍼티에 혼란스러워하지 않고 컨트롤러 1개는 값 1개를 조작한다고 생각하면 된다.

NSControl은 코코아의 디자인패턴 중 target-action이 적용되는 전형적인 클래스이다. 타깃-액션은 컨트롤에 어떤 타깃이 주어지면, 사용자가 자신을 조작할 때 미리 정해진 타깃으로 액션 메시지를 전송하게 된다. 우리가 보통 인터페이스 빌더에서 컨트롤러 객체로 액션 메시지를 연결하는 행위는 해당 컨트롤이 트리거링되는 조작을 받았을 때, 어떤 컨트롤러에게 어떤 메시지를 보내야 하는지를 알려주는 것이라 해석할 수 있다.

키밸류 코딩

컨트롤이 조작되었을 때, 컨트롤을 타깃에게 어떠한 액션 메시지를 보내게 된다. 이 때 보통은 타깃이 뷰 컨트롤러이고, 코드상에서는 NSViewController 등의 클래스를 서브 클래싱하면서 IBAction 메소드를 정의하여 인터페이스 빌더에서 컨트롤과 뷰 컨트롤러간에 action 연결을 만들게 된다.

바인딩에서 액션은 임의의 커스텀 메소드를 사용하지 않았다. 대신에 이 액션은 하고자 하는 일이 명확하다. 바인딩하는 대상의 특정한 프로퍼티 키패스를 컨트롤러의 값으로 세팅하는 것이다. 우리는 샘플 프로젝트에서 AppDelegatev 라는 이름의 프로퍼티를 만들었다. AppDelegateNSObject의 서브 클래스이므로, 키밸류 코딩 컨벤션을 따른다.(이는 NSKeyValueCoding이라는 비정규 프로토콜에서 정의된다.) 따라서 바인딩되는 객체와 키 패스를 알고 있다면, 슬라이더는 그 자신이 조작되는 시점에 타깃에게 "self.v"라는 키패스의 값을 세팅하라는 메시지를 보낼 것이다.

[self.target setValue:self.objectValue forKeyPath:@"self.v"];
 ^^^^^^^^^^^ 1                                    ^^^^^^^^^^ 2
  1. 타깃은 바인딩될 객체로 설정된다.
  2. self.v는 모델 키패스로 설정됐다.

이를 통해서 슬라이더를 움직이면 AppDelegate의 프로퍼티 v는 슬라이더의 값이 된다. 그러면 이 값이 변경되었을 때, 레이블은 어떻게 업데이트 되는 것일까?

키밸류 옵저빙

프로퍼티의 구조를 이해하고 있다면, 어떤 프로퍼티 값이 변경되었을 때 특정한 동작을 취하게 하는 것을 구현하는 방법에 대해서 감을 잡을 것이다. 보통은 setter 메소드를 정의하는 것이다. 샘플 프로젝트의 v 는 copy, nonatomic 시멘틱으로 정의되어 있다. 따라서 컴파일러는 다음과 같이 메소드를 합성해 낼 것이다.

- (void)setV:(NSNumber *)v
{
  NSNumber* newV = [v copy];
  __v = newV;
}

만약, 이 시점에 레이블의 텍스트를 변경하고 싶다면 다음과 같이 코드를 한 줄 추가해주면 된다.

- (void)setV:(NSNumber *)v
{
  NSNumber* newV = [v copy];
  __v = newV;
  [self.vLabel setObjectValue: self.v];
}

문제는 이렇게하려면 앱 델리게이트가 해당 레이블에 대한 참조를 가져야한다는 것이다. 그런데 우리는 샘플 프로젝트에서 어떠한 아웃렛도 선언하거나 연결한 적이 없다. 그러면 어떻게 레이블은 그 값을 자동으로 변경할 수 있을까? 그 비밀은 키-밸류 옵저빙이다. 키 밸류 옵저빙은 어떤 객체가 다른 객체의 키패스에 대한 값이 변경될 때, 그것에 대한 통지를 받게 되는 매커니즘이다. 이와 관련된 내부적인 처리는 모두 런타임에서 수행되며, 우리는 그저 그 키패스에 해당하는 프로퍼티가 키밸류 코딩 규칙을 따르도록 정의하면 되는 것이다.2

바인딩을 세팅하게 되면, 앞서 살펴본바와 같이 타깃-액션 메시지를 보내는 기능을 동적으로 생성하는 동시에, UI컨트롤은 모델 키패스에 대하여 자신을 옵저버로 등록한다. 그리고 해당 프로퍼티가 변경되면, 그 프로퍼티의 값을 자신의 objectValue로 설정한다.

대략 다음과 같은식의 코드들이 자동으로 붙게 된다. 여기서 대상 객체를 target으로 했지만, 실제로 하나의 컨트롤은 여러 개의 모델과 바인딩될 수 있으므로 별도의 객체 식별자를 사용할 것으로 보인다.

...
// 아마도 awakeFromNib 쯤에...
[self.taget addObserver:self
            forKeyPath:@"self.v"
            options:NSKeyValueObservingNew
            context:NULL];
...

// 그리고 바인딩된 모델 키패스의 변경을 감지하도록
- (void)observeValueForKeyPath:(NSString*)keypath 
        ofObject:(id)object 
        change:(NSDictionary<NSKeyValueChangeKey, id>*)change
        context:(void*)context
{
   if([keypath isEqualToString:@"self.v") {
     // 변경된 값을 자신의 값으로 치환한다.
     (id)newVaue = [change objectForKey:NSKeyValueChangeNewKey];
     self.objectValue = newValue;
   } else {
     [super observeValueForKeyPath:keypath
            ofObject:object
            change:change
            context:context];
  }
}


// 해제되기 직전에는 옵저버 등록을 해제해야 함
- (void)dealloc
{
  [self.target removeObserverForKeyPath:@"self.v"];
}

슬라이더와 레이블 모두가 이런 식으로 특정한 모델 데이터에 바인딩 되어 있다. 샘플 프로젝트에서는 슬라이더를 통해서만 모델 값을 변경할 수 있었지만, + , – 버튼을 이용해서 값을 조정한다던가, 레이블 대신에 텍스트 필드를 사용해서 값을 입력받을 수 있게한다면 이 모든 관계들이 실시간으로 영향을 받으면서 변경되는 것이다.

Swift와 코코아 바인딩

키밸류코딩과 키밸류옵저빙은 모두 Objective-C 런타임에 전적으로 의존하는 매커니즘이다. 그러면 Swift로 작성하는 코코아 앱에서는 코코아바인딩을 사용할 수 없을까? 당연히 사용할 수 있다. 대신에 몇가지 조건이 있다.

  1. 모델 키패스를 포함하는 객체의 클래스는 NSObject의 서브 클래스여야 하며
  2. Objective-C런타임에서 식별할 수 있게 @objc 접두어를 써서 정의해야 한다.
  3. 모델 키패스가 되려는 프로퍼티 역시 @objc 접두어를 써야 한다.
  4. 동시에 접근자 메소드를 동적으로 생성해내기 위해서 dynamic 키워드도 써야 한다.

따라서 샘플 프로젝트의 앱 델리게이트는 Swift로 작성됐다면 다음과 같은 모양이어야 할 것이다.

@objc
class AppDelegate: NSObject, NSApplicationDelegate
{
  @IBOutlet weak var window: NSWindow!
  @objc dynamic var v: float = 50.0;
}

그외에…

  1. 코코아 바인딩은 인터페이스 빌더에서 설정하는 것이 간편하다. 하지만 실제로 이 기능은 NSKeyValueBindingCreation이라는 비정규 프로토콜에 정의된 메소드를 호출하는 것으로 축약된다.
  2. 샘플 앱 프로젝트는 여기에서 다운받을 수 있다.(Swift 버전은 여기)

참고자료

 


  1. 특정한 값을 바인딩하기 위한 선택사항으로는 동일 nib 파일 내에 있어야 한다는 제약은 있다. 이는 스토리보드 상에서도 같이 적용될 것이다. 
  2. 키밸류 코딩 규칙을 따르도록 하는 것은 전혀 어렵지 않다. @property를 써서 정의하면 된다. 키밸류 코딩 규칙은 getter, setter 메소드와 인스턴스 변수이름 사이의 관계로만 정의되어 있다.