Objective-C 코드에서 대괄호로 감싼 [receiver message] 구문, 겉보기엔 그냥 메서드 호출처럼 보입니다.
그런데 이게 C++이나 Java의 메서드 호출과는 근본적으로 다른 물건입니다.
결론부터 말씀드릴게요.
Objective-C의 메서드 호출은 “함수 호출”이 아니라 “메시지 전달”입니다.
어떤 코드가 실행될지는 컴파일 시점이 아니라 런타임에 결정됩니다.
이 차이를 알면 메서드 스위즐링, KVO(Key-Value Observing), 크래시 로그의 unrecognized selector까지 한 줄로 꿰어집니다. Effective Objective-C 2.0의 11~12번 항목이 다루는 내용이기도 합니다.
핵심 요약부터 (3가지)
[obj foo]는 컴파일되면 objc_msgSend(obj, @selector(foo)) 함수 호출로 바뀝니다.- objc_msgSend는 런타임에 클래스의 메서드 테이블을 탐색해서 실행할 함수(IMP)를 찾습니다.
- 못 찾으면 바로 크래시가 아니라 메시지 포워딩이라는 3단계 구제 절차가 돌아갑니다.
모든 메시지는 objc_msgSend로 모입니다
C++의 가상 함수는 컴파일 시점에 vtable 인덱스가 정해집니다. 반면 Objective-C는 거의 모든 메시지가 objc_msgSend라는 관문을 지나갑니다. 정확히 말하면 [super foo]는 objc_msgSendSuper로 컴파일되고, x86-64 시절엔 구조체 반환용 objc_msgSend_stret 같은 형제 함수도 있었지만(arm64에서는 objc_msgSend로 통합), 동작 원리는 전부 같습니다.
NSString *result = [greeting uppercaseString];
이 코드는 컴파일러를 거치면 이런 C 함수 호출이 됩니다.
NSString *result = objc_msgSend(greeting, @selector(uppercaseString));
objc_msgSend가 하는 일은 단순합니다.
- receiver의 isa 포인터를 따라 클래스를 찾는다
- 클래스의 메서드 캐시를 먼저 뒤진다 (매우 빠름)
- 캐시에 없으면 메서드 리스트를 탐색하고 없으면 부모 클래스로 올라간다
- 찾은 함수 포인터(IMP)로 점프한다
찾은 결과는 캐시에 저장되기 때문에 같은 메시지를 두 번째 보낼 때부터는 함수 호출에 가까운 속도가 나옵니다. “동적 디스패치라 느리다”는 걱정이 실무에서 거의 문제가 안 되는 이유입니다.
못 찾으면? 메시지 포워딩 3단계
메서드 탐색이 최상위 클래스까지 올라가도 실패하면, 런타임은 크래시 전에 세 번의 기회를 줍니다.
1단계: 동적 메서드 해석 (resolveInstanceMethod:)
“이 셀렉터에 해당하는 메서드를 지금이라도 추가할래?“라고 클래스에 물어봅니다. Core Data의 @dynamic 프로퍼티가 이 단계에서 접근자를 실시간으로 만들어 붙입니다.
+ (BOOL)resolveInstanceMethod:(SEL)sel {
if (sel == @selector(dynamicMethod)) {
class_addMethod(self, sel, (IMP)dynamicIMP, "v@:");
return YES;
}
return [super resolveInstanceMethod:sel];
}
2단계: 대체 수신자 (forwardingTargetForSelector:)
“네가 처리 못 하면 대신 처리할 객체라도 알려줄래?“라고 묻습니다. 다른 객체를 리턴하면 메시지가 그쪽으로 넘어갑니다. 상속 없이 “다중 상속 비슷한 것”을 흉내 낼 때 쓰이는 지점입니다.
3단계: 완전한 포워딩 (forwardInvocation:)
이 단계에 들어가기 전에 런타임은 methodSignatureForSelector:로 메서드 시그니처부터 물어봅니다. 여기서 nil이 돌아오면 포워딩은 열리지도 않고 곧장 doesNotRecognizeSelector:로 넘어가 크래시합니다. 시그니처가 있으면 그걸 바탕으로 메시지 전체가 NSInvocation 객체로 포장되어 넘어옵니다. 여기서는 인자를 바꾸거나, 여러 객체에 뿌리거나, 아예 조용히 삼켜버릴 수도 있습니다. 가장 유연하지만 가장 비쌉니다.
3단계까지 전부 실패했을 때 비로소 그 유명한 크래시가 납니다.
-[MyViewController buttonTapped:]: unrecognized selector sent to instance 0x7f8a2c400c50
이 크래시 로그가 “함수가 없다”가 아니라 “셀렉터를 인식하지 못했다”인 이유, 이제 명확해집니다. 컴파일 타임엔 아무 문제가 없었고 런타임 탐색과 포워딩이 전부 실패한 결과물이니까요.
이 구조가 만들어낸 것들
이 런타임 구조 위에서 iOS 개발의 익숙한 기능들이 돌아갑니다.
- KVO: 옵저버를 붙이면 런타임이 해당 객체의 isa를 몰래 서브클래스로 바꿔치기합니다
- 메서드 스위즐링: 셀렉터와 IMP의 연결을 런타임에 교체합니다
- NSProxy: 포워딩만으로 동작하는 대리 객체를 만듭니다 (NSTimer 순환 참조 해결에도 등장합니다)
- @dynamic: 접근자를 컴파일 타임에 만들지 않고 1단계 포워딩에서 해결합니다
Swift가 메시지 디스패치 대신 정적·vtable(테이블) 디스패치를 택하면서 이 유연함 대신 속도와 안전을 가져갔는데, 그래서 역으로 Swift에서 KVO를 쓰려면 @objc dynamic을 붙여야 합니다. Objective-C 런타임의 메시징 세계로 그 프로퍼티를 다시 넘겨주는 표시인 셈입니다.
마무리
- Objective-C 메서드 호출의 실체는 objc_msgSend를 통한 메시지 전달입니다
- 실행할 코드는 런타임에 결정되고, 캐시 덕분에 속도 걱정은 접어두셔도 됩니다
- 탐색 실패 시 동적 해석 → 대체 수신자 → 완전한 포워딩 순서로 구제 기회가 있습니다
- unrecognized selector 크래시는 이 모든 단계가 실패했다는 뜻입니다
Objective-C 레거시 코드를 만질 일이 없더라도, UIKit 아래에서 여전히 이 런타임이 돌고 있습니다. 크래시 로그 한 줄이 다르게 읽히기 시작하면, 디버깅의 결이 달라집니다.
