- Optional을 안전하게 쓴다는 게 무슨 뜻인가요?
- force unwrap(
!)은 절대 쓰면 안 되나요? - Optional을 쓰면 메모리를 더 쓰나요?
enum { case none; case some(Wrapped) } — nil을 없애는 게 아니라 그냥 지나칠 수 없게 만든다String?·클래스 참조는 오버헤드 0. Int?만 1바이트가 는다30초 답변 🔥 먼저 이 문장
그래서 "안전 사용"의 핵심은
if let이냐 guard let이냐 같은 문법이 아니라 설계 판단입니다 —
이 "없음"이 정상 상태인지(→ Optional), 실패인지(→ throws), 아니면 애초에 없어야 할 상태인지(→ 타입을 바꿔라).
잘못된 층에서 Optional을 쓰면 if let이 화면 코드까지 번집니다.
L1개념 — 정체와 도구 전부
@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(x > 0, "...") // Debug(-Onone)에서만 검사. Release에서 제거됨
assertionFailure("...") // ↳ 같은 조건
precondition(x > 0, "...") // Debug + Release(-O) 모두 검사
preconditionFailure("...") // ↳ -Ounchecked 에서만 제거됨
fatalError("...") // 항상 검사. 최적화 수준과 무관하게 살아남는다
// ↳ Never 반환이라 컴파일러가 도달 불가로 처리
// 선택 기준
// 내부 로직 검증(성능 민감) → assert
// 외부 입력·API 계약 위반 → precondition ← 대부분 이게 맞다
// 도달하면 안 되는 분기 → fatalError"릴리즈에서도 잡히길 원하는가"가 판단 기준입니다. 사용자 기기에서 잘못된 상태로 계속 도는 것보다, 즉시 멈추고 크래시 리포트를 받는 게 나은 경우가 많습니다.
L3구현 — Optional은 정말 메모리를 더 쓰는가
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을 숨깁니다.
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!)의 정체
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"이다 — 합 타입의 산술
|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: (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 inhabitant | Swift Optional | 포인터가 절대 안 쓰는 값(0x0 등)에 nil |
| 포인터 태깅 | ObjC NSNumber, 각종 VM | 정렬 때문에 항상 0인 하위 비트에 타입/값을 저장 |
| tagged pointer | ObjC 작은 문자열·숫자 | 포인터 자체에 값을 넣어 힙 할당을 없앰 |
| 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도 정확히 같은 발상이다:
// "쓰이지 않는 표현 공간이 있으면, 공짜로 정보를 더 담을 수 있다"
"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로 강등시키는 것이니, 쓸 때마다 "이 에러 이유를 정말 버려도 되나"를 확인해야 합니다.
L4 Optional도 결국 nil인데, 왜 안전하다고 하나요?
핵심은 "nil이 없어진 게 아니라, nil의 위치가 값에서 타입으로 옮겨간 것"입니다.
널 참조의 진짜 문제는 nil의 존재가 아니라, String 타입 자리에 nil이 들어갈 수 있다는 것이었습니다. 타입이 거짓말을 하니 컴파일러가 검사할 근거가 없었죠.
Swift는 String과 String?을 서로 다른 타입으로 만들었습니다. 그러면 세 가지가 따라옵니다.
- 대입 자체가 막힌다 —
String?을String에 넣으려면 반드시 언래핑 코드를 써야 합니다. 검사를 잊을 수가 없습니다. - 시그니처가 문서가 된다 —
func f() -> User?만 봐도 실패 가능성을 압니다. 주석이 필요 없습니다. - 오류 지점이 앞당겨진다 — 널 역참조는 사용 시점에 터지지만, Optional은 컴파일 시점에 잡힙니다.
그리고 앞서 본 대로 이 안전이 대체로 공짜입니다 — extra inhabitant 덕에 메모리 오버헤드가 0인 경우가 많고, @frozen enum이라 최적화도 잘 됩니다. 안전을 런타임 비용 없이 산 드문 사례이고, 그래서 02번의 "런타임 검사를 타입으로 옮긴다"의 대표 예입니다.
쉽게 이해하기
Optional을 "내용물이 있을 수도 없을 수도 있는 봉투"라고 생각해 봅시다. Swift는 봉투를 뜯지 않고는 내용물을 못 쓰게 합니다. 귀찮지만, 예전 언어들은 봉투인지 아닌지도 모르는 채로 꺼내 쓰다가 사고가 났어요.
중요한 건 문법이 아니라 "이걸 봉투에 넣는 게 맞나?"입니다.
- 선물에 리본이 있을 수도 없을 수도 있다 → 봉투가 맞습니다(정상적으로 없을 수 있음).
- 배송이 실패했다 → 봉투가 아니라 사유서가 필요합니다. "빈 봉투"로는 왜 실패했는지를 알 수 없어요(
throws). - "아직 배송 안 함 / 배송 중 / 도착 / 반송"처럼 상태가 여럿이면, 봉투 하나로 뭉개면 안 됩니다(enum).
그리고 메모리 이야기가 재미있는 지점입니다. "봉투를 하나 더 쓰니까 자리를 더 차지하겠네?"라고 생각하기 쉬운데, 꼭 그렇지 않습니다.
숫자는 0부터 아주 큰 수까지 전부 의미가 있어서, "비었음"을 표시할 자리가 없습니다. 그래서 옆에 표식 한 칸을 더 붙여야 해요. 그런데 주소(포인터)는 다릅니다. "0번지"라는 주소는 아무도 안 씁니다. 그러니 0번지를 "비었음"이라는 뜻으로 쓰기로 약속하면 됩니다 — 자리를 하나도 더 안 쓰고요.
이 "안 쓰는 자리를 찾아서 정보를 숨기는" 수법은 컴퓨터 곳곳에 있습니다. 주소가 항상 8의 배수라서 맨 끝 세 자리가 늘 0이면, 거기에 몰래 표시를 남겨 두는 식이죠. 공간을 새로 만드는 게 아니라 찾아내는 겁니다.
Optional은 nil을 없애는 장치가 아니라 nil을 타입으로 끌어올리는 장치다. 그리고 안전 사용의 핵심은 문법이 아니라 "이 없음이 정상인지 실패인지"를 경계에서 결정하는 것이다.
설계 근거 · 1차 자료
이 페이지의 "왜 그렇게 설계했나" 주장은 아래에서 나왔습니다. 면접에서 근거를 물으면 이 이름들을 대면 됩니다.
- Tony Hoare — 널 참조를 "실수"라 부른 당사자의 회고 — Optional 설계의 출발점
- TSPL — 체이닝이 평탄화되는 규칙의 공식 정의
- SE-0345 — 언래핑 문법을 왜 더 줄였는가