반응형

 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를 사용하고 있다면 다음을 반드시 확인해야 합니다.

  1. addObserver와 removeObserver가 짝이 맞는가?
  2. observer가 deinit에서 removeObserver를 호출하는가?
  3. block 기반 KVO(NSKeyValueObservation)를 사용한다면:
    • observation을 프로퍼티로 강하게 유지하고 있는가?
    • self를 [weak self]로 캡처하고 있는가?
  4. 관찰 대상 객체의 라이프사이클이 observer보다 짧지 않은가? 
  5. 가능하면 KVO 대신 다른 메커니즘(Combine, delegate 등)을 사용할 수 없는가?

9. 요약

  • KVO는 매우 섬세한 메커니즘이며, add/remove 짝이 맞지 않으면 크래시로 직행한다.
  • removeObserver를 빼먹거나, 대상/관찰자 라이프사이클이 꼬이면
    추적하기 어려운 버그와 메모리 누수를 낳는다.
  • Swift의 NSKeyValueObservation 블록 API를 사용하면
    add/remove 관리가 조금 쉬워지지만, 여전히 self capture는 주의해야 한다.
  • 가능하다면 KVO 대신 더 현대적인 관찰 메커니즘을 사용하는 것이 안전하다.

핵심 문장:

KVO를 쓴다면, add/remove와 라이프사이클, self 캡처까지 완전히 이해하고 써야 한다.

반응형
Posted by 까칠코더
,