Swift 에러 처리 | do-catch, throw, Result

Swift 에러 처리 | do-catch, throw, Result

이 글의 핵심

Swift 에러 처리에 대해 정리한 개발 블로그 글입니다. enum NetworkError: Error { case badURL case timeout case noConnection case serverError(Int) }

들어가며

throws·try·docatch실패 가능한 연산을 시그니처에 드러냅니다. Result 타입으로 값과 오류를 한꺼번에 다루는 패턴도 많습니다.


1. 에러 정의

Error 프로토콜

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)
}

LocalizedError

enum FileError: LocalizedError {
    case notFound
    case permissionDenied
    
    var errorDescription: String? {
        switch self {
        case .notFound:
            return "파일을 찾을 수 없습니다"
        case .permissionDenied:
            return "권한이 없습니다"
        }
    }
}

2. throw와 try

기본 에러 처리

func fetchData(from url: String) throws -> String {
    guard !url.isEmpty else {
        throw NetworkError.badURL
    }
    
    // 네트워크 요청 시뮬레이션
    if url.contains("timeout") {
        throw NetworkError.timeout
    }
    
    return "데이터"
}

// do-catch로 처리
do {
    let data = try fetchData(from: "https://example.com")
    print("성공: \(data)")
} catch NetworkError.badURL {
    print("잘못된 URL")
} catch NetworkError.timeout {
    print("타임아웃")
} catch {
    print("기타 에러: \(error)")
}

try? - Optional 변환

// 에러 무시, nil 반환
let data = try? fetchData(from: "https://example.com")
if let data = data {
    print("데이터: \(data)")
} else {
    print("실패")
}

// 기본값 제공
let result = try? fetchData(from: "bad") ?? "기본값"

try! - 강제 언래핑

// 에러 발생 시 크래시 (확실할 때만 사용)
let data = try! fetchData(from: "https://example.com")

3. Result<Success, Failure>

Result 사용

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, flatMap
let result = fetchData(from: "https://example.com")
    .map { $0.uppercased() }
    .mapError { _ in NetworkError.noConnection }

Result와 throws 변환

// Result → throws
func getData() throws -> String {
    let result = fetchData(from: "https://example.com")
    return try result.get()
}

// throws → Result
func getDataResult() -> Result<String, Error> {
    Result { try fetchData(from: "https://example.com") }
}

4. 실전 예제

예제: 사용자 검증

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
    let age: Int
    let email: String
    
    static func validate(name: String, age: Int, email: String) throws -> User {
        guard !name.isEmpty else {
            throw ValidationError.emptyName
        }
        
        guard age >= 0 && age <= 150 else {
            throw ValidationError.invalidAge
        }
        
        guard email.contains("@") else {
            throw ValidationError.invalidEmail
        }
        
        return User(name: name, age: age, email: email)
    }
}

// 사용
do {
    let user = try User.validate(name: "", age: 25, email: "[email protected]")
    print("사용자 생성: \(user.name)")
} catch {
    print("검증 실패: \(error.localizedDescription)")
}

실전 심화 보강

실전 예제: Result로 네트워크 레이어 경계 정리

throws만 쓰는 대신 Result를 반환하면 비동기 체이닝과 테스트가 단순해질 때가 많습니다.

import Foundation

enum NetworkFailure: Error {
    case invalidURL
    case status(Int)
    case 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와의 선택은 호출부 스타일과 팀 컨벤션에 따릅니다.

자주 하는 실수

  • try!로 네트워크 응답을 강제 언래핑해 크래시를 내는 경우.
  • LocalizedError 없이 사용자에게 내부 enum 케이스 이름이 그대로 노출되는 경우.
  • Resultmap/flatMap 실패 타입이 달라져 타입 추론이 꼬이는 경우.

주의사항

  • @frozen이 아닌 라이브러리 에러 타입은 미래 케이스 추가에 대한 switch 경고가 생길 수 있습니다(@unknown default).

실무에서는 이렇게

  • 도메인 에러와 전송 계층 에러를 분리하고, UI에는 매핑된 메시지만 노출합니다.
  • 로깅은 NSError userInfo 또는 OSLog에 상관 ID를 남깁니다.

비교 및 대안

API장점
throws언어 관용, 짧은 호출부
Result값으로 합성, 동기 코드와 유사
async + throwsSwift 동시성과 자연스럽게 결합

추가 리소스


정리

핵심 요약

  1. Error 프로토콜: 에러 타입 정의
  2. throw: 에러 발생
  3. do-catch: 에러 처리
  4. try/try?/try!: 에러 전파 방식
  5. Result: 성공/실패를 값으로 표현

다음 단계

  • Swift 비동기
  • Swift SwiftUI
  • Swift Combine

관련 글

  • C++ expected |
  • JavaScript 에러 처리 | try-catch, Error 객체, 커스텀 에러
  • Rust 에러 처리 | Result, Option, ? 연산자
  • C++ 예외 처리 | try/catch/throw
  • C++ 네트워크 에러 완벽 가이드 | errno·타임아웃·재시도·서킷브레이커 [#28-3]