Swift 오류 처리와 제네릭: throws, 타입 지정 throws, Result, where
Swift 6.4.2로 실행한 중급 강의. do/try/catch, try!의 실제 크래시와 사라지는 출력, 타입 지정 throws, Result, 제네릭 제약 위반의 실제 오류, 제네릭 대 any -O 실측과 판단표.
시리즈: Swift 강의 4/6 (중급). struct, enum, protocol로 타입을 설계할 수 있게 되었고(3강), 이제 실패할 수 있는 코드나 여러 타입에 두루 쓰이는 코드를 짜야 하는 분을 위한 글입니다. 다 읽으면 throws, 타입 지정 throws, Result, Optional, try!를 상황에 맞게 고를 수 있고, "requires that X conform to Y" 같은 컴파일러 오류를 짐작하지 않고 읽을 수 있습니다.
Kotlin에는 checked exception이 없고 Java에는 있습니다. 그래서 둘 다 써 본 개발자는 Swift의 throws를 보고 "이건 어느 쪽이지?"라고 헷갈립니다. 답은 둘 다 아닙니다. Swift는 호출마다 try를 적게 하지만, 기본적으로는 어떤 오류가 나올지 타입으로 알려 주지 않습니다. 그 빈틈을 메우려고 Result와 타입 지정 throws(E)가 생겼고, 결국 쓰는 방법이 다섯 가지가 됐습니다. 여기에 제네릭 오류 메시지가 엉뚱한 줄을 가리키는 것처럼 보이는 문제까지 겹칩니다. 이 글은 API 클라이언트나 파서를 맡았다가 둘 중 하나에서 막힌 분을 위한 것입니다. 모든 코드는 Swift 6.4.2(swiftc -swift-version 6, Swift 6 언어 모드 + 엄격한 동시성 검사)로 Apple Silicon Mac (M1 Pro)에서 컴파일·실행했고, 출력과 크래시 메시지, 컴파일 오류는 그 결과를 그대로 옮겼습니다.
3강에서 말했듯 이 툴체인에서 swift file.swift만 쓰면 Swift 5 언어 모드로 돕니다. -swift-version 6을 붙이세요.
throws, do/try/catch, try?, try!
throws가 붙은 함수는 값을 반환하는 대신 실행을 멈추고 호출자에게 오류를 넘길 수 있습니다. 호출자는 반드시 try를 써야 하는데, 여기서 제어가 빠져나갈 수 있다는 눈에 보이는 표시입니다. 오류는 Error 프로토콜을 준수하는 아무 타입이나 되고, 보통 enum으로 만듭니다.
enum ParseError: Error, CustomStringConvertible {
case empty
case notANumber(String)
case outOfRange(Int)
var description: String {
switch self {
case .empty: "empty input"
case .notANumber(let s): "not a number: \"\(s)\""
case .outOfRange(let v): "out of range: \(v)"
}
}
}
func parseAge(_ s: String) throws -> Int {
guard !s.isEmpty else { throw ParseError.empty }
guard let v = Int(s) else { throw ParseError.notANumber(s) }
guard (0...150).contains(v) else { throw ParseError.outOfRange(v) }
return v
}
for input in ["42", "", "abc", "200"] {
do {
let age = try parseAge(input)
print("ok:", age)
} catch ParseError.outOfRange(let v) where v > 150 {
print("too old:", v)
} catch {
print("failed:", error, "| type:", type(of: error))
}
}
print("try? :", (try? parseAge("abc")) as Any, (try? parseAge("7")) as Any)
let forced = try! parseAge("x1")
print("unreachable", forced)
ok: 42
failed: empty input | type: ParseError
failed: not a number: "abc" | type: ParseError
too old: 200
try? : nil Optional(7)
errors/errors.swift:30: Fatal error: 'try!' expression unexpectedly raised an error: not a number: "x1"
프로세스 종료 코드는 133, 즉 128 + 5(SIGTRAP)였습니다. 용어를 정리하면 이렇습니다.
catch절은 위에서부터 차례로 검사되고,switch처럼 패턴과where를 쓸 수 있습니다. 마지막의 맨catch에서는 오류가error라는 이름의 상수로 들어옵니다.try?는 "throw하거나Int를 반환"을Int?로 바꿉니다. 오류 자체는 버려지므로 왜 실패했는지는 알 수 없게 됩니다.try!는 "절대 실패하지 않는다, 실패하면 죽어도 된다"는 선언입니다. 운영 환경에서는 잡을 수 있는 예외가 아니라 크래시 리포트가 됩니다.
흔한 실수 → 실제로 일어나는 일 → 해결. 이 파일을 처음 실행할 때는 표준 출력을 파일로 리다이렉트했습니다. 출력 파일은 0줄이었고, 표준 오류에 fatal error 한 줄만 남았습니다. 파일로 가는 표준 출력은 버퍼링되는데, 트랩으로 죽으면 버퍼가 비워지지 않아 크래시 전의 print가 전부 사라진 것입니다. CI 로그나 서버 프로세스에서는 "아무것도 안 하고 죽었다"처럼 보입니다. 디버깅할 때는 import Foundation 후 위험한 줄 앞에서 fflush(stdout)을 호출하면 됩니다(위 출력도 그렇게 얻었습니다). 근본적인 해결은 내가 통제하지 못하는 입력에 try!를 쓰지 않는 것입니다. try!는 정규식 리터럴이나 앱에 포함된 리소스처럼 틀리면 프로그래머 실수인 경우에만 쓰세요.
타입 지정 throws(Swift 6): 컴파일 확인
그냥 throws는 "any Error를 던진다"는 뜻이라, 호출자의 catch에는 존재 타입이 오고 마지막 받이 분기가 필요합니다. 타입 지정 throws(typed throws, SE-0413, Swift 6.0 컴파일러)를 쓰면 함수가 던지는 오류 타입을 하나로 정할 수 있습니다.
enum ParseError: Error { case empty, notANumber(String) }
struct NetworkError: Error {}
func parse(_ s: String) throws(ParseError) -> Int {
guard !s.isEmpty else { throw .empty } // type known: .empty works
guard let v = Int(s) else { throw .notANumber(s) }
return v
}
do {
_ = try parse("x")
} catch {
// error is ParseError here, not any Error: the switch needs no default
switch error {
case .empty: print("empty")
case .notANumber(let s): print("typed catch:", s)
}
}
let r = Result { try parse("12") }
print(r, type(of: r))
func never() throws(Never) -> Int { 1 } // same as non-throwing
print(never())
typed catch: x
success(12) Result<Int, Error>
1
-swift-version 6에서도 -swift-version 5에서도 진단 메시지 없이 컴파일됐습니다. 타입 지정 throws는 Swift 6 언어 모드 기능이 아니라 컴파일러 기능입니다. 다른 타입을 던지면 컴파일 오류가 납니다.
typed_err.swift:4:27: error: thrown expression type 'NetworkError' cannot be converted to error type 'ParseError'
출력 두 번째 줄을 보세요. Result { try parse("12") }는 Result<Int, ParseError>가 아니라 Result<Int, Error>가 됐습니다. 클로저가 던지는 타입은 여전히 any Error로 추론됩니다. Result<Int, ParseError> { try parse("x") }라고 쓰자 swiftc는 error: invalid conversion of thrown error type 'any Error' to 'ParseError'를 냈습니다. 다음 절처럼 클로저 자체에 타입을 적어야 합니다.
타입 지정 throws가 맞는 곳은 모듈 내부 코드, 또는 파서나 검증기처럼 호출자가 정말로 모든 케이스를 switch하는 작고 닫힌 오류 집합입니다. 나중에 실패 이유가 늘어날 수 있는 공개 API라면 여전히 그냥 throws가 유연합니다. 타입 지정 오류에 나중에 케이스를 추가하면, 완전하게 처리하던 호출자가 모두 깨집니다.
Result: 저장할 수 있는 오류
Result<Success, Failure>는 .success(값)과 .failure(오류)를 가진 enum입니다. 결과를 저장하거나, 넘기거나, 나중에 전달해야 할 때 씁니다. 완료 콜백, 항목별 결과를 모은 배열, throw할 수 없는 경계 같은 경우입니다.
enum ParseError: Error { case empty, notANumber(String) }
func parse(_ s: String) throws(ParseError) -> Int {
guard !s.isEmpty else { throw .empty }
guard let v = Int(s) else { throw .notANumber(s) }
return v
}
func attempt(_ s: String) -> Result<Int, ParseError> {
Result { () throws(ParseError) -> Int in try parse(s) } // closure must spell out its error type
}
let loose = Result { try parse("12") }
print(type(of: loose), type(of: attempt("x")))
switch attempt("x").map({ $0 * 2 }) {
case .success(let v): print("value", v)
case .failure(.notANumber(let s)): print("bad input", s)
case .failure(.empty): print("empty")
}
print(["1", "x", "3"].map(attempt).compactMap { try? $0.get() })
func load(_ s: String, completion: (Result<Int, ParseError>) -> Void) { completion(attempt(s)) }
load("") { print("callback:", $0) }
Result<Int, Error> Result<Int, ParseError>
bad input x
[1, 3]
callback: failure(result.ParseError.empty)
map은 성공 값만 변환하고, get()은 Result를 다시 throw하는 호출로 바꿉니다. 실패 타입이 구체 타입이라서 .failure(.notANumber(let s))로 하는 switch가 완전해집니다. async/await(5강)를 쓰면 완료 콜백에 쓰던 Result는 대부분 사라지지만, 결과를 모으는 용도로는 지금도 유용합니다.
제네릭: 제약, 연관 타입, where 절
제네릭 함수나 타입은 타입 매개변수(T)를 가져서, 구현 하나로 여러 타입에 쓸 수 있습니다. 제약(T: Comparable)은 T를 본문이 필요로 하는 연산을 지원하는 타입으로 좁힙니다. 프로토콜은 연관 타입(associated type)을 선언할 수 있는데, 준수하는 타입마다 채워 넣는 타입 자리입니다. where 절은 그 자리에 제약을 더합니다.
// 1. constraint: T must be Comparable
func largest<T: Comparable>(_ xs: [T]) -> T? {
guard var best = xs.first else { return nil }
for x in xs.dropFirst() where x > best { best = x }
return best
}
print(largest([3, 9, 2]) as Any, largest(["pear", "apple"]) as Any, largest([Double]()) as Any)
// 2. protocol with an associated type
protocol Store {
associatedtype Item
mutating func add(_ item: Item)
var all: [Item] { get }
}
struct MemoryStore<Element>: Store { // Item is inferred as Element
private var items: [Element] = []
mutating func add(_ item: Element) { items.append(item) }
var all: [Element] { items }
}
// 3. where clause on the associated type
func summary<S: Store>(_ s: S) -> String where S.Item: Numeric & CustomStringConvertible {
let total = s.all.reduce(0, +)
return "\(s.all.count) items, total \(total)"
}
var ints = MemoryStore<Int>(); ints.add(5); ints.add(7)
print(summary(ints))
// 4. conditional extension: only when Element is Equatable
extension MemoryStore where Element: Equatable {
func contains(_ x: Element) -> Bool { all.contains(x) }
}
print(ints.contains(7))
// 5. primary associated type lets you write `some Collection<Int>`
func sum(_ xs: some Collection<Int>) -> Int { xs.reduce(0, +) }
print(sum([1, 2, 3]), sum(Set([10, 20])))
Optional(9) Optional("pear") nil
2 items, total 12
true
6 30
놓치기 쉬운 것은 4번입니다. User가 Equatable이 아니면 MemoryStore<User>에는 contains가 아예 존재하지 않습니다. 조건부 확장은 그 메서드를 지원할 수 있는 특수화에만 메서드를 붙입니다.
제약을 못 맞췄을 때의 실제 오류
위의 제약을 한 파일에서 일부러 모두 어겨 봤습니다. 각 오류의 첫 줄은 다음과 같습니다.
func largest<T: Comparable>(_ xs: [T]) -> T? { xs.max() }
struct User { let name: String }
_ = largest([User(name: "a")])
// … Store / MemoryStore as above, then:
_ = summary(MemoryStore<String>())
func addAll<T>(_ xs: [T]) -> T { xs.reduce(0, +) }
_ = MemoryStore<User>().has(User(name: "b")) // has() is in `extension … where E: Equatable`
let s: Store = MemoryStore<Int>()
error: global function 'largest' requires that 'User' conform to 'Comparable'
| `- note: where 'T' = 'User'
error: global function 'summary' requires that 'String' conform to 'Numeric'
| `- note: where 'S.Item' = 'String'
error: referencing instance method 'has' on 'MemoryStore' requires that 'User' conform to 'Equatable'
| `- note: where 'E' = 'User'
error: cannot convert value of type '(Int) -> Int' to expected argument type '(Int, T) throws -> Int'
error: cannot convert return expression of type 'Int' to return type 'T'
warning: use of protocol 'Store' as a type must be written 'any Store'; this will be an error
in a future Swift language mode [#ExistentialAny]
읽는 법은 이렇습니다.
- "requires that 'X' conform to 'Y' … note: where 'T' = 'X'"는 호출하는 줄에 붙고, note가 어떤 타입 매개변수에 무엇이 들어갔는지 알려 줍니다. 해결은 타입을 준수시키거나(
struct User: Comparable, 연습 1 참고) 다른 함수를 부르는 것입니다. - 헷갈리는 것은
addAll입니다. 제약 없는T에서는T에0이나+가 있는지 컴파일러가 모르기 때문에,reduce깊은 곳의 타입 불일치로 보고합니다. "제약을 추가하라"고는 말해 주지 않습니다. "cannot convert … to 'T'"가 보이면 본문이 어떤 연산을 쓰는지 따져 보고 그 프로토콜(T: Numeric)을 추가하세요. - 마지막 줄은
ExistentialAny플래그가 없어도 나오는 경고입니다. 3강에서는let s: Shape가 조용히 통과했지만,Store는 연관 타입이 있어서 Swift 6.4.2가 이미any Store를 요구합니다.
실측: 제네릭 대 구체 타입 대 any
제네릭 함수에는 비용이 있을까요? Square struct 100만 개(시드 42 고정 LCG)의 area()를 세 가지 함수로 합산했습니다. 함수가 따로 남도록 @inline(never)를 붙였고, swiftc -O -swift-version 6으로 빌드했습니다. 한 번 실행할 때 10회 잰 최솟값을 3번 실행해 얻었고, 환경은 Apple Silicon Mac (M1 Pro)입니다.
@inline(never) func totalConcrete(_ xs: [Square]) -> Double { var t = 0.0; for x in xs { t += x.area() }; return t }
@inline(never) func totalGeneric<T: Shape>(_ xs: [T]) -> Double { var t = 0.0; for x in xs { t += x.area() }; return t }
@inline(never) func totalAny(_ xs: [any Shape]) -> Double { var t = 0.0; for x in xs { t += x.area() }; return t }
| 요소 100만 개 | -O(3회) |
-Onone |
요소 크기 |
|---|---|---|---|
구체 타입 [Square] |
0.93–0.94 ms | 72.40 ms | 8 B |
제네릭 <T: Shape> |
0.93 ms | 99.66 ms | 8 B |
존재 타입 [any Shape] |
1.87–1.88 ms | 87.48 ms | 40 B |
-O에서 제네릭 버전은 직접 쓴 구체 타입 버전과 똑같이 빨랐습니다. 이유는 어셈블리(swiftc -O -emit-assembly)에서 확인했습니다. 제네릭 본체와 별도로, swift demangle로 풀면 generic specialization <bg.Square> of bg.totalGeneric<A where A: bg.Shape>([A]) -> Swift.Double이 되는 심벌이 있었습니다. 옵티마이저가 Square 전용 복사본을 만든 것입니다. any 버전은 두 배 걸렸습니다. 요소마다 40바이트 상자가 있고, area()를 부를 때마다 witness table을 거치기 때문입니다.
Debug 빌드에서는 순위가 뒤집혀 제네릭이 가장 느렸습니다(99.66 ms). 특수화가 없으면 요소마다 타입 메타데이터를 넘기고 witness table로 호출하기 때문입니다. Debug로 벤치마크하면 안 되는 이유가 하나 더 생겼습니다. 특수화에도 한계가 있습니다. 컴파일러가 제네릭 본문을 볼 수 있어야 하므로, @inlinable이 없는 다른 모듈의 제네릭 함수는 특수화되지 않은 경로로 실행됩니다.
판단표와 판단 도식
| 상황 | 선택 | 실행에서 확인한 주의점 |
|---|---|---|
| 일어나면 버그(깨진 리터럴, 포함 파일 누락) | try! / precondition |
크래시, 종료 코드 133. 버퍼에 있던 표준 출력이 사라질 수 있음 |
| 호출자는 성공·실패만 알면 됨 | try? → Optional |
실패 이유는 버려짐 |
| 이유에 따라 대응, 오류가 늘 수 있음(공개 API) | throws |
catch에 마지막 받이 분기 필요 |
작고 닫힌 집합, 모든 케이스를 switch |
throws(E) |
default 없이 완전 처리. 클로저에는 throws(E) 명시 필요 |
| 결과를 저장·수집·나중에 전달 | Result<T, E> |
map, get(). E를 유지하려면 클로저에 타입 표기 |
| 알고리즘 하나를 여러 요소 타입에 | 제네릭 <T: P> |
-O에서 특수화되어 구체 타입과 같은 속도 |
| 한 컬렉션에 정말로 다른 타입이 섞임 | [any P] |
이번 루프에서 2배 느림, 요소당 40 B |
연습 문제
연습 1. largest는 그대로 두고, largest([User(name: "a", score: 3), User(name: "b", score: 8)])가 컴파일되어 점수가 높은 사용자의 이름을 출력하도록 만드세요.
연습 2. 위의 타입 지정 parse를 이용해 func parseAll(_ xs: [String]) -> Result<[Int], ParseError>를 작성하세요. 첫 번째 잘못된 문자열에서 멈춰야 합니다.
정답
User에<연산자를 정의해Comparable을 준수시킵니다. 저장 프로퍼티가 모두Equatable이므로==는 자동으로 합성됩니다.
struct User: Comparable {
let name: String
let score: Int
static func < (l: User, r: User) -> Bool { l.score < r.score }
}
func largest<T: Comparable>(_ xs: [T]) -> T? { xs.max() }
print(largest([User(name: "a", score: 3), User(name: "b", score: 8)])!.name) // b
- 타입 지정
do블록과 타입 지정 클로저를 써서 실패 타입을ParseError로 유지합니다.
func parseAll(_ xs: [String]) -> Result<[Int], ParseError> {
do throws(ParseError) {
return .success(try xs.map { s throws(ParseError) in try parse(s) })
} catch {
return .failure(error)
}
}
print(parseAll(["1", "2", "3"])) // success([1, 2, 3])
print(parseAll(["1", "oops", ""])) // failure(ex4.ParseError.notANumber("oops"))
map은 "oops"에서 멈췄고 빈 문자열까지 가지 않았습니다. 이 툴체인에서는 Array.map이 클로저의 타입 지정 오류를 그대로 다시 던지기 때문에 do throws(ParseError) 블록이 컴파일됩니다.
솔직한 한계와 버전 노트
- 실행한 것은 Swift 6.4.2(swiftlang-6.4.2.1.9)뿐입니다. 타입 지정 throws는 Swift 6.0(SE-0413)에 들어왔다고 되어 있지만 6.0을 설치해 확인하지는 않았습니다. 6.4.2에서는
-swift-version 5와-swift-version 6모두에서 동작했습니다. 기본 연관 타입(some Collection<Int>)은 Swift 5.7부터이지만 여기서만 확인했습니다. Result { try … }의 클로저가any Error로 추론되는 것은 6.4.2에서 본 동작입니다. 이후 컴파일러에서는 타입 지정 오류가 추론될 수도 있습니다.- 크래시 메시지의
errors/errors.swift:30은 모듈/파일 이름이고, 실린 코드의try!줄을 가리킵니다. 표준 출력 줄은 그 앞에import Foundation과fflush(stdout)을 넣은 두 번째 빌드에서, 표준 오류 줄은 첫 번째 빌드에서 얻었습니다. @inlinable이 없는 다른 모듈의 제네릭이 특수화되지 않는다는 것은 문서상의 동작입니다. 모듈 두 개짜리 패키지를 만들어 측정하지는 않았습니다.- 벤치마크는 단일 파일에서
ContinuousClock으로 10회 잰 최솟값이며, 옵티마이저가 모든 코드를 볼 수 있는 조건입니다.
이전: Swift 강의 3/6: struct, class, protocol. 다음: Swift 강의 5/6: 동시성, async/await, actor. 관련: Dart 강의 4: 레코드, 패턴, sealed, Java 강의: 스트림, 람다, Optional, C#의 제네릭과 예외 처리.
자주 묻는 질문
Swift의 타입 지정 throws(typed throws)를 쓰려면 Swift 6이 필요한가요?
throws(ParseError)처럼 쓰는 타입 지정 throws는 Swift 6.0 컴파일러에 들어왔습니다(SE-0413). 저희는 Swift 6.4.2에서만 확인했고, -swift-version 6과 -swift-version 5 모두에서 컴파일됐습니다. catch 안에서 error가 구체 타입이 되므로 switch에 default가 필요 없습니다. 다만 Result { try parse(s) }는 Result<Int, Error>로 추론됐기 때문에, 클로저를 () throws(ParseError) -> Int로 직접 적어야 했습니다.
Swift 제네릭 함수는 구체 타입 함수보다 느린가요?
컴파일러가 호출을 볼 수 있는 최적화 빌드에서는 느리지 않습니다. swiftc -O로 struct 100만 개를 합산하니 구체 타입 함수는 0.93ms, T: Shape 제네릭 함수도 0.93ms였습니다. 컴파일러가 Square용 특수화 버전을 만들었기 때문입니다. 같은 루프를 [any Shape]로 돌리면 1.87ms로 약 2배 걸렸습니다.
try!가 실패하면 어떻게 되나요?
Fatal error: 'try!' expression unexpectedly raised an error와 오류 설명이 출력되며 프로그램이 멈추고, macOS에서는 종료 코드 133(SIGTRAP)이 됩니다. 실행해 보니 표준 출력을 파일로 리다이렉트한 경우 크래시 전에 print한 내용이 버퍼에 남은 채 모두 사라졌습니다.