에러 처리

에러 처리

프로그램이 에러를 만나도 어떻게 대응하고 회복하는지 다뤄요.

*에러 처리(error handling)*는 프로그램에서 에러가 발생하는 상황에 대응하고 회복하는 과정이에요. Swift는 실행 중에 회복 가능한 에러를 던지고(throwing), 잡고(catching), 전파하고(propagating), 다루는 일을 일급 시민으로 지원해요.

어떤 작업은 항상 끝까지 실행되거나 유용한 결과를 만들어 내리라는 보장이 없어요. 옵셔널은 값이 없음을 나타내는 데 쓰이지만, 작업이 실패했을 때는 실패했는지를 아는 게 더 유용할 때가 많아요. 그래야 코드가 그에 맞게 대응할 수 있으니까요.

디스크에 있는 파일을 읽고 처리하는 작업을 생각해 볼게요. 이 작업이 실패하는 경우는 여러 가지예요. 지정한 경로에 파일이 없을 수도 있고, 읽기 권한이 없을 수도 있으며, 호환되는 형식으로 인코딩돼 있지 않을 수도 있어요. 이런 상황들을 서로 구분할 수 있으면 프로그램은 일부 에러를 스스로 해결하고, 해결하지 못하는 에러는 사용자에게 알려 줄 수 있어요.

Note: Swift의 에러 처리는 Cocoa와 Objective-C에서 쓰는 NSError 클래스의 에러 처리 패턴과 연동돼요. 이 클래스에 대한 자세한 내용은 Handling Cocoa Errors in Swift를 참고하세요.

출처: The Swift Programming Language

본문

에러 나타내고 던지기

Swift에서 에러는 Error 프로토콜을 준수하는 타입의 값으로 표현돼요. 이 빈(empty) 프로토콜은 어떤 타입이 에러 처리에 쓰일 수 있다는 걸 나타내 줘요.

Swift의 열거형은 관련된 에러 조건들을 묶어 모델링하기에 특히 잘 맞아요. 연관 값(associated value)을 쓰면 에러의 성격에 대한 추가 정보도 전달할 수 있고요. 예를 들어 게임 안의 자동판매기를 운용할 때 겪을 에러 조건을 이렇게 나타낼 수 있어요:

enum VendingMachineError: Error {
    case invalidSelection
    case insufficientFunds(coinsNeeded: Int)
    case outOfStock
}

에러를 던진다는 건 예상하지 못한 일이 생겨서 정상적인 실행 흐름을 이어갈 수 없다는 걸 알리는 거예요. 에러를 던질 때는 throw 문을 써요. 예를 들어 아래 코드는 자동판매기에 동전이 다섯 개 더 필요하다는 에러를 던져요.

throw VendingMachineError.insufficientFunds(coinsNeeded: 5)

에러 처리하기

에러가 던져지면, 그 주변의 어떤 코드가 에러를 처리할 책임을 져야 해요. 예를 들어 문제를 고치거나, 다른 방법을 시도하거나, 사용자에게 실패를 알리는 식으로요.

Swift에서 에러를 처리하는 방법은 네 가지예요. 함수에서 그 함수를 호출한 코드로 에러를 전파할 수도 있고, do-catch 문으로 처리할 수도 있으며, 옵셔널 값으로 처리하거나, 에러가 발생하지 않을 것이라고 단언할 수도 있어요. 각 방법은 아래 절에서 하나씩 다뤄요.

함수가 에러를 던지면 프로그램의 흐름이 바뀌기 때문에, 코드에서 에러를 던질 수 있는 지점을 빠르게 찾을 수 있게 해 두는 게 중요해요. 그 지점을 표시하려면 에러를 던질 수 있는 함수·메서드·초기화 구문을 호출하는 코드 앞에 try 키워드(또는 그 변형인 try?, try!)를 써요. 이 키워드들은 아래 절에서 각각 설명할게요.

Note: Swift의 에러 처리는 다른 언어의 예외 처리와 try, catch, throw 키워드를 쓴다는 점에서 비슷해요. 다만 Objective-C를 포함한 많은 언어의 예외 처리와 달리, Swift의 에러 처리는 호출 스택을 풀어내는(unwinding) 일을 하지 않아요. 그 과정은 계산 비용이 꽤 들거든요. 그래서 throw 문의 성능 특성은 return 문과 비슷해요.

Throwing 함수로 에러 전파하기

함수·메서드·초기화 구문이 에러를 던질 수 있다는 걸 나타내려면, 선언에서 매개변수 뒤에 throws 키워드를 써요. throws로 표시된 함수를 throwing 함수라고 불러요. 함수가 반환 타입을 지정한다면, throws 키워드는 반환 화살표(->) 앞에 써요.

func canThrowErrors() throws -> String

func cannotThrowErrors() -> String

throwing 함수는 함수 안에서 던져진 에러를 호출한 범위(scope)로 전파해요.

Note: 오직 throwing 함수만 에러를 전파할 수 있어요. nonthrowing 함수 안에서 던져진 에러는 반드시 그 함수 안에서 처리해야 해요.

아래 예시에서 VendingMachine 클래스의 vend(itemNamed:) 메서드는 요청한 상품이 없거나, 품절이거나, 비용이 현재 넣은 동전 금액을 넘으면 그에 맞는 VendingMachineError를 던져요:

struct Item {
    var price: Int
    var count: Int
}

class VendingMachine {
    var inventory = [
        "Candy Bar": Item(price: 12, count: 7),
        "Chips": Item(price: 10, count: 4),
        "Pretzels": Item(price: 7, count: 11)
    ]
    var coinsDeposited = 0

    func vend(itemNamed name: String) throws {
        guard let item = inventory[name] else {
            throw VendingMachineError.invalidSelection
        }

        guard item.count > 0 else {
            throw VendingMachineError.outOfStock
        }

        guard item.price <= coinsDeposited else {
            throw VendingMachineError.insufficientFunds(coinsNeeded: item.price - coinsDeposited)
        }

        coinsDeposited -= item.price

        var newItem = item
        newItem.count -= 1
        inventory[name] = newItem

        print("Dispensing \(name)")
    }
}

vend(itemNamed:) 메서드의 구현은 guard 문을 써서 간식 구매 요건 중 하나라도 충족되지 않으면 메서드를 일찍 빠져나가고 그에 맞는 에러를 던져요. throw 문은 즉시 프로그램 제어를 넘기기 때문에, 모든 요건이 충족될 때만 상품이 나와요.

vend(itemNamed:) 메서드가 던지는 에러를 전파하므로, 이 메서드를 호출하는 코드는 에러를 처리하거나(do-catch 문, try?, try! 사용) 계속 전파해야 해요. 예를 들어 아래 예시의 buyFavoriteSnack(person:vendingMachine:)도 throwing 함수이고, vend(itemNamed:) 메서드가 던지는 에러는 buyFavoriteSnack(person:vendingMachine:) 함수가 호출되는 지점까지 전파돼요.

let favoriteSnacks = [
    "Alice": "Chips",
    "Bob": "Licorice",
    "Eve": "Pretzels",
]
func buyFavoriteSnack(person: String, vendingMachine: VendingMachine) throws {
    let snackName = favoriteSnacks[person] ?? "Candy Bar"
    try vendingMachine.vend(itemNamed: snackName)
}

이 예시에서 buyFavoriteSnack(person: vendingMachine:) 함수는 주어진 사람의 좋아하는 간식을 찾아서 vend(itemNamed:) 메서드를 호출해 그 간식을 사려고 해요. vend(itemNamed:) 메서드가 에러를 던질 수 있으므로, 앞에 try 키워드를 붙여서 호출해요.

Throwing 초기화 구문도 throwing 함수와 같은 방식으로 에러를 전파할 수 있어요. 예를 들어 아래 목록에 있는 PurchasedSnack 구조체의 초기화 구문은 초기화 과정의 일부로 throwing 함수를 호출하고, 만나는 에러를 호출한 쪽으로 전파해서 처리해요.

struct PurchasedSnack {
    let name: String
    init(name: String, vendingMachine: VendingMachine) throws {
        try vendingMachine.vend(itemNamed: name)
        self.name = name
    }
}

Do-Catch로 에러 처리하기

do-catch 문으로 코드 블록을 실행하면서 에러를 처리해요. do 절의 코드가 에러를 던지면, 그 에러를 처리할 수 있는 catch 절이 무엇인지 catch 절들을 차례로 대조해요.

do-catch 문의 일반적인 형태는 다음과 같아요:

do {
    try <#expression#>
    <#statements#>
} catch <#pattern 1#> {
    <#statements#>
} catch <#pattern 2#> where <#condition#> {
    <#statements#>
} catch <#pattern 3#>, <#pattern 4#> where <#condition#> {
    <#statements#>
} catch {
    <#statements#>
}

catch 뒤에 패턴을 써서 그 절이 처리할 수 있는 에러를 나타내요. catch 절에 패턴이 없으면 그 절은 어떤 에러든 대조하고, 에러를 error라는 이름의 지역 상수에 담아요. 패턴 대조에 대한 자세한 내용은 doc:Patterns를 참고하세요.

예를 들어 아래 코드는 VendingMachineError 열거형의 세 가지 경우를 모두 대조해요.

var vendingMachine = VendingMachine()
vendingMachine.coinsDeposited = 8
do {
    try buyFavoriteSnack(person: "Alice", vendingMachine: vendingMachine)
    print("Success! Yum.")
} catch VendingMachineError.invalidSelection {
    print("Invalid Selection.")
} catch VendingMachineError.outOfStock {
    print("Out of Stock.")
} catch VendingMachineError.insufficientFunds(let coinsNeeded) {
    print("Insufficient funds. Please insert an additional \(coinsNeeded) coins.")
} catch {
    print("Unexpected error: \(error).")
}
// Prints "Insufficient funds. Please insert an additional 2 coins."

위 예시에서 buyFavoriteSnack(person:vendingMachine:) 함수는 에러를 던질 수 있으므로 try 표현식 안에서 호출돼요. 에러가 던져지면 실행이 즉시 catch 절들로 넘어가고, catch 절들이 전파를 계속할지 결정해요. 어떤 패턴에도 대조되지 않으면 에러는 마지막 catch 절이 받아서 error라는 지역 상수에 담겨요. 에러가 던져지지 않으면 do 문의 나머지 문장들이 실행돼요.

catch 절들이 do 절의 코드가 던질 수 있는 모든 에러를 처리할 필요는 없어요. catch 절 중 아무것도 에러를 처리하지 않으면, 그 에러는 둘러싼 범위로 전파돼요. 다만 전파된 에러는 어딘가 둘러싼 범위에서 처리해야 해요. nonthrowing 함수에서는 둘러싼 do-catch 문이 에러를 처리해야 하고, throwing 함수에서는 둘러싼 do-catch 문이나 호출한 쪽 중 하나가 처리해야 해요. 에러가 처리되지 않은 채 최상위 범위까지 전파되면 런타임 에러가 생겨요.

예를 들어 위 예시는 VendingMachineError가 아닌 에러를 호출하는 함수 쪽에서 받도록 다시 쓸 수 있어요:

func nourish(with item: String) throws {
    do {
        try vendingMachine.vend(itemNamed: item)
    } catch is VendingMachineError {
        print("Couldn't buy that from the vending machine.")
    }
}

do {
    try nourish(with: "Beet-Flavored Chips")
} catch {
    print("Unexpected non-vending-machine-related error: \(error)")
}
// Prints "Couldn't buy that from the vending machine."

nourish(with:) 함수에서 vend(itemNamed:)VendingMachineError 열거형의 한 경우인 에러를 던지면, nourish(with:)가 메시지를 출력해서 에러를 처리해요. 그 외의 경우에는 nourish(with:)가 에러를 호출 지점으로 전파하고, 그 에러는 일반 catch 절이 받아요.

관련된 여러 에러를 한 번에 잡는 또 다른 방법은 catch 뒤에 콤마로 구분해서 나열하는 거예요. 예를 들어:

func eat(item: String) throws {
    do {
        try vendingMachine.vend(itemNamed: item)
    } catch VendingMachineError.invalidSelection, VendingMachineError.insufficientFunds, VendingMachineError.outOfStock {
        print("Invalid selection, out of stock, or not enough money.")
    }
}

eat(item:) 함수는 잡을 자판기 에러들을 나열하고, 에러 문구가 그 목록의 항목과 대응돼요. 나열된 세 에러 중 하나라도 던져지면 이 catch 절이 메시지를 출력해서 처리해요. 그 외의 에러는, 나중에 추가될 수 있는 다른 자판기 에러를 포함해, 둘러싼 범위로 전파돼요.

에러를 옵셔널 값으로 변환하기

try?는 에러를 옵셔널 값으로 변환해서 처리해요. try? 표현식을 평가하는 동안 에러가 던져지면, 표현식의 값은 nil이 돼요. 예를 들어 아래 코드에서 xy는 같은 값과 같은 동작을 가져요:

func someThrowingFunction() throws -> Int {
    // ...
}

let x = try? someThrowingFunction()

let y: Int?
do {
    y = try someThrowingFunction()
} catch {
    y = nil
}

someThrowingFunction()이 에러를 던지면 xy의 값은 nil이 돼요. 그렇지 않으면 xy의 값은 함수가 반환한 값이에요. xysomeThrowingFunction()이 반환하는 타입의 옵셔널이에요. 여기서 함수는 정수를 반환하므로, xy는 옵셔널 정수예요.

모든 에러를 같은 방식으로 처리하고 싶을 때 try?를 쓰면 간결한 에러 처리 코드를 만들 수 있어요. 예를 들어 아래 코드는 여러 방법으로 데이터를 가져오고, 모든 방법이 실패하면 nil을 반환해요.

func fetchData() -> Data? {
    if let data = try? fetchDataFromDisk() { return data }
    if let data = try? fetchDataFromServer() { return data }
    return nil
}

에러 전파 비활성화하기

때로는 throwing 함수나 메서드가 실제로 실행 중에는 에러를 던지지 않을 거라고 확실히 아는 경우가 있어요. 그런 경우에는 표현식 앞에 try!를 써서 에러 전파를 비활성화하고, 에러가 던져지지 않을 것이라는 런타임 단언으로 그 호출을 감쌀 수 있어요. 실제로 에러가 던져지면 런타임 에러가 생겨요.

예를 들어 아래 코드는 주어진 경로의 이미지 리소스를 불러오거나, 불러올 수 없으면 에러를 던지는 loadImage(atPath:) 함수를 사용해요. 이 경우 이미지가 애플리케이션에 포함되어 배포되므로 실행 중에는 에러가 던져지지 않아요. 그래서 에러 전파를 비활성화하는 게 적절해요.

let photo = try! loadImage(atPath: "./Resources/John Appleseed.jpg")

에러 타입 지정하기

지금까지의 예시들은 모두 가장 흔한 종류의 에러 처리를 씁니다. 코드가 던지는 에러가 Error 프로토콜을 준수하는 어떤 타입의 값이든 될 수 있는 경우예요. 이 접근은 코드가 실행되는 동안 어떤 에러가 일어날지 미리 다 알 수 없다는 현실과 맞아요. 특히 다른 곳에서 던져진 에러를 전파할 때요. 또 에러가 시간이 지나며 바뀔 수 있다는 사실도 반영해요. 라이브러리(의존하는 라이브러리들이 쓰는 라이브러리도 포함)의 새 버전이 새 에러를 던질 수 있고, 실제 사용자 구성의 복잡한 조합이 개발이나 테스트 중에는 드러나지 않던 실패 양태를 노출할 수도 있어요. 위 예시들의 에러 처리 코드는 항상 특정 catch 절이 없는 에러를 처리할 기본(default) 경우를 포함하고 있어요.

대부분의 Swift 코드는 던지는 에러의 타입을 지정하지 않아요. 하지만 다음 특수한 경우에는 코드가 오직 한 특정 타입의 에러만 던지도록 제한할 수 있어요:

  • 메모리 동적 할당을 지원하지 않는 임베디드 시스템에서 코드를 실행할 때요. any Error나 다른 박스형(boxed) 프로토콜 타입의 인스턴스를 던지려면 에러를 저장할 메모리를 실행 중에 할당해야 해요. 반면 특정 타입의 에러를 던지면 Swift는 에러에 힙 할당을 하지 않을 수 있어요.
  • 에러가 라이브러리 같은 어떤 코드 단위의 구현 세부사항이고, 그 코드의 인터페이스 일부가 아닐 때요. 에러가 오직 라이브러리에서만 오고 다른 의존성이나 라이브러리 사용자에게서 오지 않으므로, 가능한 모든 실패를 빠짐없이 나열할 수 있어요. 또 이 에러들이 라이브러리의 구현 세부사항이므로 항상 그 라이브러리 안에서 처리돼요.
  • 클로저 인자를 받아서 그 클로저의 에러를 전파하는 함수처럼, 제네릭 매개변수로 설명되는 에러만 전파하는 코드에서요. 특정 에러 타입을 전파하는 것과 rethrows를 쓰는 것의 비교는 doc:Declarations#Rethrowing-Functions-and-Methods를 참고하세요.

예를 들어, 평점을 요약하는 코드를 생각해 봐요. 다음과 같은 에러 타입을 써요:

enum StatisticsError: Error {
    case noRatings
    case invalidRating(Int)
}

함수가 에러로 오직 StatisticsError 값만 던진다고 지정하려면, 함수를 선언할 때 throws만 쓰는 대신 throws(StatisticsError)라고 써요. 이 문법을 typed throws라고도 불러요. 선언에서 throws 뒤에 에러 타입을 써서요. 예를 들어 아래 함수는 에러로 StatisticsError 값을 던져요.

func summarize(_ ratings: [Int]) throws(StatisticsError) {
    guard !ratings.isEmpty else { throw .noRatings }

    var counts = [1: 0, 2: 0, 3: 0]
    for rating in ratings {
        guard rating > 0 && rating <= 3 else { throw .invalidRating(rating) }
        counts[rating]! += 1
    }

    print("*", counts[1]!, "-- **", counts[2]!, "-- ***", counts[3]!)
}

위 코드에서 summarize(_:) 함수는 1부터 3까지의 척도로 표현된 평점 목록을 요약해요. 이 함수는 입력이 유효하지 않으면 StatisticsError의 인스턴스를 던져요. 위 코드에서 에러를 던지는 두 지점 모두 함수의 에러 타입이 이미 정해져 있으므로 에러의 타입을 생략해요. 이렇게 함수에서 에러를 던질 때는 throw StatisticsError.noRatings라고 쓰는 대신 짧은 형태인 throw .noRatings를 쓸 수 있어요.

함수 시작 부분에 특정 에러 타입을 쓰면, Swift는 다른 에러를 던지지 않는지 검사해요. 예를 들어 앞서 이 장에서 나온 VendingMachineError를 위의 summarize(_:) 함수에서 쓰려고 하면, 그 코드는 컴파일 시점에 에러를 만들어요.

일반 throwing 함수 안에서 typed throws를 쓰는 함수를 호출할 수 있어요:

func someThrowingFunction() throws {
    let ratings = [1, 2, 3, 2, 2, 1]
    try summarize(ratings)
}

위 코드는 someThrowingFunction()의 에러 타입을 지정하지 않으므로 any Error를 던져요. 에러 타입을 throws(any Error)로 명시적으로 쓸 수도 있어요. 아래 코드는 위 코드와 같아요:

func someThrowingFunction() throws(any Error) {
    let ratings = [1, 2, 3, 2, 2, 1]
    try summarize(ratings)
}

이 코드에서 someThrowingFunction()summarize(_:)가 던지는 에러를 전부 전파해요. summarize(_:)의 에러는 항상 StatisticsError 값이고, 이것은 someThrowingFunction()이 던져도 되는 유효한 에러예요.

반환 타입이 Never인 함수를 써서 결코 반환하지 않는 함수를 만들 수 있는 것처럼, throws(Never)를 써서 결코 throw하지 않는 함수를 만들 수 있어요:

func nonThrowingFunction() throws(Never) {
  // ...
}

이 함수는 던질 Never 타입의 값을 만들 수 없기 때문에 throw할 수 없어요.

함수의 에러 타입을 지정하는 것 외에도, do-catch 문에 특정 에러 타입을 쓸 수 있어요. 예를 들어:

let ratings = []
do throws(StatisticsError) {
    try summarize(ratings)
} catch {
    switch error {
    case .noRatings:
        print("No ratings available")
    case .invalidRating(let rating):
        print("Invalid rating: \(rating)")
    }
}
// Prints "No ratings available".

이 코드에서 do throws(StatisticsError)라고 쓴 것은 그 do-catch 문이 에러로 StatisticsError 값을 던진다는 뜻이에요. 다른 do-catch 문처럼 catch 절은 가능한 모든 에러를 처리하거나, 처리하지 않은 에러를 둘러싼 범위가 처리하도록 전파할 수 있어요. 이 코드는 열거형 값마다 하나씩인 switch 문으로 모든 에러를 처리해요. 패턴이 없는 다른 catch 절처럼 이 절은 어떤 에러든 대조하고, 에러를 error라는 이름의 지역 상수에 담아요. do-catch 문이 StatisticsError 값을 던지므로, errorStatisticsError 타입의 값이에요.

위의 catch 절은 switch 문으로 가능한 각 에러를 대조하고 처리해요. StatisticsError에 새 경우를 추가하면서 에러 처리 코드를 갱신하지 않으면, switch 문이 더 이상 빠짐없이(exhaustive) 대조하지 않으므로 Swift가 에러를 내요. 자기 에러를 전부 잡는 라이브러리라면 이 접근으로 새 에러에 대응하는 새 처리 코드가 생기도록 만들 수 있어요.

함수나 do 블록이 오직 한 타입의 에러만 던진다면, Swift는 이 코드가 typed throws를 쓴다고 추론해요. 이 더 짧은 문법을 쓰면 위의 do-catch 예시를 다음과 같이 쓸 수 있어요:

let ratings = []
do {
    try summarize(ratings)
} catch {
    switch error {
    case .noRatings:
        print("No ratings available")
    case .invalidRating(let rating):
        print("Invalid rating: \(rating)")
    }
}
// Prints "No ratings available".

위의 do-catch 블록이 어떤 타입의 에러를 던지는지 지정하지 않았어도, Swift는 그것이 StatisticsError를 던진다고 추론해요. Swift가 typed throws를 추론하지 않도록 하려면 throws(any Error)를 명시적으로 쓸 수 있어요.

정리 동작 지정하기

defer 문은 코드 실행이 현재 코드 블록을 떠나기 직전에 일련의 문장을 실행해요. 이 문장을 쓰면 실행이 어떤 방식으로 현재 코드 블록을 떠나든 — 에러가 던져져서든 return이나 break 같은 문장 때문이든 — 반드시 수행해야 할 정리 작업을 할 수 있어요. 예를 들어 defer 문으로 파일 디스크립터를 닫고, 수동으로 할당한 메모리를 해제하도록 만들 수 있어요.

defer 문은 현재 범위를 빠져나갈 때까지 실행을 미뤄요. 이 문장은 defer 키워드와 나중에 실행할 문장들로 이루어져요. 미뤄진(deferred) 문장에는 breakreturn처럼 실행 제어를 문장 밖으로 옮기거나 에러를 던지는 코드가 들어갈 수 없어요. 미뤄진 동작들은 소스 코드에 쓴 순서의 반대 순서로 실행돼요. 즉 첫 번째 defer 문의 코드가 마지막에, 두 번째 defer 문의 코드가 마지막에서 두 번째에 실행되고, 소스 코드 순서상 마지막 defer 문이 가장 먼저 실행돼요.

func processFile(filename: String) throws {
    if exists(filename) {
        let file = open(filename)
        defer {
            close(file)
        }
        while let line = try file.readline() {
            // Work with the file.
        }
        // close(file) is called here, at the end of the scope.
    }
}

위 예시는 defer 문으로 open(_:) 함수에 대응하는 close(_:) 호출이 있도록 보장해요.

에러 처리 코드가 없을 때도 defer 문을 쓸 수 있어요. 자세한 내용은 doc:ControlFlow#Deferred-Actions를 참고하세요.

더 알아보기

  • 옵셔널과의 관계: 에러 처리는 값을 강제 언래핑하는 try!처럼 옵셔널 처리와 이어져요. 옵셔널의 기본 개념은 Language Guide의 Basics 장에서 다뤄요.
  • 제네릭과 재throw: 특정 에러 타입을 전파하는 것과 rethrows의 차이는 Language Guide의 Declarations 장에서 Rethrowing Functions and Methods 절에서 확인할 수 있어요.
  • Cocoa 연동: Cocoa·Objective-C의 NSError 기반 에러와 Swift 에러를 함께 쓰는 법은 Handling Cocoa Errors in Swift 문서를 참고하세요.
  • Control Flow의 지연 동작: 에러 처리 없이도 쓸 수 있는 defer의 다른 쓰임은 Language Guide의 Control Flow 장에서 Deferred Actions 절에서 더 자세히 다뤄요.