[Swift] Swift는 에러를 어떻게 처리할까? (Error Handling)

2024. 2. 13. 23:25Swift, iOS Foundation

오늘은 Swift에서 에러를 처리하는 방법, Error Handling에 대해 배워보도록 하겠다.

모든 프로그래밍 언어가 에러를 처리하는 방법을 가지고 있듯이, Swift도 마찬가지로 프로그램에서 발생한 에러 조건에 응답하고, 처리하는 프로세스를 갖고 있다.

공식문서 상에서는 이것을 "Swift는 런타임 에러를 던지고(throwing), 잡고(catching), 전파하고(propragating), 조작하는(manipulating) 방법을 모두 지원한다"라고 표현했다.

에러를 "던지고", "잡고", "전파"하고, "조작"한다는 말이 혹시 어렵지는 않은가...? (나만 그런가)

내가 공부를 했을 때, Swift 공식문서를 보고 공부하기에는 번역상으로 이해가 안 가는 말이 많아서 많은 어려움을 겪었는데, 이번에는 나의 방식대로 Swift에서는 어떻게 에러를 발생시키고, 처리하는지에 대한 구체적인 방법을 정리해보려고 한다!

 

1. 에러를 던지다 (throw)


"에러를 던진다"는 말을 들어본 적이 있는가...?

나는 처음에 에러를 던진다고 해서, 도대체 어디다가 에러를 던진다는 건지, 언제 에러를 던지는 건지 도무지 하나도 이해가 가지 않았었다...

사실, 에러를 던진다는 말을 "에러를 발생시킨다", "프로그램에서 예상치 못한 상황이 발생했음을 표현하다" 정도로 표현했더라면 쉽게 이해할 수 있었을텐데, 이 에러를 발생시킬 때, throw라는 명령어를 사용하기 때문에 "에러를 던지다"라는 표현이 굳어진 듯했다.

이제 앞으로 에러를 던지다라는 말을 "에러를 발생시킨다"라는 말로 바꿔서 이해해보자.
그럼 에러를 언제 던지는 걸까? (= 언제 에러를 발생시키는 걸까?)

한번, 예시를 들어보겠다.

1번부터 10번까지 상품을 선택할 수 있는 자판기가 하나 있다고 가정해보자.
사용자가 자판기에 원하는 상품에 맞는 돈을 넣고, 상품 번호를 입력하면, 그 번호에 맞는 상품을 제공하는 것이 우리가 생각하는 자판기 작동의 정상적인 실행 프로세스이다.

그런데 만약, 사용자가 돈을 부족하게 넣은 상태로 번호를 입력한다면? 사용자가 상품을 제공하지 않는 잘못된 번호를 입력했다면? 혹은 정확한 금액과 번호를 입력(Input)했어도, 그 상품에 대한 재고가 자판기에 부족하다면?
예상치 못한 상황에 자판기 프로그램은 당황(?)하고 말 것이다.
여기서 말을 순화해서 당황이라고 말했지만, 심하게는 그 프로그램의 작동이 완전히 멈춰버릴 수도 있는 상황일 것이다.

이런 경우를 대비하기 위해, 바로 에러를 발생시키는 것이다!

에러는 그냥 아무거나 막 던지는 것은 아니고, 이미 코드 상에서 에러 상황들을 표현(마련)해두고 그 상황을 발생시키는 것이다. (이미 짜여진 각본이랄까...?)
그렇기 때문에, 우리들은 에러를 발생시키는 것을 배우기 이전에 에러를 표현하는 방법부터 알아야 한다.

Swift에서는 에러를 열거형(enum)과 Error 프로토콜을 준수하는 타입의 값으로 표현한다.

위의 예를 들어, 자판기를 동작한다고 가정했을 때 발생하는 에러를 표현하는 방법은
VendingMachineError라는 열거형 타입 안에, 발생할 수 있는 에러의 경우(case)를 모두 작성하게 되는 것이다.

(여기서는 case가 유효하지 않은 Selection을 했을 경우, 부족한 돈이 들어오는 경우, 재고가 없는 경우 등이 들어갈 수 있겠다.)

// 사용 방법
enum 에러 종류 이름: Error {
    case 에러 타입1
    case 에러 타입2
    case 에러 타입3
}

// 자판기 에러 예제
enum VendingMachineError: Error {
    case invalidSelection
    case insufficientFunds(coinsNeeded: Int)
    case outOfStock
}

이렇게 위에서 열거형과 Error 프로토콜을 이용해 에러가 발생하는 경우를 만들어주었으니,
이제 실제로 어떻게 에러를 발생시킬 차례이다.

Swift에서는 throw라는 키워드를 사용해 에러를 발생시킨다.

아래와 같이 에러를 발생시키면,
컴파일러는 예상치 못한 상황이 발생했다는 것을 알아차리고 정상적인 작동 흐름이 진행될 수 없다는 것을 눈치채게 된다.

// 사용 방법
throw 에러 종류 이름.에러 타입

// 자판기 에러 예제
throw VendingMachineError.insufficientFunds(coinsNeeded: 5)

하지만, 당연히 여기서 끝나지는 않는다.

에러를 발생시키는 이유는 결국은, 그 에러를 어떻게든 문제를 해결하던지, 다른 방법으로 시도를 해본다던지, 에러를 알라고 다른 대안을 제안하던지 등의 작업으로 유도하기 위함인데,
이 내용을 바로 에러 처리(Handling Error)라고 부르는 것이다.

Swift에서는 에러를 처리하기 위한 4가지의 방법을 소개하고 있다. 아래에서 하나씩 살펴보도록 하자.

 

2. 에러를 전파하다 (propagate)


위에서는 순서상으로 잡다(catch)가 더 먼저 위치해 있지만, 설명 편의상 전파하다(propagate) 부분을 먼저 설명하도록 하겠다.

말이 어렵지만, 첫 번째 에러 처리 방법은 단순하게 throws라는 키워드를 사용하는 방식이다.

함수의 매개변수 뒤에 throws라는 키워드가 붙어 있다면,
이 함수는 "에러가 발생할 가능성이 있는 함수입니다"라는 뜻을 가지고 있는 함수라고 보면 된다.

이렇게 throws가 붙어 있는 함수를 Swift에서는 throwing function이라고 부른다.
이 throwing function이 내부에서 발생하는 에러를 호출된 범위로 전파하는 기능을 갖고 있다.

즉 다시 말해, 여기에서 에러를 던진다(throws)는 뜻은 "에러를 처리해주는 곳으로 전달한다"라는 실질 의미를 갖고 있는 것이고 이를 영문 번역상에서는 전파하다, propagate라고 표현한 것이다.
// 에러가 발생할 가능성이 없는 함수
func cannotThrowErrors() -> String { }

// 에러가 발생할 가능성이 있는 함수 (throwing function) -> return 타입이 Void일 경우
func canThrowErrors() throws { }

// 에러가 발생할 가능성이 있는 함수 (throwing function) -> return 타입이 있을 경우
func canThrowErrors() throws -> String { }

아래 코드를 보면서 더 자세하게 이해해보자.

우선, VendingMachine이라는 클래스에는 vend라는 내부 메서드가 있다.

이 메서드에는 throws가 붙어있는 것으로 보아, 에러가 발생할 가능성이 있는 부분이고, 실제로 자판기에서 발생할 수 있는 에러들을 guard-let문을 사용해서 처리한 것을 확인할 수 있다.

만약 이 코드 guard-let문에서, 에러 쪽으로 빠지게 된다면, 그대로 이 함수에서 에러를 발생시키고 함수를 탈출할 수 있을 것이다.

이처럼 함수 내에서 return처럼 에러를 함수를 종료시키도록 나누는 방법이 propagate 방법이다.

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 {
    	// inventory에 있는 item을 고른 적절한 Selection인지 판단
        guard let item = inventory[name] else {
            throw VendingMachineError.invalidSelection
        }

        // inventory에 있는 item의 재고(count)가 1개 이상 있는지 확인
        guard item.count > 0 else {
            throw VendingMachineError.outOfStock
        }

        // coinsDeposited에 들어온 돈이 item의 가격 이상으로 들어왔는지 확인
        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라는 위의 메서드를 사용할 때는

에러가 발생할 가능성이 있기 때문에 try라는 키워드를 사용해서 호출해야 한다. (뒤에서 배울 키워드를 사용해도 가능)

우선 아직까지는 try 키워드는 정말 말 그대로, "에러가 발생할 수 있지만 시도하겠다"라는 뜻으로 호출을 할 때 사용을 하는 녀석이라고 생각해주자.

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

 

3. 에러를 잡다 (catch)


두 번째 방법은 Do-Catch문을 사용해서 에러를 처리하는 방법이다.

생각보다 여기서는 사용하는 키워드의 이름이 직관적이어서 해석 그대로 설명을 해보자면,

do 어떤 것을 하는데
-> try 에러가 발생할 수도 있는 시도를
-> catch 어떤 에러인지 잡아보자!"의 순서로 진행된다고 이해를 해볼 수 있다.

다시 말해, 위에서 설명했던 것처럼 try를 이용해서 에러가 발생할 수 있는 상황에 대한 명령을 내렸을 때,

각 에러의 case(에러가 발생할 수 있는 상황)를 catch 구문 뒤에 명시를 해줘서 처리해줄 코드를 추가로 작성해주면 되는 것이다.

case가 여러 개이기 때문에 switch 구문을 사용하는 것도 가능하고,
각 case를 나눠줄 필요가 없을 경우에는 catch 구문을 간략하게 설정하는 것도 가능하고, 상황에 따라 catch 키워드를 아예 제거하는 것도 모두 가능하다고 한다!

// 사용 방법
do {
    try expression
    statements
} catch pattern 1 {
    statements
} catch pattern 2 where condition {
    statements
} catch {
    statements
}

// 자판기 에러 예제
do {
    try machine.receiveMoney(0)
} catch VendingMachineError.invalidInput {
    print("입력이 잘못되었습니다")
} catch VendingMachineError.insufficientFunds(let moneyNeeded) {
    print("\(moneyNeeded)원이 부족합니다")
} catch VendingMachineError.outOfStock {
    print("수량이 부족합니다")
}

 

4. 에러를 조작하다(manipulate)


추가적으로 에러를 처리하는 나머지 두 방법들을 알아보려고 한다.

예전부터 느끼는 거지만, Swift에서는 ?와 ! 키워드를 아주 명확하게 구분되어서 사용하고 있다.
불확실성(값의 존재 유무, 타입의 변환 가능 유무)을 나타낼 때는 ?를, 강제성을 나타낼 때는 !를 사용했는데, 이번 오류처리도 역시 마찬가지다.

try? 키워드는 오류가 발생할 수도 있고, 아닐 수도 있는 옵셔널과 같은 의미를 지니고 있으며,
오류가 발생했을 경우에는 nil을, 오류가 발생하지 않을 때는 Optional Value를 반환한다.

result = try? machine.vend(numberOfItems: 2)
result // Optional("2개 제공함")

result = try? machine.vend(numberOfItems: 2)
result // nil

try! 키워드는 절대 오류가 발생하지 않을 것이라는 "강한 확신"을 의미한다.

혹시나 오류가 발생한다면?
런타임 에러가 발생하여, 프로그램 자체가 멈출 수 있으니.
되도록이면 Swift에서는 !의 사용은 자제하는 것이 좋다는 거 잊지 않도록 하자.

result = try! machine.vend(numberOfItems: 1)
result // 1개 제공함

result = try! machine.vend(numberOfItems: -1)
result // 런타임 오류 발생! 프로그램 중지