Swift 변수와 타입 | var, let, 옵셔널

이 글의 핵심

Swift는 값이 없을 수 있다는 사실을 옵셔널 타입으로 드러내기 때문에 nil 관련 크래시를 컴파일 단계에서 상당 부분 막을 수 있습니다. 대신 !로 강제 언래핑하면 그 보호가 사라지므로, guard let으로 조기 반환하는 패턴과 String!처럼 암시적으로 언래핑되는 옵셔널을 써도 되는 경우를 함께 정리합니다.

시리즈 안내

#02 | 📋 전체 목차 | 이전: #01 시작하기 · 다음: #03 컬렉션


들어가며

타입 추론과 옵셔널(?)으로 “값이 없을 수 있음”을 타입에 표시합니다. guard let·if let으로 언랩 흐름을 명확히 두는 패턴이 흔합니다.

Swift의 변수 체계는 두 가지 원칙 위에 서 있습니다. 첫째, 바뀌지 않는 값이 기본입니다. let을 먼저 쓰고 정말 바뀌어야 할 때만 var를 쓰도록 컴파일러가 유도합니다. 둘째, nil은 타입으로만 들어올 수 있습니다. Objective-C에서는 어떤 객체 포인터든 nil일 수 있었고 nil에 메시지를 보내면 조용히 무시되어 버그가 숨었는데, Swift는 String과 String?을 완전히 다른 타입으로 나눠 “값이 없을 수 있는 곳”을 코드에서 바로 보이게 했습니다. 이 글은 두 원칙이 실제 코드에서 어떻게 드러나는지, 그리고 옵셔널을 다룰 때 흔히 빠지는 함정이 무엇인지 순서대로 설명합니다.


var와 let, 타입 추론

var vs let

// 변수 (var): 변경 가능
var name = "홍길동"
name = "김철수"  // OK
print(name)  // 김철수
// 상수 (let): 변경 불가
let age = 25
// age = 26  // 컴파일 에러!
// 권장: 기본적으로 let 사용, 필요할 때만 var
let maxCount = 100
var currentCount = 0

let으로 선언한 값을 바꾸려 하면 cannot assign to value: 'age' is a 'let' constant 에러가 나고, 반대로 var로 선언했는데 한 번도 바꾸지 않으면 variable 'x' was never mutated; consider changing to 'let' constant 경고가 나옵니다. 컴파일러가 양쪽에서 let을 쓰도록 밀어 주는 셈입니다. let이 많을수록 “이 값이 어디서 바뀌는가”를 추적할 필요가 줄어들고, 컴파일러도 최적화하기 쉬워집니다.

let의 의미는 타입의 종류에 따라 달라진다는 점이 중요합니다. 구조체(struct)나 Array, String 같은 값 타입을 let으로 선언하면 내부 프로퍼티까지 전부 바꿀 수 없습니다. let list = [1, 2]; list.append(3)은 cannot use mutating member on immutable value 에러입니다. 반면 클래스(class) 같은 참조 타입을 let으로 선언하면 다른 객체를 가리키도록 바꿀 수 없을 뿐, 그 객체의 var 프로퍼티는 자유롭게 바꿀 수 있습니다. Java의 final이나 C++의 T* const와 비슷한 동작이라, 클래스 인스턴스에서 let이 불변을 보장한다고 착각하면 안 됩니다.

타입 추론

// 타입 추론 (권장)
let name = "홍길동"  // String으로 추론
let age = 25         // Int로 추론
let pi = 3.14        // Double로 추론
// 명시적 타입 (필요 시)
let name: String = "홍길동"
let age: Int = 25
let pi: Double = 3.14

(위 두 블록은 비교를 위해 같은 이름을 다시 쓴 것이라, 한 스코프에 그대로 두면 invalid redeclaration of 'name' 에러가 납니다.)

타입 추론은 선언할 때 한 번 타입을 정하고 고정합니다. var count = 0은 Int가 되므로 나중에 count = 1.5를 대입할 수 없습니다. 명시적 타입이 필요한 경우는 대개 세 가지입니다. 리터럴의 기본 추론과 다른 타입을 원할 때(let ratio: Float = 0.5, let bytes: UInt8 = 255), 초기값 없이 선언만 할 때(let result: String 후 분기에서 한 번만 대입), 그리고 빈 컬렉션이나 nil로 시작할 때(var names: [String] = [], var token: String? = nil)입니다. 마지막 경우에 타입을 빼면 컴파일러가 추론할 근거가 없어 empty collection literal requires an explicit type 에러가 납니다.

복잡한 식에서 타입 추론이 컴파일 시간을 크게 늘리는 경우도 있습니다. 리터럴과 연산자가 많이 섞인 한 줄짜리 식(let x = a + b * 2 + c / 3.0 + ...)에서 Swift 컴파일러가 가능한 타입 조합을 모두 따지다가 the compiler is unable to type-check this expression in reasonable time 에러를 내는 것이 알려진 문제입니다. 이럴 때는 중간값에 타입을 명시하거나 식을 여러 줄로 나누면 해결됩니다.


정수·실수·문자열·불리언 타입

정수 타입

// Int (플랫폼에 따라 32bit 또는 64bit)
let int: Int = 10
let negativeInt: Int = -10
// UInt (부호 없는 정수)
let uint: UInt = 10
// let negativeUInt: UInt = -10  // 에러!
// 크기별 정수
let int8: Int8 = 127
let int16: Int16 = 32767
let int32: Int32 = 2147483647
let int64: Int64 = 9223372036854775807
// 최소/최대값
print(Int.min)  // -9223372036854775808
print(Int.max)  // 9223372036854775807

Int.min/Int.max의 출력은 64비트 플랫폼 기준입니다. 현재 iOS·macOS 기기는 모두 64비트라 Int는 사실상 Int64와 같은 범위입니다. Swift 공식 가이드는 특별한 이유가 없으면 Int를 쓰라고 권장합니다. 값이 음수가 될 수 없더라도 UInt를 쓰면 Int와 섞을 때마다 명시적 변환이 필요해지고, 뺄셈 결과가 음수가 되는 순간 크래시하기 때문입니다. Int8, UInt16 같은 크기 지정 타입은 바이너리 파일 포맷이나 네트워크 프로토콜처럼 크기가 정해진 데이터를 다룰 때 씁니다.

Swift의 정수 연산은 오버플로 시 조용히 넘어가지 않고 런타임에 크래시합니다. var x = Int8.max; x += 1은 Fatal error: Arithmetic overflow로 앱이 종료됩니다. C나 Java처럼 값이 음수로 뒤집히는 것보다 안전하다는 판단이지만, 해시 계산처럼 오버플로를 의도하는 코드에서는 &+, &* 같은 오버플로 연산자를 써야 합니다. 오버플로 여부를 확인하고 싶다면 x.addingReportingOverflow(1)이 결과와 오버플로 플래그를 함께 돌려줍니다. 리터럴이 범위를 넘으면(let n: Int8 = 200) 컴파일 시점에 integer literal '200' overflows when stored into 'Int8' 에러가 납니다.

실수 타입

// Double (64bit, 기본)
let double: Double = 3.14159265359
// Float (32bit)
let float: Float = 3.14
// 타입 명시 필요
let pi = 3.14  // Double로 추론
let piFloat: Float = 3.14  // Float로 명시

소수 리터럴은 항상 Double로 추론되므로 Float가 필요하면 타입을 명시해야 합니다. Float는 유효 자릿수가 약 6~7자리로 적어서, 좌표 계산처럼 오차가 누적되는 곳에서는 금방 드러납니다. Metal이나 SIMD 연산처럼 API가 Float를 요구하는 경우가 아니면 Double이 기본입니다. UIKit/CoreGraphics에서 자주 보는 CGFloat는 64비트 플랫폼에서 Double과 같은 크기이지만 별도 타입이라, Swift 5.5 이전에는 Double과 섞을 때 매번 변환이 필요했습니다.

부동소수점의 일반적인 함정도 그대로 적용됩니다. 0.1 + 0.2 == 0.3은 false이므로 비교할 때는 허용 오차를 두어야 하고, 금액 계산에는 Double 대신 Foundation의 Decimal을 씁니다. Decimal(0.1)은 이미 오차가 섞인 Double에서 만들어지므로 Decimal(string: "0.1")로 만드는 것이 정확합니다.

문자열

let text: String = "Hello, Swift!"
// 문자열 보간
let name = "홍길동"
let age = 25
let message = "이름: \(name), 나이: \(age)"
print(message)
// 여러 줄 문자열
let multiline = """
첫 번째 줄
두 번째 줄
세 번째 줄
"""
// 문자열 연산
let hello = "Hello"
let world = "World"
let greeting = hello + ", " + world + "!"

Swift의 String은 값 타입이라서 다른 변수에 대입하면 복사본처럼 동작합니다(실제로는 수정될 때까지 버퍼를 공유하는 copy-on-write). 한쪽을 바꿔도 다른 쪽에 영향이 없으므로, 여러 곳에 넘긴 문자열이 몰래 바뀌는 문제가 없습니다.

가장 다른 언어와 다른 점은 글자 단위가 유니코드 확장 자소 클러스터(grapheme cluster)라는 것입니다. "한글".count는 2, "👨‍👩‍👧".count는 1입니다. 여러 코드 포인트로 이루어진 가족 이모지도 사람이 보는 한 글자로 셉니다. 그 대가로 String은 정수 인덱스를 지원하지 않습니다. text[0]은 'subscript(_:)' is unavailable: cannot subscript String with an Int 에러가 나고, text[text.index(text.startIndex, offsetBy: 2)]처럼 String.Index를 써야 합니다. 각 글자의 바이트 길이가 다르기 때문에 n번째 글자를 찾는 것은 O(n) 작업이고, Swift는 이 비용을 숨기지 않으려고 정수 인덱스를 막아 둔 것입니다. 여러 줄 문자열(""")에서는 닫는 """의 들여쓰기가 기준이 되어 그만큼의 앞 공백이 각 줄에서 제거된다는 점도 알아 두면 좋습니다.

불리언

let isActive: Bool = true
let isCompleted: Bool = false
// 논리 연산
let result1 = true && false  // false
let result2 = true || false  // true
let result3 = !true          // false

Swift의 Bool은 정수와 섞이지 않습니다. C처럼 if count { ... }로 0이 아닌지 검사할 수 없고 if count != 0처럼 명시해야 합니다. 옵셔널도 마찬가지로 if optionalValue { ... }는 에러이며 if optionalValue != nil이나 if let을 써야 합니다. 조건식에 들어갈 수 있는 것이 Bool뿐이라서, “0과 nil과 false가 모두 거짓”인 언어에서 생기는 혼란이 없습니다. &&와 ||는 단축 평가를 하므로, 오른쪽에 비용이 크거나 부수 효과가 있는 식을 둘 때 순서를 고려해야 합니다.


옵셔널: 바인딩, 체이닝, ??, 강제 언래핑

옵셔널이란?

// 일반 변수: nil 불가
var name: String = "홍길동"
// name = nil  // 에러!
// 옵셔널: nil 가능
var optionalName: String? = "홍길동"
optionalName = nil  // OK
print(optionalName)  // Optional("홍길동") 또는 nil

String?은 Optional<String>의 줄임말이고, 내부적으로는 .none과 .some("홍길동") 두 경우를 가진 열거형입니다. 그래서 print(optionalName)은 값 대신 Optional("홍길동")이라는 포장된 모습을 출력하고, Xcode는 Expression implicitly coerced from 'String?' to 'Any' 경고를 보여 줍니다. 화면이나 로그에 Optional(...)이라는 글자가 그대로 찍혀 나오는 것은 옵셔널을 언래핑하지 않고 문자열 보간에 넣었다는 신호이며, 실제 앱에서 사용자에게 “안녕하세요, Optional(“홍길동”)님”이 표시되는 버그로 자주 나타납니다.

옵셔널이 있으면 String 타입의 값은 절대 nil이 아니라는 보장이 생깁니다. 함수가 String을 받는다면 호출하는 쪽에서 이미 nil 처리를 끝낸 것이므로, 함수 안에서 방어 코드를 쓸 필요가 없습니다. 반대로 String?을 받는 함수는 nil을 어떻게 처리할지 반드시 결정해야 합니다. 옵셔널은 이 책임의 경계를 타입으로 그어 주는 도구입니다.

옵셔널 바인딩 (if let)

옵셔널 값을 안전하게 추출하는 방법입니다:

var name: String? = "홍길동"
// if let: 옵셔널 바인딩
if let unwrappedName = name {
    // name이 nil이 아니면 이 블록 실행
    // unwrappedName: String 타입 (옵셔널 아님)
    // name의 값이 unwrappedName에 언래핑되어 할당됨
    print("이름: \(unwrappedName)")
    // unwrappedName은 이 블록 안에서만 유효
} else {
    // name이 nil이면 이 블록 실행
    print("이름 없음")
}
// 여러 옵셔널 동시 바인딩
let firstName: String? = "홍"
let lastName: String? = "길동"
if let first = firstName, let last = lastName {
    // 둘 다 nil이 아닐 때만 실행
    print("이름: \(first)\(last)")  // 이름: 홍길동
}
// guard let: 조기 반환 패턴
func greet(name: String?) {
    // guard: 조건이 false면 else 블록 실행 후 반환
    guard let name = name else {
        // name이 nil이면 여기 실행
        print("이름 없음")
        return  // 함수 종료 (guard는 반드시 return/throw/break 필요)
    }
    
    // 여기서는 name이 nil이 아님을 보장
    // name: String 타입 (언래핑됨)
    // guard let으로 추출한 변수는 함수 끝까지 유효
    print("안녕하세요, \(name)님!")
    // 추가 로직 작성 가능 (들여쓰기 깊이 감소)
}
greet(name: "홍길동")  // 안녕하세요, 홍길동님!
greet(name: nil)       // 이름 없음

guard let name = name처럼 같은 이름으로 다시 바인딩하는 것은 Swift에서 흔한 관용구입니다. 바깥의 옵셔널 name을 언래핑된 name이 가려서(shadowing), 이후 코드에서는 옵셔널 버전을 실수로 쓸 수 없게 됩니다. Swift 5.7부터는 이것을 더 줄여 if let name { ... }, guard let name else { return }처럼 쓸 수 있습니다.

guard의 else 블록은 반드시 현재 스코프를 빠져나가야 합니다. return, throw, 루프 안이라면 break/continue, 또는 fatalError()처럼 반환하지 않는 함수 호출이 있어야 하며, 없으면 'guard' body must not fall through, consider using a 'return' or 'throw' to exit the scope 에러가 납니다. 이 제약 덕분에 컴파일러는 guard 아래 코드에서 name이 반드시 값을 가진다고 확신할 수 있습니다. if let으로 바인딩한 값이 블록 안에서만 유효한 것과 달리, guard let으로 바인딩한 값은 함수 끝까지 쓸 수 있는 이유가 이것입니다. if let vs guard let:

// if let: 옵셔널이 있을 때 처리
func processUser(user: User?) {
    if let user = user {
        // user 처리 (들여쓰기 깊어짐)
        print(user.name)
        // 여러 줄 로직...
    }
}
// guard let: 옵셔널이 없으면 조기 반환 (권장)
func processUser(user: User?) {
    guard let user = user else {
        return  // nil이면 즉시 종료
    }
    
    // 정상 흐름 (들여쓰기 얕음)
    print(user.name)
    // 여러 줄 로직...
}

여러 조건 체크:

func validateUser(name: String?, age: Int?, email: String?) {
    // 모든 값이 nil이 아닌지 한 번에 체크
    guard let name = name,
          let age = age,
          let email = email,
          age >= 18,  // 추가 조건도 가능
          email.contains("@") else {
        print("유효하지 않은 사용자")
        return
    }
    
    // 모든 조건을 통과한 경우
    print("유효한 사용자: \(name), \(age)세, \(email)")
}

쉼표로 이어진 조건은 왼쪽부터 차례로 평가되고, 하나라도 실패하면 나머지는 평가하지 않습니다. 그래서 age >= 18에서 쓰는 age는 이미 언래핑된 Int입니다. 순서를 바꿔 age >= 18을 let age = age보다 앞에 두면 age가 아직 옵셔널이라 비교할 수 없다는 에러가 납니다.

이 방식의 단점은 어느 조건에서 실패했는지 알 수 없다는 것입니다. 사용자에게 “이메일 형식이 잘못되었습니다”처럼 구체적인 메시지를 보여 줘야 한다면, 조건을 여러 개의 guard로 나누고 각각 다른 에러를 반환하거나 throw하는 편이 낫습니다. 참고로 email.contains("@")는 이메일 검증으로는 매우 느슨한 검사라서, 실제 서비스에서는 서버 측 검증이나 정규식을 함께 써야 합니다.

옵셔널 체이닝

struct User {
    var name: String
    var email: String?
}
let user: User? = User(name: "홍길동", email: "[email protected]")
// 옵셔널 체이닝
let emailLength = user?.email?.count
print(emailLength)  // Optional(17)
// 여러 단계
let firstChar = user?.email?.first?.uppercased()

user?.email?.count의 결과가 Int??가 아니라 Int?인 점이 옵셔널 체이닝의 핵심입니다. 체인 중간에 옵셔널이 몇 번 나오든 결과는 한 겹의 옵셔널로 평평하게 합쳐집니다. 원래 count의 타입은 Int이지만, 체인 어디서든 nil이 나올 수 있으므로 전체 결과는 Int?가 됩니다. 그래서 체인 결과를 쓸 때는 다시 if let이나 ??로 풀어야 합니다. let length = user?.email?.count ?? 0처럼 기본값과 함께 쓰는 형태가 가장 흔합니다.

체이닝은 대입에도 쓸 수 있습니다. user?.email = "[email protected]"은 user가 nil이면 아무 일도 하지 않습니다. 편리하지만 대입이 실패해도 에러가 나지 않는다는 뜻이므로, 반드시 저장되어야 하는 값이라면 guard let으로 먼저 확인하는 편이 안전합니다. 이 대입식의 결과 타입은 Void?라서 if (user?.email = "...") != nil로 성공 여부를 확인할 수도 있습니다.

옵셔널 체이닝의 구현 관점(심화)

문법상 ?.는 “앞이 nil이면 나머지를 평가하지 않는다”는 단축 평가(short-circuit)를 보장합니다. 즉 a?.b?.c에서 a가 nil이면 b와 c는 호출·접근조차 시도되지 않습니다. 부수 효과가 있는 호출을 체이닝 중간에 두면, nil에서 멈췄을 때 그 호출이 생략되는지 항상 염두에 두어야 합니다.

타입 수준에서 옵셔널은 Optional<Wrapped> 열거형입니다. 컴파일러는 옵셔널 체이닝 표현을 임시 상수에 바인딩하는 분기로 낮추는데, 개념적으로는 아래와 같은 구조와 같습니다(실제 SIL/LLVM 출력은 최적화로 더 단순해질 수 있음).

// user?.profile?.displayName — 개념적 전개(의사 코드)
// let 결과: String? = ...
switch user {
case .none:
    결과 = .none
case .some(let u):
    switch u.profile {
    case .none:
        결과 = .none
    case .some(let p):
        결과 = .some(p.displayName)
    }
}

프로퍼티뿐 아니라 optionalValue?.method()처럼 메서드 호출도 동일합니다. method는 옵셔널이 풀린 수신자에 대해서만 호출되며, 반환 타입이 원래 T이면 전체 표현식의 타입은 T?가 됩니다. 인자를 받는 호출도 수신자가 nil이면 인자 표현식까지 가지 않습니다(단, 일부 복합 표현은 컴파일러 버전·세부 규칙을 따르므로 “부수 효과 없는 순수 접근”으로 두는 편이 안전합니다).

함수형 스타일으로는 옵셔널을 map/flatMap(Swift에서는 Optional.flatMap 등)으로 연결하는 것과 대응합니다. 예를 들어 x?.y는 x.flatMap { $0.y }와 같은 모나드적 연쇄로 이해할 수 있으며, 이 관점이 중첩된 옵셔널을 풀 때의 사고 모델이 됩니다.

서브스크립트 collection?[index]도 동일한 규칙으로, 컬렉션이 nil이면 인덱싱 자체가 일어나지 않습니다. 다차원 체이닝은 각 단계마다 옵셔널이 한 겹씩 쌓일 수 있으므로, 결과 타입이 T??처럼 보일 때는 flatMap/map으로 한 겹 줄이기 등을 검토합니다.

Nil 병합 연산자 (??)

let name: String? = nil
let displayName = name ?? "Guest"
print(displayName)  // Guest
// 체이닝
let email: String? = nil
let backup: String? = nil
let result = email ?? backup ?? "[email protected]"

??는 왼쪽이 nil이 아니면 언래핑한 값을, nil이면 오른쪽 값을 돌려줍니다. 오른쪽 식은 왼쪽이 nil일 때만 평가되므로(@autoclosure로 구현됨), name ?? loadDefaultName()처럼 비용이 드는 함수를 두어도 값이 있으면 호출되지 않습니다. 오른쪽이 옵셔널이 아닌 값이면 결과도 옵셔널이 아닌 타입이 되므로, 체인의 마지막에 반드시 확정된 기본값을 두는 것이 이 연산자를 쓰는 방식입니다.

주의할 점은 “값이 없음”과 “빈 값”을 구분하지 않는다는 것입니다. let nickname: String? = ""이면 nickname ?? "Guest"는 빈 문자열을 반환해 화면에 아무것도 표시되지 않습니다. 서버가 필드를 빼는 대신 빈 문자열을 보내는 API를 다룬다면 nickname.flatMap { $0.isEmpty ? nil : $0 } ?? "Guest"처럼 빈 값도 걸러야 합니다. 또 ??를 남용하면 nil이 들어온 원인을 숨기게 됩니다. 필수 데이터가 누락된 상황을 기본값으로 덮어 두면, 나중에 “왜 사용자 이름이 전부 Guest로 나오지?”라는 문제로 돌아옵니다.

강제 언래핑 (!)

let name: String? = "홍길동"
// 강제 언래핑 (위험!)
print(name!)  // 홍길동
// nil이면 크래시
let nilName: String? = nil
// print(nilName!)  // 런타임 에러!
// 암시적 언래핑 옵셔널
var implicitName: String! = "홍길동"
let plain: String = implicitName  // String이 필요한 곳에서는 자동 언래핑
print(implicitName)  // Optional("홍길동") — Swift 4.2+에서는 옵셔널로 취급됨

!로 nil을 강제 언래핑하면 Fatal error: Unexpectedly found nil while unwrapping an Optional value와 함께 앱이 즉시 종료됩니다. iOS 앱 크래시 리포트에서 가장 흔하게 보이는 메시지 중 하나입니다. 제가 Swift 코드를 리뷰하며 가장 자주 지적하는 부분도 이것인데, 개발 중에는 항상 데이터가 있는 상태로 테스트하기 때문에 !가 문제를 일으키지 않다가, 네트워크가 느리거나 서버 응답에 필드가 빠진 실제 사용자 환경에서만 크래시합니다. !를 쓰는 기준은 “여기서 nil이면 프로그램 로직이 틀린 것이고, 계속 실행하는 것이 더 위험하다”고 확신할 수 있을 때입니다. 번들에 포함된 리소스 로드(Bundle.main.url(forResource:...)!)가 대표적인 예이고, 그 외에는 guard let으로 처리하는 편이 안전합니다.

암시적 언래핑 옵셔널(String!)은 원래 “초기화 시점에는 nil이지만 사용 전에는 반드시 채워지는” 값을 위한 타입입니다. 스토리보드의 @IBOutlet weak var label: UILabel!이 대표적입니다. Swift 4.2부터는 String!이 별도 타입이 아니라 “필요하면 자동으로 강제 언래핑되는 String?”으로 바뀌었습니다. 그래서 String이 필요한 곳에 넣으면 언래핑되지만, print처럼 Any를 받는 곳이나 let copy = implicitName처럼 타입을 추론하는 곳에서는 그냥 String?로 취급되어 Optional("홍길동")이 출력됩니다. 예전 자료의 “자동 언래핑” 설명을 그대로 믿으면 헷갈리는 부분입니다.


명시적 타입 변환

// 정수 → 실수
let intValue: Int = 10
let doubleValue: Double = Double(intValue)
// 문자열 → 정수
let str = "123"
if let number = Int(str) {
    print(number)  // 123
}
// 실패 시 nil
let invalid = Int("abc")  // nil

Swift는 숫자 타입 사이의 자동 변환을 전혀 하지 않습니다. let total = intValue + 1.5는 binary operator '+' cannot be applied to operands of type 'Int' and 'Double' 에러가 납니다. Int와 Int64, Int와 UInt 사이도 마찬가지입니다. 처음에는 번거롭지만, C 계열 언어에서 암묵적 변환으로 생기는 정밀도 손실이나 부호 문제를 컴파일 시점에 모두 드러내 줍니다. 반대 방향인 Int(3.9)는 반올림이 아니라 소수점을 버려서 3이 되고, Int(Double.infinity)나 범위를 넘는 값은 런타임 크래시가 납니다. 안전하게 바꾸려면 Int(exactly: 3.9)(정확히 표현할 수 없으면 nil)나 Int(clamping:)을 씁니다.

Int("123")이 Int?를 반환하는 것은 옵셔널이 실패 가능한 변환을 표현하는 전형적인 예입니다. 앞뒤 공백이 있는 " 123", 소수점이 있는 "12.5", 천 단위 쉼표가 있는 "1,000"은 모두 nil이 됩니다. 사용자 입력을 받는다면 trimmingCharacters(in: .whitespaces)로 공백을 먼저 제거하고, 지역별 숫자 형식이 필요하면 NumberFormatter를 써야 합니다.


예제: 사용자 정보 처리

struct User {
    let name: String
    var age: Int
    var email: String?
}
func printUserInfo(user: User?) {
    guard let user = user else {
        print("사용자 없음")
        return
    }
    
    print("이름: \(user.name)")
    print("나이: \(user.age)")
    
    if let email = user.email {
        print("이메일: \(email)")
    } else {
        print("이메일 미등록")
    }
}
let user1 = User(name: "홍길동", age: 25, email: "[email protected]")
let user2 = User(name: "김철수", age: 30, email: nil)
printUserInfo(user: user1)
printUserInfo(user: user2)

이 예제는 옵셔널을 다루는 두 가지 전략을 한 함수에 섞었습니다. user 자체가 없으면 더 진행할 의미가 없으므로 guard let으로 조기 반환하고, email은 없어도 나머지 정보를 출력할 수 있으므로 if let으로 분기합니다. “이 값이 없으면 함수 전체가 의미 없는가, 일부만 달라지는가”를 기준으로 두 문법을 고르면 됩니다.

구조체 설계도 눈여겨볼 만합니다. name은 let이라 생성 후 바꿀 수 없고, age와 email은 var입니다. email만 String?인 것은 “이메일은 선택 입력”이라는 비즈니스 규칙을 타입이 표현한 것입니다. 모든 필드를 옵셔널로 만들면 작성은 편하지만 사용하는 모든 곳에서 언래핑을 해야 하고, 필수 필드가 빠진 잘못된 User를 만들 수 있게 됩니다. 서버 JSON을 Codable로 디코딩할 때도 이 원칙이 그대로 적용되어, 옵셔널이 아닌 필드가 응답에 없으면 keyNotFound 디코딩 에러가 납니다. 그 에러가 귀찮다고 모든 필드를 옵셔널로 바꾸기보다, 정말 없을 수 있는 필드만 옵셔널로 두는 것이 데이터 품질을 지키는 방법입니다.


변수·타입·옵셔널 요약

  1. var: 변수(변경 가능), let: 상수(변경 불가)
  2. 기본 타입: Int, Double, String, Bool
  3. 옵셔널: nil 가능, ?로 표시
  4. 옵셔널 바인딩: if let, guard let
  5. 옵셔널 체이닝: ?.로 안전 접근
  6. Nil 병합: ??로 기본값

다음 단계


같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. String!처럼 선언하는 암시적 언래핑 옵셔널은 언제 쓰나요?

A. 암시적 언래핑 옵셔널은 초기화 직후에는 nil일 수 있지만 사용 시점에는 반드시 값이 채워진다고 확신할 수 있을 때를 위한 타입입니다. 접근할 때마다 자동으로 언래핑되므로 편하지만, 그 가정이 깨져 nil인 상태에서 읽으면 강제 언래핑과 똑같이 크래시가 납니다. 값이 없을 가능성이 조금이라도 있다면 일반 옵셔널과 if let, guard let, ?? 기본값으로 처리하는 편이 안전합니다.