Swift의 throws는 "거의 공짜"라는 말과 함께 소개되지만, 그 말이 정확히 무엇을 뜻하고 어디까지만 맞는지는 잘 안 다뤄진다. 이 챕터는 에러 전파의 ABI 레벨 구현부터, try?/rethrows/typed throws가 각각 무엇을 바꾸는지, Result와의 상호 변환, NSError 브리징, defer의 실무 함정까지를 다룬다.
Q1. Swift throws가 "zero-cost"라 불리는 근거는? 그 표현이 정확히는 왜 부정확한가?
Swift의 에러 전파는 C++ 예외처럼 테이블 기반 스택 언와인딩을 하지 않는다. 대신 컴파일러가 콜링 컨벤션에 전용 에러 레지스터(ARM64는 x21, x86-64는 r12, 둘 다 callee-saved)를 예약해두고, throws 함수는 정상 반환값과 함께 이 레지스터에 에러 포인터를 실어 돌아온다. 호출부는 try 지점마다 이 레지스터가 0인지 test+분기 명령 한 번으로 검사한다. 이 덕분에 정상 경로에는 별도의 프레임 등록·언와인딩 테이블이 필요 없어 "zero-cost"라 불리지만, Swift 팀 문서 스스로 이 표현이 좁은 의미임을 인정한다 — 매 호출마다 분기가 실제로 존재하고, 에러 경로 코드가 함수 안에 같이 있는 것만으로 코드 크기·지역성(locality)에 비용이 생긴다.
원리
ErrorHandlingRationale.md는 에러 전파를 구현하는 세 가지 방식을 비교한다. 첫째는 암묵적 수동 전파(매 호출부마다 명시적으로 에러 여부를 체크해야 하므로 정상 경로에도 비용이 붙는다), 둘째는 setjmp/longjmp(컨텍스트 저장과 프레임 등록 자체가 무겁다), 셋째는 테이블 기반 언와인딩(C++ 예외 방식 — 정상 경로에는 셋업 코드가 없다는 의미에서 "zero-cost"라 불린다)이다. Swift는 이 셋 중 어느 것도 그대로 쓰지 않고, 콜링 컨벤션 차원에서 에러를 추가 반환값처럼 취급하는 방식을 택했다.
내부 동작
CallingConventionSummary.rst에 명시된 대로, Swift ABI는 ARM64에서 x21, x86-64에서 r12를 "Error return" 전용 레지스터로 예약한다(둘 다 callee-saved). LLVM IR 레벨에서는 이것이 swifterror 파라미터 어트리뷰트로 모델링되는데, 겉보기엔 ErrorObject** 타입의 입력 인자처럼 보이지만 실제로는 고정 물리 레지스터에 담긴 추가 리턴 값처럼 최적화된다.
Mike Ash가 Swift 4 기준으로 어셈블리까지 내려가 확인한 바로는, 에러를 던질 때 swift_allocError 런타임 콜로 저장 공간을 할당하고 mov r12, rax로 에러 레지스터를 채운다. 호출부는 try 지점마다 test r12, r12 + jne(또는 je) 한 쌍으로 분기한다. 정상 경로에서 추가되는 비용은 "throws 컨텍스트 진입 시 에러 레지스터를 0으로 초기화하는 명령 1개 + 분기 1개(또는 인텔에서는 fused uop 1개)" 정도다. 이게 C++보다 유리한 진짜 이유는 명령 수가 적어서라기보다, 컴파일 타임에 어떤 함수가 throw 가능한지 정적으로 알기 때문에 동적 언와인딩 테이블 룩업 자체가 필요 없다는 데 있다.
이 규약은 2017년 x86-64 이야기로 끝난 게 아니다. Swift 6.2.1 / ARM64에서 직접 뽑아보면 동일한 구조가 그대로 나온다 — 그리고 "zero-cost가 아닌 이유"가 어셈블리에 눈으로 보인다.
swiftc -O -emit-assembly로 뽑은 호출부// 원본
struct E: Error {}
@inline(never) func mayThrow(_ x: Int) throws -> Int {
if x < 0 { throw E() }
return x * 2
}
@inline(never) func caller(_ x: Int) -> Int {
do { return try mayThrow(x) } catch { return -1 }
}
// swiftc -O -emit-assembly 결과 중 caller 본체 (주석은 설명용으로 덧붙임)
_$s8swifterr6calleryS2iF:
sub sp, sp, #48
stp x22, x21, [sp, #16] // x21은 callee-saved라 호출자가 보존해준다
stp x29, x30, [sp, #32]
add x29, sp, #32
mov x21, #0 // ① 호출 전 에러 레지스터를 0으로 초기화
bl _$s8swifterr8mayThrowyS2iKF
cbz x21, LBB2_2 // ② 0이면 '에러 없음' — 정상 경로로 분기
mov x0, x21 // 0이 아니면 x21이 곧 에러 객체 포인터
bl _swift_errorRelease // catch에서 에러를 소비하고 해제
mov x0, #-1
LBB2_2:
ldp x29, x30, [sp, #32]
ldp x22, x21, [sp, #16]
add sp, sp, #48
ret
①과 ②가 바로 "zero-cost"라는 말이 좁은 의미인 이유다. 정상 경로에서도 throwing 호출마다 초기화 mov 한 개와 검사 분기 cbz 한 개가 반드시 실행된다. 진짜로 0인 것은 언와인딩 테이블 룩업과 프레임 등록이지, 명령 수가 아니다. 또 하나 눈여겨볼 것: throw는 swift_allocError로 힙 할당을 한다. 에러를 정상 제어 흐름처럼 초당 수만 번 던지는 코드가 느린 건 분기 때문이 아니라 이 할당 때문이다.
실험 · 도구
실제 어셈블리로 확인해보자.
// error_test.swift
enum E: Error { case bad }
func maybeThrow(_ b: Bool) throws -> Int {
if b { throw E.bad }
return 42
}
func caller(_ b: Bool) -> Int {
(try? maybeThrow(b)) ?? -1
}swiftc -O -emit-assembly error_test.swift -o error_test.s
grep -A6 "maybeThrow" error_test.s # x21(ARM64)/r12(x86-64) 관련 mov·tst·b.ne 확인
swiftc -emit-sil error_test.swift | grep -B2 -A2 "error :"
# throws 함수 시그니처에 @error 파라미터가 별도로 붙는 것을 확인할 수 있다LLDB에서는 image lookup -rn swift_allocError로 런타임 심볼 존재를 확인할 수 있고, Xcode 브레이크포인트 내비게이터에서 "Swift Error Breakpoint"를 추가하면 심볼 swift_willThrow에 자동으로 브레이크포인트가 걸려 throw가 실행되는 순간(catch되기 전) 에러 값을 조사할 수 있다.
프로젝트 적용
① "zero-cost니까 throws를 쓰든 안 쓰든 상관없다"는 잘못된 결론이다. 다만 실무 임팩트는 대부분 무시할 수준이므로, 에러가 나야 하는 곳엔 주저 없이 throws를 쓰는 게 맞다 — 성능이 아니라 API 설계(어떤 실패가 값으로 드러나야 하는가) 관점에서 판단하라.
// ❌ 실패를 옵셔널로 뭉개면 '왜' 실패했는지가 사라진다
func parseConfig(_ s: String) -> Config? { ... }
// ✅ 실패 사유를 타입으로 드러낸다 — 성능 차이는 무시 가능한 수준
enum ConfigError: Error { case emptyInput, invalidFormat(line: Int) }
func parseConfig(_ s: String) throws -> Config {
guard !s.isEmpty else { throw ConfigError.emptyInput }
// ...
return Config()
}② 아주 뜨거운 루프(hot loop) 안에서 매 반복마다 throws 호출을 건다면, 분기 예측 실패 비용까지 신경 써야 하는 극단적 상황도 있다. 이런 경우엔 루프 밖에서 한 번만 throws 경계를 통과시키고 내부는 non-throwing으로 유지하는 편이 유리하다.
// ❌ 루프 내부에서 매번 throws 호출 + try 분기
func sumBad(_ items: [String]) throws -> Int {
var total = 0
for s in items {
total += try Int(validating: s) // 매 반복마다 에러 레지스터 체크
}
return total
}
// ✅ 변환은 루프 밖에서 한 번에 끝내고, 루프 내부는 non-throwing
func sumGood(_ items: [String]) throws -> Int {
let numbers = try items.map { try Int(validating: $0) } // 경계는 여기 한 번
return numbers.reduce(0, +) // 루프는 non-throwing
}"Swift throws는 완전히 공짜(free)다"는 틀렸다. Swift 팀 문서 스스로 "정상 경로에 명시적 셋업이 없다"는 좁은 의미로만 zero-cost라 부른다고 인정한다. 매 try 지점마다 레지스터 검사·분기 명령이 실제로 삽입되고, throws 여부는 콜링 컨벤션 자체를 바꾸므로 일부 인라이닝·최적화 결정에도 영향을 준다. 또한 "throws는 내부적으로 예외를 던진다"도 틀렸다 — 스택 언와인딩이 없는, 숨겨진 리턴 채널을 체크하는 평범한 제어 흐름이다.
친구랑 게임할 때 "실패하면 손 들어"라고 약속했다고 하자. C++ 예외는 실패했을 때 심판이 경기 기록 전체를 뒤져서 "언제부터 잘못됐는지" 찾아내는 것과 비슷하다(테이블 언와인딩). Swift는 그 대신 각자 손에 작은 깃발(에러 레지스터)을 쥐고 있다가, 차례가 끝날 때마다 상대가 그 깃발이 올라갔는지만 힐끗 보는 방식이다. 힐끗 보는 것도 분명히 하는 행동(비용)이지만, 기록을 통째로 뒤지는 것보다는 훨씬 빠르다. 비유가 깨지는 곳: 실제로는 "손을 든다"는 행동 자체(에러 객체 할당)에도 비용이 있고, 깃발을 힐끗 보는 동작이 게임 규칙(콜링 컨벤션) 자체에 미리 박혀 있어야 한다는 점은 이 비유로 다 설명되지 않는다.
꼬리 질문
왜 x21/r12처럼 "레지스터 하나"를 예약하는 방식을 택했는가? 추가 파라미터나 추가 반환값 방식과 비교하면?
NSError**와 유사)과 추가 반환값 방식을 모두 검토했다고 서술한다. 최종적으로 채택된 건 전용 레지스터를 콜링 컨벤션에 고정으로 박아넣는 방식인데, 이렇게 하면 함수 시그니처(스택 레이아웃)를 건드리지 않고도 모든 throws 함수가 동일한 규약으로 에러를 주고받을 수 있어, 컴파일러가 어떤 함수가 throws인지 그때그때 다르게 처리할 필요가 없어진다. LLVM의 swifterror 어트리뷰트가 이를 IR 레벨에서 표현한다.throws가 콜링 컨벤션을 바꾼다는 게 실무에서 어떤 형태로 드러나는가?
(Int) -> Int를 (Int) throws -> Int가 필요한 곳에 전달), 그 반대는 안 된다. 이는 단순한 타입 체크 문제가 아니라, 실제로 호출 규약(에러 레지스터를 세팅하는 코드가 있는지) 자체가 다르기 때문에 컴파일러가 필요하면 thunk(중계 함수)를 만들어 규약을 맞춰준다. 또한 @convention(c) 함수 포인터에는 throws 함수를 직접 못 쓰는 것도 같은 이유다.ARM64와 x86-64에서 에러 레지스터 번호가 다른데, 크로스플랫폼(리눅스·WASM)에서는 어떻게 되는가?
Q2. try? / try! / rethrows는 각각 무엇을 바꾸는가? (SE-0230 전후 차이 포함)
try?는 에러를 옵셔널로 바꾼다(성공은 .some(값), 실패는 nil). try!는 "여기서는 절대 에러가 안 난다"를 단언하고, 실제로 나면 런타임 크래시로 이어진다 — 사실상 fatalError를 감싼 것과 같은 급이다. rethrows는 함수 자신은 무조건 throws가 아니라, 인자로 받은 클로저가 실제로 throw할 때만 자신도 throw한다고 선언하는 장치다. 그리고 SE-0230(Swift 5.0) 이전에는 이미 옵셔널을 반환하는 throwing 함수에 try?를 쓰면 T??(중첩 옵셔널)가 나왔지만, 이후로는 평탄화된 T?가 나온다.
원리
swift-book ErrorHandling은 try?를 "에러가 던져지면 표현식의 값이 nil이 된다"고 정의한다. try!는 "에러 전파를 비활성화하고, 에러가 안 날 것이라는 런타임 단언(assertion)으로 감싼다"고 명시하며, 실제로 던져지면 런타임 에러가 난다고 경고한다. rethrows는 별도 제안서 없이 언어 초기 설계에 포함된 장치로, ErrorHandlingRationale.md에 따르면 목적은 "모든 고차 함수가 무조건 throws로 오염되는 것"을 막는 것이다.
내부 동작
SE-0230(Flatten Nested Optionals resulting from 'try?')은 Swift 5.0부터 적용된다. 이전에는 func f() throws -> Int?에 try? f()를 쓰면 "throws가 실패했는지"와 "원래 반환값이 nil인지"라는 두 종류의 실패가 별개 레이어로 겹쳐 Int??가 나왔다. 이 이중 옵셔널은 if let 한 번으로 완전히 벗겨지지 않고, 대부분의 호출부가 "에러가 났는지"와 "원래 값이 nil인지"를 구분할 필요조차 없었기 때문에 실용성이 떨어졌다. SE-0230은 이를 Int? 하나로 평탄화했다 — 옵셔널 체이닝이 항상 한 겹으로 평탄화되는 것과 같은 철학이다.
rethrows의 타입체크 규칙은 "throws 클로저 파라미터를 최소 하나 받고, 함수 몸체에서 그 클로저 호출 외의 경로로는 throw하지 않는다"는 것을 컴파일러가 검증한다. 호출부에서 non-throwing 클로저를 넘기면 그 호출 지점 자체가 non-throwing으로 취급되어 try를 안 써도 된다 — 즉 rethrows 함수의 throws 여부는 호출 시점에 클로저에 따라 결정되는, 정적이지만 조건부인 성질을 갖는다.
실험 · 도구
func maybeInt() throws -> Int? { return nil }
let x = try? maybeInt()
print(type(of: x))
// Swift 5.0+: Optional<Int> (즉 Int?)
// Swift 4 이하였다면: Optional<Optional<Int>> (Int??)rethrows의 조건부 성질은 swiftc -emit-sil로 확인 가능하다: non-throwing 클로저를 넘긴 호출 지점의 SIL에는 try_apply 대신 평범한 apply가 나오는 것을 대조해볼 수 있다.
프로젝트 적용
① try?는 "에러 종류가 중요하지 않고 성공/실패만 필요한" 지점에만 써라. 에러 원인을 로깅해야 한다면 do-catch로 명시적으로 잡아라 — try?는 에러 값 자체를 버린다.
enum CacheError: Error { case corrupted, missing }
func loadCache() throws -> Data { throw CacheError.missing }
// ❌ 왜 실패했는지 영영 알 수 없다
let cached = try? loadCache()
// ✅ 원인을 알아야 한다면 do-catch로 명시적으로 처리
func loadCacheLogged() -> Data? {
do {
return try loadCache()
} catch {
print("캐시 로드 실패: \(error)") // 원인을 남긴다
return nil
}
}② try!는 "이 실패는 프로그래머 실수로만 일어날 수 있고, 그 실수는 반드시 즉시 크래시로 드러나야 한다"는 확신이 있을 때만 쓴다. 예를 들어 앱 번들에 포함된, 컴파일 타임에 정합성이 보장된 리소스를 파싱할 때다.
// ✅ 정당한 사례: 번들에 포함된, 빌드 시점에 항상 유효함이 보장되는 리소스
let config = try! JSONDecoder().decode(
AppConfig.self,
from: Bundle.main.data(forResource: "default_config", withExtension: "json")!
)
// 이 파일이 없거나 깨졌다면 그건 빌드 파이프라인 버그이므로,
// 조용히 넘어가기보다 즉시 크래시로 드러나는 게 맞다.
// ❌ 네트워크 응답처럼 외부 요인으로 실패할 수 있는 곳에 try!는 금물
let user = try! JSONDecoder().decode(User.self, from: networkResponse)"try?는 항상 T??를 반환한다"는 SE-0230(Swift 5.0) 이후로는 틀렸다 — 평탄화된 T?가 나온다. 오래된 블로그·자료가 T??로 설명한다면 Swift 4 이하 기준일 가능성이 높다. 또 "rethrows 함수는 항상 에러를 던진다"도 틀렸다 — 인자로 받은 클로저가 실제로 throw할 때만 던지며, non-throwing 클로저를 넘기면 그 호출은 아예 non-throwing으로 취급된다.
try?는 "혹시 안 되면 그냥 없는 셈 치자"이고, try!는 "절대 안 될 리 없으니까 안 되면 그건 재난이다, 바로 멈춰"다. rethrows는 좀 다른데, "네가 나한테 위험한 일을 시키면 나도 위험해지고, 안전한 일을 시키면 나도 안전해"라고 말하는 심부름꾼 같은 것이다 — 심부름꾼(rethrows 함수) 자체는 항상 위험한 게 아니라 누가 무슨 심부름(클로저)을 시켰는지에 따라 위험 여부가 달라진다. 비유가 깨지는 곳: 심부름꾼 비유는 "컴파일 타임에 이미 정해진다"는 점을 담지 못한다 — 실제로는 실행 전에 컴파일러가 어떤 호출이 위험한지 이미 다 확정해둔다.
꼬리 질문
SE-0230 이전 Int?? 결과를 이미 옵셔널 바인딩(if let)으로 처리하던 코드는 마이그레이션 시 어떻게 되는가?
if let x = try? f()처럼 한 겹만 벗기던 코드는 Swift 4에서는 x가 여전히 Int?였지만(바깥 옵셔널만 벗겨짐), SE-0230 이후에는 x가 바로 Int가 된다. 즉 바인딩 결과 타입이 바뀌므로 x를 다시 옵셔널처럼 다루던 코드(예: x ?? 0을 또 쓰던 곳)는 컴파일 에러나 의미 변화가 생길 수 있어, 마이그레이터가 이 패턴을 플래그했다.rethrows 함수 안에서 클로저 호출과 무관한 자기 자신의 throw를 넣으면 왜 컴파일 에러가 나는가?
throw MyError.x를 직접 넣으면, non-throwing 클로저를 넘긴 호출자 입장에서 "throws가 아니라고 알고 있었는데 실제로는 throw할 수 있는" 모순이 생긴다. 컴파일러는 이 계약을 정적으로 강제하기 위해 rethrows 함수 안에서의 직접 throw를 클로저 호출을 통한 것 외에는 금지한다.try? 뒤에 옵셔널 체이닝을 이어 붙이면(try? f()?.prop) 무슨 일이 일어나는가?
try? f() 자체가 이미 한 겹 T?이므로, 그 뒤에 ?.를 이어 붙여도 옵셔널 체이닝의 일반 규칙(중첩되지 않고 항상 한 겹으로 평탄화)이 그대로 적용되어 최종 결과는 여전히 한 겹 옵셔널이다. 에러로 인한 nil과 체이닝 중간의 nil이 구분되지 않는다는 한계는 그대로 남는다 — 애초에 try?의 설계 의도가 그 구분을 포기하는 것이기 때문이다.Q3. typed throws(SE-0413)는 무엇을 얻고 API 진화 관점에서 무엇을 잃는가?
Swift 6.0에 SE-0413으로 들어온 throws(E)는 함수가 특정 콘크리트 에러 타입만 던진다고 선언하는 문법이다. throws(CatError)처럼 쓰면 catch 블록 안에서 error가 any Error가 아니라 CatError로 곧바로 타입 추론된다. 얻는 것은 정보 손실 없는 에러 타입, existential(any Error) 힙 할당·메타데이터 오버헤드 회피, Embedded Swift처럼 existential 자체가 금지되는 제약 환경에서의 사용 가능성이다. 잃는 것은 API 진화 유연성이다 — 한번 콘크리트 타입을 public API에 박아넣으면, 나중에 에러 종류를 늘리려고 any Error로 되돌리는 순간 그 타입에 switch로 의존하던 모든 호출자 코드가 깨진다.
원리
SE-0413의 동기는 세 가지다. (1) 무타입 throws는 Result<T,E>·Task<T,E>보다 타입 정보가 적다. (2) Result 기반의 명시적 에러 처리는 보일러플레이트가 많다. (3) any Error는 힙 할당과 메타데이터 오버헤드가 있고, Embedded Swift 같은 제약 환경에서는 existential 자체가 아예 금지될 수 있다. 문법은 func summarize(_ ratings: [Int]) throws(StatisticsError) 형태이며, 함수 안에서는 throw .noRatings처럼 짧은 형태의 컨텍스트 기반 타입 추론이 동작한다.
내부 동작
서브타이핑 규칙이 명확하다: 논스로잉 함수는 throws(Subtype) 자리에 들어갈 수 있고, throws(Concrete)는 업캐스트로 throws(any Error) 자리에 들어갈 수 있다. Never는 모든 에러 타입의 서브타입이라 throws(Never)는 non-throwing 함수와 동등하게 취급된다 — 제네릭 함수에서 "이 클로저는 절대 안 던진다"를 표현할 때 특히 유용하다. 특수 케이스로 throws(any Error)는 기존 무타입 throws와 완전히 동일한 의미다.
rethrows와의 공존 규칙도 있다: "제네릭 에러 파라미터 E + 그 E를 던지는 클로저 파라미터만 있고 다른 throw 제약이 없는" 함수는 호출자 관점에서 여전히 rethrows처럼 취급되어 하위호환이 유지된다. 표준 라이브러리도 이를 반영해 Result.init(catching:)과 Result.get()이 throws(Failure)를 쓰도록, map/filter 등 기존 rethrows 메서드들이 제네릭 typed throws로 전환됐다.
2026년 현재 구현 현황: Swift 6.0에 문법·타입체크·서브타이핑·표준 라이브러리 반영까지는 끝났다. 하지만 클로저 본문 구조로부터 던지는 타입을 추론하는 것, Task/AsyncSequence 같은 동시성 라이브러리 전면 반영, throws(some Error) 같은 opaque 에러 타입은 여전히 제안서의 Future Directions로 남아 있다 — 초기안에 있던 일부 타입 추론 개선이 6.0에는 구현되지 않고 이월됐다.
실험 · 도구
enum StatisticsError: Error { case noRatings, invalidRating(Int) }
func summarize(_ ratings: [Int]) throws(StatisticsError) {
guard !ratings.isEmpty else { throw .noRatings } // 짧은 형 throw 가능
}
do throws(StatisticsError) {
try summarize([])
} catch {
switch error { // error가 any Error가 아니라 StatisticsError로 추론됨
case .noRatings: print("no ratings")
case .invalidRating(let r): print("invalid: \(r)")
}
}swiftc -emit-sil -O typed_throws.swift | grep -A3 "function_ref"
// any Error 버전은 existential 박싱/언박싱 관련 런타임 콜이 섞여 나오고,
// 콘크리트 typed throws 버전은 그 부분이 사라진 SIL을 대조 관찰할 수 있다프로젝트 적용
① 모듈 내부에서만 쓰이는 알고리즘·파서처럼 "이 함수가 던질 수 있는 에러 종류를 이미 다 알고, 앞으로도 바뀔 계획이 없는" 코드에 typed throws를 적극 쓴다.
enum JSONParseError: Error {
case unexpectedToken(String)
case unterminatedString
}
// internal 파서라 앞으로도 이 두 가지 실패 케이스로 충분하다는 확신이 있다
func parseToken(_ s: Substring) throws(JSONParseError) -> Token {
guard let first = s.first else { throw .unterminatedString }
// ...
return Token(raw: String(first))
}② 외부에 공개되는 public API, 특히 향후 새로운 실패 사유를 추가할 가능성이 있는 곳에는 typed throws를 성급히 걸지 마라. 대신 무타입 throws를 유지하거나, 정말 필요하면 프로토콜로 감싸 진화 여지를 남긴다.
// ❌ 라이브러리 v1에서 이렇게 박아버리면
public func fetch(_ url: URL) throws(NetworkError) -> Data { ... }
// v2에서 DiskCacheError 같은 새 실패 원인을 추가하려면
// any Error로 넓혀야 하고, 그 순간 호출자의
// switch error { case .timeout: ... } 코드가 전부 깨진다
// ✅ 외부 공개 API는 무타입으로 유연성을 남긴다
public func fetch(_ url: URL) throws -> Data { ... }"typed throws가 Swift 6에서 완전히 끝난 기능이다"는 틀렸다 — 클로저 본문으로부터의 추론, concurrency 라이브러리 통합, opaque throws는 여전히 Future Directions다. "throws(E)를 쓰면 catch에서 무조건 exhaustive switch를 강제한다"도 틀렸다 — 문법상 강제되지 않고, catch { }로 뭉뚱그려도 되며 exhaustiveness는 switch error { }를 쓸 때만 별개로 요구된다. "이제 모든 public API에 typed throws를 기본으로 써야 한다"도 제안서 자체가 부인한다 — 제약 환경·모듈 내부·제네릭 rethrowing에 국한해 쓰고, 진화 가능성 있는 외부 API는 신중하라고 명시한다.
무타입 throws는 "뭔가 잘못될 수 있어"라고만 적힌 표지판이고, throws(CatError)는 "고양이 관련 문제만 생길 수 있어"라고 구체적으로 적힌 표지판이다. 구체적인 표지판이 훨씬 유용하지만, 나중에 "사실 강아지 문제도 생길 수 있게 됐어"라고 바꾸려면 표지판을 다시 세워야 하고, 그 표지판만 보고 대비하던 사람들(호출자 코드)이 헷갈리게 된다. 비유가 깨지는 곳: 표지판을 다시 세우는 건 실제로는 "컴파일 에러"라는 훨씬 확실한 신호로 나타난다 — 조용히 헷갈리는 게 아니라 빌드가 그 자리에서 멈춘다.
꼬리 질문
throws(Never)는 실제로 어디에 쓰이는가?
E: Error로 잡아두면, 호출자가 non-throwing 클로저를 넘겼을 때 E가 Never로 특수화되어 그 함수 호출 전체가 non-throwing으로 취급된다. 예를 들어 map을 제네릭 typed throws로 재설계하면, non-throwing 클로저를 넘긴 map 호출에는 try가 아예 필요 없어진다 — 기존 rethrows가 정적으로 하던 일을 타입 시스템으로 더 명시적으로 표현한 것이다.throws(any Error)와 무타입 throws는 정말 100% 동일한가?
throws는 사실 throws(any Error)의 syntactic sugar였던 것으로 재정의된다. 다만 표기가 다르므로 코드 리뷰나 문서화 관점에서 명시적으로 throws(any Error)라고 쓰면 "일부러 넓게 열어뒀다"는 의도를 드러낼 수 있다는 실무적 차이는 있다.typed throws 도입 이전 코드에 throws(SomeError)를 소급 적용하면 소스 호환이 깨지는가?
throws에서 콘크리트 throws(SomeError)로 좁히는 방향은 서브타이핑상 안전한 방향(콘크리트는 항상 any Error로 업캐스트 가능)이지만, 호출자가 이미 catch let e as OtherErrorType처럼 다른 타입으로 캐스팅을 시도하던 코드가 있다면 그 캐스팅이 항상 실패하게 되는 의미 변화는 생길 수 있다. 반대로 catch { }로 뭉뚱그려 받던 호출자는 영향이 거의 없다.Q4. Result와 throws는 어떻게 상호 변환하며 각각 어디에 맞는가?
Result<Success, Failure: Error>는 .success(Success)/.failure(Failure) 두 케이스를 가진 평범한 enum이다. Result.get()이 throws로 되돌리고, Result(catching:)이 throws 클로저를 캡처해 Result로 감싼다 — 이 둘로 양방향 변환이 항상 가능하다. throws는 동기적이고 즉시 처리되는 에러에 적합하고(do-catch·try 문법 지원이 좋다), Result는 에러를 값으로 저장·전달·지연 처리해야 하는 상황(클로저 캡처, 배열에 담기, 비동기 콜백)에 적합하다.
원리
SE-0235는 Result 도입 동기를 "Swift의 현재 에러 처리(throws/try/catch)는 자동적·동기적이지만, 모든 에러 전파·처리 시나리오를 커버할 유연성이 부족하다"로 설명한다 — 특히 비동기 API, 지연된 에러 처리, Error를 준수하지 않는 값을 에러처럼 다뤄야 하는 시나리오다. Failure는 반드시 Error를 준수해야 한다는 제약이 있는데, 이는 에러 처리와의 상호운용을 단순화하고 Result<Error, Value>처럼 제네릭 파라미터 순서를 실수로 뒤바꾸는 흔한 오타를 컴파일 에러로 즉시 잡아준다.
내부 동작
get()은 func get() throws -> Success 시그니처로, Result를 다시 throws 세계로 되돌리는 변환 메서드다. init(catching:)은 throws 클로저를 받아 성공/실패를 캡처하며 Failure == Swift.Error일 때 사용 가능하다. map/mapError/flatMap/flatMapError는 각각 성공/실패 값을 변환하며, flatMap 계열은 중첩된 Result를 평탄화한다. SE-0413(typed throws) 반영 이후로는 get()과 init(catching:)이 throws(Failure)를 쓰도록 개선되어, non-throwing 클로저를 넘기면 Failure가 Never로 추론되는 등 더 유연해졌다.
실험 · 도구
enum NetworkError: Error { case timeout }
func fetch() -> Result<Data, NetworkError> { .failure(.timeout) }
// Result → throws
func fetchOrThrow() throws -> Data { try fetch().get() }
// throws → Result
func wrap() -> Result<Data, Error> {
Result(catching: { try fetchOrThrow() })
}실제로 콜백 기반 API(예: 구식 URLSession completion handler)를 흉내 내는 함수를 만들어 Result를 클로저 인자로 캡처해보면, 에러가 호출 시점이 아니라 나중에 처리될 수 있다는 점을 눈으로 확인할 수 있다.
프로젝트 적용
① completion handler 기반 비동기 API(레거시 콜백 스타일)에는 Result가 자연스럽다 — 에러를 즉시 던지지 못하고 나중에 클로저로 전달해야 하기 때문이다.
enum ImageError: Error { case notFound, decodeFailed }
// throws로는 표현 불가 — 완료 시점이 미래이므로 값으로 들고 있어야 한다
func loadImage(url: URL, completion: @escaping (Result<UIImage, ImageError>) -> Void) {
DispatchQueue.global().async {
guard let data = try? Data(contentsOf: url) else {
completion(.failure(.notFound)); return
}
guard let image = UIImage(data: data) else {
completion(.failure(.decodeFailed)); return
}
completion(.success(image))
}
}② 동기적으로 바로 처리 가능한 로직이라면 Result로 감싸지 말고 그냥 throws를 써라 — do-catch·try?·try! 문법 지원이 switch로 매번 케이스를 꺼내는 것보다 간결하다.
// ❌ 굳이 Result로 감싸면 매번 switch로 꺼내야 해서 번거롭다
func parseBad(_ s: String) -> Result<Int, ParseError> { ... }
switch parseBad("42") {
case .success(let n): print(n)
case .failure(let e): print(e)
}
// ✅ 동기 로직은 throws가 더 간결하다
func parseGood(_ s: String) throws -> Int { ... }
do { print(try parseGood("42")) } catch { print(error) }"Result는 throws보다 항상 상위호환이라 Result만 쓰면 된다"는 틀렸다. 동기 API에서는 do-catch가 더 간결하고 컴파일러 지원(try를 통한 early return)이 좋다. Result는 주로 비동기·지연·저장이 필요한 경우에 쓴다. 또 "Result<Success, Failure>의 Failure는 아무 타입이나 된다"도 틀렸다 — Error를 준수해야 하는 제약이 컴파일 타임에 강제된다.
throws는 지금 당장 "이거 실패야!"라고 소리치는 것이고, Result는 결과를 상자에 담아 나중에 열어볼 수 있게 건네주는 것이다. 지금 바로 확인할 수 있으면 소리치는 쪽(throws)이 더 간단하지만, 상대가 지금 자리에 없어서 나중에 확인해야 한다면(비동기) 상자에 담아 건네는 쪽(Result)이 유일한 방법이다. 비유가 깨지는 곳: 실제로는 상자(Result) 안의 내용물도 결국 .get()으로 다시 "소리치는" 형태(throws)로 바꿀 수 있어서, 둘은 완전히 다른 세계가 아니라 서로 오갈 수 있는 관계다.
꼬리 질문
Result의 flatMap과 throws의 에러 전파는 실제로 같은 일을 하는가?
Result.flatMap은 이를 값 변환 체인으로 명시적으로 표현하고, throws는 try 표현식들을 순서대로 실행하다가 첫 실패에서 함수 전체를 빠져나가는 제어 흐름으로 표현한다. 체인이 길어질수록 throws + try가 중첩 없이 더 읽기 쉬운 경우가 많다.Result<Success, Never>는 왜 유용한가?
Failure를 Never로 고정하면 "이 Result는 절대 실패할 수 없다"는 것을 타입으로 보장한다. 실패가 없는데도 API 일관성을 위해 Result 형태를 유지해야 하는 경우(예: 여러 종류의 completion handler를 통일된 시그니처로 다루는 프레임워크 코드)에 쓰인다. .failure 케이스에 접근하는 코드는 Never 값을 만들 수 없으므로 컴파일 타임에 도달 불가능하다는 것이 보장된다.init(catching:)에 던지는 클로저 대신 이미 Result인 값을 넣으면 무슨 일이 일어나는가?
init(catching:)은 () throws -> Success 클로저를 받으므로, 이미 Result인 값을 그대로 넣을 수는 없고 .get()으로 먼저 풀어 throws 클로저 형태로 바꿔야 한다(Result(catching: { try existingResult.get() })). 이는 Result끼리 중첩되는 것을 방지하고 항상 throws를 매개로 변환이 이뤄지게 하는 설계다..get()) 내용물을 꺼낸 다음에야 새 상자에 담을 수 있다.Q5. Swift Error는 NSError로 어떻게 브리징되는가? CustomNSError·LocalizedError의 역할은?
Swift 2부터 Error를 준수하는 어떤 타입이든 as NSError로 자동 브리징된다 — 별도 프로토콜을 구현하지 않아도 컴파일러가 타입의 mangled name을 도메인으로, enum 케이스의 순번(0부터)을 코드로 채워 기본 NSError를 만들어낸다. CustomNSError는 이 기본값을 errorDomain(static)·errorCode·errorUserInfo로 직접 override하는 프로토콜이고, LocalizedError는 사용자에게 보여줄 문자열(errorDescription 등)을 채우는 프로토콜이다. 둘은 관심사가 분리돼 있어(Cocoa 상호운용 vs UI 노출) 동시에 준수해도 된다.
원리
SE-0112(Swift 3에 구현)가 이 체계를 정리했다. CustomNSError는 다음 시그니처를 갖는다(NSHipster로 현행 확인된 최신 형태 — SE-0112 원안은 errorDomain이 인스턴스 프로퍼티였으나 현재 표준 라이브러리 선언은 static이다).
protocol CustomNSError: Error {
static var errorDomain: String { get } // static! (초안과 다른 부분)
var errorCode: Int { get }
var errorUserInfo: [String: Any] { get }
}
protocol LocalizedError: Error {
var errorDescription: String? { get }
var failureReason: String? { get }
var recoverySuggestion: String? { get }
var helpAnchor: String? { get }
}내부 동작
Objective-C API와 상호운용할 때 Swift Error는 자동으로 NSError로 브릿지된다. 브릿징 시점에 런타임이 그 값의 타입이 CustomNSError를 준수하는지, LocalizedError를 준수하는지 각각 체크해서 errorUserInfo에 반영한다 — 예컨대 LocalizedError.errorDescription이 있으면 그 값을 NSLocalizedDescriptionKey로 채워 넣는다. 어느 프로토콜도 준수하지 않는 평범한 Error enum이라도, 컴파일러가 타입의 mangled name 기반 문자열을 도메인으로, 매치된 enum 케이스의 discriminator(선언 순서 0부터)를 코드로 자동 채워 넣으므로 브리징 자체는 항상 성립한다. 역방향(NSError → Swift 에러)은 _ObjectiveCBridgeableError(init?(_bridgedNSError:))가 담당하며, domain/code가 맞는지 검증한 뒤 매핑한다.
실험 · 도구
enum MyError: Error { case badInput }
do { throw MyError.badInput }
catch {
let ns = error as NSError
print(ns.domain) // 기본값: 모듈명.타입명 형태의 mangled 문자열 (환경에 따라 실제 출력 확인 권장)
print(ns.code) // enum case의 순번(0부터)
}
enum MyError2: Error, CustomNSError, LocalizedError {
case badInput
static var errorDomain: String { "com.example.MyError2" }
var errorCode: Int { 42 }
var errorUserInfo: [String: Any] { [:] }
var errorDescription: String? { "잘못된 입력입니다" }
}
do { throw MyError2.badInput }
catch {
let ns = error as NSError
print(ns.domain, ns.code) // com.example.MyError2 42
print(ns.localizedDescription) // 잘못된 입력입니다 (errorDescription이 반영됨)
}프로젝트 적용
① Objective-C 프레임워크와 경계를 넘나드는 에러 타입(예: 레거시 Cocoa API를 감싸는 코드)은 CustomNSError를 구현해 도메인·코드를 명시적으로 통제하라 — 자동 브리징의 mangled name 도메인은 릴리즈 빌드·최적화 옵션에 따라 형태가 흔들릴 수 있어 로그·크래시 리포트 비교에 불안정하다.
enum UploadError: Error, CustomNSError {
case fileTooLarge(bytes: Int)
case serverRejected(statusCode: Int)
static var errorDomain: String { "com.myapp.UploadError" } // 안정적인 문자열로 고정
var errorCode: Int {
switch self {
case .fileTooLarge: return 1
case .serverRejected: return 2
}
}
var errorUserInfo: [String: Any] {
switch self {
case .fileTooLarge(let bytes): return ["bytes": bytes]
case .serverRejected(let code): return ["statusCode": code]
}
}
}② 사용자에게 노출할 에러 메시지는 LocalizedError로 분리해 UI 레이어가 error.localizedDescription 하나만 신뢰하면 되게 만들어라 — error.legacyDescription처럼 별도 API를 UI 코드 곳곳에 흩뿌리지 않는다.
enum SignupError: Error, LocalizedError {
case weakPassword, emailTaken
var errorDescription: String? {
switch self {
case .weakPassword: return "비밀번호가 너무 약합니다."
case .emailTaken: return "이미 가입된 이메일입니다."
}
}
}
// UI 레이어는 항상 이 한 줄만 신뢰하면 된다
func show(_ error: Error, in alert: UIAlertController) {
alert.message = error.localizedDescription
}"Swift Error는 Objective-C NSError와 완전히 다른 별개 체계라 서로 안 섞인다"는 틀렸다 — Swift 2부터 자동 브릿징이 기본 내장돼 있고 as NSError로 언제든 변환 가능하다. "CustomNSError.errorDomain은 인스턴스 프로퍼티다"도 틀렸다 — SE-0112 초안 논의와 달리 현재 표준 라이브러리 선언은 static var다(오래된 예제가 인스턴스 프로퍼티로 보인다면 구버전 정보). "LocalizedError를 준수하면 자동으로 다국어 로컬라이징이 된다"도 틀렸다 — 프로퍼티 값 자체는 개발자가 원하는 언어로 직접 채워야 하며, "Localized"는 그저 사용자 대면 문자열을 위한 프로토콜이라는 뜻일 뿐이다.
Swift의 에러는 원래 한국어로 말하는 사람이라고 치면, NSError는 예전부터 쓰던 영어 서식 문서 같은 것이다. 아무 말도 안 해도 자동 번역기(컴파일러)가 대충 서식에 맞게 채워주긴 하는데(도메인은 이름표, 코드는 몇 번째로 등록됐는지), 정확한 서식을 원하면 직접 CustomNSError로 이름표를 써주고, 사람들에게 보여줄 안내문은 LocalizedError로 따로 써주는 것이다. 비유가 깨지는 곳: 자동 번역기가 채워주는 기본값은 사람이 알아보기엔 별로 안 예쁜(mangled name 같은) 문자열이라, 실제로는 "대충이라도 통한다"보다 "형식은 갖췄지만 읽기엔 불편하다"에 더 가깝다.
꼬리 질문
같은 enum 케이스라도 associated value가 다르면 NSError 코드(errorCode)가 달라지는가?
CustomNSError.errorCode를 직접 구현해 associated value를 반영한 코드를 계산해야 한다 — 자동 브리징만으로는 구분되지 않는다.errorUserInfo에 NSLocalizedDescriptionKey를 직접 넣으면 LocalizedError.errorDescription과 충돌하는가?
NSError.localizedDescription에 영향을 준다. 브리징 로직은 LocalizedError.errorDescription이 있으면 이를 NSLocalizedDescriptionKey로 채워 넣으므로, errorUserInfo에서 같은 키를 다시 수동으로 지정하면 어느 쪽이 최종적으로 남는지는 구현 세부사항에 의존하게 돼 혼란스럽다. 실무에서는 한쪽만 골라 일관되게 쓰는 게 안전하다 — 대개 LocalizedError 하나로 통일한다.NSError를 Swift Error로 되돌릴 때(_ObjectiveCBridgeableError) 무엇을 검증하는가?
init?(_bridgedNSError:)는 넘어온 NSError의 domain과 code가 해당 Swift 에러 타입이 기대하는 값과 일치하는지 확인한 뒤에만 성공적으로 매핑한다. domain/code가 맞지 않으면 이니셜라이저가 nil을 반환해 그 NSError는 해당 Swift 타입으로 변환되지 않는다 — 엉뚱한 도메인의 NSError가 잘못된 Swift 에러 케이스로 둔갑하는 것을 막는 안전장치다.Q6. defer의 실행 순서와, 실무에서 자주 놓치는 함정은?
defer는 같은 스코프 안에서 LIFO(나중에 쓴 것이 먼저 실행) 순서로 실행되며, 정상 return·break뿐 아니라 throw로 빠져나가는 경로에서도 반드시 실행된다. 다만 그 스코프에 진입조차 하지 않았다면 당연히 실행되지 않고, fatalError처럼 프로세스를 즉시 종료하는 트랩을 지나면 defer도 실행되지 않는다(트랩은 return이 아니라 프로세스 종료이기 때문). 실무 함정은 크게 두 가지다: defer 클로저가 값을 캡처한 뒤 그 값이 defer 실행 시점까지 바뀌는 경우, 그리고 루프 안에 defer를 넣어 "함수가 끝날 때 한 번"이 아니라 "매 반복이 끝날 때마다" 실행되는 경우다.
원리
swift-book ErrorHandling은 defer 순서를 명시한다: "첫 번째 defer 문의 코드가 마지막에 실행되고, 두 번째 defer 문의 코드가 마지막에서 두 번째로 실행되는 식"이다. 스코프를 벗어나는 모든 경로(정상 return, break, throw)에서 실행되며, defer 블록 안에는 스코프를 벗어나는 코드(return/break/throw)를 쓸 수 없다는 문법 제약이 있다.
내부 동작
defer는 함수 전체가 아니라 defer 문이 위치한 스코프에 묶인다 — 즉 if/while/for 블록 안에 있는 defer는 그 블록을 벗어날 때 실행되지, 바깥 함수가 끝날 때까지 미뤄지지 않는다. 컴파일러는 defer 블록들을 스코프 탈출 지점마다 역순으로 호출하는 코드를 생성한다고 이해하면 된다 — 스택에 defer 클로저를 push했다가 스코프를 나갈 때 pop하며 실행하는 것과 동일한 그림이다. 이 스택 구조 때문에 여러 defer가 있으면 항상 LIFO다.
실험 · 도구
func processFile(filename: String) throws {
print("시작")
defer { print("defer 1") }
defer { print("defer 2") }
defer { print("defer 3") }
throw NSError(domain: "test", code: 1)
}
try? processFile(filename: "x")
// 출력: 시작 → defer 3 → defer 2 → defer 1 (throw 경로에서도 전부 실행됨)func captureTrap() {
var count = 0
defer { print("종료 시점의 count: \(count)") } // 변수 자체를 캡처 — 실행 시점 값을 읽는다
count = 1
count = 2
// 출력: 종료 시점의 count: 2 (defer 등록 시점의 0이 아니다!)
}프로젝트 적용
① 리소스 정리(파일 핸들 닫기, 락 해제)는 defer로 "여는 곳 바로 다음 줄"에 짝지어 써서, 그 사이에 몇 개의 return/throw 경로가 생기든 놓치지 않게 하라.
import Foundation
func processFile(at url: URL) throws -> [String] {
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() } // 이 함수의 어떤 return/throw 경로로 나가든 반드시 닫힌다
guard let data = try handle.readToEnd() else { return [] }
guard let text = String(data: data, encoding: .utf8) else {
throw NSError(domain: "decode", code: 1) // 여기서 나가도 handle은 닫힌다
}
return text.split(separator: "\n").map(String.init)
}② 루프 안에서 매 반복마다 정리 작업이 필요한 게 아니라면, defer를 루프 바깥(함수 스코프)에 두거나 루프 밖으로 로직을 빼내라 — 루프 몸체 안의 defer는 "함수가 끝날 때 한 번"이 아니라 "매 반복이 끝날 때마다" 실행된다.
func processAll(_ urls: [URL]) {
for url in urls {
// ❌ "전부 끝나면 한 번 로그를 찍고 싶었다"는 의도라면 이 코드는 틀렸다.
// for 블록 스코프에 묶여 매 반복이 끝날 때마다 실행된다.
defer { print("\(url) 처리 완료") }
// ... 처리
}
// ✅ "전체가 끝나면 한 번만"이 의도라면 defer를 함수 스코프에 둔다
defer { print("전체 \(urls.count)건 처리 완료") }
}"defer는 등록된 순서대로 실행된다"는 반대다 — 나중에 쓴 defer가 먼저 실행되는 LIFO(스택) 순서다. "defer는 변수의 '등록 시점 값'을 스냅샷처럼 찍어둔다"도 대부분 틀렸다 — 클로저가 변수 자체를 캡처하므로, defer 블록이 실제로 실행되는 시점의 최신 값을 읽는다(값을 스냅샷하고 싶다면 별도 상수에 복사해둬야 한다). "defer는 함수가 끝날 때만 실행된다"도 틀렸다 — defer는 자신이 선언된 스코프(if/for/while 블록 포함)를 벗어날 때 실행되며, 그 스코프가 함수 전체와 같지 않을 수 있다.
defer는 "이 방을 나갈 때 꼭 이것부터 해"라고 문 앞에 붙여두는 포스트잇이다. 포스트잇을 여러 장 붙이면, 문에서 가장 가까운(제일 나중에 붙인) 포스트잇부터 떼어서 처리한다 — 그래서 나중 것이 먼저 실행된다. 그리고 그 방에 아예 안 들어갔다면 포스트잇도 안 붙인 것이니 당연히 아무 일도 안 일어난다. 비유가 깨지는 곳: 포스트잇에 "지금 count 값"이라고 써놨다고 생각하기 쉽지만, 실제로는 "나갈 때 count를 다시 읽어봐"라고 써있는 것에 가깝다 — 붙인 순간의 값이 아니라 뗄 때의 값을 본다.
꼬리 질문
defer 안에서 또 다른 defer를 등록하면 어떻게 되는가?
guard else { return } 안에 있는 defer와, guard 통과 후 함수 본문에 있는 defer는 실행 시점이 다른가?
guard else 블록 안의 defer는 그 else 블록(짧은 스코프) 전용이라 guard가 실패해 조기 return하는 그 순간에만 실행되고, guard를 통과한 이후 함수 본문에 있는 defer는 함수의 나머지 실행이 어떤 경로로 끝나든(정상 종료·다른 return·throw) 그때 가서 실행된다. 즉 같은 함수 안에서도 defer가 어느 스코프에 있느냐에 따라 "언제 실행될지"가 완전히 달라진다.defer 안에서 에러를 throw하면 어떻게 되는가?
throw를 쓰는 것 자체가 컴파일 에러다. 대신 defer 안에서 throws 함수를 호출하고 싶다면 try?로 에러를 흡수하거나(defer { try? handle.close() }), 별도로 로깅만 하는 식으로 처리해야 한다 — defer가 실행되는 시점은 이미 함수가 빠져나가는 절차 중이라 새로운 에러를 다시 던져 그 절차에 끼어들 수 없다.