Swift 에러 처리 — do-catch, Result, throws

Swift의 에러 처리

Swift는 Error 프로토콜, throws/do-catch, Result 타입을 통해 체계적인 에러 처리를 지원합니다. 컴파일러가 에러 처리를 강제하므로, 에러를 무시하거나 빠뜨리는 실수를 방지합니다.

이 글에서는 에러 정의부터 Result 타입, 비동기 에러 처리까지 실전 패턴을 정리합니다.

에러 타입 정의

Swift에서 에러는 Error 프로토콜을 채택하는 타입으로 정의합니다. 열거형으로 정의하면 관련 에러를 그룹화하고 연관 값으로 상세 정보를 전달할 수 있습니다.

import Foundation

// 에러 타입 정의 — 열거형 + Error 프로토콜
enum ValidationError: Error, CustomStringConvertible {
    case emptyField(name: String)
    case tooShort(field: String, minimum: Int, actual: Int)
    case tooLong(field: String, maximum: Int, actual: Int)
    case invalidFormat(field: String, expected: String)

    // 사용자 친화적 메시지
    var description: String {
        switch self {
        case .emptyField(let name):
            return "\(name) 필드는 비어있을 수 없습니다."
        case .tooShort(let field, let min, let actual):
            return "\(field)은(는) \(min)자 이상이어야 합니다. (현재: \(actual)자)"
        case .tooLong(let field, let max, let actual):
            return "\(field)은(는) \(max)자 이하여야 합니다. (현재: \(actual)자)"
        case .invalidFormat(let field, let expected):
            return "\(field)의 형식이 올바르지 않습니다. (기대: \(expected))"
        }
    }
}

enum NetworkError: Error {
    case invalidURL(String)
    case connectionFailed
    case serverError(statusCode: Int, message: String)
    case decodingFailed(underlying: Error)
    case timeout
}

// LocalizedError 채택 — 시스템 에러 메시지 통합
extension NetworkError: LocalizedError {
    var errorDescription: String? {
        switch self {
        case .invalidURL(let url):
            return "유효하지 않은 URL입니다: \(url)"
        case .connectionFailed:
            return "네트워크 연결에 실패했습니다."
        case .serverError(let code, let msg):
            return "서버 에러 [\(code)]: \(msg)"
        case .decodingFailed(let error):
            return "데이터 디코딩 실패: \(error.localizedDescription)"
        case .timeout:
            return "요청 시간이 초과되었습니다."
        }
    }
}

// 검증 함수 — throws로 에러 던지기
func validateUsername(_ username: String) throws {
    guard !username.isEmpty else {
        throw ValidationError.emptyField(name: "사용자 이름")
    }
    guard username.count >= 3 else {
        throw ValidationError.tooShort(field: "사용자 이름", minimum: 3, actual: username.count)
    }
    guard username.count <= 20 else {
        throw ValidationError.tooLong(field: "사용자 이름", maximum: 20, actual: username.count)
    }
}

// do-catch로 에러 처리
func registerUser(username: String) {
    do {
        try validateUsername(username)
        print("등록 성공: \(username)")
    } catch let error as ValidationError {
        print("검증 실패: \(error)")
    } catch {
        print("알 수 없는 에러: \(error)")
    }
}

registerUser(username: "홍길동")
// 실행 결과: 등록 성공: 홍길동

registerUser(username: "ab")
// 실행 결과: 검증 실패: 사용자 이름은(는) 3자 이상이어야 합니다. (현재: 2자)

registerUser(username: "")
// 실행 결과: 검증 실패: 사용자 이름 필드는 비어있을 수 없습니다.

try, try?, try! 의 차이

Swift는 세 가지 try 변형을 제공합니다. 각각의 에러 처리 방식이 다릅니다.

import Foundation

enum ParseError: Error {
    case invalidInput(String)
}

func parseInt(_ str: String) throws -> Int {
    guard let value = Int(str) else {
        throw ParseError.invalidInput(str)
    }
    return value
}

// try — do-catch 블록 필수
func demonstrateTry() {
    // try — 에러를 catch로 처리
    do {
        let num = try parseInt("42")
        print("try 결과: \(num)")
    } catch {
        print("에러: \(error)")
    }
    // 실행 결과: try 결과: 42

    // try? — 에러 시 nil 반환 (Optional)
    let result1: Int? = try? parseInt("42")
    let result2: Int? = try? parseInt("abc")
    print("try? 성공: \(result1 ?? -1)")  // 42
    print("try? 실패: \(result2 ?? -1)")  // -1
    // 실행 결과:
    // try? 성공: 42
    // try? 실패: -1

    // try! — 에러 시 런타임 크래시 (확실할 때만!)
    let guaranteed = try! parseInt("100")
    print("try! 결과: \(guaranteed)")
    // 실행 결과: try! 결과: 100
    // try! parseInt("abc")  // 런타임 크래시!

    // try?와 nil 병합 조합 — 기본값 패턴
    let port = (try? parseInt("8080")) ?? 3000
    print("포트: \(port)")
    // 실행 결과: 포트: 8080

    let fallback = (try? parseInt("invalid")) ?? 3000
    print("폴백 포트: \(fallback)")
    // 실행 결과: 폴백 포트: 3000
}

demonstrateTry()

try?는 에러 세부 정보가 필요 없을 때 간편하게 사용합니다. try!는 절대 실패하지 않는다고 확신할 때만 사용하며, 가능한 피하는 것이 좋습니다.

Result 타입 — 성공과 실패를 명시적으로

Result 타입은 성공 값과 에러를 하나의 타입으로 감싸서 반환합니다. 콜백 기반 API나 에러를 나중에 처리할 때 유용합니다.

import Foundation

struct User {
    let id: Int
    let name: String
    let email: String
}

enum APIError: Error, CustomStringConvertible {
    case notFound(id: Int)
    case unauthorized
    case rateLimited(retryAfter: Int)

    var description: String {
        switch self {
        case .notFound(let id): return "ID \(id)를 찾을 수 없습니다"
        case .unauthorized: return "인증이 필요합니다"
        case .rateLimited(let sec): return "\(sec)초 후 재시도하세요"
        }
    }
}

// Result 타입 반환
func fetchUser(id: Int) -> Result<User, APIError> {
    guard id > 0 else {
        return .failure(.notFound(id: id))
    }
    guard id != 999 else {
        return .failure(.unauthorized)
    }
    return .success(User(id: id, name: "사용자_\(id)", email: "user\(id)@example.com"))
}

// Result 사용 패턴
func processUser(id: Int) {
    let result = fetchUser(id: id)

    // switch로 처리
    switch result {
    case .success(let user):
        print("사용자: \(user.name) (\(user.email))")
    case .failure(let error):
        print("에러: \(error)")
    }
}

processUser(id: 1)
// 실행 결과: 사용자: 사용자_1 (user1@example.com)

processUser(id: 0)
// 실행 결과: 에러: ID 0를 찾을 수 없습니다

processUser(id: 999)
// 실행 결과: 에러: 인증이 필요합니다

// Result의 함수형 메서드 — map, flatMap, mapError
func enrichUser(id: Int) {
    let displayName = fetchUser(id: id)
        .map { "\($0.name) <\($0.email)>" }  // 성공 값 변환

    switch displayName {
    case .success(let name):
        print("표시명: \(name)")
    case .failure(let error):
        print("실패: \(error)")
    }
}

enrichUser(id: 5)
// 실행 결과: 표시명: 사용자_5 <user5@example.com>

// Result를 throws로 변환
func getUser(id: Int) throws -> User {
    return try fetchUser(id: id).get()  // 실패 시 에러 던짐
}

do {
    let user = try getUser(id: 3)
    print("get() 결과: \(user.name)")
} catch {
    print("get() 에러: \(error)")
}
// 실행 결과: get() 결과: 사용자_3

rethrows와 에러 전파

rethrows는 클로저가 에러를 던질 때만 함수도 에러를 던지는 조건부 throws입니다. map, filter 등 표준 라이브러리 함수가 이 패턴을 사용합니다.

import Foundation

// rethrows — 클로저가 throws일 때만 함수도 throws
func retry<T>(
    times: Int,
    delay: TimeInterval = 1.0,
    operation: () throws -> T
) rethrows -> T {
    var lastError: Error?

    for attempt in 1...times {
        do {
            return try operation()
        } catch {
            lastError = error
            print("시도 \(attempt)/\(times) 실패: \(error)")
            if attempt < times {
                Thread.sleep(forTimeInterval: delay)
            }
        }
    }
    throw lastError!
}

// 에러 전파 예제
var callCount = 0

func unstableOperation() throws -> String {
    callCount += 1
    if callCount < 3 {
        throw NSError(domain: "test", code: callCount, userInfo: [
            NSLocalizedDescriptionKey: "임시 에러 #\(callCount)"
        ])
    }
    return "성공 (시도 \(callCount)회)"
}

do {
    let result = try retry(times: 5, delay: 0.1) {
        try unstableOperation()
    }
    print("최종 결과: \(result)")
} catch {
    print("모든 시도 실패: \(error)")
}
// 실행 결과:
// 시도 1/5 실패: 임시 에러 #1
// 시도 2/5 실패: 임시 에러 #2
// 최종 결과: 성공 (시도 3회)

// defer — 스코프 종료 시 반드시 실행
func processFile(path: String) throws {
    print("파일 열기: \(path)")
    defer {
        print("파일 닫기: \(path)")  // 에러 발생 여부와 무관하게 실행
    }

    // 작업 수행...
    print("파일 처리 중...")
    // throw SomeError  // 에러가 발생해도 defer 블록 실행됨
    print("파일 처리 완료")
}

try processFile(path: "/tmp/data.txt")
// 실행 결과:
// 파일 열기: /tmp/data.txt
// 파일 처리 중...
// 파일 처리 완료
// 파일 닫기: /tmp/data.txt

실전 팁

  • 구체적 에러 타입 정의: Error 프로토콜을 채택한 열거형으로 도메인별 에러를 정의합니다. 연관 값으로 맥락 정보를 전달합니다.
  • LocalizedError 채택: 사용자에게 표시할 에러 메시지가 필요하면 LocalizedError를 채택하여 errorDescription을 구현합니다.
  • try? + ?? 패턴: 에러 세부 정보가 불필요할 때 (try? operation()) ?? defaultValue 패턴을 사용합니다.
  • Result는 콜백에 유용: completion handler 기반 API에서는 Result로 성공/실패를 전달합니다. async/await가 가능하면 throws를 직접 사용합니다.
  • defer로 정리: 파일 핸들, 네트워크 연결 등 반드시 정리해야 하는 리소스는 defer 블록에서 해제합니다.
  • try! 사용 금지: 프로덕션 코드에서 try!는 런타임 크래시를 유발합니다. 테스트 코드에서만 제한적으로 사용합니다.

이 글이 도움이 되었나요?