- Swift를 다른 언어와 비교했을 때 가장 큰 특징은 뭐라고 생각하나요?
- "타입 안정성이 높다"가 실제 코드에서 어떤 차이로 나타나나요?
- Swift의 안전성은 무엇을 대가로 얻은 건가요?
30초 답변 🔥 먼저 이 문장
Swift가 이 충돌을 푸는 방식이 일관됩니다. "실행해 봐야 아는 것"을 "코드를 보면 아는 것"으로 옮깁니다. nil은
Optional 타입으로, 데이터 경합은 Sendable·actor로, 실패 가능성은 throws로, 클로저 수명은 @escaping으로.
전부 런타임 검사를 컴파일 타임 검사로 옮긴 것이라 런타임 비용 없이 안전을 얻습니다. 대가는 컴파일 시간과 어노테이션 부담입니다.
L1개념 — 타입 안정성이 코드에서 드러나는 네 장면
// ① 암묵적 형변환 없음 — 의도치 않은 정밀도 손실이 컴파일 에러
let a: Int = 3, b: Double = 1.5
// let c = a + b // ❌ 컴파일 에러
let c = Double(a) + b // ✅ 변환을 코드에 남긴다
// ② nil이 타입에 드러난다
func find(_ id: Int) -> User? { /* ... */ }
// let name = find(1).name // ❌ nil 처리를 건너뛸 수 없다
// ③ exhaustive switch — 케이스를 추가하면 처리 누락이 전부 컴파일 에러
enum Payment { case card, bank, point } // 여기에 .voucher 를 추가하면
switch payment { // 이 switch 들이 전부 에러가 난다
case .card: break
case .bank: break
case .point: break
}
// ④ 제네릭 제약 — 런타임 캐스팅 없이 다형성
func maxOf<T: Comparable>(_ xs: [T]) -> T? { xs.max() }
// as! 도, Any 도 필요 없다
Objective-C는 nil에 메시지를 보내면 조용히 무시합니다. 편하지만 오류가 사라지는 게 아니라 발생 지점에서 멀어집니다.
화면이 비었는데 원인은 세 화면 전의 nil인 상황이죠. Optional은 정확히 그 문제를 발생 지점에서 강제로 다루게 만드는 장치입니다.
L2언어 설계 — 안전을 얻는 네 개의 축
축 1 · 메모리 안전과 ARC
| ARC (Swift) | 추적 GC (Java, C#) | |
|---|---|---|
| 해제 시점 | 결정적 — 카운트가 0이 되는 즉시 | 비결정적 — 수집기가 정할 때 |
| 전역 일시 정지 | stop-the-world 없음 | 수집 시 정지 가능 |
| 순환 참조 | 해결 못 함 → 개발자 책임 | 도달 불가면 수집 |
| 상시 비용 | 참조 카운트 증감(원자적 연산) | 할당은 싸고, 수집 때 몰아서 |
GC 같은 전역 stop-the-world는 없습니다. 하지만 비용이 없다는 건 과장입니다. 큰 객체 그래프의 루트를 놓으면 연쇄 해제가 그 자리에서 일어나 순간적인 프레임 지연을 만들 수 있습니다. 참조 카운트 증감도 멀티스레드에서 원자적 연산이라 공짜가 아닙니다. 정확히는 "예측 불가능한 전역 정지가 없고, 비용이 해제 지점에 분산되어 있다"입니다.
축 2 · 배타적 접근 (SE-0176)
func swapValues(_ x: inout Int, _ y: inout Int) { /* ... */ }
var n = 1
// swapValues(&n, &n) // ❌ overlapping accesses to 'n'
// C에서는 이런 앨리어싱이 조용히 통과해 정의되지 않은 동작이 된다.
// Swift는 "겹치는 접근"을 금지해서, 컴파일러가 앨리어싱 없음을 가정하고
// 더 공격적으로 최적화할 수 있게 만든다 — 안전과 성능을 동시에 얻는 지점.축 3 · 에러는 예외가 아니라 제어 흐름
Swift의 throws는 다른 언어의 예외와 다릅니다. 스택 되감기(unwinding)를 하지 않고 사실상 Result 반환에 가깝게 구현되므로 성능이 예측 가능합니다.
enum NetworkError: Error { case timeout, offline }
func fetch() throws(NetworkError) -> Data { /* ... */ }
do {
let data = try fetch()
} catch {
// error 타입이 NetworkError 로 확정 → exhaustive switch 가능
switch error {
case .timeout: retry()
case .offline: showOfflineBanner()
}
}축 4 · 동시성
| 기능 | 제안 | 푸는 문제 |
|---|---|---|
async/await | SE-0296 | 콜백 중첩 제거. 스레드를 막지 않고 중단/재개 |
| 구조적 동시성 | SE-0304 | async let·TaskGroup — 자식 수명이 부모 스코프에 묶인다 |
actor | SE-0306 | 가변 상태 격리. 외부 접근은 await로 직렬화 |
Sendable | SE-0302 | 격리 경계를 넘어도 되는 타입인지 컴파일러가 검사 |
| Swift 6 모드 | — | 위 검사를 기본으로 (complete concurrency checking) |
Swift 6.2에서는 방향이 한 번 조정됐습니다. 초기 모델이 "안전하지만 배우기 어렵다"는 피드백을 받았고, 접근하기 쉬운 동시성이라는 이름으로 다음이 들어왔습니다.
- 기본 격리를
MainActor로 (SE-0466) — 모듈 단위로 "표시 없는 코드는 메인 액터"로 둘 수 있다. 대부분의 앱 코드가 실제로 메인에서 도는 현실에 맞춘 것. nonisolated(nonsending)(SE-0461) —nonisolatedasync 함수가 호출자의 액터에서 실행되게 해서 불필요한 액터 이동(hop)과Sendable요구를 줄인다.
Swift 6.0이 "안전을 기본값으로"였다면, 6.2는 "그 안전을 덜 아프게"입니다. 안전성을 포기한 게 아니라 기본값을 현실에 맞게 다시 잡은 것이라고 설명하면 정확합니다.
L3구현 — SIL이라는 중간 언어
Swift 컴파일러가 특이한 점은 LLVM IR로 바로 내리지 않고 SIL(Swift Intermediate Language)이라는 자체 중간 언어를 거친다는 것입니다. 이유는 명확합니다 — Swift 고유의 최적화는 LLVM이 모르기 때문입니다.
final을 붙이면 빨라진다", "WMO를 켜면 최적화된다"는 말은 전부 SIL 단계에서 일어나는 일이다. 05번 디스패치에서 다시 만난다.- ARC 최적화 —
retain직후release처럼 상쇄되는 쌍을 제거. LLVM은 참조 카운팅 의미를 모르므로 이걸 못 합니다. - 제네릭 특수화 —
f<T>를f<Int>전용 사본으로 복제해 witness table 조회를 없앱니다. - devirtualization — 오버라이드가 없음을 증명해 vtable 호출을 직접 호출로.
- 배타적 접근 검사 — 정적으로 판정되면 런타임 검사를 아예 빼 버립니다.
ABI 안정성이 실제로 고정한 것
Swift 5의 ABI 안정성은 "버전이 올라가도 된다"는 선언이 아니라, 바이너리 수준 규약을 못 박은 것입니다.
- 호출 규약 — 인자를 어느 레지스터에 싣고 소유권을 누가 갖는가
- 메모리 레이아웃 — 타입의 크기·정렬·필드 순서
- 이름 맹글링 — 제네릭·오버로드를 유일한 심볼 이름으로 인코딩하는 규칙
- 메타데이터 형식 — 런타임이 타입을 조회하는 자료구조
결과: 표준 라이브러리가 OS에 내장되어 앱 바이너리에서 빠졌고, 다른 버전의 컴파일러로 빌드한 바이너리끼리 링크가 가능해졌습니다. 대가로 라이브러리 진화(library evolution) 모드에서는 구조체 레이아웃을 마음대로 바꿀 수 없어 간접 접근이 늘어납니다 — 디스패치가 다시 동적으로 되돌아가는 지점입니다.
L4CS 근본 — 타입 시스템은 무엇을 증명하고, 무엇을 못 하나
① 타입 시스템의 정의: 실행 전에 오류 부류의 부재를 증명하는 장치
타입 이론에서 "타입 시스템이 건전(sound)하다"는 것은 두 정리로 정의됩니다.
- Progress — 타입이 붙은 프로그램은 값이거나, 아니면 한 걸음 더 실행할 수 있다. 중간에 "막히지" 않는다.
- Preservation — 한 걸음 실행해도 타입이 유지된다.
둘을 합치면 "타입이 맞는 프로그램은 타입 오류로 멈추지 않는다"가 증명됩니다. 흔히 말하는 "well-typed programs don't go wrong"이 이겁니다. Swift가 "안전하다"고 할 때의 정확한 의미도 "모든 버그가 없다"가 아니라 "이 부류의 오류가 없음이 보장된다"입니다.
② 대수적 데이터 타입 — 타입의 크기를 계산할 수 있다
Swift의 struct와 enum은 각각 곱 타입(product type)과 합 타입(sum type)입니다. 이름이 대수적인 이유는 실제로 표현 가능한 값의 개수가 곱셈과 덧셈이기 때문입니다.
|Bool| = 2
|Void| = 1
// 곱 타입 (struct / tuple) — 각 필드 개수의 곱
struct S { var a: Bool; var b: Bool } // |S| = 2 × 2 = 4
// 합 타입 (enum) — 각 케이스 개수의 합
enum E { case x(Bool); case y } // |E| = 2 + 1 = 3
// Optional 은 "+1" 이다
// Optional<T> = T + 1 → |Bool?| = 2 + 1 = 3 (true, false, nil)
// ─────────── 그래서 이런 판단이 계산이 된다 ───────────
// ❌ Bool 3개 → 2 × 2 × 2 = 8가지, 그중 유효한 건 4가지
struct BadState { var isLoading: Bool; var hasError: Bool; var isEmpty: Bool }
// ✅ enum 하나 → 정확히 4가지, 무효 상태가 존재하지 않음
enum GoodState { case idle, loading, loaded([Item]), failed(Error) }"make illegal states unrepresentable"이라는 격언은 감성적 조언이 아니라 상태 공간의 크기를 8에서 4로 줄이는 산술입니다. 줄어든 4개가 바로 테스트해야 했지만 아무도 안 하던 케이스입니다.
③ Swift의 타입 추론은 Hindley-Milner가 아니다
함수형 언어(ML, Haskell)는 Hindley-Milner 추론을 씁니다 — 거의 항상 최적 타입을 효율적으로 찾아 줍니다.
Swift는 쓸 수가 없습니다. 오버로딩, 서브타이핑, 리터럴 프로토콜(ExpressibleByIntegerLiteral 등), 암묵적 변환이 있으면 HM의 전제가 깨집니다.
그래서 Swift는 제약 기반(constraint-based) 추론을 씁니다 — 식에서 제약을 모으고, 가능한 오버로드 조합을 탐색해서 해를 찾습니다. 그리고 여기서 실무에서 다들 겪는 그 에러가 나옵니다.
// 이 한 줄이 컴파일러를 몇 분씩 잡아먹거나 아예 포기하게 만든다
let x = a + b * c - d / e + f * g
// 왜? + - * / 각각이 Int, Double, Float, CGFloat... 여러 오버로드를 갖는다.
// 연산자가 n개면 조합이 지수적으로 늘어난다 (조합 폭발).
// 컴파일러는 시간 제한에 걸리면 "too complex"로 포기한다.
// ✅ 해법도 원리에서 나온다 — 탐색 공간을 쪼개거나 못 박는다
let t1: Double = b * c
let t2: Double = d / e
let x = a + t1 - t2 + f * g // 타입을 명시하면 탐색이 확정된다이 답을 할 수 있으면 "컴파일 느려요"라는 불평이 원리 설명으로 바뀝니다. 실무 경험과 CS가 만나는 가장 좋은 지점 중 하나입니다.
④ Rice의 정리 — 컴파일러가 원리적으로 못 잡는 것
Rice의 정리: 프로그램의 비자명한 의미적 성질은 일반적으로 결정 불가능하다. "이 코드에 데이터 경합이 있는가?", "이 참조는 절대 nil이 아닌가?", "이 클로저는 함수를 탈출하는가?" — 전부 정확히 판정할 수 없습니다.
그럼 컴파일러는 어떻게 하나? 보수적 근사(conservative approximation)를 합니다. 확신할 수 없으면 안전한 쪽으로 거부합니다.
그래서 Swift에는 반드시 탈출구가 존재합니다. 그리고 탈출구의 이름이 전부 "이건 네가 책임져"라고 말하고 있습니다.
| 탈출구 | 컴파일러가 포기한 것 | 개발자가 지는 책임 |
|---|---|---|
@unchecked Sendable | 이 타입이 경계를 넘어도 안전한지 | 락이나 불변성으로 직접 보장 |
! (force unwrap) | 이 값이 nil이 아님 | nil이면 크래시가 맞다는 판단 |
unowned | 대상이 더 오래 산다는 것 | 수명 순서를 직접 보장 |
withoutActuallyEscaping | 이 클로저가 탈출하지 않음 | 스코프 밖으로 안 내보낸다는 약속 |
Unsafe*Pointer | 메모리 유효성 전부 | 수명·정렬·타입 전부 |
"Swift는 왜 @unchecked Sendable 같은 구멍을 남겨 뒀나요?"는 함정 질문처럼 들리지만 정답이 명확합니다.
"Rice의 정리 때문에 정적 분석은 완전할 수 없습니다. 컴파일러는 sound하게(놓치지 않게) 만들되 complete하지는 못하므로, 안전한데도 거부되는 코드가 반드시 생깁니다. 탈출구는 설계 실수가 아니라 그 구조적 격차를 메우는 장치입니다."
CS 정본으로 더 내려가기
이 페이지의 L4는 면접 답변에 필요한 깊이까지만 팝니다. 같은 주제를 CS 원리 → iOS 매핑 → 재현·측정 순서로 끝까지 파는 것은 허브의 iOS 개발자 CS 로드맵이고, 대응 챕터는 아래입니다.
경험으로 말하기
❌ 나열형 — "Swift는 Optional, 제네릭, 프로토콜 지향, async/await를 지원합니다."
✅ 경험형 — "레거시 완료 핸들러 API를 async로 감싸면서 [weak self]를 신경 쓸 지점이 통째로 사라졌습니다. 콜백이 없으니 self를 붙잡을 클로저가 없고, 취소는 구조적 동시성이 전파해 줍니다.
대신 마이그레이션 중에 Sendable 경고가 수백 개 나왔고, 그중 상당수는 실제로는 안전한데 컴파일러가 증명하지 못하는 경우였습니다. 그래서 strict-concurrency를 모듈 단위로 단계적으로 올리면서, 진짜 위험한 것과 근거 있는 @unchecked를 구분해 처리했습니다."
후자에는 기능 → 없앤 작업 → 대신 생긴 작업 → 그 작업을 처리한 판단 기준이 다 들어 있습니다.
꼬리 질문 대비
L2 Swift의 안전성은 무엇을 대가로 얻었나요?
네 가지를 정직하게 말하면 됩니다.
- 컴파일 시간 — 제약 기반 타입 추론은 탐색이고, 제네릭 특수화는 코드 복제이며, 매크로는 확장 비용입니다. 큰 프로젝트에서 실질적 문제입니다.
- ARC 런타임 비용 — 참조 카운트 증감. 값 타입을 기본으로 두는 문화가 이걸 줄이려는 대응이기도 합니다.
- 어노테이션 부담 —
@escaping,@MainActor,Sendable... 안전을 타입으로 표현하는 대가로 코드에 표시를 붙여야 합니다. - 학습 곡선 — 특히 동시성. 6.2의 "approachable concurrency"가 나온 것 자체가 이 비용이 컸다는 증거입니다.
L3 ARC와 GC 중 뭐가 더 좋은 건가요?
"더 좋다"가 아니라 다른 트레이드오프입니다. 다만 모바일 UI 환경에서 ARC가 유리한 이유가 구체적으로 있습니다.
- 프레임 예산 — 60Hz면 16.7ms 안에 한 프레임을 끝내야 합니다. GC의 예측 불가능한 정지는 이 예산을 통째로 날립니다. ARC는 비용이 해제 지점에 분산됩니다. (11번과 연결)
- 메모리 여유 — 추적 GC는 힙에 여유가 있어야 효율이 납니다. iOS는 앱당 메모리가 빡빡하고, 압박이 오면 Jetsam이 종료시킵니다. (09번과 연결)
- 결정적 해제 — 파일 핸들·소켓을
deinit에서 정리할 수 있습니다. GC 언어는 finalizer 시점이 보장되지 않아 별도 장치(try-with-resources등)가 필요합니다.
대신 ARC는 순환 참조를 개발자에게 떠넘깁니다 — 순수 참조 카운팅은 지역 정보만 보므로 사이클을 판정할 수 없고, Swift는 그 판정 장치(사이클 수집기)를 넣지 않기로 선택했기 때문입니다(09번 L4에서 자세히 다룹니다).
L4 컴파일러가 다 잡아 준다면 왜 아직도 런타임 크래시가 나나요?
세 층으로 나눠 답하면 완결됩니다.
① 원리적 한계 (Rice의 정리) — 프로그램의 비자명한 의미적 성질은 결정 불가능합니다. 타입 시스템은 결정 가능한 근사만 검사합니다. 배열 인덱스가 범위 안인지는 일반적으로 컴파일 타임에 알 수 없어서, Swift는 런타임 검사로 남기고 대신 UB가 아니라 확정 크래시로 만들었습니다 — 안전의 정의가 "안 죽는다"가 아니라 "조용히 잘못되지 않는다"임을 보여 주는 예입니다.
② 설계상 남긴 것 — 강제 언래핑, fatalError, precondition은 일부러 죽이는 장치입니다. 잘못된 상태로 계속 도는 것보다 즉시 멈추는 게 낫다는 판단이죠.
③ 경계 밖 — C/Objective-C 상호운용, Unsafe*Pointer, 시스템 프레임워크 내부는 Swift 타입 시스템의 관할이 아닙니다. Swift 6의 데이터 경합 안전도 Swift 코드 안에서만 보장됩니다.
정리: 타입 시스템은 "오류 부류의 부재"를 증명하는 것이지, "모든 오류의 부재"를 증명하지 않습니다.
쉽게 이해하기
프로그래밍 언어를 공장의 안전 규정이라고 생각해 봅시다.
옛날 언어(C, Objective-C)는 "조심하세요"라고 써 붙인 공장입니다. 숙련공은 빠르게 일하지만 실수하면 다칩니다. 게다가 다치는 순간과 실수한 순간이 다릅니다 — 어제 잘못 놓은 부품 때문에 오늘 사고가 나요. 이게 nil 크래시와 데이터 경합의 정체입니다.
Swift는 애초에 잘못 조립되지 않는 부품을 쓰는 공장입니다. 안 맞는 부품은 끼워지지가 않습니다(컴파일 에러). "조심"에 의존하지 않죠.
그런데 여기서 중요한 사실이 하나 있습니다. 검사기는 애매하면 무조건 반려합니다. 사실은 멀쩡한 부품인데도요. 왜냐하면 "이 부품이 정말 안전한가"를 완벽하게 판정하는 기계는 만들 수 없다는 게 수학적으로 증명돼 있거든요(Rice의 정리). 그래서 공장에는 "제가 책임지겠습니다" 도장이 반드시 있습니다 — @unchecked Sendable, !, unowned가 그 도장입니다.
이걸 알면 태도가 바뀝니다. 컴파일러가 반려할 때 "왜 안 되지, 짜증나네"가 아니라 "아, 얘가 증명을 못 하는구나. 내가 증명할 수 있나?"로 묻게 됩니다. 증명할 수 있으면 도장을 찍고 근거를 주석으로 남기고, 못 하면 설계를 바꿉니다.
Swift의 모든 특징은 한 방향을 가리킨다 — 실행해 봐야 아는 것을 코드를 보면 알게 만든다. 비용은 컴파일 시간과 표시(어노테이션)이고, 한계는 Rice의 정리가 정해 준다.
설계 근거 · 1차 자료
이 페이지의 "왜 그렇게 설계했나" 주장은 아래에서 나왔습니다. 면접에서 근거를 물으면 이 이름들을 대면 됩니다.
- The Swift Programming Language — 언어 전반의 공식 설명
- SE-0296 — 콜백 대신 중단·재개를 택한 이유(Motivation 절)
- SE-0176 — 겹치는 접근을 금지해 최적화 여지를 얻은 근거
- SE-0413 — 에러 타입을 시그니처에 올린 이유
- SE-0390 — 소유권을 타입으로 표현하려는 방향
- Swift 6 Migration Guide — data-race safety를 기본값으로 올린 배경