← Swift & Foundation 로드맵 5 / 33

05 · 프로퍼티

get/set의 copy-in copy-out부터 언더스코어 _modify 코루틴, didSet이 침묵하는 init 구간, lazy var의 경쟁 조건, 프로퍼티 래퍼 desugaring, key path 계층까지.
진행률
0 / 0 완료

프로퍼티는 Swift에서 가장 자주 쓰지만 가장 대충 아는 문법이다. get/set 뒤에 언더스코어 붙은 코루틴 접근자가 숨어 있고, didSet은 생각보다 훨씬 자주 침묵하며, lazy var는 "한 번만 계산된다"는 믿음을 배신할 수 있다. 이 챕터는 프로퍼티가 컴파일러 안에서 실제로 어떤 코드로 풀리는지, 그리고 그게 왜 면접에서도 실무에서도 함정이 되는지를 다룬다.

Q1. 저장 프로퍼티·연산 프로퍼티와 _read/_modify 접근자는 무엇이 다른가?

🔑 30초 답변

저장 프로퍼티는 인스턴스 메모리 레이아웃의 일부로 값 자체를 들고 있고, 연산 프로퍼티는 값을 저장하지 않고 get/(옵셔널)set으로 매번 계산한다. 문제는 일반 get/set이 "복사해서 왕복"하는 모델이라는 것 — 값을 get으로 통째로 꺼내 임시 사본을 고치고 set으로 통째로 되써 넣는다. _read/_modify는 아직 언더스코어가 붙은 비공식 코루틴 접근자로, yield를 통해 저장 위치 자체에 대한 참조를 넘겨 그 자리에서 바로 고치게 해준다. 표준 라이브러리가 Array 서브스크립트 같은 곳에서 내부적으로 이걸 써서 array[i].mutate()가 값을 복사해 왕복하지 않고 제자리에서 바뀌게 만든다.

원리

저장 프로퍼티는 "인스턴스의 일부로 저장되는 상수 또는 변수"이고 클래스·구조체에만 있다(열거형은 불가). 연산 프로퍼티는 값을 저장하지 않고 getter와 옵셔널 setter를 제공하며 클래스·구조체·열거형 모두에 정의할 수 있다. TSPL Properties가 규정하는 건 여기까지다 — get/set 이외의 접근자 이름은 언어 레퍼런스에 공식적으로 노출돼 있지 않다.

내부 동작

일반 연산 프로퍼티의 호출 순서는 copy-modify-write다. box.value += 1 같은 복합 대입은 get()으로 현재 값을 꺼내고, 그 사본에 += 1을 적용하고, set(사본)으로 전체 값을 다시 써 넣는다. Value가 큰 구조체나 배열이면 이 왕복 자체가 O(size) 복사 비용이다.

_modify(그리고 읽기 전용 짝인 _read)는 코루틴이다. 함수가 중간까지 실행되다가 yield로 저장 위치에 대한 inout 유사 참조를 호출부에 넘기고, 호출부가 그 참조를 통해 직접 값을 고친 뒤, 코루틴이 재개되어 마무리된다. 복사가 아예 일어나지 않는다. 표준 라이브러리는 Array의 서브스크립트 같은 곳에서 이 메커니즘을 내부적으로 쓰기 때문에 array[i].append(x) 같은 코드가 배열 저장소를 직접 수정한다.

중요한 건 _read/_modify가 2026년 현재도 여전히 언더스코어가 붙은 비공식·실험적 기능이라는 점이다. Swift 5.0부터 컴파일러가 내부적으로 써왔지만 공식 문법으로 노출된 적이 없고, 이를 정식 언어 기능으로 승격시키는 작업이 SE-0474 "Yielding accessors"와 그 읽기 전용 대응인 SE-0507 "Borrow accessors"로 아직 진행 중이다. 강의에서는 "이미 자리 잡은 정식 문법"이 아니라 "공식화를 기다리는 내부 최적화 기법"으로 다루는 게 정확하다.

get/set 왕복 vs _modify 코루틴 ① 일반 get/set — 복사해서 왕복 저장 위치 get() 임시 사본 여기서 수정 set(사본) — 전체를 다시 써 넣는다 ② _modify 코루틴 — 그 자리에서 직접 수정 저장 위치 yield & 호출부가 그 자리에서 직접 수정 사본 없음 — 재개하면 끝 복사 0회 값이 클수록 ①/② 비용 차이가 커진다 — Array 원소 in-place mutate가 바로 이 경로다
get/set은 값을 복사해 왕복하지만, _modify는 저장 위치 자체를 코루틴으로 넘겨 그 자리에서 고친다.

실험 · 도구

_modify는 언더스코어 기능이지만 실제로 컴파일되므로 직접 붙여서 관찰할 수 있다. get/set/_modify 각각에 print를 넣고 값을 다르게 건드려 보면, 복합 접근(append 등)일 때만 _modify가 선택되고 전체 재대입일 때는 set이 선택되는 걸 눈으로 확인할 수 있다.

get / set / _modify 중 무엇이 호출되는지 직접 찍어보기
struct Box {
    private var storage: [Int] = []
    var items: [Int] {
        get { print("get 호출"); return storage }
        set { print("set 호출"); storage = newValue }
        _modify {
            print("_modify 진입")
            yield &storage
            print("_modify 탈출")
        }
    }
}

var b = Box()
b.items.append(1)   // "_modify 진입" → "_modify 탈출"만 찍힌다. get/set 왕복이 없다.
b.items = [9]        // "set 호출"만 찍힌다 — 전체 재대입은 set 경로를 그대로 탄다.
print(b.items)        // "get 호출"

컴파일러가 실제로 만드는 코드를 보고 싶으면 swiftc -emit-sil로 SIL을 뽑아 함수 시그니처에서 modify 코루틴(yield_once 컨벤션)을 검색한다. 표준 라이브러리 소스에서 Array의 서브스크립트 구현을 찾아봐도 _modify가 그대로 쓰여 있다.

프로젝트 적용

큰 값 타입을 감싸는 wrapper 프로퍼티/서브스크립트를 직접 만들 때, get/set만 정의하면 wrapper.items[i].mutate() 같은 코드가 항상 전체 값을 복사해서 왕복한다. _modify는 언더스코어 기능이라 공개 API 표면에 그대로 노출하기보다는, 호출자가 직접 필드를 고치게 하는 mutating 클로저 API로 감싸는 편이 안전하다.

공개 API는 mutating 클로저로 — _modify는 내부 구현에만
final class Cache<Value> {
    private var storage: Value

    init(_ initial: Value) { storage = initial }

    // ❌ 공개 API로 '_modify'를 그대로 노출하면 컴파일러 업데이트에 취약하다.
    // ✅ 대신 mutating 클로저를 받는 메서드로 '그 자리에서 고치는' API를 흉내낸다.
    func modify(_ body: (inout Value) -> Void) {
        body(&storage)   // 여기서 딱 한 번만 '직접 참조'를 넘긴다
    }
}

let cache = Cache([1, 2, 3])
cache.modify { $0.append(4) }   // 복사-왕복 없이 storage를 바로 고침

성능이 정말 중요한 내부 모듈(공개 API가 아닌 자기 모듈 안)이라면 _modify를 직접 붙여 써도 되지만, 언더스코어 기능이라 컴파일러 버전이 바뀌면 동작이 달라질 수 있다는 걸 코드 주석에 명시하고, SE-0474가 공식화되면 modify(언더스코어 없는)로 옮길 준비를 해 둔다.

내부 전용 고성능 서브스크립트에 _modify 적용 — 공개 API로는 노출하지 않는다
struct RingBuffer<Element> {
    private var storage: [Element]

    // ⚠️ _modify는 언더스코어 실험적 기능. 모듈 내부 구현에만 쓰고
    //    라이브러리 공개 인터페이스(공개 subscript)에는 노출하지 않는다.
    subscript(index: Int) -> Element {
        get { storage[index] }
        set { storage[index] = newValue }
        _modify { yield &storage[index] }
    }
}
⚠️ 흔한 오해

"_modify는 그냥 문법 설탕이라 성능과 무관하다"는 틀렸다. 큰 값 타입에서 get+set 왕복 복사를 없애는 실질적 최적화다. 반대로 "저장 프로퍼티든 연산 프로퍼티든 += 같은 복합 연산은 다 똑같이 동작한다"도 틀렸다 — 저장 프로퍼티는 애초에 접근자 호출 자체가 없는 직접 메모리 접근이고, 연산 프로퍼티는 _modify가 없으면 무조건 복사-왕복이다.

🧒 쉽게 이해하기

냉장고에 있는 반찬통을 고친다고 생각해 보자. get/set 방식은 통을 통째로 꺼내서(get) 식탁에서 반찬을 더 담고(수정), 통째로 다시 냉장고에 넣는(set) 것이다. 반찬 한 조각만 추가하는데도 매번 통 전체를 들고 왔다 갔다 한다. _modify 방식은 냉장고 문을 열어 놓은 채로(yield) 손만 넣어서 그 자리에서 반찬을 더 담고 문을 닫는 것이다. 통을 옮기지 않으니 훨씬 빠르다. 비유가 깨지는 곳: 실제 냉장고 문 열어두기는 위험하지만, 코드에서는 이 "문 열어두기"가 컴파일러가 보장하는 안전한 코루틴이라 위험하지 않다.

꼬리 질문

_modify가 없는 연산 프로퍼티에 swap(&a.b, &a.c)처럼 두 번 inout으로 넘기면 무슨 일이 생기나?
연산 프로퍼티는 그 자체로 실제 메모리 주소가 없으므로, inout으로 넘기려면 컴파일러가 임시 지역 변수를 만들어 get으로 채우고, 함수가 끝나면 set으로 되써 넣는 "materialization"을 거친다. swap 같은 함수에 두 연산 프로퍼티를 넘기면 각각 별도의 임시 변수가 만들어지고 실제 저장소끼리 직접 스왑되는 게 아니라 get→swap(임시들끼리)→두 번의 set으로 처리된다. 저장 프로퍼티라면 이런 materialization 없이 진짜 주소끼리 직접 교환된다.
쉽게 말하면 저장 프로퍼티는 실제 서랍이라 서랍끼리 내용물을 바로 바꿀 수 있지만, 연산 프로퍼티는 서랍이 아니라 "요청하면 만들어주는 임시 쟁반"이라, 쟁반을 하나씩 만들어서 바꾼 다음 각자 원래 자리에 도로 넣어줘야 한다.
didSet/willSet이 붙은 저장 프로퍼티도 _modify의 도움을 받을 수 있는가?
개념적으로는 가능하다 — 옵저버가 붙은 저장 프로퍼티도 컴파일러가 접근자를 합성해야 하는 지점이라, in-place 수정 경로에서 willSet을 먼저 부르고 값을 고친 뒤 didSet을 부르는 코루틴을 만들 수 있다. 다만 이건 컴파일러 구현 세부사항이고 언어 스펙이 보장하는 공개 계약은 아니다. 확실한 건 관찰 가능한 동작(옵저버가 몇 번, 어떤 순서로 불리는지)이지, 그 밑에서 복사가 몇 번 일어나는지는 최적화 디테일이라는 점이다.
쉽게 말하면 감시 카메라(옵저버)가 달린 서랍이어도, 서랍 내용물을 통째로 꺼냈다 넣었다 하지 않고 문 열어둔 채로 손만 넣어 고치는 효율적인 방법을 컴파일러가 알아서 찾아줄 수 있다는 뜻이다. 다만 그 효율화 방법 자체를 개발자가 보장받는 건 아니다.
_modifyinout 파라미터 자체와 다르게 "코루틴"이라고 불리는가?
inout 파라미터는 함수 호출 전체가 한 번에 실행되고 끝나는 일반 함수다(내부적으로 copy-in copy-out이라도 함수 자체는 코루틴이 아니다). 반면 _modify는 실행이 중간에 멈추고(yield 지점) 호출부에게 제어권과 참조를 넘긴 다음, 호출부가 그 참조로 뭔가를 다 마치면 다시 실행이 재개되는 2단계 실행 구조를 갖는다 — 이 "멈췄다 재개되는" 실행 모델이 코루틴의 정의 그 자체다.
쉽게 말하면 일반 함수는 문을 열어주고 심부름을 대신 다 해준 다음에야 결과를 돌려주는 사람이고, 코루틴은 문을 열어준 채로 "자, 여기 직접 하시고 다 하면 알려주세요"라고 말한 뒤 잠깐 멈춰 기다리는 사람이다.

Q2. init 안에서 didSet이 호출되지 않는 이유는? inout은 왜 항상 옵저버를 부르는가?

🔑 30초 답변

두 가지가 정확히 반대 방향의 함정이다. (a) 자기 자신의 init 본문 안에서 저장 프로퍼티에 값을 대입하는 건 "아직 완전히 초기화되지 않은 인스턴스"에 대한 첫 설정으로 취급돼 willSet/didSet이 건너뛰어진다 — 2단계 초기화 규칙이 "덜 만들어진 객체를 관찰하는 것"을 애초에 금지하기 때문이다. (b) 반대로 옵저버가 붙은 프로퍼티를 inout 파라미터로 넘기면, 실제 값이 전혀 바뀌지 않아도 willSet/didSet이 무조건 호출된다 — inout이 copy-in copy-out 모델이라 함수가 끝나는 순간 무조건 값을 "다시 써 넣기" 때문이다.

원리

TSPL Properties는 옵저버를 "프로퍼티 값이 설정될 때마다, 새 값이 기존 값과 같아도" 호출된다고 정의한다. 동시에 명시적으로 예외를 둔다: "슈퍼클래스 프로퍼티의 willSet/didSet은 서브클래스 이니셜라이저 안에서, 슈퍼클래스 init이 호출된 뒤에 설정될 때 호출된다. 클래스가 자기 자신의 init 본문에서 자기 프로퍼티를 설정하는 동안에는 호출되지 않는다." inoutTSPL Functions에서 "값을 함수 안으로 복사해 넣고, 함수 안에서 그 복사본을 수정하고, 함수가 끝나면 그 결과를 원래 변수 자리로 다시 복사해 넣는" copy-in copy-out 모델로 규정된다.

내부 동작

옵저버는 "저장 프로퍼티에 대한 실제 대입 연산"에 컴파일러가 끼워 넣는 훅이다. 자기 자신의 init 본문에서 프로퍼티에 값을 대입하는 순간은 그 프로퍼티가 아직 완전히 초기화되지 않았을 수 있는 구간을 포함하므로, 컴파일러는 이를 "재설정"이 아니라 "최초 설정"으로 취급해 훅을 건너뛴다. 반면 서브클래스 init에서 super.init()이 반환된 뒤에 상속받은 프로퍼티를 건드리는 건, 그 프로퍼티가 이미 슈퍼클래스 관점에서 초기화를 마친 "재설정"이라 훅이 걸린다.

inout 쪽은 다른 메커니즘이다. 함수 호출부는 프로퍼티 값을 복사해 임시 저장소에 넣고 함수 본문에 그 임시 저장소를 넘긴다. 함수가 무엇을 하든(값을 바꾸든 안 바꾸든) 호출이 끝나는 시점에 임시 저장소의 값을 원래 프로퍼티에 "다시 쓰는" 코드가 반드시 실행된다. 이 write-back 자체가 프로퍼티에 대한 정식 대입이므로 옵저버 훅을 100% 통과한다. SE-0176이 이 모델을 언어 차원에서 formalize해, 그 write-back이 일어나는 동안 같은 메모리에 대한 다른 접근이 겹치지 않도록 배타적 접근(exclusivity)을 강제한다 — 즉 write-back이 "일어날지 말지"가 아니라 "안전하게 일어남을 보장"하는 쪽이 SE-0176의 역할이다.

init 본문 안에서는 옵저버가 잠들어 있다 ① 자기 자신의 init 본문 self.value = v → didSet 안 불림 초기화 완료 시점 ② 초기화 완료 이후 a.value = 20 → didSet 불림 예외: inout으로 넘기면 ①/② 구분 없이 항상 켜진다 copy-in copy-out — 값이 안 바뀌었어도 함수가 끝나며 무조건 다시 써 넣기 때문
자기 자신의 init 안에서는 옵저버가 잠들어 있다가, 초기화가 끝나야 깨어난다. 단 inout 전달은 이 구분과 무관하게 항상 옵저버를 깨운다.

실험 · 도구

아래 두 스니펫을 그대로 swift -e 나 플레이그라운드에서 돌리면 두 함정을 각각 눈으로 확인할 수 있다.

init 안에서는 didSet이 안 불리고, inout으로 넘기면 값이 그대로여도 불린다
class A {
    var value: Int = 0 {
        willSet { print("willSet: \(value) -> \(newValue)") }
        didSet  { print("didSet: \(oldValue) -> \(value)") }
    }
    init(v: Int) {
        self.value = v   // 여기서는 아무것도 출력되지 않는다 (자기 자신의 init 안)
    }
}
let a = A(v: 10)   // 출력 없음
a.value = 20       // "willSet: 10 -> 20" / "didSet: 10 -> 20"

func noop(_ x: inout Int) {
    // 아무 것도 안 바꾼다
}
noop(&a.value)     // 그런데도 "willSet: 20 -> 20" / "didSet: 20 -> 20"이 찍힌다

프로젝트 적용

didSet에 부수효과(저장, 네트워크 호출, UI 갱신)를 넣어 두면, 그 프로퍼티를 inout으로 받는 제네릭 헬퍼(예: swap, &$0 클로저 패턴, mutating 유틸)에 값이 안 바뀌었는데도 넘기기만 해도 그 부수효과가 실행된다. 옵저버 안에 "실제로 값이 바뀌었는지"를 직접 확인하는 방어 코드를 넣어야 안전하다.

didSet에 저장 로직을 둘 땐 반드시 oldValue와 비교한다
struct Settings {
    var theme: String = "dark" {
        didSet {
            // ❌ 이렇게만 쓰면 inout으로 &settings.theme를 넘기기만 해도
            //    값이 그대로인데 저장이 매번 실행된다.
            // persist(theme)

            // ✅ 실제로 바뀐 경우에만 저장한다
            guard theme != oldValue else { return }
            persist(theme)
        }
    }
}

func persist(_ value: String) {
    print("디스크에 저장: \(value)")
}

init 안에서 프로퍼티를 설정하면서도 didSet과 같은 "부가 로직"을 실행하고 싶다면, 옵저버에 기대지 말고 init 마지막에 그 로직을 명시적으로 한 번 호출한다.

init에서 didSet 로직이 필요하면 명시적으로 호출한다
class ProfileViewModel {
    var displayName: String {
        didSet { notifyNameChanged() }
    }

    init(displayName: String) {
        self.displayName = displayName   // didSet 안 불림 — 자기 init 안이므로
        notifyNameChanged()               // ✅ 필요한 로직이면 직접 한 번 불러준다
    }

    private func notifyNameChanged() {
        print("이름 변경 알림: \(displayName)")
    }
}
⚠️ 흔한 오해

"자기 init 안에서 프로퍼티를 대입해도 didSet은 항상 호출된다"는 틀렸다 — 위 실험처럼 완전히 침묵한다. "inout 파라미터로 넘겼는데 값이 실제로 안 바뀌었으면 옵저버가 안 불린다"도 틀렸다 — copy-in copy-out 모델 때문에 함수가 끝나면 무조건 다시 쓰기가 일어나 옵저버가 불린다.

🧒 쉽게 이해하기

집에 보안 경보기(옵저버)를 단다고 생각해 보자. 집을 짓는 중(자기 자신의 init 본문)에 인부들이 가구를 이리저리 옮겨도 경보기는 아직 설치 전이라 울리지 않는다. 집이 완공되고 경보기가 켜진 뒤(초기화 완료 이후)부터는 가구를 옮길 때마다 경보기가 반응한다. 그런데 inout은 조금 다른 이야기다 — 누가 문을 열었다가(값을 복사해 가서) 아무것도 안 바꾸고 그대로 닫아도(다시 써 넣어도), 경보기는 "문이 열렸다 닫혔다"는 사실 자체에 반응하지 "안이 실제로 바뀌었는지"는 신경 쓰지 않는다. 비유가 깨지는 곳: 진짜 경보기는 문이 열리는 동작 자체를 감지하지만, Swift의 옵저버는 물리적 움직임이 아니라 "대입이라는 코드상의 이벤트"에 반응한다는 점이 다르다.

꼬리 질문

서브클래스 init에서 슈퍼클래스 프로퍼티를 super.init() 이후에 설정하면 왜 옵저버가 호출되는가?
super.init()이 반환된 시점에 슈퍼클래스가 정의한 저장 프로퍼티들은 이미 슈퍼클래스 관점에서 초기화가 끝난 상태로 취급된다. 그 뒤 서브클래스 init 본문에서 그 프로퍼티를 다시 대입하는 건 "아직 안 끝난 값의 최초 설정"이 아니라 "이미 완성된 값의 재설정"이므로, 자기 자신의 init 규칙(예외)이 적용되지 않고 정상적으로 옵저버가 걸린다.
쉽게 말하면 부모가 이미 다 지어놓고 넘겨준 집에 자식이 이사 온 뒤 가구를 옮기는 것이므로, 이 시점엔 경보기가 이미 켜져 있다.
직접 정의한 연산 프로퍼티에도 willSet/didSet을 붙일 수 있는가?
일반적으로는 안 된다 — willSet/didSet은 저장 프로퍼티 전용이고, 연산 프로퍼티는 get/set 안에서 원하는 로직을 직접 쓰면 되므로 별도 옵저버가 필요 없다. 유일한 예외는 오버라이드다 — 서브클래스가 상속받은 프로퍼티(원래 정의가 저장이었든 연산이었든 상관없이)에 willSet/didSet을 추가로 얹어 "관찰만" 붙일 수 있다.
쉽게 말하면 내가 처음부터 만드는 계산기(연산 프로퍼티)엔 이미 원하는 로직을 계산기 안에 다 넣을 수 있으니 감시 카메라가 따로 필요 없다. 하지만 남이 만든 계산기를 물려받아 쓸 때는 그 계산기 자체를 못 고치니 옆에 카메라만 추가로 다는 것이다.
옵저버가 붙은 프로퍼티를 중첩된 여러 inout 함수에 연달아 넘기면 몇 번 호출되는가?
함수 경계 하나를 통과할 때마다 그 함수가 끝나는 시점에 한 번씩 write-back이 일어나므로, 옵저버도 함수 호출 경계 수만큼 반복해서 불린다. f(&x.prop) 안에서 다시 g(&x.prop)을 호출한다면(같은 프로퍼티를 중첩 전달), 안쪽 함수가 끝나며 한 번, 바깥 함수가 끝나며 또 한 번 — 총 두 번 옵저버가 호출된다.
쉽게 말하면 문을 열었다 닫는 걸 두 겹으로 하면(안쪽 문, 바깥쪽 문) 경보기는 그때마다 따로 반응한다. 문이 두 번 여닫혔으니 경보기도 두 번 울리는 것이다.

Q3. lazy var가 스레드 세이프하지 않은 이유를 컴파일러 생성 코드 수준에서 설명하라

🔑 30초 답변

공식 문서가 직접 경고한다 — "lazy가 붙은 프로퍼티가 여러 스레드에서 동시에 접근되고 아직 초기화되지 않았다면, 딱 한 번만 초기화된다는 보장이 없다." 내부적으로 lazy var는 옵셔널 백킹 저장소를 두고 "nil이면 초기화식을 평가해서 채우고, 있으면 그대로 반환"하는 단순한 if 분기로 컴파일된다. 이 nil 체크 자체가 원자적이지 않고 어떤 락도 걸려 있지 않다 — static let/전역 상수가 swift_once로 보호받는 것과 정반대다. 두 스레드가 동시에 첫 접근을 하면 초기화식이 중복 실행될 수 있고, 그 경쟁 자체가 정의되지 않은 동작(UB)이다.

원리

"lazy 저장 프로퍼티는 처음 쓰일 때까지 초기값이 계산되지 않는 프로퍼티"다. 초기화 시점이 인스턴스 생성 이후로 미뤄질 수 있으므로 반드시 var로 선언해야 한다(let은 불가). TSPL Properties는 이 지연 초기화에 대해 어떤 동시성 보장도 하지 않는다고 명시한다. 반대로 타입 저장 프로퍼티(static)와 전역 상수/변수는 "여러 스레드에서 동시에 접근돼도 정확히 한 번만 초기화됨을 보장"한다 — lazy 표시가 필요 없을 정도로 이 보장이 기본값이다. static let이 정확히 어떻게 swift_once로 이 보장을 얻는지는 02장에서 다룬다. 여기서는 lazy var가 왜 그 보호를 못 받는지에 집중한다.

내부 동작

인스턴스 lazy var는 옵셔널 백킹 저장소 하나를 만들고, 접근할 때마다 그 저장소를 확인하는 방식으로 컴파일된다. 단순화하면 대략 이런 모양이다.

lazy var가 대략 어떤 모양으로 풀리는지(단순화한 그림 — 실제 심볼명은 다르다)
// 당신이 쓰는 코드
final class Configuration {
    lazy var expensiveValue: Int = computeExpensiveValue()
}

// 컴파일러가 만드는 코드의 '대략적인' 모양
final class Configuration {
    private var _lazyStorage_expensiveValue: Int? = nil

    var expensiveValue: Int {
        get {
            if let value = _lazyStorage_expensiveValue {
                return value           // 이미 있으면 그대로 반환
            }
            let value = computeExpensiveValue()  // 없으면 여기서 계산
            _lazyStorage_expensiveValue = value   // 계산한 값을 저장
            return value
        }
        set { _lazyStorage_expensiveValue = newValue }
    }
}

if let value = ... 체크와 뒤이은 계산·쓰기는 하나의 원자적 연산으로 묶여 있지 않다. 두 스레드가 동시에 처음 접근하면 둘 다 "저장소가 비어 있다"는 걸 보고, 둘 다 computeExpensiveValue()를 실행하고(초기화식에 부수효과가 있다면 그게 중복 실행됨), 둘 다 저장소에 값을 쓴다 — 나중에 쓴 스레드의 값이 최종적으로 남는다. static let/전역이 쓰는 swift_once 같은 원자적 1회성 플래그가 여기엔 전혀 없다. 이 경쟁 자체가 Swift의 배타적 접근 모델상 정의되지 않은 동작이라 Thread Sanitizer가 데이터 레이스로 잡아낸다.

부가 규칙 두 가지: lazy 프로퍼티는 willSet/didSet 옵저버를 가질 수 없고, 반드시 var여야 하므로 let으로는 선언할 수 없다 — 둘 다 "값이 나중에 채워질 수도, 재대입될 수도 있는 슬롯"이라는 lazy의 근본 성질과 충돌하는 조합이라 컴파일러가 막는다.

잠금 없는 nil 체크 — 두 스레드가 동시에 통과한다 스레드 A storage == nil? true → 초기화식 실행 storage = 값 A 스레드 B storage == nil? A가 쓰기 전이라 역시 true storage = 값 B (덮어씀) nil 체크에 잠금이 없다 — static let의 swift_once와 다르다 초기화식이 두 번 실행되고(부수효과 중복), 최종적으로 남는 값은 나중에 쓴 스레드 것 — 이 경쟁 자체가 Swift 메모리 모델상 정의되지 않은 동작(UB)이다
lazy var의 nil 체크·계산·쓰기는 원자적으로 묶여 있지 않아, 동시 첫 접근 시 초기화식이 중복 실행될 수 있다.

실험 · 도구

초기화식에 print를 넣고 여러 스레드에서 동시에 첫 접근을 시도하면, "딱 한 번"이라는 기대가 깨지는 걸 실측할 수 있다(경쟁 상황이라 재현은 확률적이다).

동시 접근으로 lazy var 초기화식이 중복 실행되는지 실측
final class Configuration {
    lazy var expensiveValue: Int = {
        print("계산 시작: \(Thread.current)")
        Thread.sleep(forTimeInterval: 0.01)
        return 42
    }()
}

let config = Configuration()
DispatchQueue.concurrentPerform(iterations: 8) { _ in
    _ = config.expensiveValue
}
// "계산 시작: ..."이 1번이 아니라 여러 번 찍힐 수 있다 (비결정적 재현)

Xcode 스킴 → Diagnostics → Thread Sanitizer를 켜고 위 코드를 실행하면 data race 경고가 뜬다 — TSPL이 "no guarantee"라고 명시한 것을 그대로 실측하는 셈이다. 같은 코드를 static let으로 바꿔 대조하면 "계산 시작"이 정확히 1번만 찍혀 swift_once의 보장을 확인할 수 있다.

프로젝트 적용

여러 스레드에서 동시에 접근할 가능성이 있는 lazy var는 락으로 첫 접근을 직접 보호하거나, 아예 static let 기반 구조로 재설계한다.

동시 접근이 걱정되면 직접 락으로 감싼 지연 초기화 박스를 쓴다
final class LazyBox<Value> {
    private let lock = NSLock()
    private var storage: Value?
    private let make: () -> Value

    init(_ make: @escaping () -> Value) { self.make = make }

    var value: Value {
        lock.lock(); defer { lock.unlock() }
        if let v = storage { return v }
        let v = make()
        storage = v
        return v
    }
}

final class Configuration {
    // 실제로 비싼 계산이 여기 들어간다
    static func computeExpensiveValue() -> Int { (1...1_000_000).reduce(0, +) }

    private let _expensive = LazyBox { Configuration.computeExpensiveValue() }
    var expensiveValue: Int { _expensive.value }   // 이제 동시 접근에도 안전
}

반대로, 접근이 항상 메인 스레드로 한정된다는 걸 알고 있으면(예: UIKit 라이프사이클 콜백 안에서만 접근) lazy var를 그대로 써도 무방하다. 락을 습관적으로 추가하면 불필요한 오버헤드다 — 실제로 동시 접근 가능성이 있는지부터 확인한다.

메인 스레드 전용이라면 lazy var 그대로가 맞다
final class FeedViewController: UIViewController {
    // ✅ viewDidLoad, cellForItemAt 등 모두 메인 스레드에서만 호출되므로
    //    락 없는 lazy var로도 충분하다. 과잉 방어가 오히려 코드만 무겁게 한다.
    lazy var layout: UICollectionViewFlowLayout = {
        let l = UICollectionViewFlowLayout()
        l.minimumLineSpacing = 8
        return l
    }()
}
⚠️ 흔한 오해

"lazy var는 스레드 세이프하다"는 명백히 틀렸다 — TSPL이 직접 "no guarantee... initialized only once"라고 경고한다. "lazy varstatic let처럼 컴파일러가 자동으로 락을 걸어준다"도 틀렸다 — 그 보호는 static/전역에만 있고, 인스턴스 lazy var에는 어떤 동기화도 자동으로 붙지 않는다.

🧒 쉽게 이해하기

친구 여럿이 잠긴 문 앞에 동시에 도착했다고 하자. lazy var 방식은 각자 "어? 아직 안 열려 있네" 하고 확인하고는, 다들 동시에 자기 열쇠로 문을 열기 시작하는 것이다(초기화식을 각자 실행). 문은 결국 열리지만 열쇠질을 여러 번 한 셈이고, 만약 문 여는 과정에서 뭔가를 적어 넣는(부수효과) 작업이었다면 그게 중복으로 일어난다. static let은 반대로, 경비원 한 명만 문을 열 권한이 있고 나머지는 경비원이 열 때까지 얌전히 기다리는 것이다. 비유가 깨지는 곳: 실제 문은 한 번 열리면 그걸로 끝이지만, 코드에서는 두 친구가 "각자 다른 값"으로 문 뒤 내용물을 채워 넣을 수 있어서 최종 결과가 어느 쪽 열쇠가 나중에 돌았느냐에 따라 달라진다.

꼬리 질문

static let은 왜 안전한데 lazy var는 왜 안전하지 않은가?
static let과 전역 상수는 컴파일러가 "globalinit 토큰"과 outlined 초기화 함수를 만들고 그 안에서 swift_once를 호출해, 초기화 블록이 정확히 한 번만 그리고 모든 스레드에 안전하게 보이도록(happens-before) 보장한다. 인스턴스 lazy var는 이런 토큰·원자적 플래그가 전혀 생성되지 않고, 단순 옵셔널 nil 체크로만 구현된다. 이 메커니즘 차이는 02장에서 swift_once 관점으로 더 깊이 다룬다.
쉽게 말하면 static let엔 전용 경비원(swift_once)이 붙어 있고 lazy var엔 없다.
lazy varwillSet/didSet 옵저버를 가질 수 없는 이유는?
lazy는 "첫 접근 시점까지 값 설정을 미루고, 그 이후엔 일반 저장 프로퍼티처럼 재대입도 가능한" 슬롯이다. 옵저버는 "이미 값이 있는 저장 프로퍼티가 재설정될 때" 훅을 거는 기능인데, lazy의 최초 계산이 옵저버 관점에서 "최초 설정"인지 "재설정"인지가 애매해지고, 지연 계산과 옵저버 타이밍이 서로 다른 전제를 요구하기 때문에 컴파일러가 이 조합 자체를 막는다.
쉽게 말하면 "언제 채워질지 모르는 서랍"과 "채워질 때마다 사진 찍는 카메라"는 서로 타이밍이 안 맞아서 같이 못 쓰게 막아둔 것이다.
let으로 선언된 구조체 인스턴스의 lazy var에 처음 접근하면 왜 컴파일 에러가 나는가?
구조체는 값 타입이라 lazy var의 첫 접근(백킹 저장소에 값을 쓰는 것)이 곧 그 인스턴스 자체를 변경하는 mutating 동작이다. 인스턴스가 let으로 선언돼 있으면 애초에 mutating 동작을 허용하지 않으므로, 아직 계산되지 않은 lazy 프로퍼티를 읽으려는 시도 자체가 "let 인스턴스를 변경하려 한다"는 컴파일 에러로 막힌다. 클래스는 참조 타입이라 인스턴스 자체(참조)를 안 바꾸고 내부 저장소만 바꾸면 되므로 이 문제가 없다.
쉽게 말하면 구조체의 lazy 프로퍼티를 처음 읽는 건 사실 그 구조체 안에 뭔가를 "새로 써 넣는" 행동이라, 애초에 못 고치게 잠가둔(let) 구조체에는 그 행동 자체가 금지된다.

Q4. 프로퍼티 래퍼(SE-0258)는 어떤 코드로 풀리는가? — _foo, $foo, projectedValue

🔑 30초 답변

@Wrapper var foo = 1738은 컴파일러가 세 가지를 만들어낸다: (1) 실제 값을 들고 있는 private var _foo: Wrapper<Int> 백킹 저장소, (2) _foo.wrappedValue를 그대로 전달하는 get/set을 가진 계산 프로퍼티 foo, (3) wrapper가 projectedValue를 정의했다면 그걸 노출하는 $foo. 래퍼를 여러 개 쌓으면 먼저 쓴 것이 바깥, 나중 쓴 것이 안쪽으로 중첩되며(비교환적), SE-0293은 이 메커니즘을 함수/클로저 파라미터에도 확장해 파라미터의 실제 타입과 ABI를 바꿔버린다.

원리

SE-0258은 이 desugar 규칙을 명세한다. 백킹 저장 프로퍼티는 관례상 _ 접두사가 붙고 self._foo로 직접 접근/조작할 수 있다. 초기값(= 1738)이 있으면 그 wrapper의 init(wrappedValue:)로 전달된다. 프로젝션의 접근 수준은 항상 원본 프로퍼티와 동일하다. SE-0293은 함수/클로저 파라미터에도 래퍼를 붙일 수 있게 확장했다 — 이때 파라미터의 실제 타입이 바뀌고 그 변경이 ABI에 반영되며, 호출자 쪽 컴파일 방식도 그 타입에 맞춰 인자를 넘기도록 바뀐다.

내부 동작

기본 desugar는 다음과 같다.

@Wrapper var foo = 1738 — 당신이 쓰는 코드와 컴파일러가 만드는 코드
// 당신이 쓰는 코드
@Wrapper var foo = 1738

// 컴파일러가 생성하는 코드
private var _foo: Wrapper<Int> = Wrapper<Int>(wrappedValue: 1738)
var foo: Int {
    get { return _foo.wrappedValue }
    set { _foo.wrappedValue = newValue }
}
// wrapper가 projectedValue를 정의했다면 추가로:
var $foo: Wrapper<Int>.Projection {
    get { _foo.projectedValue }
}

래퍼를 두 개 이상 쌓으면(@DelayedMutable @Copying var path: UIBezierPath) 먼저 쓴 래퍼가 바깥, 나중 쓴 래퍼가 안쪽으로 중첩된다 — _path: DelayedMutable<Copying<UIBezierPath>>가 되고, foo의 get/set은 _path.wrappedValue.wrappedValue처럼 두 단계를 순차로 거친다. 이 합성은 "교환적(commutative)이지 않다" — @A @B var x@B @A var x는 서로 다른 중첩 타입이자 다른 실행 순서를 만든다. 각 레벨이 init(wrappedValue:)를 제공해야 하며, 초기화는 각 레벨을 순서대로 감싸며 진행된다.

SE-0293이 도입한 파라미터 래퍼는 이런 모양이다.

함수 파라미터에 붙은 프로퍼티 래퍼 (SE-0293)
@propertyWrapper
struct Clamped<Value: Comparable> {
    private let range: ClosedRange<Value>
    var wrappedValue: Value {
        didSet { wrappedValue = min(max(wrappedValue, range.lowerBound), range.upperBound) }
    }
    init(wrappedValue: Value, _ range: ClosedRange<Value>) {
        self.range = range
        self.wrappedValue = min(max(wrappedValue, range.lowerBound), range.upperBound)
    }
}

func setHealth(@Clamped(0...100) _ value: Int) {
    print(value)   // 호출부가 무슨 값을 넘기든 이미 0...100으로 잘려 들어온다
}

setHealth(150)   // 함수 안에서 value는 100
프로퍼티 래퍼 desugaring — before / after 당신이 쓰는 코드 @Wrapper var foo = 10 컴파일러 컴파일러가 생성하는 코드 private var _foo: Wrapper<Int> var foo: Int { get / set } _foo.wrappedValue를 그대로 전달 var $foo { _foo.projectedValue } 합성(composition) @A @B var x — 먼저 쓴 A가 바깥, 나중 쓴 B가 안쪽으로 중첩된다 _x: A<B<Value>>
선언 한 줄이 백킹 저장 프로퍼티(_foo), 전달용 연산 프로퍼티(foo), 프로젝션($foo) 세 가지로 풀린다. 래퍼를 여러 개 쌓으면 먼저 쓴 것이 바깥에서 나중 것을 감싼다.

실험 · 도구

매크로와 달리 프로퍼티 래퍼는 Xcode의 "Expand Macro" 메뉴로는 안 보인다. 대신 swiftc -emit-silgen으로 desugar된 _foo/foo/$foo 선언을 직접 확인한다.

프로퍼티 래퍼가 실제로 어떤 심볼로 풀리는지 SILGen에서 확인
// 터미널에서
// swiftc -emit-silgen MyFile.swift | grep -E "_foo|\\$foo|foo"
// 로 _foo(백킹 저장소), foo(get/set), $foo(프로젝션) 세 심볼이
// 각각 별도 접근자로 컴파일되는 걸 확인할 수 있다.

@propertyWrapper
struct Loud<Value> {
    private var value: Value
    var wrappedValue: Value {
        get { value }
        set { value = newValue; print("바뀜: \(value)") }
    }
    var projectedValue: String { "Loud(\(value))" }
    init(wrappedValue: Value) { value = wrappedValue }
}

struct Demo {
    @Loud var count = 0
}
var d = Demo()
d.count += 1      // "바뀜: 1"
print(d.$count)   // "Loud(1)" — $count는 projectedValue를 그대로 반환

프로젝트 적용

래퍼 타입 자체가 제공하는 부가 API(예: 유효 범위, 리셋 메서드)가 필요할 때는 foo가 아니라 백킹 저장소 _foo에 직접 접근한다.

_foo로 래퍼 자체의 API에 접근하기
@propertyWrapper
struct Clamped<Value: Comparable> {
    let range: ClosedRange<Value>
    var wrappedValue: Value {
        didSet { wrappedValue = min(max(wrappedValue, range.lowerBound), range.upperBound) }
    }
    init(wrappedValue: Value, _ range: ClosedRange<Value>) {
        self.range = range
        self.wrappedValue = min(max(wrappedValue, range.lowerBound), range.upperBound)
    }
}

struct Player {
    @Clamped(0...100) var health = 100

    // _health 는 컴파일러가 만들어주는 저장 프로퍼티이고 항상 private 이다.
    // 그래서 래퍼 자체의 API(range 등)는 '타입 안에서만' 꺼낼 수 있다.
    var healthRange: ClosedRange<Int> { _health.range }
}

var p = Player()
p.health -= 200
print(p.health)        // 0   — didSet 이 범위로 잘라냈다
print(p.healthRange)   // 0...100

// print(p._health.range)
// ❌ error: &#x27;_health&#x27; is inaccessible due to &#x27;private&#x27; protection level
//    바깥에서 래퍼 본체에 닿고 싶으면 projectedValue($health)를 열어줘야 한다.

래퍼를 합성할 땐 순서가 결과를 바꾼다는 걸 반드시 확인한다. 문자열을 다듬는 두 래퍼를 반대 순서로 붙이면 결과가 달라진다.

합성 순서를 바꾸면 결과가 달라진다 — 비교환적
@propertyWrapper struct Trimmed {
    private var v = ""
    var wrappedValue: String {
        get { v }
        set { v = newValue.trimmingCharacters(in: .whitespaces) }
    }
    init(wrappedValue: String) { self.wrappedValue = wrappedValue }
}

// ❌ 같은 타입(String)을 다루는 비제네릭 래퍼 둘은 애초에 합성되지 않는다
// @propertyWrapper struct Capitalized { var wrappedValue: String ... }
// struct A { @Trimmed @Capitalized var name: String }
// error: composed wrapper type &#x27;Capitalized&#x27; does not match type of
//        &#x27;Trimmed.wrappedValue&#x27;, which is &#x27;String&#x27;

// ✅ 바깥 래퍼가 '제네릭'이어야 안쪽 래퍼를 값으로 감쌀 수 있다
@propertyWrapper struct Logged<Value> {
    private var v: Value
    var wrappedValue: Value {
        get { v }
        set { print("Logged가 받은 값:", newValue); v = newValue }
    }
    init(wrappedValue: Value) { v = wrappedValue }
}

struct Form {
    @Logged @Trimmed var name: String = "  hyosung  "
}

var f = Form()
print("[\(f.name)]")     // [hyosung]  — 읽을 때는 안쪽까지 자동으로 이어진다
f.name = "  park "
// 출력: Logged가 받은 값: Trimmed(v: "park")
print("[\(f.name)]")     // [park]

// 핵심: 바깥 래퍼가 보는 값은 String이 아니라 '안쪽 래퍼 그 자체'다.
// @Logged @Trimmed 는 Logged&lt;Trimmed&gt; 이고, 순서를 바꾸면 감싸는 방향이
// 통째로 뒤집히므로 합성 순서는 결과와 타입 양쪽을 바꾼다.
⚠️ 흔한 오해

"$foo는 항상 Binding이다"는 틀렸다 — $foo의 타입은 그 wrapper가 정의한 projectedValue의 타입일 뿐이고, Binding@State가 그런 프로젝션을 정의했기 때문에 나오는 특수 사례다. "프로퍼티 래퍼는 SwiftUI 전용 기능이다"도 틀렸다 — SE-0258은 SwiftUI와 무관하게 일반 언어 기능으로 제안·구현됐고, SwiftUI가 그걸 대표적으로 활용할 뿐이다.

🧒 쉽게 이해하기

프로퍼티 래퍼는 선물 포장지 같은 것이다. 진짜 선물(원래 값)은 포장지 안(_foo)에 들어 있고, "foo"라고 부르면 포장지에 뚫린 작은 구멍으로 선물을 바로 넣었다 뺐다 할 수 있다(get/set). "$foo"는 포장지 겉면에 붙은 특별한 창문 같은 것으로, 선물 자체가 아니라 포장지가 따로 보여주고 싶은 정보(예: 포장 방식)를 보여준다. 포장지를 여러 겹 씌우면(래퍼 합성) 상자 안에 상자가 들어 있는 모양이 되는데, 어느 걸 먼저 씌우느냐에 따라 선물이 처음 마주치는 포장이 달라진다. 비유가 깨지는 곳: 실제 포장지는 순서를 바꿔도 부피만 조금 다를 뿐이지만, 코드에서는 순서가 바뀌면 검증 로직이 적용되는 순서와 최종 결과 자체가 달라질 수 있다.

꼬리 질문

함수 파라미터에 프로퍼티 래퍼(SE-0293)를 붙이면 ABI에 어떤 영향이 있는가?
파라미터의 실제 타입 자체가 바뀐다 — 소스 코드에서는 Int를 넘기는 것처럼 보여도, 컴파일된 함수 시그니처는 래퍼 타입(또는 그 초기화 경로)을 반영하도록 바뀌고 호출자 쪽 코드 생성도 그에 맞춰 조정된다. 그래서 이 기능은 단순한 소스 레벨 설탕이 아니라 ABI에 실제로 반영되는 변경이다.
쉽게 말하면 겉보기엔 그냥 숫자를 건네는 것 같아도, 실제로는 "포장된 채로" 건네지고 함수 쪽에서 그 포장을 풀도록 약속이 바뀐다는 뜻이다.
$foo가 아예 생기지 않는 경우는 언제인가?
wrapper 타입이 projectedValue를 정의하지 않으면 $foo 자체가 합성되지 않는다. 이 상태에서 obj.$foo를 참조하면 "그런 멤버가 없다"는 컴파일 에러가 난다 — $foo는 wrapper가 선택적으로 제공하는 부가 기능이지 모든 프로퍼티 래퍼가 자동으로 갖는 필수 요소가 아니다.
쉽게 말하면 포장지에 특별한 창문을 만들지 않았다면, 그 창문으로 들여다보려 해도 애초에 창문이 없다.
여러 래퍼를 합성했을 때 각 레벨의 초기화는 어떤 순서로 일어나는가?
각 래퍼가 자신만의 init(wrappedValue:)를 제공해야 하고, 초기화는 선언 순서를 따라 바깥 레벨부터 안쪽 레벨을 감싸며 진행된다 — 예컨대 @A @B var x = v라면 먼저 B(wrappedValue: v)가 만들어지고, 그 결과가 다시 A(wrappedValue:)의 인자로 들어가는 식으로 중첩 초기화가 일어난다.
쉽게 말하면 선물을 먼저 속 포장지로 감싼 다음, 그걸 통째로 다시 겉 포장지로 감싸는 순서다.

Q5. KeyPath / WritableKeyPath / ReferenceWritableKeyPath는 무엇이 다르고 어디에 쓰는가?

🔑 30초 답변

계층은 AnyKeyPath(루트·값 둘 다 타입 소거) > PartialKeyPath<Root>(값만 소거) > KeyPath<Root, Value>(읽기 전용) > WritableKeyPath<Root, Value>(값 타입에 대해 in-place 쓰기, 루트가 var여야 함) > ReferenceWritableKeyPath<Root, Value>(클래스 전용, WritableKeyPath의 서브타입) 순이다. ReferenceWritableKeyPath가 따로 필요한 이유는 딱 하나다 — 참조 타입은 "가리키는 대상"과 "그 가리키는 자리(변수)"가 분리돼 있어서, 변수 자체는 let이어도 가리키는 인스턴스의 저장 프로퍼티는 바꿀 수 있기 때문이다. SE-0249\.name 같은 key path 리터럴을 (Root) -> Value 함수가 기대되는 자리에 그대로 쓸 수 있게 해, users.map(\.name) 같은 코드를 가능하게 한다.

원리

Key path는 런타임 값이 아니라 "루트 타입에서 값 타입으로 가는 경로"를 나타내는 1급 값(first-class value)으로, subscript(keyPath:)를 통해 나중에 적용할 수 있다. KeyPath는 읽기 전용, WritableKeyPath는 값 타입에 대해 inout을 통한 in-place mutation을 지원하도록 설계돼 있고(Q1의 _modify와 같은 계열의 메커니즘과 맞닿아 있다), ReferenceWritableKeyPath는 참조 타입 프로퍼티를 let 컨텍스트에서도 안전하게 바꿀 수 있게 해준다.

내부 동작

대상 프로퍼티 자체가 let으로 선언돼 있으면, 루트가 무엇이든 그 프로퍼티에 대한 key path는 애초에 WritableKeyPath가 될 수 없다 — 계층 구조상 KeyPath(읽기 전용)에 머문다. 반면 프로퍼티 자체는 var인데 루트 타입이 구조체(값 타입)라면, 그 key path로 실제 쓰기를 하려면 루트를 담은 변수가 var여야 한다 — 값 타입의 in-place 수정은 결국 그 변수 자체에 대한 inout 접근이기 때문이다. 클래스는 다르다. let obj: SomeClass처럼 참조 변수 자체가 let이어도, obj가 가리키는 인스턴스의 저장 프로퍼티(var로 선언된)는 바꿀 수 있다 — obj라는 "참조 슬롯"은 안 바뀌고 그 슬롯이 가리키는 힙 객체 내부만 바뀌기 때문이다. ReferenceWritableKeyPath는 바로 이 케이스, 즉 "루트 바인딩이 let이어도 쓰기가 되는" 경우를 표현하기 위해 WritableKeyPath의 서브타입으로 따로 존재한다.

값 타입 root는 var가 있어야 쓰기가 되고, 참조 타입 root는 let이어도 된다
struct Point { var x: Int; var y: Int }
final class Counter { var count: Int = 0 }

let wp: WritableKeyPath<Point, Int> = \Point.x
var p = Point(x: 1, y: 2)          // var여야 쓰기 가능
p[keyPath: wp] = 10                 // OK

let rwp: ReferenceWritableKeyPath<Counter, Int> = \Counter.count
let c = Counter()                   // c 자체는 let!
c[keyPath: rwp] = 5                 // OK — c라는 참조는 안 바뀌고 count만 바뀐다
Key Path 계층 — 아래로 갈수록 할 수 있는 일이 늘어난다 AnyKeyPath — 루트·값 둘 다 소거 PartialKeyPath<Root> — 값만 소거 KeyPath<Root, Value> — 읽기 전용 WritableKeyPath<Root, Value> 값 타입 root — var 바인딩이 있어야 실제로 쓸 수 있다 ReferenceWritableKeyPath<Root, Value> 클래스 root — let 바인딩으로도 내부 값은 바꿀 수 있다 단, 대상 프로퍼티 자체가 let이면 root가 무엇이든 WritableKeyPath가 만들어지지 않는다
계층 아래로 갈수록 가능한 연산이 늘어난다. ReferenceWritableKeyPath는 root가 참조 타입일 때, let로 들고 있어도 내부 저장 프로퍼티를 바꿀 수 있게 해준다.

실험 · 도구

let 프로퍼티에 대한 key path를 WritableKeyPath 타입으로 명시해 보면 컴파일러가 그 자리에서 거절하는 걸 확인할 수 있다.

let 프로퍼티는 WritableKeyPath가 될 수 없음을 직접 확인
struct Point {
    var x: Int
    let y: Int   // let!
}

let kx: KeyPath<Point, Int> = \.x          // OK — 읽기 전용이면 항상 성립
let wx: WritableKeyPath<Point, Int> = \.x  // OK — x는 var라서 쓰기 가능

// let wy: WritableKeyPath<Point, Int> = \.y
// ❌ 컴파일 에러: y는 let이라 WritableKeyPath 자체를 만들 수 없다

프로젝트 적용

필드마다 비교 클로저를 새로 쓰는 대신, key path 하나로 제네릭 정렬/조회 유틸을 만든다. SE-0249 덕분에 key path 리터럴을 함수처럼 바로 넘길 수 있다.

key path로 제네릭 정렬/매핑 유틸 만들기
struct User { let name: String; var age: Int }

func sorted<T, V: Comparable>(_ items: [T], by keyPath: KeyPath<T, V>) -> [T] {
    items.sorted { $0[keyPath: keyPath] < $1[keyPath: keyPath] }
}

let users = [User(name: "B", age: 30), User(name: "A", age: 20)]
let byAge = sorted(users, by: \.age)     // 제네릭 정렬 — 필드마다 새 함수 필요 없음
let names = users.map(\.name)             // SE-0249 — key path 리터럴을 함수처럼 바로 전달

클래스 기반 뷰 모델에서 "이 key path가 가리키는 값을 갱신해 달라"는 제네릭 바인딩 API를 만들 때 ReferenceWritableKeyPath가 정확히 필요한 지점이다 — 뷰 모델을 let으로 들고 있어도 내부 프로퍼티를 갱신할 수 있다.

ReferenceWritableKeyPath로 let 뷰모델의 내부 값 갱신하기
final class ProfileViewModel {
    var displayName: String = ""
    var age: Int = 0
}

func bind<Root: AnyObject, Value>(
    _ root: Root,
    _ keyPath: ReferenceWritableKeyPath<Root, Value>,
    to value: Value
) {
    root[keyPath: keyPath] = value   // root가 let이어도 내부는 바뀐다
}

let vm = ProfileViewModel()          // let — 하지만 참조 타입이라 문제 없음
bind(vm, \.displayName, to: "효성")
bind(vm, \.age, to: 30)
⚠️ 흔한 오해

"모든 key path는 쓰기가 가능하다"는 틀렸다 — 계층 구조상 KeyPath는 읽기 전용이고, let 프로퍼티에 대한 key path는 WritableKeyPath가 될 수 없다. "key path는 그냥 문자열 기반 리플렉션이다"도 틀렸다 — 컴파일 타임에 타입 체크되는 값이며 리플렉션과는 무관하다.

🧒 쉽게 이해하기

key path는 큰 상자(Root) 안에서 작은 서랍(Value)까지 가는 길을 적어 둔 쪽지다. 읽기 전용 쪽지(KeyPath)는 "이 길로 가면 서랍 안을 볼 수 있다"만 알려준다. 쓰기 가능한 쪽지(WritableKeyPath)는 "이 길로 가서 서랍 내용물을 바꿔도 된다"까지 알려주는데, 상자 자체를 내 손에 쥐고 있어야(var) 실제로 바꿀 수 있다. 그런데 어떤 상자는 "사물함"(클래스) 같아서, 사물함 위치 자체는 고정(let)이어도 안에 든 물건은 얼마든지 바꿔치기할 수 있다 — 그게 ReferenceWritableKeyPath다. 비유가 깨지는 곳: 쪽지 자체는 아무것도 하지 않는다. 실제로 상자를 열고 닫는 동작은 나중에 그 쪽지를 [keyPath:]로 "적용"할 때만 일어난다.

꼬리 질문

\.name을 함수처럼 쓰는 SE-0249 변환은 왜 key path 리터럴에만 적용되는가?
이 변환은 컴파일러가 "함수 타입이 기대되는 문맥에서 key path 리터럴 표현식을 만났을 때"에 한해 그 자리에서 특별히 처리하는 문법적 변환이다. 변수에 이미 담겨 있는 KeyPath 값 자체가 일반적으로 함수 타입에 자동으로 준수(conform)하도록 언어가 바뀐 게 아니라서, let kp = \User.email; users.map(kp)처럼 리터럴이 아니라 변수를 통해 넘기는 경우는 이 변환의 적용 범위 밖일 수 있다 — 리터럴 표현식 자리에서만 확실히 동작한다.
쉽게 말하면 "지금 이 자리에 바로 쓴 쪽지"는 컴파일러가 알아서 척척 바꿔주지만, "미리 써서 서랍에 넣어둔 쪽지"까지 자동으로 알아채 주는 건 아니라는 뜻이다.
PartialKeyPathAnyKeyPath는 실제로 언제 쓰는가?
PartialKeyPath<Root>는 루트 타입은 알지만 값 타입을 지운 경우에 쓴다 — 예컨대 한 타입의 여러 프로퍼티를 이기종 목록([PartialKeyPath<User>])으로 순회하며 필드별로 다른 처리를 할 때. AnyKeyPath는 루트·값 모두 타입 소거가 필요한, 훨씬 더 제네릭한 순회·리플렉션 스타일 코드에 쓰이며 실제 값을 꺼내려면 런타임 캐스팅이 필요하다.
쉽게 말하면 PartialKeyPath는 "이 상자 안 어딘가로 가는 길인 건 아는데 뭐가 들었는지는 모르는 쪽지"고, AnyKeyPath는 "어느 상자로 가는 길인지조차 모르는, 완전히 포장된 쪽지"다.
배열 자체가 let이면 그 원소 프로퍼티는 왜 WritableKeyPath로도 못 바꾸는가?
let array: [Point]처럼 배열 변수 자체가 let이면, array[0]에 대한 in-place 수정은 결국 array라는 변수에 대한 inout 접근을 필요로 하는데 let 변수는 inout으로 넘길 수 없다. 그래서 원소 타입의 프로퍼티가 아무리 var이고 그 key path가 WritableKeyPath여도, array[0][keyPath: wp] = 5 같은 코드는 arrayvar일 때만 컴파일된다.
쉽게 말하면 서랍 자체는 열 수 있어도, 그 서랍이 들어 있는 캐비닛 전체가 못 박혀 있으면(let) 서랍을 꺼내 내용물을 바꿔치기할 방법이 없다.

출처 · 참고자료