옵셔널은 매일 타이핑하는 문법이라 "nil을 안전하게 다루는 설탕(sugar)" 정도로만 알고 넘어가기 쉽다. 이 챕터는 그 표면 아래로 내려간다. Optional<Wrapped>가 정말로 무엇으로 구현돼 있는지, 체이닝이 왜 겹겹이 쌓이지 않는지, Int?는 1바이트가 아니라 9바이트로 자라는데 String?는 왜 한 바이트도 안 늘어나는지, 그리고 ! 한 글자를 눌렀을 때 정말로 무슨 일이 일어나는지를 다룬다. 마지막 질문은 "force unwrap을 언제 써도 되는가"라는, 실무에서 가장 자주 논쟁하는 지점이다.
Q1. Optional<Wrapped>는 실제로 무엇으로 구현돼 있는가? 옵셔널 체이닝은 왜 중첩되지 않는가?
Optional은 표준 라이브러리에 정의된 평범한 enum이다 — case none과 case some(Wrapped) 두 케이스뿐이고, Int?는 Optional<Int>의 문법 설탕(syntactic sugar)일 뿐이다. 특수 컴파일러 마법이 아니라 associated value를 가진 일반 enum이므로 switch로 직접 .some/.none을 매칭할 수도 있다. 옵셔널 체이닝(a?.b?.c)은 언어 문법 차원에서 "전체 체인을 하나의 표현식"으로 다뤄, 중간에 몇 단계를 거치든 결과를 정확히 한 겹만 씌운다. 그래서 Int 프로퍼티를 3단 체이닝으로 읽어도 결과는 Int???가 아니라 Int?다.
원리
Swift 공식 언어 가이드는 옵셔널을 "값이 있거나, 아예 없거나(either there is a value... or there isn't a value at all)"를 표현하는 타입으로 정의하며, 그 실체가 .some/.none 두 케이스의 enum임을 명시한다. 옵셔널 체이닝은 "옵셔널 위에서 프로퍼티·메서드·서브스크립트를 조회"하는 문법으로, 실패하면 조용히 nil을 돌려주는 graceful failure를 강제 언래핑(!)의 즉시 크래시와 대비시킨다. 언어 가이드는 여기서 결정적인 규칙 하나를 명시적으로 적어둔다: 체이닝 결과는 원래 타입이 비옵셔널이어도 항상 옵셔널이 되지만, 여러 단계를 이어 붙여도 옵셔널이 겹으로 쌓이지 않는다. 이건 우연이 아니라 언어 스펙이 보장하는 동작이다.
내부 동작
비교 대상을 두면 이 "평탄화가 왜 특별한가"가 또렷해진다. 만약 a?.b?.c가 정말로 a에 .map { $0.b }를, 그 결과에 다시 .map { $0.c }를 반복 적용하는 것과 같았다면 — b, c 자체가 옵셔널 프로퍼티인 이상 매 단계마다 map이 클로저의 리턴값(그 자체로 옵셔널)을 다시 한 겹 감싸버려서, 3단 체이닝 끝에는 Int???가 나와야 정상이다. 하지만 실제로는 그렇게 동작하지 않는다. 컴파일러는 a?.b?.c 전체를 하나의 옵셔널 체이닝 표현식으로 파싱해, 중간에 nil을 만나는 순간 전체 표현식을 즉시 nil로 short-circuit 하고, 마지막에 딱 한 번만 옵셔널로 감싼다 — 여러 단계의 if let을 중첩한 것과 동치인 셈이다. 이는 함수형 언어의 용어로 말하면 map(functor, 결과를 그대로 다시 감쌈)이 아니라 flatMap/모나드 bind(결과를 평탄화)에 해당하는 의미론을 언어 문법 차원에서 내장한 것이다. Optional 스스로도 map과 flatMap을 표준 라이브러리 메서드로 노출해 이 차이를 직접 확인할 수 있게 해준다: 변환 클로저가 옵셔널을 반환하면 map은 T??를, flatMap은 그걸 평탄화한 T?를 돌려준다.
실험 · 도구
체이닝 결과 타입을 type(of:)로 직접 찍어보면 평탄화를 눈으로 확인할 수 있다.
class C { var value: Int = 1 }
class B { var c: C? = C() }
class A { var b: B? = B() }
let a: A? = A()
let r = a?.b?.c?.value
print(type(of: r)) // Optional<Int> (Int??? 아님)
// map과 flatMap의 차이로 "체이닝이 왜 특별한지"를 직접 대조
let opt: Int? = 3
let mapped = opt.map { Optional($0 * 2) } // Int?? — map은 결과를 그대로 다시 감싼다
let flat = opt.flatMap { Optional($0 * 2) } // Int? — flatMap은 평탄화한다
print(type(of: mapped), type(of: flat))
// 출력(Swift 6.2.1 실측): Optional<Int> Optional<Optional<Int>> Optional<Int>이 스크립트는 swift chain_test.swift로 바로 돌려볼 수 있다. swiftc -emit-sil로 체이닝이 포함된 함수의 SIL을 뽑아 bind_optional/select_enum 계열 명령이 중간 단계마다 반복되고, 마지막에만 inject_enum_addr(다시 옵셔널로 감싸기)이 한 번 나오는 것을 대조해볼 수 있다(정확한 opcode는 컴파일러 버전마다 다를 수 있으니 로컬에서 직접 확인하는 걸 권장).
프로젝트 적용
① 깊게 중첩된 옵셔널 프로퍼티 접근에는 체이닝이 guard let을 5단으로 쌓는 것보다 훨씬 읽기 좋다. 다만 체이닝이 실패한 지점을 구분해야 하는 로직에는 맞지 않는다 — 어디서 nil이 났는지는 알려주지 않기 때문이다.
struct Response { var user: User? }
struct User { var profile: Profile? }
struct Profile { var nickname: String? }
// ✅ "있으면 쓰고 없으면 그만"일 때는 체이닝이 짧고 명확하다
func nickname(from response: Response) -> String? {
response.user?.profile?.nickname
}
// ✅ 어느 단계에서 비었는지 로그로 구분해야 한다면 단계별로 나눈다
func nicknameWithDiagnostics(from response: Response) -> String? {
guard let user = response.user else {
print("user가 없음"); return nil
}
guard let profile = user.profile else {
print("profile이 없음"); return nil
}
return profile.nickname
}② 변환 클로저가 옵셔널을 반환하는 상황에서는 map 대신 flatMap을 골라야 이중 옵셔널을 피한다. URL(string:)처럼 실패 가능한 이니셜라이저를 옵셔널 위에서 호출할 때 흔히 마주친다.
let rawURLString: String? = fetchRawURLString()
// ❌ map: URL(string:)도 실패 가능(옵셔널 반환)이라 결과가 URL??로 이중 래핑된다
let maybeURLDoubled = rawURLString.map { URL(string: $0) } // URL??
// ✅ flatMap: 한 겹으로 평탄화되어 옵셔널 바인딩이 그대로 먹힌다
let maybeURL = rawURLString.flatMap { URL(string: $0) } // URL?
guard let url = maybeURL else { return }"체이닝을 여러 번 이으면 옵셔널이 겹겹이 쌓인다"는 틀렸다 — 이건 map을 반복 호출했을 때나 생기는 현상이고, 언어 문법으로서의 옵셔널 체이닝(?.)은 항상 한 겹으로 평탄화된다. 또 "리턴 타입이 없는 메서드는 체이닝해도 아무 값도 안 나온다"도 부정확하다 — Void를 리턴하는 메서드도 체이닝을 거치면 Void?가 되어, != nil 비교로 그 호출이 실제로 실행됐는지(수신자가 nil이 아니었는지) 확인할 수 있다.
택배 상자 안에 상자가 들어있고, 그 안에 또 상자가 들어있는 러시아 인형을 상상해보자. 보통이라면 상자를 열 때마다 "안에 상자가 또 있을 수도 있음"이라는 딱지가 하나씩 늘어야 할 것 같다. 그런데 옵셔널 체이닝은 다르다. a?.b?.c라고 쓰면, "이 전체 과정을 한 번에 열어보고, 중간에 상자가 비어있으면(nil) 거기서 바로 멈춰서 '없음' 딱지 하나만 붙인다"는 규칙이다. 그래서 세 번을 열든 다섯 번을 열든 마지막에 붙는 딱지는 딱 하나다. 비유가 깨지는 곳: 스스로 .map을 여러 번 손으로 반복하면 진짜로 딱지가 겹겹이 쌓인다(Int???) — 이건 옵셔널 체이닝 문법(?.)만의 특별한 배려이지, 옵셔널을 다루는 모든 방법이 자동으로 평탄화되는 건 아니다.
꼬리 질문
a?.b?.c에서 b가 side effect가 있는 연산 프로퍼티라면 언제 평가되는가?
a가 nil이면 b의 getter는 아예 호출되지 않는다. a가 값을 갖고 있어야만 b의 getter가 실행되고, 그 결과가 nil이면 역시 c는 평가되지 않는다. 디버깅할 때 이 점을 놓치면 "분명 코드에 있는 side effect가 왜 실행이 안 됐지"라는 혼란으로 이어지기 쉽다.서브스크립트를 체이닝할 때 ?의 위치가 왜 앞뒤로 달라지는가?
?를 쓴다(john.residence?[0] — residence가 옵셔널이라서). 반대로 서브스크립트 자체가 옵셔널을 반환할 때는 대괄호 뒤에 ?를 쓴다(testScores["Dave"]?[0] — 딕셔너리 서브스크립트 자체가 옵셔널을 돌려주므로). 즉 ?는 항상 "지금 막 확인이 필요한 옵셔널 값" 바로 뒤에 붙는다는 원칙은 동일하다.옵셔널 체이닝과 as? 캐스팅, try?는 왜 같은 "평탄화 철학"으로 묶이는가?
as?는 옵셔널을 다른 타입으로 캐스팅할 때 중첩 옵셔널까지 투명하게 풀어내도록 문서화돼 있고, try?도 Swift 5.0(SE-0230)부터 원래 옵셔널을 리턴하는 함수에 적용해도 T??가 아니라 평탄화된 T?를 돌려주도록 바뀌었다. 옵셔널 체이닝이 이 철학을 언어 문법 차원에서 가장 먼저, 가장 일관되게 구현한 사례라고 볼 수 있다.Q2. MemoryLayout<Int?>.size는 9인데 String?은 왜 오버헤드가 0인가? — spare bit / extra inhabitant
Optional<T>가 T보다 커지느냐 마느냐는 순전히 T의 비트 표현에 이미 "쓸모없는(무효한) 비트 패턴"이 남아있느냐에 달려 있다. Int는 8바이트의 모든 비트 패턴이 유효한 정수 값이라 여분이 전혀 없고(extra inhabitant 0개), 그래서 .none을 표현할 별도 태그 바이트가 붙어 Int?는 9바이트가 된다(정렬 때문에 stride는 16). 반면 클래스 참조는 포인터가 8바이트 정렬돼 있어 NULL(0)이라는, 유효한 힙 주소가 될 수 없는 패턴이 이미 존재하고, String도 Swift 5 네이티브 표현 안에 discriminator 비트가 있어 여분 패턴을 노출한다. 이 여분 비트를 그대로 .none 태그로 재사용하기 때문에 String?·AnyObject?는 원본과 크기가 똑같다.
원리
Swift ABI 공식 문서(docs/ABI/TypeLayout.rst)는 "single-payload enum"(데이터를 가진 케이스가 1개, 데이터 없는 케이스가 여러 개인 enum — Optional이 정확히 이 모양이다)의 레이아웃 규칙을 이렇게 정의한다. 컴파일러는 우선 payload 타입의 비트 표현 중 그 타입으로는 나올 수 없는 값(extra inhabitant)을 찾아, 그 여분 패턴들로 데이터 없는 케이스(.none)를 인코딩하려 한다. Extra inhabitant가 부족하면 spare bits(항상 0이거나 무시되는 비트)에 태그를 끼워 넣고, 그마저 부족하면 마지막 수단으로 별도의 태그 바이트를 추가한다. 즉 옵셔널이 "공짜"인지 아닌지는 언어가 정한 규칙이 아니라, payload 타입 하나하나의 물리적 비트 표현이 결정하는 문제다.
내부 동작
Int는 8바이트 전부가 의미 있는 정수 값이라 extra inhabitant가 0개이고, spare bit도 없다. 그래서 Int?는 8바이트 payload 뒤에 존재 유무를 나타내는 태그 바이트 1개를 추가해 9바이트가 되고, Int의 정렬(8)에 맞춰 stride는 16으로 올림된다. 클래스 참조(포인터)는 다르다 — 힙 오브젝트는 최소 8바이트 정렬로 할당되므로 포인터 값의 하위 비트는 항상 0이고, 무엇보다 정수 표현 0(NULL)은 애초에 유효한 힙 주소가 될 수 없다. Swift는 이 하나의 확실한 무효 패턴을 .none으로 그대로 쓰기 때문에 AnyObject?·클래스 타입 옵셔널은 비옵셔널과 정확히 같은 8바이트다. String은 Swift 5부터 16바이트 고정 표현(15바이트 이하 문자열은 힙 할당 없이 인라인 저장하는 스몰 스트링 최적화 포함)을 쓰는데, 이 표현 자체에 discriminator/플래그 비트가 이미 내장돼 있어 옵셔널로 감싸도 추가 바이트가 붙지 않는다.
흥미로운 지점은 커스텀 struct다. TypeLayout.rst는 "spare bits는 오직 primitive integer 타입의 상위 비트만 고려한다"고 명시하는데, Bool이 바로 그런 사례다. Bool은 개념적으로 1비트만 의미가 있지만 실제로는 바이트 하나를 통째로 차지하는 primitive integer로 다뤄지고, 표준 라이브러리는 나머지 비트 패턴을 extra inhabitant로 노출한다. 그래서 Bool?는 Bool과 크기가 같다(실측: 둘 다 1바이트). 더 나아가 구조체 전체가 이 여분을 물려받는다: Int 필드와 Bool 필드를 함께 가진 구조체는, Bool 필드 하나가 노출하는 여분 비트를 구조체 전체의 witness table도 그대로 노출해 옵셔널로 감싸도 크기가 늘지 않는다. 반대로 Int 필드 두 개짜리 구조체처럼 어느 필드도 여분 비트를 노출하지 않으면, 옵셔널로 감싸는 순간 가차없이 태그 바이트가 붙는다.
| 타입 | size | stride | align | 왜 이런가 |
|---|---|---|---|---|
Int | 8 | 8 | 8 | 모든 비트 패턴이 유효한 정수 → extra inhabitant 0개 |
Int? | 9 | 16 | 8 | 여분이 없어 태그 바이트 추가, stride는 정렬 때문에 16으로 올림 |
Bool | 1 | 1 | 1 | 바이트 하나를 쓰지만 표준 라이브러리가 여분 비트 패턴을 노출 |
Bool? | 1 | 1 | 1 | 그 여분 비트를 옵셔널 태그로 재사용 → 오버헤드 0 |
String | 16 | 16 | 8 | Swift 5 네이티브 표현(스몰 스트링 최적화 포함), discriminator 비트 내장 |
String? | 16 | 16 | 8 | discriminator의 여분 패턴 재사용 → 오버헤드 0 |
클래스 참조 Foo | 8 | 8 | 8 | 힙 포인터 1개 |
Foo? | 8 | 8 | 8 | 0(NULL)은 유효한 힙 주소가 될 수 없어 그대로 .none으로 재사용 |
struct(Int, Int) | 16 | 16 | 8 | 두 필드 다 여분 비트 없음 → 재사용할 게 없음 |
struct(Int, Int)? | 17 | 24 | 8 | 태그 바이트 추가 + 정렬 패딩으로 stride가 8만큼 더 뜀 |
struct(Int, Bool) | 9 | 16 | 8 | Bool 필드가 노출한 여분 비트가 구조체 전체에도 반영됨 |
struct(Int, Bool)? | 9 | 16 | 8 | 그 여분 비트를 옵셔널 태그로 재사용 → 오버헤드 0 |
위 수치는 Swift 6.2.1(swiftlang-6.2.1.4.8, arm64-apple-macosx26.0)에서 직접 실행해 측정한 값이다. 정렬·패딩 규칙은 플랫폼(32비트 vs 64비트)에 따라 달라질 수 있으니, 실무에서는 타겟 환경에서 직접 재측정하는 걸 권장한다.
실험 · 도구
MemoryLayout은 런타임 리플렉션 없이 컴파일 타임에 확정되는 값이라 swift -e 한 줄로 바로 확인할 수 있다. 위 표의 모든 행은 아래 스크립트로 재현했다.
swift -e '
print(MemoryLayout<Int>.size, MemoryLayout<Int>.stride)
print(MemoryLayout<Int?>.size, MemoryLayout<Int?>.stride)
print(MemoryLayout<Bool?>.size, MemoryLayout<Bool?>.stride)
print(MemoryLayout<String?>.size, MemoryLayout<String?>.stride)
final class Foo {}
print(MemoryLayout<Foo?>.size) // 8 — NULL이 그대로 .none
struct TwoInts { var a: Int; var b: Int }
struct IntBool { var age: Int; var flag: Bool }
print(MemoryLayout<TwoInts?>.size) // 17 — 여분 비트 없음, 태그 추가
print(MemoryLayout<IntBool?>.size) // 9 — Bool의 여분 비트를 재사용, 오버헤드 0
'더 깊이 들어가고 싶다면 swiftc -emit-sil로 Optional<Int>과 Optional<AnyObject>를 다루는 함수의 SIL을 뽑아 대조해보라. 클래스 옵셔널 쪽은 포인터 null 체크 한 번으로 끝나는 반면, 정수 옵셔널 쪽은 별도 discriminator 바이트를 읽고 쓰는 명령이 추가로 나온다.
프로젝트 적용
① [Int?]처럼 값 타입 옵셔널을 대량으로 담는 배열은 원소마다 태그 바이트(+패딩)가 붙어 캐시 지역성과 메모리 사용량에 실측 가능한 영향을 준다. "혹시 없을 수도 있는 값"을 정말 옵셔널로 표현해야 하는지, 아니면 sentinel 값이나 별도 Bool 배열로 표현하는 게 나은지는 가독성과 메모리의 트레이드오프다.
let n = 1_000_000
let optionalInts: [Int?] = (0..<n).map { $0 % 3 == 0 ? nil : $0 }
// Int? 하나가 stride 16이므로, 배열은 원소당 8바이트를 '공짜로'가 아니라
// 정렬 패딩으로 그냥 날리고 있다 — Int 배열이었다면 원소당 8바이트로 끝났을 자리다
print(MemoryLayout<Int?>.stride * n) // 대략적인 저장 공간 추정치② 커스텀 struct를 설계할 때, 필드 중 하나라도 (Bool·enum·클래스 참조처럼) 여분 비트를 가진 타입이 있으면 그 구조체를 옵셔널로 감싸는 비용이 사실상 0이 될 수 있다. MemoryLayout으로 직접 확인하는 습관을 들이면 "이 모델을 옵셔널 필드로 두는 게 공짜인지 아닌지"를 감이 아니라 수치로 판단할 수 있다.
struct CacheEntry {
var payload: Int
var isStale: Bool // 이 필드 하나 때문에 옵셔널이 '공짜'가 될 가능성이 생긴다
}
func checkOverhead<T>(_ type: T.Type) {
print("\(type): \(MemoryLayout<T>.size) -> \(MemoryLayout<T?>.size)")
}
checkOverhead(CacheEntry.self) // CacheEntry: 9 -> 9 (오버헤드 없음)"옵셔널은 항상 원래 타입보다 최소 1바이트 커진다"는 틀렸다 — 클래스 참조, String, 포인터류, 그리고 여분 비트를 가진 필드를 포함한 구조체는 대부분 크기가 그대로다. 태그가 실제로 추가되는 쪽은 Int·Double·여분 비트 없는 필드로만 이루어진 struct처럼 "모든 비트 패턴이 이미 의미로 꽉 찬" 값 타입뿐이다. 또 "이건 이론적인 이야기고 실무 영향은 없다"도 부정확하다 — 대량으로 저장되는 옵셔널 값 타입은 배열/딕셔너리의 메모리 사용량과 캐시 지역성에 실측 가능한 영향을 준다.
사물함에 이름표를 붙인다고 생각해보자. 어떤 사물함(Int)은 숫자 0부터 아주 큰 수까지 전부 "진짜 물건"으로 채워져 있어서, "비어있음"을 표시할 자리가 하나도 안 남는다 — 그래서 옆에 작은 메모지(태그 바이트)를 하나 더 붙여야 한다. 반대로 어떤 사물함(전화번호가 적힌 카드처럼 생긴 것)은 애초에 "이 번호는 절대 나올 수 없음"이라고 정해진 빈 칸이 이미 있다 — 그럼 그 빈 칸에 그냥 "비어있음"이라고 적으면 되니까 메모지가 따로 필요 없다. 비유가 깨지는 곳: 어떤 사물함은 겉보기엔 숫자 상자(struct) 같은데, 그 안에 우연히 "이 칸엔 0 아니면 1만 들어감" 같은 작은 서랍(Bool 필드)이 하나 딸려 있으면, 그 서랍의 남는 자리를 빌려서 전체 사물함이 메모지 없이도 "비어있음"을 표시할 수 있게 된다 — 이건 첫 비유만으로는 설명이 안 되는, struct 특유의 보너스다.
꼬리 질문
Optional<Optional<Int>>(Int??)는 Int?보다 얼마나 더 커지는가?
Int?는 9바이트, Int??는 10바이트다. Int는 애초에 여분 비트가 0개이므로, 첫 번째 옵셔널이 태그 바이트 하나를 다 써버리면 두 번째 옵셔널(.none/.some(.none)/.some(.some(x)) 세 상태를 구분해야 함)은 그 태그 안에 여유가 있어도 컴파일러가 별도로 한 바이트를 더 쓴다. 값 타입은 이렇게 중첩할수록 계속 자랄 수 있는 반면, 클래스 참조처럼 여분 비트가 애초에 넉넉한 타입은 여러 겹 중첩해도 크기가 그대로 흡수되는 경우가 있다(실측: Foo??는 여전히 8바이트).이 spare bit 최적화는 컴파일러가 아무 struct에나 마음대로 적용하는가?
MemoryLayout으로 직접 재는 것이 유일하게 확실한 방법이다.enum에 case가 여러 개 있어도 이 최적화가 똑같이 적용되는가?
CharOrSectionMarker)는 UnicodeScalar(21비트만 쓰고 11비트가 남는 i32 표현)를 payload로 갖는 enum이 Paragraph, Chapter라는 데이터 없는 케이스 두 개를 그 남는 비트 영역의 특정 상수 값으로 별도 태그 없이 인코딩하는 사례다. 즉 이 원리는 Optional(케이스 2개)에 국한되지 않고, "payload 타입 하나 + 데이터 없는 케이스 여럿"이라는 single-payload enum 형태 전체에 똑같이 적용되는 일반 규칙이다.Q3. if let shorthand(SE-0345)는 어떻게 desugar되며, 어떤 표현식에는 못 쓰는가?
Swift 5.7(SE-0345)부터 if let x { }처럼 이름만 쓰면 컴파일러가 그 자리에서 if let x = x { }로 확장(desugar)한다. 순수 문법 설탕이라 ABI나 런타임 동작에 변화가 없고, guard let x else·while let x에도 똑같이 적용되며 self 축약(guard let self else { return })도 지원한다. 다만 이 축약은 유효한 식별자 하나여야만 한다 — if let foo.bar처럼 멤버 접근이나 계산 프로퍼티, 임의의 표현식에는 쓸 수 없고 반드시 if let foo = foo.bar로 풀어 써야 한다.
원리
SE-0345는 "기존 옵셔널 변수를 같은 이름으로 shadowing할 때" 명시적으로 = someLengthyVariableName을 두 번 쓰는 반복을 줄이기 위한 제안이다. 제안서는 if let myNumber { }와 if var myNumber { }를 모두 허용하고, 축약 대상이 "이미 존재하는 옵셔널 값(변수·상수·프로퍼티)을 같은 이름으로 다시 바인딩하는" 경우로 명확히 한정한다. 타입 어노테이션(if let foo: Foo)도 함께 쓸 수 있다.
내부 동작
컴파일러는 if let x { ... }를 파싱하는 시점에 곧바로 if let x = x { ... }로 확장한다 — 이건 타입체커 이후 단계의 트릭이 아니라, 그야말로 "짧게 쓴 걸 길게 펴는" 구문 설탕이라 SIL이나 ABI에는 아무 흔적도 남지 않는다. 이 확장의 핵심 제약은 "왼쪽에 오는 대상이 유효한 식별자(identifier)여야 한다"는 것이다. 실제로 if let foo.bar { }처럼 멤버 접근을 축약하려 하면 컴파일러가 정확히 unwrap condition requires a valid identifier라는 진단 메시지를 낸다 — "무엇을 shadow할지"가 이름 하나로 딱 떨어져야 하는데, foo.bar는 계산될 수도 있는 표현식이라 "같은 이름으로 다시 바인딩한다"는 shorthand의 전제 자체가 성립하지 않기 때문이다. self는 예외가 아니라 오히려 규칙을 그대로 따르는 사례다 — self 자체는 유효한 식별자이므로 guard let self else { return }는 guard let self = self else { return }로 문제없이 확장되지만, self.foo는 멤버 접근이라 여전히 축약 대상이 아니다.
실험 · 도구
실제 컴파일러 진단 메시지를 직접 재현하려면 swiftc -typecheck로 충분하다.
// ok.swift — self shorthand는 정상 동작
final class Worker {
var name: String = "w"
func start() {
run { [weak self] in
guard let self else { return } // ✅ self는 유효한 식별자
print(self.name)
}
}
func run(_ f: @escaping () -> Void) { f() }
}
// $ swiftc -typecheck ok.swift -> 에러 없음
// bad.swift — 멤버 접근은 축약 불가
struct Box { var value: Int? }
func test(b: Box) {
if let b.value { print(b.value) }
}
// $ swiftc -typecheck bad.swift
// bad.swift:3:12: error: unwrap condition requires a valid identifier프로젝트 적용
① 기존에 if let x = x { }로 되어 있던 shadowing 코드는 이름을 그대로 유지하는 한 shorthand로 정리해 가독성을 높일 수 있다. 다만 배포 타겟이 Swift 5.7 미만이면 이 문법 자체를 쓸 수 없으니 최소 배포 버전을 먼저 확인해야 한다.
// ❌ Swift 5.6 이하 스타일 (지금도 유효하지만 반복이 많다)
func process(userID: Int?) {
if let userID = userID {
print("처리 중: \(userID)")
}
}
// ✅ Swift 5.7+ — 같은 이름을 그대로 shadow할 때만 축약 가능
func processShort(userID: Int?) {
if let userID {
print("처리 중: \(userID)")
}
}② 클로저에서 [weak self]를 캡처한 뒤 guard let self else { return }로 강한 참조를 되찾는 패턴은 이 shorthand가 특히 자주 등장하는 자리다. 다만 self.something처럼 프로퍼티를 직접 축약하려는 실수는 여전히 컴파일 에러로 걸린다는 걸 기억해야 한다.
final class ImageLoader {
var cache: [String: Data] = [:]
func load(_ key: String, completion: @escaping (Data?) -> Void) {
fetch(key) { [weak self] data in
guard let self else { return } // ✅ self 자체는 식별자
self.cache[key] = data // self.cache는 축약 대상이 아니라 그냥 멤버 접근
completion(data)
}
}
func fetch(_ key: String, completion: @escaping (Data?) -> Void) { }
}"if let x는 새 변수를 자유롭게 만드는 문법이다"는 절반만 맞다 — 반드시 같은 이름의 기존 옵셔널을 shadow하는 용도로 한정된다. "computed property나 옵셔널 체이닝 결과도 if let self.prop처럼 축약할 수 있다"는 틀렸다 — 축약 가능 여부는 오직 "왼쪽이 유효한 식별자 하나인가"로 결정되며, 멤버 접근·서브스크립트·임의 표현식은 전부 제외된다.
이름표가 붙은 상자를 "같은 이름표를 단 채로 다시 확인해서 열어보기"라고 생각하면 된다. if let x는 "x라는 상자를 x라는 같은 이름으로 다시 열어본다"는 뜻을 짧게 줄인 것뿐이다. 그런데 "b라는 상자 안에 있는 value라는 물건"처럼 이름이 하나가 아니라 "상자.물건" 형태면, 컴파일러는 "그래서 결과물의 이름을 뭐라고 붙일 건데?"를 알 수 없어서 축약을 거부한다. 비유가 깨지는 곳: self는 특별해 보이지만 사실 특별 취급이 아니다 — self도 그냥 하나의 이름표(식별자)라서 규칙을 그대로 통과할 뿐이다.
꼬리 질문
guard let x else { return }와 guard let x = x else { return }는 Swift 5.7 전후로 어떻게 다른가?
guard let x else 형태 자체가 파싱되지 않아 반드시 guard let x = x else로 풀어 써야 했다. 5.7부터는 두 표현이 완전히 동치이며, 축약형은 컴파일러가 파싱 시점에 풀어 쓴 형태로 확장할 뿐이므로 생성되는 코드나 성능에는 차이가 없다. 즉 이건 순수하게 "타이핑을 줄여주는" 변화다.여러 옵셔널을 콤마로 나열하면서 일부만 shorthand를 쓸 수 있는가?
if let a, let b = someExpression, c > 0 { }처럼 shorthand와 풀어 쓴 바인딩, 일반 Bool 조건을 한 줄에 자유롭게 섞을 수 있다. 콤마로 나열된 조건 중 하나라도 nil이거나 false면 전체 조건이 실패한다는 규칙은 shorthand 도입 전후로 변하지 않았다.shorthand로 만들어진 상수는 바깥 스코프의 원래 변수에 영향을 주는가?
if let x { }가 만드는 x는 if 블록 안에서만 유효한 완전히 새로운 상수(또는 var인 경우 변수)이며, 바깥 스코프의 원래 옵셔널 x는 전혀 건드리지 않는다. 이건 shadowing의 일반 규칙 그대로다 — 이름이 같아서 헷갈리기 쉽지만, 블록을 벗어나면 바깥의 원래 옵셔널만 남는다.Q4. IUO(String!)의 타입 시스템상 정체는 무엇인가? 왜 남아 있는가?
IUO(Implicitly Unwrapped Optional)는 Optional<T>와 별개의 타입이 아니다. SE-0054(Swift 3)가 이를 명확히 재정의했다: 선언 끝의 !는 더 이상 "IUO라는 별도 타입"을 뜻하지 않고, "① 선언의 타입이 Optional이고, ② 그 값이 값으로 쓰일 때 암묵적으로 강제 언래핑될 수 있다는 속성(attribute)이 붙어 있다"는 뜻이다. 그래서 String!은 대입·바인딩·전달에서는 그냥 String?처럼 동작하고, "값으로 직접 쓰이는 지점"에서만 자동으로 !가 삽입된다. 여전히 남아 있는 이유는 두 가지다 — 아직 nullability가 명시되지 않은 Objective-C API를 임포트할 때, 그리고 초기화 시점에는 비어 있지만 곧바로 채워진다는 게 프로그램 구조상 확실한 경우(대표적으로 IBOutlet)의 definite-initialization 문제를 우회하는 용도다.
원리
Swift 공식 언어 가이드는 IUO를 "프로그램 구조상 값이 처음 설정된 후에는 항상 값이 있다는 게 명확할 때" 쓰는 것으로 규정하고, 클래스 초기화(class initialization) 시나리오 — 예컨대 IBOutlet처럼 init 시점엔 값이 없지만 이후 즉시 채워지는 경우 — 를 대표 사용처로 명시한다. 동시에 "나중에 nil이 될 가능성이 있는 변수에는 IUO를 쓰지 말고, 생애주기 동안 nil 체크가 필요하면 항상 일반 옵셔널을 쓰라"고 명확히 경고한다.
내부 동작
SE-0054 이전에는 ImplicitlyUnwrappedOptional<T>가 Optional<T>와 별개의 타입으로 존재했고, 이 타입이 대입·산술식·함수 인자 전달 등 문맥마다 각각 다른 암묵적 변환 규칙을 갖고 코드 전체로 전파되는 문제가 있었다(제안서는 이를 "IUO는 과도기적 기술(transitional technology)"이라 표현한다). SE-0054는 이 모델을 폐기하고 정확히 이렇게 재정의했다: "선언 타입 끝의 !는 더 이상 그 선언이 IUO 타입임을 뜻하지 않는다. 오히려 (1) 그 선언이 옵셔널 타입을 가지며, (2) 그 값이 암묵적으로 강제될 수 있음을 나타내는 속성을 갖는다는 뜻이다." 실제로 이 속성은 "값으로 직접 쓰이는 지점에서만" 자동 언래핑을 삽입하고, 그 외 모든 문맥(대입, 옵셔널 바인딩, 다른 옵셔널로의 전달)에서는 그냥 일반 Optional<T>로 취급된다. 그래서 let y: String? = x(x는 String!)처럼 옵셔널 변수에 대입하는 건 아무 변환 없이 그대로 되고, if let value = x처럼 옵셔널 바인딩도 그대로 동작한다 — 이중으로 언래핑되는 게 아니라 애초에 Optional<String> 그 자체이기 때문이다. IUO가 타입 시스템에서 별도 타입이 아니라는 가장 직접적인 증거는, 제네릭 타입 인자 위치에 !를 쓸 수 없다는 점이다 — MemoryLayout<String!>은 아예 컴파일되지 않고 컴파일러가 "use '?' instead"라고 알려준다. 값 하나를 값으로 직접 사용하는 지점(예: name.count)에서만 자동 언래핑이 삽입되고, 그 값이 실제로 nil이면 일반 강제 언래핑과 똑같이 "Unexpectedly found nil while implicitly unwrapping an Optional value"로 트랩된다.
실험 · 도구
IUO가 별도 타입이 아니라는 걸 직접 증명하는 가장 빠른 방법은 제네릭 위치에 !를 써보는 것이다 — 컴파일러가 곧바로 정체를 알려준다.
// swift -e 'print(MemoryLayout<String!>.size)'
// error: using '!' is not allowed here
// note: use '?' instead
// -> String! 은 "타입 표현식"으로 쓸 수 있는 별도 타입이 아니라
// 변수/프로퍼티/파라미터 선언 위치에서만 의미를 갖는 문법이다
let x: String! = "hi"
let y: String? = x // 변환 없이 그대로 대입됨 — 이미 Optional<String>이므로
print(y as Any) // Optional("hi")
if let value = x { // 옵셔널 바인딩도 평범하게 동작
print("bound:", value)
}
var name: String! = nil
print(name.count) // 값으로 '직접' 쓰이는 지점 -> 자동 강제 언래핑 삽입
// Fatal error: Unexpectedly found nil while implicitly unwrapping an Optional value프로젝트 적용
① UIKit의 @IBOutlet이 여전히 !로 선언되는 이유는 정확히 이 "definite-initialization 우회" 용도다. init 시점에는 스토리보드 연결이 아직 없어 값이 없지만, viewDidLoad 시점에는 항상 있다는 게 프레임워크의 생명주기 계약으로 보장된다. 다만 이 보장이 없는 일반 프로퍼티에까지 습관적으로 !를 쓰는 건 원래 문서가 명시적으로 경고하는 오용이다.
final class ProfileViewController: UIViewController {
@IBOutlet var nameLabel: UILabel! // ✅ viewDidLoad 이후엔 항상 값이 있다는 계약
var cachedUser: User! // ⚠️ 이건 그 계약이 없다 — 옵셔널로 바꿔야 함
override func viewDidLoad() {
super.viewDidLoad()
nameLabel.text = "Hello" // 값으로 직접 쓰는 지점 — 안전
}
}② Objective-C 브릿징 헤더에서 nullability가 지정되지 않은(nonnull/nullable 애노테이션이 없는) API는 여전히 !로 임포트된다. 이런 값은 받자마자 일반 옵셔널로 좁혀서, 그 이후 코드에서는 IUO의 "언제 터질지 모르는 암묵적 언래핑"을 다시 만들지 않는 게 안전하다.
// LegacyLibrary.h (Objective-C, nullability 애노테이션 없음)
// - (NSString *)legacyIdentifier;
// -> Swift에서 String! 로 임포트됨
func safeIdentifier(from legacy: LegacyObject) -> String? {
// ✅ 받자마자 일반 옵셔널로 좁혀서 그 이후로는 IUO를 들고 다니지 않는다
let identifier: String? = legacy.legacyIdentifier
return identifier
}"IUO는 force-unwrap을 강제하는 특수 타입이다"는 틀렸다 — 대입·전달·바인딩 시점에는 그냥 옵셔널처럼 취급되고, 값으로 직접 사용될 때만 자동 언래핑이 삽입된다. "IUO를 쓰면 nil 체크를 아예 안 해도 안전하다"도 틀렸다 — 안전과는 무관하며, 내부적으로 여전히 강제 언래핑이라 nil이면 그대로 크래시한다.
String!은 "언젠가는 알아서 열리는 특수 상자"가 아니라, 그냥 평범한 "비어있을 수도 있는 상자"(String?)에 포스트잇 한 장이 붙어 있는 것뿐이다. 포스트잇에는 이렇게 적혀 있다 — "이 상자에서 물건을 바로 꺼내 쓰려고 하면, 굳이 뚜껑을 열라고 말 안 해도 알아서 열어줄게." 그래서 상자를 다른 상자에 옮겨 담거나(대입), "혹시 비었나?" 확인만 할 때는(바인딩) 포스트잇이 아무 역할도 안 한다. 비유가 깨지는 곳: 포스트잇이 있어도 상자가 진짜로 비어 있으면 "열어줄게"라던 약속은 못 지켜지고 그 자리에서 그냥 깨진다(크래시) — 포스트잇은 안전을 보장하는 게 아니라 그냥 "자동으로 열어준다"는 편의일 뿐이다.
꼬리 질문
IUO를 파라미터 타입으로 받으면 호출하는 쪽은 무엇을 넘겨도 되는가?
T! 파라미터가 T?와 동일하게 타입 체크되므로, T 값이든 T? 값이든 nil 리터럴이든 전부 그대로 넘길 수 있다. 자동 언래핑은 그 파라미터를 함수 내부에서 값으로 직접 쓸 때만 관여하고, 호출자 쪽 타입 체크에는 전혀 영향을 주지 않는다.SE-0054는 중첩 IUO([Int!], Int!? 같은)를 어떻게 처리했는가?
[Int!], Int!? 등)를 금지했다. IUO가 타입 트리 어디에나 나타날 수 있던 이전 모델의 복잡도를 줄이기 위한 조치로, 그 결과 IUO는 오직 변수·프로퍼티·파라미터·리턴 타입 선언의 가장 바깥쪽에서만 의미를 갖는다.왜 IUO를 아예 없애지 않고 지금까지 남겨뒀는가?
nil이 될 수 있는 일반적인 상황에는 여전히 일반 옵셔널을 쓰는 게 맞다.Q5. force unwrap과 assert / precondition / fatalError는 각각 언제가 맞는가? (-Ounchecked 포함)
넷 다 "조건이 깨지면 프로그램을 멈춘다"는 점은 같지만, 릴리즈 빌드에서 살아남는지가 다르다. assert는 디버그 전용 내부 정합성 체크로 릴리즈(-O)에서는 조건 평가 자체가 사라진다. precondition과 강제 언래핑 !은 API 사용자(호출자)의 입력을 검증하는 용도로 릴리즈에서도 살아있지만, 안전 체크를 전부 끄는 -Ounchecked에서는 둘 다 사라지고 옵티마이저가 "조건은 항상 참"이라고 가정해버려 정의되지 않은 동작(UB)으로 이어질 수 있다. fatalError는 최적화 레벨과 무관하게 무조건 실행된다. force unwrap이 정당한 경우는 "실패 원인이 프로그래머 실수뿐인 불변식"(정적으로 항상 있다고 보장된 리소스, 컴파일 타임에 유효함이 확실한 리터럴)이지, 네트워크 응답처럼 외부에서 온 값에는 절대 쓰면 안 된다.
원리
표준 라이브러리 Assert.swift 소스의 주석은 세 함수의 역할을 명확히 구분한다. assert(_:_:)는 "테스트 중에는 활성화되지만 배포되는 코드의 성능에는 영향을 주지 않는 내부 정합성 체크"용이다. precondition(_:_:)는 "배포되는 코드에서도 프로그램이 계속 진행되면 안 되는 조건을 감지"하는 용도로 명시된다. 언어 가이드는 강제 언래핑 !을 "사실상 fatalError(_:file:line:)를 짧게 쓴 것"이라고 직접 규정한다 — 즉 !는 문법적으로는 짧지만 의미상으로는 "무조건 멈추는" 쪽에 훨씬 가깝고, 조건부로 사라지는 assert와는 근본적으로 다른 급이다.
내부 동작
세 최적화 레벨에서 정확히 무엇이 남고 무엇이 사라지는지는 문서에 명시돼 있다. -Onone(디버그)에서는 assert·precondition 모두 조건을 평가하고, 거짓이면 메시지를 출력한 뒤 디버깅 가능한 상태로 정지한다. -O(릴리즈)에서는 assert의 조건은 "평가되지 않으며 아무 효과도 없다" — 그야말로 코드에서 증발한다. 반면 같은 -O에서도 precondition은 "조건이 거짓이면 프로그램 실행을 멈춘다"고 명시돼 여전히 살아있다. -Ounchecked(Xcode의 "Disable Safety Checks")에서는 assert·precondition 둘 다 "조건이 평가되지 않지만, 옵티마이저는 그 조건이 항상 참이라고 가정할 수 있다"로 바뀐다 — 이게 핵심 위험 지점이다. 조건이 실제로 거짓인데도 옵티마이저가 "항상 참"이라는 잘못된 전제 위에서 최적화를 해버리면, 크래시 대신 조용히 틀린 결과가 나올 수 있다. 직접 컴파일해서 확인한 결과가 이를 정확히 보여준다: x = 0일 때 precondition(x > 0) 다음에 100 / x를 실행하면, -Onone과 -O에서는 precondition이 걸려 트랩(exit 133, SIGTRAP)이 발생하지만, -Ounchecked에서는 트랩도, 0으로 나누기 크래시도 없이 그냥 0이 출력되고 정상 종료된다 — 옵티마이저가 "x > 0"이 항상 참이라고 가정한 코드 경로가 실제로는 성립하지 않는 값 위에서 실행되며 나온, 예측 불가능한 결과다. fatalError는 이 표 어디에도 걸리지 않는다 — 최적화 레벨과 무관하게 항상 실행되어 프로그램을 무조건 종료한다.
| 도구 | -Onone (디버그) | -O (릴리즈) | -Ounchecked |
|---|---|---|---|
assert | 평가함 · 실패 시 정지 | 평가 안 함 · 완전히 사라짐 | 평가 안 함 · 사라짐 |
precondition | 평가함 · 실패 시 정지 | 평가함 · 실패 시 정지 | 평가 안 함 · 항상 참으로 가정(UB) |
강제 언래핑 ! | nil이면 트랩 | nil이면 트랩 | 체크 제거 · UB |
fatalError | 무조건 정지 | 무조건 정지 | 무조건 정지 |
실험 · 도구
세 최적화 플래그로 각각 컴파일해서 실제 종료 코드를 비교하면 표의 내용을 그대로 재현할 수 있다.
// risky.swift
@inline(never) // 인라인되어 상수 폴딩되는 것을 막는다
func risky(_ x: Int) -> Int {
assert(x > 0, "assert: x must be positive")
precondition(x > 0, "precondition: x must be positive")
return 100 / x
}
// ⚠️ 여기서 risky(0)처럼 '리터럴'을 넘기면 이 실험은 성립하지 않는다.
// 옵티마이저가 100/0을 컴파일 타임에 UB로 증명해버려서 main 전체를
// 조건 없는 트랩 하나(brk #0x1)로 치환한다 — risky를 호출조차 하지 않는다.
// 그래서 -Ounchecked에서도 무출력 + exit 133이 나온다.
// 체크 제거의 효과를 보려면 컴파일 타임에 알 수 없는 값을 넘겨야 한다.
let n = CommandLine.arguments.count - 1 // 인자 없이 실행하면 0
print(risky(n))$ swiftc -Onone risky.swift -o r_onone && ./r_onone
risky.swift:4: Assertion failed: assert: x must be positive
# exit 133 (SIGTRAP) — assert가 먼저 걸린다
$ swiftc -O risky.swift -o r_o && ./r_o
# 메시지 없이 exit 133 — assert는 사라졌지만 precondition은 여전히 걸린다
# (메시지 문자열만 릴리스 빌드에서 제거된다)
$ swiftc -Ounchecked risky.swift -o r_unchecked && ./r_unchecked
0
# exit 0 — 트랩도 없이 그냥 0이 출력된다.
# precondition이 제거되어 100/0의 SDIV가 그대로 실행되고,
# ARM64에서 정수 0으로 나누기는 트랩이 아니라 0을 반환한다.크래시 지점을 더 들여다보고 싶다면 Xcode 브레이크포인트 내비게이터에서 "Swift Error Breakpoint"를 추가해 swift_willThrow에, LLDB에서 image lookup -rn swift_allocError로 관련 런타임 심볼을 확인해볼 수 있다. force unwrap이나 precondition 실패는 트랩 명령(brk/ud2)으로 즉시 프로세스를 종료시키므로 크래시 직후 bt로 스택을 바로 확인할 수 있다.
프로젝트 적용
① 실패 원인이 프로그래머 실수뿐인 불변식에는 precondition류(force unwrap 포함)를 정당하게 쓴다. viewDidLoad 이후의 IBOutlet, 정적으로 항상 로드되는 앱 번들 리소스가 대표적이다. 반대로 네트워크 응답, 사용자 입력, 파일 시스템처럼 런타임에 실패할 수 있는 외부 데이터에는 강제 언래핑을 절대 쓰지 않고 guard let/do-catch로 실패를 정상적인 제어 흐름으로 다룬다.
// ✅ 정당한 force unwrap: 앱 번들에 반드시 존재하는 리소스 (프로그래머 실수만이 실패 원인)
let iconURL = Bundle.main.url(forResource: "icon", withExtension: "png")!
// ❌ 부당한 force unwrap: 서버 응답은 언제든 형태가 바뀌거나 실패할 수 있다
func parseBad(_ json: [String: Any]) -> String {
json["nickname"] as! String // 서버가 필드를 바꾸는 순간 프로덕션에서 크래시
}
// ✅ 외부 입력은 실패를 정상 흐름으로 다룬다
func parseSafe(_ json: [String: Any]) -> String? {
json["nickname"] as? String
}② "컴파일 타임에 유효함이 확실한 리터럴"도 force unwrap이 합리적인 대표 사례다 — 정규식 리터럴이 실제로 컴파일되는지는 코드 리뷰 시점에 사람이 확인할 수 있는 문제이지, 런타임에 매번 실패를 대비해야 할 문제가 아니다.
// ✅ 정규식 패턴이 리터럴로 고정돼 있고, 오타가 있었다면 '이 함수를 처음 호출했을 때'가 아니라
// 개발 중에 바로 알아챘을 것이다 — 실패 원인이 프로그래머 실수뿐이므로 try!이 적절하다
func isValidHexColor(_ s: String) -> Bool {
let regex = try! NSRegularExpression(pattern: "^#[0-9A-Fa-f]{6}$")
return regex.firstMatch(in: s, range: NSRange(s.startIndex..., in: s)) != nil
}
// ❌ 패턴이 서버 설정값처럼 런타임에 바뀔 수 있다면 try!은 위험하다
func isValidPattern(_ s: String, remotePattern: String) -> Bool {
guard let regex = try? NSRegularExpression(pattern: remotePattern) else { return false }
return regex.firstMatch(in: s, range: NSRange(s.startIndex..., in: s)) != nil
}"assert와 precondition은 결국 같은 거다"는 릴리즈 빌드 기준으로 완전히 틀렸다 — assert는 릴리즈에서 사라지고 precondition은 남는다. "-Ounchecked는 그냥 좀 더 빠른 릴리즈 빌드다"도 틀렸다 — 배열 bounds, 강제 언래핑, precondition, 정수 오버플로 체크를 실제로 제거하며, 조건이 실제로 깨지면 크래시가 아니라 UB로 이어진다(위 실측처럼 크래시조차 없이 틀린 값이 조용히 나올 수 있다). "force unwrap이 실패하면 예외(exception)가 던져진다"도 틀렸다 — Swift에는 예외 기반 크래시가 없고, 트랩 명령으로 스택 unwinding 없이 프로세스가 즉시 종료된다.
학교 시험에 비유하면 이렇다. assert는 "연습 문제 풀 때만 채점하는 선생님"이다 — 실전 시험(릴리즈 빌드)에서는 아예 채점을 안 한다. precondition은 "연습이든 실전이든 항상 채점하는 선생님"이다. fatalError는 "학생이 뭘 냈든 무조건 0점 처리하고 시험을 끝내버리는 선생님"이다. 그런데 -Ounchecked는 아주 특별한 상황이다 — 이건 "채점 자체를 그만두고, 이제부터 모든 학생이 만점이라고 그냥 믿어버리는" 모드다. 실제로 학생이 틀렸어도 "만점이라 믿었으니" 그 이후 계산이 전부 이상하게 꼬여버릴 수 있다. 비유가 깨지는 곳: 보통 시험에서 "채점을 안 한다"는 건 그냥 넘어간다는 뜻이지만, -Ounchecked에서 "항상 참이라고 믿는다"는 건 그 믿음을 바탕으로 다른 계산까지 최적화해버린다는 뜻이라, 단순히 "넘어가는" 것보다 훨씬 위험하다.
꼬리 질문
assertionFailure()와 preconditionFailure()는 각각 어디에 쓰는가?
switch의 도달 불가능해야 하는 default 케이스)에 쓰는 "조건 없는" 버전이다. assertionFailure()는 assert(false, ...)와 동일하게 릴리즈(-O)에서 사라지고, preconditionFailure()는 precondition(false, ...)와 동일하게 릴리즈에서도 살아남아 프로그램을 정지시킨다. "이론적으로 절대 안 오지만 혹시 몰라서"는 assertionFailure, "여기 왔다는 것 자체가 심각한 버그다"는 preconditionFailure로 구분하면 된다.왜 하필 force unwrap이 assert가 아니라 precondition 급으로 취급되는가?
!을 "fatalError를 짧게 쓴 것"이라고 규정한 것도 같은 맥락이다 — 디버그 빌드에서만 잡아내고 릴리즈에서는 조용히 넘어가도 되는 "내부 점검"이 아니라, 릴리즈에서도 반드시 걸러야 하는 "이 값이 없으면 이후 코드가 아예 성립하지 않는다"는 수준의 조건이라고 보는 것이다.