Skip to content

UIKit Observable Rendering

TomBumSuChoi edited this page Apr 12, 2026 · 2 revisions

🔄 UIKit + @Observable 렌더링 주기 분리 패턴

관련 기술: #UIKit #Observable #Swift6 #Performance 참고 자료:


1. @Observable 내부 동작 원리

1.1 매크로가 생성하는 코드

@Observable을 class에 붙이면 컴파일러가 stored property를 computed property로 변환한다.

// 원본
@Observable class Model {
    var name = ""
}

// ↓ 매크로 확장 (개념적)
class Model: Observable {
    private let _$observationRegistrar = ObservationRegistrar()
    private var _name = ""

    var name: String {
        get {
            _$observationRegistrar.access(self, keyPath: \.name)  // ← 읽기 추적
            return _name
        }
        set {
            _$observationRegistrar.withMutation(of: self, keyPath: \.name) {  // ← 변경 알림
                _name = newValue
            }
        }
    }
}

핵심 포인트:

  • getter: access()를 호출해 "누가 이 프로퍼티를 읽었는지" 등록
  • setter: withMutation()을 호출해 "이 프로퍼티가 바뀔 것임"을 알림
  • 값이 같아도 setter를 호출하면 observation이 fire됨 — Equatable 체크 없음

1.2 withObservationTracking 동작

withObservationTracking {
    // apply 클로저: 여기서 읽은 프로퍼티만 추적 대상
    print(model.name)   // → name에 대한 observation 등록
} onChange: {
    // name이 변경되면 이 클로저가 호출됨 (1회성!)
    print("changed!")
}
  • apply 클로저 안에서 access()가 호출된 프로퍼티만 추적
  • onChange1회만 호출됨 (one-shot). 계속 추적하려면 재귀적으로 다시 호출해야 함
  • SwiftUI의 body와 UIKit의 updateProperties()는 이 메커니즘을 자동으로 반복 호출함

1.3 class-only 제약

@Observableclass에만 적용 가능하다 (SE-0395).

  • class는 참조 타입 → 메모리 위치가 안정적 → ObservationRegistrar가 인스턴스에 고정
  • struct는 값 타입 → 복사될 때마다 registrar도 복사 → 어떤 인스턴스에 대한 변경인지 추적 불가

2. UIKit updateProperties() 동작 원리

2.1 위치: UIKit 업데이트 사이클

Trait Update → updateProperties() → layoutSubviews() → Display
  • updateProperties()는 layout 이전에 실행되는 프로퍼티 설정 전용 메서드
  • 텍스트, 색상, 가시성 등 geometry와 무관한 속성 변경에 사용
  • WWDC25에서 도입 (iOS 26). UIObservationTrackingEnabled 키로 iOS 18부터 사용 가능

2.2 자동 observation tracking

UIKit은 updateProperties() 실행 시 내부적으로 withObservationTracking을 감싼다.

// UIKit 내부 (개념적)
func _runUpdateProperties() {
    withObservationTracking {
        self.updateProperties()       // ← 여기서 읽은 @Observable 프로퍼티 추적
    } onChange: { [weak self] in
        DispatchQueue.main.async {
            self?.setNeedsUpdateProperties()  // ← 변경 시 다음 사이클에 재호출
        }
    }
}

결과: updateProperties() 안에서 읽은 @Observable 프로퍼티가 변경되면, UIKit이 자동으로 updateProperties()를 다시 호출한다. 이 과정이 자동으로 반복된다.

2.3 적용 대상 메서드

메서드 자동 tracking 용도
updateProperties() 프로퍼티, 스타일, 콘텐츠
layoutSubviews() 위치, 크기, geometry
updateConfiguration(using:) 셀 configuration
viewDidLoad() 초기 설정 (1회성)

3. struct State의 observation 함정

3.1 문제 구조

@Observable class ViewModel {
    var state: State  // ← State는 struct
}

struct State {
    var analysisState: AnalysisState  // 1회성 변화
    var currentPlaybackState: AudioPlaybackState  // 0.1초마다 변화
}

state@Observable class의 단일 stored property다. observation 시스템은 state 프로퍼티 단위로 추적한다.

// updateProperties() 안에서
viewModel.state.analysisState  // → viewModel.state에 대한 observation 등록

state.currentPlaybackState가 바뀌면 → struct 전체가 재할당 → state setter 호출 → observation fire → updateProperties() 재호출!

의도: analysisState만 추적하고 싶었음 현실: state의 어떤 프로퍼티든 바뀌면 트리거됨

3.2 간접 오염: 호출 체인을 통한 tracking 전파

override func updateProperties() {
    switch analysisObservable.analysisState {  // ← 이것만 추적? 아님!
    case .completed:
        applySnapshot()  // ← 이 안에서 viewModel.state를 읽음!
    }
}

func applySnapshot() {
    // 이 코드는 updateProperties()의 tracking context 안에서 실행됨
    let items = viewModel.state.keyPoints  // ← viewModel.state observation 등록!
}

updateProperties()applySnapshot()viewModel.state 읽기 → ViewModel observation 등록 → state.currentPlaybackState 변경 시에도 updateProperties() 재호출

교훈: tracking context는 호출 체인 전체에 전파된다. updateProperties() 안에서 호출하는 모든 함수의 모든 @Observable 프로퍼티 접근이 추적 대상이 된다.

3.3 @Observable setter의 무조건 fire

@Observable class PlaybackHighlight {
    var playingParagraphInfo: PlayingParagraphInfo?
}

// 0.1초마다 호출
highlight.playingParagraphInfo = sameValue  // ← 값이 같아도 observation fire!

@Observable의 setter(withMutation)는 Equatable 비교를 하지 않는다. 매번 setter가 호출되면 모든 observer에게 알림이 간다.


4. 해결 원칙

원칙 1: updateProperties() 안에서 고주기 상태를 읽지 않는다

빠른 주기 (0.1초 간격)
  currentPlaybackState ──→ AudioPlayerObservable ──→ AudioPlayerView.updateProperties()

느린 주기 (1회성/이벤트성)
  analysisState ──→ AnalysisObservable ──→ VC.updateProperties()
  errorMessage  ──→ ErrorObservable    ──→ VC.updateProperties()

원칙 2: 별도 @Observable wrapper class로 관찰 범위를 격리한다

struct State {
    let analysisObservable = AnalysisObservable()    // VC가 관찰
    let errorObservable = ErrorObservable()          // VC가 관찰
    let audioPlayerObservable = AudioPlayerObservable()  // PlayerView가 관찰
    let playbackHighlight = PlaybackHighlight()      // ScriptCell이 관찰

    // 이 프로퍼티 변경이 위 Observable들을 오염시키지 않음
    var currentPlaybackState: AudioPlaybackState { didSet { audioPlayerObservable.playbackState = currentPlaybackState } }
}

원칙 3: tracking context 내에서 viewModel.state를 읽지 않는다

// ❌ 잘못된 패턴
override func updateProperties() {
    applySnapshot()  // 내부에서 viewModel.state 읽음 → tracking 오염
}

// ✅ 올바른 패턴: 1회만 실행하거나, tracking context 밖에서 호출
override func updateProperties() {
    if !hasAppliedCompletedSnapshot {
        hasAppliedCompletedSnapshot = true
        applySnapshot()  // 이후 tracking에 viewModel이 포함되지 않음
    }
}

원칙 4: @Observable setter 호출 전 동일 값 체크

// ❌ 매번 fire
highlight.playingParagraphInfo = newInfo

// ✅ 변경 시에만 fire
guard playingParagraphInfo != newInfo else { return }
highlight.playingParagraphInfo = newInfo

5. 디버깅 체크리스트

updateProperties()가 과도하게 호출된다면:

  • updateProperties() 안에서 viewModel.state를 직접 읽고 있지 않은가?
  • updateProperties()에서 호출하는 함수 안에서 viewModel.state를 읽고 있지 않은가?
  • @Observable 프로퍼티에 같은 값을 반복 set하고 있지 않은가?
  • 고주기 상태(재생/타이머/센서)가 저주기 상태(콘텐츠/에러)와 같은 @Observable 인스턴스에 있지 않은가?
  • lastAppliedXxx 같은 shadow state가 생기고 있지 않은가? → observation 분리 필요 신호

6. 적용 대상

  • updateProperties() 내에 재생/센서/타이머 등 고주기 상태와 일반 콘텐츠 상태가 혼재하는 경우
  • lastAppliedXxx 패턴이 반복적으로 등장하는 경우
  • @Observable class의 struct 프로퍼티 안에 변화 주기가 다른 상태가 혼재하는 경우

Clone this wiki locally