Swift & Objective-C

KVO 내부 동작 완벽 정리, isa-swizzling의 정체

KVO는 옵저버를 붙이는 순간 런타임이 객체의 클래스를 동적 서브클래스로 바꿔치기합니다. isa-swizzling의 동작과 class가 거짓말을 하는 이유, setter를 거치지 않으면 알림이 오지 않는 조건을 정리했습니다.

7분 읽기
KVO 내부 동작 완벽 정리, isa-swizzling의 정체 대표 이미지

KVO(Key-Value Observing)는 쓰는 법만 보면 단순합니다. addObserver로 등록하면 프로퍼티가 바뀔 때마다 알림이 옵니다.

관련 글 Objective-C 메서드 스위즐링(Method Swizzling) 완벽 정리에서 배경 개념과 이어지는 적용 사례를 함께 확인할 수 있습니다.

그런데 이상하지 않나요? 프로퍼티 setter에 알림 코드를 넣은 적이 없는데, 런타임은 값이 바뀐 걸 어떻게 알까요?

답은 과감합니다. 옵저버를 붙이는 순간, 런타임이 그 객체의 클래스를 몰래 바꿔치기합니다.

이 기법의 이름이 isa-swizzling입니다. 지난 글의 메서드 스위즐링이 연결표의 “줄”을 바꾸는 일이었다면, 이번엔 객체가 소속된 “클래스” 자체를 갈아끼우는 이야기입니다.


addObserver 순간에 벌어지는 일

Person 객체에 옵저버를 붙이면 런타임은 다음을 수행합니다.

  1. NSKVONotifying_Person이라는 서브클래스를 동적으로 생성합니다
  2. 관찰 대상 프로퍼티의 setter를 오버라이드합니다 — 변경 전후에 알림 코드를 끼워 넣은 버전으로
  3. 그 객체의 isa 포인터를 새 서브클래스로 교체합니다

이후 person.name = @"Kim"을 실행하면 호출 흐름이 이렇게 됩니다.

// NSKVONotifying_Person이 오버라이드한 setter의 의사 코드
- (void)setName:(NSString *)name {
    [self willChangeValueForKey:@"name"];
    [super setName:name];             // 원래 setter 실행
    [self didChangeValueForKey:@"name"];  // 여기서 옵저버에게 통지
}

객체의 메모리 내용은 그대로인데 소속 클래스만 바뀌었으니, 기존 코드는 아무것도 눈치채지 못합니다. 마지막 옵저버가 제거되면 isa는 원래 클래스로 돌아갑니다.

KVO가 NSKVONotifying_Person 동적 서브클래스를 만들고 Person 객체의 isa를 교체하는 흐름도
KVO가 동적 서브클래스를 만들고 isa를 교체하는 순서입니다

클래스가 거짓말을 한다: class vs object_getClass

여기서 재미있는 디테일이 나옵니다. 옵저버가 붙은 객체에게 클래스를 물어보면 답이 두 가지로 갈립니다.

[person class];              // Person — 거짓말
object_getClass(person);     // NSKVONotifying_Person — 진실

런타임은 동적 서브클래스에서 -class 메서드까지 오버라이드해서 원래 클래스를 답하도록 만들어 둡니다. 개발자가 “내 객체 클래스가 왜 이상한 이름이지?“라고 혼란에 빠지지 않게 하려는 배려이자, 구현 세부를 숨기려는 설계입니다.

object_getClass()는 isa 포인터를 직접 읽기 때문에 진짜 클래스가 보입니다. 디버깅 중 두 값이 다르다면, 그 객체는 지금 누군가에게 관찰당하고 있다는 뜻입니다.


setter를 거치지 않으면 알림도 없다

isa-swizzling의 원리를 알면 KVO의 유명한 제약이 당연해집니다.

person.name = @"Kim";       // 알림 발생 — 오버라이드된 setter 경유
person->_name = @"Kim";     // 알림 없음 — ivar 직접 수정, setter를 안 거침

KVO 알림의 진원지는 오버라이드된 setter입니다. ivar를 직접 수정하면 그 경로를 우회하니 아무 일도 일어나지 않습니다. Objective-C에서 프로퍼티 접근에 점 문법(내부적으로 setter 호출)을 권장하는 실질적인 이유 중 하나입니다.

setter 없이 값을 바꿔야 하는 상황이라면 수동 알림으로 감싸면 됩니다.

[self willChangeValueForKey:@"name"];
_name = @"Kim";
[self didChangeValueForKey:@"name"];

반대로 특정 프로퍼티의 자동 알림을 끄고 싶다면 automaticallyNotifiesObserversForKey:에서 NO를 리턴하면 됩니다.


실무에서 조심할 것들

관찰 수명 관리는 여전히 중요합니다. 블록 기반 API가 반환한 NSKeyValueObservation 토큰을 관찰이 필요한 동안 보관해야 합니다(Apple KVO 문서).

관찰을 끝낼 때 토큰을 무효화하거나 해제하면 등록도 정리됩니다. 문자열 기반 API를 유지한다면 addObserverremoveObserver의 수명을 직접 맞춰야 합니다(Apple KVO 문서).

KVO와 메서드 스위즐링을 같은 클래스에 섞으면 순서 문제가 생깁니다. 스위즐링 후 KVO가 붙으면 동적 서브클래스가 스위즐링된 setter를 감싸지만 반대 순서면 서로의 가정이 어긋날 수 있습니다.

런타임을 만지는 기법 두 개가 한 객체에서 만나면 디버깅 난도가 곱해집니다.

Swift에서는 @objc dynamic이 필수입니다.

class Person: NSObject {
    @objc dynamic var name: String = ""
}

let observation = person.observe(\.name, options: [.new]) { _, change in
    print(change.newValue ?? "")
}

isa-swizzling이 작동하려면 setter 호출이 Objective-C 메시지 경로로 흘러야 하기 때문입니다.

Swift 4부터는 블록 기반 observe(_:options:changeHandler:) API를 씁니다. 반환된 NSKeyValueObservation 토큰이 해제될 때 등록 해제까지 자동으로 처리해 줍니다. Using Key-Value Observing in Swift

덕분에 문자열 keyPath 시절의 사고 대부분이 이 구조 하나로 막힙니다.

KVO isa-swizzling 전 Person 객체와 NSKVONotifying_Person 동적 서브클래스를 비교한 그림
겉으로는 Person, 런타임 클래스는 NSKVONotifying_Person입니다

정리

  • KVO는 옵저버 등록 순간 NSKVONotifying_ 서브클래스를 동적 생성해 isa를 바꿔치기합니다
  • 알림의 진원지는 오버라이드된 setter — willChange/didChange가 끼워진 버전입니다
  • -class는 원래 클래스를 답하도록 조작돼 있고, 진짜는 object_getClass()로 보입니다
  • ivar 직접 수정은 setter를 우회하므로 알림이 나가지 않습니다 — 필요하면 willChange/didChange로 수동 통지합니다
  • Swift에서는 @objc dynamic + 블록 기반 observe API가 표준이고, 토큰 해제가 곧 등록 해제입니다

메서드 스위즐링과 isa-swizzling까지 보면 Objective-C 런타임의 그림이 잡힙니다. 메서드 연결표도, 객체의 소속 클래스도 전부 런타임에 바꿀 수 있는 데이터라는 것 — 이 유연함이 Cocoa 프레임워크 마법 대부분의 원천입니다.

API와 버전 근거는 Using Key-Value Observing in Swift에서 2026-08-17에 확인했습니다.

이어서 읽기

출처 및 확인 기준