Devin.KR

오류 처리 - throws 와 do catch

개발자KR 조회 0

이 장에서 배우는 것

습관 기록 앱은 값이 잘못 들어오는 일을 피할 수 없다. 이름이 빈 채로 기록이 오기도 하고, 물을 0ml 마셨다는 기록이나 같은 날의 중복 기록이 오기도 한다. 이 장에서는 이런 실패를 Swift가 제공하는 오류 처리(error handling)로 표현하고 받아 내는 방법을 익힌다. 앞 장에서 프로토콜로 약속을 세우는 법을 배웠는데, 오류도 Error라는 프로토콜 하나를 따르는 타입일 뿐이다.

  • Error 프로토콜을 따르는 열거형으로 실패의 종류를 정의한다.
  • throw와 throws로 실패를 호출한 쪽에 알린다.
  • do-catch와 패턴으로 오류의 종류별로 대응한다.
  • try?와 try!가 언제 알맞고 언제 위험한지 구분한다.
  • Result 타입과 defer를 상황에 맞게 쓴다.

문제 상황

기록을 추가하는 함수를 처음 만들 때는 보통 실패를 값으로 흉내 낸다. 잘못된 입력이면 false를 돌려주거나, 합계를 구하다 실패하면 -1을 돌려주는 식이다. 처음에는 간단해 보이지만 곧 문제가 생긴다.

  • false만 봐서는 이름이 비어서 실패했는지 중복이라서 실패했는지 알 수 없다.
  • -1도 정상 값처럼 계산에 섞여 들어가 합계를 조용히 틀리게 만든다.
  • 호출하는 쪽이 반환값 검사를 빼먹어도 컴파일러가 알려 주지 않는다.

Swift의 오류 처리는 세 가지를 문법으로 강제해서 이 문제를 푼다. 실패할 수 있는 함수에는 throws가 붙고, 호출하는 곳에는 try가 붙으며, 오류를 처리하지 않으면 컴파일이 되지 않는다. 실패 가능성이 코드에 그대로 드러나는 것이다.

오류를 타입으로 표현하기

Error 프로토콜을 따르는 열거형

오류의 종류는 열거형으로 정의하는 것이 가장 자연스럽다. 이름이 비었다, 모르는 습관이다, 양이 잘못됐다처럼 종류가 정해져 있고, 종류마다 덧붙일 정보가 다르기 때문이다. 열거형이 연관값(associated value)을 가질 수 있다는 점을 앞서 열거형을 다룬 장에서 봤다. 여기서는 그 연관값에 "무엇이 잘못됐는지"를 담는다.

enum HabitError: Error {
    case emptyName
    case unknownHabit(String)
    case invalidAmount(Int)
    case duplicate(name: String, day: Int)
}

Error는 채워야 할 요구사항이 없는 프로토콜이어서 이름만 적으면 된다. 이 선언만으로 HabitError의 값은 던질 수 있는 값이 된다.

throw 와 throws

실패할 수 있는 함수는 매개변수 목록 뒤에 throws를 적는다. 함수 안에서 실패를 알릴 때는 throw 뒤에 오류 값을 쓴다. throw가 실행되면 그 함수는 그 자리에서 끝나고, 반환값 대신 오류가 호출한 쪽으로 넘어간다.

func total(for habit: String) throws -> Int {
    guard known.contains(habit) else { throw HabitError.unknownHabit(habit) }
    return 0
}

앞서 제어 흐름을 다룬 장의 guard가 여기서 잘 맞는다. 조건이 틀리면 곧바로 던지고 나가면, 정상 경로는 들여쓰기 없이 아래로 이어진다. 이런 식으로 입력 검사를 함수 앞쪽에 모아 두는 형태를 흔히 쓴다.

던지는 함수를 호출할 때는 반드시 try를 붙인다. 아래 그림은 오류가 어떻게 호출의 반대 방향으로 거슬러 올라가는지 보여 준다.

던져진 오류는 호출의 반대 방향으로 올라가 가장 가까운 do-catch에서 처리된다.

오류를 받는 방법

do-catch 와 패턴

do 블록 안에서 try로 호출하면, 오류가 던져진 순간 나머지 코드는 건너뛰고 catch 절로 간다. catch에는 switch의 case처럼 패턴을 쓸 수 있다. 연관값을 꺼내 변수에 묶거나 where로 조건을 더할 수 있다. 위에서부터 차례로 맞는 절을 찾고, 어느 패턴에도 맞지 않는 오류를 위해 마지막에 패턴 없는 catch를 둔다. 이 절 안에서는 error라는 이름의 상수가 자동으로 생긴다.

do {
    try journal.add(habit: "물", day: 1, amount: 0)
} catch HabitError.invalidAmount(let value) where value == 0 {
    print("양이 0이다")
} catch HabitError.invalidAmount(let value) {
    print("양이 올바르지 않다: \(value)")
} catch {
    print("그 밖의 오류: \(error)")
}

더 구체적인 패턴을 위에, 더 넓은 패턴을 아래에 둔다. 순서가 뒤집히면 구체적인 절에 닿지 못한다.

try? 와 try!

오류의 이유가 필요 없을 때는 try?를 쓴다. 성공하면 값을 옵셔널로 감싸 돌려주고, 실패하면 nil이 된다. 앞서 옵셔널을 다룬 장의 if let이나 ??와 그대로 어울린다. try!는 실패하지 않는다고 확신할 때 쓰는데, 실패하면 프로그램이 즉시 멈춘다.

try 의 세 가지 형태 비교
형태실패하면결과 타입알맞은 때
try오류가 catch 나 바깥으로 간다원래 타입이유를 알고 대응해야 할 때
try?nil옵셔널실패해도 기본값으로 이어 가면 될 때
try!프로그램이 멈춘다원래 타입코드 구조상 실패할 수 없을 때

Result 타입과 defer

Result: 성공이나 실패를 값으로 들고 다니기

Result는 성공 값 또는 실패 오류 중 하나를 담는 열거형이다. .success(값)과 .failure(오류) 두 경우가 있고, Result<Entry, ParseError>처럼 성공 타입과 오류 타입을 함께 적는다. 결과를 변수에 저장하거나 배열에 모았다가 나중에 처리하고 싶을 때 쓸모가 있다. switch로 두 경우를 나눠 받거나, get()을 호출해 다시 던지는 방식으로 되돌릴 수 있다. 던지는 코드를 Result { try ... }로 감싸면 던져진 오류가 .failure로 담긴다.

defer: 나갈 때 반드시 실행할 코드

defer 블록은 현재 범위를 떠날 때 실행된다. 정상 반환이든 throw든 마찬가지다. 파일을 열고 닫거나, 시작과 끝을 기록하는 식의 뒷정리를 한 곳에 적어 두기에 알맞다. 한 범위에 defer가 여럿이면 나중에 적은 것부터 실행된다. 아래 그림은 도중에 오류가 던져졌을 때의 순서다.

defer는 throw로 함수가 중단될 때도 호출한 쪽의 catch보다 먼저 실행된다.

defer는 등록된 시점부터 효력이 있다. 그보다 앞에서 던져진 오류에는 반응하지 않으므로, 뒷정리가 필요한 자원을 얻은 직후에 적는다. 또 defer는 이미 일어난 변경을 되돌리지 않는다. 그림의 예에서도 앞의 두 줄은 이미 기록된 채로 남는다.

완성 코드

아래 코드를 main.swift 한 파일에 저장한다. 습관 기록을 추가하는 함수, 합계를 구하는 함수, 문자열 한 줄을 해석하는 함수와 여러 줄을 가져오는 함수가 이 장의 내용을 차례로 사용한다.

enum HabitError: Error {
    case emptyName
    case unknownHabit(String)
    case invalidAmount(Int)
    case duplicate(name: String, day: Int)
}

enum ParseError: Error {
    case wrongFieldCount(Int)
    case notANumber(String)
}

struct Entry {
    let habit: String
    let day: Int
    let amount: Int
}

struct HabitJournal {
    private(set) var entries: [Entry] = []
    private let known: Set<String> = ["물", "운동", "독서"]

    mutating func add(habit: String, day: Int, amount: Int) throws {
        guard habit.isEmpty == false else { throw HabitError.emptyName }
        guard known.contains(habit) else { throw HabitError.unknownHabit(habit) }
        guard amount > 0 else { throw HabitError.invalidAmount(amount) }
        if entries.contains(where: { $0.habit == habit && $0.day == day }) {
            throw HabitError.duplicate(name: habit, day: day)
        }
        entries.append(Entry(habit: habit, day: day, amount: amount))
    }

    func total(for habit: String) throws -> Int {
        guard known.contains(habit) else { throw HabitError.unknownHabit(habit) }
        return entries.filter { $0.habit == habit }.reduce(0) { $0 + $1.amount }
    }
}

func report(_ journal: inout HabitJournal, habit: String, day: Int, amount: Int) {
    do {
        try journal.add(habit: habit, day: day, amount: amount)
        print("기록: \(habit) \(day)일 \(amount)")
    } catch HabitError.emptyName {
        print("이름이 비었다")
    } catch HabitError.invalidAmount(let value) where value == 0 {
        print("양이 0이다")
    } catch HabitError.invalidAmount(let value) {
        print("양이 올바르지 않다: \(value)")
    } catch HabitError.duplicate(let name, let at) {
        print("중복: \(name) \(at)일")
    } catch {
        print("그 밖의 오류: \(error)")
    }
}

func parse(_ line: String) -> Result<Entry, ParseError> {
    let parts = line.split(separator: ",").map(String.init)
    guard parts.count == 3 else { return .failure(.wrongFieldCount(parts.count)) }
    guard let day = Int(parts[1]) else { return .failure(.notANumber(parts[1])) }
    guard let amount = Int(parts[2]) else { return .failure(.notANumber(parts[2])) }
    return .success(Entry(habit: parts[0], day: day, amount: amount))
}

func importLines(_ lines: [String], into journal: inout HabitJournal) throws -> Int {
    print("가져오기 시작")
    defer { print("가져오기 끝") }
    var count = 0
    for line in lines {
        let entry = try parse(line).get()
        try journal.add(habit: entry.habit, day: entry.day, amount: entry.amount)
        count += 1
    }
    return count
}

print("[1] do-catch 와 패턴")
var journal = HabitJournal()
report(&journal, habit: "물", day: 1, amount: 500)
report(&journal, habit: "", day: 1, amount: 10)
report(&journal, habit: "운동", day: 1, amount: 0)
report(&journal, habit: "운동", day: 1, amount: -5)
report(&journal, habit: "물", day: 1, amount: 200)
report(&journal, habit: "요가", day: 1, amount: 20)
report(&journal, habit: "운동", day: 1, amount: 30)
report(&journal, habit: "물", day: 2, amount: 700)

print("[2] try? 와 try!")
if let water = try? journal.total(for: "물") {
    print("물 합계: \(water)")
}
if let yoga = try? journal.total(for: "요가") {
    print("요가 합계: \(yoga)")
} else {
    print("요가 합계: 없음")
}
let workout = try! journal.total(for: "운동")
print("운동 합계: \(workout)")

print("[3] Result")
for line in ["독서,3,20", "독서,3", "독서,삼,20"] {
    switch parse(line) {
    case .success(let entry):
        print("해석 성공: \(entry.habit) \(entry.day)일 \(entry.amount)")
    case .failure(let error):
        print("해석 실패: \(error)")
    }
}
let wrapped = Result { try journal.total(for: "요가") }
if case .failure(let error) = wrapped {
    print("Result 로 감쌈: \(error)")
}

print("[4] defer")
var imported = HabitJournal()
do {
    let n = try importLines(["물,1,500", "운동,1,30", "독서,x,10", "물,2,300"], into: &imported)
    print("가져온 줄: \(n)")
} catch let error as ParseError {
    print("형식 오류: \(error)")
} catch {
    print("다른 오류: \(error)")
}
print("남은 기록: \(imported.entries.count)")

줄별 해설

  • enum HabitError: Error: 실패의 종류를 열거형으로 정의했다. duplicate는 연관값에 name, day 라벨을 붙여 어느 습관의 며칠이 겹쳤는지 담는다.
  • enum ParseError: 문자열 해석에서만 나오는 오류를 별도 타입으로 나눴다. 오류 종류가 다르면 타입을 나누면 catch let error as ParseError처럼 타입으로 가려 받을 수 있다.
  • private(set) var entries: 밖에서는 읽기만 가능하고 쓰기는 add를 거쳐야 한다. 검사를 거치지 않은 기록이 들어올 길을 막는다.
  • mutating func add ... throws: 구조체의 속성을 바꾸므로 mutating이고, 실패할 수 있으므로 throws다. 네 개의 guard와 if가 이름, 습관 종류, 양, 중복 순으로 검사한다. 모든 검사를 통과한 뒤에야 append가 실행되므로, 실패한 호출은 기록을 바꾸지 않는다.
  • func total: 알려지지 않은 습관이면 던지고, 아니면 해당 습관의 양을 모두 더해 돌려준다. 기록이 아직 없는 알려진 습관은 오류가 아니라 0이다.
  • report: do 안에서 try로 호출하고 catch를 위에서 아래로 시도한다. value == 0인 경우를 먼저 두고, 일반 invalidAmount를 그다음에 뒀다. 마지막 catch는 unknownHabit 같은 나머지를 받는다. 매개변수 journal은 inout이라 호출할 때 &journal로 넘긴다.
  • parse: 던지지 않고 Result를 돌려준다. 필드 수가 3이 아니거나 숫자가 아니면 .failure, 모두 맞으면 .success다.
  • importLines: defer를 함수 맨 앞에 두어 어디서 끝나든 "가져오기 끝"이 출력된다. parse(line).get()은 .failure면 그 오류를 던지고 .success면 값을 꺼낸다. 이 함수는 두 종류의 오류를 모두 흘려보내므로 그냥 throws다.
  • [2] 구역: try?의 결과가 옵셔널이므로 if let으로 풀었다. try!는 "운동"이 알려진 습관임이 코드에서 분명한 자리에만 썼다.
  • [3] 구역: switch로 두 경우를 모두 다뤘다. Result { try ... }는 던져진 오류를 .failure로 감싼다. 이때 오류 타입은 구체적인 HabitError가 아니라 일반 Error다.
  • [4] 구역: 세 번째 줄에서 오류가 던져진다. defer가 먼저 실행되고 그다음 catch let error as ParseError가 오류를 받는다. 앞의 두 줄은 이미 기록되었으므로 남은 기록은 2다.

실행 결과

$ swiftc main.swift -o app
$ ./app
[1] do-catch 와 패턴
기록: 물 1일 500
이름이 비었다
양이 0이다
양이 올바르지 않다: -5
중복: 물 1일
그 밖의 오류: unknownHabit("요가")
기록: 운동 1일 30
기록: 물 2일 700
[2] try? 와 try!
물 합계: 1200
요가 합계: 없음
운동 합계: 30
[3] Result
해석 성공: 독서 3일 20
해석 실패: wrongFieldCount(2)
해석 실패: notANumber("삼")
Result 로 감쌈: unknownHabit("요가")
[4] defer
가져오기 시작
가져오기 끝
형식 오류: notANumber("x")
남은 기록: 2

실무에서 자주 틀리는 것

try? 로 이유를 버리고 나중에 원인을 찾는다

실패 이유가 필요한 자리에서 try?를 쓰면, 기록이 안 들어간 까닭을 알 수 없다.

// 틀림: 중복인지 양 문제인지 알 수 없다
if (try? journal.total(for: habit)) == nil {
    print("실패")
}
// 고침: 이유가 필요하면 do-catch 로 받는다
do {
    let sum = try journal.total(for: habit)
    print(sum)
} catch {
    print("실패 이유: \(error)")
}

catch 순서를 거꾸로 적는다

넓은 패턴이 위에 있으면 아래의 구체적인 패턴은 실행될 기회가 없다. 컴파일러가 경고를 내기도 하지만, 경고를 놓치면 의도와 다른 메시지가 나간다.

// 틀림: 첫 catch 가 모든 오류를 가져간다
do {
    try journal.add(habit: "물", day: 1, amount: 0)
} catch {
    print("그 밖의 오류")
} catch HabitError.invalidAmount(let value) {
    print("양이 올바르지 않다: \(value)")
}
// 고침: 구체적인 것을 위에, 패턴 없는 catch 를 맨 아래에
do {
    try journal.add(habit: "물", day: 1, amount: 0)
} catch HabitError.invalidAmount(let value) {
    print("양이 올바르지 않다: \(value)")
} catch {
    print("그 밖의 오류")
}

바깥에서 온 입력에 try! 를 쓴다

사용자 입력이나 파일 내용은 코드가 보장할 수 없다. 이런 값에 try!를 쓰면 잘못된 입력 한 줄이 프로그램 전체를 멈춘다.

// 틀림: 모르는 습관이 들어오면 프로그램이 멈춘다
let sum = try! journal.total(for: userInput)
// 고침: 기본값으로 이어 가거나 do-catch 로 알린다
let sum = (try? journal.total(for: userInput)) ?? 0

defer 를 던지는 줄 뒤에 둔다

defer는 적힌 위치에 도달해야 등록된다. 그 앞에서 던져지면 뒷정리가 실행되지 않는다.

// 틀림: 첫 줄에서 던지면 "끝" 이 출력되지 않는다
func run(_ line: String) throws {
    print("시작")
    let entry = try parse(line).get()
    defer { print("끝") }
    print(entry.habit)
}
// 고침: 뒷정리가 필요해진 직후, 던질 수 있는 코드보다 앞에 적는다
func run(_ line: String) throws {
    print("시작")
    defer { print("끝") }
    let entry = try parse(line).get()
    print(entry.habit)
}

한눈에 보기

오류 처리 문법과 쓰임 정리
문법역할예
enum X: Error오류의 종류를 정의한다case invalidAmount(Int)
throws실패할 수 있는 함수임을 표시한다func add(...) throws
throw오류를 던지고 함수를 끝낸다throw HabitError.emptyName
do-catch종류별로 오류를 받는다catch HabitError.duplicate(let n, let d)
try?실패를 nil로 바꾼다(try? total(for: "물")) ?? 0
try!실패하면 프로그램을 멈춘다실패할 수 없는 자리에만 쓴다
Result성공이나 실패를 값으로 보관한다.success(entry), .failure(error)
defer범위를 떠날 때 실행한다defer { print("끝") }

연습 문제

  1. HabitError에 양이 10000을 넘으면 던지는 tooLarge(Int) 케이스를 추가하려 한다. 바꿔야 할 곳을 코드로 보여라.
  2. "요가"처럼 모르는 습관의 합계를 0으로 취급하고 싶다. total(for:)를 호출하는 한 줄을 try?로 써라.
  3. parse("물,1,500")의 결과에서 양(amount)만 꺼낸 Result를 만들려 한다. 어떻게 쓰고, 결과는 무엇인가.
  4. 다음 함수를 호출했을 때 출력 순서를 쓰고 이유를 설명하라.
    func demo() {
        defer { print("A") }
        defer { print("B") }
        print("C")
    }

정답과 해설

  1. 케이스를 추가하고 add에 guard를 하나 더 둔다.
    enum HabitError: Error {
        case emptyName
        case unknownHabit(String)
        case invalidAmount(Int)
        case duplicate(name: String, day: Int)
        case tooLarge(Int)
    }
    
    // add 안, amount 검사 아래
    guard amount <= 10000 else { throw HabitError.tooLarge(amount) }
    report의 마지막 catch가 새 오류를 받으므로 기존 코드는 그대로 동작한다. 별도 메시지가 필요하면 catch HabitError.tooLarge(let value) 절을 추가한다.
  2. let sum = (try? journal.total(for: "요가")) ?? 0 이다. try?가 실패를 nil로 바꾸고 ??가 기본값 0을 채운다. 결과는 0이다.
  3. parse("물,1,500").map { $0.amount } 이다. map은 .success일 때만 안의 값을 변환하고 .failure는 그대로 둔다. 결과는 .success(500)이며 Result<Int, ParseError> 타입이다.
  4. 출력은 C, B, A 순서다. print("C")가 본문에서 먼저 실행되고, 함수를 떠날 때 defer가 나중에 적은 것부터 실행되기 때문이다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.