Firebase Analytics를 붙이면 화면 진입 이벤트가 자동으로 찍힙니다. viewDidAppear에 코드를 한 줄도 안 넣었는데 말이죠.
이게 가능한 이유가 **메서드 스위즐링(Method Swizzling)**입니다. 런타임에 메서드의 구현을 통째로 바꿔치기하는 기법인데, Objective-C 런타임의 유연함을 보여주는 대표 사례이자, 잘못 쓰면 디버깅 지옥을 여는 양날의 검이기도 합니다.
objc_msgSend 글에서 다뤘듯 Objective-C 메서드 호출은 “셀렉터로 IMP(함수 포인터)를 찾아 점프”하는 구조입니다. 스위즐링은 바로 이 셀렉터→IMP 연결표를 런타임에 수정하는 일입니다.
원리: 연결표의 두 줄을 맞바꾼다
클래스의 메서드 리스트는 “셀렉터 → IMP” 매핑 테이블입니다. method_exchangeImplementations는 두 항목의 IMP를 서로 맞바꿉니다.
| 셀렉터 | 교체 전 IMP | 교체 후 IMP |
|---|---|---|
viewDidAppear: |
원본 구현 | 내 구현 |
swz_viewDidAppear: |
내 구현 | 원본 구현 |
교체 후에는 시스템이 viewDidAppear:를 호출하는 순간 내 구현이 실행됩니다. 원본이 사라진 게 아니라 swz_viewDidAppear:라는 이름 뒤로 이사 갔을 뿐입니다.
관례적인 구현 전체
실무에서 통용되는 안전 장치가 다 들어간 형태는 이렇습니다.
@implementation UIViewController (Tracking)
+ (void)load {
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
Class cls = [self class];
SEL originalSEL = @selector(viewDidAppear:);
SEL swizzledSEL = @selector(swz_viewDidAppear:);
Method original = class_getInstanceMethod(cls, originalSEL);
Method swizzled = class_getInstanceMethod(cls, swizzledSEL);
BOOL added = class_addMethod(cls, originalSEL,
method_getImplementation(swizzled),
method_getTypeEncoding(swizzled));
if (added) {
class_replaceMethod(cls, swizzledSEL,
method_getImplementation(original),
method_getTypeEncoding(original));
} else {
method_exchangeImplementations(original, swizzled);
}
});
}
- (void)swz_viewDidAppear:(BOOL)animated {
[self swz_viewDidAppear:animated]; // 재귀 아님! 아래 설명
NSLog(@"화면 진입: %@", NSStringFromClass([self class]));
}
@end
이 코드에서 헷갈리기 쉬운 포인트가 두 곳 있습니다.
첫째, [self swz_viewDidAppear:animated]는 재귀가 아닙니다. 이 코드가 실행되는 시점엔 이미 IMP가 맞바뀐 뒤라서 swz_ 셀렉터에는 원본 구현이 연결돼 있습니다. 즉 이 한 줄이 “원본 호출”입니다. 원본을 빼먹으면 화면 전환 로직이 통째로 증발하니, 사실상 필수입니다.
둘째, 왜 method_exchangeImplementations를 바로 안 부르고 class_addMethod를 먼저 시도할까요? 대상 메서드가 그 클래스가 아니라 부모 클래스에만 구현돼 있는 경우 때문입니다. 이때 바로 exchange하면 부모 클래스의 메서드를 바꿔버려서 그 부모를 상속한 다른 서브클래스 전부가 영향을 받습니다. class_addMethod가 성공하면 “이 클래스에 원본을 새로 추가”한 것이므로 안전하게 자기 클래스 범위에서만 교체됩니다.
+load와 dispatch_once를 쓰는 이유는 간단합니다. 스위즐링은 전역 상태를 바꾸는 일이라 앱 수명에서 딱 한 번, 가장 이른 시점에 일어나야 하기 때문입니다. +load의 정확한 호출 시점은 다음 글(+load vs +initialize)에서 따로 다룹니다.
조용한 부작용: _cmd가 거짓말을 한다
스위즐링된 메서드 안에서 _cmd(현재 셀렉터)를 출력하면 viewDidAppear:가 아니라 swz_viewDidAppear:가 나옵니다. 구현은 바뀌었지만 호출 경로의 셀렉터는 그대로이기 때문입니다.
평소엔 문제가 없지만 _cmd를 키로 쓰는 코드(예: Associated Objects의 키로 _cmd를 쓰는 패턴)와 섞이면 미묘한 버그가 됩니다. 원본 구현이 내부적으로 _cmd에 의존하고 있었다면 동작이 달라질 수 있습니다.
어디까지 써도 되는가
스위즐링이 정당화되는 영역은 꽤 좁습니다.
- 전 화면 공통 계측: Analytics·로깅 SDK가 화면 진입, 버튼 탭을 자동 수집할 때
- 서드파티·시스템 버그 우회: 소스가 없는 프레임워크의 동작을 임시로 교정할 때
- 개발용 디버깅 도구: 특정 메서드 호출을 전부 추적하고 싶을 때
반대로 이런 상황이라면 위험 신호입니다.
- 여러 라이브러리가 같은 메서드를 스위즐링하면 실행 순서가 로드 순서에 좌우되고 하나가 원본 호출을 빼먹으면 나머지 전부가 무너집니다
- 스택 트레이스에
swz_메서드가 끼어들어 크래시 리포트 해석이 어려워집니다 - 시스템 내부 구현에 기대는 스위즐링은 OS 업데이트 한 번에 깨질 수 있습니다
그래서 서브클래싱, 델리게이트 프록시, 컴포지션으로 해결되는 문제라면 그쪽이 항상 먼저입니다. 스위즐링은 “다른 방법이 구조적으로 불가능할 때”의 마지막 카드로 남겨두는 게 맞습니다.
Swift에서는?
순수 Swift 메서드는 정적 디스패치(또는 vtable)라서 이 기법이 통하지 않습니다. 스위즐링하려면 대상 메서드가 Objective-C 런타임에 노출돼 있어야 합니다.
class Tracker: NSObject {
@objc dynamic func fire() { }
}
@objc dynamic이 붙어야 objc_msgSend 경로로 호출이 흐르고 그래야 연결표를 바꿔칠 수 있습니다. UIKit 클래스들은 Objective-C 기반이라 여전히 스위즐링이 통하지만 SwiftUI 세계로 갈수록 이 기법의 자리는 좁아지고 있습니다.
정리
- 스위즐링은 셀렉터→IMP 연결표를 런타임에 맞바꾸는 기법입니다 — 원본은 사라지지 않고 다른 셀렉터 뒤로 이사합니다
- swizzled 구현 안의
[self swz_...]호출은 재귀가 아니라 원본 호출입니다 class_addMethod먼저 시도하는 이유는 부모 클래스 메서드를 건드리지 않기 위해서입니다+load+dispatch_once로 앱 수명에서 한 번만 실행되게 합니다_cmd불일치, 라이브러리 간 충돌, OS 업데이트 리스크가 실존하니 마지막 카드로만 씁니다- Swift에서는
@objc dynamic이 붙은 메서드만 스위즐링됩니다
다음 글에서는 스위즐링과 헷갈리기 쉬운 또 하나의 런타임 마법, KVO(Key-Value Observing)의 isa-swizzling을 뜯어봅니다. 메서드가 아니라 클래스 자체를 바꿔치기하는 이야기입니다.
