[Cocoa] 키밸류 코딩에 대한 오해와 진실

키 밸류 코딩은 코코아 프레임워크의 핵심적인 요소 중 하나로, 상당히 많은 코코아 프레임워크의 구성 요소들이 이 특성에 의존하고 있다. 특히 키-밸류 옵저빙, 코어데이터 및 코코아 바인딩 등 코딩의 수고스러움을 비약적으로 줄여주는 기술들이 이에 기반하고 있다. 키 밸류 코딩도 그 자체로는 너무나 간단한 개념이라 그런지, 프로퍼티 선언 등의 Objective-C 언어 자체의 기능과 혼동하는 글들이 많아서 정리해 본다.

선언된 프로퍼티

프로퍼티(declared property)는 객체 내부에 숨겨져 있는 속성값 (주로 인스턴스 변수의 값)에 대해 객체 외부에서 액세스할 수 있도록 인터페이스를 만들어주는 것이다. 객체의 외부에서는 밖으로 공개된 메소드들만 보이기 때문에 내부 속성값을 얻거나, 쓸 수 있도록 하는 메소드들을 짝지어서 제공하며, 이를 접근자 메소드라 한다. 프로퍼티는 이 접근자 메소드를 자동으로 생성해주는 기능이다.

예를 들어 Person이라는 클래스를 만든다고 생각해보자. 이 클래스는 이름(firstName)과 성(lastName)이라는 두 개의 문자열 속성을 가지며, 이들은 각각 NSString 형태의 인스턴스 변수로 저장된다고 하자. Objective-C에서는 객체의 외부에서 인스턴스 변수를 직접 액세스할 수 없으니, 다음과 같은 식으로 접근자 메소드를 만들어주게 된다. 이 때 각각의 인스턴스 변수를 읽어주는 메소드는 인스턴스 변수의 이름과 동일하게 하고, 인스턴스 변수에 값을 쓰는 메소드는 set이라는 접두어를 붙인다.

#import <Foundation/Foundation.h>

@interface Person : NSObject
{
    NSString *firstName, *lastName;
}
-(NSString *)firstName;
-(NSString *)lastName;
-(void)setFirstName:(NSString*)newFirstName;
-(void)setLastName:(NSString*)newLastName;
@end

구현부를 간단히 만들어보자. 두 속성은 각각 문자열 객체이며, 이름 값으로 전달된 객체가 외부에서 변경되더라도 일단 주어진 이름은 계속 유지해야 한다. 따라서 이름을 변경할 때는 주어진 인자를 복사하고, 메모리 릭을 방지하기 위해 기존의 값을 해제하는 과정을 거쳐야 한다.

@implementation Person
-(id)init {
    self = [super init];
    if(self){
        firstName = nil;
        lastName = nil;
    }
    return self;
}

-(NSString *)firstName {
    return firstName;
}

-(NSString *)lastName {
    return lastName;
}

-(void)setFirstName:(NSString *)newFirstName {
    NSString *oldFirstName = firstName;
    firstName = [newFirstName copy];
    [oldFirstName release];
}

-(void)setLastName:(NSString *)newLastName {
    NSString *oldLastName = lastName;
    lastName = [newLastName copy];
    [oldLastName release];
}

상당히 간단하지만, 속성이 아주 많은 큰 객체에 대해서 이러한 접근자 메소드를 매번 일일이 만들기에는 여간 골치아픈 게 아니다. 그래서 프로퍼티 선언을 사용한다. 위 클래스는 다음과 같이 보다 심플하게 작성할 수 있다. (Xcode 4.x 이상)

@interface Person : NSObject
@property (copy, nonatomic) NSString *firstName;
@property (copy, nonatomic) NSString *lastName;
@end

@implementation Person
@sythesize firstName, lastName;
@end

프로퍼티로 선언하는 경우, 대등되는 인스턴스 변수들은 nil로 초기화되며, 별도의 접근자 메소드를 작성하지 않아도 된다. 저 @synthesize 구문은 바로 이어지는 “속성”들에 대해 접근자 메소드를 자동으로 만들어주라는 메시지이다. 그러면 컴파일러가 알아서 접근자 메소드를 만들고, 이 때의 접근자 메소드 이름은 앞서의 예제와 똑같이 firstName, setFirstName: 과 같은 식으로 만들어진다. 이 때 중요한 것은 이 접근자 메소드의 이름이 결정되는 방식이다.

  • getter 접근자의 이름은 기본적으로 프로퍼티 이름과 동일하다.
  • setter 접근자의 이름은 -setPropertyName:의 형태를 띈다. 이 때 프로퍼티 이름은 Cocoa의 메소드 이름 관습에 맞게 대문자로 변경한다.

이 규칙은 별거 없어 보이지만, 사실 매우 중요하다. 즉 어떤 임의의 객체에 대해,

해당 객체의 “어떤 프로퍼티” 값을 얻기 위해서는 그 프로퍼티와 같은 이름의 메시지를 객체에게 보내면 된다. : [someObject propertyName]
이 프로퍼티가 만약 변경이 가능한 값이라면, 이름 관습에 의해 정해진 setter 메시지를 객체에게 보낼 수 있다. : [someObject setPropertyName:newValue]

라는 식의 조작이 가능하다는 전제가 성립된다는 것이다. 물론 디자인하기 따라서 다른 방식의 접근자 이름을 줄 수도 있다. 하지만! 이 관습을 따르는 것은 곧 “키-밸류 코딩”을 따르는 것과 동일하다는 말이다. 그럼 키밸류 코딩이란 뭔가?

키 밸류 코딩

키-밸류 코딩이란 간단히 말해 “객체의 어떤 속성값은 그 객체에 대해 속성 이름을 키로 정해 그 키에 대응되는 값으로 표현”한다는 의미인데, 어떤 임의의 객체에 대해 마치 NSDictionary 처럼 문자열 키를 통해서 내부의 값을 얻는 방법을 말한다. 그러니까, NSDictionary가 아니어도 “문자열”로 된 이름으로 프로퍼티 값을 얻어낸다는 것이다. 쉬운 말인데, 더 쉽게 설명할 수도 없고 아마 “응? 뭐?” 이런 느낌일테다. 그리고 “늘 하던대로” 접근자 메소드를 만들어주면 (내지는 그냥 프로퍼티로 만들어주면) 키 밸류 코딩을 공짜로 따라오게 된다. 그리고, 알게되겠지만, 이 방식은 엄청나게 유연한 코딩을 가능하게 해주고, 이를 통해 타이핑해야 할 양을 줄이는 것도 가능하다.

키 밸류 코딩은 간단히 “문자열”로 된 인자를 받아서, 그에 대응하는 접근자 메소드를 호출하는 방식이다. 이를 개별 클래스에서 구현하려면 상당히 귀찮고 기나긴 과정을 통해 구현해야 할 것이고, 그만큼 버그가 발생할 소지가 늘어나겠지만, 친절한 코코아는 모든 클래스의 조상님인 NSObject에서 이를 구현해 놓고 있다.

NSObject의 키밸류 코딩 구현

키 밸류 코딩의 핵심은 -valueForKey: 메소드와 -setValue:forKey: 메소드 두 개로 이루어진다. 즉 문자열을 가지고 객체 내의 임의의 속성 값을 제어하는 것이다. (이는 자바 스크립트와 같은 언어에서는 언어차원에서 지원하기도 한다.) 그럼 NSObject가 이를 어떻게 처리하는 지 살펴보자. 키 밸류 코딩을 따르는 객체에 [myObject valueForKey:@"myFavoriteMovie"]라는 메시지를 보냈다고 가정해보자.

  • 키로 들어온 문자열과 독같은 이름의 메소드가 있는지 확인한다. 만약 이 객체가 -myFavoriteMovie 라는 메소드를 구현했다면, 이를 실행해서 그 결과를 돌려준다.
  • 이런 메소드가 없다면 다른 비슷한 이름을 찾아본다. -getMyFavoriteMovie-isMyFavoriteMovie(BOOL 타입의 프로퍼티 getter)를 찾아본다.
  • 1,2에서 실패했다면 이 이름의 프로퍼티가 없다는 뜻이다. 이 때는 좀 더 다른 메소드를 찾는다. -countOfMyFavoriteMovie, objectInMyFavoriteMovieAtIndex, -myFavoriteMovieAtIndexes 등의 메소드를 찾는다. 이러한 메소드가 있다는 것은 해당 객체의 속성이 배열이나 세트와 관련이 있다는 의미가 되므로, 해당 배열의 프록시 객체를 리턴한다.
  • 메소드에서 이름 찾기에 실패하면 인스턴스 변수에서 찾기를 시도한다. myFavoriteMovie, _myFavoriteMovie, isMyFavoriteMoview, _isMyFavoriteMovie 등의 인스턴스 변수를 찾아 있는 경우에 이 값을 리턴한다.
  • 여기까지 왔는데도 찾을 수 없다면 이 객체에는 그런 키가 없다는 뜻이다. 이제 객체는 [self valueForUndefinedKey:@"myFavoriteMovie"] 를 보낸다. 해당 객체의 클래스가 이 메소드를 따로 오버라이드하지 않았다면 디폴트 구현에 의해 예외가 발생된다.

위와 같이 키-밸류 코딩은 문자열을 통한 간접적인 접근으로 상당히 많은 과정을 거쳐서 값을 찾도록 한다. 따라서 당연히도 직접적인 액세스 방식보다는 느린 단점이 있지만, 코드의 양을 줄이는데 도움이 되고 유연한 설계를 가능하게 한다. 오히려 데이터 모델이 매우 복잡한 구성을 가지는 경우에는 이를 사용하는 것이 실수를 줄이고 생산성을 높이는데 큰 도움을 줄 수 있다.

키 밸류 코딩 따르기

키밸류 코딩을 따르는 것은 간단하다. 앞서 이야기했던 대로 프로퍼티의 접근자 메소드 이름 규칙을 따라주기만 하면 된다. 그에 필요한 모든 동작은 NSObject로부터 상속받으며, 필요한 경우에는 valueForUndefinedKey:에 대해서만 별도의 처리를 하면 된다.

기본적으로 NSObject의 valueForUndefinedKey: 메소드는 예외를 일으키도록 처리되는데, 다음과 같은 방식으로 특정한 키에 대해서는 예외처리 대신 미리 정해진 값을 줄 수 있다.

-(id)valueForUndefinedKey:(NSString *)key {
    if( [is equalToString:@"myFormerGirlFriend"]) {
        return @"That is the top secret";
    } 
    return [super valueForUndefinedKey:key];
}

nil 처리

-setValeu:forKey:를 통해서 특정한 키에 대해 nil 값을 대입하는 경우가 있다. 문제는 모든 프로퍼티가 객체가 아니라는 점이 문제이다. 예를 들어 Person 클래스에 age라는 프로퍼티가 있는데, 여기에 nil을 대입하는 경우의 동작은 0으로 초기화하는 것이 보다 올바른 동작일 것이다. 문제는 이 경우에는 일반화하여 처리할 수가 없어서 객체는 스스로에게 다시 -setNilValueForKey: 메시지를 보내게 되는 구조로 설계가 되어 있다. 이 메소드에 대해 조상님의 디폴트 구현은 NSInvalidArgumentException예외를 일으키는 것이므로, 이를 정상적으로 처리하고 싶다면 이 메소드를 오버라이드해야 한다.

-(void)setNilValueForKey:(NSString *)key {
    if ([key isEqualToString:@"age"]) {
        self.age = @(0);
    } else {
        [super setNilValueForKey:key];
    }
}

그외…

메소드의 원형을 보면 값은 “객체”의 형태로만 주고 받게 되어 있다. 만약 프로퍼티 자체가 int 형 스칼라 변수로 되어 있다면, NSNumber 형으로 변환되어 리턴될 것이며, setValue의 경우에는 -intValue 값을 해당 인스턴스 변수에 넣도록 한다. 대부분의 스칼라 값에 대해서는 이런 변환이 간단하지만, 구조체라면 조금 이야기가 달라진다.

코코아는 기본적으로 NSPoint, NSRange, NSRect, NSSize와 같은 구조체에 대해서는 자동 변환을 제공한다. 그외의 구조체에 대해서는 NSValue 로 래핑한 객체를 돌려주게 되고, 반대로 이러한 구조체 멤버에 값을 수정하기 위해서도 NSValue로 감싼 객체를 사용해야 한다. 그러나, 멤버 자체를 구조체로 사용하는 것은 ARC의 적용을 어렵게 만드는 관계로, 객체를 사용하도록 한다.