Objective-C 서브 클래스에 관한 팁

Subclass

서브클래싱 해야할 때

만약 커스텀 레이아웃을 가진 UITableViewCell 객체가 필요하다면 해당 클래스를 상속받은 서브클래스를 작성해야 한다. 많은 객체들이 같은 뷰를 가져야 한다면 이를 서브클래싱하여 새로 만드는것이 옳다. 그렇게 함으로써 변경사항은 간편하게 묶이기 때문이고, 이렇게 만들어진 클래스는 단지 파일을 복사하는 것만으로 프로젝트를 넘나들면서 재활용할 수 있기도 하기 때문이다.

만약 여러 플랫폼이나 버전에 따라 조금씩 달라지는 코드를 사용해야 한다면 이때도 특정한 클래스를 만들고 환경에 따라 사용할 서브 클래스를 생성하고, 변경되는 부분들을 오버라이딩하면 된다. 예를 들어 OBJDevice 클래스가 있고 이 클래스를 서브클래싱하는 OBJCIPadDevice, OBJCIPhoneDevice 클래스를 만들고, 각각의 서브 클래스는 공통 구현은 상속받고, 다른 구현은 오버라이딩하는 식이다.

또한 모델 오브젝트를 만들 때도 서브클래싱은 유용하다. 실제로 Cocoa/CocoaTouch의 공통부분이라 할 수 있는 Foundation에서는 원형 클래스인 NSObject를 정의해놓았는데, 고맙게도 대부분의 데이터 모델 클래스에서 필수적인 isEqual:(동질성 비교), hash:(좋은 해시맵을 만들기 위한 멋진 해시함수), copyWithZone:(iOS7부터는 더 이상 사용되지 않지만, 객체를 메모리째로 복사할 때 필요)와 같은 메소드를 잘 구현해 놓았다. 만약 여러분이 새로운 루트 모델 클래스를 만들고자 한다면 이러한 메소드들을 새롭게 구현해야 하지만, NSObject를 상속받아 새로운 객체를 디자인한다면, 최소한 이런 기본 동작에 관련된 메소드에서는 실수할 확률을 크게 줄일 수 있다.

서브클래싱 하지 말아야 할 때

하지만 덮어놓고 서브 클래싱하다보면 많은 클래스에서 깊고도 복잡한 클래스 상속관계들을 보게 된다. 상속관계가 지나치게 길어진다면 다른 대안들을 떠올려볼 수 있다. 이에 대해서는 조금 더 자세히 후술할 것인데, 만약 서브클래싱하는 목적이 단순히 인터페이스만 공유하는 것이라면(즉 같은 인터페이스를 물려받는 사촌 클래스들이 여럿이라면) 프로토콜을 사용하는 것이 더 바람직 할 수 있다. 또한 자주, 그리고 많이 변경되어야 하는 객체를 만들어야 한다면 서브클래싱하지 않고 델리게이트를 사용하는 것이 훨씬 동적인 객체를 만들기 쉽다. 또한 기존 클래스에 기능을 추가하여 확장하려 한다면 카테고리도 좋은 방법이다. 일련의 서브클래스들이 같은 메소들을 각각 오버라이드 해야 한다면 설정 객체(configuration object)를 대신 사용하는 것이 나을 수 있다. 그리고 단지 어떤 기능을 재사용하기를 원한다면 객체를 확장하기 보다는 그냥 여러 객체를 생성하는 게 나을 때도 있다.

깊은 상속 구조는 최소한 Objective-C에서는 바람직하지 않다. 이는 성능과 관련이 있는데, Objective-C는 완전히 새로운 언어라기보다는 libobjc.dll에 의해 객체 메시징을 C함수 호출로 바꿔주는 기능을 얹어놓은 C언어이기 때문이다. 만약 A라는 클래스의 인스턴스 객체 a가 someMsg: 라는 메시지를 받으면, a는 자신이 처리할 수 있는 메시지라면 그에 대응하는 C함수를 찾아 호출해야 하는데, 이를 찾는 과정은 클래스 A의 디스패치테이블(해당 클래스가 정의한 셀렉터 목록)을 뒤져보고 없으면 A의 수퍼클래스, 거기에 없으면 또 그 수퍼 클래스… 이런 상속체인을 거슬러 올라가게 된다. 그리고 운이 나쁜 경우, Unrecognized Selector예외는 항상 NSObject에서 나게 마련이다.

물론 Objective-C 런타임은 셀렉터를 캐싱하기 때문에 어느 정도 메시지 교환이 이루어지고나면 이 성능은 많이 좋아지긴 한다.

서브클래싱 대신 프로토콜을 적용할 때

다음 두 클래스를 보자.

@class Player : NSObject
- (void)play;
- (void)pause;
@end

@class YouTubePlayer : Player
@end

이렇게 동일한 메소드들을 갖기 위해서 서브클래싱했지만, 만약 구현 코드가 완전히 달라, 수퍼클래스의 구현 내용을 재활용할 일이 별로 없다면?

@protocol VideoPlayer <NSObject>
- (void)play;
- (void)pause;
@end

@class Player : NSObject <VideoPlayer>
@end

@class YouTubePlayer : NSObject <VideoPlayer>
@end

서브 클래스로 상속하기 보다는 프로토콜을 사용하여 이 두 클래스를 먼 친척(?)으로 만드는 것이 낫다.

델리게이트

만약 위 예제에서 플레이어 클래스가 재생중에 어떤 다른 일을 하는 기능을 추가하고 싶다고 하자. 그럼 원래 기능을 고스란히 가지고 새로운 기능을 더한 서브 클래스를 만들면 될까?

@class Player;
@protocol PlayerDelegate
- (void)playerDidStartPlaying:(Player *)player;
@end

@class Player : NSObject <VideoPlayer>
@property (nonatomic, weak) id<PlayerDelegate> delegate;
@end

그냥 델리게이트를 만들어서 델리게이트가 그 일을 하도록 해주면 된다.

카테고리

기존 클래스를 서브클래싱하지 않고 기능을 추가한다. 특히 기존 코드를 확장할 때 간단히 사용할 수 있는 방법이다. 다음은 NSArray 클래스에 기능을 추가할 수 있다.

@interface NSArray (OBJExtras)
- (void) obj_arrayByRemovingFirstObject;
@end

기존 코드 내의 클래스가 쓰이는 부분을 교체할 필요가 없고, 심지어 기존 클래스가 자신이 만든 클래스일 필요도 없다.

설정 객체

예를 들어 프레젠테이션 앱을 만들면서 디자인 테마 정보를 담는 클래스인 Theme를 작성했다고 가정하자. 여기에는 글꼴이나 배경색과 같은 프로퍼티를 가지고 있게 된다. 그리고 이런 디자인 속성이 다른 서브 클래스들(Theme의 자식들)을 만들면서 초기화 메소드를 매번 오버라이딩해야 한다면, 그것도 그것대로 삽질이 된다. 이럴 때는 초기화 메소드가 사전이나 혹은 별도로 정의된 설정 데이터 모델 객체를 하나 받는 것으로 디자인을 바꾼다.

설정 객체는 그저 필요한 속성 정보들을 담을 수 있는 간단한 객체이면 되며, Theme 클래스에는 initWithConfigurationObject:와 같은 초기화 메소드를 하나 추가해주면 될 것이다.

컴포지션

컴포지션은 흔히 사용되는 패턴은 아니지만 서브클래싱을 대체할 수 있는 강력한 수단이다. 클래스에서 코드들을 재사용하지만 같은 인터페이스를 공유하지 않는다면 이를 활용한다. 예를 들어 캐싱 클래스를 디자인해보자.

@interface OBJCache : NSObject
- (void)cacheValue:(id)value forKey:(NSString *)key;
- (void)removeCachedValueForKey:(NSString *)key;
@end

이 클래스가 하는 일은 키에 따라 캐시값을 저장하고, 역시 키로 캐시 값을 꺼내오는 일이다. NSDictionary와 비슷한 일을 하니, 이 클래스를 서브클래싱하면 되겠구나!!!

@interface OBJCache : NSDictionary
- (void)cacheValue:(id)value forKey:(NSString *)key;
- (void)removeCachedValueForKey:(NSString *)key;
@end

하지만 직접 커스텀 Dictionary를 구현해서 NSDictionary가 어떻게 동작하는지 연구하는 프로젝트가 아니라면 그냥 이 클래스가 사전 프로퍼티를 하나 가지고 이걸 내부적으로 쓰면 된다.

@interface OBJCache : NSObject
- (strong, nonatomic) NSDictionary *dataDict;
- (void)cacheValue:(id)value forKey:(NSString *)key;
- (void)removeCachedValueForKey:(NSString *)key;
@end