옵셔널 - 값이 없을 수 있음을 타입으로
이 장에서 배우는 것
습관 기록 앱은 값이 없는 순간을 계속 만난다. 오늘 아직 운동을 기록하지 않았을 수 있고, 사용자가 숫자 칸에 글자를 적었을 수도 있다. 목표를 정하지 않은 습관을 조회할 수도 있다. 이 장에서는 이런 "값이 없을 수 있음"을 Swift가 타입으로 표현하는 방법, 즉 옵셔널(Optional)을 배운다. 앞 장에서 배운 guard와 이 장의 옵셔널을 합치면 입력 검사 코드를 짧고 읽기 쉽게 쓸 수 있다.
Int?같은 옵셔널 타입과nil이 무엇인지 설명할 수 있다.if let과guard let으로 값을 안전하게 꺼낼 수 있다.??로 기본값을 정하고, 옵셔널 체이닝(?.)으로 값이 없으면 중간에 멈추는 코드를 쓸 수 있다.- 강제 언래핑(
!)이 프로그램을 멈추게 하는 이유를 설명하고 대안을 고를 수 있다. map으로 옵셔널 안의 값을 변환할 수 있다.
문제 상황
습관 기록 앱에 다음 세 가지 일이 동시에 일어난다고 하자.
- 사용자가 "물 6잔"은 "6"으로, "운동 30분"은 "삼십"으로 입력했다. 문자열을 숫자로 바꾸면 앞의 것은 성공하고 뒤의 것은 실패한다.
- "명상"이라는 습관을 조회했는데 목표가 등록된 적이 없다.
- 오늘 기록이 하나도 없는 날의 최댓값을 구하려 한다. 비교할 값 자체가 없다.
많은 언어는 이런 상황을 "null"이라는 특별한 값으로 처리한다. 문제는 어떤 변수가 null일 수 있는지 코드만 봐서는 알기 어렵다는 점이다. 확인을 빠뜨리면 프로그램은 실행 중에 갑자기 멈춘다. Swift는 값이 없을 수 있는 변수에 다른 타입을 붙여서 이 문제를 컴파일 단계로 끌어온다. 확인하지 않고는 값을 쓸 수 없게 만든다.
옵셔널은 값이 들어 있을 수도 있는 상자다
Swift에서 Int는 언제나 정수 값이 있는 타입이다. 값이 없을 수도 있다면 타입 뒤에 ?를 붙여 Int?로 쓴다. 이것이 옵셔널(Optional) 타입이다. Int?는 "정수가 들어 있는 상자이거나, 비어 있는 상자"로 생각하면 된다. 비어 있는 상태를 나타내는 값이 nil이다.
중요한 점은 Int?와 Int가 서로 다른 타입이라는 것이다. 상자 안의 값을 꺼내기 전에는 더하기나 비교 같은 정수 계산에 쓸 수 없다. 상자에서 값을 꺼내는 일을 언래핑(unwrapping)이라 한다. 이 장의 나머지는 모두 언래핑을 어떻게 안전하게 하느냐에 관한 이야기다.
옵셔널은 우리가 이미 쓴 곳에서도 나온다. 딕셔너리에서 키로 값을 찾으면 그 키가 없을 수 있으므로 결과는 항상 옵셔널이다. goals["물"]의 타입은 Int가 아니라 Int?다. 문자열을 정수로 바꾸는 Int("6")도 실패할 수 있어서 Int?를 돌려준다. 배열의 first와 max()도 요소가 없을 수 있어서 옵셔널이다.
if let: 값이 있을 때만 안쪽 코드를 실행한다
가장 기본적인 언래핑은 if let이다.
if let goal = goals["물"] {
print("물 목표: \(goal)")
} else {
print("물 목표: 없음")
}
상자에 값이 있으면 그 값이 goal이라는 이름의 일반 Int로 꺼내져 중괄호 안에서 쓰인다. 비어 있으면 else 쪽이 실행된다. 꺼낸 값의 이름이 원래 변수 이름과 같아도 되면 if let lastMemo {처럼 이름을 한 번만 쓸 수 있다. 쉼표로 조건을 이어 붙이면 여러 상자를 한꺼번에 확인하고, 일반 조건도 함께 쓸 수 있다. 모든 조건이 참일 때만 안쪽이 실행된다.
guard let: 값이 없으면 일찍 빠져나간다
앞 장에서 guard는 조건이 맞지 않으면 일찍 빠져나가는 문이라고 배웠다. guard let은 그 문에 언래핑을 합친 것이다. 통과하면 꺼낸 값이 guard 아래 줄 전체에서 쓰인다. if let은 값을 쓰는 범위가 중괄호 안으로 한정되지만, guard let은 그 뒤 코드 전체가 범위다. 그래서 입력 검사를 앞에 모아 두고 본 작업을 들여쓰기 없이 쓸 수 있다.
옵셔널을 다루는 도구들
값이 없을 때 어떻게 하고 싶은지에 따라 도구를 고른다.
| 방법 | 쓰는 때 | nil 이면 | 예 |
|---|---|---|---|
| if let | 있을 때와 없을 때 할 일이 다르다 | else 쪽 실행 | 목표 출력 |
| guard let | 없으면 더 진행할 수 없다 | 함수나 반복에서 빠져나감 | 입력 검사 |
| ?? | 없을 때 쓸 대체 값이 있다 | 기본값 사용 | 기록 없으면 0 |
| ?. | 값 안의 속성이나 메서드를 부른다 | 전체 결과가 nil | 메모 글자 수 |
| map | 있는 값만 변환하고 싶다 | nil 그대로 | "6잔 마심" |
| ! | 없을 수 없다고 직접 보증한다 | 프로그램이 멈춤 | 쓰지 않는다 |
?? 로 기본값 정하기
??는 왼쪽이 옵셔널이고, 값이 있으면 그 값을, nil이면 오른쪽 값을 돌려준다. 결과는 옵셔널이 아닌 일반 타입이다. 예를 들어 records["운동"] ?? 0은 기록이 있으면 그 분 수를, 없으면 0을 준다. 한 줄로 끝나서 편하지만, "없음"과 "0"은 의미가 다르다는 점을 뒤에서 다시 다룬다.
옵셔널 체이닝으로 값이 없으면 중간에 멈추기
메모는 있을 수도 없을 수도 있다. 메모의 글자 수를 알고 싶을 때 memos["물"]?.count처럼 옵셔널 뒤에 ?.를 쓴다. 값이 있으면 점 뒤의 속성이나 메서드를 실행하고, nil이면 그 자리에서 멈추고 전체 결과를 nil로 만든다. 결과 타입은 Int가 아니라 Int?다. 점을 여러 번 이어 써도 중간에 하나라도 nil이면 끝까지 nil이며, 결과에는 옵셔널이 겹쳐 쌓이지 않고 한 겹만 남는다. 마지막에 ??를 붙여 일반 값으로 바꾸는 방식이 흔하다.
map 으로 있는 값만 변환하기
옵셔널에도 map이 있다. 값이 있으면 그 값에 변환을 적용해 다시 옵셔널로 돌려주고, nil이면 nil을 그대로 돌려준다. if let으로 풀어서 쓸 만큼은 아닌 한 줄짜리 변환에 알맞다. 중괄호 안의 $0은 상자에서 꺼낸 값을 가리키는 짧은 이름이다. 중괄호로 묶은 이런 코드 조각을 클로저(closure)라 부르는데, 정확한 문법은 다음 장에서 함수와 함께 다룬다. 여기서는 "값을 받아 결과를 만드는 짧은 식" 정도로 읽으면 된다.
강제 언래핑이 위험한 이유
옵셔널 뒤에 !를 붙이면 검사 없이 값을 꺼낸다. 프로그래머가 "여기에는 반드시 값이 있다"고 컴파일러에게 약속하는 문법이다. 값이 실제로 있으면 문제가 없다. nil이면 프로그램이 그 자리에서 즉시 종료되며, 실행 중에 "Unexpectedly found nil while unwrapping an Optional value"라는 오류 메시지가 나온다. 문제는 컴파일 단계에서는 아무 경고가 없다는 것이다. 개발 중에 쓴 테스트 데이터에는 값이 항상 있어서 잘 돌다가, 실제 사용자가 빈 값을 넣는 순간 멈춘다. 옵셔널을 도입한 이유가 "확인을 강제하는 것"인데 !는 그 확인을 통째로 건너뛴다. 이 책의 예제는 !를 쓰지 않는다. 위 표의 다른 방법으로 거의 모든 경우를 대신할 수 있다.
옵셔널의 더 자세한 규칙은 Swift 공식 문서의 기본 문법과 옵셔널 체이닝 항목에서 확인할 수 있다.
완성 코드
아래 코드는 습관 기록 앱의 기록 입력 처리를 화면 없이 흉내 낸다. 파일 하나(main.swift)로 되어 있다.
let goals: [String: Int] = ["물": 8, "운동": 30, "독서": 20]
let rawInputs: [String: String] = ["물": "6", "운동": "삼십", "독서": "20"]
let memos: [String: String] = ["물": "점심 이후 2잔 추가"]
let habitNames = ["물", "운동", "독서", "명상"]
print("== 1. 값이 없을 수 있는 타입 ==")
var lastMemo: String? = nil
print(lastMemo == nil ? "메모 없음" : "메모 있음")
lastMemo = "저녁 산책"
if let lastMemo {
print("메모: \(lastMemo)")
}
for name in habitNames {
if let goal = goals[name] {
print("\(name) 목표: \(goal)")
} else {
print("\(name) 목표: 없음")
}
}
print("== 2. guard let 으로 입력 변환 ==")
var records: [String: Int] = [:]
for name in habitNames {
guard let text = rawInputs[name] else {
print("\(name): 입력 없음, 건너뜀")
continue
}
guard let amount = Int(text) else {
print("\(name): 입력 \"\(text)\" 변환 실패, 건너뜀")
continue
}
records[name] = amount
print("\(name): \(amount) 기록")
}
print("== 3. ?? 로 기본값 ==")
for name in ["물", "운동", "독서"] {
let done = records[name] ?? 0
let goal = goals[name] ?? 1
print("\(name): \(done)/\(goal) (\(done * 100 / goal)%)")
}
if let done = records["물"], let goal = goals["물"], done < goal {
print("물: 목표까지 \(goal - done)잔 남음")
}
print("== 4. 옵셔널 체이닝 ==")
for name in ["물", "운동"] {
let memoLength = memos[name]?.count
print("\(name) 메모 글자 수: \(memoLength ?? 0)")
}
let firstWord = memos["물"]?.split(separator: " ").first
print("물 메모 첫 단어: \(firstWord ?? "없음")")
print("== 5. map ==")
let waterText = records["물"].map { "\($0)잔 마심" } ?? "기록 없음"
let exerciseText = records["운동"].map { "\($0)분 운동" } ?? "기록 없음"
print(waterText)
print(exerciseText)
print("== 6. 빈 컬렉션의 최댓값 ==")
let emptyRecords: [String: Int] = [:]
print("빈 기록의 최댓값: \(emptyRecords.values.max() ?? -1)")
print("오늘 최대 기록: \(records.values.max() ?? -1)")
줄별 해설
- 1~4줄: 목표, 입력 문자열, 메모, 습관 이름 목록이다. 입력이 문자열인 것은 사용자가 화면에 적은 글을 받는 상황을 흉내 낸 것이다. 습관 이름은 딕셔너리가 아니라 배열에 담아 순회해야 출력 순서가 항상 같다.
- 7줄:
String?에nil을 넣어 "아직 메모가 없다"는 상태를 만든다.var인 이유는 아래에서 값을 다시 넣기 때문이다. 8줄의== nil비교는 값을 꺼내지 않고 비어 있는지만 확인한다. - 10~12줄:
if let lastMemo는 같은 이름으로 값을 꺼내는 줄임 표기다. 중괄호 안의lastMemo는String이다. - 13~19줄: 딕셔너리 조회 결과가
Int?이므로if let으로 나눈다. "명상"은 목표가 없어else로 간다. - 24~27줄: 첫 번째
guard let은 입력 자체가 있는지 본다. 없으면continue로 이번 반복을 건너뛴다.guard의else는 반드시 현재 흐름을 벗어나야 한다. - 28~31줄:
Int(text)는 변환에 실패하면 nil을 돌려주므로 두 번째guard let으로 걸러낸다. "삼십"은 여기서 걸러진다. 통과한amount는 일반Int라서records에 바로 넣는다. - 35~37줄:
records[name] ?? 0은 기록이 없는 습관을 0으로 센다.goals[name] ?? 1의 1은 아래 줄의 나눗셈에서 0으로 나누는 일을 막기 위한 값이다. 목표가 있는 세 습관만 순회하므로 실제로는 쓰이지 않는다. - 39줄: 쉼표로 두 상자를 동시에 열고
done < goal조건까지 검사한다. 하나라도 어긋나면 안쪽은 실행되지 않는다. - 43줄:
memos[name]?.count는Int?다. "운동"은 메모가 없어 nil이 되고,?? 0으로 0이 된다. - 46줄: 점 두 개(
split,first)가 체이닝으로 이어진다. 메모가 없었다면 전체가 nil이 되어 "없음"이 출력된다. - 50~51줄: 기록이 있으면
map이 문장으로 바꾸고, 없으면 nil이 남아??가 대체 문구를 채운다. 문장 변환과 기본값 처리가 한 줄에 들어간다. - 56~57줄: 빈 컬렉션의
max()는 nil이다. 값이 없다는 사실을-1로 표시했다. 이 -1은 정상 기록에 나올 수 없는 값이라서 구분이 가능하다.
실행 결과
$ swiftc main.swift -o app
$ ./app
== 1. 값이 없을 수 있는 타입 ==
메모 없음
메모: 저녁 산책
물 목표: 8
운동 목표: 30
독서 목표: 20
명상 목표: 없음
== 2. guard let 으로 입력 변환 ==
물: 6 기록
운동: 입력 "삼십" 변환 실패, 건너뜀
독서: 20 기록
명상: 입력 없음, 건너뜀
== 3. ?? 로 기본값 ==
물: 6/8 (75%)
운동: 0/30 (0%)
독서: 20/20 (100%)
물: 목표까지 2잔 남음
== 4. 옵셔널 체이닝 ==
물 메모 글자 수: 11
운동 메모 글자 수: 0
물 메모 첫 단어: 점심
== 5. map ==
6잔 마심
기록 없음
== 6. 빈 컬렉션의 최댓값 ==
빈 기록의 최댓값: -1
오늘 최대 기록: 20
실무에서 자주 틀리는 것
딕셔너리 조회에 ! 를 붙인다
틀린 코드:
let goal = goals["명상"]!
print("목표: \(goal)")
"명상"은 목표가 등록되지 않아 실행하는 순간 프로그램이 멈춘다. 컴파일은 아무 문제 없이 통과해서, 키가 없는 데이터가 들어올 때까지 발견되지 않는다. 고친 코드:
guard let goal = goals["명상"] else {
print("목표가 등록되지 않았다")
return
}
print("목표: \(goal)")
위 조각은 앞 장에서 배운 대로 흐름을 벗어나야 하는 guard의 예다. 최상위 코드가 아니라 반복문 안이라면 return 대신 continue를 쓴다. 지금 단계에서 return은 함수 안에서만 쓸 수 있으니, 다음 장에서 함수를 배운 뒤에 그대로 옮겨 쓰면 된다.
"없음"을 0 으로 뭉개서 의미를 잃는다
틀린 코드:
let done = records["운동"] ?? 0
if done == 0 {
print("운동을 0분 했다")
}
기록이 없는 것과 0분이라고 기록한 것은 다른 사실인데 둘이 같은 출력이 된다. 통계에서 평균을 낼 때 기록이 없는 날까지 0으로 섞으면 평균이 낮아진다. 구분이 필요하면 ??를 쓰지 말고 if let으로 나눈다.
if let done = records["운동"] {
print("운동 \(done)분 기록")
} else {
print("운동 기록 자체가 없다")
}
기본값은 "없을 때 0으로 취급해도 계산이 맞는 경우"에만 쓴다.
if let 을 겹겹이 쌓는다
틀린 코드:
if let text = rawInputs["물"] {
if let amount = Int(text) {
if amount > 0 {
records["물"] = amount
}
}
}
동작은 하지만 정작 하는 일은 맨 안쪽 한 줄이고, 들여쓰기가 깊어질수록 읽기 어렵다. 쉼표로 이어 한 번에 쓰거나, 반복문 안이라면 guard let으로 펼친다.
if let text = rawInputs["물"], let amount = Int(text), amount > 0 {
records["물"] = amount
}
옵셔널을 그대로 문자열에 끼워 넣는다
틀린 코드:
print("물 기록: \(records["물"])")
컴파일러가 옵셔널의 디버그용 표현이 출력된다는 경고를 내고, 출력도 물 기록: Optional(6)처럼 사용자에게 보여 줄 문장이 아니게 된다. 값을 꺼내거나 기본값을 채운 뒤에 끼워 넣는다.
print("물 기록: \(records["물"] ?? 0)")
한눈에 보기
| 문법 | 의미 | 결과 타입 | 한 줄 예 |
|---|---|---|---|
| Int? | 값이 있거나 nil 이다 | 옵셔널 | var a: Int? = nil |
| if let x = y | 값이 있을 때만 블록 실행 | x 는 일반 타입 | if let g = goals["물"] { } |
| guard let x = y else | 없으면 빠져나가고 이후 x 사용 | x 는 일반 타입 | guard let n = Int(s) else { continue } |
| y ?? d | nil 이면 기본값 d | 일반 타입 | records["물"] ?? 0 |
| y?.member | nil 이면 전체가 nil | 옵셔널 | memos["물"]?.count |
| y.map { } | 있는 값만 변환 | 옵셔널 | records["물"].map { $0 * 2 } |
| y! | 검사 없이 꺼냄, nil 이면 종료 | 일반 타입 | 쓰지 않는다 |
| 코드 | nil 이 되는 경우 |
|---|---|
| 딕셔너리[키] | 키가 없을 때 |
| Int("문자열") | 숫자로 읽을 수 없을 때 |
| 배열.first | 배열이 비어 있을 때 |
| 컬렉션.max() | 요소가 하나도 없을 때 |
연습 문제
- 완성 코드의
goals를 사용해 "독서"와 "수면" 두 습관의 목표를 각각if let으로 출력하는 코드를 쓰라. 목표가 없으면 "목표 없음"을 출력한다. Int("12"),Int("12a"),Int("")의 결과를 각각 예측하라. 그다음 세 식 모두에?? -1을 붙이면 어떤 값이 되는가?- 완성 코드의
records에서 "독서" 기록을 두 배로 만든 값과 "명상" 기록을 두 배로 만든 값을map으로 구하되, 기록이 없으면 0이 되도록 하라. - 아래 코드를
guard let두 개를 쓰는 반복문 형태로 다시 써라. 입력이 없으면 "없음", 숫자가 아니면 "오류"를 출력한다.for name in ["물", "독서"] { if let text = rawInputs[name] { if let amount = Int(text) { print("\(name): \(amount)") } else { print("\(name): 오류") } } else { print("\(name): 없음") } }
정답과 해설
-
for name in ["독서", "수면"] { if let goal = goals[name] { print("\(name) 목표: \(goal)") } else { print("\(name) 목표 없음") } }"독서"는 20이 나오고 "수면"은 키가 없어 else 쪽이 실행된다. 딕셔너리 조회 결과는 항상 옵셔널이므로
if let이 자연스러운 선택이다. -
Int("12")는Optional(12),Int("12a")와Int("")는 둘 다 nil이다.?? -1을 붙이면 각각 12, -1, -1이다. 변환 함수는 "숫자 전체"가 숫자로 읽힐 때만 값을 주고, 글자가 하나라도 섞이거나 비어 있으면 nil을 준다. -
let readingDouble = records["독서"].map { $0 * 2 } ?? 0 let meditationDouble = records["명상"].map { $0 * 2 } ?? 0"독서" 기록은 20이므로 40이 되고, "명상"은 기록이 없어 map이 nil을 그대로 넘기므로 기본값 0이 된다.
-
for name in ["물", "독서"] { guard let text = rawInputs[name] else { print("\(name): 없음") continue } guard let amount = Int(text) else { print("\(name): 오류") continue } print("\(name): \(amount)") }실패 경우를 위에서 먼저 처리하고 정상 흐름을 마지막에 한 줄로 두는 형태다. 들여쓰기가 한 단계로 줄어, 새 검사 조건을 추가할 때도 같은 모양의
guard를 한 블록 더 넣으면 된다. 출력은 "물: 6", "독서: 20"이다.