iOS Objective-C Runtime 의 핵심 원리와 실무 적용 방안

Runtime 시스템 개요

Objective-C 는 정적 컴파일 언어가 아닌 동적 언어로 분류됩니다. 이는 많은 결정 사항들이 컴파일 시점이 아닌 실행 시점 (Runtime) 에 이루어진다는 것을 의미합니다. 컴파일러만으로는 충분하지 않으며, 컴파일된 코드를 실제로 실행하기 위해 런타임 시스템이 필요합니다. Objective-C Runtime 은 바로 이 역할을 수행하며, Objective-C 언어 체계의 근간을 이룹니다.

C 언어의 경우 함수 호출이 컴파일 시점에 바인딩되지만, Objective-C 는 메시징 기반의 동적 호출 방식을 사용합니다. 컴파일 단계에서는 어떤 함수가 호출될지 확정되지 않으며, 실제 싖 환경에서 메서드 이름을 기반으로 적절한 구현체를 찾아 호출합니다. 이러한 높은 동적 성격을 구현하기 위해 Runtime 은 주로 C 와 어셈블리로 작성되어 성능 최적화가 이루어져 있습니다.

Runtime 헤더 파일 및 주요 타입

iOS SDK 의 usr/include/objc 디렉토리에는 런타임 관련 헤더 파일들이 포함되어 있습니다. 그중에서도 message.hruntime.h 가 가장 핵심적입니다. 전자는 객체에게 메시지를 전송하는 저수준 함수들을 정의하며, 후자는 런타임 시스템을 조작하는 다양한 API 를 제공합니다.

사용 시에는 다음과 같이 헤더를 임포트해야 합니다.

#import <objc/message.h>
#import <objc/runtime.h>

runtime.h 에는 런타임 조작을 위한 핵심 타입들이 정의되어 있습니다. 클래스, 메서드, 인스턴스 변수 등을 불투명 포인터 타입으로 추상화하여 제공합니다.

// 클래스 정의 내의 메서드를 나타내는 타입
typedef struct objc_method *Method;

// 인스턴스 변수를 나타내는 타입
typedef struct objc_ivar *Ivar;

// 카테고리를 나타내는 타입
typedef struct objc_category *Category;

// Objective-C 프로퍼티를 나타내는 타입
typedef struct objc_property *objc_property_t;

// 클래스 자체를 나타내는 타입 (objc.h 정의)
typedef struct objc_class *Class;

이러한 타입 정의를 통해 클래스의 내부 구조를 세부적으로 분해하고 조작할 수 있습니다. Objective-C 2.0 이후 일부 구조체 멤버는 직접 접근이 제한되었지만 (OBJC2_UNAVAILABLE), 대신 제공되는 접근자 함수를 통해 안전하게 값을 얻을 수 있습니다.

Class userClass = [User class];
// 직접 구조체 접근은 불가능하며, 전용 함수 사용
const char *className = class_getName(userClass);
NSLog(@"%s", className); // 출력: User

API 네이밍 컨벤션

Runtime 함수들은 접두사를 통해 조작 대상에 따른 계층 구조를 명확히 보여줍니다.

  • object_: 객체 인스턴스 조작
  • class_: 클래스 객체 조작
  • method_: 메서드 조작
  • ivar_: 인스턴스 변수 조작
  • property_: 프로퍼티 조작
  • protocol_: 프로토콜 조작

또한 objc_ 접두사를 가진 함수들은 런타임 시스템 전체를 관리하며, 메모리 상의 클래스 목록 조회나 연관 객체 관리 등 광범위한 작업을 수행합니다. 예를 들어, 현재 실행 중인 앱에 로드된 모든 클래스 목록을 출력하는 코드는 다음과 같습니다.

- (void)analyzeLoadedClasses {
    unsigned int totalClasses = 0;
    Class *classList = objc_copyClassList(&totalClasses);
    
    for (unsigned int i = 0; i < totalClasses; i++) {
        const char *name = class_getName(classList[i]);
        printf("%s\n", name);
    }
}

메시지 전송 메커니즘

Objective-C 에서 메서드 호출은 본질적으로 객체에게 메시지를 보내는 행위입니다. 컴파일러는 [object method] 형태의 코드를 objc_msgSend 함수 호출로 변환합니다.

// 일반적인 메서드 호출
User *user = [[User alloc] init];
[user authenticate];

//底层 메시지 전송 형태로 변환됨
objc_msgSend(user, @selector(authenticate));

// 클래스 메서드 호출 역시 객체 (메타클래스) 에게 메시지 전송
[User login]; 
// 변환됨: objc_msgSend([User class], @selector(login));

컴파일 옵션 -rewrite-objc 를 사용하면 clang 을 통해 소스 코드가 어떻게 변환되는지 직접 확인해 볼 수 있습니다.

메서드 구현 교환 (Method Swizzling)

기존 코드를 수정하지 않고 특정 메서드의 동작을 변경해야 할 때 메서드 구현 교환 기법을 사용합니다. 주로 로그 기록, 성능 분석, 기능 확장 등의 목적으로 활용됩니다. 클래스가 메모리에 로드될 때 한 번만 호출되는 load 메서드 내에서 구현하는 것이 안전합니다.

예를 들어, 모든 뷰 컨트롤러의 viewDidLoad 호출 시점을 로그로 남기려면 다음과 같이 구현할 수 있습니다.

@implementation UIViewController (Logging)

+ (void)load {
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        Class class = [self class];
        
        // 원래 메서드와 교체할 메서드 선택자 획득
        SEL originalSelector = @selector(viewDidLoad);
        SEL swizzledSelector = @selector(logged_viewDidLoad);
        
        Method originalMethod = class_getInstanceMethod(class, originalSelector);
        Method swizzledMethod = class_getInstanceMethod(class, swizzledSelector);
        
        // 메서드 구현 교환
        method_exchangeImplementations(originalMethod, swizzledMethod);
    });
}

- (void)logged_viewDidLoad {
    // 교환되었으므로 실제로는 원래 viewDidLoad 가 호출됨
    [self logged_viewDidLoad]; 
    NSLog(@"View Loaded: %@", NSStringFromClass([self class]));
}

@end

연관 객체 (Associated Objects)

카테고리를 사용하여 클래스에 새로운 프로퍼티를 추가할 때, 인스턴스 변수를 직접 추가할 수 없으므로 연관 객체 기능을 사용합니다. 이는 객체에 키 - 값 쌍을 동적으로 바인딩하는 방식입니다.

예를 들어, 시스템 클래스인 UIView 에 고유 식별자 프로퍼티를 동적으로 추가하는 사례입니다.

static char kUniqueIdentifierKey;

@implementation UIView (Identifier)

- (NSString *)uniqueID {
    return objc_getAssociatedObject(self, &kUniqueIdentifierKey);
}

- (void)setUniqueID:(NSString *)uniqueID {
    objc_setAssociatedObject(self, 
                             &kUniqueIdentifierKey, 
                             uniqueID, 
                             OBJC_ASSOCIATION_COPY_NONATOMIC);
}

@end

또한, 콜백 객체 (예: 버튼 타겟) 에 추가 데이터를 전달할 때도 유용합니다. UIAlertView 대신 UIButton 에 데이터를 연관시켜 관리할 수 있습니다.

- (void)configureButton:(UIButton *)button withData:(NSDictionary *)data {
    // 버튼에 데이터 연관시키기
    objc_setAssociatedObject(button, "action_data", data, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
    [button addTarget:self action:@selector(buttonTapped:) forControlEvents:UIControlEventTouchUpInside];
}

- (void)buttonTapped:(UIButton *)sender {
    NSDictionary *data = objc_getAssociatedObject(sender, "action_data");
    // 연관된 데이터 활용 로직
}

objc_setAssociatedObject 의 네 번째 파라미터인 정책 (policy) 은 메모리 관리 방식 (Retain, Copy, Assign 등) 을 결정합니다.

동적 메서드 해결 (Dynamic Method Resolution)

존재하지 않는 메서드를 호출하려 할 때, 런타임은 클래스에 resolveInstanceMethod: 와 같은 기회를 제공하여 동적으로 메서드를 추가할 수 있게 합니다. 이는 메서드 구현 테이블을 지연 생성하여 리소스를 절약하거나, 특정 프로토콜을 동적으로 준수하게 만들 때 사용됩니다.

@implementation TaskManager

- (void)viewDidLoad {
    [super viewDidLoad];
    TaskManager *manager = [[TaskManager alloc] init];
    // 구현되지 않은 메서드 호출 시도
    [manager performSelector:@selector(executeTask)];
}

// 구현되지 않은 인스턴스 메서드 호출 시 인터셉트
+ (BOOL)resolveInstanceMethod:(SEL)sel {
    if (sel == @selector(executeTask)) {
        // 동적으로 메서드 추가
        // 타입 인코딩: void 반환 (@: self, :_cmd)
        class_addMethod(self, sel, (IMP)dynamicTaskImplementation, "v@:");
        return YES;
    }
    return [super resolveInstanceMethod:sel];
}

void dynamicTaskImplementation(id self, SEL _cmd) {
    NSLog(@"Dynamic task executed on %@", self);
}

@end

Runtime 를 활용한 딕셔너리 - 모델 변환

KVC(Key-Value Coding) 를 사용하면 딕셔너리 데이터를 모델 객체에 쉽게 매핑할 수 있지만, 존재하지 않는 키에 대해 오류가 발생할 수 있습니다. Runtime 를 이용하여 모델의 인스턴스 변수를 순회하며 딕셔너리 데이터를 안전하게 바인딩하는 방법을 구현할 수 있습니다.

+ (instancetype)objectWithJSON:(NSDictionary *)json {
    id instance = [[self alloc] init];
    
    unsigned int count = 0;
    // 클래스의 인스턴스 변수 목록 획득
    Ivar *ivarList = class_copyIvarList(self, &count);
    
    for (unsigned int i = 0; i < count; i++) {
        Ivar ivar = ivarList[i];
        const char *name = ivar_getName(ivar);
        NSString *key = [NSString stringWithUTF8String:name];
        
        // 인스턴스 변수는 보통 '_' 로 시작하므로 제거
        if ([key hasPrefix:@"_"]) {
            key = [key substringFromIndex:1];
        }
        
        id value = json[key];
        if (value) {
            // 중첩된 딕셔너리 처리 등 추가 로직 가능
            [instance setValue:value forKey:key];
        }
    }
    free(ivarList);
    return instance;
}

Runtime 메시지 Lookup 흐름

메서드 호출 시 런타임은 다음과 같은 순서로 구현체를 찾습니다.

  1. 객체의 isa 포인터를 통해 클래스 구조체를 찾습니다.
  2. 클래스의 메서드 캐시 (Cache) 에서 선택자 (SEL) 에 해당하는 구현체 주소를 검색합니다.
  3. 캐시에 없다면 메서드 목록 (Method List) 을 순회하며 찾습니다.
  4. 현재 클래스에서 찾지 못하면 슈퍼클래스로 연쇄적으로 검색을 계속합니다.
  5. 최종적으로 구현체를 찾지 못하면 메시지 전달 체인 (forwardingTarget, forwardInvocation 등) 을 통해 오류 처리 또는 대체 실행을 시도합니다.

이러한 동적 바인딩 과정 덕분에 Objective-C 는 강력한 유연성을 가지지만, 동시에 런타임 오버헤드가 발생할 수 있음을 이해해야 합니다.

태그: Objective-C iOS-Runtime Method-Swizzling Associated-Objects Dynamic-Method-Resolution

8월 16일 01:16에 게시됨