← iOS 개발자 CS 로드맵 34 / 35

34 · Foundation과 Objective-C 상호운용

swift-foundation 재작성의 실제 타임라인과 검증 가능한 성능 수치, toll-free bridging의 두 계층과 지연 비용, Data가 감추는 여러 표현, NSError 브리징 체계, KVO의 isa-swizzling과 dynamic이 필요한 이유, Observation의 접근 추적.
진행률
0 / 0 완료

Swift 앱은 순수 Swift로 보이지만 그 아래 상당 부분이 Objective-C 유산 위에 서 있다. Foundation, UIKit, 그리고 KVO 같은 메커니즘이 그렇다.

이 챕터는 그 경계에서 실제로 무슨 일이 일어나는지를 다룬다. 캐스팅 하나가 언제 공짜이고 언제 비싼지, 에러가 어떻게 두 세계를 오가는지, 관찰이 왜 @objc만으로는 동작하지 않는지.

동시에 이 경계는 지금 이동 중이다. Foundation의 내부 구현이 Objective-C에서 Swift로 재작성되고 있고, KVO는 Observation으로 대체되고 있다. 그래서 무엇이 이미 바뀌었고 무엇이 남았는지를 아는 것 자체가 실무 지식이다.

Q1. Foundation은 지금 무엇으로 만들어져 있는가?

🔑 30초 답변

Objective-C에서 Swift로 재작성하는 다년 프로젝트가 진행 중이고, 핵심 타입은 이미 교체됐다. 흐름은 2022-12 계획 발표 → 2023-04 프리뷰 패키지와 성능 수치 공개 → 2023년 가을(iOS 17)Predicate·FormatStyle신규 API 우선 도입2024년 가을(iOS 18/macOS 15)JSONDecoder·URL·Calendar·FileManager기존 핵심 타입 실제 교체 → Swift 6로 Linux·Windows 통합 순이다. swift.org가 공개한 검증 가능한 수치는 넷뿐이다 — Locale 고정 조회 자릿수 단위 개선, Calendar 20%대, Date FormatStyle 150%, JSON 디코딩 200~500%.

CS 원리

이것은 인터페이스를 유지한 채 구현을 교체하는 대규모 사례다. P0 17장의 정보 은닉이 실제로 값을 하는 순간이다 — API가 구현 세부를 감추고 있었기 때문에 내부를 통째로 갈아끼울 수 있었다.

재작성의 동기는 계층 제거다. 기존 Foundation은 Objective-C로 작성돼 있어, Swift에서 쓸 때마다 브리징 계층을 통과했다. 값 타입을 참조 타입으로 감싸고, 참조 카운팅과 메시지 디스패치를 거치고, 다시 값으로 돌아왔다.

31장 Q6에서 본 JSON 디코딩 개선의 원인이 정확히 이것이다 — 리플렉션이 아니라 JSONSerialization이 만드는 Objective-C 객체 그래프와의 브리징(swift_dynamicCast) 제거가 개선을 만들었다.

또 다른 동기는 이식성이다. Objective-C 런타임은 Apple 플랫폼에 묶여 있어, Linux·Windows에서는 별도 구현(swift-corelibs-foundation)이 필요했다. 순수 Swift로 재작성하면 하나의 구현으로 통합된다.

여기서 실무적으로 중요한 것은 수치를 정확히 인용하는 태도다. "Foundation이 전반적으로 빨라졌다"는 검증할 수 없는 서술이고, 공개된 것은 특정 영역의 특정 수치 넷이다.

재작성은 한 번에 일어나지 않았다 2022-12 계획 발표 2023-04 프리뷰·수치 공개 2023 가을 · iOS 17 신규 API 우선 Predicate · FormatStyle 2024 가을 · iOS 18 기존 핵심 타입 교체 JSONDecoder · URL · Calendar Swift 6~ Linux·Windows 통합 "2023년 발표 = 그해에 다 바뀜"은 틀렸다 — 교체는 정확히 한 사이클 뒤다 swift.org 가 공개한 검증 가능한 수치는 넷 Locale 고정 조회 자릿수 단위 Calendar 20%대 Date FormatStyle 150% JSON 디코딩 200~500% 그 외의 "전반적으로 빨라졌다"는 검증할 수 없는 서술이다
인터페이스가 구현을 감추고 있었기에 내부를 통째로 갈아끼울 수 있었다 — P0 17장 정보 은닉의 실물 사례다.

iOS에서는

실무에 미치는 영향은 세 갈래다.

(1) 성능이 조용히 좋아졌다. 코드를 안 바꿔도 OS를 올리면 개선된다. 다만 최소 지원 버전에 따라 사용자마다 다르다.

// 같은 코드가 iOS 18+ 에서 더 빠르게 돈다
let items = try JSONDecoder().decode([Item].self, from: data)
//   구현이 바뀐 것이지 우리 코드가 바뀐 게 아니다

(2) 미묘한 동작 차이가 있을 수 있다. 재작성은 동작 호환을 목표로 하지만, 경계 사례에서 차이가 드러날 수 있다.

// 검증할 가치가 있는 영역
// - URL 파싱: RFC 3986 파서로 통일되며 동작이 바뀐 사례가 있다 (27장 Q6)
// - Calendar 경계: DST·윤년 계산 (29장)
// - 날짜 파싱 관용도: 소수 초 처리 등 (29장 Q5 꼬리질문)

(3) 서버 사이드·크로스 플랫폼이 쉬워진다. Linux·Windows에서 같은 구현이 돌므로 플랫폼별 차이가 줄어든다 — 29장 Q5에서 본 DateFormatter의 Linux 스레드 안전성 이슈 같은 것들이 여기 해당한다.

// 새 API 는 순수 Swift 로 설계돼 있어 크로스 플랫폼이 자연스럽다
import FoundationEssentials       // Foundation 의 코어만 — 더 가볍다
//   서버 사이드에서는 전체 Foundation 대신 이쪽을 import 할 수 있다

실험 · 도구

어느 구현이 도는지 확인하는 방법이다.

# 앱이 링크한 Foundation 을 확인한다
otool -L YourApp.app/YourApp | grep -i foundation

# 런타임에 실제 클래스를 확인 (브리징된 타입의 정체)
import Foundation

// 브리징된 객체의 실제 클래스를 본다 — Q5 에서 다시 쓰는 기법이다
let s = "hello"
print(object_getClass(s as NSString) ?? "nil")
//   _NSCFString · NSTaggedPointerString 등 구현체 이름이 드러난다

let d = Data([1,2,3])
print(object_getClass(d as NSData) ?? "nil")

성능 변화를 확인하려면 OS 버전별로 재야 한다.

// 같은 벤치를 여러 시뮬레이터에서 돌려 비교한다
let clock = ContinuousClock()
let t = clock.measure {
    for _ in 0..<1000 { _ = try? JSONDecoder().decode([Item].self, from: sample) }
}
print("\(ProcessInfo.processInfo.operatingSystemVersionString): \(t)")
# 여러 OS 버전 시뮬레이터에서 같은 테스트를 돌린다
for os in "17.5" "18.0"; do
  xcodebuild test -scheme Bench \
    -destination "platform=iOS Simulator,name=iPhone 15,OS=$os" 2>&1 | grep "operatingSystem"
done

프로젝트 적용

  1. 성능 개선을 우리 공로로 기록하지 않는다. OS 업데이트로 지표가 좋아졌으면 그렇게 기록해야 다음 판단이 정확해진다(P1 21장 Q5의 릴리스 태깅).
  2. 최소 지원 버전을 올릴 때 이 개선을 근거로 쓸 수 있다. 다만 수치는 공개된 넷만 인용한다.
  3. 경계 동작을 회귀 테스트로 고정한다. URL 파싱·날짜 파싱은 구현 교체의 영향을 받는 영역이다.
  4. 서버 사이드에서는 FoundationEssentials를 검토한다. 전체 Foundation보다 가볍다.
  5. "Foundation은 느리다"는 오래된 인식을 갱신한다. 특히 JSON 디코딩은 크게 달라졌다.
⚠️ 흔한 오해

"2023년에 발표됐으니 그해에 다 바뀌었다"는 틀렸다 — 신규 API가 먼저 들어왔고 기존 핵심 타입 교체는 정확히 한 사이클 뒤인 2024년 가을이다. "Foundation 전체가 몇 배 빨라졌다"도 과장이다 — 공개된 검증 가능한 수치는 넷뿐이고 영역별로 다르다. "Swift로 재작성됐으니 브리징이 사라졌다"도 부정확하다 — Objective-C 프레임워크와의 경계는 여전히 존재한다(Q2). "재작성은 동작이 완전히 같다"도 보장이 아니다 — 경계 사례에서 차이가 드러날 수 있다.

🧒 쉽게 이해하기

오래된 건물을 겉모습은 그대로 두고 안쪽만 새로 짓는 공사를 생각해 보자.

들어가는 문도, 방 배치도 그대로다. 그래서 쓰는 사람은 달라진 걸 모른다. 그런데 배관과 전선이 새것으로 바뀌어 물이 더 빨리 나오고 전기도 덜 샌다.

이게 가능한 이유는 처음부터 "안쪽이 어떻게 생겼는지 몰라도 쓸 수 있게" 만들어 뒀기 때문이다. 방 안 배관을 직접 만지게 했다면 못 바꿨을 것이다.

공사는 한 번에 안 끝났다. 먼저 새로 만드는 방부터 새 방식으로 짓고, 1년 뒤에 기존 방들을 손봤다. 그래서 "공사 발표했으니 다 됐겠지" 하면 틀린다.

그리고 "얼마나 좋아졌냐"는 질문에 정확히 답할 수 있는 건 네 가지뿐이다. 나머지는 "좋아진 것 같다" 수준이라, 그렇게 말해야 정직하다.

꼬리 질문

OS 업데이트로 성능이 바뀌면 지표를 어떻게 해석해야 하는가?

OS 버전을 지표의 차원으로 두어야 구분된다. 그러지 않으면 우리 개선과 OS 개선이 뒤섞인다.

P1 18장·22장에서 만든 파이프라인에 OS 버전 태그가 이미 있다면 그대로 쓸 수 있다.

// 성능 이벤트에 OS 버전을 항상 붙인다
span.setTag("os_version", ProcessInfo.processInfo.operatingSystemVersionString)
span.setTag("app_version", BuildInfo.release)      // P1 21장 Q5

분석할 때는 같은 OS 버전 안에서 앱 버전을 비교한다. 이렇게 하면 다음이 구분된다.

  • 우리 개선 — 같은 OS에서 앱 버전이 올라갈 때 좋아짐.
  • OS 개선 — 같은 앱 버전에서 OS가 올라갈 때 좋아짐.
  • 사용자 이동 — 전체 평균만 좋아짐(사용자들이 새 OS로 옮겨간 결과).

세 번째가 특히 헷갈린다 — 아무것도 안 했는데 평균 지표가 좋아진다. P1 22장 Q4에서 본 표본 편향과 같은 구조다.

쉽게 말하면 반 평균 점수가 올랐을 때, 우리가 잘 가르쳐서인지 공부 잘하는 학생이 전학 와서인지 구분해야 한다. 그러려면 학생별로 나눠서 봐야 한다.
FoundationEssentials는 언제 쓰는가?

서버 사이드·CLI처럼 UI가 없는 환경에서 유용하다. Foundation의 코어만 담고 있어 의존과 바이너리 크기가 줄어든다.

포함 여부의 대략적 기준은 이렇다.

  • 포함String 확장, Data, Date·Calendar, JSONEncoder/Decoder, URL, UUID, Predicate플랫폼 독립적인 것.
  • 미포함FileManager의 일부, 네트워킹, 국제화 데이터가 큰 부분 등 플랫폼·리소스 의존이 큰 것.
#if canImport(FoundationEssentials)
import FoundationEssentials
#else
import Foundation
#endif
//   크로스 플랫폼 패키지에서 흔히 쓰는 패턴이다

iOS 앱에서는 이득이 크지 않다 — 시스템 Foundation이 dyld 공유 캐시에 있어 추가 비용이 거의 없기 때문이다(P1 26장 Q3). 서버나 임베디드처럼 런타임을 함께 배포하는 환경에서 의미가 있다.

쉽게 말하면 공구함 전체를 들고 다닐지 자주 쓰는 몇 개만 챙길지의 문제다. 집에 공구함이 이미 있으면(아이폰의 시스템 프레임워크) 굳이 챙길 필요가 없고, 출장 갈 때(서버 배포)는 가벼운 게 낫다.
구현 교체로 동작이 바뀐 사례를 어떻게 미리 잡는가?

경계 사례 회귀 테스트가 유일하게 확실한 방법이다. 이 티어의 다른 장들에서 만든 테스트가 여기서 값을 한다.

특히 검증할 영역들이다.

영역확인할 것참조
URL 파싱무효 문자·이중 인코딩27장 Q6
날짜 파싱소수 초·시간대 표기 변형29장 Q5
달력 계산DST 경계·윤년29장 Q3
문자열 정렬로케일 대조 결과28장 Q2
JSON 디코딩숫자 정밀도·키 전략31장 Q6

테스트 작성 시 28장 Q2 꼬리질문의 원칙을 지킨다 — 정확한 출력 문자열을 고정하지 말고 성질을 검사한다. 구현이 바뀌면 서식이 미세하게 달라질 수 있는데, 그것이 버그가 아닐 수도 있기 때문이다.

// ❌ 출력을 그대로 고정 — 구현 교체로 깨질 수 있다
#expect(date.formatted() == "2026. 8. 15.")

// ✅ 성질을 검사
#expect(date.formatted(.iso8601) == "2026-08-15T00:00:00Z")   // 이건 규격이 고정이다
#expect(Calendar(identifier: .gregorian).component(.year, from: date) == 2026)

그리고 OS 베타 기간에 CI를 돌리는 것이 실질적으로 가장 효과적이다 — 정식 출시 전에 차이를 발견할 수 있다.

쉽게 말하면 건물 안쪽 공사가 끝난 뒤 물이 잘 나오는지, 전기가 들어오는지 확인해 봐야 한다. 특히 평소에 잘 안 쓰는 구석 수도꼭지를 틀어 봐야 한다 — 문제는 대개 거기서 나온다.

Q2. Toll-Free Bridging의 실제 비용은 얼마인가?

🔑 30초 답변

이름이 같아 보이는 두 계층이 섞여 있고, 하나만 진짜 공짜다. 좁은 의미의 toll-free bridging은 CoreFoundation 타입(CFStringRef 등)과 대응 Foundation 클래스(NSString 등) 사이의 캐스팅만 가리킨다 — 둘은 메모리상 진짜 같은 객체라 캐스트가 포인터를 다르게 보는 것뿐이고 정말 공짜다. 반면 Swift 값 타입(String·Array·Dictionary)과 ObjC 클래스 사이의 브리징은 _ObjectiveCBridgeable을 통한 별개 메커니즘이다. 실측(10만 개 [Int])으로 as NSArray 캐스트는 0.0055ms로 지연 래퍼만 만들고, 실제 박싱 비용 16ms순회해 꺼낼 때 발생한다.

CS 원리

두 계층을 구분하는 것이 이 문항의 전부다.

좁은 의미 toll-freeSwift 값 타입 브리징
대상CFStringNSStringStringNSString
메커니즘메모리 레이아웃이 같음_ObjectiveCBridgeable
캐스트 비용진짜 0 — 포인터 재해석지연 래퍼 생성
실제 비용없음소비 시점에 발생

첫 번째가 공짜인 이유는 같은 객체이기 때문이다. Apple이 CoreFoundation과 Foundation을 설계할 때 메모리 레이아웃을 일치시켜, 같은 메모리를 두 타입 시스템에서 다르게 볼 수 있게 했다.

두 번째는 근본적으로 다른 문제다. Swift의 Array<Int>연속된 정수 버퍼이고 NSArray객체 포인터 배열이다. 표현이 다르므로 변환이 필요하다.

여기서 지연(lazy) 전략이 나온다. 캐스트 시점에 전부 변환하면 비싸므로, 래퍼만 만들어 두고 실제 원소를 꺼낼 때 변환한다. 그래서 비용이 캐스트가 아니라 소비 시점으로 미뤄진다.

이것은 측정을 오도하기 쉬운 구조다. 캐스트만 재면 "공짜"라는 결론이 나오는데, 실제 사용 경로 전체를 재야 진짜 비용이 보인다.

이름이 비슷한 두 계층이 섞여 있다 CFString ↔ NSString 메모리상 진짜 같은 객체 캐스트 = 포인터를 다르게 보기 정말 공짜다 Array<Int> ↔ NSArray 표현이 다르다 — 정수 버퍼 vs 객체 포인터 _ObjectiveCBridgeable 로 변환 공짜가 아니다 비용이 캐스트가 아니라 소비 시점에 발생한다 (10만 개 실측) as NSArray 캐스트 0.0055 ms 래퍼만 생성 __SwiftDeferred NSArray 순회 NSNumber 박싱 16 ms 캐스트만 재면 "공짜"라는 잘못된 결론이 나온다 사용 경로 전체를 재야 진짜 비용이 보인다
"toll-free이니 Array<Int>NSArray로 바꾸는 것도 공짜"는 두 계층을 혼동한 것이다.

iOS에서는

실무에서 이 비용이 드러나는 자리는 ObjC API 경계다.

import Foundation

let numbers = Array(0..<100_000)

// 캐스트 자체는 거의 공짜다 — 지연 래퍼만 만든다
let ns = numbers as NSArray                    // 약 0.0055ms

// 실제 비용은 순회할 때 발생한다
for case let n as Int in ns { _ = n }          // 약 16ms — NSNumber 박싱

// 27장 Q4 꼬리질문에서 본 원칙이 여기도 적용된다
// ❌ 루프 안에서 매번 브리징
for i in 0..<1000 { _ = (text as NSString).substring(to: 5) }

// ✅ 한 번만 변환해 재사용
let nsText = text as NSString
for i in 0..<1000 { _ = nsText.substring(to: 5) }

어떤 타입이 어느 계층인지 알아 두면 판단이 빨라진다.

가장 확실한 대응은 브리징 자체를 줄이는 것이다.

// ObjC API 를 쓰더라도 경계를 좁게 유지한다
func processImages(_ urls: [URL]) -> [Result] {
    // 내부는 전부 Swift 타입으로
    urls.compactMap { url in
        // 경계에서만 최소한으로 변환
        guard let cg = CGImageSourceCreateWithURL(url as CFURL, nil) else { return nil }
        …
    }
}

실험 · 도구

비용이 어디서 발생하는지 나눠 재면 분명해진다.

import Foundation

let numbers = Array(0..<100_000)
let clock = ContinuousClock()

// (1) 캐스트만
let castTime = clock.measure { _ = numbers as NSArray }

// (2) 캐스트 + 순회
let fullTime = clock.measure {
    let ns = numbers as NSArray
    for case let n as Int in ns { _ = n }
}

// (3) 비교군 — Swift 배열 순회
let nativeTime = clock.measure { for n in numbers { _ = n } }

print("캐스트만:   \(castTime)")      // 약 0.0055ms
print("캐스트+순회: \(fullTime)")     // 약 16ms
print("네이티브:   \(nativeTime)")    // 훨씬 빠르다

// 지연 래퍼의 정체를 확인한다
print(object_getClass(numbers as NSArray) ?? "nil")
//   __SwiftDeferredNSArray — 아직 변환하지 않았다는 뜻이다

코드베이스에서 브리징이 뜨거운 경로에 있는지 찾는다.

# 루프 안 브리징 — 반복 변환 후보
grep -rn -B3 "as NSString\|as NSArray\|as NSDictionary" --include=*.swift . \
  | grep -E "for |while |\.map|\.forEach" | head -20

# CF 타입 캐스트 — 이쪽은 대체로 공짜다
grep -rn "as CF[A-Z]" --include=*.swift . | head

실제 병목인지는 Time Profiler에서 bridge·NSNumber 관련 프레임이 보이는지로 확인한다(P0 15장 Q6).

프로젝트 적용

  1. 루프 안에서 브리징하지 않는다. 한 번 변환해 재사용한다 — 가장 효과가 큰 조치다.
  2. ObjC API 경계를 좁게 유지한다. 내부 로직은 Swift 타입으로 다루고 경계에서만 변환한다.
  3. 컬렉션 브리징을 특히 주의한다. 원소가 값 타입이면 개수에 비례하는 박싱이 일어난다.
  4. "toll-free"라는 말에 속지 않는다. 두 계층 중 어느 쪽인지 확인한다.
  5. 측정은 사용 경로 전체로 한다. 캐스트만 재면 비용을 놓친다.
⚠️ 흔한 오해

"toll-free이니 Array<Int>NSArray로 바꾸는 것도 공짜"는 서로 다른 두 계층을 혼동한 것이다. "캐스트가 빠르니 브리징이 싸다"도 틀렸다 — 비용이 소비 시점으로 미뤄질 뿐이다. "Swift는 ObjC와 완전히 분리됐다"도 아니다 — Foundation·UIKit 경계에서 여전히 만난다. "as 캐스트는 항상 O(1)"도 틀렸다 — 브리징 종류에 따라 다르다.

🧒 쉽게 이해하기

"환전 수수료가 없다"는 말에 두 가지 경우가 섞여 있다.

하나는 진짜 같은 돈인 경우다. 같은 지폐를 다른 이름으로 부르는 것뿐이라 바꿀 것도 없다. 이건 정말 공짜다.

다른 하나는 모양이 다른 돈인 경우다. 동전 10만 개를 지폐로 바꿔야 한다면 세는 시간이 든다.

그런데 재미있는 일이 벌어진다. 창구에서 "바꿔 드릴게요" 하고 영수증만 주고 실제로는 안 센다. 그래서 창구에서 걸린 시간을 재면 거의 0초다.

진짜 세는 일은 돈을 실제로 꺼내 쓸 때 벌어진다. 그때 16밀리초가 든다.

그래서 "창구가 빠르니까 환전이 공짜"라고 하면 틀린다. 돈을 다 쓸 때까지를 재야 진짜 비용이 나온다.

꼬리 질문

NSString과 String을 섞어 쓰는 코드는 어떻게 정리하는가?

경계를 한 곳으로 모으고 내부는 Swift 타입으로 통일하는 것이 원칙이다.

정리 순서는 이렇다.

(1) NSString이 필요한 진짜 이유를 확인한다. 대개 셋 중 하나다 — NSRange API(27장 Q7), NSAttributedString, ObjC 라이브러리 인터페이스.

(2) 대체 가능한지 본다. 상당수는 이미 Swift 대안이 있다.

// ❌ NSString 관용구
let ext = (path as NSString).pathExtension
let joined = (base as NSString).appendingPathComponent(name)

// ✅ Swift 대안
let ext = URL(filePath: path).pathExtension
let joined = URL(filePath: base).appending(path: name).path()

// ❌ NSRegularExpression + NSRange
// ✅ Swift Regex (27장 Q7)

(3) 남는 경계는 래퍼로 감싼다. 변환 지점이 한 곳이면 비용도 한 곳이고 나중에 바꾸기도 쉽다.

enum LegacyText {
    /// ObjC 라이브러리 경계 — 여기서만 변환한다
    static func highlight(_ text: String, pattern: String) -> [Range<String.Index>] {
        let ns = text as NSString          // 한 번만
        let re = try? NSRegularExpression(pattern: pattern)
        let all = re?.matches(in: text, range: NSRange(location: 0, length: ns.length)) ?? []
        return all.compactMap { Range($0.range, in: text) }   // Swift 범위로 돌려준다
    }
}
쉽게 말하면 두 나라 돈을 주머니마다 섞어 넣으면 계산할 때마다 환전해야 한다. 문 앞에서 한 번만 바꾸고 안에서는 한 종류만 쓰는 게 낫다.
딕셔너리 브리징이 특히 비싼 이유는?

키와 값 양쪽 모두 박싱이 필요하고, 해시 재계산까지 일어날 수 있기 때문이다.

[String: Int]NSDictionary로 바꾸면 이런 일이 필요하다.

  • 키 변환 — 각 StringNSString으로.
  • 값 변환 — 각 IntNSNumber로 박싱.
  • 해시 재계산 — Swift와 ObjC의 해시 계약이 달라 다시 계산될 수 있다.

배열과 마찬가지로 지연 전략이 적용되지만, 키 조회 한 번이 전체 변환을 유발할 수 있어 배열보다 예측이 어렵다.

let dict = Dictionary(uniqueKeysWithValues: (0..<10_000).map { ("key\($0)", $0) })
let clock = ContinuousClock()

let castOnly = clock.measure { _ = dict as NSDictionary }
let withLookup = clock.measure {
    let ns = dict as NSDictionary
    _ = ns["key5000"]                      // 조회 한 번이 변환을 유발할 수 있다
}
print("캐스트: \(castOnly)\n조회 포함: \(withLookup)")

대응은 ObjC API에 딕셔너리를 통째로 넘기지 않는 것이다. 필요한 값만 뽑아 넘기거나, 처음부터 NSDictionary로 만드는 편이 나을 수 있다 — 예컨대 UserDefaults에 저장할 값이라면 그렇다.

쉽게 말하면 동전 지갑을 통째로 바꾸려면 동전마다 하나씩 세야 하는데, 딕셔너리는 이름표까지 함께 바꿔야 해서 두 배 걸린다. 게다가 정리 순서까지 다시 매겨야 할 수 있다.
ObjC 라이브러리를 Swift 래퍼로 감싸는 것이 항상 좋은가?

대체로 좋지만 비용이 있다. 판단 기준이 있다.

감싸는 것이 좋은 경우다.

  • API가 자주 쓰인다 — 브리징 지점을 한 곳으로 모으면 이득이 크다.
  • 타입이 불편하다NSRange·NSError·id 계열을 Swift 타입으로 바꿔 주면 호출부가 크게 나아진다.
  • 교체 가능성이 있다 — 래퍼가 있으면 나중에 구현을 바꾸기 쉽다(P0 17장).

감싸지 않는 것이 나은 경우다.

  • 한 곳에서만 쓴다 — 래퍼가 간접 계층만 늘린다.
  • API 표면이 아주 넓다 — 전부 감싸려면 유지 비용이 크고, 일부만 감싸면 두 방식이 공존해 오히려 혼란스럽다.
  • 성능이 critical하다 — 래퍼가 추가 변환을 만들 수 있다.

실무 절충은 "자주 쓰는 좁은 표면만 감싸기"다. 전부 감싸려 하지 말고 실제로 쓰는 것만 노출한다 — P0 17장의 인터페이스는 비용이라는 원칙이 여기도 적용된다.

쉽게 말하면 외국 상점에서 물건을 자주 산다면 통역을 한 명 두는 게 낫다. 그런데 한 번만 갈 거면 통역 구하는 게 더 번거롭다. 그리고 통역에게 가게 물건 전부를 외우게 할 필요는 없다 — 자주 사는 것만 알면 된다.

Q3. Data는 무엇을 감추고 있는가?

🔑 30초 답변

여러 저장 방식을 한 값 안에서 구분해 담는다. Data@frozen struct로 선언된 값 타입이고, 내부 버퍼를 CoW로 공유하다가 바이트를 바꾸는 순간에만 복사한다. NSData와 toll-free 브리징되어 ObjC와 Swift 사이의 공용 화폐 역할을 한다. 실측으로 MemoryLayout<Data>.size == 16인데, [UInt8](8)이나 참조 타입 NSData(8)보다 큰 이유는 빈 값 · 소량 인라인 바이트 · 힙 버퍼 참조 · 메모리 매핑 파일 백킹까지 여러 표현을 한 값 안에서 구분해야 하기 때문이다. 그래서 "Data는 항상 순수 힙 바이트 배열"은 부정확하다.

CS 원리

이것은 태그된 표현(tagged representation)의 사례다. 하나의 타입이 여러 내부 표현을 가지고, 값 안의 태그로 어느 것인지 구분한다.

27장 Q4의 스몰 스트링 최적화와 같은 발상이다 — 작은 값은 인라인으로, 큰 값은 힙 참조로. Data는 여기에 매핑된 파일이라는 표현을 하나 더 갖는다.

표현쓰이는 상황특징
빈 값Data()할당 없음
인라인아주 짧은 바이트열힙 할당 없음
힙 버퍼일반적인 경우CoW로 공유
매핑된 파일큰 파일 읽기디스크가 백킹 — RAM에 다 없다

마지막이 중요하다. Data(contentsOf:options: .mappedIfSafe)로 읽으면 파일이 가상 메모리에 매핑되어, 실제 바이트는 접근할 때 페이지 폴트로 올라온다(P0 01장 Q7). 그래서 10GB 파일도 Data로 열 수 있지만, 전부 순회하면 그만큼 읽는다.

여러 표현을 담으려면 값이 커진다. 16바이트인 이유가 이것이고, 이는 표현력과 크기의 트레이드오프다.

또 하나 알아 둘 것은 연속성이다. DataProtocol여러 조각으로 나뉜(discontiguous) 데이터를 허용하지만, 실측으로 Data는 400MB 파일에 append를 반복해도 항상 연속 버퍼로 정리됐다(regions.count == 1). 진짜 discontiguous 사례는 DispatchData에서만 나왔다.

한 타입이 네 가지 표현을 담는다 — 그래서 16바이트 빈 값 Data() 할당 없음 인라인 소량 바이트 힙 할당 없음 힙 버퍼 일반적인 경우 CoW 로 공유 매핑된 파일 .mappedIfSafe 디스크가 백킹 크기 비교 — 표현력의 대가 [UInt8] 8 바이트 — 참조 하나 NSData 8 바이트 — 참조 하나 Data 16 바이트 — 네 표현을 구분해야 하므로 "Data 는 항상 순수 힙 바이트 배열"은 부정확하다
27장 Q4의 스몰 스트링 최적화와 같은 발상에 매핑된 파일이라는 표현이 하나 더 있다.

iOS에서는

메모리 매핑이 큰 파일 처리의 열쇠다.

import Foundation

// ❌ 전체를 RAM 으로 읽는다 — 400MB 파일이면 400MB 를 쓴다 (P0 10장 Q2)
let all = try Data(contentsOf: bigFileURL)

// ✅ 매핑 — 가상 주소만 잡고 실제 페이지는 접근 시 올라온다
let mapped = try Data(contentsOf: bigFileURL, options: .mappedIfSafe)
let header = mapped.prefix(1024)              // 앞 1KB 만 실제로 읽힌다

다만 매핑에도 대가가 있다.

CoW 성질도 실무에 영향을 준다.

var a = Data(repeating: 0, count: 10_000_000)
var b = a                    // 복사가 아니다 — 버퍼를 공유한다
b[0] = 1                     // 여기서 실제 복사가 일어난다 (10MB)

// 27장 Q5 의 슬라이스 문제도 그대로 적용된다
let slice = a[0..<10]        // 원본 버퍼를 붙잡는다
let detached = Data(a[0..<10])   // ✅ 떼어낸다

브리징은 상대적으로 싸다 — NSData와 표현이 가까워 컬렉션 브리징(Q2) 같은 박싱이 없다.

실험 · 도구

표현 차이를 직접 관찰해 보자.

import Foundation

print(MemoryLayout<Data>.size)        // 16
print(MemoryLayout<[UInt8]>.size)     // 8

// 매핑 여부에 따라 메모리 사용이 다르다
func rssMB() -> Double { /* 27장 Q5 의 rssMB 사용 */ 0 }

print("시작: \(rssMB())")
let full = try Data(contentsOf: bigURL)                       // 전체 로드
print("전체 로드: \(rssMB())")

let mapped = try Data(contentsOf: bigURL, options: .mappedIfSafe)
print("매핑: \(rssMB())")                                      // 거의 안 는다
_ = mapped.reduce(0, &+)                                       // 전부 순회하면
print("순회 후: \(rssMB())")                                    // 여기서 는다

연속성도 확인할 수 있다.

var d = Data()
for _ in 0..<1000 { d.append(Data(repeating: 0, count: 400_000)) }
print(d.regions.count)        // 1 — 항상 연속 버퍼로 정리된다

import Dispatch
var dd = DispatchData.empty
for _ in 0..<3 { dd.append(DispatchData(bytes: UnsafeRawBufferPointer(start: nil, count: 0))) }
//   DispatchData 는 여러 조각을 유지할 수 있다
# 프로세스의 매핑 상태를 본다 (macOS)
vmmap $(pgrep MyApp) | grep -i "mapped file" | head

# 큰 파일을 전체 로드하는 코드 찾기 — 매핑 후보
grep -rn "Data(contentsOf:" --include=*.swift . | grep -v "mapped"

프로젝트 적용

  1. 큰 파일은 .mappedIfSafe로 연다. 헤더만 보거나 일부만 쓰는 경우 메모리를 크게 아낀다.
  2. 전부 순회할 거면 매핑 이득이 없다. 스트리밍 처리로 바꾸는 편이 낫다.
  3. Data 슬라이스를 오래 보관하지 않는다. 27장 Q5와 같은 문제다 — Data(slice)로 떼어낸다.
  4. Data를 로그에 찍지 않는다. 바이너리가 그대로 남고 PII가 섞일 수 있다(P1 23장).
  5. 메모리 지표를 볼 때 매핑을 구분한다. 매핑된 페이지는 깨끗한 페이지라 압박 시 버려질 수 있어, dirty 메모리와 성격이 다르다(P0 01장).
⚠️ 흔한 오해

"Data는 항상 순수 힙 바이트 배열"은 부정확하다매핑된 파일 백킹일 수 있다. "Data[UInt8]보다 크니 비효율적"도 단순화다 — 여러 표현을 담는 대가이고 그 덕에 인라인·매핑이 가능하다. "Data는 조각으로 나뉠 수 있다"도 실측과 다르다 — 400MB append 반복에도 항상 연속 버퍼였고, discontiguous는 DispatchData에서 나왔다. "매핑하면 메모리를 안 쓴다"도 틀렸다 — 접근한 만큼 올라온다.

🧒 쉽게 이해하기

Data"바이트 뭉치"를 담는 상자인데, 안에 담는 방식이 네 가지다.

비어 있음 — 아무것도 없다.
아주 조금 — 상자 안에 직접 넣는다. 창고에 갈 필요가 없다.
보통 — 창고에 두고 보관증만 상자에 넣는다.
아주 큼 — 창고에도 안 넣고 "이 파일에 있어요"라는 쪽지만 넣는다.

마지막이 재미있다. 10GB짜리 파일도 상자에 담을 수 있는데, 실제로는 아무것도 안 옮겼다. 필요한 부분을 꺼낼 때 그때그때 읽어 온다.

그래서 앞부분만 볼 거면 이 방식이 아주 유리하다. 그런데 처음부터 끝까지 다 볼 거면 결국 다 읽으니 이득이 없다.

네 가지를 구분하려니 상자에 표시가 더 필요해서, 이 상자는 다른 상자보다 조금 크다(16 대 8). 표현이 많아진 대가다.

꼬리 질문

큰 파일을 처리할 때 매핑과 스트리밍 중 무엇을 골라야 하는가?

접근 패턴이 정한다.

패턴선택이유
일부만 본다 (헤더·인덱스)매핑필요한 페이지만 올라온다
무작위 접근매핑OS 페이지 캐시가 잘 동작한다
처음부터 끝까지 한 번스트리밍메모리를 일정하게 유지한다
쓰기가 섞임스트리밍·전용 API매핑 쓰기는 다루기 까다롭다
// 스트리밍 — 메모리를 일정하게 유지한다
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }                     // 32장 Q7 의 defer 용법

while let chunk = try handle.read(upToCount: 1 << 20), !chunk.isEmpty {
    process(chunk)                                 // 1MB 씩
}

주의할 점은 메인스레드에서 하지 않는 것이다 — P0 10장 Q1에서 본 대로 동기 파일 I/O는 메인을 막고, 큰 파일이면 이 된다(P1 20장).

쉽게 말하면 두꺼운 책에서 목차만 볼 거면 책을 통째로 들고 오는 게 아니라 필요한 쪽만 펼치면 된다. 그런데 처음부터 끝까지 읽을 거면 어차피 다 봐야 하니, 한 장씩 넘기며 읽는 게 책상이 덜 어지럽다.
Data를 문자열로 바꿀 때 주의할 점은?

인코딩이 틀리면 조용히 실패하거나 깨진 문자가 나온다. 27장의 문자열 주제가 여기서 만난다.

// ❌ 실패를 무시한다
let s = String(data: data, encoding: .utf8) ?? ""    // 빈 문자열이 되어 원인을 잃는다

// ✅ 실패를 구분한다
guard let s = String(data: data, encoding: .utf8) else {
    logger.error("UTF-8 디코딩 실패 — \(data.count) 바이트, 앞 16바이트: \(data.prefix(16).map { String(format: "%02X", $0) }.joined(separator: " "), privacy: .public)")
    throw DecodeError.invalidEncoding
}

확인할 점들이다.

  • BOM — 파일 앞에 EF BB BF가 있으면 문자열 앞에 보이지 않는 문자가 붙는다.
  • 잘린 멀티바이트 — 스트리밍으로 나눠 읽으면 청크 경계에서 문자가 쪼개진다. 그러면 그 청크만으로는 디코딩에 실패한다.
  • 인코딩 추정 — 서버가 Content-Type에 charset을 주면 그것을 따르고, 없으면 UTF-8을 가정하되 실패를 기록한다.
// 청크 경계 문제 — 누적 버퍼를 두고 완전한 부분만 디코딩한다
var pending = Data()
func feed(_ chunk: Data) {
    pending.append(chunk)
    // UTF-8 은 자기 동기화 가능하므로 마지막 불완전 시퀀스를 남긴다
    while let (text, consumed) = decodeLongestValidPrefix(pending) {
        emit(text); pending.removeFirst(consumed)
    }
}
쉽게 말하면 바이트를 글자로 바꿀 때 어떤 규칙으로 적혔는지 알아야 한다. 규칙이 틀리면 글자가 깨진다. 그리고 나눠서 읽으면 글자 하나가 두 조각에 걸칠 수 있어서, 완전한 글자까지만 바꾸고 나머지는 다음 조각과 붙여야 한다.
Data의 CoW 때문에 메모리가 예상보다 많이 쓰이는 경우는?

공유하던 버퍼를 조금 수정하는 순간 전체가 복사된다.Data일수록 이 순간이 비싸다.

let original = try Data(contentsOf: url)      // 100MB
var copy = original                            // 아직 100MB (공유)
copy[0] = 0                                    // 여기서 +100MB — 순간 200MB

실무에서 이게 드러나는 패턴들이다.

  • 루프 안 수정 — 각 반복에서 복사가 일어나면 피크 메모리가 치솟는다.
  • 여러 곳에 보관 — 원본을 캐시에 두고 수정본을 만들면 둘 다 유지된다.
  • 매핑된 Data 수정 — 수정하면 매핑의 이점이 사라지고 메모리로 올라온다.

대응은 이렇다.

// ✅ 원본이 더 필요 없으면 명시적으로 놓아준다
var buffer = try Data(contentsOf: url)
process(&buffer)                    // inout 으로 넘기면 불필요한 복사가 줄어든다

// ✅ 새 버퍼를 만들 거면 예약해 재할당을 줄인다
var out = Data(); out.reserveCapacity(expectedSize)

// ✅ 수정이 잦으면 [UInt8] 이 나을 수 있다 — 표현이 단순하다

진단은 Allocations의 Persistent Bytes로 한다 — 27장 Q5에서 본 세대 비교가 그대로 쓰인다.

쉽게 말하면 큰 서류 뭉치를 둘이 같이 보고 있다가, 한 명이 한 글자만 고치려 해도 자기 사본을 통째로 복사해야 한다. 그래서 잠깐이지만 서류가 두 배가 된다.

Q4. NSError 브리징에서는 무엇이 오가는가?

🔑 30초 답변

Error를 준수하는 어떤 Swift 타입이든 별도 구현 없이 NSError로 자동 브리징된다. Swift 2부터의 체계이고 SE-0112가 Swift 3에서 정리했다. 기본값은 타입의 mangled name을 도메인으로, enum 케이스의 선언 순번(0부터)을 코드로 채운다. 이 기본값을 바꾸려면 CustomNSError(static var errorDomain·errorCode·errorUserInfo)를 준수하고, 사용자에게 보일 문자열은 LocalizedError가 채운다 — 관심사가 분리돼 있어 동시에 준수해도 된다. 역방향(NSError → Swift Error)은 _ObjectiveCBridgeableErrordomain/code 일치를 검증한 뒤에만 매핑한다.

CS 원리

두 에러 모델은 구조가 다르다.

Swift ErrorNSError
형태임의의 타입 (주로 enum)클래스 — domain + code + userInfo
식별타입으로문자열 domain + 정수 code
추가 정보연관값userInfo 딕셔너리

브리징은 이 구조 차이를 메우는 사상이다. Swift 타입에는 domain·code가 없으므로 기본값을 만들어 낸다 — 타입 이름과 케이스 순번이다.

여기서 취약점이 하나 생긴다. 케이스 순번이 코드가 되므로, 케이스 순서를 바꾸면 코드가 바뀐다. 저장되거나 전송된 에러 코드가 있다면 의미가 달라진다 — 31장 Q7의 의미 변경과 같은 종류의 위험이다.

그래서 안정적인 코드가 필요하면 CustomNSError로 명시해야 한다. 이는 암묵적 기본값에 의존하지 말라는 이 티어의 반복 주제와 같다.

역방향 변환이 검증을 거치는 것도 중요하다. 아무 NSError나 Swift 타입으로 바뀌면 거짓 매칭이 생기므로, domain과 code가 맞을 때만 변환된다.

구조가 다른 두 모델을 사상한다 Swift Error enum + 연관값 타입으로 식별 as NSError domain/code 검증 후 NSError domain + code + userInfo 문자열·정수로 식별 기본값 domain = mangled name code = 케이스 순번 두 프로토콜이 관심사를 나눠 채운다 CustomNSError static var errorDomain · errorCode 기본값을 직접 override LocalizedError errorDescription 등 사용자 노출 문자열 케이스 순서를 바꾸면 기본 code 가 바뀐다 — 31장 Q7 의 의미 변경과 같은 위험
관심사가 분리돼 있어 두 프로토콜을 동시에 준수해도 된다.

iOS에서는

기본 브리징은 아무 설정 없이 동작한다.

import Foundation

enum AppError: Error {
    case notFound        // code 0
    case timeout         // code 1
    case invalid(String) // code 2
}

let ns = AppError.timeout as NSError
ns.domain              // "MyApp.AppError" 형태의 mangled name
ns.code                // 1 — 선언 순번
//   ⚠️ 케이스 순서를 바꾸면 code 가 바뀐다

안정적인 코드가 필요하면 명시한다.

enum PaymentError: Error {
    case declined, insufficientFunds, networkFailure
}

extension PaymentError: CustomNSError {
    static var errorDomain: String { "com.example.payment" }   // ✅ static var

    var errorCode: Int {
        switch self {
        case .declined:          return 1001    // 명시적 — 순서를 바꿔도 안전하다
        case .insufficientFunds: return 1002
        case .networkFailure:    return 1003
        }
    }

    var errorUserInfo: [String: Any] {
        ["retryable": self == .networkFailure]
    }
}

extension PaymentError: LocalizedError {
    var errorDescription: String? {
        switch self {
        case .declined:          return String(localized: "결제가 거절되었습니다")
        case .insufficientFunds: return String(localized: "잔액이 부족합니다")
        case .networkFailure:    return String(localized: "네트워크 오류가 발생했습니다")
        }
    }
}
//   두 프로토콜은 관심사가 다르므로 함께 준수하는 것이 자연스럽다

localizedDescription이 어디서 오는지 이해하면 혼란이 줄어든다.

let e: Error = PaymentError.declined
e.localizedDescription        // LocalizedError.errorDescription 이 채운 값

let e2: Error = AppError.notFound
e2.localizedDescription       // LocalizedError 미준수 — 일반적인 기본 문구가 나온다
//   "The operation couldn't be completed. (MyApp.AppError error 0.)"

이 기본 문구가 사용자에게 그대로 노출되는 것이 흔한 사고다 — 31장 Q4의 원칙대로 사용자 메시지는 따로 만들어야 한다.

실험 · 도구

기본값이 어떻게 만들어지는지 확인해 보자.

import Foundation

enum E1: Error { case a, b, c }

for e in [E1.a, E1.b, E1.c] {
    let ns = e as NSError
    print("\(e) → domain=\(ns.domain) code=\(ns.code)")
}
// a → domain=main.E1 code=0
// b → domain=main.E1 code=1
// c → domain=main.E1 code=2

// 케이스 순서를 바꾸면 code 가 바뀐다 — 저장·전송된 코드가 있으면 위험하다
enum E2: Error { case c, a, b }
print((E2.a as NSError).code)     // 1 — E1 에서는 0 이었다

역방향 검증도 확인된다.

let real = PaymentError.declined as NSError
let fake = NSError(domain: "com.example.payment", code: 9999)

real as? PaymentError            // ✅ 매핑된다
fake as? PaymentError            // nil — code 가 맞지 않는다
//   domain/code 검증을 거치므로 아무 NSError 나 변환되지 않는다
# 에러 코드를 저장·전송하는 곳 — CustomNSError 로 고정할 후보
grep -rn "\.code\b" --include=*.swift . | grep -iE "save|store|send|log|analytics" | head

# LocalizedError 미준수인데 localizedDescription 을 사용자에게 보여주는 곳
grep -rn "localizedDescription" --include=*.swift . | grep -iE "alert|label|message" | head

프로젝트 적용

  1. 저장·전송되는 에러 코드는 CustomNSError로 고정한다. 기본값은 케이스 순서에 묶여 있어 리팩터링에 취약하다.
  2. 사용자에게 보일 문구는 LocalizedError로 명시한다. 안 하면 영문 기본 문구가 노출된다.
  3. 두 프로토콜을 함께 쓴다. 관심사가 다르므로 충돌하지 않는다.
  4. errorDomainstatic var임을 기억한다. 인스턴스 프로퍼티로 착각하면 컴파일이 안 된다.
  5. ObjC 라이브러리 에러를 Swift 타입으로 매핑할 때 domain/code를 확인한다. 자동 변환은 검증을 거치므로 우리 타입으로는 안 바뀐다.
⚠️ 흔한 오해

"CustomNSError.errorDomain은 인스턴스 프로퍼티"는 틀렸다 — 현재 표준 라이브러리 선언은 static var다. "ResultNSError는 완전히 다른 체계"도 틀렸다 — 둘 다 결국 같은 Error 프로토콜 위에서 상호운용된다. "localizedDescription은 항상 뭔가 유용한 값을 준다"도 위험하다 — LocalizedError 미준수면 기계적인 영문 문구다. "에러 코드는 자동으로 안정적"도 틀렸다 — 케이스 순번이라 순서를 바꾸면 달라진다.

🧒 쉽게 이해하기

두 나라가 사고 보고서를 다르게 쓴다.

한쪽은 "사고 종류"를 이름으로 적는다. "타이어 펑크", "연료 부족" 이런 식이다.
다른 쪽은 "어느 부서, 몇 번 사고"라는 숫자로 적는다. "교통과 3번" 이런 식이다.

둘이 서류를 주고받으려면 변환이 필요하다. 그래서 시스템이 알아서 번호를 붙여 준다 — 목록에서 몇 번째인지로.

여기서 문제가 생긴다. 나중에 목록 순서를 바꾸면 번호가 달라진다. 이미 "교통과 1번"이라고 적어 보낸 서류들이 다른 뜻이 되어 버린다.

그래서 번호가 중요하면 직접 정해 둬야 한다. "타이어 펑크는 무조건 1001번" 하는 식으로.

그리고 사람에게 보여줄 문장은 또 따로 적어야 한다. 안 적으면 "작업을 완료할 수 없습니다 (오류 0)" 같은 기계적인 말이 손님에게 그대로 나간다.

꼬리 질문

ObjC 라이브러리의 NSError를 어떻게 Swift 에러로 다루는가?

domain/code를 보고 우리 타입으로 매핑하되, 원본을 잃지 않는 것이 중요하다.

enum NetworkError: Error {
    case offline
    case timeout
    case other(underlying: NSError)     // ✅ 원본을 보관한다

    init(_ ns: NSError) {
        switch (ns.domain, ns.code) {
        case (NSURLErrorDomain, NSURLErrorNotConnectedToInternet): self = .offline
        case (NSURLErrorDomain, NSURLErrorTimedOut):               self = .timeout
        default:                                                    self = .other(underlying: ns)
        }
    }
}

원본 보관이 중요한 이유는 진단 정보가 userInfo에 있기 때문이다. 실패한 URL, 하위 에러, 복구 제안 등이 담긴다 — 이를 버리면 P1 18장에서 만든 로깅이 무의미해진다.

// 로깅할 때 userInfo 를 함께 남긴다 (PII 주의 — P1 23장)
if case .other(let ns) = error {
    logger.error("네트워크 실패: \(ns.domain, privacy: .public)/\(ns.code) \(ns.localizedDescription, privacy: .public)")
    if let url = ns.userInfo[NSURLErrorFailingURLStringErrorKey] as? String {
        logger.error("실패 URL: \(URL(string: url)?.observabilitySafe.absoluteString ?? "-", privacy: .public)")
    }
}

그리고 기존 도메인 상수를 쓴다. NSURLErrorDomain·NSCocoaErrorDomain 같은 상수와 코드 상수가 이미 정의돼 있으므로 숫자를 직접 적지 않는다.

쉽게 말하면 외국에서 온 사고 보고서를 우리 양식으로 옮겨 적을 때, 원본을 버리지 말고 같이 보관해야 한다. 옮겨 적으면서 빠진 정보가 나중에 필요해진다.
에러를 서버로 전송할 때 무엇을 담아야 하는가?

재현과 집계에 필요한 것만 담고, PII는 담지 않는다. P1 18장·23장의 원칙이 그대로 적용된다.

담아야 할 것들이다.

  • 안정적인 식별자 — domain + code. 이래야 집계와 추세가 가능하다(P1 18장 Q6의 그룹핑).
  • 맥락 — 화면, 사용자 동작, 앱 버전, OS 버전(Q1 꼬리질문).
  • 재시도 가능성 — 대응을 정하는 정보.

담지 말아야 할 것들이다.

  • localizedDescription 전체 — 번역된 문자열이라 로케일마다 달라져 집계가 갈린다(P1 18장 Q6의 가변값 문제).
  • userInfo 통째로 — URL·파일 경로·요청 본문에 PII가 섞일 수 있다.
struct ErrorReport: Encodable {
    let domain: String
    let code: Int
    let screen: String
    let appVersion: String
    let osVersion: String
    let retryable: Bool
    // localizedDescription 은 넣지 않는다 — 로케일마다 달라 그룹핑이 쪼개진다
}

여기서 P1 18장 Q6의 핵심이 다시 등장한다 — 가변값을 빼면 그룹핑이 복구된다. 번역된 문구는 대표적인 가변값이다.

쉽게 말하면 사고를 본사에 보고할 때 "어느 부서 몇 번"이라는 고정된 번호로 보내야 통계가 잡힌다. "타이어가 펑크났어요"라는 문장으로 보내면 사람마다 표현이 달라서 같은 사고인데 다 따로 세어진다.
Swift 에러가 ObjC 코드로 넘어갈 때 무엇이 필요한가?

대부분 자동으로 되지만, ObjC 쪽 규약을 맞춰야 한다.

Swift의 throws 함수를 @objc로 노출하면 ObjC의 NSError** 규약으로 변환된다.

@objc final class Service: NSObject {
    @objc func load() throws -> Data { … }
}
// ObjC 에서는 이렇게 보인다:
//   - (nullable NSData *)loadAndReturnError:(NSError **)error;
//   Swift 에러가 자동으로 NSError 로 브리징돼 out 파라미터에 채워진다

주의할 점이 셋이다.

  • 반환 타입 제약 — ObjC 규약상 실패를 표현할 수 있어야 하므로, 값 타입을 반환하는 throws 함수는 @objc로 노출되지 않을 수 있다.
  • 도메인·코드가 그대로 간다 — 그래서 CustomNSError의미 있는 값을 주는 것이 중요하다.
  • ObjC 쪽이 에러를 무시할 수 있다NSError**nil을 넘길 수 있어 강제성이 없다. Swift의 try가 주던 안전망이 사라진다.

실무에서는 ObjC 경계를 좁게 유지하는 것이 답이다(Q2 꼬리질문과 같은 원칙). 새 코드는 순수 Swift로 두고, 필요한 지점만 @objc로 노출한다.

쉽게 말하면 우리 방식의 사고 보고를 외국 양식으로 바꿔 보내는 건 자동으로 된다. 다만 저쪽 양식은 "보고 안 해도 그만"인 구조라, 받는 쪽이 그냥 무시할 수도 있다. 그래서 중요한 건 양식을 넘기는 지점을 최소로 하는 것이다.

Q5. KVO는 어떻게 구현되며 왜 @objc만으로는 부족한가?

🔑 30초 답변

런타임이 동적 서브클래스를 만들어 인스턴스의 isa 포인터를 바꿔치기한다. 관찰이 시작되는 순간 NSKVONotifying_X 클래스가 생성되고, 그 인스턴스의 isa가 거기로 교체된다 — 이것이 isa-swizzling이다. 결정적인 사실이 하나 있다. @objc만 있고 dynamic이 없어도 isa는 바뀌지만, 콜백은 조용히 발동하지 않는다. dynamic 없는 프로퍼티 대입은 컴파일러가 정적으로 저장소에 직접 쓰는 코드로 확정objc_msgSend를 아예 거치지 않고, 스위즐링된 서브클래스의 오버라이드된 setter를 우회하기 때문이다. 게다가 type(of:)이 사실을 숨긴다.

CS 원리

KVO는 런타임 가로채기(interception)의 전형이다. 원본 코드를 고치지 않고 동작 사이에 끼어드는 기법이고, P1 22장 Q5의 자동 계측(메서드 스위즐링)과 같은 계열이다.

가로채기가 성립하려면 호출이 반드시 지나는 지점이 있어야 한다. Objective-C에서 그 지점이 objc_msgSend다 — 모든 메서드 호출이 런타임 조회를 거치므로, 조회 결과를 바꾸면 다른 구현이 실행된다.

KVO의 구현은 이렇다.

  1. 관찰 시작 시 원본 클래스의 동적 서브클래스를 만든다.
  2. 서브클래스에서 setter를 오버라이드해 변경 통지를 넣는다.
  3. 그 인스턴스의 isa 포인터를 서브클래스로 바꾼다.
  4. 서브클래스가 -class도 오버라이드해 원래 클래스 이름을 반환한다.

4번이 흥미롭다 — 가로채기 사실을 숨기는 것이 설계에 포함돼 있다. 그래서 type(of:)는 원래 클래스를 보여주고, object_getClass만 isa를 직접 읽어 진짜를 드러낸다.

Swift에서 문제가 되는 지점은 Swift가 기본적으로 정적 디스패치를 쓴다는 것이다(33장 Q3). @objc런타임 테이블에 노출할 뿐이고, 호출이 objc_msgSend를 거치게 만드는 것은 dynamic이다. 노출됐지만 거치지 않으면 가로채기가 무력화된다.

그래서 "isa는 바뀌었는데 통지는 안 온다"는 상태가 만들어진다 — 가로채기 장치는 설치됐지만 아무도 그 문을 지나가지 않는 것이다.

관찰을 걸면 isa 가 동적 서브클래스로 바뀐다 Cat 인스턴스 관찰 시작 isa → NSKVONotifying_Cat setter 를 오버라이드 그런데 그 setter 로 가느냐는 별개 문제다 @objc 만 cat.name = "…" 저장소에 직접 쓴다 setter 를 우회 → 통지 없음 @objc dynamic cat.name = "…" objc_msgSend 를 거친다 오버라이드된 setter → 통지 ✅ 그리고 이 사실은 숨겨져 있다 type(of: cat) → Cat (거짓) object_getClass(cat) → 진짜
동적 서브클래스가 -class도 오버라이드해 원래 이름을 반환하도록 만들어져 있다.

iOS에서는

재현이 간단하고, 결과가 인상적이다.

import Foundation

final class Cat: NSObject {
    @objc var nameA = ""            // dynamic 없음
    @objc dynamic var nameB = ""    // dynamic 있음
}

let cat = Cat()
print(type(of: cat))                // Cat
print(object_getClass(cat)!)        // Cat

let obs = cat.observe(\.nameB) { _, _ in print("nameB 변경 감지") }

print(type(of: cat))                // Cat            ← 거짓말
print(object_getClass(cat)!)        // NSKVONotifying_Cat  ← 진짜

cat.nameB = "나비"                   // "nameB 변경 감지" 출력
cat.nameA = "야옹"                   // 아무 출력 없음 — dynamic 이 없어 우회했다

Swift 네이티브 KVO API를 쓰면 그나마 안전하다 — key path 기반이라 오타는 컴파일 타임에 잡힌다(32장 Q3).

// ✅ 타입 안전한 방식
let token = object.observe(\.property, options: [.new, .old]) { obj, change in
    print(change.oldValue, change.newValue)
}
//   token 이 살아 있는 동안만 관찰된다 — 해제를 잊어도 크래시하지 않는다

// ⚠️ 문자열 기반 구식 API — 오타가 런타임에야 드러나고 해제도 수동이다
object.addObserver(self, forKeyPath: "propety", options: [], context: nil)   // 오타!

값 타입에는 쓸 수 없다는 근본 제약도 있다. KVO는 NSObject 런타임에 의존하므로 struct·enum에는 적용되지 않는다. 이것이 Observation이 필요한 이유이기도 하다(Q6).

실험 · 도구

type(of:)가 어떻게 속이는지 직접 확인해 보자.

import Foundation

final class Probe: NSObject { @objc dynamic var value = 0 }

let p = Probe()
func report(_ label: String) {
    print("\(label): type(of:)=\(type(of: p))  object_getClass=\(object_getClass(p)!)")
}

report("관찰 전")
let t = p.observe(\.value) { _, _ in }
report("관찰 중")        // type(of:) 는 그대로, object_getClass 만 바뀐다
t.invalidate()
report("해제 후")

// 동적 서브클래스의 메서드 목록을 들여다본다
if let cls = object_getClass(p) {
    var count: UInt32 = 0
    if let methods = class_copyMethodList(cls, &count) {
        for i in 0..<Int(count) {
            print(NSStringFromSelector(method_getName(methods[i])))
        }
        free(methods)
    }
}
//   setValue: · class · dealloc · _isKVOA 등이 보인다

코드베이스에서 위험한 선언을 찾는다.

# @objc 만 있고 dynamic 이 없는 프로퍼티 — KVO 대상이면 동작하지 않는다
grep -rnE "@objc +var " --include=*.swift . | grep -v dynamic

# 문자열 기반 KVO — key path 로 옮길 후보
grep -rn "addObserver(.*forKeyPath:" --include=*.swift .

# 관찰 토큰을 보관하지 않는 곳 — 즉시 해제되어 동작하지 않는다
grep -rn "\.observe(" --include=*.swift . | grep -v "let \|var \|= "

프로젝트 적용

  1. KVO 대상 프로퍼티에는 @objc dynamic을 함께 붙인다. @objc만으로는 조용히 동작하지 않는다.
  2. key path 기반 API를 쓴다. 문자열 경로는 오타가 런타임에야 드러난다.
  3. 관찰 토큰을 반드시 보관한다. 반환값을 버리면 즉시 해제되어 콜백이 오지 않는다.
  4. 새 코드에는 Observation을 쓴다. KVO는 레거시·시스템 프레임워크 관찰에만 남긴다(Q6).
  5. type(of:)로 진단하지 않는다. 런타임 상태를 확인하려면 object_getClass를 쓴다.
⚠️ 흔한 오해

"@objc만 붙이면 KVO가 동작한다"는 틀렸다dynamic이 있어야 호출이 objc_msgSend를 거쳐 오버라이드된 setter로 간다. "isa가 바뀌었으니 관찰이 걸린 것"도 틀렸다 — isa는 바뀌어도 대입이 그 경로를 안 지나가면 통지가 없다. "type(of:)로 KVO 여부를 확인할 수 있다"도 틀렸다 — 동적 서브클래스가 일부러 원래 이름을 반환한다. "KVO는 어떤 타입에나 쓸 수 있다"도 틀렸다 — NSObject 상속이 필요하고 값 타입에는 못 쓴다.

🧒 쉽게 이해하기

"누가 이 서랍을 열면 알려 줘"라고 부탁했다고 하자.

시스템이 하는 일은 이렇다. 서랍장을 몰래 똑같이 생긴 새 서랍장으로 바꾸고, 새 서랍장에는 열릴 때 종이 울리는 장치를 달아 둔다. 겉보기엔 똑같아서 아무도 눈치 못 챈다.

그런데 문제가 하나 있다. 어떤 사람은 서랍을 안 열고 뒷판을 뜯어서 물건을 넣는다. 그러면 종이 안 울린다.

Swift에서 이런 일이 벌어진다. @objc"서랍에 손잡이를 달아 두는" 것이고, dynamic"반드시 손잡이로만 열어라"는 지시다. 손잡이만 달고 지시를 안 하면, 컴퓨터는 더 빠른 뒷판 경로를 쓴다.

더 헷갈리는 건, 서랍장을 바꿔치기한 사실을 숨긴다는 점이다. "이 서랍장 뭐야?"라고 물으면 원래 이름을 대답한다. 진짜를 보려면 특별한 방법으로 확인해야 한다.

꼬리 질문

KVO 관찰이 동작하지 않을 때 진단 순서는?

네 가지를 순서대로 확인한다. 대부분 앞의 둘에서 끝난다.

(1) dynamic이 있는가. 가장 흔한 원인이다.

@objc dynamic var value = 0     // 둘 다 필요하다

(2) 토큰을 보관했는가. observe의 반환값을 버리면 즉시 해제된다.

// ❌ 반환값을 버린다 — 즉시 해제되어 콜백이 안 온다
_ = object.observe(\.value) { _, _ in }

// ✅ 보관한다
private var token: NSKeyValueObservation?
token = object.observe(\.value) { _, _ in }

(3) 실제로 그 setter를 거치는가. 내부에서 저장 프로퍼티에 직접 쓰면(예: _value = x) 통지가 없다. 수동 통지가 필요할 수 있다.

willChangeValue(for: \.value)
_value = newValue
didChangeValue(for: \.value)

(4) isa가 바뀌었는지 확인한다. 앞의 실험 코드로 object_getClass를 찍어 본다 — NSKVONotifying_ 접두사가 없으면 관찰 자체가 안 걸린 것이다.

이 순서가 P1 20장 Q7의 좁히기 절차와 같은 구조다 — 가장 흔하고 확인이 쉬운 것부터 배제한다.

쉽게 말하면 종이 안 울리면 순서대로 확인한다. "손잡이로만 열라고 했나?" → "부탁한 쪽지를 버리지 않았나?" → "누가 뒷판으로 넣고 있나?" → "서랍장이 진짜 바뀌긴 했나?"
메서드 스위즐링과 isa-swizzling은 무엇이 다른가?

바꾸는 대상이 다르다.

메서드 스위즐링isa-swizzling
바꾸는 것클래스의 메서드 구현인스턴스의 클래스 포인터
영향 범위그 클래스의 모든 인스턴스그 인스턴스 하나
되돌리기어렵다 — 전역 영향관찰 해제 시 자동
쓰이는 곳관측 SDK 자동 계측(P1 22장 Q5)KVO

메서드 스위즐링이 더 위험한 이유는 전역 영향순서 의존 때문이다. 여러 SDK가 같은 메서드를 스위즐링하면 초기화 순서에 따라 동작이 달라진다(P1 18장 Q7 꼬리질문).

isa-swizzling은 인스턴스 단위라 상대적으로 안전하지만, 여러 관찰자가 같은 객체에 붙으면 서브클래스가 재사용된다.

둘 다 Objective-C 런타임의 동적 성질에 의존하므로, 순수 Swift 타입에는 적용되지 않는다. 그래서 Swift 진영은 매크로 기반 접근(Observation)으로 옮겨가고 있다(Q6).

쉽게 말하면 하나는 "이 회사의 모든 서랍장 설계도를 바꾸는" 것이고, 다른 하나는 "이 방의 서랍장 하나만 다른 걸로 바꿔치기하는" 것이다. 앞의 것이 훨씬 파급이 크고 되돌리기 어렵다.
시스템 프레임워크 객체를 관찰해야 할 때는?

KVO가 여전히 유일한 수단인 경우가 있다. 시스템 객체는 @Observable로 바꿀 수 없기 때문이다.

흔한 대상들이다.

  • URLSessionTask.progress — 다운로드 진행률.
  • AVPlayerItem.status · AVPlayer.timeControlStatus — 재생 상태.
  • WKWebView.estimatedProgress · .title.
  • UIScrollView.contentOffset(대안이 있으면 델리게이트를 권장).
// key path 기반이라 타입 안전하다
private var progressToken: NSKeyValueObservation?

progressToken = webView.observe(\.estimatedProgress, options: [.new]) { [weak self] _, change in
    guard let v = change.newValue else { return }
    Task { @MainActor in self?.progressBar.progress = Float(v) }
}
//   콜백이 어느 스레드에서 오는지 보장이 약하므로 UI 갱신은 메인으로 옮긴다

주의할 점이 셋이다.

  • 콜백 스레드 — 변경이 일어난 스레드에서 불린다. UI 갱신은 메인으로 옮겨야 한다(P0 07장).
  • 호출 빈도contentOffset처럼 프레임마다 바뀌는 값은 관찰 비용이 크다. 델리게이트나 다른 수단을 우선 검토한다.
  • 토큰 수명 — 화면이 사라질 때 해제되도록 소유 관계를 명확히 한다.

최근 API는 AsyncSequence를 제공하는 경우가 늘고 있어, 가능하면 그쪽이 읽기 좋다(35장).

쉽게 말하면 남의 서랍장은 우리가 새 방식으로 바꿀 수 없다. 그래서 그쪽을 볼 때는 옛날 방식을 계속 써야 한다. 다만 종이 울리는 곳이 어느 방인지(어느 스레드인지) 확인하고, 화면을 고칠 일이면 제자리로 옮겨서 해야 한다.

Q6. Observation은 KVO를 어떻게 대체하는가?

🔑 30초 답변

등록이 아니라 접근 추적(access tracking)으로 바꿨다. @Observable 매크로는 뷰의 body실제로 읽은 프로퍼티만 추적해 그 프로퍼티가 바뀔 때만 해당 뷰를 무효화한다. ObservableObject/@Published어떤 프로퍼티든 바뀌면 구독하는 모든 뷰를 재평가하는 것과 근본적으로 다르다. 실측 차이가 인상적이다 — 같은 값을 재대입(0→0)하면 @Observable은 통지가 0번 발동하지만, @PublishedobjectWillChange는 같은 대입에도 무조건 2번 발동했다. 그리고 Optional·배열 안 개별 객체의 프로퍼티까지 추적된다 — KVO식 접근에는 없던 기능이다.

CS 원리

관찰 시스템의 근본 문제는 "누가 무엇에 의존하는가"를 어떻게 아는가다. 방식이 둘 있다.

등록 기반 (KVO·@Published)접근 추적 (Observation)
의존 선언사람이 명시읽는 행위가 곧 선언
빠뜨림가능 — 등록을 잊는다불가능 — 읽으면 추적된다
과잉흔함 — 안 쓰는 것도 등록없음 — 안 읽으면 추적 안 됨
세분성객체 단위(@Published)프로퍼티 단위

접근 추적의 핵심 통찰은 "의존은 이미 코드에 있다"는 것이다. body에서 model.name을 읽었다면 그것이 곧 의존이고, 따로 선언할 필요가 없다.

32장 Q3 꼬리질문에서 본 대로 이 설계는 빠뜨릴 수 없다 — 새 프로퍼티를 읽기 시작하면 자동으로 관찰 대상이 된다. 31장 Q2의 CodingKeys 갱신 누락과 정반대의 설계다.

구현은 매크로가 담당한다. @Observable이 각 프로퍼티의 get·set에 추적 훅을 삽입하고, withObservationTracking읽힌 프로퍼티 집합을 수집한다. Objective-C 런타임에 의존하지 않으므로 순수 Swift 타입에서 동작한다.

같은 값 재대입에서 차이가 나는 이유는 통지 시점이다. objectWillChange대입 자체에 반응하지만, Observation은 실제 변화를 본다.

"누가 무엇에 의존하는가"를 아는 두 방식 등록 기반 — ObservableObject 사람이 @Published 로 명시한다 하나만 바뀌어도 구독한 모든 뷰 재평가 등록을 빠뜨리면 조용히 안 온다 접근 추적 — @Observable body 가 읽은 것이 곧 의존이다 읽은 프로퍼티가 바뀔 때만 그 뷰 무효화 빠뜨릴 수가 없다 실측 — 같은 값을 재대입(0 → 0)했을 때 통지 횟수 @Published objectWillChange 2번 발동 @Observable 0번 발동 Optional · 배열 안 개별 객체의 프로퍼티까지 추적된다 "@Published 를 클래스 레벨로 옮긴 문법 설탕"이 아니라 추적 방식 자체가 다르다
의존이 이미 코드에 있다는 통찰 — 읽는 행위가 곧 선언이다.

iOS에서는

선언이 단순해지는 것이 첫인상이다.

import Observation

// 이전
final class OldModel: ObservableObject {
    @Published var name = ""
    @Published var count = 0
    var internalCache: [String] = []      // @Published 를 빠뜨리면 갱신이 안 온다
}

// 지금
@Observable final class NewModel {
    var name = ""
    var count = 0
    var internalCache: [String] = []      // 별도 표시가 필요 없다
    @ObservationIgnored var scratch = 0   // 추적에서 빼고 싶을 때만 명시
}

세분성 차이가 성능으로 나타난다.

struct CountView: View {
    let model: NewModel
    var body: some View {
        Text("\(model.count)")            // count 만 읽는다
    }
}
//   model.name 이 바뀌어도 이 뷰는 재평가되지 않는다.
//   ObservableObject 였다면 name 변경에도 재평가됐다.

같은 값 재대입이 통지를 만들지 않는 것도 실무에서 유용하다 — 서버에서 받은 데이터를 통째로 대입하는 패턴에서 불필요한 재렌더가 사라진다.

// 목록을 새로고침할 때마다 전부 대입해도
model.items = fetched
//   실제로 바뀐 것이 없으면 통지가 없다 (@Published 였다면 무조건 발동)

SwiftUI 밖에서도 쓸 수 있다.

withObservationTracking {
    _ = model.count                       // 이 읽기가 의존으로 등록된다
} onChange: {
    print("count 가 곧 바뀐다")
    // 주의: onChange 는 한 번만 불린다 — 계속 관찰하려면 다시 등록해야 한다
}

주의할 점이 있다. onChange1회성이고, 변경 직전에 불린다. 지속 관찰이 필요하면 재등록하거나 AsyncSequence 형태의 상위 API를 쓴다.

실험 · 도구

통지 횟수 차이를 직접 재현해 보자.

import Observation
import Combine

final class Pub: ObservableObject { @Published var v = 0 }
@Observable final class Obs { var v = 0 }

// @Published — 같은 값을 넣어도 발동한다
let p = Pub()
var pubCount = 0
let c = p.objectWillChange.sink { _ in pubCount += 1 }
p.v = 0; p.v = 0
print("@Published 통지: \(pubCount)회")      // 2회

// @Observable — 실제 변화가 없으면 발동하지 않는다
let o = Obs()
var obsCount = 0
func track() {
    withObservationTracking { _ = o.v } onChange: { obsCount += 1; track() }
}
track()
o.v = 0; o.v = 0
print("@Observable 통지: \(obsCount)회")     // 0회

SwiftUI에서 재평가 횟수를 확인하는 방법이다.

struct DebugView: View {
    let model: NewModel
    var body: some View {
        let _ = Self._printChanges()      // 무엇 때문에 재평가됐는지 콘솔에 찍힌다
        Text("\(model.count)")
    }
}
# 아직 ObservableObject 를 쓰는 곳 — 이전 후보
grep -rn "ObservableObject\|@Published" --include=*.swift . | wc -l

# @ObservationIgnored 가 필요한 곳 — 추적하면 안 되는 캐시·스크래치
grep -rn -A2 "@Observable" --include=*.swift . | grep -iE "cache|scratch|temp" | head

프로젝트 적용

  1. 새 모델은 @Observable로 만든다. 선언이 단순하고 빠뜨릴 수 없다.
  2. 기존 ObservableObject는 점진적으로 옮긴다. 화면 단위로 바꾸면 회귀 범위가 좁다.
  3. 재평가 범위가 줄어드는 것을 측정한다. Self._printChanges()로 확인하면 개선이 눈에 보인다.
  4. 추적하면 안 되는 프로퍼티에 @ObservationIgnored를 붙인다. 내부 캐시가 대표적이다.
  5. 시스템 객체 관찰에는 여전히 KVO가 필요하다. 둘이 공존하는 것이 정상이다(Q5 꼬리질문).
⚠️ 흔한 오해

"@Observable@Published를 클래스 레벨로 옮긴 문법 설탕"이라는 것은 틀렸다추적 방식 자체가 다르다. 하나는 등록, 하나는 접근 추적이다. "둘 다 값이 바뀔 때 통지한다"도 실측과 다르다 — 같은 값 재대입에서 @Published발동하고 @Observable안 한다. "@Observable은 SwiftUI 전용"도 틀렸다 — withObservationTracking으로 어디서나 쓸 수 있다. "onChange는 계속 불린다"도 틀렸다 — 1회성이라 재등록이 필요하다. "KVO를 완전히 대체한다"도 아직 아니다 — 시스템 프레임워크 객체에는 KVO가 필요하다.

🧒 쉽게 이해하기

"뭐가 바뀌면 알려 줘"를 처리하는 방식이 두 가지다.

옛날 방식미리 신청서를 내는 것이다. "이 서랍, 저 서랍을 봐 주세요" 하고 하나씩 적는다. 문제는 적는 걸 빠뜨리면 못 듣는다는 것, 그리고 서랍 하나만 열려도 신청한 사람 전부에게 연락이 간다는 것이다.

새 방식내가 실제로 열어본 서랍을 시스템이 기억했다가 그 서랍이 바뀔 때만 알려준다. 신청서를 안 써도 되니 빠뜨릴 수가 없고, 안 열어본 서랍 때문에 연락받을 일도 없다.

실제로 재 보면 차이가 뚜렷하다. 서랍에 있던 것과 똑같은 물건을 다시 넣었을 때, 옛날 방식은 "바뀌었어요!"라고 두 번 알린다. 사실 아무것도 안 바뀌었는데도. 새 방식은 한 번도 안 알린다.

그러니 이건 "적는 위치만 바뀐 것"이 아니라 일하는 방식 자체가 다른 것이다.

꼬리 질문

ObservableObject에서 @Observable로 옮길 때 무엇이 깨질 수 있는가?

재평가 타이밍과 $ 프로젝션이 주된 차이다.

(1) $model.property 바인딩이 사라진다. @Published의 프로젝션에 의존하던 코드를 바꿔야 한다.

// 이전
@ObservedObject var model: OldModel
TextField("이름", text: $model.name)

// 지금 — @Bindable 을 쓴다
@Bindable var model: NewModel
TextField("이름", text: $model.name)

(2) 프로퍼티 래퍼가 달라진다. @StateObject@State, @ObservedObject → 평범한 프로퍼티 또는 @Bindable.

(3) 재평가가 줄어들면서 드러나는 버그가 있을 수 있다. 이전에는 과잉 재평가 덕에 우연히 갱신되던 화면이 안 갱신될 수 있다 — 사실 원래 의존을 제대로 안 읽던 코드다.

// 계산된 값을 뷰 밖에서 만들어 넘기면 의존이 추적되지 않는다
struct Parent: View {
    let model: NewModel
    var body: some View {
        Child(text: computeText())      // ⚠️ 여기서 읽어야 추적된다
    }
    func computeText() -> String { model.name.uppercased() }   // body 안에서 불리므로 OK
}

(4) objectWillChange에 직접 의존하던 코드가 있으면 대체가 필요하다.

이전은 화면 단위로 하고, 각 화면마다 실제로 갱신되는지 확인하는 것이 안전하다.

쉽게 말하면 새 방식으로 바꾸면 꼭 필요한 연락만 온다. 그런데 전에는 연락이 많이 와서 우연히 맞아떨어지던 화면이 있었다면, 이제 그 화면이 안 바뀐다. 사실은 원래부터 신청을 제대로 안 했던 것이다.
@Observable 객체를 여러 스레드에서 쓰면 안전한가?

안전하지 않다. Observation은 관찰 메커니즘이지 동기화 메커니즘이 아니다.

@Observable 클래스는 평범한 클래스이므로, 33장 Q6에서 본 가변 상태의 동시 접근 문제가 그대로 적용된다.

// ❌ 여러 스레드에서 접근하면 데이터 경쟁이다
@Observable final class Model { var count = 0 }
DispatchQueue.concurrentPerform(iterations: 1000) { _ in model.count += 1 }

// ✅ 메인 액터에 가둔다 — UI 모델은 대개 이것이 맞다
@MainActor @Observable final class UIModel { var count = 0 }

// ✅ 또는 액터로 감싸고 UI 상태만 메인으로 옮긴다
actor DataStore { … }

Swift 6의 엄격 동시성이 이 문제를 컴파일 타임에 잡는다(35장) — @Observable 클래스를 여러 격리 도메인에서 쓰려 하면 Sendable 요구로 막힌다.

실무 권장은 @MainActor를 붙이는 것이다. UI 모델은 어차피 메인에서만 갱신되어야 하고, 그러면 관찰 콜백의 스레드도 예측 가능해진다(P0 07장).

쉽게 말하면 "누가 서랍을 열면 알려 주는 장치"는 여러 사람이 동시에 열 때 순서를 정해 주지는 않는다. 알림과 정리는 다른 일이다. 그래서 "이 서랍은 한 사람만 만진다"는 규칙을 따로 정해야 한다.
관찰이 너무 자주 발동하면 어떻게 줄이는가?

읽는 범위를 좁히는 것이 기본이고, 그다음이 값 정리다.

(1) 뷰를 쪼갠다. 큰 뷰가 여러 프로퍼티를 읽으면 그중 하나만 바뀌어도 전체가 재평가된다. 자주 바뀌는 부분을 별도 뷰로 분리하면 그 뷰만 갱신된다.

// ❌ 한 뷰가 전부 읽는다
struct Big: View {
    let m: Model
    var body: some View {
        VStack { Text(m.title); Text("\(m.tick)") }    // tick 이 바뀌면 title 도 재평가
    }
}

// ✅ 자주 바뀌는 부분을 분리
struct Big2: View {
    let m: Model
    var body: some View { VStack { Text(m.title); TickView(m: m) } }
}
struct TickView: View { let m: Model; var body: some View { Text("\(m.tick)") } }

(2) 중간 값을 만들지 않는다. 배열 전체를 새로 대입하면 모든 원소 의존이 무효화된다. 바뀐 원소만 갱신하는 편이 낫다.

(3) 고빈도 값은 관찰 밖에 둔다. 스크롤 오프셋처럼 프레임마다 바뀌는 값은 @ObservationIgnored로 빼고 다른 수단으로 전달한다.

(4) 측정한다. Self._printChanges()무엇 때문에 재평가되는지 확인하면 원인이 바로 보인다. P1 25장 Q6의 commit hitch 진단과 이어진다 — 과잉 재평가는 commit 비용으로 나타난다.

쉽게 말하면 방 하나에 서랍이 열 개면 하나만 열려도 방 전체를 다시 정리한다. 자주 여는 서랍을 작은 방으로 따로 빼면 그 방만 정리하면 된다.

출처 · 참고자료