Objective-C, Swift

(Swift) 에러 핸들링 기법

에러 핸들링은 프로그램 내에서 에러가 발생한 상황에 대해 대응하고 이를 복구하는 과정이다. Swift는 에러를 던지고, 캐치하고, 이전하며 조작할 수 있는 기능을 언어의 기본 기능으로 지원한다.

어떤 동작들은 항상 유용한 결과를 내놓거나 완전하게 실행되는 것이 보장되지 않는다. 결과가 없는 경우에 이를 표현할 수 있는 옵셔널 타입을 사용할 수도 있지만, 처리 자체가 실패하는 경우, 어떤 원인으로 실패하였는지를 알면 코드가 이에 적절히 대응할 수 있도록 할 수 있기에 그 원인을 하는 것은 유용하다.

예를 들어 디스크상의 파일로부터 데이터를 읽고 처리하는 작업을 생각해보자. 여기에는 작업이 실패할 수 있는 경우가 꽤 많다. 주어진 경로에 파일이 존재하지 않거나, 파일을 읽을 수 있는 권한이 없거나 혹은 파일이 호환되는 포맷으로 인코딩 되어 있지 않을 수도 있다. 이런 상황들을 구분하는 것은 프로그램으로 하여금 에러를 스스로 해결하거나, 자동으로 해결할 수 없는 에러에 대해서 사용자에게 보고할 수 있게끔 한다.

Objective-C로 Cocoa를 쓰는 경우에는 NSError를 사용하는 패턴이 이용되며, Swift는 이러한 패턴에도 적절히 대응할 수 있다. 자세한 것은 관련 문서를 참고.

에러를 표현하고 던지기

Swift에서 에러는 Error 프로토콜을 따르는 타입의 값으로 표현된다. 이 프로토콜은 내용이 정의되지 않은 빈 프로토콜로 에러 핸들링에 사용된다는 것만 약속되어 있다.

Swift 열거 타입들은 서로 관련이 있는 일련의 에러 상황들을 모델링하기에 적절하다. 연관값(associated value)은 에러의 성질과 에러와 상호작용하기 위한 여러 정보들을 추가로 덧불일 수 있다. 예를 들어 자판기 내에서 발생할 수 있는 에러에 대해서는 아래와 같이 정의해볼 수 있을 것이다.

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

에러를 던지는 것은 뭔가 예상치 못한 일이 발생했다는 것을 알려주게 되며, 더 이상 정상적인 흐름으로 실행을 계속할 수 없다는 것을 의미한다. 에러를 던지기 위해서는 throw 구문을 사용한다. 아래의 코드는 자판기에서 동전 다섯개가 더 필요하다는 것을 알리면서 에러를 던지는 구문이다.

throw VendingMachineError.insufficientFunds(coinsNeeded: 5)

에러 다루기

에러가 던져지면 그 주위의 코드들은 에러에 대응해야 한다. 문제를 바로잡거나, 다른 접근법을 사용하거나 혹은 실패에 대해 사용자에게 보고하는 등의 조치를 취해야 한다.

에러를 핸들링하는 방법에 있어 Swift에서는 크게 4가지의 전략이 있다. 1) 현재 코드를 호출한 곳으로 에러를 전파한다. 2) do - catch 구문을 이용해서 에러를 처리한다. 3) 에러때문에 얻지 못한 값을 옵셔널 값으로 대치한다. 4) 에러가 발생하지 않도록 사전에 차단한다. 각각의 접근법들은 아래의 절에서 설명할 것이다.

에러를 전파하기

메소드, 함수, 이니셜라이저가 에러를 던질 수 있게 컴파일러에게 알려주려면 throws 키워드를 인자 다음에 추가해서 함수를 선언한다. throws로 표시된 함수들을 throwing functions라고 부른다. 만약 리턴 타입이 존재한다면 -> 앞에 throws를 붙인다.

func canThrowErrors() throws -> String { ... }

func cannotThrowsErrors() -> String { ... }

에러를 던지는 함수들은 내부로 던져진 에러를 다시 호출부로 전파할 수 있다.

에러를 던질 수 있는 함수들만이 에러 전파가 가능하다. 그렇지 않은 함수들은 내부에서 에러를 처리해야만 한다.

아래 예에서 VendingMachine 클래스는 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 coinDeposited = 0

    func vend(itemNamed name: String) throws {
        guard let item = inventory[name] else {
            throw VendingMachineError.invalidSelection
        }
        guard let item.count > 0 else {
            throw VendingMachineError.outOfStock
        }
        guard item.price <= coinDeposited 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:vendingMaching:) 이라는 함수를 작성해보자. 이 함수는 역시 오류를 던질 수 있는 함수이고, 내부에서 오류가 던져지면 이를 처리하지 않고 전파한다.

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:)를 호출한다. 이 메소드는 에러를 던지기 때문에 호출 시에 try를 마크한다.

이니셜라이저도 오류를 전파할 수 있다. 예를 들어 PurchasedSnack 구조체를 아래와 같이 작성한다고 해보자. 초기화 시에 자판기에서 과자를 뽑아야 하는데 이 과정에 에러가 발생할 수 있고, 이를 초기화 메소드가 처리할 필요는 없으므로 에러를 전파하기로 결정했다고 가정한다.

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

Do-Catch 를 써서 에러를 다루기

do - catch 구문을 써서 에러를 다룰 수 있다. do 블럭 내에서 에러가 발생했다면 이는 catch로 전달되는데, catch는 에러에 대한 패턴 매칭을 통해서 에러마다 서로 다른 처리 방식을 택할 수 있다.

do {
  try {expression}
  {statements}
} catch {pattern1} {
  {statements 2}
} catch {pattern2} where {condition} {
  {statements 3}
}

catch 구문이 어떠한 패턴도 가지지 않는다면 해당 구문은 모든 종류의 예외를 잡아서 처리하게 된다.

하지만 do - catch가 항상 모든 예외를 다 처리해야 하는 것은 아니다. 처리할 수 있는 예외들만 처리하고 나머지 에러들은 다시 상위의 레벨로 전파해버릴 수도 있다. 다음의 코드는 자판기에 대한 세 가지 예외는 처리하지만 그 외의 오류는 모두 전파한다.

var vendingMachine = VendingMachine()
vendingMachine.coinsDeposited = 8
do {
  try buyFavoriteSnack(person: "Alice", vendingMachine: vendingMachine)
} catch VendingMachineError.invalidSelection {
  print("Invalid Selection")
} catch VendingMachineError.outOfStock {
  print("Out of stock.")
} catch VendingMachineError.insufficientFunds(let coinsNeeded) {
  print("Insufficient funds. please insert \(coinsNeeded) coins.")
}

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

try? 를 사용하면 에러를 옵셔널 값으로 변환할 수 잇다. 무슨말인고 하니 리턴 값이 있는 throwing funcionse들은 이 구문을 이용해서 옵셔널 값을 리턴하도록 바꿀 수 있다는 뜻이다.

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

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

위 예제에서 try?의 역할은 그 아래의 y가 어떻게 처리되고 있는지에서 드러난다. 두 코드는 완전히 동일하며, try?는 단순한 문법적 설탕인 셈이다. 다음 예제는 디스크와 서버로부터 데이터를 가져오는 함수이다.

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

에러 전파 막기

가끔은 비록 throwing fuction을 호출하더라도 실패하지 않을 것이라는 것을 미리 아는 경우가 있다. 이러한 경우에 대해서 try!를 이용할 수 있다. 이 구문은 단순히 try!로 호출한 값을 언래핑하는 것처럼 보이지만 (결과적으로는 그런 것이니) 실제로는 예외가 발생하지 않는다는 것을 보장하고 해당 함수를 호출하는 것이다. 따라서 판단을 잘못했거나 예측하지 못했던 원인으로 에러가 발생한 경우에 즉시 런타임 에러를 맞게 된다.

아래의 코드는 경로로부터 이미지를 로드하는데, 예외가 발생하지 않을 것이라 예측하고 try!를 썼다.

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

이 때 경로는 애플리케이션의 리소스 디렉토리이고 이미지는 애플리케이션과 함께 배포될 것이므로 실패하지 않는다고 봐도 무방한 것이다.

클린업

defer 구문을 사용하여 현재 코드 블럭에서 실행흐름이 벗어날 때 수행해야 할 일을 지정할 수 있다. defer는 실행 흐름이 어떻게 벗어나는지 그 과정을 구분하지 않는다. return, break 혹은 예외로 인해 강제로 벗어나는 경우에 조차 동작한다. 따라서 열었던 파일이나 소켓을 닫거나, 수동으로 직접 할당한 메모리를 정리하는 등의 코드를 이곳에 넣을 수 있다.

func processFile(filename: String) throws {
  if exists(filename) {
    let file = open(filename)
    defer {
      close(file)
    }
    while let line = try file.readline() {
      /// work with line
    }
  }
}