NotificationCenter, KVO(Key-Value Observing), Observation 프레임워크는 전부 "무언가 바뀌면 알려달라"는 같은 문제를 서로 다른 시대의 서로 다른 방식으로 푼다. NotificationCenter는 이름만으로 느슨하게 엮인 브로드캐스트이고, KVO는 Objective-C 런타임이 프로퍼티 setter를 몰래 가로채는 저수준 트릭이며, Observation은 Swift 매크로가 컴파일 타임에 접근 추적 코드를 심어 넣는 최신 해법이다. 이 챕터는 셋 모두 macOS에서 그대로 동작한다는 점을 이용해 "post는 정말 동기인가", "isa 포인터가 진짜로 바뀌는가", "@Observable은 정말로 뷰를 덜 다시 그리는가" 같은 주장을 전부 실제 코드로 직접 확인한다.
Q1. NotificationCenter.post는 동기인가 비동기인가? 옵저버는 어느 스레드에서 실행되는가?
post는 동기다. queue에 nil을 넘기면(가장 흔한 형태) 모든 옵저버가 실행을 마칠 때까지 post가 반환되지 않고, 옵저버는 게시(post)를 호출한 바로 그 스레드에서 실행된다. Apple 공식 문서도 "queue가 nil이면 블록은 게시 스레드에서 동기적으로 실행된다"고 명시한다. 결과는 두 가지다 — 느린 옵저버가 게시자를 그만큼 붙잡아 두고, 백그라운드 스레드에서 post하면 UI를 만지는 옵저버도 그 백그라운드 스레드에서 돈다.
원리
NotificationCenter는 옵저버 패턴을 "이름(Notification.Name)"이라는 느슨한 식별자로 구현한 브로드캐스트 메커니즘이다. 공식 문서는 이를 "등록된 옵저버에게 정보를 브로드캐스트하는 통지 디스패치 메커니즘"이라 정의한다. 큐를 넘기지 않는 한, 이 브로드캐스트는 함수 호출과 다를 게 없다 — post가 옵저버 블록들을 순서대로 직접 호출하고, 그 호출들이 전부 끝나야 post 자신도 반환된다.
내부 동작
addObserver(forName:object:queue:using:) 문서의 queue 파라미터 설명이 이 챕터에서 가장 중요한 한 문장이다: "queue가 nil이면, 블록은 게시 스레드에서 동기적으로 실행된다(When nil, the block runs synchronously on the posting thread)." 즉 queue: nil은 "메인 스레드 보장"이 아니라 "스레드를 안 바꾼다"는 뜻이다. OperationQueue.main을 넘기면 그 순간부터는 진짜 비동기가 된다 — post는 작업을 그 큐에 올려두고 곧바로 반환하고, 옵저버는 나중에 메인 큐 차례가 됐을 때 실행된다. 옵저버가 여러 개고 각자 다른 큐를 지정했다면 그 옵저버들끼리는 병렬로 실행될 수 있지만, queue: nil로 등록된 옵저버는 게시자와 같은 스레드에서 하나씩 순서대로 실행된다.
실험 · 도구
옵저버 안에 Thread.sleep을 넣고 post 앞뒤의 시각을 재면 동기라는 주장이 숫자로 증명된다.
import Foundation
let center = NotificationCenter.default
let name = Notification.Name("test.sync")
center.addObserver(forName: name, object: nil, queue: nil) { _ in
print(" 옵저버 시작 — thread:", Thread.current, "isMain:", Thread.isMainThread)
Thread.sleep(forTimeInterval: 0.5)
print(" 옵저버 종료 (0.5초 대기 후)")
}
let t0 = Date()
print("post() 호출 직전:", Thread.current)
center.post(name: name, object: nil)
let elapsed = Date().timeIntervalSince(t0)
print("post() 반환 — 경과 시간: \(elapsed)초")
// 실제 실행 결과:
// post() 호출 직전: <_NSMainThread: ...>{number = 1, name = main}
// 옵저버 시작 — thread: <_NSMainThread: ...> isMain: true
// 옵저버 종료 (0.5초 대기 후)
// post() 반환 — 경과 시간: 0.5048890113830566초옵저버 안에서 0.5초를 잤을 뿐인데 post() 호출 자체가 정확히 그만큼 늦게 반환됐다. 옵저버가 백그라운드 스레드에서 실행되길 기대하며 Thread.isMainThread를 찍어보면, 메인 스레드에서 post했을 때는 true가 나온다 — "post는 게시 스레드를 그대로 물려준다"는 것을 보여준다.
프로젝트 적용
① 메인 스레드에서 post될 가능성이 있는 알림의 옵저버 안에는 무거운 동기 작업(디코딩, 대량 순회, 디스크 I/O)을 넣지 않는다. 넣는 순간 그 옵저버가 게시자의 실행을 그만큼 붙잡고, 게시자가 UI 이벤트 처리 중이었다면 프레임 드랍으로 바로 이어진다.
// ❌ 옵저버가 곧 게시자의 실행 시간이 된다
NotificationCenter.default.addObserver(forName: .dataDidUpdate, object: nil, queue: nil) { note in
let parsed = expensiveDecode(note.userInfo) // 이 시간만큼 post() 호출자가 멈춘다
render(parsed)
}
// ✅ 받는 즉시 별도 큐로 넘겨 게시자를 붙잡지 않는다
NotificationCenter.default.addObserver(forName: .dataDidUpdate, object: nil, queue: nil) { note in
let payload = note.userInfo
Task.detached(priority: .userInitiated) {
let parsed = expensiveDecode(payload)
await MainActor.run { render(parsed) }
}
}② 백그라운드 스레드에서 post할 때는, UI를 만지는 옵저버가 있다는 걸 알고 있다면 명시적으로 queue: .main을 지정하거나, 옵저버 쪽에서 스스로 메인으로 다시 튄다. "옵저버가 알아서 메인에서 돌겠지"라고 가정하면 안 된다.
DispatchQueue.global().async {
// ... 네트워크 작업 ...
// ❌ queue: nil로 등록된 UI 옵저버가 있다면 그 옵저버도 백그라운드에서 돈다
NotificationCenter.default.post(name: .downloadFinished, object: nil)
}
// 옵저버 등록 시점에 명시적으로 메인 큐를 지정해두면 안전하다
NotificationCenter.default.addObserver(forName: .downloadFinished, object: nil, queue: .main) { _ in
imageView.image = downloadedImage // 항상 메인 큐에서 실행됨이 보장된다
}"큐를 안 넘기면 시스템이 알아서 메인 스레드에서 실행해준다"는 틀렸다. queue: nil은 "게시 스레드를 그대로 쓴다"는 뜻이지 "메인 스레드를 보장한다"는 뜻이 아니다. 백그라운드 스레드에서 post하면 queue: nil로 등록된 옵저버도 정확히 그 백그라운드 스레드에서 실행된다 — "가끔 이상하게 UI가 백그라운드에서 갱신되다 크래시난다"는 버그의 흔한 원인이다.
동기 post는 반 전체에게 공지사항을 직접 말로 전달하는 것과 같다. 선생님이 "오늘 숙제는~"이라고 말을 시작하면, 모든 학생이 다 알아들을 때까지 선생님은 다음 할 일로 못 넘어간다. 비동기(queue: .main)는 공지사항을 쪽지로 써서 게시판에 붙여두는 것과 비슷하다 — 붙이자마자 선생님은 바로 다음 수업 준비를 하러 가고, 학생들은 각자 편한 때 게시판을 보고 읽는다. 비유가 깨지는 곳: 진짜 쪽지 게시판은 아무 때나 확인해도 상관없지만, OperationQueue.main은 "메인 스레드가 한가해지는 대로 최대한 빨리"라는 시점이 있어서 보통 눈 깜짝할 사이(밀리초 이하)에 처리된다 — 며칠씩 안 볼 수도 있는 진짜 게시판과는 다르다.
꼬리 질문
같은 이름에 옵저버가 여러 개 등록돼 있으면 실행 순서가 보장되는가?
queue: nil로 등록된 옵저버들은 게시 스레드에서 순차 실행되므로 실무에서는 대체로 등록한 순서와 비슷하게 동작하는 걸 관찰할 수 있지만, 이는 구현 세부사항이지 계약이 아니다. 옵저버 간에 순서 의존성이 있는 로직을 짠다면 그 자체가 설계 결함으로 봐야 한다 — 순서가 필요하면 옵저버 밖에서 명시적으로 조율해야 한다.옵저버 안에서 같은 알림을 다시 post하면 어떻게 되는가?
AsyncSequence 기반 `notifications(named:)`는 동기/비동기 구분이 어떻게 다른가?
for await notification in center.notifications(named: name) { }처럼 await로 순회해야 하는 비동기 시퀀스지만, 내부적으로 post 자체의 동기성이 바뀌는 건 아니다. 오히려 신경 써야 할 건 다른 문제다 — Notification 타입 자체가 Sendable을 준수하지 않아서 이 시퀀스를 격리 경계 너머로 순회하면 컴파일러 경고가 뜬다. 그래서 실무에서는 .map { $0.name }처럼 Sendable한 프로퍼티만 뽑아 쓰는 패턴을 권장한다.Q2. 블록 기반 옵저버는 왜 반드시 토큰을 보관하고 제거해야 하는가? iOS 26의 타입 메시지 API는 무엇을 바꿨나?
addObserver(forName:object:queue:using:)는 불투명한 토큰을 반환하는데, notification center가 그 토큰과 블록을 모두 강하게 붙잡고 있다가 removeObserver를 호출해야만 놓아준다. 토큰을 버리면 다시는 제거할 수 없고, 블록이 self를 캡처하고 있었다면 그 객체도 영원히 해제되지 않는다. iOS 26에서 추가된 NotificationCenter.MainActorMessage/AsyncMessage는 타입 있는 Sendable 메시지 구조체와, 스코프를 벗어나면 자동으로 등록 해제되는 ObservationToken을 도입해 이 문제를 근본적으로 없앴다.
원리
옛 Notification 모델은 이름(문자열 기반 Notification.Name)과 타입 없는 userInfo: [AnyHashable: Any]? 딕셔너리로 이뤄져 있다. 등록은 명령형이다 — 옵저버를 "더한다"는 동작 자체가 상태(딕셔너리 안 엔트리)를 만들고, 그 상태를 되돌리는 것도 별도의 명시적 동작(removeObserver)이다. 이 모델은 스스로 자기 수명을 관리하지 않는다.
내부 동작
공식 문서가 정확히 명시한다: "notification center는 이 복사된 블록을 옵저버 등록을 제거할 때까지 강하게 붙잡는다"(strongly holds), 그리고 반환값에 대해서도 "notification center는 이 반환값을 옵저버 등록을 제거할 때까지 강하게 붙잡는다"고 못 박는다. 더 나아가 "addObserver(forName:object:queue:using:)가 지정한 어떤 객체든, 시스템이 그 객체를 해제하기 전에 반드시 removeObserver를 호출해야 한다"고 요구한다. 블록이 self를 강하게 캡처하고 있다면(가장 흔한 실수) 이 강한 사슬은 center → 토큰/블록 → self로 이어져, self를 우리 코드에서 아무리 nil로 만들어도 center가 여전히 붙잡고 있어 deinit이 호출되지 않는다.
iOS 26은 이 모델을 구조적으로 바꿨다. NotificationCenter.MainActorMessage와 NotificationCenter.AsyncMessage는 userInfo 딕셔너리 대신 프로퍼티를 가진 Sendable 구조체로 메시지를 정의한다. MainActorMessage는 메인 액터에 고정돼 동기 배달되고, AsyncMessage는 임의의 격리에서 비동기 배달된다(시간에 민감한 메시지에는 부적합하다고 문서가 명시한다). 등록은 addObserver(of:for:using:)가 반환하는 NotificationCenter.ObservationToken으로 관리하는데, 이 토큰은 스코프를 벗어나면 자동으로 옵저버를 해제한다 — 옛 API의 "수동 remove 필수" 부담이 사라진다. makeNotification(_:)/makeMessage(_:)로 기존 Notification 기반 코드와도 상호 변환할 수 있어 점진적 마이그레이션이 가능하다.
실험 · 도구
토큰을 버리고 self를 캡처하면 정말로 객체가 안 죽는지 직접 확인할 수 있다.
import Foundation
let name = Notification.Name("widget.tick")
final class Widget {
init() {
// 토큰을 버림 + self를 암묵적으로 강하게 캡처 — 흔한 실수
_ = NotificationCenter.default.addObserver(forName: name, object: nil, queue: nil) { _ in
self.report()
}
}
func report() { print(" Widget 콜백 실행 — 아직 살아있다는 뜻") }
deinit { print(" Widget deinit 호출됨") }
}
var w: Widget? = Widget()
w = nil // 우리 쪽 강한 참조는 끊었다
print("w = nil 완료")
NotificationCenter.default.post(name: name, object: nil)
print("post 완료")
// 실제 실행 결과:
// w = nil 완료
// Widget 콜백 실행 — 아직 살아있다는 뜻 ← deinit 로그가 안 찍혔다
// post 완료w = nil 이후에도 콜백이 실행됐고, Widget deinit 호출됨 로그는 끝까지 나오지 않는다 — center가 블록을, 블록이 self를 붙잡고 있다는 증거다. iOS 26 타입 메시지 API는 실제로 컴파일·실행되는 것도 확인했다.
import Foundation
final class Event {}
struct EventDidStart: NotificationCenter.MainActorMessage {
typealias Subject = Event
let startedAt: Date
}
let event = Event()
Task { @MainActor in
let token = NotificationCenter.default.addObserver(of: event, for: EventDidStart.self) { message in
print("타입 메시지 수신 — startedAt:", message.startedAt)
}
NotificationCenter.default.post(EventDidStart(startedAt: Date()), subject: event)
_ = token
}
// 실제 출력: 타입 메시지 수신 — startedAt: 2026-07-27 09:22:30 +0000프로젝트 적용
① 옛 API를 계속 써야 한다면(최소 배포 버전이 iOS 26 미만이면 그렇다), 토큰을 반드시 프로퍼티로 보관하고 deinit에서 제거한다.
final class ProfileViewModel {
private var token: NSObjectProtocol?
init() {
token = NotificationCenter.default.addObserver(
forName: .userDidUpdateProfile, object: nil, queue: .main
) { [weak self] note in
self?.reload() // weak self — 순환 참조 자체를 만들지 않는다
}
}
func reload() { /* ... */ }
deinit {
if let token { NotificationCenter.default.removeObserver(token) }
}
}② 최소 배포 버전이 iOS 26 이상이라면 타입 메시지로 옮긴다. userInfo의 Any 캐스팅이 사라지고, 페이로드가 컴파일 타임에 검사된다.
@available(iOS 26, macOS 26, *)
struct ProfileDidUpdate: NotificationCenter.MainActorMessage {
typealias Subject = ProfileStore
let newName: String // userInfo["name"] as? String 같은 캐스팅이 필요 없다
}
@available(iOS 26, macOS 26, *)
final class ProfileObserverExample {
var token: NotificationCenter.ObservationToken?
@MainActor func start(store: ProfileStore) {
// 토큰을 프로퍼티에 저장하면 이 인스턴스가 살아있는 동안만 관찰,
// 저장 안 해도 반환값이 즉시 해제되며 자동으로 등록도 풀린다
token = NotificationCenter.default.addObserver(of: store, for: ProfileDidUpdate.self) { message in
print("새 이름:", message.newName)
}
}
}"[weak self]로 캡처하면 토큰 관리를 안 해도 된다"는 절반만 맞다. weak self는 순환 참조와 self의 수명 연장 문제는 막아주지만, notification center의 등록 딕셔너리 안 엔트리 자체는 여전히 남아 있다. post가 그 이름으로 계속 들어올 때마다 이미 죽은 self를 가리키는 빈 블록이 매번 실행되며 CPU를 조금씩 낭비하고, 오래 쌓이면 등록 테이블 자체가 불필요하게 커진다. weak self는 크래시를 막을 뿐, 제거해야 한다는 사실 자체를 없애주지 않는다.
옛날 잡지 정기구독과 비슷하다. 신청서(토큰)를 내가 서랍에 보관해뒀다가, 더는 안 볼 때 내가 직접 "구독 취소" 전화를 해야 한다. 안 하면 잡지사는 계속 잡지를 보내고, 심지어 내 주소록(캡처된 self)을 계속 붙잡고 있어서 내가 이사를 가려고 해도(deinit하려고 해도) 잡지사 목록에서 안 지워진다. iOS 26 방식은 "구독권 자체에 자동 만료 기능"이 달린 것과 같다 — 구독권(토큰)을 내가 안 쓰게 되면 그 즉시 자동으로 구독도 끊긴다. 비유가 깨지는 곳: 진짜 잡지 구독은 주소만 기억할 뿐 나를 물리적으로 못 붙잡지만, Swift 클로저가 self를 캡처하는 건 진짜로 참조 카운트를 올려 그 객체를 메모리에서 못 내려가게 만든다.
꼬리 질문
selector 기반 addObserver(_:selector:name:object:)는 블록 API와 같은 문제가 있는가?
removeObserver 없이도 크래시는 안 난다. 다만 이건 "안전망"이지 "권장 패턴"은 아니다. 시스템 버전에 따라 동작이 미묘하게 달라졌던 이력이 있어, 여전히 명시적으로 제거하는 습관이 안전하다.MainActorMessage와 AsyncMessage 중 실무에서는 언제 무엇을 쓰나?
MainActorMessage다 — 동기 배달이라 post가 반환되는 시점엔 모든 메인 액터 옵저버가 이미 처리를 끝낸 상태다. 백그라운드 작업 진행 상황 알림처럼 순서·타이밍이 엄격하지 않고 임의의 액터에서 게시해야 하는 경우엔 AsyncMessage가 자연스럽다. 문서 자체가 "AsyncMessage는 시간에 민감한 메시지에는 부적합하다"고 명시한다.ObservationToken을 아예 저장하지 않으면 어떻게 되는가?
Q3. KVO는 어떻게 동작하는가? — isa-swizzling과 @objc dynamic이 둘 다 필요한 이유
어떤 인스턴스를 처음 관찰하는 순간, Objective-C 런타임은 그 인스턴스의 실제 클래스(Dog)를 상속하는 숨은 서브클래스(NSKVONotifying_Dog)를 만들어 관찰 대상 프로퍼티의 setter를 오버라이드하고, 그 인스턴스의 isa 포인터를 그 서브클래스로 바꿔치기한다. 이 가로채기는 setter 호출이 반드시 동적 디스패치를 거쳐야만 성립하므로, @objc(런타임에 노출)와 dynamic(vtable이 아닌 objc_msgSend로 강제 디스패치) 둘 다 있어야 KVO가 동작한다.
원리
KVO는 프로퍼티 단위 옵저버 패턴을 수동 코드 없이 제공하겠다는 목표를 가진 Objective-C 런타임 기능이다. Swift의 일반 프로퍼티 접근은 컴파일 타임에 정적으로 인라인되거나 vtable을 거치는데, 이런 접근은 런타임이 "지금 이 프로퍼티가 바뀌었다"는 사실을 가로챌 지점이 없다. KVO가 성립하려면 setter 호출이 컴파일 타임에 고정되지 않고 런타임에 실제 구현을 찾아가는 경유지(objc_msgSend)를 반드시 거쳐야 하고, Swift에서 이 경유지를 강제하는 게 dynamic이다.
내부 동작
구체적 절차는 이렇다. 어떤 인스턴스에 observe(_:options:changeHandler:)(또는 addObserver(_:forKeyPath:...))가 처음 호출되면, 런타임은 그 인스턴스가 속한 클래스(Dog)를 상속하는 NSKVONotifying_Dog라는 서브클래스가 이미 있는지 확인하고 없으면 그 자리에서 만든다. 이 서브클래스는 관찰 대상 프로퍼티의 setter를 오버라이드해 willChangeValue(forKey:) → 원래 구현 호출 → didChangeValue(forKey:) 순서로 감싸고, 인스턴스의 isa 포인터를 이 서브클래스로 바꿔치기한다(swizzling) — 새 인스턴스를 만드는 게 아니라, 같은 메모리 주소의 같은 객체가 가리키는 클래스만 바뀐다. 여기에 함정이 하나 더 있다: 이 서브클래스는 -class(Swift에서는 type(of:))도 같이 오버라이드해서 원래 클래스인 척 거짓말을 한다. 그래서 type(of:)로는 스위즐 여부를 알 수 없고, -class 메서드를 거치지 않는 object_getClass로만 진짜 런타임 클래스가 보인다.
import Foundation
@objc(Dog) class Dog: NSObject {
@objc dynamic var name: String = "초코"
}
let dog = Dog()
print("관찰 전:", String(cString: class_getName(object_getClass(dog)!)))
let token = dog.observe(\.name, options: [.old, .new]) { _, change in
print(" 변경 감지:", change.oldValue ?? "", "->", change.newValue ?? "")
}
print("관찰 후:", String(cString: class_getName(object_getClass(dog)!)))
print("type(of:) 는 여전히:", type(of: dog)) // 스위즐된 클래스가 class 메서드를 오버라이드해 원래 타입인 척한다
dog.name = "바둑이"
_ = token
// 실제 실행 결과:
// 관찰 전: Dog
// 관찰 후: NSKVONotifying_Dog
// type(of:) 는 여전히: Dog
// 변경 감지: 초코 -> 바둑이실험 · 도구
실험은 두 가지를 더 확인해준다. 첫째, final class도 KVO 대상이 될 수 있다 — final은 Swift 컴파일러가 Swift 레벨 서브클래싱을 막는 제약일 뿐이고, isa-swizzling은 Objective-C 런타임이 objc_allocateClassPair 같은 저수준 API로 런타임에 서브클래스를 만드는 것이라 이 제약과 무관하게 동작한다. 둘째, 서브클래스는 클래스당 하나만 생성돼 공유된다 — 서로 다른 인스턴스를 관찰해도, 심지어 서로 다른 프로퍼티를 관찰해도 같은 NSKVONotifying_Dog 클래스 객체를 가리킨다.
@objc(Dog) final class Dog: NSObject { // final이어도 문제없다
@objc dynamic var name: String = "초코"
@objc dynamic var age: Int = 1
}
let dog1 = Dog(), dog2 = Dog()
let t1 = dog1.observe(\.name) { _, _ in }
let t2 = dog2.observe(\.age) { _, _ in } // 다른 인스턴스, 다른 프로퍼티
print(object_getClass(dog1)! == object_getClass(dog2)!) // true — 클래스당 서브클래스 1개
_ = (t1, t2)LLDB에서는 expr -l objc -- (void)NSLog(@"%@", object_getClass(dog))로도 같은 걸 확인할 수 있고, class_copyMethodList로 NSKVONotifying_Dog가 실제로 setName:을 오버라이드하고 있는지도 들여다볼 수 있다.
프로젝트 적용
① Swift 순수 타입(struct, NSObject를 상속하지 않는 class)에는 KVO를 쓸 수 없다 — isa-swizzling 자체가 Objective-C 런타임 클래스 객체를 전제로 하기 때문이다. UIScrollView.contentOffset이나 Operation.isFinished처럼 프레임워크가 KVO로 진행 상황을 알려주는 지점을 관찰할 때만 @objc dynamic이 필요하다는 걸 기억해두면, 순수 Swift 모델에 불필요하게 NSObject를 상속시키는 걸 피할 수 있다.
// ✅ 프레임워크의 KVO 지점(예: Operation의 진행 상태)을 관찰할 때만 필요
final class ProgressObserver: NSObject {
private var token: NSKeyValueObservation?
func track(_ operation: Operation) {
token = operation.observe(\.isFinished, options: [.new]) { op, change in
guard change.newValue == true else { return }
print("작업 완료:", op)
}
}
}
// ❌ 순수 데이터 모델에 KVO 대응을 위해 NSObject를 상속시킬 필요는 없다
// struct/순수 class + Combine 또는 @Observable로 충분하다② @objc dynamic은 vtable/witness table 최적화를 포기하고 objc_msgSend 경유를 강제하는 것이므로 공짜가 아니다. 관찰이 실제로 필요한 프로퍼티에만 붙이고, 순수 계산이나 내부 전용 프로퍼티에는 붙이지 않는다.
final class PlayerState: NSObject {
@objc dynamic var score: Int = 0 // KVO로 UI가 관찰 — 동적 디스패치 필요
var internalTickCount: Int = 0 // 아무도 관찰 안 함 — 정적 디스패치로 충분
}"final class는 KVO 대상이 될 수 없다"는 틀렸다 — 위 실험에서 직접 확인했듯 final은 Swift 컴파일러 레벨 제약일 뿐이고, isa-swizzling은 그 아래 Objective-C 런타임 레벨에서 일어난다. 또 "관찰하는 프로퍼티마다 새 서브클래스가 생긴다"도 틀렸다 — 서브클래스는 원래 클래스당 하나만 생성돼 그 클래스의 모든 관찰 대상 프로퍼티와 모든 인스턴스가 공유한다.
학교에서 몰래카메라(KVO)를 설치하는 상황을 상상해보자. 원래 학생(원본 클래스)은 그대로 두고, 그 학생이 뭔가 적을 때마다(setter 호출) 몰래 알려주는 "가짜 신분증"(NSKVONotifying_Dog)을 만들어 슬쩍 바꿔치기하는 것과 같다. 그런데 이 가짜 신분증은 누가 "너 진짜 이름이 뭐야?"(type(of:))라고 물으면 뻔뻔하게 원래 이름을 댄다 — 그래서 겉으로는 아무 일도 없었던 것처럼 보인다. 비유가 깨지는 곳: 진짜 신분증 바꿔치기는 들키면 문제가 되지만, KVO의 이 "거짓말"은 시스템이 의도적으로 설계한 정상 동작이다 — 프레임워크 코드가 KVO 여부를 신경 쓰지 않고 원래 클래스만 알아도 되게 하기 위한 장치다.
꼬리 질문
KVO가 걸린 인스턴스에서 self.dynamicType 대신 어떻게 진짜 클래스를 확인하는가?
object_getClass(instance)는 -class 메서드 오버라이드를 거치지 않고 인스턴스의 isa 포인터를 직접 읽으므로 스위즐된 진짜 클래스(NSKVONotifying_Dog)를 그대로 보여준다. 반면 type(of:)나 Objective-C의 [obj class]는 오버라이드된 -class 메서드를 호출하므로 원래 클래스(Dog)로 속는다. class_getName(object_getClass(obj))로 문자열까지 확인하면 확실하다.type(of:)), 지문 조회(object_getClass)를 하면 진짜 정체가 나온다는 뜻이다.관찰 중인 프로퍼티를 super.name = ...처럼 super를 거쳐 바로 접근하면 알림이 안 오는가?
dog.name = "..." 호출은 동적 디스패치를 거치므로 이 오버라이드를 통과하지만, 원본 구현을 우회하는 저수준 접근(예: KVC의 willChangeValue/didChangeValue를 짝 없이 직접 호출하거나, 인스턴스 변수에 직접 쓰는 것)은 이 감시망을 피해간다. 그래서 커스텀 접근자를 짤 때 자동 KVO에 기대지 않고 수동으로 willChangeValue/didChangeValue를 호출해야 하는 경우도 있다.KVO의 isa-swizzling과 Q4에서 다룰 @objc dynamic의 vtable 우회는 07.html의 디스패치 이야기와 어떻게 이어지는가?
07.html(프로토콜과 디스패치)에서 다룬 vtable/objc_msgSend 구분이 정확히 여기서 실전으로 쓰인다. Swift 클래스 메서드는 기본적으로 vtable(정적으로 정해진 오프셋)을 거쳐 호출되는데, 이 방식은 컴파일 타임에 호출 대상이 고정돼 런타임이 끼어들 자리가 없다. dynamic은 이 경로를 포기하고 매번 objc_msgSend로 런타임에 구현을 찾게 만들어서, KVO 같은 런타임 개입(스위즐된 서브클래스로 구현을 갈아치우는 것)이 가능해진다.Q4. NSKeyValueObservation의 생명주기는 옛 addObserver(_:forKeyPath:) API와 무엇이 다른가?
옛 addObserver(_:forKeyPath:options:context:)는 등록과 해제를 손으로 정확히 짝 맞춰야 하는 명령형 API라, deinit에서 removeObserver를 빠뜨리면 관찰 대상이 옵저버 등록 정보를 남긴 채 해제될 때 크래시로 이어질 수 있다. 반면 observe(_:options:changeHandler:)가 반환하는 NSKeyValueObservation은 Apple 문서가 명시하듯 "deinit될 때 자동으로 invalidate()가 호출"되므로, 관찰의 수명이 그 토큰을 담은 변수의 수명에 자연스럽게 묶인다.
원리
옛 API는 관찰 등록이라는 부수효과를 만드는 명령(addObserver)과 그걸 되돌리는 별개의 명령(removeObserver)으로 나뉘어 있어, 둘 사이의 균형을 프로그래머가 스스로 추적해야 한다. 새 API는 등록의 결과를 객체의 수명(NSKeyValueObservation)으로 표현해, 그 객체가 해제되는 순간 관찰도 함께 끝나도록 설계됐다 — RAII(자원 획득이 곧 초기화)와 같은 발상이다.
내부 동작
옛 API에서는 observeValue(forKeyPath:of:change:context:) 하나가 그 옵저버가 등록한 모든 keyPath의 콜백을 받는다. 그래서 여러 곳(예: 상속 계층에서 상위 클래스와 하위 클래스가 각각 같은 프로퍼티를 관찰)에서 호출이 섞이지 않도록 context 포인터로 "이 콜백이 어느 등록에서 왔는지"를 구분해야 했다. 새 API는 observe(\.name, options:changeHandler:)처럼 keyPath 하나당 클로저 하나가 짝지어지므로 이 구분이 아예 필요 없어졌고, 문자열 "name" 대신 KeyPath를 쓰므로 오타가 있으면 런타임이 아니라 컴파일 타임에 걸린다.
| 항목 | 옛 API — addObserver(_:forKeyPath:options:context:) | 새 API — observe(_:options:changeHandler:) |
|---|---|---|
| 등록 | 관찰 대상 객체에 옵저버(self)와 문자열 keyPath, context를 전달 | 관찰 대상 객체에 KeyPath와 클로저를 전달, NSKeyValueObservation 반환 |
| 콜백 수신 | observeValue(forKeyPath:of:change:context:) 하나가 전부 받음 — context로 구분 | 등록할 때 넘긴 클로저가 그 keyPath 전용으로 바로 호출됨 |
| 타입 안전성 | 문자열 keyPath — 오타는 런타임에야 발견됨 | KeyPath — 오타는 컴파일 에러 |
| 해제 | 수동 removeObserver(_:forKeyPath:)를 반드시 짝 맞춰 호출 | 토큰이 deinit되면 자동으로 invalidate() 호출 |
| 실패 모드 | 짝을 안 맞추면 콘솔에 "was deallocated while key value observers were still registered" 계열 진단, 심하면 크래시 | 토큰을 저장하지 않으면 그 자리에서 바로 해제될 뿐 — 크래시 아님 |
실험 · 도구
새 API가 정말로 자동 해제되는지, deinit 순서를 로그로 직접 확인할 수 있다.
import Foundation
@objc(Dog) class Dog: NSObject { @objc dynamic var name: String = "초코" }
final class Owner {
var token: NSKeyValueObservation?
init(observing dog: Dog) {
token = dog.observe(\.name, options: [.new]) { _, change in
print(" Owner가 감지:", change.newValue ?? "")
}
}
deinit { print("Owner deinit — token도 곧 해제된다") }
}
let dog = Dog()
var owner: Owner? = Owner(observing: dog)
dog.name = "1차 변경" // Owner가 살아있음 — 감지됨
owner = nil // Owner 해제 -> token deinit -> invalidate() 자동 호출
print("owner를 nil로 만든 뒤:")
dog.name = "2차 변경" // 관찰 끊김 — 아무것도 출력되지 않아야 정상
print("끝 — 크래시 없음, 추가 감지 로그도 없음")
// 실제 실행 결과:
// Owner가 감지: 1차 변경
// Owner deinit — token도 곧 해제된다
// owner를 nil로 만든 뒤:
// 끝 — 크래시 없음, 추가 감지 로그도 없음 ← "2차 변경"에 대한 감지 로그가 없다dog.name = "2차 변경" 다음에 아무 로그도 안 찍혔다 — owner가 사라지며 token도 함께 deinit됐고, invalidate()가 자동 호출돼 관찰이 끊겼다는 뜻이다. 옛 API였다면 Owner의 deinit에서 removeObserver를 부르지 않았을 경우, 이후 dog가 name을 바꿀 때마다 이미 해제된 Owner에게 메시지를 보내려다 문제가 생겼을 것이다.
프로젝트 적용
① 옛 API를 유지보수해야 한다면 등록·해제를 반드시 짝 맞추고, context 포인터로 어느 클래스가 등록한 관찰인지 구분한다.
private var kvoContext = 0
final class LegacyObserver: NSObject {
weak var dog: Dog?
func start(observing dog: Dog) {
self.dog = dog
dog.addObserver(self, forKeyPath: "name", options: [.new], context: &kvoContext)
}
override func observeValue(forKeyPath keyPath: String?, of object: Any?,
change: [NSKeyValueChangeKey: Any]?, context: UnsafeMutableRawPointer?) {
guard context == &kvoContext else {
super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
return
}
print("이름 변경 감지")
}
deinit {
dog?.removeObserver(self, forKeyPath: "name", context: &kvoContext) // 짝을 반드시 맞춘다
}
}② 새 API에서는 프로퍼티마다 별도 토큰이 생기므로, 여러 개를 관찰한다면 배열로 모아 보관하는 편이 관리하기 쉽다.
final class DogMonitor {
private var tokens: [NSKeyValueObservation] = []
func start(observing dog: Dog) {
tokens.append(dog.observe(\.name, options: [.new]) { _, c in print("이름:", c.newValue ?? "") })
tokens.append(dog.observe(\.age, options: [.new]) { _, c in print("나이:", c.newValue ?? "") })
// deinit에서 tokens 배열이 해제되면 모든 관찰이 자동으로 끊긴다 — 별도 정리 코드 불필요
}
}"새 API는 클로저 캡처 걱정이 전혀 없다"는 틀렸다. changeHandler가 self를 강하게 캡처하고, 그 self가 다시 token을 프로퍼티로 들고 있으면 self → token → closure → self로 순환 참조가 생긴다. 이 경우 자동 invalidate() 메커니즘 자체가 deinit이 안 불려서 발동하지 못한다 — 여전히 [weak self] 캡처가 필요할 수 있다.
옛 API는 도서관에서 책을 빌리고 손수 반납해야 하는 대출 시스템과 같다. 반납일을 넘기면 연체료(크래시나 진단 로그)가 발생한다. 새 API는 "구독 기간이 끝나면 자동으로 회수해가는 대여 앱"과 비슷해서, 내가 앱을 지우면(토큰이 담긴 변수가 사라지면) 자동으로 반납 처리가 된다. 비유가 깨지는 곳: 진짜 자동 반납 앱은 책 자체를 수거해가지만, NSKeyValueObservation의 자동 해제는 "지켜보는 것"만 멈출 뿐 관찰 대상 객체(dog)에는 아무 영향도 주지 않는다.
꼬리 질문
옛 API에서 context 포인터는 정확히 왜 필요했는가?
observeValue(forKeyPath:of:change:context:)는 그 옵저버가 등록한 모든 keyPath, 심지어 상속 계층에서 상위 클래스가 등록한 관찰까지 한곳으로 몰아서 받는다. 여러 등록이 겹치면 "이 콜백이 어느 addObserver 호출에서 온 것인지" 구분할 방법이 필요한데, 그 식별자로 쓰라고 만들어진 게 context다. 보통 전역 static var의 주소를 넘겨, context == &myContext로 비교해 자기 등록인지 아닌지 걸러낸다.한 객체의 여러 프로퍼티를 관찰하면 토큰이 몇 개 필요한가?
observe(_:options:changeHandler:) 호출마다 별도의 NSKeyValueObservation을 반환하므로, keyPath 하나당 토큰이 하나씩 필요하다. 여러 개를 한 객체 안에서 관리하려면 배열이나 Set에 모아두고, 그 컬렉션 자체가 해제될 때 안에 든 모든 토큰이 함께 invalidate()되게 하는 패턴이 일반적이다.changeHandler 안에서 self를 캡처하면 어떤 문제가 남는가?
self의 프로퍼티로 저장하면서 changeHandler가 다시 self를 강하게 캡처하면, self → token → closure → self로 이어지는 순환 참조가 생긴다. 자동 해제는 "토큰이 deinit될 때"를 조건으로 하는데, 이 순환 때문에 self도 token도 영원히 deinit되지 않아 애초에 그 자동 해제 자체가 발동할 기회를 못 얻는다. [weak self] 캡처로 이 사슬을 끊어야 한다.Q5. @Observable은 ObservableObject/@Published와 비교해 뷰 갱신을 어떻게 더 잘게 나누는가?
ObservableObject는 @Published 프로퍼티 중 아무거나 바뀌면 objectWillChange가 발동해, 그 객체를 구독하는 모든 뷰가 무조건 재평가된다(객체 단위, coarse-grained). @Observable은 뷰의 body가 실제로 읽은 프로퍼티만 추적해, 그 프로퍼티가 바뀔 때만 해당 뷰를 무효화한다(프로퍼티 단위, fine-grained). Apple 마이그레이션 가이드도 "SwiftUI는 관찰 대상 프로퍼티가 바뀌고 그 뷰의 body가 그 프로퍼티를 직접 읽었을 때만 갱신한다"고 명시한다.
원리
ObservableObject의 추적 단위는 객체다 — @Published는 프로퍼티가 바뀔 때마다 그 소속 객체의 objectWillChange 퍼블리셔로 신호를 보낼 뿐, "어떤 뷰가 실제로 이 프로퍼티를 썼는지"는 전혀 모른다. @Observable(Observation 프레임워크, WWDC23 세션 10149)은 접근 추적(access tracking)이라는 다른 메커니즘을 쓴다 — 프로퍼티 래퍼로 미리 선언하는 대신, 그 프로퍼티가 읽히는 순간 "지금 이걸 읽고 있는 게 누구인가"를 등록해두고, 나중에 그 프로퍼티가 바뀌면 그때 등록해둔 대상에게만 알린다.
내부 동작
@Observable이 실제로 무엇을 생성하는지는 swiftc -Xfrontend -dump-macro-expansions로 직접 확인할 수 있다. 클래스 하나에 매크로를 붙이면, 컴파일러는 _$observationRegistrar라는 ObservationIgnored 저장 프로퍼티를 추가하고, 각 저장 프로퍼티를 _count 같은 백업 프로퍼티 + 계산 프로퍼티 쌍으로 다시 쓴다.
// 원본:
// @Observable
// final class Counter {
// var count: Int = 0
// }
// 매크로가 생성한 코드(발췌, 실제 확장 결과):
@ObservationIgnored private let _$observationRegistrar = Observation.ObservationRegistrar()
@ObservationIgnored private var _count: Int = 0
var count: Int {
get {
access(keyPath: \.count) // "지금 count를 읽었다"고 등록
return _count
}
set {
guard shouldNotifyObservers(_count, newValue) else {
_count = newValue // 값이 실제로 같으면 알리지 않고 그냥 대입
return
}
withMutation(keyPath: \.count) { // 값이 실제로 다를 때만 관찰자에게 통지
_count = newValue
}
}
}핵심은 get의 access(keyPath:)다 — 뷰의 body가 counter.count를 읽는 순간 "이 뷰가 count라는 keyPath에 의존한다"는 사실이 등록되고, 그 뷰가 읽지 않은 다른 프로퍼티(label 등)가 바뀌어도 이 뷰는 재평가 대상에서 빠진다. 게다가 set의 shouldNotifyObservers는 Equatable 타입이면 값이 실제로 같으면 통지 자체를 생략한다 — 이건 @Published에는 없는 최적화다. 직접 확인해보면 차이가 뚜렷하다: @Observable에서 같은 값(0 → 0)을 대입하면 관찰 콜백이 0번 발동하지만, @Published의 objectWillChange는 같은 값을 대입해도 무조건 발동한다.
// @Observable — withObservationTracking으로 직접 관찰
import Observation
@Observable final class Counter { var count: Int = 0 }
let c = Counter()
var changeCount = 0
func track() {
withObservationTracking { _ = c.count } onChange: {
changeCount += 1
track() // SwiftUI가 매 렌더마다 재구독하는 것과 동일한 패턴
}
}
track()
c.count = 0 // 같은 값
c.count = 5 // 다른 값
print(changeCount) // 실제 결과: 1 — 같은 값 대입은 통지되지 않았다
// ObservableObject — objectWillChange를 직접 구독
import Combine
final class LegacyCounter: ObservableObject { @Published var count: Int = 0 }
let lc = LegacyCounter()
var fireCount = 0
let sub = lc.objectWillChange.sink { _ in fireCount += 1 }
lc.count = 0 // 같은 값
lc.count = 5 // 다른 값
print(fireCount) // 실제 결과: 2 — 같은 값이어도 무조건 발동했다실험 · 도구
SwiftUI 없이도 Observation의 withObservationTracking(_:onChange:)만으로 추적 단위를 확인할 수 있다 — 위 코드가 그 실험이다. swiftc -Xfrontend -dump-macro-expansions -c file.swift -o /dev/null로 매크로가 생성한 전체 코드를 파일로 덤프해 _$observationRegistrar, access, withMutation이 실제로 어떤 이름의 임시 파일에 만들어지는지도 볼 수 있다. Xcode에서는 소스 위 매크로 이름을 우클릭해 "Expand Macro"를 선택하면 같은 내용을 인라인으로 보여준다.
프로젝트 적용
① 기존 ObservableObject 뷰모델을 @Observable로 옮길 때는 @Published를 전부 제거하고, @StateObject는 @State로, 읽기 전용으로만 쓰던 @ObservedObject는 평범한 프로퍼티로 바꾼다.
// ❌ 이전 — count 하나만 바뀌어도 label을 읽는 뷰까지 전부 재평가
final class OldViewModel: ObservableObject {
@Published var count: Int = 0
@Published var label: String = "카운터"
}
struct OldView: View {
@StateObject var vm = OldViewModel()
var body: some View { Text(vm.label) } // count 변경에도 계속 재평가된다
}
// ✅ 이후 — body가 실제로 읽는 프로퍼티가 바뀔 때만 재평가
@Observable
final class NewViewModel {
var count: Int = 0
var label: String = "카운터"
}
struct NewView: View {
@State var vm = NewViewModel()
var body: some View { Text(vm.label) } // count가 바뀌어도 이 뷰는 재평가되지 않는다
}② 뷰가 관찰할 필요가 없는 내부 상태(캐시, 디버그 카운터, Combine 구독 Set<AnyCancellable> 등)는 @ObservationIgnored로 추적에서 제외해 불필요한 오버헤드를 줄인다.
@Observable
final class SearchViewModel {
var query: String = "" // 뷰가 읽음 — 추적 대상
var results: [Item] = [] // 뷰가 읽음 — 추적 대상
@ObservationIgnored
private var debounceWorkItem: DispatchWorkItem? // 뷰와 무관한 내부 상태 — 추적 제외
}"@Observable은 그냥 @Published를 클래스 레벨로 옮긴 문법 설탕일 뿐"이라는 생각은 틀렸다. 단순히 선언 위치만 바뀐 게 아니라, 추적 방식 자체가 "프로퍼티 래퍼가 미리 신호를 쏘는 방식"에서 "읽는 시점에 의존성을 등록하는 방식"으로 바뀌었다. 이 차이가 뷰 단위 무효화가 아니라 프로퍼티 단위 무효화를 가능하게 하는 핵심이며, 위에서 실측했듯 같은 값 재대입 시 통지를 생략하는 최적화까지 딸려온다 — 단순 문법 치환으로는 나올 수 없는 동작이다.
ObservableObject는 반 전체에게 방송으로 "누군가 성적이 바뀌었어요!"라고 알리는 것과 같다 — 정작 그 학생 성적에 관심 없던 아이들까지 전부 하던 일을 멈추고 방송을 들어야 한다. @Observable은 "네가 몇 번 학생 성적을 궁금해했는지" 미리 적어뒀다가, 정확히 그 학생 성적이 바뀔 때만 그 아이에게만 살짝 알려주는 것과 같다. 비유가 깨지는 곳: 실제 학교 방송은 누가 몇 번 학생을 궁금해했는지 미리 알 방법이 없지만, @Observable은 SwiftUI가 뷰의 body를 실행하는 그 순간 어떤 프로퍼티를 읽었는지 코드 차원에서 정확히 기록해두기 때문에 이런 정밀한 배달이 가능하다.
꼬리 질문
@Observable 클래스를 관찰하는 쪽에서 @ObservedObject/@StateObject가 여전히 필요한가?
@Observable 타입은 ObservableObject 프로토콜을 준수하지 않으므로 그 데이터 흐름 프로퍼티 래퍼들이 애초에 적용 대상이 아니다. 대신 일반 프로퍼티, 값 유지가 필요하면 @State, 바인딩($ 문법)이 필요하면 @Bindable을 쓴다. 다만 StateObject/EnvironmentObject도 @Observable 타입을 감쌀 수 있게 호환은 되므로, 마이그레이션을 점진적으로 진행하는 동안은 섞어 써도 앱이 정상 동작한다.Optional이나 배열에 담긴 객체도 @Observable로 추적할 수 있는가?
ObservableObject로는 못 하던 것이다. ObservableObject는 프로퍼티 래퍼가 감싸는 그 자리 자체가 추적 대상이라 [Item]? 안에 든 개별 Item의 변화까지는 자동으로 안 잡히지만, @Observable은 접근 자체를 추적하므로 옵셔널이나 컬렉션에 담긴 개별 객체의 프로퍼티를 읽어도 그 읽기가 그대로 등록된다.shouldNotifyObservers의 같은 값 생략 최적화는 항상 켜져 있는가?
shouldNotifyObservers는 오버로드로 구현돼 있다 — 프로퍼티 타입이 Equatable을 준수하면 lhs != rhs로 실제 값 비교를 하고, 아니면(비교 불가능한 타입이면) 무조건 true를 반환해 항상 통지한다. 즉 이 최적화는 "값을 비교할 수 있을 때만" 자동으로 적용되고, Equatable이 아닌 타입에는 적용되지 않는다.