Today I Learn

Clean Architecture - Error 설계에 대한 고찰

Goniii 2025. 7. 2. 12:39

GitHub 전체 코드입니다.

https://github.com/LeeeeSuHyeon/Error_Clean_Architecture

 

 

 

배경과 문제의식

기존의 에러 처리 방식은 Manager, Repository, ViewModel, ViewController에 이르기까지 각 계층마다 다양한 에러 타입을 전달하고 처리하는 구조였다.

이로 인해 각 Rx 스트림마다 onError를 따로 구독하고 Alert을 띄우는 코드가 반복되어 코드 중복이 심하고,
에러가 발생할 경우 Rx 스트림이 끊겨 UI 이벤트가 무시되거나 앱의 흐름이 비정상적으로 종료되는 문제가 있었다.

 

개선 방향

  • 에러를 하나의 상태로 관리하는 방향으로 구조로 개편
  • ViewModel 내부에서 에러를 Observable<AppError>로 상태 관리하고, 각 Task 내부에서 catch 처리 후 _error.onNext(...) 형태로 이벤트를 방출
  • 에러는 공통 Enum인 AppError로 통합되며, ViewController에서는 이 하나의 스트림만 구독하여 Alert을 띄우고, 로그를 출력하는 방식으로 단순화

 

구현 방식 요약

  • LocalizedError 프로코톨을 채택한 AppErrorProtocol을 정의해 errorDescription, debugDescription을 공통 인터페이스로 설계.
  • NetworkError, CoreDataError 등 각 도메인 에러는 이 프로토콜을 채택해 구성.
  • 모든 에러는 Domain 계층(레포지토리)에서 AppError로 Wrapping되어 전달됨.
  • ViewModel에서는 catch 블록에서 AppError로 변환 후 상태로 방출.
  • ViewController는 ViewModel의 state.error를 통해 Alert 출력과 로그 디버깅을 통합적으로 처리.

 

우선 기존 구조에 대해 살펴보자

 

기존 구조

Domain Layer에 정의한 Error 타입을 Data Layer, Presentation Layer에서 모두 사용하는 구조

 

 

기존 파일 구조 예시

 

1. Domain Layer

1-1. Error

  • 프로젝트 전체에서 사용하는 에러 타입 정의
/// CoreData 에러
enum BeforeCoreDataError: Error {
    case entityNotFound
}

/// 네트워크 에러
enum BeforeNetworkError: Error {
    case invalidURL
}

1-2. UseCase

  • Repository로 부터 받은 에러를 그대로 ViewModel에 전달
final class BeforeUseCase {
    private let repository: Repository

    init(repository: Repository = Repository()) {
        self.repository = repository
    }

    func executeCoreData() async throws {
        try await repository.fetchCoreData()
    }

    func executeNetwork() async throws {
        try await repository.fetchNetworkData()
    }
}

2. Data Layer

2-1. Manager

  • Network, CoreData에 접근하여 데이터 불러오기 및 에러 처리
  • 에러를 반환하는 구조
  • 매니저는 모두 Domain에서 정의한 에러를 던짐
/// CoreData Manager
final class BeforeCoreDataManager {
    func execute() async throws {
        throw CoreDataError.entityNotFound("entityNotFound")
    }
}

/// Network Manager
final class BeforeNetworkManager {
    func execute() async throws {
        throw NetworkError.invalidURL("invalidURL")
    }
}

2-2. Repository

  • Repository는 Manager에서 던진 에러를 그대로 UseCase로 전달
final class BeforeRepository {
    private let coreDataManager: BeforeCoreDataManager
    private let networkManager: BeforeNetworkManager

    init(
        coreDataManager: BeforeCoreDataManager = BeforeCoreDataManager(),
        networkManager: BeforeNetworkManager = BeforeNetworkManager()
    ) {
        self.coreDataManager = coreDataManager
        self.networkManager = networkManager
    }

    /// CoreData 데이터 가져오기
    func fetchCoreData() async throws {
        try await coreDataManager.execute()
    }

    /// 네트워크 데이터 가져오기
    func fetchNetworkData() async throws {
        try await networkManager.execute()
    }
}

3. Presentation Layer

3-1. ViewModel

  • fetchData() 메서드에서 UseCase를 통해 데이터 또는 에러를 받아옴
  • 에러 발생 시 Output의 data에 onError()로 에러를 던져 스트림을 끊고 에러 전달
final class BeforeViewModel {
    private let useCase: UseCase
    private let disposeBag = DisposeBag()

    /// 사용자 입력
    enum Input {
        case viewDidLoad
    }

    /// View에 필요한 데이터
    struct Output {
        var data: Observable<String>
    }

    private let _input = PublishSubject<Input>()
    var input: AnyObserver<Input> { _input.asObserver() }

    private let _data = PublishSubject<String>()
    let output: Output

    init(useCase: UseCase = UseCase()) {
        self.useCase = useCase
        output = Output(
            data: _data.asObservable(),
        )

        bindAction()
    }

    private func bindAction() {
        _input.subscribe(with: self) { owner, input in
            switch input {
            case .viewDidLoad:
                owner.fetchData()
            }
        }.disposed(by: disposeBag)
    }

    /// 데이터 불러오기
    private func fetchData() {
        Task {
            do {
                try await useCase.executeCoreData() // CoreData 접근
                try await useCase.executeNetwork()  // Network 접근
                _data.onNext("데이터 불러오기")
            } catch {
                // 에러 스트림 생성 및 스트림 해제
                _data.onError(error)
            }
        }
    }
}

3-2 ViewController

  • ViewModel의 Output을 구독하여 전달받은 데이터를 사용하거나
  • 에러를 전달받아 사용자에게 에러 메시지를 띄움
/// Output 바인딩
private func bindViewModel() {

    // 데이터 바인딩
    viewModel.output.data
        .observe(on: MainScheduler.instance)
        .subscribe(onNext: { data in
            print(data)
        }, onError: { [weak self] error in // 에러 처리
            // Alert 띄우기 (error.localizedDescription)
            let alertViewController = UIAlertController(
                title: "에러",  // Alert Title 설정
                message: error.localizedDescription, // Alert Message 설정
                preferredStyle: .alert
            )
            let confirmAction = UIAlertAction(title: "확인", style: .default)
            alertViewController.addAction(confirmAction)
            self?.present(alertViewController, animated: true)
        }).disposed(by: disposeBag)
}

 

기존 구조 문제점

  • 에러에 대한 Alert Title을 직접 관리하기 떄문에 일관성을 위해 개발자가 신경써야 함
  • Error 스트림이 끊기기 때문에 재요청 불가
  • Rx 스트림마다 onError를 따로 구독하고 Alert을 띄우는 코드가 반복되어 코드 중복이 심함
    • ex)
    // 데이터 바인딩
    viewModel.output.data1
       .observe(on: MainScheduler.instance)
       .subscribe(onNext: { data in
          print(data)
       }, onError: { [weak self] error in // 에러 처리
          // Alert 띄우기
    }).disposed(by: disposeBag)
    
    // 데이터 바인딩
    viewModel.output.data2
       .observe(on: MainScheduler.instance)
       .subscribe(onNext: { data in
          print(data)
       }, onError: { [weak self] error in // 에러 처리
          // Alert 띄우기
    }).disposed(by: disposeBag)
    
    // 데이터 바인딩
    viewModel.output.data3
       .observe(on: MainScheduler.instance)
       .subscribe(onNext: { data in
          print(data)
       }, onError: { [weak self] error in // 에러 처리
          // Alert 띄우기
    }).disposed(by: disposeBag)

 


 

개선 방향

  • 에러를 하나의 상태로 관리하는 방향으로 구조로 개편
  • ViewModel 내부에서 에러를 Observable<AppError>로 상태 관리하고, 각 Task 내부에서 catch 처리 후 _error.onNext(...) 형태로 이벤트를 방출
  • 에러는 공통 Enum인 AppError로 통합되며, ViewController에서는 이 하나의 스트림만 구독하여 Alert을 띄우고, 로그를 출력하는 방식으로 단순화

 

개선 파일 구조 예시

 

1. Domain Layer

1-1. AppError

  • 프로젝트 전체에서 사용하는 에러 타입 정의
  • 사용자에게 보여줄 에러 메시지와 디버깅 전용 에러 메시지 구분
    • UIAlertController의 message 값으로 errorDescription을 사용
  • Repository에서 Data Error 타입을 Domain Error로 래핑할 때 사용
    • Data Error에서 정의한 errorDescription, debugDescription 사용
/// 모든 에러 타입의 프로토콜 (사용자 친화적인 에러메시지와 디버깅 구분을 위해)
protocol AppErrorProtocol: LocalizedError {
    var errorDescription: String? { get }
    var debugDescription: String { get }
}

/// 앱에서 발생할 수 있는 모든 에러 타입
enum AppError: AppErrorProtocol {
    case network(AppErrorProtocol)
    case coreData(AppErrorProtocol)
    case unknown(Error)
}

extension AppError {
    /// 사용자에게 보여줄 에러 메시지 정의
    var errorDescription: String? {
        switch self {
        case .network(let error), .coreData(let error):
            return error.errorDescription // Data Error에서 정의한 errorDescription, debugDescription 사용
        case .unknown:
            return "알 수 없는 에러가 발생했습니다."
        }
    }

    /// 개발자 디버깅용 에러 메시지 정의
    var debugDescription: String {
        switch self {
        case .network(let error), .coreData(let error):
            return error.debugDescription // Data Error에서 정의한 errorDescription, debugDescription 사용
        case .unknown(let error):
            return "unknonwn Error: \\(error.localizedDescription)"
        }
    }
}

1-2. UseCase

  • 기존 UseCase 구조와 동일
  • Repository로 부터 받은 에러를 그대로 ViewModel에 전달
final class UseCase {
    private let repository: Repository

    init(repository: Repository = Repository()) {
        self.repository = repository
    }

    func executeCoreData() async throws {
        try await repository.fetchCoreData()
    }

    func executeNetwork() async throws {
        try await repository.fetchNetworkData()
    }
}

2. Data Layer

2-1. Domain Error

  • Domain Layer에서만 사용되는 에러 타입 정의
  • 각 에러 타입의 세부 에러 타입
/// 각 에러 타입의 세부 에러 타입
enum CoreDataError: AppErrorProtocol {
    case entityNotFound(Error)
    case readError(Error)
}

extension CoreDataError {
    /// 사용자 친화적인 에러 메시지
    var errorDescription: String? {
        switch self {
        case .entityNotFound:
            "데이터 접근에 실패했습니다."
        case .readError:
            "데이터를 읽어오는 데 실패했습니다."
        }
    }

    /// 디버깅용 에러 메시지
    var debugDescription: String {
        switch self {
        case .entityNotFound(let error):
            "entityNotFound: \\(error.localizedDescription)"
        case .readError(let error):
            "readError: \\(error.localizedDescription)"
        }
    }
}
/// 각 에러 타입의 세부 에러 타입
enum NetworkError: AppErrorProtocol {
    case invalidURL(Error)
    case failToDecode(Error)
}

extension NetworkError {
    /// 사용자 친화적인 에러 메시지
    var errorDescription: String? {
        switch self {
        case .invalidURL:
            "유효하지 않은 URL입니다."
        case .failToDecode:
            "데이터 변환 실패"
        }
    }

    /// 디버깅용 에러 메시지
    var debugDescription: String {
        switch self {
        case .invalidURL(let error):
            "invalidURL: \\(error.localizedDescription)"
        case .failToDecode(let error):
            "failToDecode: \\(error.localizedDescription)"
        }
    }
}

2-2. Manager

  • 기존 매니저와 동일
  • Network, CoreData에 접근하여 데이터 불러오기 및 에러 처리
  • 에러를 반환하는 구조
  • 매니저는 모두 DataLayer에서 정의한 에러를 던짐 (AppError에 직접 접근 x)
/// CoreData Manager
final class CoreDataManager {
    func execute() async throws {
        throw CoreDataError.entityNotFound("entityNotFound")
    }
}

/// Network Manager
final class NetworkManager {
    func execute() async throws {
        throw NetworkError.invalidURL("invalidURL")
    }
}

2-3. Repository

  • Repository는 Manager에서 던진 에러를 AppError로 래핑하여 UseCase로 전달
  • 이와 같이 설계하면 Domain, Presentation Layer는 각 Error 타입의 세부 내용을 알 수 없음
final class Repository {
    private let coreDataManager: CoreDataManager
    private let networkManager: NetworkManager

    init(
        coreDataManager: CoreDataManager = CoreDataManager(),
        networkManager: NetworkManager = NetworkManager()
    ) {
        self.coreDataManager = coreDataManager
        self.networkManager = networkManager
    }

    func fetchCoreData() async throws {
        do {
            try await coreDataManager.execute()
        } catch let error as CoreDataError {
            // 에러 핸들링 (Data Error -> Domain Error 변환)
            throw AppError.coreData(error)
        }
    }

    func fetchNetworkData() async throws {
        do {
            try await networkManager.execute()
        } catch let error as NetworkError {
            // 에러 핸들링 (Data Error -> Domain Error 변환)
            throw AppError.network(error)
        }
    }
}

3. Presentation Layer

3-1. AppError + Extension

  • ViewController에서 사용자에게 보여줄 Alert의 Title을 각 에러 타입마다 일관되게 설정
  • 즉, UIAlertController를 생성할 때 error 타입에서 title과 message 설정 가능
    • title: AppError.alertTitle
    • message: AppError.errorDescription
extension AppError {
    /// Error Alert에 표시될 Title 설정
    var alertTitle: String {
        switch self {
        case .network:
            "네트워크 에러"
        case .coreData:
            "저장소 에러"
        case .unknown:
            "알 수 없는 에러"
        }
    }
}

3-2. ViewModel

  • 에러를 하나의 output으로 설정하여 모든 데이터에 대해 onError로 받지 않고, 하나의 output을 구독하여 관찰 → 중복 코드 제거
  • fetchData() 메서드에서 UseCase를 통해 데이터 또는 에러를 받아옴
  • do-catch 문법은 모든 에러 타입을 Error 프로토콜로 받기 때문에 AppError로 타입 캐스팅 필요
  • handleError() 메서드 내 error.debugDescription으로 디버깅 로그 작성
  • Output의 error 속성에 에러 전달
final class ViewModel {
	
	... 
  struct Output {
        var data: Observable<String>
        var error: Observable<AppError> // 에러 타입을 따로 선언 (AppError 타입)
    }

    /// 데이터 불러오기
    private func fetchData() {
        Task {
            do {
                try await useCase.executeCoreData()
                try await useCase.executeNetwork()
                _data.onNext("데이터 불러오기")
            } catch {
                handleError(error)
            }
        }
    }

    /// 에러 핸들링
    private func handleError(_ error: Error) {
        // 받은 에러를 AppError 타입으로 변환 후 Output.error에 방출
        if let error = error as? AppError {
            _error.onNext(error)
            NSLog(error.debugDescription)
        } else {
            _error.onNext(AppError.unknown(error))
            NSLog(error.localizedDescription)
        }
    }
}

3-2 ViewController

  • ViewModel의 Output을 구독하여 전달받은 데이터를 사용
  • 에러는 따로 하나의 프로퍼티로 전달받아 일관되게 처리
  • AppError 타입에 정의한 AlertTitle, errorDescription으로 일관된 에러 Alert 구현 가능
  • Output의 프로퍼티 개수가 증가해도 error에 대한 바인딩은 하나만 있으면 됨으로 중복 코드를 줄일 수 있음
  • error 스트림 자체는 끊지 않기 때문에 지속적인 에러 구독과 재시도 가능
/// Output 바인딩
private func bindViewModel() {

    // 데이터 바인딩
    viewModel.output.data
        .bind(with: self) { owner, data in
            print(data)
        }.disposed(by: disposeBag)

    // 에러 바인딩
    viewModel.output.error
        .observe(on: MainScheduler.asyncInstance)
        .bind(with: self) { owner, error in
            // Alert 띄우기 (error.localizedDescription)
            let alertViewController = UIAlertController(
                title: error.alertTitle,             // Alert Title 설정
                message: error.localizedDescription, // Alert Message 설정
                preferredStyle: .alert
            )
            let confirmAction = UIAlertAction(title: "확인", style: .default)
            alertViewController.addAction(confirmAction)
            owner.present(alertViewController, animated: true)
        }.disposed(by: disposeBag)
}

개선 효과

  • 에러 흐름이 단순해지고 명확해져 코드 가독성과 디버깅 효율이 크게 향상
  • Rx 스트림의 중단 없이 에러를 비동기 상태로 처리할 수 있어, UX 흐름이 안정화
  • ViewController에서 Alert 띄우는 로직이 중앙화되어 중복 제거가 가능했고, 새로운 화면을 추가할 때도 일관된 방식으로 대응할 수 있게 됨

한계

  • ViewController에서 동일한 에러 처리 (Alert Present)가 아닌 각각 다른 처리를 필요로 한다면, 개선된 구조가 유연성이 떨어질 수 있다.
  • (output.error를 구독하는 부분에서 전달받은 AppError 타입을 switch-case로 나누어 분기한다면 극복할 수 있을 것 같다)

 

느낀 점

이번 구조 개선을 통해 비즈니스 로직과 에러 처리 로직의 분리, Rx 스트림의 일관된 흐름 유지, 에러의 목적별 메시지 분리(사용자용 vs 디버깅용) 등의 설계적 중요성을 체감했다

특히 “에러는 이벤트로 처리하고, 상태로 전달한다”는 원칙을 직접 설계 및 구현하며, Clean Architecture에 있어 에러 처리 또한 명확한 책임 분리가 필요한 영역임을 깊이 이해하게 되었다.

728x90