티스토리 뷰

swift/문법

26. Swift 문법 - Error Handling

DevBee 2020. 10. 27. 20:58

1. Error Handling

보통 에러가 발생하면 프로그램이 강제로 종료되지만, 발생 가능한 에러를 직접 처리하면 강제 종료 없이 계속 프로그램을 실행할 수 있습니다.

 

에러는 크게 Compile Time Error 와 Runtime Error 로 구분합니다. Compile Time Error 는 대부분 문법과 관련 있습니다. Compiler 가 제공하는 정보를 통해 비교적 쉽게 수정할 수 있고, Fix it 으로 수정하는 것도 가능합니다. Runtime Error 는 프로그램이 실행되는 동안 발생합니다. 문법적 에러가 아니더라고 디바이스나 리소스 상태에 따라서 에러가 발생할 수 있습니다. 보통 Runtime Error 가 발생하면 프로그램이 강제로 종료됩니다. 하지만 발생 가능한 에러를 미리 처리해두면 프로그램을 crash 없이 계속 사용할 수 있습니다. 강제 종료 횟수가 줄어들면 그만큼 사용성이 증가합니다.

 

먼저 에러 형식부터 선언해보겠습니다. 에러 프로토콜을 채용하면 에러 형식이 됩니다. 그리고 대부분 열거형으로 선언합니다. 데이터 파싱에서 사용할 에러 형식을 열거형으로 선언해보겠습니다.

enum DataParsingError: Error {
    case invalidType
    case invalidField
    case missingRequiredField(String)
}

 

Error 형식에는 필수 멤버가 선언되어 있지 않아서 프로토콜을 채용하겠다고 선언하는 것으로 충분합니다.

 

에러 형식은 Swift 에러 처리 시스템에 통합됩니다. 이제 데이터 파싱에서 에러가 발생하면 새로운 에러 인스턴스를 생성하고 에러를 처리하는 코드로 전달할 수 있습니다. 에러를 전달 받는 코드에서는 발생한 에러 종류를 확인하고 에러를 처리합니다. 에러를 전달하는 것을 "에러를 던진다"고 표현합니다. 이것은 에러를 전달하는 문법에서 throw 키워드를 사용하기 때문입니다.

 

문법을 살펴보겠습니다.

throw expression

 

throw 뒤에는 표현식이 오는데 에러 형식의 인스턴스가 옵니다. 일반 형식의 인스턴스가 오면 컴파일 에러가 발생합니다. 그리고 throw 문은 아무데서나 호출할 수 없습니다. 코드 블록이 에러를 던질 수 있다고 선언되어 있어야 합니다. 이런 코드 블록은 다음과 같이 선언합니다.

// 함수 문법
func name(parameters) throws -> ReturnType {
    statements
}

// 생성자 문법
init(parameters) throws {
    statements
}

// 클로저 문법
{ (parameters) throws -> ReturnType in
    statements
}

 

throw 키워드는 에러를 던지는 키워드이고, throws 키워드는 함수, 메소드, 생성자, 클로저가 에러를 던질 수 있다고 선언합니다. 이렇게 선언에 throws 키워드가 선언된 블록은 Throwing Function / Method, Throwing Initializer, Throwing Closure 라고 부릅니다.

 

그러면 간단한 Throwing Function 을 구현해보겠습니다.

func parsing(data: [String: Any]) throws {
    guard let _ = data["name"] else {
        throw DataParsingError.missingRequiredField("name")
    }
    
    guard let _ = data["age"] as? Int else {
        throw DataParsingError.invalidType
    }
    
    // Parsing...
}

 

throw 문은 return 문과 마찬가지로 코드 블록의 실행을 즉시 종료합니다. 다시 말해서 throw 문이 호출되면 같은 블록에 있는 나머지 코드는 실행되지 않습니다.

 

파라미터로 전달된 Dictionary 에 name 키가 추가되어 있고 age 키에 저장된 값은 Int 로 캐스팅할 수 있다면 throw 문은 호출되지 않습니다. 그렇다고 throws 키워드를 삭제할 수 없습니다. 뒤집어서 설명해보면 throws 키워드가 있다고 해서 코드 블록에서 항상 throw 문을 호출해야 하는 것은 아닙니다. 코드가 에러 없이 정상적으로 실행되면 throw 문은 호출될 필요가 없습니다. throw 문은 에러가 발생한 경우에만 호출되어야 합니다.

 

Throwing Function 을 호출할 때는 try 표현식을 호출합니다. 문법을 보면 3가지 형태가 있습니다.

try expression
try? expression
try! expression

 

표현식에는 주로 Throwing Function / Method, Throwing Initializer, Throwing Closure 를 호출하는 코드가 옵니다. 첫 번째 문법의 경우 do-catch 문과 함께 사용합니다. 나머지 두 문법의 경우 Optional 이 결합된 형태입니다. ? 가 붙어있는 문법은 Optional Try 라고 부르고 표현식에서 에러가 발생할 경우 nil 을 반환합니다. ! 가 붙어있는 문법은 Forced Try 라고 부르고 에러가 발생할 경우 런타임 에러가 발생합니다. 그래서 가능하다면 사용하지 않는 것이 좋습니다.

 

자동완성을 봤을 때 오른쪽에 throws 키워드가 있는 함수, 메소드, 생성자, 클로저는 반드시 try 표현식을 호출해야 합니다.

 

위에서 구현한 parsing 함수를 호출해 보겠습니다.

try? parsing(data: [:])

 

지금처럼 Optional Try 를 호출한 경우에는 crash 가 발생하지 않고 단지 nil 을 반환하고 호출문이 종료됩니다.

 

에러를 처리하는 방법은 크게 3가지로 분류됩니다.

1. do-catch Statements : 주로 코드에서 발생한 에러를 개별적으로 처리할 때 사용합니다.

2. try Expression + Optional Biniding

3. hand over : 전달받은 에러를 다시 다른 코드 블록으로 전달합니다.

 

2. do-catch Statements

문법을 살펴보겠습니다.

do {
    try expression
    statements
} catch pattern {
    statements
} catch pattern where condition {
    statements
}

 

do block 은 필수 블록입니다. 여기서는 try 표현식을 사용해서 에러가 발생할 수 있는 코드를 실행합니다. try 표현식에서 에러가 발생하면 do block 에서 이어지는 코드는 실행되지 않고 아래쪽에 있는 catch block 이 실행됩니다.

 

catch block 은 do block 에서 발생한 에러를 처리합니다. 패턴으로 처리하고 싶은 에러를 선언하거나 패턴을 생략하고 전체 에러를 처리할 수도 있습니다. 그리고 where 절을 추가해서 매칭시킬 에러 패턴에 조건을 추가할 수 있습니다.

 

do block 에서 발생 가능한 모든 에러는 catch block 을 통해 모두 처리되어야 합니다. catch block 을 생략한 경우에는 에러가 다른 코드로 전파될 수 있도록 구현해야 합니다.

enum DataPassingError: Error {
    case invalidType
    case invalidField
    case missingRequiredField(String)
}

func parsing(data: [String: Any]) throws {
    guard let _ = data["name"] else {
        throw DataPassingError.missingRequiredField("name")
    }
    
    guard let _ = data["age"] as? Int else {
        throw DataPassingError.invalidType
    }
    
    // Parsing...
}

do {
    try parsing(data: [:])
} catch DataPassingError.invalidType {
    print("invalid type error")
} catch {
    print("handle error")
}

 

catch block 을 작성할 때는 가장 까다로운 패턴부터 작성하여야 합니다. 패턴이 생략된 catch block 은 항상 마지막에 작성되어야 합니다.

 

마지막에 작성된 catch block 을 삭제한 경우를 살펴보겠습니다. 현재는 do-catch block 이 global scope 에서 작성되어 있기 때문에 모든 에러를 처리하지 않아도 컴파일 에러가 발생하지 않습니다. 그러나 실제 프로젝트에서는 이렇게 global scope 에 작성하는 경우가 없습니다. 코드를 함수 내부로 이동시켜 보겠습니다.

func handleError() {
    do {
        try parsing(data: [:])
    } catch DataPassingError.invalidType {
        print("invalid type error")
    }
}

 

그러면 곧바로 컴파일 에러가 발생합니다. do-catch 문은 반드시 do block 에서 발생할 수 있는 모든 에러를 catch block 에서 처리해야 합니다.

 

에러를 해결하는 방법은 2가지입니다. 이전처럼 나머지 모든 에러를 처리하는 catch block 을 구현하거나 handleError 함수가 나머지 에러를 다른 코드로 던지도록 선언합니다.

 

handleError 함수에 throws 키워드를 추가하면 invalidType 에러를 제외한 다른 에러는 handleError 함수를 호출한 코드로 전달됩니다.

func handleError() throws {
    do {
        try parsing(data: [:])
    } catch DataPassingError.invalidType {
        print("invalid type error")
    }
}

 

catch block 을 모두 생략하고 무조건 에러를 모두 던지도록 구현할 수도 있습니다. 이 경우에는 do-catch 문을 생략하고 바로 try 문을 작성해도 문제가 없습니다.

 

패턴이 없는 catch 블록은 패턴을 가진 catch 블록에서 처리되지 않은 나머지 모든 에러를 처리합니다. 그래서 어떤 에러가 발생했는지 판단할 수단이 필요합니다. 패턴이 없는 catch block 에는 error 라는 특별한 로컬 상수가 제공됩니다. 이 error 상수로 발생한 에러가 전달됩니다. error 상수의 경우 타입이 Error 프로토콜이기 때문에 Type Casting 이 필요합니다.

func handleError() throws {
    do {
        try parsing(data: [:])
    } catch {
        if let error = error as? DataPassingError {
            switch error {
            case .invalidType:
                print("invalid type")
            default:
                print("handle error")
            }
        }
    }
}

 

패턴이 없는 catch block 은 대부분 이런 패턴으로 구현합니다.

 

3. Optional Try

에러를 던지는 함수나 생성자를 호출할 때는 try 표현식을 사용해야 합니다. do block 이 아닌 다른 곳에서 호출할 때는 Optional try 와 Forced try 를 사용합니다. Optional try 는 표현식에서 에러가 전달된 경우 nil 을 리턴합니다. 반면 Forced try 는 실행을 중지합니다. 다시 말해서 런타임에러가 발생합니다. 두 표현식은 에러를 Optional 값으로 변경합니다. 따라서 주로 Optional Binding 과 함께 사용합니다.

enum DataParsingError: Error {
    case invalidType
    case invalidField
    case missingRequiredField(String)
}

func parsing(data: [String: Any]) throws {
    guard let _ = data["name"] else {
        throw DataParsingError.missingRequiredField("name")
    }
    
    guard let _ = data["age"] as? Int else {
        throw DataParsingError.invalidType
    }
    
    // Parsing...
}

if let _ = try? parsing(data: [:]) {
    print("success")
} else {
    print("fail")
}

 

함수에서 에러가 발생하는 경우 바인딩이 실패하고 else block 이 실행됩니다.

 

위 코드를 do-catch 문으로 작성해 보겠습니다.

do {
    try parsing(data: [:])
    print("success")
} catch {
    print("fail")
}

 

Optional try 를 사용할 떄 반드시 Optional Binding 을 사용해야 하는 것은 아닙니다. 그냥 함수만 호출하고 결과는 신경쓸 필요가 없다면 Optional try 만 작성하여도 무방합니다.

try? parsing(data: [:])

try! parsing(data: ["name": "steve", "age": 33])

 

try! 를 사용한 경우를 보면 지금은 파라미터로 올바른 딕셔너리를 전달하였고 이 경우에는 아무런 문제가 없습니다. 딕셔너리를 빈 딕셔너리로 변경 후 코드를 실행하면 런타임 에러가 발생합니다. 실제 디바이스에서 실행할 경우 crash 가 발생합니다.

 

Forced try 는 표현식에서 발생한 에러를 다른 곳으로 전달할 수 없습니다. 그리고 do-catch 를 통해 에러를 처리하는 것도 불가능합니다. 에러가 발생하는 즉시 프로그램이 강제로 종료되기 때문입니다. 그래서 Forced try 는 가능하면 사용하지 않는 것이 좋습니다. 에러가 발생하지 않는 것이 확실한 경우에만 제한적으로 사용하여야 합니다.

 

4. defer Statements

defer 문은 코드의 실행을 scope 가 종료되는 시점으로 연기시킵니다. 주로 코드에서 사용했던 자원을 정리할 때 사용합니다. 문법을 살펴보면 다음과 같습니다.

defer {
    statements
}

 

defer 문을 호출하면 block 에 포함된 코드가 바로 실행되지는 않습니다. 대신 defer 문을 호출한 scope 의 실행이 종료될 때까지 연기됩니다.

func processFile(path: String) {
    print("1")
    let file = FileHandle(forReadingAtPath: path)
    
    // Process
    
    defer {
        print("2")
        file?.closeFile()
    }
    
    if path.hasSuffix(".jpg") {
        print("3")
        return
    }
    
    defer {
        print("5")
    }
    
    print("4")
}

 

defer 문은 런타임 에러로 인해 프로그램이 비정상적으로 종료되는 경우를 제외하고는 항상 해당 scope 가 종료되는 시점에 실행됩니다.

processFile(path: "file.swift")
// 출력
1
4
5
2

processFile(path: "file.jpg")
// 출력
1
3
2

 

단순히 defer 문을 구현한다고 해서 항상 defer block 에 포함된 코드가 실행되는 것은 아닙니다. 반드시 defer 문이 호출되어야 종료 시점에 실행할 코드가 예약됩니다. 이렇게 조건에 따라 defer 문이 호출되지 않는 문제를 해결하기 위해 주로 scope 시작 지점에서 defer 문을 호출합니다.

 

defer 문은 호출한 순서대로 코드를 예약합니다. 여기에서는 2을 출력하는 defer block 이 가장 먼저 예약됩니다. 예약된 block 이 실행될 때는 defer 문이 호출된 순서와 반대로 가장 마지막에 예약된 block 이 먼저 실행됩니다.

 

5. Result Type

Result Type 을 이해하기 위해서 오류 처리 시스템이 어떻게 발전해 왔는지 간단히 살펴보겠습니다.

 

Swift 1.x 최초 버전에서는 Objective-C 와 동일한 방법으로 오류를 처리하였습니다. NSError 포인터를 사용해야 했기 때문에 포인터 사용을 지양하는 Swift 에는 어울리지 않는 방식이었습니다.

 

그래서 Swift 2.x + 부터는 새로운 에러 처리 방식이 도입되었고 지금까지 사용되고 있습니다. 새로운 모델에서는 에러가 발생할 수 있는 코드 블록을 Throwing Function 이나 Throwing Method 로 선언합니다. 그리고 do-catch 문에서 try 표현식을 통해 호출하고 발생한 에러를 처리합니다. 에러 형식은 특별한 프로토콜을 채용한 형식으로 선언합니다.

enum NumberError: Error {
    case negaticeNumber
    case evenNumber
}

enum AnotherNumberError: Error {
    case tooLarge
}

func process(oddNumber: Int) throws -> Int {
    // 음수가 전달된 경우
    guard oddNumber >= 0 else {
        throw NumberError.negaticeNumber
    }
    
    // 짝수가 전달된 경우
    guard !oddNumber.isMultiple(of: 2) else {
        throw NumberError.evenNumber
    }
    
    return oddNumber * 2
}

do {
    let result = try process(oddNumber: 1)
    print(result)
} catch {
    print(error.localizedDescription)
}

 

에러를 올바르게 처리하기 위해서는 함수가 던지는 에러가 무엇인지 정확하게 파악해야 하는데 throws 키워드는 에러를 던진다는 것을 의미할 뿐 어떤 에러인지 특정 지을 수 없습니다. 따라서 함수 실행 후 전달되는 에러의 형식은 Error 프로토콜입니다.

 

어떤 에러가 전달되는지 파악이 되었다면 해당 에러로 type casting 이 필요합니다.

do {
    let result = try process(oddNumber: 1)
    print(result)
} catch let myErr as NumberError {
    switch myErr {
    case .negaticeNumber:
        print("negative number")
    case .evenNumber:
        print("even number")
    }
} catch {
    print(error.localizedDescription)
}

 

이번에는 AnotherNumberError 에 있는 tooLarge 에러를 던지도록 guard 문을 추가해보겠습니다.

func process(oddNumber: Int) throws -> Int {
    // 음수가 전달된 경우
    guard oddNumber >= 0 else {
        throw NumberError.negaticeNumber
    }
    
    // 짝수가 전달된 경우
    guard !oddNumber.isMultiple(of: 2) else {
        throw NumberError.evenNumber
    }
    
    guard oddNumber < 1000 else {
        throw AnotherNumberError.tooLarge
    }
    
    return oddNumber * 2
}

 

하나의 함수에서 두가지 에러를 던지고 있는데 이때 에러 없이 실행되기는 하지만 AnotherNumberError 를 올바르게 처리했다고 할 수는 없습니다. 컴파일러가 새로운 형식의 에러가 추가되었다는 것을 인식하지 못하기 때문에 에러 처리에서 논리적인 에러가 발생할 가능성이 높습니다.

 

만약 위 코드에서 패턴이 없는 catch block 을 삭제하는 경우에는 process 함수가 NumberError 만 던진다면 문제 없지만 다른 형식으로 에러를 던지면 런타임 에러가 발생합니다. 이런 경우 컴파일 시점에 에러를 파악할 수 없기 때문에 코드의 안정성이 낮아집니다.

 

동일한 코드를 Result Type 으로 처리해보겠습니다.

 

Result 는 제네릭 열거형입니다. Success case 와 Failure case 가 선언되어 있고 연관 값을 가지고 있습니다. Success case 에 저장할 수 있는 형식에는 제한이 없지만 Failure case 에는 에러 형식만 저장할 수 있습니다. 두번째 형식 파라미터로 에러 형식을 명확히 선언하기 때문에 형식에 관한 모호함이 모두 사라집니다. 보통 작업의 결과는 성공과 실패 2가지 입니다. 작업이 성공하면 Success case 에 작업의 결과가 저장됩니다. 반대로 작업이 실패하면 Failure case 에 에러가 저장됩니다.

 

Result Type 은 Throwing Closure 로 초기화하는 생성자를 제공합니다.

let result = Result{ try process(oddNumber: 1) }

 

이렇게 하면 result 상수에 Result 인스턴스가 저장되는데 이 인스턴스는 열거형입니다. 그리고 이 열거형은 Success case 와 Failure case 를 가지고 있습니다. 그래서 switch 문으로 처리할 수 있습니다.

switch result {
case .success(let data):
    print(data)
case .failure(let error):
    print(error.localizedDescription)
}

 

함수가 정상적으로 실행되면 Success case 와 매칭되고 연관된 값을 통해 리턴된 값을 사용할 수 있습니다. 반대로 에러가 전달되면 Failure case 와 매칭되고 전달된 에러는 연관 값을 통해서 얻을 수 있습니다. do-catch 문으로 작성한 코드보다 성공과 실패가 더 명확해졌습니다.

 

이번에는 process 함수에서 Reault Type 을 리턴하도록 변경해보겠습니다.

func processResult(oddNumber: Int) -> Result<Int, NumberError> {
    // 음수가 전달된 경우
    guard oddNumber >= 0 else {
        // return Result.failure(NumberError.negaticeNumber)
        // Throwing Function 에서는 형식 추론이 불가하기 때문에 전체 이름을 써야하는데 여기에서는 형식 이름을 생략할 수 있습니다.
        return .failure(.negaticeNumber)
    }
    
    // 짝수가 전달된 경우
    guard !oddNumber.isMultiple(of: 2) else {
        return .failure(.evenNumber)
    }
    
    return .success(oddNumber * 2)
}

 

Result Type 으로 에러를 처리할 때는 함수에서 에러를 직접 던지지 않고 영문값으로 저장해서 리턴합니다. 이전 함수와 비교해보면 작업이 성공하면 Int 값이 리턴되고 작업에 실패하면 NumberError 가 발생한다는 것을 명확히 파악할 수 있습니다. 에러 형식을 직접 선언하기 때문에 형식 안정성이 보장되고 잘못된 형식으로 인해 발생하는 문제는 컴파일 에러를 통해서 바로 확인할 수 있습니다.

 

이전 함수와 비교해보면 에러를 직접 던지는 것이 아니라 연관 값으로 리턴합니다. 다시 말해서 함수를 Throwing Function 으로 선언하지 않습니다. 이런 차이때문에 함수를 호출하는 방식과 에러를 처리하는 방식이 달라집니다.

 

성공과 실패가 열거형으로 리턴되고 에러는 실제로 결과를 사용하는 시점에 처리합니다. Throwing Function 으로 구현하면 do block 에서 try 표현식을 호출하고 catch block 에서 바로 에러를 처리합니다. 반면 Result Type 으로 처리하면 인스턴스를 저장해두었다가 Switch 문으로 case 를 확인하고 연관 값에 저장된 에러를 처리합니다. 에러를 처리하는 시점이 함수를 호출하는 시점에서 작업 결과를 사용하는 시점으로 이동하였습니다. 이런 패턴을 Delayed Error Handling 이라고 합니다.

 

작업이 성공한 경우만을 처리하고 싶다면 Result Type 이 제공하는 get 메소드를 활용히면 처리가 가능합니다. 이 메소드는 작업이 성공하면 결과값을 리턴하고 실패하면 에러를 던지는 Throwing Method 입니다. 이 메소드의 결과는 do-catch 문이나 Optional try 로 처리하면 됩니다.

if let result2 = try? result.get() {
    print(result2)
}

 

이렇게 하면 작업이 성공한 경우에만 if 블록이 실행됩니다.

 

에러 처리 방식을 모두 Result Type 으로 사용하는 것이 좋은 것은 아니며 그냥 에러를 처리하는 방식이 다양하다는 것만 이해하면 좋을 것 같습니다.

'swift > 문법' 카테고리의 다른 글

27. Swift 문법 - Availability Condition  (0) 2020.10.27
25. Swift 문법 - Generics  (0) 2020.10.27
24. Swift 문법 - Memory, Value Type and Reference Type  (0) 2020.10.27
23. Swift 문법 - Protocol 2  (0) 2020.10.26
22. Swift 문법 - Protocol 1  (0) 2020.10.26
댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/11   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
글 보관함