공식 문서로 공부하는 Swift (16) - 에러 처리

ci·2020년 5월 31일
2

Error Handling

에러 처리(error handling)는 프로그램에서 발생한 에러를 받고 대응하는 과정이다. Swift는 런타임 에러를 발생시키고(throwing), 잡고(catching), 전파하고(propragating), 조작하는(manipulating) 일급 클래스(first-class)를 지원한다.

몇몇 연산자는 항상 완벽하게 실행되거나 유용한 결과물을 제공하는 것을 보장하지 않는다. 옵셔널은 값의 부재를 나타내는 데 사용한다. 하지만 연산이 실패했을 때 무엇이 실패를 유도했는지 이해하고, 코드가 거기에 따라 대응하도록 하는 게 유용할 때가 있다.

디스크에서 파일을 읽고 데이터를 쓰는 작업을 고려해 보자. 존재하지 않는 파일에 경로를 설정하거나, 읽기 권한이 없거나, 적절한 포맷으로 인코딩되어 있지 않는 등, 이 작업이 실패할 경우의 수는 많다. 이렇게 서로 다른 상황을 구별하는 것은 프로그램이 특정한 에러를 해결하고, 해결할 수 없는 에러는 유저와 소통할 수 있게 해 준다.

Swift의 에러 처리는 Cocoa와 Objective-C의 ESError를 사용하는 에러 처리 패턴과 상호 운용한다.



에러의 표시와 발생

Swift에서 에러는 Error 프로토콜을 준수하는 타입의 값으로 표현된다. 이 빈 프로토콜은 에러를 처리하는 데 이 타입을 사용할 수 있음을 나타낸다.

Swift의 열거형은 특별히 이런 관련된 에러를 그룹화(Grouping)하고 추가적인 정보를 제공하기에 적합하다. 예를 들어, 게임 안에서 판매 기기 동작의 에러 상황을 다음과 같이 표현할 수 있다.

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

에러를 발생시킴으로써 예상치 못한 것이 발생하거나 일반적인 실행 흐름이 계속될 수 없다는 것을 알릴 수 있다. throw문 을 사용해 에러를 발생시킨다. 아래 코드는 판매 기기에 5개의 코인이 더 필요하다는 것을 알리는 에러를 발생시킨다.

throw VendingMachineError.insufficientFunds(coinsNeeded: 5)


에러 처리

에러가 발생했을 때, 특정 코드 영역은 에러를 처리해야 할 책임이 있다. 문제를 수정하거나, 다른 대안을 시도하거나, 사용자에게 실패를 알리거나 해야 한다.

Swift에는 에러를 처리하는 네 가지 방법이 있다.

  1. 에러가 발생한 함수에서 리턴 값으로 에러를 반환해 해당 함수를 호출한 코드에서 에러를 처리하도록 하는 방법.
  2. do-catch문을 사용.
  3. 옵셔널 값을 반환.
  4. assert를 사용해 강제로 크래쉬를 발생.

함수가 에러를 발생시킬 때, 이는 프로그램의 흐름을 바꾼다. 때문에 에러를 발생시킬 수 있는 코드의 위치를 빠르게 파악하는 것이 중요하다. 이를 위해 try 키워드를 사용한다. 에러를 발생시킬 수 있는 함수, 메소드, 이니셜라이저를 호출하는 코드 이전에 작성한다.

Swfit의 에러 처리는 다른 언어의 에러 처리와 유사하다. Objective-C를 포함해 다른 언어의 exception 처리와 다른 점은 Swift에서 에러 처리는 많은 계산이 필요할 수 있는 콜스택(call stack) 되돌리기(unwinding)와 관련이 없다는 것이다. 그렇기 때문에 에러를 반환하는 throw 구문은 일반적인 반환 구문인 return 구문과 비슷한 성능을 보여준다.


함수를 사용해 에러 전파하기

함수, 메소드, 이니셜라이저가 에러를 발생시킬 수 있음을 알리기 위해, 매개 변수 선언 뒤에 throws 키워드를 작성한다. throws가 표시되어 있는 함수를 throwing function이라고 부른다. 만약 함수가 반환 타입을 지정했다면, 매개 변수 선언과 반환 화살표(->) 사이에 키워드를 적는다.

func canThrowErrors() throws -> String

func cannotThrowErrors() -> String

throwing function은 그 함수를 호출한 곳으로 에러를 전파한다.

throwing function만이 에러를 전파할 수 있다. throwing function이 아닌 함수는 함수 안에서 에러를 처리해야 한다.


아래 예시는 VendingMachine 클래스를 정의한다. 이 클래스는 VendingMachineError를 발생시킬 수 있는 vend(itemNamed:) 메소드를 갖고 있다. 이 에러는 요구되는 아이템이 가능하지 않거나, 재고가 없거나, 현재 예산을 초과한 비용이 필요한 경우 발생한다.

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:) 메소드가 에러를 전파하기 때문에 이 메소드를 호출하는 모든 코드는 반드시 에러를 처리해야 한다. 아래 예시의 buyFavoriteSnack(person:vendingMachine:) 메소드 역시 에러를 발생시킬 수 있는 함수이고, 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 initializerthrowing function과 동일한 방법으로 에러를 전파한다. PurchasedSnack 구조체의 이니셜라이저는 초기화 과정에서 throwing function을 호출한다. 그리고 이 구조체를 호출한 곳으로 에러를 전파하여 이를 처리한다.

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 구문으로 구분해 처리할 수 있다. 어떤 것이 에러를 처리할 수 있는지 결정한다.

do {
    try expression
    statements
} catch pattern 1 {
    statements
} catch pattern 2 where condition {
    statements
} catch {
    statements
}

어떤 에러를 이 구문이 처리할 수 있는지 알리기 위해 catch 뒤에 패턴을 작성한다. 만약 catch절이 패턴을 갖고 있지 않다면 발생하는 모든 에러를 지역 상수인 error로 바인딩 한다.


다음 코드는 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 구문에 잡히고, 지역 상수 error에 바인딩 된다. 에러가 발생하지 않는다면 do 구문이 계속 실행된다.


catch 구문은 do 구문에서 발생할 수 있는 모든 가능한 에러를 처리할 필요는 없다. 만약 에러를 처리할 수 있는 catch 구문이 없다면 에러는 주변 범위(surrounding scope)로 에러를 전파한다. 전파된 에러는 반드시 특정 주변 범위에서 처리되어야 한다. 에러를 발생 시키지 않는 함수에서는 관련된 do-catch 구문에서 그 에러를 반드시 처리해야 하고, 에러를 발생 시키는 함수에서는 에러를 do-catch 구문에서 처리하거나 함수를 호출한 곳에서 반드시 에러를 처리해야 한다. 만약 에러를 처리하지 않고 최상위 범위까지 전파하면 런타임 에러가 발생한다.

예를 들어, 위의 예시는 VendingMachineError가 아닌 모든 오류가 호출한 함수에 의해 포착되도록 작성될 수 있다.

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

do {
    try nourish(with: "Beet-Flavored Chips")
} catch {
    print("Unexpected non-vending-machine-related error: \(error)")
}
// Prints "Invalid selection, out of stock, or not enough money."

nourish(with:) 함수에서, 만약 vend(itemNamed:)VendingMachineError 열거형의 케이스 중 하나인 에러를 발생시킨다면, nourish(with:)은 메시지를 출력함으로써 에러를 처리한다. 그렇지 않으면 이 함수를 호출한 곳으로 에러를 전파한다.


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

try? 키워드를 사용하면 에러를 옵셔널 값으로 변환하여 처리할 수 있다. 만약 에러가 try? 표현이 평가되는 도중 발생한다면, 그 표현의 결과는 nil이 된다. 예를 들어 다음 코드에서 xy는 같은 값과 행동을 갖고 있다.

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

let x = try? someThrowingFunction()

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

someThrowingFunction()가 발생하면, xynil이 된다. 반대의 경우에는 xy 값은 함수의 반환 값이 된다. xysomeThrowingFunction()가 반환하는 타입의 옵셔널이다. 이 경우에는 함수가 Int를 반환하기 때문에 xyInt?를 반환한다.


try?를 사용하면 모든 에러를 같은 방식으로 처리하고 싶을 때 간결한 에러 처리 코드를 작성할 수 있다. 예를 들어, 아래의 코드는 데이터를 가져오기 위한 몇 가지 접근법을 보여 준다. 접근이 실패하면 nil을 반환한다.

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

에러 전파를 비활성화하기

함수나 메소드가 에러를 발생시키지 않을 것이라 확신하는 경우, try! 키워드를 사용한다. 에러 전파를 비활성화하고, 에러가 발생하지 않도록 runtime assertion으로 호출을 래핑할 수 있다. 실제 에러가 발생할 시 런타임 에러가 나타난다.


다음 코드는 주어진 경로의 이미지 리소스를 로드하거나 경로가 없을 시 에러를 발생시키는 loadImage(atPath:) 함수를 사용한다. 이 경우 이미지는 애플리케이션과 함께 제공되므로 런타임 시 에러가 발생하지 않을 것이라 확신할 수 있다. 때문에 에러 전파를 비활성화하는 것이 적절하다.

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


정리 작업 지정 (Specifying Cleanup Actions)

코드 실행이 현재 코드 블록을 떠나기 직전에 defer문을 사용하여 일련의 구문을 실행할 수 있다. 이 구문은 어떻게 현재 코드 블록을 떠나는지(에러 발생, return, break 등)와 상관없이 실행되어야 하는 모든 필요한 정리를 하도록 한다. 파일 디스크립터를 닫고 수동으로 메모리를 비우기 위해 defer문을 사용할 수 있다.

defer문은 현재 범위가 종료될 때까지 실행을 지연한다. 이 구문은 defer 키워드와 나중에 실행될 구문으로 구성된다. 지여된 구문은 breakreturn, 에러 발생처럼 구문 밖으로 제어를 전달할 코드를 포함하지 않을 수도 있다. 지연된 행동이 여러 개 있는 경우 소스코드에서 작성된 순서의 반대로 실행된다. 첫 번째 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.
    }
}

위의 코드는 open(_:) 함수 이후 close(_:) 함수의 호출을 보장하기 위해 defer문을 사용한다.

코드에 에러 처리가 없어도 defer문을 사용할 수 있다.

0개의 댓글