Skip to content

Error Handling

tomchoi edited this page Mar 20, 2026 · 1 revision

🚨 에러 핸들링 가이드

관련 기술: #Swift6.0 #TypedThrows #ErrorHandling #CleanArchitecture


기본 원칙

  • 각 레이어는 자신의 에러 타입만 외부로 노출한다
  • 하위 레이어의 에러 타입이 상위 레이어로 그대로 노출되지 않는다
  • 레이어 경계에서 에러를 변환(+Mapping.swift)한다
  • generic throws 사용 금지 — 항상 throws(SpecificError) 사용

에러 계층 구조

graph TD
    FW["🍎 시스템 프레임워크\n(AVFoundation, CoreData...)"]
    SE["ServiceError\n(Infrastructure 에러)"]
    RE["RepositoryError\n(Domain 정의)"]
    UE["UseCaseError\n(Domain 정의)"]

    FW -->|"+Mapping.swift\n(Infrastructure)| SE
    SE -->|"+Mapping.swift\n(Data/Repositories)| RE
    RE -->|"init(_:)\n(Domain/Errors)| UE
Loading

핵심 규칙: 에러는 항상 아래 → 위 방향으로 변환되며, 상위 레이어는 하위 레이어의 에러 타입을 알지 못한다.


레이어별 책임

레이어 에러 타입 정의 위치 변환 위치
Infrastructure (Service) XxxServiceError Data/Sources/Interfaces/ Infrastructure/+Mapping.swift
Data (Repository) XxxRepositoryError Domain/Sources/Errors/ Data/Repositories/+Mapping.swift
Domain (UseCase) XxxUseCaseError Domain/Sources/Errors/ Domain/Sources/Errors/+Mapping.swift (init)

녹음 기능 에러 흐름 (예시)

flowchart TD
    subgraph Framework["🍎 AVFoundation"]
        AE["NSError\n(AVAudioSession.ErrorCode)"]
    end

    subgraph Infrastructure["Data — Infrastructure"]
        M1["AudioRecorderServiceError\n+Mapping.swift"]
        ASE["AudioRecorderServiceError\n• alreadyRecording\n• sessionActivationFailed\n• mediaServicesFailed\n• startFailed\n• unknown(Error)"]
        M1 --> ASE
    end

    subgraph Repository["Data — Repositories"]
        M2["VoiceRecordStartRepositoryError\n+Mapping.swift"]
        RSE["VoiceRecordStartRepositoryError\n• alreadyRecording\n• startFailed\n• cancelled\n• unknown(Error)"]
        M2 --> RSE
    end

    subgraph Domain["Domain — UseCases"]
        UE["StartRecordingUseCaseError\n• alreadyRecording\n• startFailed\n• cancelled\n• unknown(Error)"]
    end

    AE -->|"NSError 코드 패턴 매칭"| M1
    ASE -->|"throw → catch"| M2
    RSE -->|"init(_ error: XxxRepositoryError)"| UE
Loading

각 변환 지점 코드

① AVFoundation → ServiceError (Infrastructure/Audio/AudioRecorderServiceError+Mapping.swift)

extension AudioRecorderServiceError {
    init(_ error: Error) {
        let nsError = error as NSError
        switch nsError.code {
        case AVAudioSession.ErrorCode.insufficientPriority.rawValue:
            self = .sessionActivationFailed
        case AVAudioSession.ErrorCode.mediaServicesFailed.rawValue:
            self = .mediaServicesFailed
        default:
            self = .unknown(error)
        }
    }
}

② ServiceError → RepositoryError (Data/Repositories/VoiceRecords/VoiceRecordStartRepositoryError+Mapping.swift)

extension VoiceRecordStartRepositoryError {
    init(_ error: AudioRecorderServiceError) {
        switch error {
        case .alreadyRecording:          self = .alreadyRecording
        case .sessionActivationFailed,
             .mediaServicesFailed,
             .startFailed:               self = .startFailed
        case .unknown(let e):            self = .unknown(e)
        }
    }
}

③ RepositoryError → UseCaseError (Domain/Sources/Errors/VoiceRecords/UseCases/StartRecordingUseCaseError.swift)

extension StartRecordingUseCaseError {
    init(_ error: VoiceRecordStartRepositoryError) {
        switch error {
        case .alreadyRecording:  self = .alreadyRecording
        case .startFailed:       self = .startFailed
        case .cancelled:         self = .cancelled
        case .unknown(let e):    self = .unknown(e)
        }
    }
}

+Mapping.swift 파일 위치 규칙

Data/
├── Sources/
│   ├── Infrastructure/
│   │   └── Audio/
│   │       └── AudioRecorderServiceError+Mapping.swift  ← 프레임워크 의존 매핑
│   └── Repositories/
│       └── VoiceRecords/
│           └── VoiceRecordStartRepositoryError+Mapping.swift  ← Service→Repository 매핑
Domain/
└── Sources/
    └── Errors/
        └── VoiceRecords/
            └── UseCases/
                └── StartRecordingUseCaseError.swift  ← init(_:)로 Repository→UseCase 매핑 포함

Infrastructure 매핑이 Infrastructure 폴더에 있는 이유: AVFoundation 같은 시스템 프레임워크를 import해야 하기 때문에 프레임워크 독립적이어야 하는 Interfaces 폴더에 둘 수 없다.


UseCase에서의 에러 처리 패턴

public func execute() async throws(StartRecordingUseCaseError) -> AsyncStream<Waveform> {
    if Task.isCancelled { throw .cancelled }
    do {
        return try await repository.startRecording()
    } catch {
        AppLogger.error(error)
        throw StartRecordingUseCaseError(error)  // 한 줄 변환
    }
}

catch 블록은 항상 두 가지만 한다:

  1. AppLogger.error(error) — 로깅
  2. throw XxxUseCaseError(error) — 변환 후 전파

에러 케이스 설계 원칙

  • cancelled: 모든 에러 타입에 존재, errorDescriptionnil 반환
  • unknown(Error): 예측 불가한 에러를 감싸 원본 정보 보존
  • 세분화: 상위 레이어로 올라갈수록 케이스가 줄어드는 것이 자연스럽다 (세부 원인을 추상화)
graph LR
    SE["ServiceError\n5가지 케이스\n(세분화됨)"]
    RE["RepositoryError\n4가지 케이스"]
    UE["UseCaseError\n4가지 케이스\n(추상화됨)"]

    SE -->|"세부 원인 병합"| RE
    RE -->|"1:1 매핑"| UE
Loading

Clone this wiki locally