← Mobile Foundation 인터뷰 2 / 13

02 · Swift의 특징

타입 안정성 · 동시성 → SIL 파이프라인 → 타입 이론과 정적 분석의 한계
진행률
0 / 0 완료
면접에서 나오는 형태
  1. Swift를 다른 언어와 비교했을 때 가장 큰 특징은 뭐라고 생각하나요?
  2. "타입 안정성이 높다"가 실제 코드에서 어떤 차이로 나타나나요?
  3. Swift의 안전성은 무엇을 대가로 얻은 건가요?
L1 개념
설계 목표는 safe · fast · expressive 세 개이고, 셋은 서로 충돌한다
L2 설계
충돌을 "런타임 검사를 타입으로 옮겨서" 푼다 — Optional·Sendable·throws·@escaping이 전부 같은 수법
L3 구현
SIL이라는 중간 언어에서 ARC 최적화·특수화·인라이닝이 일어난다. ABI 안정성은 레이아웃과 호출 규약을 못 박은 것
L4 CS
타입 건전성 · 대수적 데이터 타입 · Rice의 정리 — 컴파일러가 원리적으로 못 잡는 것이 있고, 그래서 탈출구가 필요하다

30초 답변 🔥 먼저 이 문장

Swift의 설계 목표는 safe · fast · expressive 셋인데, 이 셋은 서로 충돌합니다 — 안전하게 하면 느려지고, 표현력을 높이면 안전성이 흐려집니다.

Swift가 이 충돌을 푸는 방식이 일관됩니다. "실행해 봐야 아는 것"을 "코드를 보면 아는 것"으로 옮깁니다. nil은 Optional 타입으로, 데이터 경합은 Sendable·actor로, 실패 가능성은 throws로, 클로저 수명은 @escaping으로. 전부 런타임 검사를 컴파일 타임 검사로 옮긴 것이라 런타임 비용 없이 안전을 얻습니다. 대가는 컴파일 시간과 어노테이션 부담입니다.
RUNTIME — 실행해 봐야 안다 nil 참조 크래시 데이터 경합 (간헐적 오동작) 삼켜진 예외 · 처리 누락 클로저가 늘린 수명 (릭) COMPILE TIME — 코드를 보면 안다 Optional<Wrapped> Sendable · actor 격리 throws · exhaustive switch @escaping · 캡처 리스트 타입으로 옮기기
기능을 따로 외우면 목록이 되고, 이 축으로 묶으면 한 문장이 된다 — "런타임 오류를 타입 시스템으로 옮긴다."

L1개념 — 타입 안정성이 코드에서 드러나는 네 장면

타입 안정성의 실제 모습 (Swift)
// ① 암묵적 형변환 없음 — 의도치 않은 정밀도 손실이 컴파일 에러
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 없음수집 시 정지 가능
순환 참조해결 못 함 → 개발자 책임도달 불가면 수집
상시 비용참조 카운트 증감(원자적 연산)할당은 싸고, 수집 때 몰아서
⚠️ "ARC는 정지가 없다"의 정확한 범위

GC 같은 전역 stop-the-world는 없습니다. 하지만 비용이 없다는 건 과장입니다. 큰 객체 그래프의 루트를 놓으면 연쇄 해제가 그 자리에서 일어나 순간적인 프레임 지연을 만들 수 있습니다. 참조 카운트 증감도 멀티스레드에서 원자적 연산이라 공짜가 아닙니다. 정확히는 "예측 불가능한 전역 정지가 없고, 비용이 해제 지점에 분산되어 있다"입니다.

축 2 · 배타적 접근 (SE-0176)

같은 메모리를 두 경로로 동시에 만질 수 없다 (Swift)
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 반환에 가깝게 구현되므로 성능이 예측 가능합니다.

typed throws — 던지는 에러 타입까지 타입이 된다 (Swift 6, SE-0413)
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/awaitSE-0296콜백 중첩 제거. 스레드를 막지 않고 중단/재개
구조적 동시성SE-0304async let·TaskGroup — 자식 수명이 부모 스코프에 묶인다
actorSE-0306가변 상태 격리. 외부 접근은 await로 직렬화
SendableSE-0302격리 경계를 넘어도 되는 타입인지 컴파일러가 검사
Swift 6 모드위 검사를 기본으로 (complete concurrency checking)

Swift 6.2에서는 방향이 한 번 조정됐습니다. 초기 모델이 "안전하지만 배우기 어렵다"는 피드백을 받았고, 접근하기 쉬운 동시성이라는 이름으로 다음이 들어왔습니다.

📝 흐름을 한 문장으로

Swift 6.0이 "안전을 기본값으로"였다면, 6.2는 "그 안전을 덜 아프게"입니다. 안전성을 포기한 게 아니라 기본값을 현실에 맞게 다시 잡은 것이라고 설명하면 정확합니다.

L3구현 — SIL이라는 중간 언어

Swift 컴파일러가 특이한 점은 LLVM IR로 바로 내리지 않고 SIL(Swift Intermediate Language)이라는 자체 중간 언어를 거친다는 것입니다. 이유는 명확합니다 — Swift 고유의 최적화는 LLVM이 모르기 때문입니다.

Source .swift AST 파싱 Sema 타입 검사·추론 SIL Swift 고유 최적화 여기가 핵심 LLVM IR 범용 최적화 기계어 SIL에서 일어나는 일 · ARC 최적화 (불필요한 retain/release 제거) · 제네릭 특수화 · devirtualization · 인라이닝
"final을 붙이면 빨라진다", "WMO를 켜면 최적화된다"는 말은 전부 SIL 단계에서 일어나는 일이다. 05번 디스패치에서 다시 만난다.

ABI 안정성이 실제로 고정한 것

Swift 5의 ABI 안정성은 "버전이 올라가도 된다"는 선언이 아니라, 바이너리 수준 규약을 못 박은 것입니다.

결과: 표준 라이브러리가 OS에 내장되어 앱 바이너리에서 빠졌고, 다른 버전의 컴파일러로 빌드한 바이너리끼리 링크가 가능해졌습니다. 대가로 라이브러리 진화(library evolution) 모드에서는 구조체 레이아웃을 마음대로 바꿀 수 없어 간접 접근이 늘어납니다디스패치가 다시 동적으로 되돌아가는 지점입니다.

L4CS 근본 — 타입 시스템은 무엇을 증명하고, 무엇을 못 하나

① 타입 시스템의 정의: 실행 전에 오류 부류의 부재를 증명하는 장치

타입 이론에서 "타입 시스템이 건전(sound)하다"는 것은 두 정리로 정의됩니다.

둘을 합치면 "타입이 맞는 프로그램은 타입 오류로 멈추지 않는다"가 증명됩니다. 흔히 말하는 "well-typed programs don't go wrong"이 이겁니다. Swift가 "안전하다"고 할 때의 정확한 의미도 "모든 버그가 없다"가 아니라 "이 부류의 오류가 없음이 보장된다"입니다.

② 대수적 데이터 타입 — 타입의 크기를 계산할 수 있다

Swift의 structenum은 각각 곱 타입(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) 추론을 씁니다 — 식에서 제약을 모으고, 가능한 오버로드 조합을 탐색해서 해를 찾습니다. 그리고 여기서 실무에서 다들 겪는 그 에러가 나옵니다.

"expression too complex" 의 정체
// 이 한 줄이 컴파일러를 몇 분씩 잡아먹거나 아예 포기하게 만든다
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)를 합니다. 확신할 수 없으면 안전한 쪽으로 거부합니다.

모든 프로그램 실제로 안전한 프로그램 컴파일러가 안전하다고 증명할 수 있는 것 거부되지만 사실 안전한 코드 ← 여기에 @unchecked Sendable 이 필요한 이유
컴파일러의 안전 집합은 실제 안전 집합보다 항상 작다. 이 격차가 Rice의 정리가 강제하는 구조적 한계이고, 그래서 모든 정적 검사 언어에는 탈출구가 있다.

그래서 Swift에는 반드시 탈출구가 존재합니다. 그리고 탈출구의 이름이 전부 "이건 네가 책임져"라고 말하고 있습니다.

탈출구컴파일러가 포기한 것개발자가 지는 책임
@unchecked Sendable이 타입이 경계를 넘어도 안전한지락이나 불변성으로 직접 보장
! (force unwrap)이 값이 nil이 아님nil이면 크래시가 맞다는 판단
unowned대상이 더 오래 산다는 것수명 순서를 직접 보장
withoutActuallyEscaping이 클로저가 탈출하지 않음스코프 밖으로 안 내보낸다는 약속
Unsafe*Pointer메모리 유효성 전부수명·정렬·타입 전부
🧠 L4 이걸 말할 수 있으면 급이 달라진다

"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"가 나온 것 자체가 이 비용이 컸다는 증거입니다.
쉽게: 안전벨트와 같습니다. 사고를 막아 주지만 탈 때마다 매야 하죠. Swift는 "매지 않으면 시동이 안 걸리는 차"입니다.
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에서 자세히 다룹니다).

쉽게: GC는 주기적으로 오는 청소부 — 편하지만 청소 중엔 잠깐 멈춥니다. ARC는 쓴 사람이 바로 치우는 규칙 — 멈춤은 없지만 서로 미루면 영원히 안 치워집니다(순환 참조).
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를 기본값으로 올린 배경