본문 바로가기
iOS/SWIFT

[Swift] Chapter 28. 오류처리

by 원만사 2021. 10. 24.
반응형

스위프트가 제공하는 오류처리 기능에 대해 알아보자.

 

28.1 오류처리란


오류처리(Error Handling)는 프로그램이 오류를 일으켰을 때 이것을 감지하고 회복시키는 일련의 과정이다.

오류처리 기능을 통해 프로그램 자체적으로 오류를 해결할 수도 있고, 사용자와 상호작용을 통해 오류를 어떤 방향으로 풀어나갈지 제어할 수도 있다.

 

28.2 오류의 표현


스위프트에서 오류는 Error라는 프로토콜을 준수하는 타입의 값을 통해 표현된다. Error 프로토콜은 사실상 요구사항이 없는 빈 프로토콜일 뿐이지만, 오류를 표현하기 위한 타입(주로 열거형)은 이 프로토콜을 채택한다.

스위프트의 열거형은 오류의 종류를 나타내기에 아주 적합한 기능이다. 연관 값을 통해 오류에 관한 부가 정보를 제공할 수도 있다. [코드 28-1]은 프로그램 내에서 자판기를 작동시키려고 할 때 발생하는 오류의 종류를 열거형으로 표현한 것이다.

 

/*
	코드 28-1. 자판기 동작 오류의 종류를 표현한 VendingMachineError 열거형
*/ 

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

 

VendingMachineError 열거형을 살펴보면 Error 프로토콜을 채택함으로써 오류처리를 위한 타입임을 알 수 있다. 오류의 종류는 총 세 가지가 존재하고 있다. 이처럼 열거형을 통해 오류의 종류를 표현하는 것이 가장 일반적이며 편리한 방법이다.

이렇게 오류의 종류를 미리 예상한 다음, 오류 때문에 다음에 행할 동작이 정상적으로 진행되지 않을 때라면 오류를 던져(Throw Error)주면 된다. 오류를 던져줄 때는 throw 구문을 사용한다. 만약 자금이 부족하고 동전이 5개 더 필요한 상황이라면 throw VendingMachineError.insufficiendFunds(coinsNeeded: 5)라고 오류를 던져줄 수 있다.

 

28.3 오류 포착 및 처리


스위프트에는 오류를 처리하기 위한 네 가지 방법이 있다.

  • 함수에서 발생한 오류를 해당 함수를 호출한 코드에 알리는 방법
  • do-catch 구문을 이용하여 오류를 처리하는 방법
  • 옵셔널 값으로 오류를 처리하는 방법
  • 오류가 발생하지 않을 것이라고 확신하는 방법

각각의 방법에 대해 알아보자.

 

28.3.1 함수에서 발생한 오류 알리기

함수가 오류를 던지면 프로그램의 흐름이 바뀔 가능성이 매우 크다. 그러므로 어디서 오류를 던지고 받을 것인가 결정하는 것은 매우 중요하다. try 키워드로 던져진 오류를 받을 수 있다. try 키워드는 try 또는 try?, try! 등으로 표현할 수 있다.

함수, 메서드, 이니셜라이저의 매개변수 뒤에 throws 키워드를 사용하면 해당 함수, 메서드, 이니셜라이저는 오류를 던질 수 있다. 이런 함수는 호출했을 때, 동작 도중 오류가 발생하면 자신을 호출한 코드에 오류를 던져서 오류를 알릴 수 있다.

 

TIP
throws는 함수나 메서드의 자체 타입에도 영향을 미친다. 즉, throws 함수나 메서드는 같은 이름의 throws가 명시되지 않는 함수나 메서드와 구분된다. 또, throws를 포함한 함수, 메서드, 이니셜라이저는 일반 함수, 메서드, 이니셜라이저로 재정의할 수 없다. 반대로 일반 함수, 메서드, 이니셜라이저는 throws 함수, 메서드, 이니셜라이저로 재정의할 수 있다.

 

/*
	코드 28-2. 자판기 동작 도중 발생한 오류 던지기
*/

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

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),
        "Biscuit": Item(price: 7, count: 11)
    ]
    
    var coinsDeposited = 0
    
    func dispense(snack: String) {
        print("\(snack) 제공")
    }
    
    func vend(itemNamed name: String) throws {
        guard let item = self.inventory[name] else {
            throw VendingMachineError.invalidSelection
        }
        
        guard item.count > 0 else {
            throw VendingMachineError.outOfStock
        }
        
        guard item.price <= self.coinsDeposited else {
            throw VendingMachineError.insufficientFunds(coinsNeeded: item.price - self.coinsDeposited)
        }
        
        self.coinsDeposited -= item.price
        
        var newItem = item
        newItem.count -= 1
        self.inventory[name] = newItem
        
        self.dispense(snack: name)
    }
}

let favoriteSnacks = [
    "yagom": "Chips",
    "jinsung": "Biscuit",
    "heejin": "Chocolate"
]

func buyFavoriteSnack(person: String, vendingMachine: VendingMachine) throws {
    let snackName = favoriteSnacks[person] ?? "Candy Bar"
    try vendingMachine.vend(itemNamed: snackName)
}

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

let machine: VendingMachine = VendingMachine()
machine.coinsDeposited = 30

var purchase: PurchasedSnack = try PurchasedSnack(name: "Biscuit", vendingMachine: machine)
// Biscuit 제공

print(purchase.name) // Biscuit

for (person, favoriteSnack) in favoriteSnacks {
    print(person, favoriteSnack)
    try buyFavoriteSnack(person: person, vendingMachine: machine)
}

// Biscuit 제공
// Biscuit
// yagom Chips
// Chips 제공
// jinsung Biscuit
// heejin Chocolate

// 오류 발생!!

 

[코드 28-2]의 자판기 VendingMachine 클래스는 오류를 던질 수 있는 vend(itemNamed:) 메서드가 있다. 메서드 내부에서는 오류가 발생했을 때 흐름을 제어하기 위해 guard를 통한 빠른 종료 구문을 사용한다. 특정 조건이 충족되지 않는다면 throws 키워드를 통해 오류를 던져서 오류가 발생했다는 것을 자신을 호출한 코드에게 알린다.

또한 vend(itemNamed:) 메서드가 오류를 던질 가능성이 있으므로, vend(itemNamed:) 메서드를 호출하는 함수 또한 오류를 던질 수 있어야 한다. 그래서 buyFavoriteSnack(person:vendingMachine:) 함수도 throws 키워드를 통해 오류를 던질 수 있는 함수로 구현해주어야 한다.

오류를 던질 수 있는 함수, 메서드, 이니셜라이저를 호출하는 코드는 반드시 오류를 처리할 수 있는 구문을 작성해주어야 한다. 그러나 [코드 28-2]에는 오류가 발생할 수 있는 메서드와 함수를 호출하면서도 try로 시도만 할 뿐 오류가 발생했을 때 처리할 수 있는 코드는 작성되어 있지 않다.

이렇게 발생한 오류는 자신을 호출한 코드로 던져서 알릴 수는 있지만, 오류를 받은 코드가 적절히 오류를 처리해주지 않는다면 이후의 코드는 작동하지 않는다. 던져진 오류를 처리하는 방법에 대해 알아보자.

 

28.3.2 do-catch 구문을 이용하여 오류처리

함수, 메서드, 이니셜라이저등에서 오류를 던져주면 오류 발생을 전달받은 코드 블록은 do-catch 구문을 사용하여 오류를 처리해주어야 한다. do절 내부의 코드에서 오류를 던지면 catch 절에서 오류를 전달받아 적절히 처리해주면 된다.

do-catch 구문은 보통 다음처럼 표현한다.

do {
	try 오류 발생 가능코드
	오류가 발생하지 않으면 실행할 코드
} catch 오류 패턴 1 {
	처리 코드
} catch 오류 패턴 2 where 추가 조건 {
	처리 코드
}

 

catch 절에서는 catch 키워드 뒤에 처리할 오류의 종류를 써준다. 만약 catch 뒤에 오류의 종류를 명시하지 않고 코드 블록을 생성하면 블록 내부에 암시적으로 error라는 이름의 지역 상수가 오류의 내용으로 들어온다.

 

/*
	코드 28-3. do-catch 구문을 사용하여 던져진 오류를 처리
*/

func buyFavoriteSnack(person: String, vendingMachine: VendingMachine){
    let snackName = favoriteSnacks[person] ?? "Candy Bar"
    tryingVend(itemNamed: snackName, vendingMachine: vendingMachine)
}

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

func tryingVend(itemNamed: String, vendingMachine: VendingMachine) {
    do {
        try vendingMachine.vend(itemNamed: itemNamed)
    } catch VendingMachineError.invalidSelection {
        print("유효하지 않은 선택")
    } catch VendingMachineError.outOfStock {
        print("품절")
    } catch VendingMachineError.insufficientFunds(let coinsNeeded) {
        print("자금 부족 - 동전 \(coinsNeeded)개를 추가로 지급해주세요")
    } catch {
        print("그 외 오류 발생 : ", error)
    }
}

let machine: VendingMachine = VendingMachine()
machine.coinsDeposited = 20

var purchase: PurchasedSnack = try PurchasedSnack(name: "Biscuit", vendingMachine: machine)
// Biscuit 제공

print(purchase.name) // Biscuit

purchase = try PurchasedSnack(name: "Ice Cream", vendingMachine: machine)
// 유효하지 않은 선택

print(purchase.name)

for (person, favoriteSnack) in favoriteSnacks {
    print(person, favoriteSnack)
    try buyFavoriteSnack(person: person, vendingMachine: machine)
}

//yagom Chips
//Chips 제공
//jinsung Biscuit
//자금 부족 - 동전 4개를 추가로 지급해주세요
//heejin Chocolate
//유효하지 않은 선택

 

[코드 28-3]은 [코드28-2]와 달리 던져진 오류를 do-catch 구문을 사용하여 처리하는 함수를 변도로 만들어주었다. 그에 따라 오류를 받아서 다시 던지던 함수들이 더 이상 다른 곳으로 오류를 던지지 않아도 되는 형태가 되었다. 더불어 오류를 적절히 처리해주자 코드가 중간에 중단되지 않고 끝까지 정상 동작하는 것을 볼 수 있다.

 

28.3.3 옵셔널 값으로 오류처리

try?를 사용하여 옵셔널 값으로 변환하여 오류를 처리할 수도 있다. try? 표현을 통해 동작하던 코드가 오류를 던지면 그 코드의 반환 값은 nil이 된다.

 

/*
	코드 28-4. 옵셔널 값으로 오류를 처리
*/

func someThrowingFunction(shouldThrowError: Bool) throws -> Int {
    if shouldThrowError {
        enum SomeError: Error {
            case justSomeError
        }
        
        throw SomeError.justSomeError
    }
    
    return 100
}

let x: Optional = try? someThrowingFunction(shouldThrowError: true)
print(x) // nil

let y: Optional = try? someThrowingFunction(shouldThrowError: false)
print(y) // Optional(100)

 

[코드 28-4]를 보면, try? 표현을 사용하여 호출한 함수가 오류를 던지면 반환 값이 nil로 반환되고, 오류가 발생하지 않으면 옵셔널 값으로 반환되는 것을 확인할 수 있다. 이렇게 do-catch 구문을 사용하지 않더라도 옵셔널을 사용하여 오류를 처리할 수도 있다. 위의 함수에서 반환 타입은 Int지만 try? 표현을 사용하면 반환 타입이 옵셔널이 된다는 것을 알 수 있다.

기존에 반환 타입으로 옵셔널을 활용하던 방법과 결합하여 사용할 수도 있다.

 

/*
	코드 28-5. 옵셔널 값으로 오류를 처리하는 방법과 기존 옵셔널 반환 타입과의 결합
*/

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

 

[코드 28-5]의 fetchData() 함수는 반환 타입을 옵셔널로 정의했다. 즉, 함수 내부에서 제대로 처리가 이루어지지 않으면 반환 값이 nil이 될 수 있음을 내포하는 것이다. 예를 들어 데이터를 디스크에서 가져오지 못하면 서버에서 가져오는 것을 시도해보고 그조차 없으면 nil을 반환해주도록 응용해볼 수 있다.

28.3.4 오류가 발생하지 않을 것이라고 확신하는 방법

코드를 작성하는 프로그래머가 오류를 던질 수 있는 함수 등을 호출할 때 오류가 절대로 발생하지 않을 것이라고 확신할 수 있는 상황이라면 오류가 발생하지 않을 것이라는 전제 하에 try! 표현을 사용할 수 있다. 이 표현은 다른 느낌표 표현(암시적 추출 옵셔널, 강제 타입캐스팅 등)과 마찬가지로 실제 오류가 발생하면 런타임 오류가 발생하여 프로그램이 강제로 종료된다.

 

/*
	코드 28-6. 오류가 발생하지 않음을 확신하여 오류처리
*/

func someThrowingFunction(shouldThrowError: Bool) throws -> Int {
    if shouldThrowError {
        enum SomeError: Error {
            case justSomeError
        }
        
        throw SomeError.justSomeError
    }
    
    return 100
}

let y: Int = try! someThrowingFunction(shouldThrowError: false)
print(y) // 100

let x: Int = try! someThrowingFunction(shouldThrowError: true) // 런타임 오류!!

 

28.3.5 다시 던지기

함수나 메서드는 rethrows 키워드를 사용하여 자신의 매개변수로 전달받은 함수가 오류를 던진다는 것을 나타낼 수 있다. rethrows 키워드를 사용하여 다시 던지기가 가능하게 하려면 최소 하나 이상의 오류 발생 가능한 함수를 매개변수로 전달받아야 한다.

 

/*
	코드 28-7. 오류의 다시 던지기
*/

func someThrowingFunction() throws {
    enum SomeError: Error {
        case justSomeError
    }
        
    throw SomeError.justSomeError
}

// 다시 던지기 함수
func someFunction(callback: () throws -> Void) rethrows {
    try callback() // 다시 던지기 함수는 오류를 다시 던질 뿐 따로 처리하지는 않는다.
}

do {
    try someFunction(callback: someThrowingFunction)
} catch {
    print(error)
}
// justSomeError

다시 던지기 함수 또는 메서드는 기본적으로 스스로 오류를 던지지 못한다. 즉, 자신 내부에 직접적으로 throw 구문을 사용할 수 없다. 그러나 catch 절 내부에서는 throw 구문을 작성할 수 있다. 다시 던지기 함수나 메서드가 오류를 던지는 함수를 do-catch 구문 내부에서 호출하고 catch 절 내부에서 다른 오류를 던짐으로써 오류를 던지는 함수에서 발생한 오류를 제어할 수 있다. 다시 던지기 내부의 catch 절에서는 다시 던지기 함수의 매개변수로 전달받은 오류던지기 함수만 호출하고 결과로 던져진 오류만 제어할 수 있다.

 

/*
	코드 28-8. 다시 던지기 함수의 오류 던지기
*/ 

// 오류를 던지는 함수
func someThrowingFunction() throws {
    enum SomeError: Error {
        case justSomeError
    }
        
    throw SomeError.justSomeError
}

// 다시 던지기 함수
func someFunction(callback: () throws -> Void) rethrows {
    enum AnotherError: Error {
        case justAnotherError
    }
    
    do {
        // 매개변수로 전달한 오류 던지기 함수이므로
        // catch 절에서 제어할 수 있다.
        try callback()
    } catch {
        throw AnotherError.justAnotherError
    }
    
    do {
        // 매개변수로 전달한 오류 던지기 함수가 아니므로
        // catch 절에서 제어할 수 없다.
        try someThrowingFunction()
    } catch {
        // 오류 발생!!
        throw AnotherError.justAnotherError
    }
    
    // catch 절 외부에서 단독으로 오류를 던질 수는 없다. 오류 발생!
    throw AnotherError.justAnotherError
}

 

부모클래스의 다시 던지기 메서드(rethrows 메서드)는 자식클래스에서 던지기 메서드(throws 메서드)로 재정의할 수 없다. 그러나 부모클래스의 던지기 메서드는 자식클래스에서 다시 던지기 메서드로 재정의할 수 있다.

그리고 만약 프로토콜 요구사항 중에 다시 던지기 메서드가 있다면, 던지기 메서드를 구현한다고 해서 요구사항을 충족시킬 수는 없다. 그러나 프로토콜 요구사항 중에 던지기 메서드가 있다면 다시 던지기 메서드를 구현해서 요구사항을 충족시킬 수 있다.

 

/*
	코드 28-9. 프로토콜과 상속에서 던지기 함수와 다시 던지기 함수
*/

protocol SomeProtocol {
    func someThrowFunctionFromProtocol(callback: () throws -> Void) throws
    func someRethrowingFunctionFromProtocol(callback: () throws -> Void) rethrows
}

class SomeClass: SomeProtocol {
    func someThrowFunctionFromProtocol(callback: () throws -> Void) throws { }
    func someFunction(callback: () throws -> Void) rethrows { }
    
    // 던지기 메서드는 다시 던지기 메서드를 요구하는 프로토콜을 충족할 수 없다.
    // 오류 발생!
    func someRethrowingFunctionFromProtocol(callback: () throws -> Void) throws { }
    
    // 다시 던지기 메서드는 던지기 메서드를 요구하는 프로토콜의 요구사항에 부합한다.
    func someRethrowingFunctionFromProtocol(callback: () throws -> Void) rethrows { }
}

class SomeChildClass: SomeClass {
    // 부모클래스의 던지기 메서드는 자식클래스에서 다시 던지기 메서드로 재정의할 수 있다.
    override func someThrowFunctionFromProtocol(callback: () throws -> Void) rethrows { }

    // 부모클래스의 다시 던지기 메서드는 자식클래스에서 던지기 메서드로 재정의할 수 없다.
    // 오류 발생!
    override func someFunction(callback: () throws -> Void) throws { }
}

 

28.3.6 후처리 defer

defer 구문을 사용하여 현재 코드 블록을 나가기 전에 꼭 실행해야 하는 코드를 작성해줄 수 있다. 즉, defer 구문은 코드가 블록을 어떤 식으로 빠져나가든 간에 꼭 실행해야 하는 마무리 작업을 할 수 있도록 도와준다. 다시말해, 오류가 발생하여 코드 블록을 빠져나가는 것이든, 정상적으로 코드가 블록을 빠져나가는 것이든 간에 defer 구문은 코드가 블록을 빠져 나가기 전에 무조건 실행되는 것을 보장한다는 뜻이다.

 

NOTE_defer 구문과 오류처리
defer 구문은 꼭 오류처리 상황에서만 사용해야 하는 것은 아니지만, 오류처리를 할 때 유용하게 쓰인다. defer 구문은 오류처리 상황뿐만 아니라 함수, 메서드, 반복문, 조건문 등등 보통의 코드 블록 어디에서든 사용할 수 있다.

 

/*
	코드 28-10. 파일쓰기 예제에서 defer 구문 활용
*/

func writeData() {
    let file = openFile()
    
    // 함수 코드 블록을 빠져나가기 직전 무조건 실행되어 파일을 잊지 않고 닫아준다.
    defer {
        closeFile(file)
    }
    
    if ... {
        return
    }
    
    if ... {
        return
    }
    
    // 처리 끝
}

만약 defer 구문이 없었다면 중복된 코드가 많아질 것이고 차후 유지관리가 어려울뿐더러 코드가 길어지다보면 프로그래머가 파일닫기 코드를 닫지 않는 실수를 할 가능성도 높아진다.

 

defer 구문은 현재 코드 범위를 벗어나기 전까지 실행을 미루고 있다가 프로그램 실행 흐름이 코드 범위를 벗어나기 직전 실행된다. defer 구문 내부에는 break, return 등과 같이 구문을 빠져나갈 수 있는 코드 또는 오류를 던지는 코드는 작성하면 안된다. 여러 개의 defer 구문이 하나의 범위(블록) 내부에 속해 있다면 맨 마지막에 작성된 구문부터 역순으로 실행된다.

 

/*
	코드 28-12. defer 구문의 실행 순서
*/

func someThrowingFunction(shouldThrowError: Bool) throws -> Int {
    defer {
        print("First")
    }
    
    if shouldThrowError {
        enum SomeError: Error {
            case justSomeError
        }
        
        throw SomeError.justSomeError
    }
    
    defer {
        print("Second")
    }
    
    defer {
        print("Third")
    }
    
    return 100
}

try? someThrowingFunction(shouldThrowError: true)
// First
// 오류를 던지기 직전까지 작성된 defer 구문까지만 실행된다.

try? someThrowingFunction(shouldThrowError: false)
// Third
// Second
// First

 

그런데, do 구문을 catch 절과 함께 사용하지 않고 단독으로 사용할 수도 있다. 코드 블록 내부에 또 한 단계 하위의 블록을 만들고자 할 때이다. 이럴 때는 하위 블록이 종료될 때 그 내부의 defer 구문이 실행된다. [코드 28-13]에서 defer 구문이 여러 개 존재할 때 어떤 순서로 실행되는지 확인해보자.

 

/*
	코드 28-13. 복합적인 defer 구문의 실행 순서
*/

func someFunction() {
    print("1")
    
    defer {
        print("2")
    }
    
    do {
        defer {
            print("3")
        }
        
        print("4")
    }
    
    defer {
        print("5")
    }
    
    print("6")
}

someFunction()

 

 

 

스위프트 프로그래밍: Swift 5 - 교보문고

객체지향, 함수형, 프로토콜 지향 패러다임까지 한 번에! | 스위프트를 제대로 이해하고 싶은 개발자를 위한 책스위프트는 iOS와 macOS용 애플리케이션 개발에 주로 사용하는 프로그래밍 언어입니

www.kyobobook.co.kr

반응형

'iOS > SWIFT' 카테고리의 다른 글

[Swift] Chapter 30. 불명확 타입  (0) 2021.10.24
[Swift] Chapter 29. 메모리 안전  (0) 2021.10.24
[Swift] Chapter 27. ARC  (0) 2021.10.23
[Swift] Chapter 26. where 절  (0) 2021.10.23
[Swift] Chapter 25. 패턴  (0) 2021.10.23

댓글