iOS 개발자가 많이 하는 실수 - KVO(Key-Value Observing) 사용 시 removeObserver 누락 및 Strong Reference Cycle 실수
Dev Study/iOS 2025. 12. 4. 21:03반응형
iOS 개발자가 많이 하는 실수 - KVO(Key-Value Observing) 사용 시 removeObserver 누락 및 Strong Reference Cycle 실수
요즘은 Combine, KVO 대체 API, Swift Concurrency 등을 많이 쓰지만,
기존 레거시 코드나 Objective‑C 기반 라이브러리와 연동할 때
여전히 KVO(Key-Value Observing)를 사용하는 경우가 있습니다.
KVO는 매우 강력하지만, 다음과 같은 실수가 잦습니다.
- removeObserver를 빼먹어 크래시 또는 메모리 누수 발생
- Observe 대상 객체가 먼저 해제되면서 비정상 동작
- Strong Reference Cycle(강한 순환 참조)로 인한 ViewController 미해제
- Swift KVO 래퍼(API)를 잘못 이해하고 사용하는 문제
1. KVO 기본 개념
기본 사용 예(ObjC 스타일, Swift에서도 사용 가능):
class MyObserver: NSObject {
@objc dynamic var value: Int = 0
}
class MyViewController: UIViewController {
let model = MyObserver()
override func viewDidLoad() {
super.viewDidLoad()
model.addObserver(
self,
forKeyPath: #keyPath(MyObserver.value),
options: [.new, .old],
context: nil
)
}
override func observeValue(
forKeyPath keyPath: String?,
of object: Any?,
change: [NSKeyValueChangeKey : Any]?,
context: UnsafeMutableRawPointer?
) {
// 값 변경 감지
}
}
KVO는 다음 특징을 가진다.
- 감시 대상은 @objc dynamic으로 마킹된 프로퍼티
- 런타임 기반 메커니즘 (Objective‑C KVO Runtime)
- 잘못 사용할 경우 런타임 크래시가 나기 쉽다
- add/remove 쌍이 정확히 맞지 않으면 문제가 발생
2. 문제 패턴 1: removeObserver 누락으로 크래시 발생
가장 흔한 KVO 실수:
class MyViewController: UIViewController {
let model = MyObserver()
override func viewDidLoad() {
super.viewDidLoad()
model.addObserver(self, forKeyPath: #keyPath(MyObserver.value), options: [.new], context: nil)
}
// ❌ deinit 또는 적절한 시점에서 removeObserver를 호출하지 않음
}
이 상태에서:
- ViewController가 해제된 뒤에도
- model의 value가 변경되면
- 이미 해제된 observer(self)로 메시지가 날아가면서 크래시 가능
해결: deinit에서 반드시 removeObserver 호출
deinit {
model.removeObserver(self, forKeyPath: #keyPath(MyObserver.value))
}
핵심:
KVO를 addObserver 했다면,
해당 observer 라이프사이클 끝에서 반드시 removeObserver를 호출해야 한다.
3. 문제 패턴 2: add/removeObserver 호출 불일치
잘못된 예:
model.addObserver(self, forKeyPath: "value", options: [.new], context: nil)
model.addObserver(self, forKeyPath: "value", options: [.old], context: nil)
// deinit에서 한 번만 remove
deinit {
model.removeObserver(self, forKeyPath: "value") // ❌ add 2번, remove 1번
}
결과:
- 런타임 상에서 KVO 등록/해제 카운트가 일치하지 않아
- 크래시 또는 undefined behavior 발생
반대 상황 (remove를 더 많이 호출)도 마찬가지로 문제다.
원칙
- addObserver와 removeObserver는 1:1로 짝을 맞춰야 한다.
- 보통은 “한 곳에서 add → deinit에서 한 번 remove” 구조로 단순화하는 것이 좋다.
4. 문제 패턴 3: Strong Reference Cycle (관찰자가 대상 객체를 강하게 잡는 경우)
예:
class MyViewController: UIViewController {
var model: MyObserver!
override func viewDidLoad() {
super.viewDidLoad()
model = MyObserver()
model.addObserver(self, forKeyPath: #keyPath(MyObserver.value), options: [.new], context: nil)
}
deinit {
model.removeObserver(self, forKeyPath: #keyPath(MyObserver.value))
}
}
여기서는:
- VC가 model을 강하게 참조
- model이 VC를 강하게 참조하지는 않지만,
- 다른 구조(예: KVO 래퍼 객체, 클로저 기반 KVO wrapper)를 도입할 경우
다음과 같은 Strong Cycle이 생길 수 있다.
예: 클로저 기반 KVO wrapper
class KVOObserver {
var handler: ((Any?) -> Void)?
init(_ object: NSObject, keyPath: String, handler: @escaping (Any?) -> Void) {
self.handler = handler
object.addObserver(self, forKeyPath: keyPath, options: [.new], context: nil)
}
// ...
}
VC 코드:
class MyViewController: UIViewController {
var observer: KVOObserver?
let model = MyObserver()
override func viewDidLoad() {
super.viewDidLoad()
observer = KVOObserver(model, keyPath: #keyPath(MyObserver.value)) { newValue in
self.updateUI(with: newValue) // ❌ self strong capture
}
}
}
구조:
- VC → observer (strong)
- observer → handler (strong)
- handler → self(VC) strong capture
결과:
- VC가 해제되지 않는 강한 순환 참조 발생
해결: 클로저에서 [weak self] 사용
observer = KVOObserver(model, keyPath: #keyPath(MyObserver.value)) { [weak self] newValue in
self?.updateUI(with: newValue)
}
5. 문제 패턴 4: 관찰 대상이 먼저 해제되는데 observer가 남아있는 경우
상반된 케이스:
- 관찰 대상(model)이 먼저 해제
- observer 측에서는 아직 removeObserver를 하지 않음
- 런타임에서 KVO 해제 시점에 크래시 가능
예:
class MyViewController: UIViewController {
weak var model: MyObserver?
override func viewDidLoad() {
super.viewDidLoad()
let m = MyObserver()
model = m
m.addObserver(self, forKeyPath: #keyPath(MyObserver.value), options: [.new], context: nil)
}
deinit {
// 이 시점에 model은 이미 nil일 수도 있음
model?.removeObserver(self, forKeyPath: #keyPath(MyObserver.value))
}
}
이 경우, 런타임에서 “존재하지 않는 대상에 대해 removeObserver”를 시도하면서
원하지 않는 크래시가 발생할 수 있습니다.
해결 방향
- KVO 대상 객체와 observer의 라이프사이클을 명확히 정리
- 가능하면 “대상 객체가 observer보다 오래 살아있도록” 구성
- 또는 KVO 래퍼를 사용해 add/remove를 한 곳에서 처리
6. Swift KVO(Key-Value Observation) 블록 기반 API 사용 시 패턴
Swift 4+에서는 block 기반 관찰 API가 제공됩니다.
class MyViewController: UIViewController {
@objc dynamic var model = MyObserver()
private var observation: NSKeyValueObservation?
override func viewDidLoad() {
super.viewDidLoad()
observation = model.observe(\.value, options: [.new, .old]) { [weak self] object, change in
self?.handleChange(change)
}
}
}
특징:
- NSKeyValueObservation 객체가 deinit될 때 자동으로 removeObserver 수행
- add/removeObserver를 직접 맞출 필요가 없음
- 메모리/해제 관리가 훨씬 안전해짐
하지만, 여전히 다음은 주의해야 한다.
- observation을 프로퍼티로 유지해야 함 (지역 변수면 바로 해제됨)
- 클로저 내부에서 self를 [weak self]로 캡처하지 않으면 strong cycle 발생
7. KVO를 계속 사용해야 하는지 점검하기
KVO는 강력하지만:
- 런타임 의존
- API 복잡
- 디버깅 난이도 높음
대부분의 새로운 코드에서는 다음 대안이 더 안전하고 직관적입니다.
- Combine (Publisher + sink)
- Swift Concurrency + async/await + AsyncSequence
- RxSwift Observable
- Delegation / 클로저 콜백
- TCA / 상태 기반 아키텍처
즉, KVO는 주로 다음 상황에서만 사용하는 것이 좋습니다.
- 기존 Objective‑C 프레임워크와의 호환
- KVO에만 hook을 제공하는 레거시 API 감시
- UIKit 특정 내부 상태 감시 (단, 권장되진 않음)
8. 실무용 체크리스트
KVO를 사용하고 있다면 다음을 반드시 확인해야 합니다.
- addObserver와 removeObserver가 짝이 맞는가?
- observer가 deinit에서 removeObserver를 호출하는가?
- block 기반 KVO(NSKeyValueObservation)를 사용한다면:
- observation을 프로퍼티로 강하게 유지하고 있는가?
- self를 [weak self]로 캡처하고 있는가?
- 관찰 대상 객체의 라이프사이클이 observer보다 짧지 않은가?
- 가능하면 KVO 대신 다른 메커니즘(Combine, delegate 등)을 사용할 수 없는가?
9. 요약
- KVO는 매우 섬세한 메커니즘이며, add/remove 짝이 맞지 않으면 크래시로 직행한다.
- removeObserver를 빼먹거나, 대상/관찰자 라이프사이클이 꼬이면
추적하기 어려운 버그와 메모리 누수를 낳는다. - Swift의 NSKeyValueObservation 블록 API를 사용하면
add/remove 관리가 조금 쉬워지지만, 여전히 self capture는 주의해야 한다. - 가능하다면 KVO 대신 더 현대적인 관찰 메커니즘을 사용하는 것이 안전하다.
핵심 문장:
KVO를 쓴다면, add/remove와 라이프사이클, self 캡처까지 완전히 이해하고 써야 한다.
반응형

