← Mobile Foundation 인터뷰 6 / 13

06 · Optional 안전 사용

문법 → 설계 판단 → 메모리 표현 → 합 타입과 태그 압축
진행률
0 / 0 완료
면접에서 나오는 형태
  1. Optional을 안전하게 쓴다는 게 무슨 뜻인가요?
  2. force unwrap(!)은 절대 쓰면 안 되나요?
  3. Optional을 쓰면 메모리를 더 쓰나요?
L1 개념
Optional은 enum { case none; case some(Wrapped) } — nil을 없애는 게 아니라 그냥 지나칠 수 없게 만든다
L2 설계
진짜 질문은 문법이 아니라 "이 없음이 정상인가, 실패인가, 애초에 없어야 하나"
L3 구현
extra inhabitant 덕에 String?·클래스 참조는 오버헤드 0. Int?만 1바이트가 는다
L4 CS
합 타입 · 널 참조의 역사 · 태그 압축 — 포인터 태깅·NaN 박싱과 같은 기법

30초 답변 🔥 먼저 이 문장

Optional은 "값이 없을 수 있다"를 타입에 적어 두는 장치입니다. 목적은 nil을 없애는 게 아니라, nil을 다루지 않고 지나갈 수 없게 만드는 겁니다.

그래서 "안전 사용"의 핵심은 if let이냐 guard let이냐 같은 문법이 아니라 설계 판단입니다 — 이 "없음"이 정상 상태인지(→ Optional), 실패인지(→ throws), 아니면 애초에 없어야 할 상태인지(→ 타입을 바꿔라). 잘못된 층에서 Optional을 쓰면 if let이 화면 코드까지 번집니다.

L1개념 — 정체와 도구 전부

Optional의 실제 정의 — 특별한 문법이 아니라 그냥 enum이다
@frozen enum Optional<Wrapped> {
    case none
    case some(Wrapped)
}

// String? 는 Optional<String> 의 설탕이고, nil 은 .none 이다
let a: String? = nil
let b: Optional<String> = .none
let c: String? = .some("hi")
print(a == b)          // true — 같은 것

// 그래서 switch 로 다룰 수 있다
switch c {
case .none:          print("없음")
case .some(let v):   print(v)
}

// 옵셔널 체이닝이 String??? 이 되지 않는 이유 — 결과가 평탄화된다
struct A { var b: B? }
struct B { var c: String? }
let x: A? = A(b: B(c: "z"))
let y = x?.b?.c        // String?  ← String??? 이 아니다
안전 도구 전부 — 언제 무엇을 쓰는가
let raw: String? = fetch()

// ① guard let — 기본값. 없으면 빠져나간다. happy path 들여쓰기 유지
guard let raw else { return }
print(raw.count)

// ② if let — 있을 때만 하는 "부수적" 처리
if let raw { badge.text = raw }

// ③ ?? — 의미 있는 기본값이 진짜로 존재할 때만
let title = raw ?? "제목 없음"

// ④ map — 있으면 변환, 없으면 nil 유지 (한 겹)
let len: Int? = raw.map { $0.count }

// ⑤ flatMap — 변환 결과가 또 Optional일 때 (평탄화)
let n: Int? = raw.flatMap { Int($0) }        // map이면 Int?? 가 된다

// ⑥ compactMap — 컬렉션에서 nil 제거
let nums = ["1", "x", "3"].compactMap { Int($0) }   // [1, 3]

// ⑦ 옵셔널 패턴 매칭 — enum과 섞일 때
for case let .some(v) in [raw] { print(v) }

// ⑧ switch — none/some 양쪽을 명시적으로 다뤄야 할 때
switch raw {
case .some(let v) where v.isEmpty: print("빈 문자열")
case .some(let v):                 print(v)
case .none:                        print("nil")
}
❌ 안티패턴 모음 — 하나씩 왜 나쁜지
// ❌ 검사와 사용이 분리 — 컴파일러가 연결해 주지 못한다
if user != nil {
    print(user!.name)          // 여기서 user가 여전히 Optional
}
// ✅ 바인딩과 검사를 한 번에
guard let user else { return }
print(user.name)

// ❌ ?? 로 에러를 삼킨다 — "왜 0인지" 정보가 사라진다
let price = try? api.price() ?? 0

// ❌ 옵셔널 Bool — 3-state가 되어 조건문이 지옥이 된다
var isAgreed: Bool?
if isAgreed == true { }        // nil과 false를 구분해야 하나?
// ✅ 의미가 셋이면 enum
enum Consent { case unknown, agreed, declined }

// ❌ 중첩 옵셔널을 방치
let raw: String?? = someDict["k"]      // 사전에 키가 없음 vs 값이 nil
// ✅ 의미를 결정해서 평탄화
let flat: String? = someDict["k"] ?? nil

// ❌ 옵셔널 배열 vs 배열의 옵셔널을 혼동
var items1: [Item]?  = nil     // "목록 자체가 아직 없음"
var items2: [Item?]  = []      // "목록은 있는데 원소가 비어 있을 수 있음"
var items3: [Item]   = []      // "목록은 있고 비어 있음"  ← 대부분 이게 맞다

L2설계 판단 — Optional이 맞는 자리인가

"없음"의 세 가지 정체를 구분한다
// ─── ① 정상적으로 값이 없을 수 있다 → Optional 이 맞다 ───
struct User {
    let id: UUID
    let nickname: String?      // 닉네임을 안 쓰는 건 정상이다
}

// ─── ② 실패다. 그리고 이유가 있다 → throws 가 맞다 ───
// ❌ nil은 "왜 실패했는지"를 버린다
func loadUser(id: UUID) -> User? { /* ... */ }
// ✅ 이유가 전달된다
func loadUser(id: UUID) throws -> User { /* ... */ }
enum LoadError: Error { case notFound, network, unauthorized }

// ─── ③ 상태가 여러 개다 → Optional 로 뭉개면 정보가 없어진다 ───
// ❌ users == nil 이 "로딩 중"인지 "실패"인지 "아직 시작 안 함"인지 모른다
var users: [User]?
// ✅ 상태를 열거한다  (01번의 상태 공간 축소와 같은 이야기)
enum LoadState {
    case idle
    case loading
    case loaded([User])
    case failed(LoadError)
}
경계에서 옵셔널을 흡수한다 — 실무에서 가장 효과가 큰 규칙
// 서버 DTO — 서버가 언제든 필드를 빼먹을 수 있으니 전부 옵셔널
struct UserDTO: Decodable {
    let id: String?
    let name: String?
    let age: Int?
    let profileURL: String?
}

// ❌ DTO를 그대로 화면까지 흘려보내면 if let 이 UI 코드까지 번진다
label.text = dto.name ?? ""
if let urlString = dto.profileURL, let url = URL(string: urlString) { /* ... */ }

// ✅ 디코딩 계층에서 세 가지를 "결정"해서 내려보낸다
struct User {                       // 도메인 모델 — 옵셔널이 최소
    let id: UUID                    // 없으면 에러 (필수)
    let name: String                // 없으면 기본값
    let age: Int?                   // 진짜 선택 항목 — 옵셔널 유지
    let profileURL: URL?            // 파싱 실패도 nil로 흡수
}

extension User {
    init(dto: UserDTO) throws {
        guard let idString = dto.id, let id = UUID(uuidString: idString) else {
            throw LoadError.invalidPayload("id")     // ① 실패로 승격
        }
        self.id = id
        self.name = dto.name ?? "이름 없음"           // ② 기본값 적용
        self.age = dto.age                            // ③ 옵셔널 유지
        self.profileURL = dto.profileURL.flatMap(URL.init(string:))
    }
}
// 결과: 화면 코드에 if let 이 거의 사라진다

force unwrap이 정당한 경우

정당한 이유는 딱 하나입니다 — "여기서 nil이면 그건 프로그래머 오류이고, 조용히 넘어가면 더 나쁘다."

! 보다 나은 표현이 거의 항상 있다
// ❌ 왜 죽었는지 크래시 로그에 안 남는다
let url = URL(string: endpoint)!

// ✅ 실패 이유가 로그에 찍힌다
guard let url = URL(string: endpoint) else {
    preconditionFailure("잘못된 endpoint: \(endpoint)")
}

// ✅ 정당한 ! 의 예 — 뷰 로드 후엔 반드시 존재
@IBOutlet private var titleLabel: UILabel!

// ✅ 방금 넣은 값을 바로 꺼낼 때 (그래도 주석으로 근거를 남긴다)
cache[key] = value
let stored = cache[key]!    // 바로 위에서 넣었으므로 nil 불가
⚠️ 단골 꼬리질문 — assert / precondition / fatalError는 뭐가 다른가
빌드 설정별로 살아남는 것이 다르다
assert(x > 0, "...")            // Debug(-Onone)에서만 검사. Release에서 제거됨
assertionFailure("...")         //  ↳ 같은 조건

precondition(x > 0, "...")      // Debug + Release(-O) 모두 검사
preconditionFailure("...")      //  ↳ -Ounchecked 에서만 제거됨

fatalError("...")               // 항상 검사. 최적화 수준과 무관하게 살아남는다
                                //  ↳ Never 반환이라 컴파일러가 도달 불가로 처리

// 선택 기준
//   내부 로직 검증(성능 민감)        → assert
//   외부 입력·API 계약 위반          → precondition   ← 대부분 이게 맞다
//   도달하면 안 되는 분기            → fatalError

"릴리즈에서도 잡히길 원하는가"가 판단 기준입니다. 사용자 기기에서 잘못된 상태로 계속 도는 것보다, 즉시 멈추고 크래시 리포트를 받는 게 나은 경우가 많습니다.

L3구현 — Optional은 정말 메모리를 더 쓰는가

직접 재보면 답이 나온다 (arm64 · Swift 6.2.1 실측)
MemoryLayout<Int>.size          // 8
MemoryLayout<Int?>.size         // 9   ← 1바이트 늘었다
MemoryLayout<Int?>.stride       // 16  ← 정렬 때문에 배열 간격은 16

MemoryLayout<String>.size       // 16
MemoryLayout<String?>.size      // 16  ← 늘지 않았다!

class C {}
MemoryLayout<C>.size            // 8
MemoryLayout<C?>.size           // 8   ← 늘지 않았다!

MemoryLayout<Bool>.size         // 1
MemoryLayout<Bool?>.size        // 1   ← 늘지 않았다!
MemoryLayout<Bool??>.size       // 1   ← 두 겹인데도 안 늘었다

차이의 원인은 extra inhabitant(여분 거주자)입니다 — 그 타입이 절대 쓰지 않는 비트 패턴이 있으면, 컴파일러가 거기에 nil을 숨깁니다.

Int? — 여분 패턴이 없다 Int는 2⁶⁴가지 비트 패턴을 전부 쓴다 value (8 bytes) tag → size 9바이트, stride 16 nil을 표현할 자리가 없어서 태그 바이트를 따로 붙인다 String? / 클래스 참조? — 여분 패턴이 있다 포인터는 0x0 같은 값을 절대 안 쓴다 value (16 bytes) — 태그 없음 → size 그대로, 오버헤드 0 유효한 값들이 쓰는 패턴: 0x1000, 0x2A48, ... 아무도 안 쓰는 패턴 0x0 → 여기에 nil을 넣는다 = extra inhabitant 여분 패턴이 여러 개면 Bool?? 처럼 중첩 옵셔널도 같은 1바이트에 다 들어간다
"Optional은 메모리를 더 쓴다"는 Int 같은 타입에만 맞다. 참조·String처럼 여분 비트 패턴이 있는 타입은 공짜다.
중첩 옵셔널도 공짜인 이유 — 여분 패턴을 하나씩 소비한다
MemoryLayout<Bool>.size      // 1  — Bool은 0과 1만 쓴다 → 2~255가 전부 여분
MemoryLayout<Bool?>.size     // 1  — nil을 2로 표현
MemoryLayout<Bool??>.size    // 1  — 바깥 nil을 3으로 표현
MemoryLayout<Bool???>.size   // 1  — 4로... 여분이 남는 한 계속 공짜

// 반대로 Int는 여분이 없으니 한 겹부터 태그가 필요하다
MemoryLayout<Int?>.size      // 9
MemoryLayout<Int??>.size     // 10  ← 겹마다 1바이트씩 늘어난다

IUO(String!)의 정체

타입 시스템상 여전히 Optional이다
var s: String! = "hi"
let t = s            // t의 타입은 String? 이다 (String이 아니다!)
print(type(of: t))   // Optional<String>

s = nil
print(s.count)       // 💥 크래시 — 사용 시점에 자동 강제 언래핑

// 남아 있는 이유
//   ① ObjC API 브리징 — nullability 표기가 없는 헤더는 IUO로 들어온다
//   ② 2단계 초기화 — @IBOutlet 은 init 때 nil, 뷰 로드 후 채워진다

// 새 코드에서 쓰는 것 = "여기서 nil이면 크래시"를 암묵적으로 선언하는 것
// → 명시적으로 쓰는 게 낫다
private var config: Config!                     // ❌ 암묵적
private var config: Config?                     // ✅ 명시적으로 다룬다
private lazy var config: Config = makeConfig()  // ✅ 더 나은 대안

L4CS 근본 — 합 타입, 널 참조, 태그 압축

① Optional은 "+1"이다 — 합 타입의 산술

타입의 크기를 세면 Optional의 정체가 드러난다
|Bool|          = 2                    // true, false
|Optional<T>|   = |T| + 1              // T의 모든 값 + nil 하나

|Bool?|         = 2 + 1 = 3            // true, false, nil
|Bool??|        = 3 + 1 = 4
|(Bool, Bool)|  = 2 × 2 = 4            // 곱 타입 (struct/tuple)
|Bool?  × Bool| = 3 × 2 = 6

// 그래서 이런 판단이 "취향"이 아니라 "계산"이 된다
struct Bad  { var loading: Bool; var error: Error?; var data: [Item]? }
//            2 × (많음+1) × (많음+1)  ← 대부분이 무효 상태

enum Good { case idle, loading, loaded([Item]), failed(Error) }
//          정확히 4가지 — 무효 상태가 존재하지 않는다

Swift의 enum태그드 유니온(tagged union)이고, 이론적으로는 합 타입(sum type)입니다. C의 union과 다른 점이 결정적인데 — 어느 케이스가 들어 있는지 태그가 함께 저장되고, 컴파일러가 태그 검사를 강제합니다. C의 union은 태그를 개발자가 따로 관리해야 해서, 잘못된 멤버를 읽는 것을 아무도 막지 않습니다.

② 널 참조 — "십억 달러짜리 실수"

널 참조를 1965년 ALGOL W에 도입한 Tony Hoare는 2009년 강연에서 이를 "my billion-dollar mistake"라고 불렀습니다. 이유는 구현이 쉬웠기 때문이었고, 결과는 수십 년간의 널 포인터 역참조 버그였습니다.

문제의 본질 — 널이 "모든 타입의 값"이 되어 버린 것
// Java / Objective-C / C
String s = null;      // String 타입인데 String이 아닌 값이 들어간다
NSString *s = nil;    // 타입은 NSString*, 값은 없음 — 타입이 거짓말을 한다

// 이게 왜 치명적인가:
//   ① 타입 시그니처가 "이 값이 없을 수 있음"을 말해 주지 않는다
//   ② 컴파일러가 검사를 강제할 근거가 없다
//   ③ 오류가 "생성 지점"이 아니라 "사용 지점"에서 터진다 (원인에서 멀다)

// Swift의 해법 — 없을 수 있음을 "다른 타입"으로 만든다
let a: String  = ...   // 절대 nil이 아님이 타입으로 보장된다
let b: String? = ...   // 없을 수 있음이 타입에 적혀 있다
// String 과 String? 은 서로 다른 타입이라 대입조차 안 된다

Swift가 한 일은 "널을 없앤 것"이 아니라 "널을 타입 시스템 안으로 끌고 들어온 것"입니다. Optional 역시 값이 없을 수 있지만, 그 사실이 타입에 드러나므로 컴파일러가 검사를 강제할 수 있습니다. 같은 아이디어가 Haskell의 Maybe, Rust의 Option, Kotlin의 ? 타입입니다.

flatMap은 모나드 바인드다

왜 map과 flatMap을 나눠 놨는가
// map:     (T) -> U           를 받아  T? -> U?
// flatMap: (T) -> U?          를 받아  T? -> U?      ← 결과를 평탄화

let s: String? = "42"
let a = s.map { Int($0) }        // Int??  ← 두 겹이 됐다
let b = s.flatMap { Int($0) }    // Int?   ← 평탄화됨

// 이 "평탄화하며 잇기"가 모나드의 bind 연산이다.
// 옵셔널 체이닝이 바로 이것의 문법 설탕이다 —
//   user?.address?.city 는 개념적으로 flatMap 연쇄와 같은 구조이고,
//   그래서 결과가 City??? 가 아니라 City? 로 평탄화된다.

// 그래서 이런 파이프라인이 자연스럽게 나온다 — 어디서든 nil이면 전체가 nil
let url: URL? = configString
    .flatMap { Int($0) }              // String? -> Int?
    .map    { "https://api/\($0)" }   // Int?    -> String?
    .flatMap(URL.init(string:))       // String? -> URL?

// ⚠️ 함정: Optional의 flatMap 과 Sequence의 flatMap 은 다른 것이다
//    Sequence의 "nil 제거" 용도는 compactMap 으로 이름이 분리됐다

이걸 알면 "왜 try?가 중첩 옵셔널을 만들지 않는가" 같은 질문도 같은 원리로 설명됩니다 — 언어가 평탄화를 언어 차원에서 처리하기 때문입니다.

④ Extra inhabitant는 포인터 태깅·NaN 박싱과 같은 기법

"안 쓰는 비트 패턴에 추가 정보를 숨긴다"는 아이디어는 Swift만의 것이 아니라 시스템 프로그래밍의 고전 기법입니다.

기법어디서무엇을 이용하나
extra inhabitantSwift Optional포인터가 절대 안 쓰는 값(0x0 등)에 nil
포인터 태깅ObjC NSNumber, 각종 VM정렬 때문에 항상 0인 하위 비트에 타입/값을 저장
tagged pointerObjC 작은 문자열·숫자포인터 자체에 값을 넣어 힙 할당을 없앰
NaN 박싱JavaScript 엔진double의 NaN 비트 패턴이 2⁵¹가지인 점을 이용해 다른 타입을 인코딩
널 포인터 최적화Rust Option<&T>참조가 널일 수 없으므로 None을 0으로 — Swift와 동일
포인터 태깅이 가능한 이유 — 정렬이 남기는 빈 비트
// 8바이트 정렬된 객체의 주소는 항상 8의 배수다
0x1000  = ...0001 0000 0000 0000    하위 3비트 = 000
0x1008  = ...0001 0000 0000 1000    하위 3비트 = 000
0x1010  = ...0001 0000 0001 0000    하위 3비트 = 000
                                    ↑ 이 3비트는 언제나 0 = 낭비

// → 여기에 태그 3비트를 넣고, 역참조 전에 마스킹해서 지운다
let tagged = address | 0b011        // 태그 심기
let real   = tagged & ~0b111        // 태그 지우고 실제 주소 얻기

// Swift의 extra inhabitant도 정확히 같은 발상이다:
//   "쓰이지 않는 표현 공간이 있으면, 공짜로 정보를 더 담을 수 있다"
🧠 L4 이 답을 할 수 있으면 다르다

"Optional을 쓰면 메모리를 더 쓰나요?"에 대한 완결된 답:
"타입에 따라 다릅니다. Int?처럼 모든 비트 패턴을 쓰는 타입은 태그 바이트가 붙어 9바이트가 되지만, String?이나 클래스 참조는 extra inhabitant — 절대 쓰이지 않는 비트 패턴 — 에 nil을 숨기므로 오버헤드가 0입니다. 같은 아이디어를 ObjC의 tagged pointer나 JS 엔진의 NaN 박싱도 씁니다. 즉 Optional은 추상화 비용이 대체로 0인, 드문 종류의 추상화입니다."

CS 정본으로 더 내려가기

이 페이지의 L4는 면접 답변에 필요한 깊이까지만 팝니다. 같은 주제를 CS 원리 → iOS 매핑 → 재현·측정 순서로 끝까지 파는 것은 허브의 iOS 개발자 CS 로드맵이고, 대응 챕터는 아래입니다.

경험으로 말하기

🎤 문법이 아니라 "옵셔널을 어디서 없앴는가"

"guard let을 써서 안전하게 처리했습니다."

"서버 응답 필드가 거의 다 옵셔널이었고, DTO를 그대로 화면까지 넘기다 보니 셀 구성 코드가 if let 다섯 겹이었습니다. 디코딩 계층에 도메인 모델 변환을 넣고, 필드마다 ① 없으면 에러로 승격 ② 기본값 적용 ③ 진짜 선택 항목이라 옵셔널 유지 셋 중 하나로 결정해서 내려보냈습니다. 그 결과 도메인 모델에 남은 옵셔널이 12개에서 3개로 줄었고, 남은 3개는 왜 옵셔널인지 설명할 수 있는 것만 남았습니다. 부수 효과로 '화면에 아무것도 안 뜨는데 에러도 안 뜬다'는 버그가 사라졌습니다 — 이제 파싱 실패가 조용히 nil이 되는 대신 에러로 올라오니까요."

꼬리 질문 대비

L2 force unwrap은 절대 쓰면 안 되나요?

"절대"는 아닙니다. 이렇게 답하면 오히려 좋습니다.

정당한 !의 조건은 하나입니다 — "nil이면 그건 프로그래머 오류이고, 계속 진행하는 것보다 즉시 멈추는 게 낫다." 대표적으로 @IBOutlet, 하드코딩 리터럴, 방금 넣은 값입니다.

다만 실무 규칙은 이렇게 둡니다.

  • 같은 크래시라도 정보가 남는 쪽을 고른다! 대신 guard ... else { preconditionFailure("이유: \(값)") }. 크래시 로그에서 원인 파악 시간이 완전히 달라집니다.
  • 외부 입력에는 절대 쓰지 않는다 — 서버 응답, 사용자 입력, 파일 내용. 이건 프로그래머 오류가 아니라 정상적으로 일어날 수 있는 실패throws가 맞습니다.
  • 린트로 강제한다 — SwiftLint의 force_unwrapping/force_cast 룰을 켜고, 예외는 // swiftlint:disable:next근거 주석을 달게 합니다.
쉽게: 비상 정지 버튼과 같습니다. 있으면 안 되는 게 아니라, 아무 데나 달면 안 되는 것이에요. 그리고 누를 때는 왜 눌렀는지 기록이 남아야 합니다.
L3 Optional과 throws는 어떻게 구분해서 쓰나요?

기준은 "실패에 이유가 있는가, 그리고 호출자가 그 이유로 다르게 행동하는가"입니다.

같은 함수를 세 가지로
// ① Optional — "없는 게 정상". 호출자는 이유를 알 필요가 없다
dict["key"]                       // 키가 없는 건 정상이다
Int("abc")                        // 변환 실패 이유가 하나뿐이다
array.first                       // 빈 배열은 정상이다

// ② throws — 이유가 여럿이고, 이유마다 대응이 다르다
func load() throws -> User        // notFound → 안내, network → 재시도,
                                  // unauthorized → 로그인 화면

// ③ Result — 비동기 콜백이거나 실패를 값으로 들고 다녀야 할 때
func load(_ done: (Result<User, LoadError>) -> Void)

실전 판별법: "catch 블록에서 에러를 보고 분기할 것 같은가?" 그렇다면 throws입니다. catch { return nil }로 끝날 것 같다면 Optional로 충분합니다.

추가로, try?는 정보를 버리는 연산이라는 걸 알아야 합니다. throws를 Optional로 강등시키는 것이니, 쓸 때마다 "이 에러 이유를 정말 버려도 되나"를 확인해야 합니다.

쉽게: 택배가 안 왔을 때 "안 왔음"만 알면 되는 경우(Optional)와, "주소 불명인지, 배송 중인지, 반송됐는지"에 따라 다르게 행동해야 하는 경우(throws)의 차이입니다.
L4 Optional도 결국 nil인데, 왜 안전하다고 하나요?

핵심은 "nil이 없어진 게 아니라, nil의 위치가 값에서 타입으로 옮겨간 것"입니다.

널 참조의 진짜 문제는 nil의 존재가 아니라, String 타입 자리에 nil이 들어갈 수 있다는 것이었습니다. 타입이 거짓말을 하니 컴파일러가 검사할 근거가 없었죠.

Swift는 StringString?서로 다른 타입으로 만들었습니다. 그러면 세 가지가 따라옵니다.

  • 대입 자체가 막힌다String?String에 넣으려면 반드시 언래핑 코드를 써야 합니다. 검사를 잊을 수가 없습니다.
  • 시그니처가 문서가 된다func f() -> User?만 봐도 실패 가능성을 압니다. 주석이 필요 없습니다.
  • 오류 지점이 앞당겨진다 — 널 역참조는 사용 시점에 터지지만, Optional은 컴파일 시점에 잡힙니다.

그리고 앞서 본 대로 이 안전이 대체로 공짜입니다 — extra inhabitant 덕에 메모리 오버헤드가 0인 경우가 많고, @frozen enum이라 최적화도 잘 됩니다. 안전을 런타임 비용 없이 산 드문 사례이고, 그래서 02번의 "런타임 검사를 타입으로 옮긴다"의 대표 예입니다.

쉽게: 예전에는 모든 상자에 "비었을 수도 있음"이 적혀 있지 않았습니다. 그래서 열어 보고 비어 있으면 사고가 났죠. 지금은 비었을 수 있는 상자에 다른 색 라벨을 붙입니다. nil이 사라진 게 아니라, 어느 상자가 비어 있을 수 있는지 겉에서 보이게 된 겁니다.

쉽게 이해하기

Optional을 "내용물이 있을 수도 없을 수도 있는 봉투"라고 생각해 봅시다. Swift는 봉투를 뜯지 않고는 내용물을 못 쓰게 합니다. 귀찮지만, 예전 언어들은 봉투인지 아닌지도 모르는 채로 꺼내 쓰다가 사고가 났어요.

중요한 건 문법이 아니라 "이걸 봉투에 넣는 게 맞나?"입니다.

그리고 메모리 이야기가 재미있는 지점입니다. "봉투를 하나 더 쓰니까 자리를 더 차지하겠네?"라고 생각하기 쉬운데, 꼭 그렇지 않습니다.

숫자는 0부터 아주 큰 수까지 전부 의미가 있어서, "비었음"을 표시할 자리가 없습니다. 그래서 옆에 표식 한 칸을 더 붙여야 해요. 그런데 주소(포인터)는 다릅니다. "0번지"라는 주소는 아무도 안 씁니다. 그러니 0번지를 "비었음"이라는 뜻으로 쓰기로 약속하면 됩니다 — 자리를 하나도 더 안 쓰고요.

이 "안 쓰는 자리를 찾아서 정보를 숨기는" 수법은 컴퓨터 곳곳에 있습니다. 주소가 항상 8의 배수라서 맨 끝 세 자리가 늘 0이면, 거기에 몰래 표시를 남겨 두는 식이죠. 공간을 새로 만드는 게 아니라 찾아내는 겁니다.

🔑 한 문장

Optional은 nil을 없애는 장치가 아니라 nil을 타입으로 끌어올리는 장치다. 그리고 안전 사용의 핵심은 문법이 아니라 "이 없음이 정상인지 실패인지"를 경계에서 결정하는 것이다.

설계 근거 · 1차 자료

이 페이지의 "왜 그렇게 설계했나" 주장은 아래에서 나왔습니다. 면접에서 근거를 물으면 이 이름들을 대면 됩니다.

📌 각 자료가 뒷받침하는 것
  • Tony Hoare — 널 참조를 "실수"라 부른 당사자의 회고 — Optional 설계의 출발점
  • TSPL — 체이닝이 평탄화되는 규칙의 공식 정의
  • SE-0345 — 언래핑 문법을 왜 더 줄였는가