콘텐츠로 건너뛰기
Home » [Objective-C] 키밸류 코딩

[Objective-C] 키밸류 코딩

객체는 그 내부에 어떤 값을 저장하고, 접근자(accessor)라 불리는 메소드를 통해서 이 값에 접근한다. Objective-C에서는 내부 변수(ivar라 한다)와 그에 대한 접근자 메소드를 합쳐서 “프로퍼티”라는 개념으로 다룬다. 이 때 프로퍼티의 이름을 나타내는 문자열을 키(key)로 하여 그 값에 액세스하는 것을 키-밸류 코딩이라고 한다. 즉, 어떤 객체 내부의 값에 접근하려 할 때, 접근자 메소드를 호출하는 것이 아니라, 그 프로퍼티의 이름을 나타내는 문자열값을 사용하여 객체의 프로퍼티 값을 간접적으로 읽거나 쓰는 것을 말한다. 키밸류 코딩은 코코아에서 제공하는 여러 가지 기술을 사용하기 위한 전제 조건이기도 하다. 따라서 어떤 커스텀 클래스를 작성하고, 이것이 코코아 환경에서 제공하는 많은 프레임워크들과 잘 맞물려 돌아가도록 하기 위해서는 커스텀 클래스가 키밸류 코딩 프로토콜을 준수하여 호환되도록 하는 것에 신경을 써야 한다.

프로퍼티

Objective-C에서는 객체에 저장되는 값과 그 값에 접근하기 위한 접근자 메소드를 “프로퍼티”라는 하나의 개념으로 묶어서 다룬다. 키밸류 코딩의 내부를 이해하기 위해서는 이 프로퍼티에 대한 이해가 선행되어야 한다. Objective-C의 클래스는 C구조체를 기반하여 만들어진다. 만약 클래스 내부에 어떤 값을 저장하고 사용할 것이라면, 클래스의 선언부 (@interface ~ @end 구문사이의 영역)에서 이를 정의하게 된다. 만약 어떤 불리언 값 하나를 가지고 있는 클래스를 만든다고 하면 전체 코드는 아래와 같이 될 것이다. (아마 다른 곳에서 보던 Objective-C 코드와 약간 다르게 생긴 부분들이 있을 것이다.

@interface Foo: NSObject
// { ... } 내부에는 ivar 들을 정의한다.
{
    BOOL _hidden;
}
// 외부에 노출될 메소드들을 선언한다.
// 이곳에 노출되지 않는 메소드들은 객체 외부에서 호출할 수 없다.
- (BOOL)hidden;
- (void)setHidden:(BOOL)hidden;
@end


@implementation Foo
// 각각의 메소드들을 구현한다.
// 각 메소드 내에서 ivar들은 참조 가능한 범위내에 있다.
- (BOOL)hidden {
    return _hidden;
}

- (void)setHidden:(BOOL)hidden
{
    _hidden = hidden;
}
@end

이 클래스의 내부를 살펴보면 다음과 같은 것을 알 수 있다. _hidden 이라는 형태로 앞에 언더스코어로 시작하는 내부 변수(ivar)가 하나 정의되었다. 그리고 두 개의 메소드가 외부에 공개되는데, 하나는 일반적인 변수 이름과 비슷하게 생긴 isHidden 이라는 메소드로, 이 메소드는 내부의 값을 외부에서 읽을 수 있도록 하는 “읽기용 접근자”(getter)이다. 다른 하나의 메소드는 프로퍼티 값을 변경하기 위한 것으로 앞에 set이 붙어서 setHidden: 이라는 이름이 되었다. 참고로 isHidden은 hidden이 YES/NO 둘 중의 하나의 값을 갖는 불리언타입이기 때문에 이런 이름을 갖게 되는데, 이것은 코코아의 관례이고, 가급적 따르는 것이 좋다. 여기서 변수나 메소드의 이름에는 몇 가지 규칙이 있음을 알 수 있다.

  • 변수(ivar)이름 앞에는 언더스코어가 붙는다.
  • getter 접근자의 이름은 대체로 프로퍼티 이름과 같다. BOOL 타입인 경우에는 is가 앞에 붙는다.
  • setter 접근자의 이름은 프로퍼티 이름앞에 set을 붙여서 만든다.

사실 변수의 이름과 메소드의 이름은 그냥 붙이기 나름이지만, 이 규칙은 코코아에서 매우 중요하다. 사실은 이 규칙을 포함한 몇 가지 규칙을 기반으로 키밸류 코딩이 작동하기 때문이다. 그리고 그 규칙은 매우 명백하게 정의되어 있기 때문에, 실제 프로퍼티 이름만 알고 있으면 컴파일러가 자동으로 변수와 접근자를 정의하는 코드를 자동으로 작성해줄 수 있다. 그래서 보통 우리는 컴파일러의 도움을 받을 수 있는 지시어 구문을 통해서 코드를 더 간결하게 작성할 수 있다.

  • @property 구문은 isHidden 이라는 이름의 불리언 타입 프로퍼티가 있음을 정의한다. 이름 규칙에 의해 읽기, 쓰기 접근자는 각각 -isHidden, -setHidden: 일 것임을 알 수 있으므로, 그에 대한 메소드 원형 선언은 필요 없다.
  • @synthesize 구문은 해당 프로퍼티를 위한 접근자를 자동으로 ‘합성’해낸다는 뜻이다. 어떤 방식으로 작동할 것인지는 (readwrite, nonatomic) 이라는 프로퍼티 옵션에서 정의된다.

이를 사용한 예제는 아래와 같다.

@interface Foo: NSObject
@property (readwrite, nonatomic) BOOL isHidden;
@end

@implementation Foo
@synthesize isHidden;
@end

가급적 프로퍼티는 모두 이런 방식으로 작성할 것이 강력하게 권장된다. 단순히 코드량을 줄이는 것 뿐만 아니라, 이렇게 프로퍼티를 정의하면 내부적인 구현에서의 모든 이름은 코코아의 이름 규칙을 그대로 따르게 되기 때문이다. 이렇게 이름 규칙 관례를 지켜서 작성됐거나, 혹은 이에 기반하여 컴파일러가 자동으로 생성한 인스턴스 내부변수와 접근자들은 “디폴트 구현”이라고 한다.

키밸류 코딩 – 동적이며 간접적인 프로퍼티 접근법

앞선 예제에서 isHidden 이라는 이름은, 그 프로퍼티의 타입이 BOOL 이기 때문에 보다 자연스러운 표기를 위해서 is가 앞에 붙어 있다. 이 프로퍼티의 개념은 hidden 이라는 이름에 있다. 따라서 이 프로퍼티를 가리키는 키는 @"hidden" 이거나 @"isHidden" 일 수 있다. 어쨌든 이 프로퍼티가 이름 관례를 충실히 잘 지켰음을 가정한다면 우리는 이에 대한 접근자의 이름 역시 쉽게 추측할 수 있다.

키-밸류 코딩의 핵심은 여기에 있다. 문자열 값으로 전달된 프로퍼티이름이 있으면, 이름 관례에 의해서 그에 대한 접근자 및 인스턴스 변수까지 추측할 수 있고, 따라서 문자열만으로 객체 내부의 프로퍼티에 접근할 수 있는 근거를 갖게 된다는 것이다.

참고로 접근자들 역시 ‘메소드’이기 때문에 [foo isHidden] 이나 [foo setHidden:NO] 와 같은 형태로 호출하게 되지만, 접근자의 모양을 가지고 있기 때문에 foo.isHidden 이나 foo.isHidden = YES; 와 같은 식으로 사용해도 된다. 이것은 실제로 Objective-C의 정식 문법이라기 보다는 컴파일러가 메시지 전달 형태의 문장으로 자동으로 전환해서 처리한다고 생각하면 된다.

키 이름을 사용한 동적 프로퍼티 액세스

실제로 우리도 동적으로 프로퍼티를 키-밸류 코딩 스타일로 접근하여 사용할 수 있다. NSObject에 정의되어 있는 두 가지 메소드를 사용하면 된다. 바로 valueForKey:-setValue:forKey: 가 그것이다.

if ([foo valueForKey:@"hidden"]) { 
    /* ..... */
}

[foo setValue:YES forKey:@"hidden"];

키 패스의 탐색 패턴

-valueForKey: 메소드의 NSObject에서의 기본 구현은 주어진 key 에 대해서 다음의 과정을 순서대로 수행한다.

  1. 먼저 get<Key>, <key>, is<Key> 혹은 _<key>라는 이름으로 정의되어 있는 접근자 메소드를 찾는다. 만약 있다면 이를 호출한다.
  2. 간단한 접근자 메소드가 없는 경우, countOf<Key>가 있는지, 그리고 -objectIn<Key>AtIndex:-<key>AtIndexes: 중에 이름이 매칭되는 메소드가 하나 있는지 살펴본다. 만약 있다면 해당 프로퍼티는 배열과 비슷한 형식일 것이며, 원본 배열처럼 작동할 수 있는 “프록시” 객체를 생성해서 리턴한다.
  3. 만약 -countOf<Key>: , -enumeratorOf<Key>:, -memberOf<Key>: 가 모두 존재한다면 이는 세트와 비슷한 프로퍼티일 것이다. 2와 비슷하게 NSSet처럼 작동하는 프록시가 리턴된다.
  4. 접근자를 찾을 수 없다면, 클래스 메소드인 +accessInstanceVariblesDirectly 를 호출한다. 만약 이 값이 YES라면, 인스턴스 변수를 검사한다. _<key>, _is<Key>, <key>, is<Key> 의 패턴의 변수가 있는지 찾는다.
  5. 이름에 대응하는 ivar를 찾았다면, 객체 포인터인지 일반 스칼라값인지에 따라서 약간 처리가 달라지는데, 객체인 경우에는 그대로 리턴하고, 스칼라 값인 경우에는 NSNumber 등의 타입으로 감싸서 전달한다.
  6. 패턴에 대해 검색에 성공하지 못했다면, -valueForUndefinedKey: 가 호출된다. 이 메소드의 기본 동작은 예외를 발생시키는 동작이지만, 커스텀 크래스에서는 다른 동작을 할 수도 있다.

키밸류 코딩을 따르는 방법

앞서도 말했지만, 키밸류 코딩을 따르는 가장 쉬운 방법은 @property 구문을 사용하여 프로퍼티를 정의하는 것이다. 이렇게하면 컴파일러가 완전하게 키밸류 코딩에 호환되는 메소드 이름 패턴을 사용해서 접근자를 만들어내기 때문에, 대부분의 경우 공짜로 키밸류 코딩이 호환되는 객체를 얻게 된다. (사실 컴파일러 버전에 따라서 얼마나 완전하게 지원되는지에 대해서는 이견이 있을 수 있는데, 2023년 기준으로는 거의 99% 된다고 본다.)

Swift의 경우에는 프로퍼티를 정의하는 방식인 @property 디렉티브 자체가 별도의 멤버 선언 문법으로 대체되었고, ivar나 접근자 메소드를 컴파일러가 자동으로 관리하게 되었다. 게다가 모든 원시 타입이 객체이기 때문에 모든 프로퍼티가 키밸류 코딩을 따르는 것이 보장된다.

키밸류 코딩은 왜 중요한가

그렇다면 키밸류 코딩은 왜 중요한가? 이것은 특정한 프로퍼티가 변경될 때, 자동으로 옵저버들에게 통지가 가는 키밸류 옵저빙을 비롯하여, 이 기술을 기반으로 하고 있는 코코아 바인딩등에서 기본 가정으로 “모든 참여 객체가 KVC/KVO 호환이다”라는 것을 가정하기 때문이다.

다음은 키밸류 코딩으로 구현된 객체의 프로퍼티 값을 변경하는 것과 관련하여 알아 두면 좋은 몇 가지 특징이다. (이 중 몇몇은 Swift에 대해서는 들어맞지 않을 수 있다.)

  • 키밸류 코딩 이름 규칙을 지원하면 valueForKey:, setValueForKey:는 따로 구현하지 않더라도 자동으로 지원된다.
  • 키밸류 코딩 규칙을 따르더라도 _bar = @"hello";와 같이 인스턴스 변수를 직접 변경해버리면 이는 KVO와 호환되지 않는다.
    대신에 self.bar = @"hello"; 는 KVO 호환이다.
  • KVO에서는 반드시 [foo setValue:@"hello" forKey:@"bar"]를 쓰지 않아도 된다. [foo setBar:@hello]라고만 써도, 런타임에서 자동으로 통지를 보낼 수 있다. self.bar = @"hello"; 역시 setter 메소드 호출과 1:1로 치환되므로 KVO 호환이 된다. 이는 KVC 호환인 메소드는 필요한 경우 런타임에 의해 자동으로 다른 내부 메소드로 치환되기 때문에 적용가능하다. 물론 메소드 이름이 정해진 규칙을 벗어나면 이러한 기능은 지원되지 않는다.

기본적인 키밸류 코딩은 특정한 단일 값 프로퍼티의 변경을 런타임에서 동적으로 관리하는 수준에서 적용된다. 하지만 Foundation에서는 배열이나 Set과 같은 집합형식 자료 구조에 대해서도 KVC/KVO를 지원한다. 이는 단순히 이름 규칙만으로는 지원될 수 없으며, 별도의 메소드들을 추가로 작성해주어야 하는데 (대부분 NSMutableArray, NSMutableSet의 메소드들 간단힌 래핑하는 수준의 구현이다.) 이를 지원하도록 하는 것은 다음 기회에 추가로 소개하도록 하겠다.

참고자료