본문 바로가기
프로그래밍/Swift

[Swift] Error Handling

by 별준 2022. 3. 8.

References

Contents

  • Representing and Throwing Erros
  • Handling Errors
  • Specifying Cleanup Actions

Error Handling은 프로그램에서 에러 조건들에 대해 대응하고 회복하는 프로세스입니다. Swift는 런타임에서 에러가 발생한 경우 해당 에러를 처리하기 위해 throwing, catching, propagting, manipulating을 지원하는 일급 클래스(first-class)를 제공합니다.

 

어떠한 명령은 항상 실행을 완료하거나 유용한 결과를 생성한다고 보장하지 않습니다. 옵셔널은 값이 존재하지 않음을 표현하는데 사용할 수 있지만, 명령이 실패할 때는 어떠한 원인으로 실패했는지 이해하는 것이 유용하며, 이를 통해 적절한 처리를 할 수 있습니다.

 

예를 들어, 디스크에서 파일을 읽어 데이터를 처리하는 작업을 한다고 가정해봅시다. 이 작업이 실패할 경우는 파일이 존재하지 않거나, 읽기 권한이 없거나 또는 파일의 데이터가 적절한 포맷으로 인코딩되지 않는 등 여러 가지가 있을 수 있습니다. 이러한 에러들을 식별하여 사용자에게 제공해주면 프로그램 실행 중에 발생한 각 에러를 사용자가 적절하게 대응하도록 도와줄 수 있습니다.

Swift의 에러 핸들링은 Cocoa와 Objective-C의 NSError 클래스와 상호호환되는 에러 핸들링 패턴을 사용합니다.

 


Representing and Throwing Errors

Swift에서 에러는 Error 프로토콜을 준수하는 타입의 값으로 표현됩니다. 이 빈 프로토콜은 해당 타입이 에러 핸들링에 사용된다는 것을 가리킵니다.

 

Swift의 열거형은 특히 에러 조건과 관련된 그룹을 모델링하는데 적합하며, 그 값들로 에러에 특성에 대한 부가적인 정보들로 커뮤니케이션할 수 있도록 합니다. 예를 들어, 아래 코드처럼 게임 내에서 자판기 동작의 에러 조건들을 표현할 수 있습니다.

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

에러를 던지는 것(throwing)은 예기치 못한 무언가가 발생했고 일반적인 실행 흐름을 계속할 수 없다는 것을 가리킵니다. throw문을 사용하여 에러를 발생시킬 수 있습니다. 예를 들어, 다음 코드는 자판기에서 5개의 코인이 더 필요하다는 것을 가리키는 에러를 발생시킵니다.

throw VendingMachineError.insufficientFunds(coinsNeeded: 5)

 


Handling Errors

에러가 던져졌을 때, 주변의 코드는 문제를 바로잡거나, 대안 방안을 시도하거나 또는 유저에게 실패를 알리는 등 반드시 에러를 처리하기 위해 대응해야 합니다.

Swift에서는 에러를 처리하기 위한 4가지 방법이 있습니다. 어떤 함수에서 발생한 에러를 이 함수를 호출한 코드로 전파(propagate)하거나, do-catch문을 사용하여 에러를 처리하거나, 에러를 옵셔널 값으로 처리하거나, 또는 에러가 발생하지 않았다고 처리할 수 있습니다. 각 방법들은 아래에서 하나씩 살펴보겠습니다.

 

함수가 에러를 발생시킬 때, 이는 프로그램의 흐름을 변경시키며, 따라서 에러를 발생시킬 수 있는 코드의 위치를 빠르게 식별할 수 있는 것이 중요합니다. 코드에서 이러한 위치를 식별하기 위해서는 try 키워드(또는 try? or try!)를 함수나 메소드 또는 이니셜라이저를 호출하는 코드 전에 사용합니다. 이 키워드도 아래에서 살펴보겠습니다.

Swift에서의 에러 처리는 다른 언어에서 try-catch, throw 키워드를 사용하는 exception 처리와 유사합니다. Objective-C를 포함하여 다른 언어의 exception 처리와 다른점은 Swift에서 에러 처리를 많은 계산이 필요할 수 있는 Call Stack Unwinding(스택 쓸기)와 관련이 없다는 것입니다. 그렇기 때문에 에러를 반환하는 throw문은 일반적인 return과 비슷한 성능을 보여줍니다.

 

Propagating Erros Using Throwing Functions

함수, 메소드, 또는 이니셜라이저가 에러를 발생(throw)시킬 수 있다는 것을 알리기 위해서, 함수 선언부의 파라미터 뒤에 throws 키워드를 붙여주면 됩니다. throws 키워드가 붙은 함수는 throwing function이라고 부릅니다. 만약 함수의 리턴 타입이 지정되어 있다면 throw 키워드는 리턴 화살표(->) 전에 붙여줍니다.

func canThrowErrors() throws -> String

func cannotThrowErrors() -> String

throwing 함수는 함수 내부에서 에러를 던져서 함수가 호출된 곳으로 전달합니다.

오직 throwing function만이 에러를 전달할 수 있습니다. nonthrowing 함수 내에서 발생된 에러는 반드시 그 함수 내부에서 처리되어야 합니다.

아래 예제 코드에서 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:) 메소드는 이 메소드가 던진 에러들을 전달하기 때문에 이 메소드를 호출한 어떤 코드는 만드시 에러를 처리(do-catch문 또는 try?, try!를 사용)하거나 이를 계속해서 전달해야 합니다. 예를 들어, 아래 코드의 buyFavoriteSnack(person:vendingMachine:) 또한 에러를 발생시키는 함수인데 vend(itemNated:) 메소드에서 발생한 에러는 buyFavoriteSnack 함수가 실행되는 곳까지 전달됩니다.

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 함수는 주어진 사람의 가장 좋아하는 스택이 무엇인지 확인하고 이를 vend(itemNamed:) 메소드에 전달해 호출하여 구매를 시도합니다. vend 메소드는 에러를 발생시킬 수 있기 때문에 앞에 try 키워드와 함께 호출하고 있습니다.

 

throwing 이니셜라이저도 throwing 함수와 동일한 방법으로 에러를 전달할 수 있습니다. 예를 들어, 아래 코드에서 PurchasedSnact 구조는 초기화 단계 중에 throwing 함수를 호출하고, 발생된 에러는 이 이니셜라이저를 호출한 곳으로 전달됩니다.

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

 

Handling Errors Using Do-Catch

do-catch을 사용하여 에러를 처리하는 코드 블록을 작성할 수 있습니다. 만약 에러가 do절 안에서 코드에 의해 발생한다면, catch절 중에서 일치하는 것을 결정해 에러를 처리할 수 있습니다.

다음은 do-catch문의 일반적인 형태입니다.

catch문 뒤에서 처리할 수 있는 에러를 적는 패턴을 작성할 수 있습니다. 만약 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 함수는 에러를 발생시킬 수 있기 때문에 try 표현식 내에서 호출됩니다. 만약 에러가 발생되면, 실행은 즉시 catch절로 이동하여 전파를 계속 허용할지 여부를 결정합니다. 만약 어떠한 패턴도 매칭되지 않으면 에러는 마지막 catch절에서 잡히고 로컬 error 상수로 바인딩됩니다. 만약 어떠한 에러도 발생되지 않으면 do문에 남아있는 문장들이 실행됩니다.

 

catch절은 do절에서 발생될 수 있는 모든 가능한 에러를 처리하지 않아도 됩니다. 만약 에러를 처리하는 catch절이 없다면 에러는 주변 스코프로 전파됩니다. 그러나 전파된 에러는 반드시 주변 스코프 무언가에 의해 처리되어야 합니다. nonthrowing 함수에서는 do-catch문에서 반드시 에러를 처리해야 합니다. throwing 함수에서는 do-catch문이나 이 함수를 호출한 측에서 에러를 처리해야 합니다. 만약 에러를 처리하지 않고 top-level 스코프까지 에러가 전파된다면, 런타임 에러가 발생합니다.

 

예를 들어, 아래 코드는 VendingMachineError가 아닌 모든 에러가 대신 호출된 함수에 의해 catch되도록 작성되었습니다.

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하는 에러를 나열하고 있습니다. 만약 리스트된 3개의 에러 중 하나가 발생한다면 이 catch절은 메세지를 출력하는 것으로 에러를 처리합니다. 다른 에러는 주변 스코프로 전파됩니다.

 

Converting Errors to Optional Values

try?를 사용하여 이를 옵셔널 값으로 바꿔 에러를 처리할 수 있습니다. 만약 try? 표현식을 평가하는 동안 에러가 발생되었다면 그 표현식의 값은 nil이 됩니다. 예를 들어, 아래 예제 코드에서 x와 y는 같은 값을 가지며, 동작도 같습니다.

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

let x = try? someThrowingFunction()

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

만약 someThrowing() 함수가 에러를 발생시키면 x와 y의 값은 nil이 됩니다. 만약 에러를 발생시키지 않는다면, x와 y의 값은 함수가 리턴하는 값이 됩니다. someThrowingFunction()의 리턴 타입이 무엇이든 간에 x와 y는 옵셔널입니다. 여기서는 Int를 반환하므로 x와 y는 Int? 입니다.

 

모든 에러를 동일한 방식으로 처리하고 싶을 때 try?를 사용하면 코드를 간결하게 작성할 수 있습니다. 예를 들어, 다음 코드는 데이터를 가지오는데 여러 방법을 사용하는데, 모든 방법이 실패하면 nil을 리턴합니다.

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

 

Disabling Error Propagation

때때로 throwing 함수나 메소드가 런타임에 에러를 발생시키지 않는다는 것을 알고 있을 수가 있습니다. 이런 상황에서 표현식 전에 try!를 사용하여 에러 전파를 비활성화할 수 있습니다. 하지만 만약 에러가 실제로 발생한다면 런타임 에러가 발생합니다.

 

예를 들어, 다음 코드는 loadImage(atPath:) 함수를 사용하는데, 이 함수는 주어진 경로에서 이미지 리소스를 읽어들이거나 만약 이미지가 로드되지 않으면 에러를 발생시킵니다. 이 경우 어플리케이션에 이미지가 존재하기 때문에 런타임에 에러가 발생하지 않을 것이고 따라서 에러 전파를 비활성화하는 것이 적절합니다.

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

 


Specifying Cleanup Actions

defer문을 사용하면 현재 코드 블록 실행을 종료하기 전에 실행되는 일련의 문장들을 실행시킬 수 있습니다. 이 문장은 코드 실행이 현재 코드 블록을 떠나는 이유와는 관계없이 수행되어야 하는 필요한 cleanup을 수행할 수 있습니다. 예를 들면, defer문을 사용하여 파일 descriptor를 닫고, 수동으로 할당된 메모리를 해제되도록 할 수 있습니다.

 

defer문은 현재 스코프가 종료될 때까지 실행을 연기합니다. 이 문장은 defer 키워드와 나중에 실행된 문장들로 구성됩니다. 이렇게 연기된 문장들에는 break나 return문과 같이 문장의 외부로 컨트롤을 전달하는 코드나 에러를 던지는 코드가 포함될 수 없습니다. 이렇게 지연된 작업은 소스 코드에 작성된 순서의 역순으로 실행됩니다. 즉, 첫 번째 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문은 에러 처리 코드 이외에도 사용될 수 있습니다.

 

'프로그래밍 > Swift' 카테고리의 다른 글

[Swift] Automatic Reference Counting (ARC)  (0) 2022.03.10
[Swift] Opaque Types  (0) 2022.03.09
[Swift] Generics (제네릭)  (0) 2022.03.07
[Swift] Protocols (2)  (0) 2022.02.05
[Swift] Protocols (1)  (0) 2022.02.05

댓글