Swift 에러 처리 | do-catch, throw, Result
이 글의 핵심
enum NetworkError: Error { case badURL case timeout case noConnection case serverError(Int) }.
들어가며
Swift 시리즈 일곱 번째 글이다. 이번엔 실패 가능한 연산이 시그니처·호출부·UI까지 어떻게 이어지는지를 한 덩어리로 묶었고, 실제로 자주 쓰는 패턴부터 보고, 그다음에 문법·개념을 정리하는 순서로 썼다. C++·Java·Objective-C의 예외랑 겉은 비슷해도, Swift는 throws가 붙은 곳에서만 전파가 열리고 호출 쪽은 try로 그 사실을 반드시 인지하게 만든다는 점이 다르다. Error부터 Result, async throws, LocalizedError, NSError 브리징, defer, rethrows, assert·precondition·fatalError까지 프로덕션에서 반복적으로 마주치는 것을 한 흐름에 넣었으니, 목차대로 쭉 읽기보다 “실전 패턴”이 맞는 부분만 골라 봐도 된다.
앱스토어 리젝 직전에 발견한 에러 처리 버그
앱스토어 리젝 직전에 발견한 에러 처리 버그가 있었다. TestFlight·내부 기기에선 잘 돌아갔는데, 심사기기(지역·언어·시간대 조합)에서만 첫 화면 진입 직후 크래시가 났다. 스택을 까보니, 서버 DTO에 없는 필드가 “옛 클라” 경로에선 nil이 아니라 빈 문자열로 내려와 있었고, 그걸 try!로 강제 언랩한 쪽이 터진 거다. DecodingError로 잡을 수 있는 경로를 String이 비어서 실패로 가는 케이스로 두고, 마지막엔 fatalError에 가깝게 묶어 둔 것이 민감한 환경에서만 드러난 셈이었다. 결국 throws·Result로 경로를 갈라 nil/빈 값을 의도한 실패로 돌리고, 앱스토어용 빌드에선 절대 try!로 네트워크/디코딩을 끊지 말자고 팀 룰을 다시 적었다. “리뷰어 환경에선 안 터지겠지”는 통하지 않는다. 여기서 말하려는 건 에러는 정상 흐름이라는 뻔한 말이 아니라, 시그니처에 실패를 안 써 두면 나중에 크래시로 돌아온다는 것이다. 아래 실전 파트는 그때 이후로 내가 코드 리뷰에서 계속 쓰는 패턴을 모아 둔 것이다.
실전 패턴
여기서부터가 실무에서 먼저 쓰는 쪽. 문법이 헷갈리면 뒤쪽 “기본기”로 넘어가도 된다. (이 부분은 그냥 어케 쓰냐가 먼저야.)
네트워킹 에러
HTTP 상태, TLS, DNS, 바디 디코딩을 한 enum으로 합성하거나 계층별로 쪼갠다. 재시도 가능 여부(타임아웃 vs 401)는 케이스·메서드로 표현한다.
enum APIError: Error {
case transport(Error)
case status(code: Int, body: String?)
case decoding(underlying: Error)
}
struct HTTP {
static func get(_ url: URL) async throws -> String {
let (data, response) = try await URLSession.shared.data(from: url)
if let http = response as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
let body = String(data: data, encoding: .utf8)
throw APIError.status(code: http.statusCode, body: body)
}
guard let text = String(data: data, encoding: .utf8) else {
throw APIError.decoding(underlying: CocoaError(.fileReadCorruptFile))
}
return text
}
}
파일 I/O
Data(contentsOf:)·String(contentsOf:)·FileHandle는 Foundation 에러를 던진다. 권한·없는 파일·디스크 풀을 구분하려면 Cocoa/URLError와 매핑을 준비한다. 뒤에 나오는 defer랑 합치면 핸들 누수를 줄이기 쉽다.
func readUTF8File(at url: URL) throws -> String {
try String(contentsOf: url, encoding: .utf8)
}
JSON 파싱
JSONDecoder는 DecodingError를 던진다. 필드 수준 메시지를 쓰려면 커스텀 keyDecodingStrategy·init(from:)에서 CustomStringConvertible에 실어 내보내거나, DecodingError를 잡아 도메인 enum으로 변환한다.
struct UserDTO: Decodable { let name: String; let age: Int }
func decodeUser(_ data: Data) throws -> UserDTO {
do {
return try JSONDecoder().decode(UserDTO.self, from: data)
} catch {
throw AppError.storage("decode: \(error.localizedDescription)")
}
}
Validation
필드별 실패를 모아서 반환하려면 struct+배열 또는 Result 합성을 쓴다(단일 throw는 첫 실패에서 멈춘다). 폼 UI라면 ValidationError 모음이 편하다.
enum ValidationError: LocalizedError {
case emptyName
case invalidAge
case invalidEmail
var errorDescription: String? {
switch self {
case .emptyName: return "이름은 필수입니다"
case .invalidAge: return "나이는 0~150 사이여야 합니다"
case .invalidEmail: return "유효하지 않은 이메일"
}
}
}
struct User {
let name: String, age: Int, email: String
static func validate(name: String, age: Int, email: String) throws -> User {
guard !name.isEmpty else { throw ValidationError.emptyName }
guard (0...150).contains(age) else { throw ValidationError.invalidAge }
guard email.contains("@") else { throw ValidationError.invalidEmail }
return User(name: name, age: age, email: email)
}
}
do {
_ = try User.validate(name: "", age: 25, email: "[email protected]")
} catch {
print("검증 실패: \(error.localizedDescription)")
}
Result로 네트워크 경계 정리
async와 함께 쓰면, 호출 측이 await 없이 체이닝하기 좋다.
import Foundation
enum NetworkFailure: Error { case invalidURL, status(Int), decoding }
struct HTTP {
static func getString(_ urlString: String) async -> Result<String, NetworkFailure> {
guard let url = URL(string: urlString) else { return .failure(.invalidURL) }
do {
let (data, resp) = try await URLSession.shared.data(from: url)
if let http = resp as? HTTPURLResponse, !(200...299).contains(http.statusCode) {
return .failure(.status(http.statusCode))
}
guard let text = String(data: data, encoding: .utf8) else { return .failure(.decoding) }
return .success(text)
} catch { return .failure(.decoding) }
}
}
async throws vs async -> Result는 팀 스타일과 테스트 용이성이 기준이 된다. 섞을 거면 get() / Result { try }로 경계를 한곳에 몰아넣는 편이 덜 꼬인다.
async/await와 에러(실전 쪽 훅)
async 함수는 throws와 같이 쓰면 비동기 전파가 자연스럽다. Task 실패는 Result 래핑이나 withCheckedThrowingContinuation로 브리징할 수 있다.
func loadUser() async throws -> String {
let s = await HTTP.getString("https://example.com/data.txt")
switch s {
case .success(let text): return text
case .failure(let f): throw f
}
}
try await는 “비동기 + 던질 수 있음”을 한 번에 쓰는 말이고, async let으로 병렬화할 땐 한쪽이 실패하면 전부 throw로 묶이는 규칙이 있으니, 부분 실패를 허용하려면 Result로 쪼개거나 TaskGroup에서 개별 catch를 설계하는 식이 필요하다.
에러 처리 개요 — Swift의 철학
왜 throws인가
Swift의 에러 처리는 값을 반환하는 제어 흐름에 가깝다. 런타임이 임의의 위치로 점프하는 “진짜 예외”가 아니라, 컴파일러가 검증하는 계약이다. 호출자는 다음 중 하나를 선택해야 한다: do–catch로 처리하거나, 자신의 함수에 throws를 붙여 상위로 넘기거나, try?·try!로 의미를 압축한다. 그래서 “이 함수는 실패할 수 있다”는 정보가 시그니처에 남는다는 것이 1차적인 철학이다.
실패는 타입으로 표현한다
Error를 준수하는 타입(대개 enum)으로 케이스를 나누면, switch와 패턴 매칭으로 누락 없이 대응할 수 있다. String이나 Int 코드만 흘려보내는 것보다 유지보수에 유리하다.
Objective-C·Cocoa와의 연속성
Foundation과 많은 Apple API는 NSError를 쓰며, Swift에서는 Error로 자동 브리징된다. “Swift 네이티브 enum”과 “레거시 도메인 코드”를 경계에서 매핑하는 사고를 갖출 필요가 있다.
Error 프로토콜 — 에러 타입 정의
Error는 마킹 프로토콜에 가깝다. 별도 요구 사항이 거의 없어서 열거형·구조체·클래스가 모두 채택할 수 있다. 실무에서는 연관 값이 있는 enum이 가장 많이 쓰인다.
enum NetworkError: Error {
case badURL
case timeout
case noConnection
case serverError(Int)
}
enum ValidationError: Error {
case emptyField(String)
case invalidFormat(String)
case outOfRange(String, min: Int, max: Int)
}
위 선언은 “어떤 종류의 실패가 있는지”를 타입 시스템에 고정한다. 나중에 catch에서 케이스별로 메시지·재시도 정책·로그 레벨을 다르게 줄 수 있다. 연관 값은 맥락(필드 이름, 범위, HTTP 코드 등)을 실어 나르기에 좋다.
throw — 에러 던지기
throw 뒤에는 Error 타입이 와야 한다. 표현식의 타입이 Never에 가깝다고 생각하면 된다(함수는 그 시점에서 정상 반환이 아니라 “에러로 탈출”).
func parsePort(_ s: String) throws -> Int {
guard let n = Int(s), (1...65535).contains(n) else {
throw ValidationError.invalidFormat("port=\(s)")
}
return n
}
guard와 함께 쓰면 조기 종료가 읽기 쉽다. throw 이후의 코드는 실행되지 않는다.
throws — 에러를 던질 수 있는 함수
함수·이니셜라이저·computed property getter(제한 있음)·클로저 타입에 throws를 붙일 수 있다. throws가 붙은 함수는 “성공 시 T, 실패 시 Error”의 의미로 읽힌다.
func fetchData(from url: String) throws -> String {
guard !url.isEmpty else { throw NetworkError.badURL }
if url.contains("timeout") { throw NetworkError.timeout }
return "데이터"
}
호출부는 try를 써야 하므로, API 문서를 읽지 않아도 시그니처만 보고 실패 가능성을 인지할 수 있다.
do–catch — 에러 잡기
do 블록 안에서 try로 호출하고, catch에서 실패를 처리한다. catch는 패턴(아래 절)에 따라 세분화할 수 있다.
do {
let data = try fetchData(from: "https://example.com")
print("성공: \(data)")
} catch NetworkError.badURL {
print("잘못된 URL")
} catch NetworkError.timeout {
print("타임아웃")
} catch {
print("기타 에러: \(error)")
}
같은 파일 안의 구체적 케이스(NetworkError.badURL 등)는 타입 전체 이름이 필요하다(추론이 되지 않을 때). 마지막 catch는 Error 전체를 받는 기본 케이스다.
try — 에러 발생 가능한 호출
try는 “이 호출이 에러를 낼 수 있으니, 그에 맞는 맥락(do–catch 또는 상위 throws 또는 try?·try!) 안에 있어야 한다”는 표시다.
func caller() throws {
let data = try fetchData(from: "https://example.com")
_ = data
}
try는 표현식 앞에 온다. 중첩 호출 시 각 실패가 어디서 왔는지 catch에서 나누기 어려울 수 있으니, 복잡한 경우에는 중간 값을 let으로 풀어 쓰는 편이 디버깅에 유리하다.
try? — Optional로 변환
try?는 실패 시 해당 표현식 전체를 nil로 만든다. 성공이면 옵셔널로 감싼 값이 된다.
let data = try? fetchData(from: "https://example.com")
if let data { print("데이터: \(data)") } else { print("실패") }
let withFallback = (try? fetchData(from: "bad")) ?? "기본값"
에러 정보를 버린다는 점이 트레이드오프다. 로그·알럿·재시도에 필요한 구체 케이스를 잃을 수 있으니, “실패해도 괜찮은 사소한 읽기”에 쓰는 것이 일반적이다.
try! — 강제 언랩(크래시 가능)
try!는 “절대 실패하지 않는다”는 논리적 보증이 있을 때만 써야 한다. 실제로 throw가 발생하면 fatalError와 유사하게 런타임에 종료에 가깝다.
// 앱 기동 시 반드시 존재하는 로컬 리소스만 예시로
let data = try! fetchData(from: "https://example.com")
테스트·개발 전용 코드가 아닌 사용자 환경 프로덕션에서 네트워크·디스크에 try!를 쓰는 것은 권장되지 않는다. 실패는 정상 흐름이 될 수 있기 때문이다. 위 “앱스토어” 이야기가 바로 이 규칙을 어겼을 때 벌어지는 쪽이다.
catch 패턴 — 여러 catch 블록
catch는 is, as?, where, 연관 값 패턴을 사용할 수 있다. 한 블록에서 enum 케이스를 세밀하게 나누거나, NSError의 domain·code에 의존하는 Cocoa 코드에 맞출 수 있다.
do {
_ = try parsePort("99999")
} catch let err as NetworkError {
print("NetworkError: \(err)")
} catch {
if let e = error as? LocalizedError, let m = e.errorDescription {
print(m)
} else {
print(error.localizedDescription)
}
}
라이브러리의 non-frozen public enum은 이후 케이스가 추가될 수 있으므로, switch 대신 catch에 @unknown default에 해당하는 방어(catch { ... } 마지막 구간)를 두는 습관이 좋다.
에러 전파(Propagating errors)
throws 함수 안에서는 try로 호출한 에러를 잡지 않고 그대로 밖으로 보낼 수 있다. 스택 풀기(wind)는 Swift 런타임이 맡는다.
func loadAndParse() throws -> [String: Any] {
let raw = try fetchData(from: "https://example.com")
// 가정: Foundation JSON
let data = Data(raw.utf8)
let obj = try JSONSerialization.jsonObject(with: data) as? [String: Any]
guard let obj else { throw NetworkError.serverError(500) }
return obj
}
중첩이 깊어지면 Result로 변환하거나, async계층으로 옮겨 async throws로 단순화하는 경우가 있다.
Result<Success, Failure> 타입
Result는 성공·실패를 값으로 담는 열거형이다. Failure: Error로 제한된다. throws와 달리 “함수가 아닌 곳(프로퍼티, 제네릭, 클로저 저장 필드)에 실패”를 담을 때 유리하다.
func fetchData(from url: String) -> Result<String, NetworkError> {
guard !url.isEmpty else { return .failure(.badURL) }
if url.contains("timeout") { return .failure(.timeout) }
return .success("데이터")
}
switch fetchData(from: "https://example.com") {
case .success(let data): print("성공: \(data)")
case .failure(let error): print("실패: \(error)")
}
map·mapError·flatMap으로 합성할 수 있어, 동기 체이닝 스타일에 잘 맞는다.
let result = fetchData(from: "https://example.com")
.map { $0.uppercased() }
.mapError { _ in NetworkError.noConnection }
Result ↔ throws 변환은 아래처럼 이어 붙이면 된다.
func getData() throws -> String {
let result = fetchData(from: "https://example.com")
return try result.get() // 실패 시 throw
}
func getDataResult() -> Result<String, Error> {
Result { try loadAndParse() } // Swift 5 이상
}
Result.get()이 내부에서 throws를 일으킨다는 점이 연결 고리다.
defer — 정리 코드
defer 블록은 함수가 return하든 throw하든 가장 나중에 역순으로 실행된다. 파일 핸들, 락, 임시 버퍼 정리에 쓰인다.
func writeTempFile(content: String) throws -> URL {
let url = FileManager.default.temporaryDirectory.appendingPathComponent("t.txt")
let fh = try FileHandle(forWritingTo: url)
defer { try? fh.close() }
if let d = content.data(using: .utf8) {
try fh.write(contentsOf: d)
}
return url
}
defer 안에서 throw는 가능하지만, 제어 흐름이 복잡해질 수 있어 가급적 정리용으로만 쓰는 것이 안전하다.
rethrows — 고차 함수
rethrows는 “인자로 받은 클로저가 throw할 때만, 나도 throw한다”는 뜻이다. map·forEach류의 표준 라이브러리 패턴과 같다.
func mapEach<T, U>(
_ items: [T],
_ body: (T) throws -> U
) rethrows -> [U] {
var out: [U] = []
for x in items { out.append(try body(x)) }
return out
}
rethrows 함수는, 실제로 인자 클로저가 throw하지 않는 한 호출부에 try를 요구하지 않는다(컴파일러가 조건부로 처리). 직접 throw를 섞는 API를 설계할 때 유용하다.
커스텀 에러 — enum으로 Error 구현
엔트로피를 줄이기 위해 도메인 단위로 enum을 쪼개는 것이 일반적이다(네트워크, 검증, 저장소 등). LocalizedError는 사용자 메시지에만 쓰고, 내부 식별은 enum 케이스로 남긴다.
enum AppError: Error {
case network(NetworkError)
case validation(ValidationError)
case storage(String) // 키체인, 파일 등
}
이렇게 중첩하면 상위 catch에서 큰 갈래를 나누고, 내부에서 세부 케이스로 들어갈 수 있다.
LocalizedError — 사용자 친화적 메시지
LocalizedError는 errorDescription 등을 제공해 UI에 표시할 문자열을 일관되게 쓰게 돕는다.
enum FileError: LocalizedError {
case notFound
case permissionDenied
var errorDescription: String? {
switch self {
case .notFound: return "파일을 찾을 수 없습니다"
case .permissionDenied: return "권한이 없습니다"
}
}
}
LocalizedError를 구현하지 않으면 String(describing: error) 류에 내부 식별자가 섞일 수 있으니, 앱이 노출하는 모든 사용자 대면 경로는 구현해 두는 것이 좋다. failureReason·recoverySuggestion·helpAnchor는 지원팀·접근성·앱 스토어 심사 응답에도 쓰인다.
NSError 브리지 — Cocoa·Foundation 에러
Objective-C API는 NSError ** 로 에러를 돌려주며, Swift에서는 throws로 매핑되거나 Error로 온다. NSError로 캐스팅해 domain·code·userInfo를 읽을 수 있다.
import Foundation
func nsExample() {
do {
try "a".write(toFile: "/invalid\0path", atomically: true, encoding: .utf8)
} catch {
let e = error as NSError
print("domain: \(e.domain) code: \(e.code)")
if let s = e.userInfo[NSLocalizedDescriptionKey] {
print("info: \(s)")
}
}
}
도메인 상수(NSURLErrorDomain 등)와 코드는 애플 문서에서 대조해 원인을 좁힌다. Swift 전용 enum으로 변환(map 함수 하나)해두면 UI·로그 계층이 단순해진다.
assert와 precondition — 디버깅 vs 프로덕션
assert(_:_:file:line:)는 릴리스 빌드에서 no-op이 될 수 있어(최적화), 개발 중 불변식 검사에 쓴다. precondition(_:_:file:line:)는 유지하는 빌드에서 위반 시 치명적 실패에 가깝다(용도·빌드 설정에 따라 다름). 일반 API 계약·사용자 입력은 throws나 Result로 반환하는 편이 낫다. “이건 정말 버그일 때만” assert/precondition을 쓰는 게 맞다.
func indexForSorted(_ a: [Int], value: Int) -> Int {
precondition(a == a.sorted(), "정렬되지 않은 배열")
// ...
return 0
}
fatalError — 복구 불가
fatalError는 프로세스를 중단시킨다. 복구가 불가능하거나, 계속되면 데이터가 손상되는 경로에만 쓴다(예: 필수 구성 누락, 개발용 스텁). 라이브러리 공개 API에서 남용하면 앱이 죽는다. 가능하면 throws·Result·로그 후 우아한 퇴장으로 바꾼다.
func requiredKey() -> String {
guard let v = ProcessInfo.processInfo.environment["REQUIRED"] else {
fatalError("REQUIRED 누락: 개발/배포 구성을 확인하세요")
}
return v
}
async/await와 에러 — 기본(참고)
async 함수는 throws와 함께 쓰일 수 있으며, 비동기 전파가 자연스럽다. 위 “실전 패턴” 쪽에 이미 try await·Result로 네트워크 끊는 예는 넣어 두었고, 여기서는 문법 수준만 다시 짚는다. Task의 실패는 Result 래핑이나 withCheckedThrowingContinuation로 직접 옮길 수 있으며, 동시에 여러 async를 async let으로 병렬화할 때는 한쪽의 에러가 전체 throw로 전파될 수 있으니, 부분 실패를 허용하려면 Result로 쪼개거나 TaskGroup에서 개별 catch를 설계하는 편이 안전하다.
후회하지 않는 에러 처리
여기는 팀 룰 말고 나 혼자 정해 둔 가이드다. 딱딱하게 읽힐 수 있는데, 실제로는 PR에서 이렇게 말하곤 해.
실패할 수 있으면 시그니처에 throws나 Result로 박아 둔다. Bool만 던지고 “실패 이유는 주석/슬랙” 같은 건 나중에 나를 괴롭힌다. try?는 “실패 = 그냥 없음”으로도 되는 옵셔널 경로에만 쓰고, 로그·알럿·재시도가 필요한 데엔 쓰지 말자. try!는 번들 안 리소스처럼 정말 보증할 수 있을 때만 쓰고, 네트워크·유저 입력·서버 DTO엔 쓰지 말기 — 쓰면 크래시는 내가 책임진다.
LocalizedError는 사용자가 볼 수 있는 말로만 채우고, 스택·내부 id는 로그·크래시 리포터로 보낸다. Cocoa 쪽 NSError는 domain+code+userInfo를 한 세트로 읽는다. 도메인만 외우면 헷갈린다. assert·precondition은 “이게 깨지면 곧장 버그”일 때, 일반 API 실패엔 throws 써. defer는 락·핸들 풀기용으로만 쓰고, 거기서 또 던지면 제어 흐름이 미쳐. rethrows는 고차에서만, 함수마다 throws 붙이지 말기. I/O·외부는 async 쪽이 맞고, CPU만 맴도는 건 그에 맞게 나눔.
말투 풀자면, 에러는 숨기지 말고 타입이나 시그니처에 남기고, 크래시로 도망가지 말자 — 이게 전부다. (반대로 enum 예쁘게 100개 만든 뒤 아무도 catch 안 쓰면? 그거 그냥 쓰레기 맞지. 팀이 읽을 만큼만 정리하자고.)
일반적 문제 — 이렇게 풀었다
표는 없다. 자주 겪는 순으로 달아 둠.
try를 안 붙인다는 컴파일 에러는, throws 함수를 do–catch·상위 throws·try?·try! 없이 쓴 경우다. error 케이스를 못 잡는 느낌이면, Error는 열거형이 아니라서 as?로 풀거나 우리 쪽 enum으로 감싼 뒤 catch에 맞춘다. catch에 케이스를 다 못 쓰겠다 싶으면 enum을 중첩해서 위에서 큰 갈래만 먼저 잡는다. Result끼리 map이 안 붙는 건 Failure 타입이 달라서이니 mapError로 통일하거나 Error로 승격한다. async let로 병렬 뛰다 한쪽만 터지면 전부 throw로 묶이니까, 부분 실패를 모을 땐 작업마다 Result를 쌓는 식이 낫다. 로그에 케이스 이름만 있고 사용자 메시지가 없다면 LocalizedError·도메인 매핑이 빠진 것이다. NSError는 코드만 보면 애매할 수 있으니 domain까지 같이 본다. defer가 이상하면 중첩을 줄이고, 획득–해제 쌍이 한 함수 안에만 있게 맞춘다. try! 크래시는 환경(경로, 서버)이 변해서 그렇다 — throws로 돌리고, 사용자에겐 말로 돌려준다.
권하는 디버깅 순서는 이렇다. (1) 최소로 다시 띄우고, (2) catch에서 String(reflecting: error) / NSError로 성격을 잡고, (3) 네트워크·파일의 재현 조건을 고정한 다음, (4) LocalizedError·로그·메트릭이 같이 말하는지 본다. 이 순서 꼬이면? 그냥 한 잔 하고 (5) 누가 try!를 넣었는지 git blame.
내부 동작을 한눈에(개념도)
flowchart TD A[입력·이벤트] --> B[검증·디코딩] B --> C[핵심 연산] C --> D[부작용: 네트워크·파일] D --> E[성공 반환] B -. 실패 .-> F[Error] C -. 실패 .-> F D -. 실패 .-> F F --> G[ catch / mapError / async throw ]
핵심 정리
Error: 실패 종류를 타입으로 고정한다.throw/throws: 제어흐름이 아니라 서명이 있는 실패 전파.try/try?/try!: 전파·압축·치명 — 의도를 드러낸다.do–catch: 케이스·연관 값·Cocoa에 맞는 처리.Result: 실패를 값으로 합성할 때.defer/rethrows/async throws: 자원·고차·동시성과 어울리는 완성도.