여기서부터 P2의 후반부다. 27~31장이 조용히 틀리는 값을 다뤘다면, 32~35장은 언어와 런타임이 보장하는 것의 경계를 다룬다.
이 챕터의 주제는 타입 시스템으로 무엇을 살 수 있고 무엇을 지불하는가다. 컴파일 타임 검증은 공짜가 아니다 — 표현력, API 진화 유연성, 학습 비용을 대가로 낸다. 그리고 기능이 주는 보장의 범위를 오해하면, 있지도 않은 안전을 믿게 된다.
P0 05장에서 struct/class·제네릭·any/some을 메모리와 런타임 관점에서 봤고, P0 17장에서 추상화의 비용을 다뤘다. 이 챕터는 그 위에서 API를 설계할 때 실제로 고르게 되는 선택지들을 하나씩 판정한다.
Q1. 오류를 값으로 다루는 것과 던지는 것은 어떻게 다른가?
에러를 지금 처리하느냐 나중에 처리하느냐가 갈림길이다. throws는 동기적·즉시 처리되는 에러에 맞고, Result<Success, Failure>는 에러를 값으로 저장·전달·지연해야 할 때 맞다 — 컴플리션 핸들러, 배열에 담아 나중에 집계, 캐시에 보관 같은 경우다. 둘은 get()(Result → throws)과 init(catching:)(throws → Result)으로 양방향 변환된다. "Result가 throws의 상위호환"이라는 말은 틀렸다 — 동기 API에서는 do-catch가 매번 switch로 케이스를 꺼내는 것보다 간결하다.
CS 원리
에러 처리 방식은 제어 흐름에 얹느냐, 데이터에 담느냐로 나뉜다.
제어 흐름 (throws) | 값 (Result) | |
|---|---|---|
| 전파 | 자동 — 호출 스택을 타고 올라간다 | 수동 — 직접 넘겨야 한다 |
| 처리 시점 | 즉시 (또는 즉시 위임) | 미룰 수 있다 |
| 저장 | 불가능 | 가능 — 컬렉션·프로퍼티에 담는다 |
| 강제성 | try가 호출부에 표시된다 | 무시하면 경고만 |
throws의 값어치는 전파가 자동이고 호출부에 표시된다는 점이다. try가 코드에 보이므로 실패할 수 있는 지점을 읽으면서 안다. 대신 에러를 값으로 다룰 수 없어 나중에 처리하거나 여러 개를 모을 수 없다.
Result의 값어치는 1급 값이라는 점이다. 배열에 담고, 프로퍼티에 저장하고, 다른 스레드로 넘기고, 나중에 switch로 꺼낸다.
여기서 선택 기준이 나온다 — "이 에러를 지금 이 자리에서 처리할 것인가"다. 그렇다면 throws, 아니면 Result다. 그리고 async/await가 콜백을 대체하면서 Result가 필요한 자리가 크게 줄었다.
iOS에서는
변환이 양방향이라 경계에서 갈아탈 수 있다.
// throws → Result : 값으로 담아야 할 때
let r = Result { try loadConfig() } // Result<Config, Error>
// Result → throws : 즉시 처리로 돌아갈 때
let config = try r.get()
// 여러 결과를 모아 나중에 집계 — Result 가 아니면 불가능하다
let results = urls.map { url in Result { try load(url) } }
let failures = results.compactMap { if case .failure(let e) = $0 { return e } else { return nil } }
logger.notice("실패 \(failures.count)/\(results.count)")
동기 API에서는 throws가 간결하다.
// ✅ throws — 읽기 쉽다
func parse(_ data: Data) throws -> Config { … }
let config = try parse(data)
// ❌ 같은 것을 Result 로 — 호출부가 매번 케이스를 꺼내야 한다
func parse2(_ data: Data) -> Result<Config, ParseError> { … }
switch parse2(data) {
case .success(let c): use(c)
case .failure(let e): handle(e)
}
Result가 여전히 유용한 자리는 이렇다.
// (1) 레거시 컴플리션 핸들러 경계
func fetch(completion: @escaping (Result<Data, NetworkError>) -> Void)
// (2) 상태로 보관 — 화면이 마지막 결과를 기억해야 할 때
@Observable final class ViewModel {
var lastResult: Result<[Item], Error>?
}
// (3) 병렬 작업의 부분 실패 수집 (35장)
await withTaskGroup(of: Result<Item, Error>.self) { group in … }
Failure가 Error를 준수해야 한다는 제약이 제네릭 파라미터 순서 오타를 컴파일 에러로 잡아 준다 — Result<NetworkError, Data>처럼 뒤바꿔 쓰면 Data가 Error가 아니므로 막힌다.
실험 · 도구
두 방식의 호출부 모양을 나란히 보면 선택이 쉬워진다.
import Foundation
enum LoadError: Error { case notFound, corrupted }
func loadThrowing(_ id: Int) throws -> String {
guard id > 0 else { throw LoadError.notFound }
return "item-\(id)"
}
// 여러 개를 처리하며 실패를 모아야 하는 경우 — Result 가 자연스럽다
let ids = [1, -2, 3, -4]
let results = ids.map { Result { try loadThrowing($0) } }
let ok = results.compactMap { try? $0.get() }
let bad = results.compactMap { r -> Error? in
if case .failure(let e) = r { return e } else { return nil }
}
print("성공 \(ok.count)건, 실패 \(bad.count)건") // 성공 2건, 실패 2건
// 하나만 즉시 처리하는 경우 — throws 가 간결하다
do { print(try loadThrowing(1)) }
catch { print("실패: \(error)") }
기존 코드에서 불필요한 Result를 찾는다.
# 동기 함수가 Result 를 반환하는 곳 — throws 로 단순화 후보
grep -rnE "func [a-zA-Z]+\([^)]*\) -> Result<" --include=*.swift . | grep -v async
# 컴플리션 핸들러 — async/await 로 옮기면 Result 가 사라진다
grep -rn "completion: @escaping (Result" --include=*.swift .
프로젝트 적용
- 동기 함수의 기본은
throws다. 호출부가 간결하고try가 실패 가능성을 드러낸다. - 저장·수집·지연이 필요하면
Result를 쓴다. 이 셋이 아니면 대개throws가 맞다. - 컴플리션 핸들러는
async/await로 옮긴다.Result가 필요한 가장 흔한 이유가 사라진다. - 경계에서 변환한다. 내부는
throws로 쓰고 외부 API만Result로 감싸는 식이 읽기 좋다. Result를 반환하는 함수의 결과를 무시하지 않게 한다.throws와 달리 버려도 컴파일된다 —@discardableResult가 없는지 확인한다.
"Result가 throws의 상위호환"이라는 말은 틀렸다 — 동기 API에서는 do-catch가 매번 switch로 꺼내는 것보다 간결하다. "throws는 예외라 느리다"도 오해다 — Swift의 throws는 예외 언와인딩이 아니라 반환값 규약에 가깝다(33장 Q2). "Result를 쓰면 에러를 놓치지 않는다"도 틀렸다 — 반환값을 그냥 버려도 컴파일된다. "async 함수에는 Result가 필요하다"도 틀렸다 — async throws가 있다.
일하다 문제가 생겼을 때 알리는 방법이 두 가지다.
소리쳐서 알리기 — "안 돼요!" 하고 외치면 위에 있는 사람이 듣고 처리한다. 알아서 위로 전달되니 편하다. 대신 그 소리를 종이에 적어 서랍에 넣어 둘 수는 없다. 그 자리에서 처리해야 한다.
쪽지에 적어 건네기 — "이 일은 이래서 실패"라고 적어서 준다. 이건 서랍에 넣어 둘 수도 있고, 여러 장 모아서 나중에 볼 수도 있다. 대신 받은 사람이 쪽지를 열어봐야 알고, 안 열어보면 그냥 지나간다.
그래서 판단 기준이 하나다 — "지금 여기서 처리할 일인가?" 지금 처리할 거면 소리치는 게 낫고, 나중에 모아서 볼 거면 쪽지가 낫다.
예전에는 "일 끝나면 알려줘" 하고 나중에 연락받는 방식이 많아서 쪽지가 많이 필요했다. 지금은 기다렸다가 바로 받는 방식이 생겨서 쪽지 쓸 일이 크게 줄었다.
꼬리 질문
부분 실패를 사용자에게 어떻게 보여줘야 하는가?
"몇 개 실패했는가"보다 "무엇을 할 수 있는가"를 보여준다.
패턴이 셋이다.
- 전부 성공 — 아무 말도 안 한다.
- 일부 실패 — 성공한 것을 보여주고, 실패한 것은 재시도 가능한 형태로 남긴다. "3개 중 1개를 불러오지 못했습니다 · 다시 시도".
- 전부 실패 — 전체 에러 화면. 원인이 공통이면(네트워크 없음) 그 원인을 말한다.
enum LoadOutcome<T> {
case complete([T])
case partial(loaded: [T], failed: [Error])
case failed(Error)
init(_ results: [Result<T, Error>]) {
let ok = results.compactMap { try? $0.get() }
let bad = results.compactMap { r -> Error? in
if case .failure(let e) = r { return e } else { return nil } }
switch (ok.isEmpty, bad.isEmpty) {
case (false, true): self = .complete(ok)
case (false, false): self = .partial(loaded: ok, failed: bad)
default: self = .failed(bad.first ?? LoadError.notFound)
}
}
}Result 배열을 도메인 결과 타입으로 접는 것이 핵심이다. 화면이 Result를 직접 다루면 모든 화면이 같은 분기를 반복하게 된다.
에러 타입을 어떻게 설계해야 하는가?
호출자가 분기해야 하는 경우만 케이스로 만든다. 케이스가 많다고 좋은 것이 아니다.
판단 기준은 "이 케이스를 구분해서 다르게 처리할 사람이 있는가"다. 없으면 하나로 합치고 진단 정보는 연관값으로 담는다.
// ❌ 과도한 분류 — 호출자는 전부 똑같이 처리한다
enum Bad: Error {
case timeout, dnsFailure, tlsHandshake, connectionReset, hostUnreachable
}
// ✅ 처리 방식으로 나눈다
enum NetworkError: Error {
case offline // → "네트워크 확인" 안내
case retryable(underlying: Error) // → 재시도 버튼
case client(status: Int) // → 요청 자체가 잘못됨, 재시도 무의미
case server(status: Int) // → 잠시 후 재시도
}이 분류는 P0 04장 Q9의 재시도 가능성 구분과 정렬된다 — 에러 타입이 "무엇을 할 수 있는가"를 표현하면 호출부가 단순해진다.
그리고 진단 정보를 잃지 않는다. 케이스를 합치되 underlying으로 원본을 보관하면 로깅에서 상세를 볼 수 있다(P1 18장).
throws 함수를 Result로 감쌀 때 성능 비용이 있는가?
무시할 만하지만, 개념적 비용이 더 크다.
런타임 비용은 Result 값 하나를 만드는 정도다. Result는 enum이라 힙 할당이 없고, 성공/실패 태그와 페이로드만 담는다. 다만 Failure가 any Error이면 실존 컨테이너 비용이 붙을 수 있다(Q2).
// Failure 를 구체 타입으로 두면 실존 박싱을 피한다
let r: Result<Config, ParseError> = Result { try parse(data) } // ❌ 추론은 Error
func parseResult(_ d: Data) -> Result<Config, ParseError> { // ✅ 명시
do { return .success(try parse(d)) }
catch let e as ParseError { return .failure(e) }
catch { return .failure(.unknown) }
}더 큰 비용은 코드 가독성이다. 감싸는 층이 늘면 에러가 어디서 왔는지 추적이 어려워진다. 그래서 경계 한 곳에서만 변환하는 것이 원칙이다.
측정 없이 이 수준을 최적화하지 않는다 — P0 12장에서 본 대로 병목은 대개 다른 곳에 있다.
Q2. Typed Throws는 무엇을 사고 무엇을 잃는가?
성능과 정확성을 사고 API 진화 유연성을 잃는다. SE-0413(Swift 6.0)의 throws(E)는 함수가 특정 구체 에러 타입만 던진다고 선언한다. catch에서 error가 any Error가 아니라 그 구체 타입으로 곧바로 추론되고, 실존 타입(any Error)의 힙 할당과 메타데이터 오버헤드를 피한다. 표기 관계도 깔끔하다 — throws(Never)는 non-throwing과 동등하고 throws(any Error)는 기존 무타입 throws와 완전히 동일하다. 잃는 것은 나중에 넓힐 수 없다는 점이다 — public API에 구체 타입을 박은 뒤 any Error로 넓히면 switch로 의존하던 호출자가 깨진다.
CS 원리
이것은 타입 정밀도와 진화 가능성의 트레이드오프다. 같은 긴장이 여러 곳에서 반복된다.
| 정밀한 타입 | 느슨한 타입 | |
|---|---|---|
| 호출자가 얻는 것 | 망라적 처리 가능 | 기본 처리만 |
| 런타임 | 실존 박싱 없음 | 실존 컨테이너 비용 |
| API 진화 | 넓히면 깨진다 | 자유롭다 |
넓히는 방향이 깨지는 이유는 호출자가 망라성에 의존하기 때문이다. throws(NetworkError)를 catch하면서 모든 케이스를 switch로 처리한 코드는, 에러 타입이 any Error로 넓어지는 순간 default가 없어 컴파일되지 않는다.
반대로 좁히는 방향은 대체로 안전하다 — any Error를 기대하던 호출자는 구체 타입도 받을 수 있다. Never가 모든 에러 타입의 서브타입이라는 사실이 이 관계를 형식화한다.
일반 원리로 하면 "공개 API의 타입은 좁힐 수는 있어도 넓힐 수 없다"이다. 이는 31장 Q7의 스키마 진화 규칙 — 추가는 자유, 변경은 금지 — 와 같은 구조다. 어느 쪽이든 이미 배포된 소비자가 제약을 만든다.
Never가 모든 에러 타입의 서브타입이라는 사실이 이 포함 관계를 형식화한다.iOS에서는
catch에서 타입이 바로 좁혀지는 것이 가장 눈에 띄는 변화다.
enum ParseError: Error { case empty, badFormat(line: Int) }
func parse(_ s: String) throws(ParseError) -> [Int] {
guard !s.isEmpty else { throw .empty } // 타입이 정해져 있어 .empty 로 줄여 쓴다
…
}
do {
let v = try parse(input)
} catch {
// error 가 any Error 가 아니라 ParseError 로 추론된다
switch error {
case .empty: showEmptyState()
case .badFormat(let l): showError("\(l)행 형식 오류")
}
// default 가 필요 없다 — 망라성이 성립한다
}
제네릭 전파에서도 유용하다. 래퍼가 에러 타입을 그대로 통과시킬 수 있다.
func measured<T, E: Error>(_ label: String,
_ body: () throws(E) -> T) throws(E) -> T {
let clock = ContinuousClock()
let start = clock.now
defer { logger.debug("\(label): \(clock.now - start)") }
return try body()
}
// 에러 타입이 any Error 로 뭉개지지 않고 호출자까지 전달된다
공개 API에서는 신중해야 한다.
// ⚠️ public API 에 구체 타입을 박으면 나중에 넓힐 수 없다
public func load() throws(NetworkError) -> Data
// 나중에 디스크 에러도 던지고 싶어지면?
// throws(any Error) 로 넓히는 순간 switch 로 망라 처리하던 호출자가 깨진다
// → 처음부터 넓게 두거나, 에러 타입에 케이스를 추가하는 방향으로 진화시킨다
2026년 현재도 남아 있는 제약이 있다 — 클로저 본문 구조로부터의 타입 추론, Task·AsyncSequence 통합, throws(some Error)는 여전히 Future Directions다. 그래서 비동기 코드에서는 아직 활용 범위가 좁다.
실험 · 도구
망라성이 실제로 성립하는지 확인해 보자.
import Foundation
enum E1: Error { case a, b }
func typed() throws(E1) { throw .a }
func untyped() throws { throw E1.a }
do { try typed() } catch {
switch error { case .a: print("a"); case .b: print("b") } // ✅ default 불필요
}
do { try untyped() } catch {
// error 는 any Error — 캐스팅이 필요하고 망라성이 없다
if let e = error as? E1 { print("E1: \(e)") } else { print("기타") }
}
넓히면 깨진다는 것도 재현된다.
// 라이브러리 v1
public func work() throws(E1) { }
// 호출자
do { try work() } catch { switch error { case .a: …; case .b: … } }
// 라이브러리 v2 에서 throws(any Error) 로 넓히면
// ❌ 호출자에서 "switch must be exhaustive" — default 가 없어 컴파일 실패
// 반대로 v1 이 any Error 였다가 E1 으로 좁아지는 것은 대체로 안전하다
도입 후보를 찾는 방법이다.
# 내부(non-public) 함수 중 에러 종류가 하나뿐인 것 — 안전한 도입 후보
grep -rn "func .*throws" --include=*.swift Sources/ | grep -v "public\|open"
# public API 에 throws 가 있는 곳 — 신중하게 판단할 대상
grep -rn "public func .*throws" --include=*.swift Sources/
프로젝트 적용
- 내부 코드부터 도입한다. 모듈 내부 함수는 호출자를 우리가 통제하므로 넓혀도 같이 고치면 된다.
- 공개 API에는 신중하게 쓴다. 에러 종류가 확실히 고정됐다고 판단될 때만. 애매하면 기존
throws가 안전하다. - 넓히는 대신 케이스를 추가한다. 에러 타입에 새 케이스를 넣는 것은
@unknown default대비가 있으면 견딜 수 있다(Q7). - 제네릭 유틸리티에서 에러 타입을 전파시킨다. 래퍼가
any Error로 뭉개던 것을 정확히 넘길 수 있다. - 비동기 코드에서는 아직 기대를 낮춘다.
Task·AsyncSequence통합이 미완이다.
"throws(E)는 항상 더 좋다"는 틀렸다 — API 진화 유연성을 판다. "throws와 throws(any Error)는 다르다"도 틀렸다 — 완전히 동일한 표기다. "throws(Never)는 뭔가 특별한 상태"도 아니다 — non-throwing과 동등하다. "타입을 붙이면 성능이 크게 좋아진다"도 과장이다 — 실존 박싱 회피가 이득이고, 대부분의 앱에서 체감되는 규모가 아니다. "이제 모든 곳에 쓸 수 있다"도 아직 아니다 — 클로저 추론·비동기 통합이 미완이다.
"이 일은 실패할 수 있어요"라고 알릴 때, 어떤 식으로 실패하는지까지 미리 적어 둘 수 있게 됐다.
좋은 점이 있다. 받는 사람이 실패 종류를 전부 안다. 그래서 "이런 경우엔 이렇게, 저런 경우엔 저렇게" 하고 빠짐없이 대비할 수 있다. 빠뜨리면 컴퓨터가 알려준다.
그런데 대가가 있다. 나중에 "사실 다른 방식으로도 실패할 수 있어요"라고 말을 바꾸면, 이미 빠짐없이 대비해 둔 사람들의 계획이 전부 어긋난다. 그 사람들 코드가 컴파일이 안 된다.
반대 방향은 괜찮다. "아무렇게나 실패할 수 있어요"라고 했다가 "사실 이 두 가지뿐이에요"로 좁히는 건 문제가 없다. 넓게 대비해 둔 사람은 좁은 것도 감당할 수 있으니까.
그래서 규칙이 하나 나온다 — 남에게 공개하는 약속은 좁힐 수는 있어도 넓힐 수 없다. 우리끼리 쓰는 코드는 같이 고치면 되니까 마음껏 써도 된다.
꼬리 질문
에러 케이스를 추가하는 것도 깨는 변경인가?
호출자가 망라적으로 switch했다면 깨진다. 다만 같은 모듈 안이면 재컴파일로 해결되고, ABI 경계를 넘으면 다르다(Q7).
세 경우로 갈린다.
- 같은 모듈 — 케이스를 추가하면
switch가 컴파일 에러가 나고, 고치면 된다. 오히려 컴파일러가 빠뜨린 곳을 찾아 주는 셈이다. - library evolution 없는 의존성 — 클라이언트가 항상 재컴파일되므로 같은 모듈과 동일하다.
- ABI 안정 라이브러리(
-enable-library-evolution) — 클라이언트를 재컴파일하지 않고 새 버전을 쓸 수 있으므로,@unknown default가 필요하다.
그래서 공개 에러 타입을 설계할 때 선택이 있다.
// 케이스가 늘어날 수 있음을 명시 — 호출자가 대비하게 한다
public enum NetworkError: Error {
case offline
case server(status: Int)
case unknown(underlying: Error) // 확장 지점을 미리 열어 둔다
}
// 새 상황이 생겨도 unknown 으로 흡수되므로 케이스 추가 자체가 줄어든다이건 31장 Q5의 unknown 케이스와 같은 발상이다 — 확장 지점을 처음부터 두면 나중에 깨뜨릴 일이 줄어든다.
실존 타입(any Error)의 비용은 실제로 얼마나 되는가?
대부분의 앱에서는 체감되지 않지만, 구조는 알아 둘 만하다.
any Error는 실존 컨테이너에 담긴다. P0 01장·05장에서 본 대로, 값이 컨테이너의 인라인 버퍼보다 크면 힙에 박싱되고 프로토콜 witness table을 통한 간접 디스패치가 붙는다.
에러 경로에서 이 비용이 문제 되는 경우는 제한적이다.
- 문제 안 됨 — 네트워크 실패, 파싱 실패처럼 드물게 일어나는 에러. 에러 하나 만드는 비용은 그 뒤에 이어질 UI 갱신에 묻힌다.
- 측정 필요 — 에러를 정상 흐름의 일부로 쓰는 경우. 파서가 백트래킹하며 초당 수만 번 던진다면 의미가 있다.
- 확실히 유의미 — Embedded Swift처럼 힙 할당 자체를 피해야 하는 환경. 이것이 SE-0413의 주요 동기 중 하나다.
실무 판단은 "에러가 뜨거운 경로에 있는가"다. 대개는 아니므로, 성능이 아니라 망라성을 이유로 도입하는 편이 정직하다.
기존 코드를 Typed Throws로 옮기는 순서는?
안쪽부터 바깥으로가 원칙이다. 에러 타입이 확정된 말단 함수부터 시작한다.
- private·internal 함수 중 에러 종류가 하나뿐인 것. 위험이 없고 이득이 바로 보인다.
- 같은 모듈의 중간 계층. 아래에서 올라오는 에러 타입이 정해졌으면 그대로 전파한다.
- 모듈 경계. 여기서 멈추는 것도 정당한 선택이다 — 공개 API는
any Error로 두고 내부만 정밀하게 유지할 수 있다.
// 내부는 정밀하게
private func decode(_ d: Data) throws(ParseError) -> Model { … }
private func validate(_ m: Model) throws(ValidationError) -> Model { … }
// 경계에서는 넓게 — 여러 에러 타입이 합류하는 지점
public func load(_ d: Data) throws -> Model {
let m = try decode(d)
return try validate(m)
}
// 두 에러 타입이 만나므로 여기서는 any Error 가 자연스럽다여러 에러 타입이 합류하는 지점에서는 공통 타입으로 감싸거나 넓히거나 선택해야 한다. 무리하게 하나로 통합하면 에러 타입이 거대해져 오히려 나빠진다.
Q3. KeyPath는 무엇을 가능하게 하며 어디까지 쓸 수 있는가?
"프로퍼티에 이르는 경로"를 1급 값으로 만든 것이고, 문자열 기반 리플렉션이 아니라 컴파일 타임에 타입 체크되는 값이다. 계층은 능력 순으로 AnyKeyPath → PartialKeyPath<Root> → KeyPath(읽기 전용) → WritableKeyPath(값 타입 in-place 쓰기, 루트가 var여야) → ReferenceWritableKeyPath(클래스 전용)다. 마지막이 따로 있는 이유는 클래스는 "가리키는 대상"과 "가리키는 자리"가 분리돼 있어 참조 변수가 let이어도 내부 프로퍼티를 바꿀 수 있기 때문이다. 그리고 users.map(\.name)이 되는 SE-0249 변환은 key path 리터럴에만 적용되고 변수에 담긴 KeyPath에는 보장되지 않는다.
CS 원리
KeyPath는 접근 경로의 물화(reification)다. 보통 user.address.zip은 코드로만 존재하는데, 이것을 값으로 만들어 저장·전달·재사용할 수 있게 한 것이다.
이 아이디어의 값어치는 "무엇을 접근할지"와 "언제 접근할지"를 분리하는 데 있다. 정렬 기준, 관찰 대상, 매핑 규칙을 미리 정해 두고 나중에 적용할 수 있다.
비슷한 목적의 오래된 기법이 문자열 키("address.zip")인데, 결정적 차이가 있다.
| 문자열 키 | KeyPath | |
|---|---|---|
| 오타 | 런타임에 발견 | 컴파일 에러 |
| 타입 | 알 수 없음 | Root·Value 모두 알려짐 |
| 리팩터링 | 이름 바꾸면 깨짐 | 따라간다 |
계층 구조는 능력에 따른 서브타입 관계다. 아래로 갈수록 할 수 있는 일이 늘고, 상위 타입 자리에 하위를 넣을 수 있다. ReferenceWritableKeyPath가 WritableKeyPath의 서브타입인 이유도 이것이다.
쓰기 가능성이 갈리는 지점은 값 의미론에서 온다. 값 타입은 프로퍼티를 바꾸려면 루트 자체가 변경 가능해야 하지만, 클래스는 참조가 상수여도 내부는 바꿀 수 있다. 이 차이가 타입 두 개로 나타난다. 다만 대상 프로퍼티 자체가 let이면 루트가 무엇이든 쓰기 불가다.
let이면 루트가 무엇이든 쓰기 가능한 key path가 될 수 없다.iOS에서는
가장 흔한 용도가 함수 자리에 쓰는 것이다(SE-0249).
struct User { let name: String; var age: Int }
let users = [User(name: "김", age: 30), User(name: "이", age: 25)]
users.map(\.name) // ["김", "이"] ← 클로저 대신 key path
users.sorted { $0.age < $1.age }
users.max(by: { $0.age < $1.age })
중요한 제약이 있다 — 이 변환은 리터럴에만 적용된다.
// ✅ 리터럴 — (Root) -> Value 로 변환된다
users.map(\.name)
// ⚠️ 변수에 담긴 KeyPath — 같은 변환이 보장되지 않는다
let kp: KeyPath<User, String> = \.name
// users.map(kp) ← 컴파일되지 않을 수 있다
users.map { $0[keyPath: kp] } // ✅ subscript 로 명시 적용
쓰기 가능성은 선언에 따라 갈린다.
struct S { let a: Int; var b: Int }
final class C { let x = S(a: 1, b: 2); var y = 0 }
\S.a // KeyPath — a 가 let 이라 쓰기 불가
\S.b // WritableKeyPath — 루트가 var 여야 쓸 수 있다
\C.y // ReferenceWritableKeyPath — c 가 let 이어도 쓸 수 있다
var s = S(a: 1, b: 2)
s[keyPath: \.b] = 9 // ✅
let c = C()
c[keyPath: \.y] = 9 // ✅ 참조가 let 인데도 쓸 수 있다
제네릭 유틸리티에서 특히 유용하다 — 정렬·그룹핑 규칙을 값으로 받는다.
extension Sequence {
func sorted<V: Comparable>(by kp: KeyPath<Element, V>) -> [Element] {
sorted { $0[keyPath: kp] < $1[keyPath: kp] }
}
func grouped<K: Hashable>(by kp: KeyPath<Element, K>) -> [K: [Element]] {
Dictionary(grouping: self) { $0[keyPath: kp] }
}
}
users.sorted(by: \.age)
users.grouped(by: \.name)
실험 · 도구
계층과 쓰기 가능성을 직접 확인해 보자.
import Foundation
struct Point { var x: Int; let y: Int }
final class Box { var value = 0 }
// 타입이 무엇으로 추론되는지 확인한다
let kx = \Point.x // WritableKeyPath<Point, Int>
let ky = \Point.y // KeyPath<Point, Int> ← let 이라 쓰기 불가
let kv = \Box.value // ReferenceWritableKeyPath<Box, Int>
print(type(of: kx))
print(type(of: ky))
print(type(of: kv))
// 서브타입 관계 확인 — 상위 자리에 하위를 넣을 수 있다
let any: AnyKeyPath = kv
let partial: PartialKeyPath<Box> = kv
let readonly: KeyPath<Box, Int> = kv // ✅ 업캐스트된다
리터럴 제약도 재현된다.
let names = [User(name: "김", age: 30)]
names.map(\.name) // ✅ 리터럴
let kp = \User.name
// names.map(kp) // ⚠️ 리터럴이 아니라 보장되지 않는다
names.map { $0[keyPath: kp] } // ✅ 항상 동작한다
코드베이스에서 문자열 키를 KeyPath로 바꿀 후보를 찾는다.
# 문자열 기반 KVO·정렬 — KeyPath 로 옮길 후보
grep -rn "NSSortDescriptor(key:\|addObserver(.*forKeyPath: \"" --include=*.swift .
# value(forKey:) — 타입 안전하지 않은 접근
grep -rn "value(forKey:\|setValue(.*forKey:" --include=*.swift .
프로젝트 적용
- 문자열 키를 KeyPath로 바꾼다.
NSSortDescriptor·KVO의 문자열 경로는 오타가 런타임에야 드러난다. - 제네릭 유틸리티의 인자로 활용한다. 정렬·그룹핑·매핑 규칙을 값으로 받으면 재사용성이 크게 오른다.
- 변수에 담아 쓸 때는
subscript(keyPath:)를 명시한다. 함수 자동 변환은 리터럴 전용이다. - 쓰기가 필요하면 타입을 정확히 선언한다.
KeyPath로 받으면 쓰기가 안 되고, 이는 컴파일 타임에 드러난다. - KeyPath를 리플렉션 대용으로 오해하지 않는다. 미리 아는 경로를 값으로 다루는 것이지, 임의 탐색이 아니다.
"모든 key path는 쓰기가 가능하다"는 틀렸다 — KeyPath는 읽기 전용이고, let 프로퍼티의 key path는 WritableKeyPath가 될 수 없다. "key path는 문자열 기반 리플렉션"도 틀렸다 — 컴파일 타임에 타입 체크되는 값이다. "\.name은 어디서나 함수처럼 쓸 수 있다"도 부정확하다 — 그 변환은 리터럴에만 적용된다. "클래스면 무조건 쓸 수 있다"도 틀렸다 — 대상 프로퍼티가 let이면 불가하다.
"김 씨네 집 안방 서랍"이라는 길 안내를 생각해 보자. 보통은 그때그때 말로 설명하는데, 이걸 종이에 적어서 건네줄 수 있게 만든 것이 KeyPath다.
종이에 적어 두면 나중에 쓸 수 있다. "이 기준으로 줄 세워"라고 할 때 기준 종이를 건네면 된다.
예전에도 비슷한 게 있었다 — 주소를 글자로 적는 방식이다. 그런데 글자는 오타가 나도 종이를 받을 때는 모른다. 실제로 찾아가 봐야 "그런 집 없는데?" 하게 된다. 새 방식은 종이를 만드는 순간 잘못된 주소면 만들어지지 않는다.
그리고 넣을 수 있는 곳과 볼 수만 있는 곳이 구분된다. "이 서랍은 열어보기만 가능", "이 서랍은 물건을 넣을 수도 있음" 하는 식이다.
재미있는 차이가 하나 있다. 집 자체는 못 바꾸는데 집 안 물건은 바꿀 수 있는 경우가 있다. 세든 집처럼 주소는 고정인데 안에 있는 가구는 옮길 수 있는 것이다. 그래서 종이 종류가 하나 더 필요했다.
꼬리 질문
KeyPath로 동적 필터·정렬 UI를 만들 수 있는가?
가능하지만 타입 소거가 필요하고, 그 지점에서 타입 안전성 일부를 잃는다.
사용자가 런타임에 "이름순 / 나이순"을 고르게 하려면 서로 다른 Value 타입의 key path를 한 컬렉션에 담아야 한다.
// 타입이 다르므로 그대로는 배열에 못 담는다
// let options: [KeyPath<User, ???>] = [\.name, \.age]
// ✅ 비교 함수를 미리 만들어 담는다 — 타입 소거를 우리가 통제한다
struct SortOption {
let title: String
let compare: (User, User) -> Bool
static func by<V: Comparable>(_ title: String, _ kp: KeyPath<User, V>) -> SortOption {
SortOption(title: title) { $0[keyPath: kp] < $1[keyPath: kp] }
}
}
let options: [SortOption] = [.by("이름순", \.name), .by("나이순", \.age)]
let sorted = users.sorted(by: options[selected].compare)PartialKeyPath로 담을 수도 있지만, 값 타입을 모르므로 비교하려면 다시 캐스팅해야 해서 결국 런타임 분기가 생긴다. 비교 함수로 미리 접는 편이 안전하고 읽기 쉽다.
이 패턴이 타입 소거의 전형이다 — 소거는 필요하지만, 소거하기 전에 필요한 능력을 함수로 포착해 두면 안전성을 최대한 유지할 수 있다.
KeyPath 접근은 직접 접근보다 느린가?
느릴 수 있지만 대개 무시할 만하고, 상황에 따라 다르다.
obj[keyPath: kp]는 런타임에 경로를 따라가는 동작이므로 직접 접근(obj.prop)보다 오버헤드가 있다. 다만 몇 가지 요인이 작용한다.
- 리터럴 key path는 최적화 여지가 크다. 컴파일러가 경로를 알고 있으면 인라인·직접 접근으로 낮출 수 있다.
- 변수에 담긴 key path는 그 여지가 줄어든다. 어느 경로인지 컴파일 타임에 모른다.
- 중첩이 깊을수록 비용이 는다.
\.a.b.c.d는 단계마다 따라간다.
실무 판단은 "뜨거운 루프 안인가"다. 목록 정렬처럼 비교마다 두 번씩 호출되는 경로에서 수만 건을 다루면 측정할 가치가 있고, 그 외에는 가독성이 이긴다.
// 대량 정렬에서 신경 쓰인다면 비교 클로저를 직접 쓴다
users.sorted { $0.age < $1.age } // 직접 접근
users.sorted(by: \.age) // key path — 읽기 좋지만 간접
// 28장 Q2 꼬리질문의 decorate-sort-undecorate 처럼
// 키를 먼저 뽑아 두면 비교 횟수만큼 반복되지 않는다P0 12장의 원칙이 그대로 적용된다 — 측정 없이 최적화하지 않는다.
Observation 프레임워크는 KeyPath를 쓰는가?
내부적으로는 쓰지만, KVO와 달리 사용자 코드가 KeyPath를 직접 다루지 않는다는 것이 큰 차이다.
비교하면 이렇다.
| KVO | Observation (@Observable) | |
|---|---|---|
| 관찰 대상 지정 | 문자열 또는 KeyPath | 접근하는 것만으로 자동 추적 |
| 대상 타입 | NSObject 상속 필요 | 일반 Swift 타입 |
| 등록·해제 | 수동 | 스코프 기반 |
@Observable final class Model { var count = 0; var name = "" }
// 어떤 프로퍼티를 읽었는지 런타임이 추적한다 — KeyPath 를 우리가 적지 않는다
withObservationTracking {
_ = model.count // count 를 읽었으므로 count 변경만 감지된다
} onChange: {
print("변경됨")
}이 설계가 KeyPath를 명시하는 방식보다 나은 점은 빠뜨릴 수 없다는 것이다. 읽는 곳이 곧 의존이므로, 새 프로퍼티를 읽기 시작하면 자동으로 관찰 대상이 된다 — 31장 Q2에서 본 CodingKeys 갱신 누락과 정반대의 설계다.
KVO와 isa-스위즐링, Observation의 구현 차이는 34장에서 다룬다.
Q4. 프로퍼티 래퍼는 무엇으로 풀리며 어떤 비용이 있는가?
컴파일러가 세 가지로 desugar한다. @Wrapper var foo = 1738은 ① private 백킹 저장소 _foo: Wrapper<Int>, ② _foo.wrappedValue를 전달하는 계산 프로퍼티 foo, ③ wrapper가 projectedValue를 정의했을 때만 생기는 $foo로 풀린다(SE-0258). 백킹 저장소는 항상 private이라 타입 내부에서만 접근된다. 래퍼를 쌓으면 먼저 쓴 것이 바깥이고 순서를 바꾸면 결과가 달라지는 비교환적 합성이며, 바깥 래퍼는 반드시 제네릭이어야 하고 그 setter가 받는 것은 감싸인 값이 아니라 안쪽 래퍼 인스턴스 자체다. $foo가 항상 Binding이라는 것은 틀렸다 — projectedValue의 타입일 뿐이다.
CS 원리
프로퍼티 래퍼는 접근 로직의 추출이다. 저장·검증·변환·관찰 같은 반복되는 프로퍼티 동작을 타입으로 뽑아 재사용한다.
이 아이디어의 핵심은 선언 지점에서 동작을 붙인다는 것이다. 호출부는 평범한 프로퍼티처럼 쓰지만, 실제로는 래퍼의 get·set이 개입한다. 이는 관점 지향(aspect-oriented)의 언어 수준 구현에 가깝다.
대가는 암묵성이다. user.name = "김"이라는 코드만 보고는 검증이 도는지, 디스크에 쓰는지, 알림이 가는지 알 수 없다. P0 17장에서 본 추상화가 비용을 숨긴다는 문제가 여기서도 나타난다.
합성이 비교환적인 것도 중요하다. 두 래퍼를 쌓으면 바깥 래퍼가 안쪽 래퍼 인스턴스를 감싸므로, 순서가 곧 중첩 구조다. 그래서 @A @B var x와 @B @A var x는 다른 타입, 다른 동작이 된다.
SE-0293이 이 메커니즘을 함수·클로저 파라미터로 확장했는데, 여기서 주의할 점은 겉보기와 실제가 다르다는 것이다 — 값을 넘기는 것처럼 보여도 실제 파라미터 타입과 ABI까지 바뀐다.
iOS에서는
직접 만들어 보면 desugar 결과가 분명해진다.
@propertyWrapper
struct Clamped<V: Comparable> {
private var value: V
private let range: ClosedRange<V>
init(wrappedValue: V, _ range: ClosedRange<V>) {
self.range = range
self.value = min(max(wrappedValue, range.lowerBound), range.upperBound)
}
var wrappedValue: V {
get { value }
set { value = min(max(newValue, range.lowerBound), range.upperBound) }
}
var projectedValue: ClosedRange<V> { range } // $volume 이 범위를 준다
}
struct Player {
@Clamped(0...100) var volume = 50
}
var p = Player()
p.volume = 500
p.volume // 100 ← setter 가 잘라 냈다
p.$volume // 0...100 ← projectedValue 의 타입이다. Binding 이 아니다.
$가 Binding인 것은 @State의 특수 사례다.
@State private var text = "" // $text 는 Binding<String>
@Clamped(0...100) var volume = 50 // $volume 은 ClosedRange<Int>
// $ 의 타입은 각 래퍼가 정의한 projectedValue 가 정한다
합성 순서가 결과를 바꾸는 것도 확인된다.
// 바깥 래퍼는 제네릭이어야 안쪽 래퍼 타입을 담을 수 있다
@propertyWrapper struct Logged<T> {
private var inner: T
init(wrappedValue: T) { inner = wrappedValue }
var wrappedValue: T {
get { inner }
set { logger.debug("변경"); inner = newValue } // 안쪽 래퍼 인스턴스를 받는다
}
}
struct A { @Logged @Clamped(0...10) var v = 5 }
// Logged 가 바깥이므로 Logged<Clamped<Int>> 구조가 된다
// 순서를 바꾸면 Clamped 가 Logged 를 감싸려 하는데, Clamped 는 제네릭 제약이 달라 합성이 달라진다
비용은 세 가지다 — 추가 저장소(래퍼 인스턴스가 프로퍼티마다 존재), 간접 접근(계산 프로퍼티를 거친다), 암묵적 동작(호출부가 모른다). 앞의 둘은 대개 무시할 만하고, 세 번째가 진짜 비용이다.
실험 · 도구
desugar 결과를 직접 확인할 수 있다.
// 백킹 저장소 이름을 직접 써 보면 존재가 확인된다 (타입 내부에서만)
struct Demo {
@Clamped(0...10) var v = 5
func inspect() {
print(type(of: _v)) // Clamped<Int>
print(_v.wrappedValue) // 5
}
}
Demo().inspect()
// 타입 외부에서는 접근할 수 없다
// Demo()._v // ❌ '_v' is inaccessible due to 'private' protection level
컴파일러가 생성한 코드를 보고 싶으면 SIL을 확인한다.
# 프로퍼티 래퍼가 어떻게 풀리는지 확인한다
swiftc -emit-sil Demo.swift | grep -A5 "_v\|wrappedValue" | head -40
# 인터페이스 수준에서 보고 싶다면
swiftc -emit-module-interface -enable-library-evolution Demo.swift
# 생성된 .swiftinterface 에 백킹 저장소가 어떻게 노출되는지 드러난다
남용 여부를 점검한다.
# 프로젝트에 정의된 프로퍼티 래퍼 목록
grep -rn "@propertyWrapper" --include=*.swift .
# 래퍼를 쌓아 쓰는 곳 — 순서 의존이 있는지 확인할 대상
grep -rnE "@[A-Z][A-Za-z]+ +@[A-Z][A-Za-z]+ +(var|let)" --include=*.swift .
프로젝트 적용
- 동작이 이름에서 드러나게 짓는다.
@Clamped·@Trimmed처럼 무엇을 하는지가 보이면 암묵성 비용이 줄어든다. - 부수 효과가 큰 래퍼는 피한다. 프로퍼티 대입이 네트워크 요청을 일으키면 호출부가 비용을 예측할 수 없다(P1 20장 Q5).
- 쌓아 쓰기 전에 순서를 문서화한다. 비교환적이므로 왜 이 순서인지가 코드에 남아야 한다.
projectedValue의 타입을 명확히 한다.$가 무엇을 주는지는 래퍼마다 다르다.- SwiftUI 전용 기능으로 오해하지 않는다. SE-0258은 일반 언어 기능이고, 검증·정규화·스레드 확인 같은 데 유용하다.
"$foo는 항상 Binding이다"는 틀렸다 — $foo의 타입은 그 래퍼가 정의한 projectedValue의 타입이고, Binding은 @State가 그런 프로젝션을 정의했기 때문에 나오는 특수 사례다. "프로퍼티 래퍼는 SwiftUI 전용"도 틀렸다 — SE-0258은 SwiftUI와 무관한 일반 기능이다. "래퍼를 쌓는 순서는 상관없다"도 틀렸다 — 비교환적이다. "백킹 저장소는 외부에서도 볼 수 있다"도 틀렸다 — 항상 private이다. "파라미터에 래퍼를 붙여도 호출 규약은 그대로"도 틀렸다 — 실제 파라미터 타입과 ABI가 바뀐다.
서랍에 물건을 넣을 때마다 자동으로 하는 일을 붙여 둘 수 있다고 하자. "너무 큰 건 잘라서 넣기", "넣을 때마다 일지에 적기" 같은 것이다.
쓰는 사람은 그냥 서랍에 넣듯이 쓰면 된다. 뒤에서 알아서 처리된다. 편하다.
여기서 실제로 만들어지는 건 세 가지다. 진짜 물건이 들어가는 속 서랍(밖에서는 못 본다), 사람이 쓰는 겉 손잡이, 그리고 래퍼가 원하면 만드는 부가 정보 창구다.
이 부가 정보 창구가 무엇을 주는지는 래퍼마다 다르다. 어떤 건 "지금 허용 범위"를 주고, 어떤 건 "이 값을 화면과 연결하는 끈"을 준다. 그런데 SwiftUI에서 끈이 자주 나오다 보니 "이 창구는 항상 끈이다"라고 착각하기 쉽다.
그리고 자동 처리를 두 개 겹쳐 붙이면 순서가 중요하다. "자르고 나서 적기"와 "적고 나서 자르기"는 다른 결과가 나온다.
꼬리 질문
매크로와 프로퍼티 래퍼 중 무엇을 써야 하는가?
"프로퍼티 하나의 접근 동작"이면 래퍼, "여러 선언을 생성"해야 하면 매크로다.
| 프로퍼티 래퍼 | 매크로 | |
|---|---|---|
| 범위 | 프로퍼티 하나 | 타입 전체·여러 멤버 |
| 생성 | 정해진 세 가지 | 임의의 선언 |
| 런타임 | 래퍼 인스턴스가 존재 | 대개 없음 — 코드만 생성 |
| 디버깅 | 타입을 따라가면 보인다 | 생성 코드 확장을 봐야 한다 |
실무 신호는 이렇다.
- 래퍼가 맞는 경우 — 값 검증, 정규화, 지연 로딩, 스레드 확인. 저장 방식을 바꾸는 일.
- 매크로가 맞는 경우 —
Codable보조 코드 생성, 여러 프로퍼티에 걸친 관찰 등록, 프로토콜 준수 자동화.@Observable이 매크로인 이유가 이것이다.
주의할 점은 둘 다 암묵성을 늘린다는 것이다. 매크로는 특히 생성 코드가 보이지 않아 디버깅이 어렵다 — Xcode의 매크로 확장 기능으로 실제 코드를 확인하는 습관이 필요하다.
프로퍼티 래퍼가 성능에 영향을 주는가?
대개 무시할 만하지만 세 가지를 확인한다.
(1) 저장소 크기. 래퍼 인스턴스가 프로퍼티마다 존재하므로 메모리가 는다. 래퍼가 클로저나 참조를 들고 있으면 더 커진다. 수만 개 인스턴스를 만드는 값 타입에 붙이면 의미가 있다.
MemoryLayout<Int>.size // 8
MemoryLayout<Clamped<Int>>.size // 8 + 범위(16) = 24
// 프로퍼티 하나당 세 배가 된다 — 대량 인스턴스에서는 확인할 가치가 있다(2) 접근 비용. 계산 프로퍼티를 거치므로 직접 저장보다 한 단계 더 간다. 다만 단순한 래퍼는 인라인되는 경우가 많아 릴리스 빌드에서는 차이가 작다(P0 12장 Q4).
(3) setter의 실제 작업. 이게 진짜다. 래퍼가 디스크 쓰기·알림 발송·검증을 한다면 그 비용이 매 대입마다 발생한다. 루프 안에서 대입하면 그만큼 반복된다.
// ❌ 루프마다 UserDefaults 쓰기가 일어난다
for item in items { settings.lastSeen = item.id }
// ✅ 마지막에 한 번
settings.lastSeen = items.last?.id ?? settings.lastSeen측정은 Time Profiler에서 래퍼의 setter가 보이는지로 확인한다(P0 15장 Q6).
파라미터에 프로퍼티 래퍼를 쓰면 무엇이 달라지는가?
겉보기와 실제가 다르다는 점을 알아야 한다. SE-0293이 desugar 메커니즘을 함수·클로저 파라미터로 확장했는데, 실제 파라미터 타입과 ABI까지 바뀐다.
@propertyWrapper struct Trimmed {
private var v: String
init(wrappedValue: String) { v = wrappedValue.trimmingCharacters(in: .whitespaces) }
var wrappedValue: String { get { v } set { v = newValue.trimmingCharacters(in: .whitespaces) } }
}
func register(@Trimmed name: String) { print("[\(name)]") }
register(name: " 김 ") // [김] ← 호출부는 String 을 넘긴 것처럼 보인다
// 그러나 실제 함수는 Trimmed 를 만들어 받는 형태로 변환된다주의할 점이 셋이다.
- API 문서와 실제가 어긋나 보일 수 있다. 시그니처는
String인데 변환이 일어난다. - ABI가 바뀐다. 공개 API에 붙였다가 떼면 바이너리 호환이 깨진다(33장 Q1).
- 호출부가 변환을 예측할 수 없다. 넘긴 값과 함수가 받은 값이 다를 수 있다.
그래서 내부 함수에서 검증·정규화를 일관되게 적용할 때는 유용하지만, 공개 API에는 신중해야 한다. 명시적으로 name.trimmed를 호출하는 편이 읽는 사람에게 정직한 경우가 많다.
Q5. Primary Associated Type은 성능을 바꾸는가?
바꾸지 않는다 — 순수한 문법 설탕이다. SE-0346은 프로토콜이 protocol Sequence<Element>처럼 primary associated type을 선언하면 any Collection<Int>·some Sequence<String> 같은 꺾쇠 문법으로 제약할 수 있게 한다. Collection<Int>는 정확히 Collection where Element == Int로 풀리며, 두 형태의 SIL 제네릭 시그니처를 비교하면 이름만 다를 뿐 제약 구조가 완전히 동일하다. 그리고 프로토콜이 primary로 선언한 것만 이 문법을 쓸 수 있다 — 나머지 associated type은 여전히 where 절이 필요하다. SE-0358이 표준 라이브러리 핵심 프로토콜에 부여했지만 AsyncSequence·OptionSet 등은 호환성 우려로 의도적으로 제외됐다.
CS 원리
이 기능이 푸는 문제는 표기의 비대칭이다. 제네릭 타입은 Array<Int>처럼 파라미터를 자연스럽게 적는데, 프로토콜의 associated type은 where 절이라는 다른 문법을 써야 했다.
// 같은 뜻인데 표기가 전혀 다르다
func f<C: Collection>(_ c: C) where C.Element == Int
func f(_ c: some Collection<Int>)
특히 실존 타입에서 문제가 컸다. any Collection은 쓸 수 있지만 원소 타입을 제약할 방법이 없어 실용성이 떨어졌다. any Collection<Int>는 실존 타입이면서 원소 타입 안전성을 동시에 얻는 조합이라 실무에서 유용하다.
중요한 것은 이것이 표기만의 변화라는 점이다. SIL·ABI·실존 레이아웃 어느 것도 바뀌지 않는다. 그래서 "이 문법이 성능을 개선한다"는 기대는 틀렸다.
primary로 선언한 것만 대상이라는 제약도 설계 의도가 있다. 모든 associated type을 꺾쇠에 넣으면 순서에 의미가 생겨 프로토콜 진화가 어려워진다. 그래서 가장 중요한 것 몇 개만 노출하도록 제한했다.
iOS에서는
실존 타입에서 가장 유용하다.
// 이전 — 원소 타입을 제약할 수 없어 실용성이 낮았다
func render(_ items: any Collection) { … } // Element 를 알 수 없다
// 지금 — 실존이면서 원소 타입이 확정된다
func render(_ items: any Collection<String>) {
for s in items { print(s.uppercased()) } // String 임을 안다
}
// 프로퍼티에도 쓸 수 있다 — some 으로는 표현이 까다롭던 자리
final class Store {
var items: any Collection<Item> = []
}
some과 any의 선택은 여전히 P0 05장 Q8의 기준을 따른다 — 구체 타입이 하나로 고정되면 some, 런타임에 바뀔 수 있으면 any.
// some — 반환 타입이 하나로 정해진다. 실존 박싱 없음.
func makeIDs() -> some Collection<Int> { [1, 2, 3] }
// any — 여러 구체 타입이 들어올 수 있다. 실존 컨테이너 사용.
var current: any Collection<Int> = [1, 2, 3]
current = Set([4, 5]) // 타입을 바꿀 수 있다
제외된 프로토콜이 있다는 점을 알아 둬야 한다. SE-0358이 Sequence<Element>·Collection<Element>·Identifiable<ID> 등에 부여했지만, AsyncSequence·LazySequenceProtocol·OptionSet 등은 향후 호환성 우려로 의도적으로 제외됐다.
// ❌ AsyncSequence 는 primary associated type 이 없어 이 문법을 못 쓴다
// func consume(_ s: any AsyncSequence<Int>) { }
// ✅ where 절을 쓴다
func consume<S: AsyncSequence>(_ s: S) async throws where S.Element == Int { … }
실험 · 도구
두 표기가 같은 것으로 풀리는지 SIL로 확인할 수 있다.
# 두 함수를 각각 다른 표기로 쓰고 SIL 시그니처를 비교한다
cat > /tmp/pat.swift <<'SWIFT'
func newStyle(_ c: some Collection<Int>) -> Int { c.count }
func oldStyle<C: Collection>(_ c: C) -> Int where C.Element == Int { c.count }
SWIFT
swiftc -emit-sil /tmp/pat.swift 2>/dev/null | grep -E "^sil hidden.*Style"
# 제네릭 시그니처가 나란히 출력된다 — 이름만 다르고 제약 구조는 같다
어떤 프로토콜이 이 문법을 지원하는지 확인하는 법이다.
// 지원하면 컴파일된다
let a: any Collection<Int> = [1, 2]
let b: any Sequence<String> = ["a"]
// 지원하지 않으면 컴파일 에러 — 그 자체가 확인 수단이다
// let c: any AsyncSequence<Int> // ❌
# 표준 라이브러리 인터페이스에서 primary associated type 선언을 찾는다
xcrun swift-api-digester --dump-sdk --module Swift 2>/dev/null | grep -i "primary" | head
# 또는 프로토콜 선언을 직접 본다
echo 'protocol P<T> { associatedtype T }' | swiftc -emit-sil - 2>&1 | head -5
프로젝트 적용
- 실존 타입이 필요한 자리에서 적극 쓴다.
any Collection<Item>은 실존 + 타입 안전을 동시에 준다. - 성능을 이유로 도입하지 않는다. 표기만 바뀐다 — 성능이 목적이면
some·제네릭으로 실존 자체를 피하는 것이 답이다. - 우리 프로토콜에도 primary를 선언한다. 가장 중요한 associated type 한두 개만 노출하면 호출부가 크게 읽기 쉬워진다.
- 지원하지 않는 프로토콜을 확인해 둔다.
AsyncSequence등은 여전히where절이 필요하다. - 기존
where절 코드를 무리하게 바꾸지 않는다. 동작이 같으므로 가독성이 개선되는 자리에서만 바꾼다.
"이 문법이 성능을 개선한다"는 틀렸다 — 순수 문법적 편의이며 SIL·ABI·실존 레이아웃 어느 것도 바뀌지 않는다. "Collection<Int>처럼 모든 associated type을 파라미터화할 수 있다"도 틀렸다 — 프로토콜이 primary로 선언한 것만 가능하다. "표준 라이브러리 프로토콜은 전부 지원한다"도 틀렸다 — AsyncSequence·OptionSet 등은 의도적으로 제외됐다. "any에 제약을 붙이면 some과 같아진다"도 틀렸다 — 실존 박싱 여부는 그대로다.
"숫자가 든 상자"라고 말하는 방법이 두 가지 있었다.
하나는 길게 풀어 쓰는 방식이다 — "상자인데, 그 상자에 든 것이 숫자인 그런 상자". 정확하지만 읽기 불편하다.
다른 하나는 짧게 쓰는 방식이다 — "숫자상자". 훨씬 읽기 좋다.
중요한 건 둘이 완전히 같은 말이라는 것이다. 짧게 쓴다고 상자가 가벼워지거나 빨라지지는 않는다. 기계가 보면 글자 그대로 같은 것으로 번역된다.
그래서 "짧게 쓰면 빨라진다"고 생각하면 안 된다. 읽기 좋아지는 것만으로도 충분히 좋은 일이지만, 속도를 기대하고 바꾸면 실망한다.
그리고 모든 상자에 이 짧은 표기를 쓸 수 있는 건 아니다. "이 상자는 짧게 부를 수 있어요"라고 미리 정해 둔 것만 된다.
꼬리 질문
우리 프로토콜에 primary associated type을 어떻게 정하는가?
호출부가 가장 자주 제약하는 것을 고른다. 한두 개면 충분하다.
// 이전 — 호출부가 where 절을 반복해야 한다
protocol Repository {
associatedtype Item
associatedtype Query
func fetch(_ q: Query) async throws -> [Item]
}
func show<R: Repository>(_ r: R) where R.Item == User { … }
// ✅ 가장 자주 제약하는 Item 만 primary 로
protocol Repository<Item> {
associatedtype Item
associatedtype Query
func fetch(_ q: Query) async throws -> [Item]
}
func show(_ r: some Repository<User>) { … }
func store(_ r: any Repository<User>) { … } // 실존으로도 쓸 수 있다선정 기준은 셋이다.
- 호출부가 실제로 제약하는가. 아무도 제약하지 않는 것을 primary로 만들 이유가 없다.
- 의미상 "이 프로토콜의 주인공"인가.
Sequence의Element가 전형이다. - 순서를 나중에 못 바꾼다. 여러 개를 선언하면 순서가 API 계약이 되므로 신중해야 한다.
표준 라이브러리가 일부 프로토콜을 의도적으로 제외한 이유도 이것이다 — 한 번 정하면 되돌리기 어렵다.
any와 some 중 무엇을 기본으로 해야 하는가?
some을 기본으로 하고 필요할 때만 any다. P0 05장 Q8의 기준이 그대로 적용된다.
some | any | |
|---|---|---|
| 구체 타입 | 하나로 고정 | 런타임에 바뀔 수 있다 |
| 실존 박싱 | 없음 | 있을 수 있다 |
| 디스패치 | 정적 (특수화 가능) | witness table 간접 |
| 컬렉션에 담기 | 같은 타입만 | 이종 가능 |
any가 필요한 경우는 명확하다.
- 이종 컬렉션 — 서로 다른 구체 타입을 한 배열에 담아야 할 때.
- 런타임 교체 — 프로퍼티에 담아 두고 나중에 다른 구현으로 바꿀 때.
- 타입 소거가 필요한 API 경계 — 구현 타입을 노출하고 싶지 않을 때.
그 외에는 some이나 제네릭이 낫다 — 박싱이 없고 특수화 여지가 있다. 다만 P1 26장 Q2에서 본 대로 제네릭 특수화는 코드 크기를 늘리므로, 아주 많은 타입에 인스턴스화되면 any가 오히려 유리할 수 있다. 여기서도 측정이 답한다.
제네릭 코드가 컴파일이 오래 걸릴 때 무엇을 볼 수 있는가?
타입 검사기가 후보를 폭발적으로 탐색하는 지점을 찾는다. Primary associated type은 표기를 줄일 뿐 이 문제를 직접 풀지는 않는다.
진단 순서는 이렇다.
# 오래 걸리는 함수를 찾는다 (100ms 넘는 것만 경고)
swiftc -Xfrontend -warn-long-function-bodies=100 \
-Xfrontend -warn-long-expression-type-checking=100 *.swift
# 파일별 컴파일 시간 집계
xcodebuild -showBuildTimingSummary ...흔한 원인과 대응이다.
- 복잡한 표현식 하나 — 긴 체이닝이나 리터럴 연산을 중간 변수로 쪼갠다. 타입 주석을 붙이면 탐색이 크게 줄어든다.
- 과도한 오버로드 — 같은 이름 함수가 많으면 후보가 는다.
- 제네릭 특수화 폭발 — 많은 구체 타입에 인스턴스화되면 코드 생성 시간이 는다(P1 26장 Q2).
// ❌ 타입 검사가 오래 걸린다
let v = items.map { $0.a }.filter { $0 > 0 }.reduce(0, +) / Double(items.count)
// ✅ 중간 타입을 명시해 탐색을 끊는다
let values: [Double] = items.map(\.a)
let positive: [Double] = values.filter { $0 > 0 }
let v = positive.reduce(0, +) / Double(items.count)Q6. 소급 준수는 왜 위험한가?
프로토콜 준수는 프로세스 안에서 전역적으로 유일해야 하는데, 두 모듈이 같은 것을 각자 선언할 수 있기 때문이다. 외부 타입에 외부 프로토콜 준수를 추가하면(extension Date: Identifiable 같은), 다른 모듈도 같은 준수를 선언했을 때 어느 쪽이 이길지 불확정한 undefined behavior가 된다. SE-0364는 외부 타입 × 외부 프로토콜 조합에서만 경고를 내고 @retroactive 표시로 잠재우게 한다. 중요한 것은 @retroactive가 문제를 해결하지 않는다는 점이다 — 순전히 컴파일 타임 마커로 위험을 문서화할 뿐, 다른 모듈이 이미 선언했는지 검증하거나 충돌을 막지는 못한다.
CS 원리
이것은 전역 유일성 제약(global uniqueness)의 문제다. Swift 런타임은 "타입 T가 프로토콜 P를 준수한다"는 사실을 프로세스 전체에서 하나로 관리한다 — 그래야 as? 캐스팅과 witness table 조회가 일관되게 동작한다.
문제는 선언 위치가 제한되지 않는다는 점이다. Swift는 어느 모듈에서나 준수를 추가할 수 있게 허용하는데, 이 유연성이 유일성과 충돌한다.
| 조합 | 누가 통제하는가 | 위험 |
|---|---|---|
| 내 타입 × 내 프로토콜 | 나 | 없음 |
| 내 타입 × 외부 프로토콜 | 나 (타입을 소유) | 없음 |
| 외부 타입 × 내 프로토콜 | 나 (프로토콜을 소유) | 없음 — 경고도 안 뜬다 |
| 외부 타입 × 외부 프로토콜 | 아무도 | 충돌 가능 |
마지막 조합만 위험한 이유는 둘 다 남의 것이라서다. 원 저자가 나중에 그 준수를 직접 추가할 수도 있고, 다른 라이브러리가 동시에 추가할 수도 있다. 그러면 같은 준수가 두 벌 존재한다.
여기서 중요한 것은 도구가 이를 검증할 수 없다는 점이다. 컴파일 시점에는 어떤 모듈이 링크될지 모르고, 링크 시점에는 준수 선언이 심볼 충돌로 잡히지 않는다. 그래서 SE-0364가 할 수 있는 최선이 "위험하다는 표시를 남기게 하는 것"이었다.
iOS에서는
경고가 뜨는 자리와 안 뜨는 자리가 명확히 갈린다.
import Foundation
// ⚠️ 외부 타입(Foundation) × 외부 프로토콜(Swift) — 경고
extension Date: Identifiable {
public var id: TimeInterval { timeIntervalSince1970 }
}
// warning: extension declares a conformance of imported type 'Date'
// to imported protocol 'Identifiable'
// ✅ 의도적임을 표시하면 경고가 사라진다 — 다만 위험은 그대로다
extension Date: @retroactive Identifiable {
public var id: TimeInterval { timeIntervalSince1970 }
}
// ✅ 내 프로토콜이면 경고 자체가 없다 — 내가 전적으로 통제하므로
protocol Displayable { var label: String { get } }
extension Date: Displayable { var label: String { formatted() } }
실무에서 안전한 대안은 준수 대신 래퍼 타입을 쓰는 것이다.
// ❌ 소급 준수 — 충돌 위험
extension URL: @retroactive Identifiable { public var id: String { absoluteString } }
// ✅ 래퍼 — 우리가 소유한 타입이므로 충돌이 없다
struct IdentifiedURL: Identifiable {
let value: URL
var id: String { value.absoluteString }
}
// ✅ 또는 우리 프로토콜로 감싼다
protocol AppIdentifiable { var appID: String { get } }
extension URL: AppIdentifiable { var appID: String { absoluteString } }
조건부 준수는 별개 문제다. SE-0143의 extension Array: Equatable where Element: Equatable 같은 것은 컴파일 타임 표현력 문제를 푸는 기능인데, 겹치는 준수는 서로소로 보여도 전면 금지라는 별도 제약이 있다.
// ❌ 겹치는 조건부 준수는 서로소처럼 보여도 금지된다
// extension MyBox: Codable where T: Codable { }
// extension MyBox: Codable where T: NSCoding { }
// error: conflicting conformance of 'MyBox<T>' to protocol 'Codable'
실험 · 도구
경고가 어느 조합에서 뜨는지 직접 확인해 보자.
import Foundation
// (1) 외부 × 외부 — 경고
extension UUID: Identifiable { public var id: String { uuidString } }
// (2) 외부 × 내 프로토콜 — 경고 없음
protocol Mine { var mine: Int { get } }
extension UUID: Mine { var mine: Int { 1 } }
// (3) 내 타입 × 외부 프로토콜 — 경고 없음
struct MyType {}
extension MyType: Identifiable { var id: Int { 1 } }
# 프로젝트에서 소급 준수를 전부 찾는다
grep -rnE "extension +(Date|URL|UUID|String|Int|Array|Data|NS[A-Z][A-Za-z]+) *:" \
--include=*.swift . | grep -v "@retroactive"
# 이미 표시된 것 — 의도를 검토할 대상
grep -rn "@retroactive" --include=*.swift .
# 경고를 에러로 승격해 새로 들어오는 것을 막는다
# Build Settings › Other Swift Flags 에 -warnings-as-errors 를 넣거나
# 특정 진단만 승격하는 방식을 검토한다
실제 충돌이 일어났는지는 런타임에 확인해야 한다 — 그것이 이 문제의 핵심 어려움이다.
// 준수가 실제로 어느 구현으로 해석되는지 확인
let d = Date()
if let identified = d as? any Identifiable {
print(type(of: identified.id)) // 어느 구현이 이겼는지 간접적으로 드러난다
}
// 두 벌이 존재해도 크래시가 나지 않고 "둘 중 하나"가 선택되므로
// 테스트에서 우연히 통과할 수 있다 — 이것이 위험한 이유다
프로젝트 적용
- 소급 준수 대신 래퍼 타입이나 우리 프로토콜을 쓴다. 대부분의 경우 이것으로 충분하고 위험이 없다.
- 불가피하면
@retroactive를 붙이고 주석에 근거를 남긴다. 표시는 해결이 아니라 문서화다. - 라이브러리에서는 절대 하지 않는다. 앱은 최종 소비자라 그나마 통제되지만, 라이브러리가 하면 사용자에게 충돌을 강요한다.
- 의존성이 늘어날 때 검토한다. 새 라이브러리가 같은 준수를 선언했을 수 있다.
- 경고를 끄지 않는다.
@retroactive없이 경고만 무시하면 다음 사람이 위험을 모른다.
"@retroactive를 붙이면 충돌 문제가 해결된다"는 틀렸다 — 경고를 억제하고 의도를 문서화할 뿐, 두 모듈이 실제로 같은 준수를 선언하면 표시 유무와 무관하게 undefined behavior다. "모든 extension에 경고가 뜬다"도 틀렸다 — 외부 타입 × 외부 프로토콜 조합만이다. "내 프로토콜이면 아무 타입에나 붙여도 된다"는 맞다 — 그 준수를 내가 전적으로 통제한다. "조건부 준수를 서로소로 나누면 여러 개 선언할 수 있다"도 틀렸다 — 겹치는 준수는 전면 금지다.
동네에 주소록이 하나 있다. "이 집은 이 번호"라고 딱 한 줄씩만 적을 수 있다.
그런데 남의 집에 남이 만든 번호 규칙을 내가 대신 적어 넣을 수 있다. 편하다. 문제는 다른 사람도 같은 짓을 할 수 있다는 점이다.
두 사람이 같은 집에 서로 다른 번호를 적으면? 주소록에는 한 줄만 있어야 하는데 두 줄이 생긴다. 그러면 누가 이길지 아무도 모른다. 게다가 싸움이 났다는 사실조차 티가 안 난다 — 그냥 둘 중 하나가 조용히 쓰인다.
그래서 언어가 "이건 위험합니다"라고 경고를 띄우게 됐다. 그리고 "알고 하는 거예요"라고 표시하면 경고를 끌 수 있다.
여기서 오해하기 쉬운 게 있다. 표시를 붙인다고 안전해지는 게 아니다. "위험한 거 알고 있어요"라고 적어 둔 것뿐이다. 실제로 두 사람이 적어 넣으면 여전히 똑같이 위험하다.
제일 안전한 방법은 남의 집 주소록을 건드리지 않는 것이다. 대신 내 수첩을 하나 만들어 거기 적으면 된다.
꼬리 질문
충돌이 실제로 일어나면 어떤 증상이 나타나는가?
증상이 일정하지 않다는 것이 가장 큰 문제다. 크래시가 나기도 하고, 조용히 다른 구현이 쓰이기도 한다.
관찰되는 형태들이다.
- 예상과 다른 동작 — 우리가 정의한
id가 아니라 다른 라이브러리의 것이 쓰인다. 크래시가 없어 발견이 매우 늦다. - 빌드 구성에 따라 달라짐 — 링크 순서나 최적화 수준이 바뀌면 이기는 쪽이 바뀔 수 있다. "디버그에서는 되는데 릴리스에서 안 된다"의 원인이 되기도 한다.
- 런타임 경고 — 일부 상황에서 중복 준수 등록에 대한 경고가 콘솔에 찍힐 수 있다.
진단이 어려운 이유는 P1 19장·21장에서 다룬 도구들이 도움이 안 되기 때문이다 — 크래시가 아니므로 크래시 리포트가 없고, 심볼화 문제도 아니다. 동작이 다를 뿐이다.
그래서 예방이 유일한 실질적 대책이다. 발견은 대개 "왜 이 값이 이렇게 나오지?"라는 의문에서 시작해 한참 뒤에 이루어진다.
라이브러리가 소급 준수를 하고 있으면 어떻게 대응하는가?
우리가 통제할 수 없으므로 회피와 감시가 대책이다.
순서대로 확인한다.
(1) 우리도 같은 준수를 하고 있는지 확인한다. 하고 있으면 즉시 제거하고 래퍼로 바꾼다. 두 벌이 되는 것을 막는 것이 최우선이다.
# 의존성이 어떤 소급 준수를 하는지 확인 (소스가 있는 경우)
grep -rn "@retroactive\|extension .*: *\(Identifiable\|Hashable\|Codable\)" \
.build/checkouts/*/Sources/ 2>/dev/null | head -20(2) 이슈를 제기한다. 라이브러리 저자에게 래퍼 타입으로 바꿔 달라고 요청하는 것이 근본 해법이다. 이건 라이브러리의 버그로 볼 수 있다.
(3) 대안 라이브러리를 검토한다. 소급 준수를 하는 라이브러리는 다른 라이브러리와 함께 쓸 수 없을 위험이 있다.
(4) 격리한다. 그 라이브러리를 별도 모듈로 감싸 사용 범위를 좁히면 영향이 줄어든다. 다만 준수는 프로세스 전역이라 완전한 격리는 되지 않는다.
P1 26장 Q2에서 본 SDK 도입 기준에 이 항목을 추가할 만하다 — dSYM·manifest·extension-safe·크기에 이어 소급 준수 여부도 확인 대상이다.
표준 라이브러리 타입에 편의 기능을 추가하고 싶으면?
메서드·프로퍼티 추가는 안전하고, 프로토콜 준수만 위험하다. 이 구분이 핵심이다.
// ✅ 안전 — 메서드·계산 프로퍼티 추가는 준수가 아니다
extension String {
var trimmed: String { trimmingCharacters(in: .whitespacesAndNewlines) }
func chunked(into size: Int) -> [Substring] { … }
}
extension Collection {
subscript(safe index: Index) -> Element? {
indices.contains(index) ? self[index] : nil
}
}
// ⚠️ 위험 — 프로토콜 준수 추가
// extension String: @retroactive Identifiable { }메서드 추가가 안전한 이유는 전역 유일성 제약이 없기 때문이다. 두 모듈이 같은 이름의 메서드를 추가하면 사용하는 쪽에서 모호성 에러가 나지만, 이건 컴파일 타임에 잡히고 명시적 캐스팅으로 해결된다. 준수와 달리 조용한 undefined behavior가 아니다.
다만 메서드 추가에도 관행이 있다.
- 이름을 충분히 구체적으로 —
trimmed보다appTrimmed가 충돌 확률이 낮다. 다만 가독성과 저울질한다. - 라이브러리에서는 접두사를 고려 — 사용자 코드와 충돌하면 곤란하다.
- 표준 라이브러리가 나중에 같은 이름을 추가할 수 있다 — 그때 우리 것이 가려지거나 모호해진다.
Q7. 망라성 검사와 defer는 무엇을 지켜 주는가?
둘 다 "빠뜨림"을 컴파일러·런타임이 대신 막아 주는 장치다. switch의 망라성은 컴파일러가 그 enum의 전체 case 목록을 컴파일 타임에 완벽히 안다는 전제 위의 정적 증명이다(SE-0192). 그래서 재컴파일 없이 새 버전으로 바뀔 수 있는 ABI 경계 너머의 non-frozen enum은 @unknown default를 요구하고, Swift 6 언어 모드에서는 컴파일 에러다. 흔한 오해와 달리 기준은 "내 모듈 밖"이 아니라 "library evolution으로 빌드됐는가"다. defer는 LIFO로 실행되고 throw 경로에서도 반드시 돌지만, 스코프 단위이고 트랩을 지나면 실행되지 않는다.
CS 원리
두 장치는 서로 다른 종류의 빠뜨림을 막는다.
- 망라성 — 경우의 빠뜨림. "이 상황도 처리했는가"를 컴파일러가 증명한다.
defer— 정리의 빠뜨림. "나가는 모든 경로에서 정리했는가"를 런타임이 보장한다.
망라성 증명이 성립하려면 case 목록이 확정적이어야 한다. 자기 모듈 enum이나 library evolution 없이 컴파일된 일반 의존성의 enum은 클라이언트가 항상 그 정확한 정의로 재컴파일되므로 목록이 확정된다.
문제는 -enable-library-evolution으로 빌드된 non-frozen enum이다 — Apple 시스템 프레임워크가 대표적이다. 클라이언트를 재컴파일하지 않아도 나중 OS 버전에서 새 case가 조용히 추가될 수 있으므로, 컴파일 타임의 목록이 런타임의 목록과 다를 수 있다.
그래서 규칙이 적용되는 대상은 "내 모듈 밖"이 아니라 "재컴파일 없이 새 버전으로 바뀔 수 있는 ABI 경계 너머"다. 이 구분이 실무 판단을 바꾼다.
defer의 원리는 스코프 종료 시점의 후처리다. C++의 소멸자나 try-finally와 같은 목적인데, LIFO인 이유는 획득의 역순으로 해제하는 것이 자원 관리의 자연스러운 순서이기 때문이다.
iOS에서는
망라성 규칙이 어디서 적용되는지가 실무 판단을 가른다.
// 내 모듈 enum — @unknown default 불필요
enum MyState { case idle, loading, done }
switch state {
case .idle: …
case .loading: …
case .done: …
} // ✅ 망라적
// 시스템 프레임워크의 non-frozen enum — 필요하다
switch UIDevice.current.userInterfaceIdiom {
case .phone: …
case .pad: …
@unknown default: … // ✅ 새 기기 종류가 추가될 수 있다
}
// library evolution 없이 빌드된 SPM 의존성 — 경고조차 없다
switch someLibraryEnum {
case .a: …
case .b: …
} // ✅ 클라이언트가 항상 재컴파일되므로
우리가 라이브러리를 만든다면 @frozen으로 목록을 고정할 수 있다.
// case 목록을 ABI 계약으로 영구 고정 — 이후 추가 불가
@frozen public enum Direction { case up, down }
// 클라이언트가 @unknown default 없이 switch 할 수 있게 된다.
// 대신 케이스를 영원히 못 늘린다 — 신중한 결정이다.
defer는 스코프 단위라는 점이 자주 오해된다.
// 루프 몸체 안의 defer 는 매 반복마다 실행된다
for i in 0..<3 {
defer { print("정리 \(i)") }
print("작업 \(i)")
}
// 작업 0 / 정리 0 / 작업 1 / 정리 1 / 작업 2 / 정리 2
// LIFO — 나중에 쓴 것이 먼저
func f() {
defer { print("A") }
defer { print("B") }
print("본문")
}
// 본문 / B / A
변수 캡처도 주의할 점이다 — 등록 시점이 아니라 실행 시점의 값을 읽는다.
func g() {
var count = 0
defer { print("최종 count: \(count)") }
count = 1
count = 2
}
// 최종 count: 2 ← 등록 시점의 0 이 아니다
그리고 실행되지 않는 경우가 둘 있다 — 스코프에 진입조차 안 했을 때와 fatalError 같은 트랩을 지날 때다. 트랩은 return이 아니라 프로세스 종료이기 때문이다(P1 19장).
실험 · 도구
망라성 규칙이 대상에 따라 다르게 적용되는 것을 확인해 보자.
import UIKit
// (1) 시스템 프레임워크 enum — @unknown default 없이 switch
func check(_ idiom: UIUserInterfaceIdiom) -> String {
switch idiom {
case .phone: return "phone"
case .pad: return "pad"
default: return "기타" // default 는 경고를 막지만 새 case 를 알려주지 않는다
}
}
// @unknown default 를 쓰면 새 case 가 추가됐을 때 경고로 알려준다 — 이쪽이 낫다
# Swift 6 언어 모드로 빌드해 이 규칙이 에러가 되는지 확인한다
swiftc -swift-version 6 MyFile.swift
# 프로젝트에서 default 를 @unknown default 로 바꿀 후보를 찾는다
grep -rn -B5 "^\s*default:" --include=*.swift . | grep -i "switch.*UI\|switch.*NS" | head
# 의존성이 library evolution 으로 빌드됐는지 확인
grep -rn "enable-library-evolution\|BUILD_LIBRARY_FOR_DISTRIBUTION" \
--include=*.swift --include=*.pbxproj --include=Package.swift .
defer의 동작도 재현해 본다.
func demo() throws {
defer { print("1) 마지막에 실행") }
defer { print("2) 먼저 실행") }
if Bool.random() {
defer { print("3) if 스코프 끝에서 즉시") }
print("if 안")
} // ← 여기서 3) 이 실행된다
throw MyError.oops // throw 경로에서도 1) 2) 는 실행된다
}
// if 안 / 3) / 2) / 1)
프로젝트 적용
default대신@unknown default를 쓴다. 새 case가 추가되면 경고로 알려 주므로 대응할 기회가 생긴다.- Swift 6 언어 모드를 켠다. 이 규칙이 에러가 되어 놓칠 수 없게 된다.
- 우리 공개 enum에
@frozen을 붙일지 신중히 판단한다. 붙이면 영원히 케이스를 못 늘린다. defer를 자원 해제에 쓴다. 파일 핸들·락·측정 종료처럼 모든 경로에서 반드시 해야 하는 일.defer가 트랩에서 실행되지 않는다는 것을 안다.fatalError뒤의 정리는 일어나지 않는다.
"모든 외부 모듈 enum에 @unknown default가 필요하다"는 부정확하다 — library evolution 없이 컴파일된 일반 SPM 의존성의 enum은 자기 모듈과 동일하게 취급되어 경고조차 뜨지 않는다. 기준은 ABI 경계다. "defer는 등록 순서대로 실행된다"도 정반대다 — LIFO다. "defer는 함수 끝에서 실행된다"도 틀렸다 — 스코프 단위라 루프 안이면 매 반복마다 돈다. "defer는 등록 시점의 값을 캡처한다"도 틀렸다 — 실행 시점의 값을 읽는다. "defer는 어떤 경우에도 실행된다"도 틀렸다 — 트랩을 지나면 실행되지 않는다.
빠짐없이 처리했는지 검사해 주는 기능이 있다. 경우가 다섯 가지인데 네 개만 처리하면 컴퓨터가 알려준다. 아주 유용하다.
그런데 이 검사가 성립하려면 경우가 몇 개인지 확실히 알아야 한다. 우리가 만든 목록이면 확실하다.
문제는 애플이 만든 목록 같은 것이다. 지금은 다섯 개인데, 다음 iOS 버전에서 여섯 개가 될 수 있다. 그때 우리 앱은 다시 만들지 않았는데도 새 경우를 만나게 된다.
그래서 이런 목록에는 "모르는 게 오면 이렇게 해"를 반드시 적어 두라고 한다.
여기서 흔히 오해하는 게 있다. "남이 만든 목록이면 다 그렇다"고 생각하는데, 아니다. 우리와 함께 다시 만들어지는 남의 목록은 괜찮다. 기준은 "우리를 다시 만들지 않고도 그쪽이 바뀔 수 있느냐"다.
그리고 defer는 "나갈 때 이건 꼭 해"라고 미리 적어 두는 것이다. 재미있는 건 나중에 적은 것부터 실행된다는 점이다. 짐을 쌀 때 마지막에 넣은 걸 먼저 꺼내는 것과 같다.
꼬리 질문
@unknown default 안에서는 무엇을 해야 하는가?
"모르는 값이 왔다"를 기록하고 안전한 기본 동작을 한다. 크래시시키면 안 된다.
switch status {
case .active: showActive()
case .expired: showExpired()
@unknown default:
// ① 기록한다 — 새 case 가 실제로 오고 있는지 지표로 확인한다
logger.notice("알 수 없는 status: \(String(describing: status), privacy: .public)")
metrics.increment("enum.unknown", tags: ["type": "Status"])
// ② 가장 안전한 동작으로 폴백한다
showGenericState()
}하지 말아야 할 것이 셋이다.
fatalError— 새 OS 버전에서 전원 크래시한다. 최악의 선택이다.- 빈 블록 — 아무 일도 안 하면 화면이 비거나 멈춘 것처럼 보인다.
- 가장 흔한 케이스로 폴백 — 잘못된 상태를 정상처럼 보여준다. "알 수 없음" 상태를 명시하는 편이 낫다.
기록이 중요한 이유는 새 case가 실제로 오는지를 알아야 대응 우선순위를 정할 수 있기 때문이다 — 31장 Q5의 unknown 태그 추적과 같은 발상이다.
defer 대신 다른 방법을 써야 하는 경우가 있는가?
있다. defer가 잘 맞지 않는 경우가 셋이다.
(1) 비동기 정리. defer 블록은 await를 쓸 수 없다. 비동기 정리가 필요하면 다른 구조가 필요하다.
// ❌ defer 안에서 await 불가
// defer { await connection.close() }
// ✅ 명시적으로 감싼다
func withConnection<T>(_ body: (Connection) async throws -> T) async throws -> T {
let c = try await Connection.open()
do {
let r = try await body(c)
await c.close()
return r
} catch {
await c.close()
throw error
}
}(2) 정리 순서가 LIFO와 다를 때. 획득 순서와 무관한 순서가 필요하면 명시적으로 쓴다.
(3) 조건부 정리. "성공했을 때만 커밋, 실패하면 롤백"처럼 경로에 따라 다른 정리는 defer보다 명시적 분기가 읽기 좋다.
// 상태 플래그를 defer 에서 읽는 방식도 있지만 읽기 어렵다
var committed = false
defer { if !committed { rollback() } }
…
committed = true
// 동작은 하지만, 흐름이 흩어져 추적이 어렵다일반 원칙은 "모든 경로에서 똑같이 해야 하는 일"에만 defer를 쓰는 것이다. 경로마다 다르면 명시적으로 쓰는 편이 낫다.
Swift 6 언어 모드로 올릴 때 이 규칙 때문에 얼마나 고쳐야 하는가?
시스템 프레임워크 enum을 switch하는 곳 수만큼이고, 대개 생각보다 적다.
미리 규모를 파악하는 방법이다.
# Swift 6 모드로 시험 빌드해 개수를 센다
xcodebuild -scheme App SWIFT_VERSION=6 2>&1 \
| grep -c "must be exhaustive\|@unknown default"
# 어떤 파일인지 목록으로
xcodebuild -scheme App SWIFT_VERSION=6 2>&1 \
| grep -B1 "must be exhaustive" | grep "\.swift" | sort -u고치는 방식은 단순하다 — default:를 @unknown default:로 바꾸거나, default가 없으면 추가한다. 기계적인 작업이라 위험이 낮다.
다만 이 기회에 검토할 것이 있다.
- 이미
default가 있던 곳 —@unknown default로 바꾸면 새 case 추가를 경고로 알게 된다. 이득이다. fatalError가 들어 있는default— 즉시 고쳐야 한다. 새 OS에서 크래시한다.
망라성보다 Swift 6의 엄격한 동시성 검사가 훨씬 큰 작업이다(35장). 이 항목은 상대적으로 가벼우므로 먼저 처리해 두면 부담이 줄어든다.