[11편] 에러 처리 — try, throw, catch, Result

🤖 이 글은 Claude Code(AI)가 작성합니다. | 시리즈 목차 | 이전: 10편

2부를 시작합니다. 1부에서 Swift 언어의 기본 문법을 다뤘다면, 2부에서는 실제 앱을 만들 때 반드시 마주치는 주제들을 다룹니다. 첫 번째는 에러 처리입니다.

프로그램은 항상 성공하지 않습니다. 파일이 없을 수도 있고, 네트워크가 끊길 수도 있고, 사용자가 잘못된 입력을 넣을 수도 있습니다. 이런 실패 가능성을 어떻게 다루느냐가 코드의 안전성과 가독성을 크게 좌우합니다.


Swift 에러 처리의 철학

Python은 어디서든 예외가 터질 수 있지만, 그것을 강제로 처리하지 않아도 됩니다. Java는 checked exception으로 처리를 강제하지만 코드가 장황해집니다. Swift는 그 중간 어딘가를 택했습니다.

  • 에러를 던질 수 있는 함수는 throws로 명시해야 한다
  • throws 함수를 호출할 때는 반드시 try를 붙여야 한다
  • 에러는 Error 프로토콜을 채택한 타입이다 — 문자열이나 숫자가 아니다

결과적으로 “이 함수는 실패할 수 있다”는 사실이 함수 시그니처에 드러나고, 호출하는 쪽에서 그 가능성을 의식적으로 처리해야 합니다.


에러 타입 정의

9편에서 잠깐 나왔듯, 에러 타입은 Error 프로토콜을 채택한 enum으로 만드는 것이 관례입니다.

enum ValidationError: Error {
    case empty
    case tooShort(minimum: Int, actual: Int)
    case invalidCharacter(Character)
}

enum NetworkError: Error {
    case notConnected
    case timeout(seconds: Double)
    case serverError(statusCode: Int, message: String)
}

연관값을 활용하면 에러가 발생한 상황의 세부 정보를 함께 담을 수 있습니다. 나중에 catch 블록에서 그 정보를 꺼내 사용자에게 의미 있는 메시지를 보여줄 수 있습니다.


throws 함수와 throw

func validate(username: String) throws -> String {
    guard !username.isEmpty else {
        throw ValidationError.empty
    }
    guard username.count >= 3 else {
        throw ValidationError.tooShort(minimum: 3, actual: username.count)
    }
    let allowed = CharacterSet.alphanumerics.union(.init(charactersIn: "_"))
    for char in username.unicodeScalars {
        if !allowed.contains(char) {
            throw ValidationError.invalidCharacter(Character(char))
        }
    }
    return username
}

반환 타입 앞에 throws를 붙이면 이 함수가 에러를 던질 수 있음을 선언합니다. 에러를 던질 때는 throw로 에러 값을 넘깁니다. throw 이후 코드는 실행되지 않습니다.


do-catch

do {
    let name = try validate(username: "sw!ft")
    print("유효한 이름: \(name)")
} catch ValidationError.empty {
    print("이름을 입력해주세요")
} catch ValidationError.tooShort(let min, let actual) {
    print("최소 \(min)자 이상이어야 합니다. 현재: \(actual)자")
} catch ValidationError.invalidCharacter(let char) {
    print("사용할 수 없는 문자: \(char)")
} catch {
    // 위에서 처리되지 않은 모든 에러
    print("알 수 없는 오류: \(error)")
}

try가 있는 코드는 반드시 do 블록 안에 있어야 합니다. catch 패턴은 switch처럼 위에서부터 순서대로 매칭되고, 마지막 catch에는 암묵적으로 error 변수가 바인딩됩니다.


에러 전파

에러를 직접 처리하지 않고 호출자에게 넘길 수 있습니다. 함수 자체를 throws로 선언하면 됩니다.

func createAccount(username: String) throws {
    let validName = try validate(username: username)  // 에러를 위로 전파
    // ... 계정 생성 로직
    print("계정 생성: \(validName)")
}

에러는 처리할 수 있는 가장 적절한 계층에서 catch합니다. 모든 함수에서 catch할 필요는 없습니다.


try? 와 try!

do-catch 없이 에러를 다루는 두 가지 축약 방법입니다.

try? — 에러가 나면 nil을 반환하고, 성공하면 옵셔널로 감싼 값을 반환합니다. 에러의 종류는 무시됩니다.

let name = try? validate(username: "swift")
// 성공: Optional("swift")
// 실패: nil

if let name = try? validate(username: "swift") {
    print("유효: \(name)")
}

try! — 에러가 나면 런타임 크래시가 납니다. “절대 실패하지 않는다”는 확신이 있을 때만 씁니다.

// 번들에 항상 존재하는 파일을 로드할 때처럼 실패가 불가능한 경우
let data = try! Data(contentsOf: bundleURL)

Result 타입

throws는 동기 함수에 적합합니다. 하지만 비동기 콜백 기반 코드에서는 에러를 던질 수 없습니다. 이때 Result<Success, Failure> 타입을 씁니다.

enum Result<Success, Failure: Error> {
    case success(Success)
    case failure(Failure)
}
func fetchUser(id: Int, completion: (Result<String, NetworkError>) -> Void) {
    // 비동기 작업 시뮬레이션
    if id > 0 {
        completion(.success("철수"))
    } else {
        completion(.failure(.serverError(statusCode: 404, message: "Not found")))
    }
}

fetchUser(id: 1) { result in
    switch result {
    case .success(let name):
        print("사용자: \(name)")
    case .failure(let error):
        print("에러: \(error)")
    }
}

Result는 9편의 연관값 enum과 같은 구조입니다. 성공과 실패를 하나의 타입으로 표현합니다.

Result를 throws로, throws를 Result로 변환하는 것도 간단합니다.

// Result → throws
let name = try result.get()

// throws → Result
let result = Result { try validate(username: "swift") }

커스텀 에러에 LocalizedError 채택하기

에러 메시지를 사람이 읽기 좋은 형태로 제공하려면 LocalizedError를 채택합니다.

extension ValidationError: LocalizedError {
    var errorDescription: String? {
        switch self {
        case .empty:
            return "이름을 입력해주세요."
        case .tooShort(let min, let actual):
            return "이름은 최소 \(min)자 이상이어야 합니다. (현재 \(actual)자)"
        case .invalidCharacter(let char):
            return "'\(char)'는 사용할 수 없는 문자입니다."
        }
    }
}

do {
    try validate(username: "")
} catch {
    print(error.localizedDescription)  // 이름을 입력해주세요.
}

다른 언어와 비교

Python Java Swift
에러 타입 Exception 클래스 계층 Exception 클래스 계층 Error 프로토콜 채택 타입
던지기 raise throw throw
선언 없음 throws ExceptionType throws (타입 미지정)
처리 강제 없음 checked exception 강제 try 필수, catch는 선택
무시하기 그냥 호출 불가 (checked) try? 또는 try!
비동기 에러 콜백 관례 Future/CompletableFuture Result 타입

핵심 요약

  • 에러를 던지는 함수는 throws로 선언하고, 호출 시 try를 붙인다.
  • 에러는 Error 프로토콜을 채택한 enum으로 만든다. 연관값으로 상세 정보를 담는다.
  • do-catch로 에러를 처리하거나, throws 함수 안에서 그냥 try만 쓰면 상위로 전파된다.
  • try?는 실패 시 nil, try!는 실패 시 크래시. 강제 언래핑과 마찬가지로 try!는 신중하게.
  • 비동기 콜백에서는 Result<Success, Failure> 타입으로 성공과 실패를 하나로 표현한다.

다음 편은 12편 — 메모리 관리(ARC): 순환 참조와 [weak self]입니다. Swift가 메모리를 자동으로 관리하는 방식과, 그것이 실패하는 순환 참조 패턴을 다룹니다.

🤖 Generated with Claude Code

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 항목은 *(으)로 표시합니다