본문 바로가기
C.W.K.
Stream
Lesson 04 of 06 · published

뭐가 잘못됐는지 말해주는 에러

~15 min · swift-for-apple, errors, typed-throws, objc-exceptions

Level 0번들 열어본 사람
0 XP0/81 lessons0/17 achievements
0/100 XP to next level100 XP to go0% complete
"못 읽는 항목을 건너뛴 건 맞았어. 개수를 버린 게 버그였지."

Swift 에러는 설계할 수 있는 값이야

실패할 수 있는 Swift 함수는 throws로 선언하고, 호출하는 쪽은 try를 붙여야 해. Swift 6부터는 타입 있는 throws도 돼. throws(InboxError)라고 쓰면 호출자가 캐스팅 없이 실패의 종류를 정확히 알아. 에러 타입은 사람이 알아야 할 내용을 담는 그릇이야. 어느 파일인지, 어느 엔타이틀먼트인지, 어떻게 고치는지. CustomStringConvertible이나 LocalizedError를 붙이면 그게 화면에 보여줄 수 있는 문장이 돼.

try?는 어떤 에러든 nil로 바꿔버리는 비상구야. 실패해도 진짜 상관없는 데서만 써. 나머지 데선 지난 레슨의 조용한 else { return }이랑 쌍둥이야.

스스로 비었다고 보고한 큐

항목마다 파일을 따로 두는 캡처 큐가 폴더를 이렇게 읽었어. 매니페스트를 하나씩 디코딩하다가 실패하면 continue로 넘어가는 식이었지. 망가진 항목을 넘어서 계속 읽는 건 맞았어. 항목별 폴더 저장소를 쓰는 이유가 그거니까. 근데 디코딩 안 되는 매니페스트가 실패로 드러나질 않고 안 보이게 됐어. 파일은 디스크에 그대로 있는데 어디서 세든 항목이 없다고 나왔어. 로그도 없었고, 앱은 빈 큐를 보여줬지.

가정으로 끝날 얘기가 아니었어. 항목 타입에 필드가 처음 하나 추가되는 순간, 그 전에 쓴 매니페스트가 한꺼번에 디코딩이 안 돼(왜 그런지는 다음 레슨에서 볼 거야). 네트워크 없이 찍은 캡처의 유일한 사본이 그 큐에만 있는 폰이었다면, 앱을 한 번 업데이트하는 순간 큐가 아무 말 없이 비어버렸을 거야. 고친 버전은 건너뛰는 건 그대로 하되 숨기는 건 그만뒀어. 목록 함수는 읽은 항목 못 읽은 항목을 이유와 함께 돌려줘. 그러면 앱은 "이 빌드가 못 읽는 캡처 N개, 이 기기에 손대지 않고 그대로 있음"이라고 보여줘.

늘 성공만 하던 공유 확장

공유 확장 하나가 try?로 저장하고 무슨 일이 있었든 completeRequest를 불렀어. 파일이 안 불러와져도, 폴더가 없어도, iOS랑 사용자한텐 전부 성공으로 보고됐어. 가족이 여기서 가져온 규칙이 이거야. 확장은 어느 방향으로도 조용히 실패할 수 없어야 해. 거절은 시트에서 보이고, 에러는 알림이 되고, 멈춰버리면 타임아웃이 걸려서 알림으로 바뀌어.

Swift가 못 잡는 실패도 있어

Swift do/catch는 Swift 에러를 다뤄. Objective-C NSException은 못 잡아. 가족 받아쓰기 앱이 엔진에 탭을 하나도 안 단 상태에서 AVAudioEngine.prepare()를 불렀어. macOS 26에선 그게 AVFoundation 안에서 예외를 던지고, 앱은 실행 0.5초쯤 뒤에 SIGABRT로 죽었어. 어떤 catch도 소용없었을 거야. 막는 방법은 Objective-C API를 문서가 요구하는 순서와 상태를 지켜서 부르는 것뿐이야.

Code

타입 있는 throws와 건너뛴 걸 못 숨기는 목록·swift
import Foundation

struct Capture: Codable { let id: String; var text: String }

enum InboxError: Error, CustomStringConvertible {
    case containerMissing(group: String)
    case unreadable(file: String, reason: String)

    var description: String {
        switch self {
        case .containerMissing(let group):
            return "App Group container \(group) is not reachable — check the entitlement"
        case .unreadable(let file, let reason):
            return "\(file) could not be read: \(reason)"
        }
    }
}

func requireContainer(_ url: URL?, group: String) throws(InboxError) -> URL {
    guard let url else { throw .containerMissing(group: group) }
    return url
}

struct Listing<Item> {
    var items: [Item]
    var unreadable: [InboxError]      // the caller cannot accidentally not look
}

func listCaptures(in folder: URL) throws(InboxError) -> Listing<Capture> {
    let files: [URL]
    do {
        files = try FileManager.default.contentsOfDirectory(at: folder, includingPropertiesForKeys: nil)
    } catch {
        // A folder that cannot be listed is not an empty inbox.
        throw .unreadable(file: folder.lastPathComponent, reason: "\(error)")
    }
    var listing = Listing<Capture>(items: [], unreadable: [])
    for file in files where file.pathExtension == "json" {
        do {
            listing.items.append(try JSONDecoder().decode(Capture.self, from: Data(contentsOf: file)))
        } catch {
            listing.unreadable.append(.unreadable(file: file.lastPathComponent, reason: "\(error)"))
        }
    }
    return listing
}

External links

Exercise

Spark에 CaptureStore를 만들고, list()가 읽은 캡처와 못 읽은 파일을 이유와 함께 둘 다 돌려주게 해. 폴더에 JSON 파일 세 개를 넣고 하나를 손으로 망가뜨린 다음, 목록이 항목 둘과 원인이 적힌 실패 하나를 보고하는지 보여줘. 그 실패를 앱에서 보여줄 UI 문장도 한 줄 써봐.
Hint
못 읽은 목록을 로그 한 줄이 아니라 반환 타입의 일부로 만들어. 항목만 원한 호출자도 실패를 손에 쥐고 있을 수밖에 없게 만드는 게 핵심이야.

Progress

Progress is local-only — sign in to sync across devices.
이 페이지에서 버그를 발견하셨거나 피드백이 있으세요?문제 신고

댓글 0

🔔 답글 알림 (로그인 필요)
로그인댓글을 남기려면 로그인해 주세요.

아직 댓글이 없어요. 첫 댓글을 남겨보세요.