🔥 오류 처리하기

1265자
15분

오류가 던져지면, 그 오류를 처리할 책임이 있는 코드가 있어야 합니다. 예를 들어, 문제를 고치거나, 다른 방법을 시도하거나, 사용자에게 실패를 알리는 식으로요.

Swift에서 오류를 처리하는 방법에는 네 가지가 있습니다.

  1. 함수에서 오류를 그 함수를 호출한 코드로 전파하기
  2. do``catch 문을 사용해 오류 처리하기
  3. 옵셔널 값으로 오류 처리하기
  4. 오류가 발생하지 않을 것이라고 단언하기

오류를 던지는 함수는 프로그램의 흐름을 바꾸기 때문에, 코드 중 어디에서 오류를 던질 수 있는지 빨리 파악하는 것이 중요합니다. 이를 위해 오류를 던질 수 있는 함수, 메서드, 이니셜라이저를 호출하는 코드 앞에 try 키워드 (또는 try?, try! 변형)를 씁니다.

던지는 함수를 사용해 오류 전파하기

함수, 메서드, 이니셜라이저가 오류를 던질 수 있음을 나타내려면, 함수 선언부에서 매개변수 뒤에 throws 키워드를 씁니다. throws로 표시된 함수를 throwing function이라고 합니다. 함수가 반환 타입을 지정한 경우, throws 키워드를 반환 화살표(->) 앞에 씁니다.

func canThrowErrors() throws -> String
 
func cannotThrowErrors() -> String
swift

throwing function은 그 안에서 던져진 오류를 자신이 호출된 영역으로 전파합니다.

아래 예제에서, 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)")
    }
}
swift

vend(itemNamed:) 메서드의 구현은 guard 문을 사용해 스낵 구매 요건 중 하나라도 충족되지 않으면 일찍 메서드를 종료하고 적절한 오류를 던집니다. throw 문은 즉시 프로그램 제어를 전달하므로, 모든 요건이 충족될 때만 항목이 판매됩니다.

vend(itemNamed:) 메서드는 자신이 던지는 모든 오류를 전파하므로, 이 메서드를 호출하는 모든 코드는 do-catch 문, try?, try!를 사용해 오류를 처리하거나 계속 전파해야 합니다. 예를 들어, 아래 예제의 buyFavoriteSnack(person:vendingMachine:)도 throwing function이며, 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)
}
swift

이 예제에서, 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
    }
}
swift

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#>
}
swift

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).")
}
// "Insufficient funds. Please insert an additional 2 coins." 출력
swift

위 예제에서, buyFavoriteSnack(person:vendingMachine:) 함수는 오류를 던질 수 있기 때문에 try 표현식에서 호출됩니다. 오류가 던져지면, 실행은 즉시 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)")
}
// "Couldn't buy that from the vending machine." 출력
swift

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.")
    }
}
swift

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
}
swift

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
}
swift

오류 전파 비활성화하기

때로는 던지는 함수나 메서드가 실제로는 런타임에 오류를 던지지 않을 것임을 알고 있을 때가 있습니다. 그런 경우, 표현식 앞에 try!를 써서 오류 전파를 비활성화하고 오류가 던져지지 않을 것이라는 런타임 어설션으로 호출을 감쌀 수 있습니다. 실제로 오류가 던져지면, 런타임 오류를 얻게 됩니다.

예를 들어, 다음 코드는 주어진 경로에 이미지 리소스를 로드하거나 이미지를 로드할 수 없으면 오류를 던지는 loadImage(atPath:) 함수를 사용합니다. 이 경우, 이미지가 애플리케이션과 함께 제공되므로, 런타임에 오류가 던져지지 않을 것이므로 오류 전파를 비활성화하는 것이 적절합니다.

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

이렇게 try!를 사용하면 오류 전파 로직을 제거하여 코드를 더 간결하고 읽기 쉽게 만들 수 있습니다. 하지만 try!는 신중하게 사용해야 합니다. 오류가 실제로 던져질 가능성이 있다면, 오류 전파를 비활성화하는 것은 잠재적인 버그와 크래시의 원인이 될 수 있기 때문이죠.

이로써 오류를 처리하는 네 가지 주요 방법에 대해 살펴보았습니다:

  1. 오류를 전파하기
  2. do``catch로 처리하기
  3. 옵셔널로 변환하기
  4. 오류 전파 비활성화하기

실제 개발에서는 상황에 따라 적절한 방법을 선택하는 것이 중요합니다. 오류 처리는 강건하고 오류에 대응할 수 있는 코드를 작성하는 데 필수적이에요. 오류 처리를 잘 활용하면 사용자 경험을 향상시키고 애플리케이션의 안정성을 높일 수 있습니다.

다음 섹션에서는 오류를 정의하고 사용하는 더 발전된 기술에 대해 알아보겠습니다. 커스텀 오류 타입을 정의하는 방법, defer 문을 사용하여 정리 코드를 작성하는 방법 등 유용한 주제가 남아 있으니 계속해서 살펴봅시다!