SNMErrorHandler 만들기 ‐ 허혜민 - Team-HGD/SniffMEET GitHub Wiki

작업 내역

백로그

개선 배경

기존 코드 분석

기존의 SNMError는 두가지 Level에 대해서 Error를 정의합니다.

user에게 알려야하는 user Error, 단순히 개발자에게 알리면 되는 developer Error입니다.

  • 정의
struct SNMError: LocalizedError {
    enum ErrorLevel: String, Hashable {
        case user = "유저"
        case developer = "개발자"
    }
    let source: ErrorSource
    let error: any Error
    
    var errorDescription: String? {
        "\(source.rawValue) - \(error.localizedDescription)"
    }
}
  • 처리 예시
// presenter 

private func fetchNotificationList() {
        Task { [weak self] in
            guard let self else { return }
            do {
                guard let notiList = try await self.interactor?.fetchNotificationList(
                    page: self.currentPage,
                    pageSize: self.pageSize
                ) else { return }
                self.didFetchNotificationList(with: notiList)
           } catch let snmError as SNMError where snmError.level == .user {
                switch snmError.error {
                case let error as SupabaseDBError where error == .noMoreData:
                    self.didReachEndOfNotificationList()
                case let error as SupabaseSessionError where error == .sessionNotExist:
                    SNMLogger.error("세션이 존재하지 않습니다.")
                    // TODO: 로그인 화면으로 이동
                default:
                    SNMLogger.error(snmError.localizedDescription)
                }
            } catch let snmError as SNMError where snmError.level == .developer {
                SNMLogger.error(snmError.localizedDescription)
            }
       }
   }

이 구조에는 몇가지 문제점이 있습니다.

  1. 구체타입 에러에 따른 처리가 필요하다. (에러의 구체 Source를 알아야 한다)
  2. 1번으로 인해 catch문에서 중복 코드가 발생한다. (SessionError 처리 구문)
  3. 1번으로 인해 그냥 구체타입 에러를 사용하는 것과 다를바가 없다.

에러 처리 부분의 중복 코드가 가장 큰 문제였기에 에러 처리를 정의할 수 있는 Error Mapper를 구현하기로 했습니다.

어떻게 핸들링 해야할까?

우선 프로젝트 내 발생할 수 있는 에러를 모두 정리해보았습니다.

  • Supabase Session 에러
  • Network 에러
  • SupbaseDB 에러
  • SupabaseStorage 에러
  • DataManager 에러 (Keychain, UserDefaults, FileManager)
  • Util 객체 에러 (ImageSampler)
  • Foundation 에러 (JSON En/Decoder)
  • 기타 에러

나누다보니 level 기반 처리보다 어디에서 에러가 발생했는지 구분하고 Presenter에서 이에 맞게 처리하는 방향이 좋겠다는 생각이 들었습니다. 기존의 Level 방식 대신 Source 기반으로 에러를 분리해보았습니다.

Source에 따라 분리

  • 정의
struct SNMError: LocalizedError {
    enum ErrorSource: String {
        case session
        case supabaseDB 
        case supabaseStorage
        case dataManager
        case network
        case util // imageSampler와 같은 경우
        case foundation
    }
    let source: ErrorSource
    let error: any Error
    
    var errorDescription: String? {
        "\(source.rawValue) - \(error.localizedDescription)"
    }
}

Source에 따라 에러를 처리하는 Handler를 정의해보았습니다

struct SNMErrorHandler {
    private var handlers: [(SNMError.ErrorSource) -> Void] = []
    
    func handle(
        _ file: String = #file,
        _ function: String = #function,
        error snmError: SNMError
    ) {
        handlers.forEach{ handler in
            handler(snmError.source)
        }
        // default error
        if snmError.source == .session,
           let sessionError = snmError.error as? SupabaseSessionError {
            SupabaseSessionErrorHandler.handle(sessionError)
        }
        SNMLogger.error(
            file: file,
            function: function,
            snmError.localizedDescription
        )
    }
}

presenter의 handling 로직을 구현하면서 문제를 발견했습니다.

// any Presenter...

private func configureErrorHandlers() {
        fetchErrorHandler.configureHandler { [weak self] domain in
            switch domain {
            case .supabaseDB:
                break
            case .foundation:
		            break 
            default:
                break
            }
        }
    }

에러를 발생시킨 메서드가 어느 Source(Supabase, Network, Foundation 등등)를 사용하는지 Presenter를 구현하는 사람은 모두 알아야 한다는 것입니다. 메서드 간 결합도를 높이게 되는 방식이 됩니다.

이 방법은 개방 폐쇄 원칙(OCP)에도 취약합니다. 열거형이 개방 폐쇄 원칙에 취약하다는 사실은 많이 알려져 있는데요. 열거형의 특성과 더불어 Source라는 개념 때문에 OCP에 훨씬 취약하게 됩니다. 이 부분은 아래 Level 기반 처리 단락에서 더 자세히 다루겠습니다.

그냥 unknown 에러로 보내거나 switch문의 default로 뭉뜽그려 처리하면 될수도 있겠지만, 이러한 방식은 에러의 추적을 어렵게 만듭니다.

Source 기반 핸들링 방식을 폐기하고 Level 방식을 세분화하는 방식으로 돌아갔습니다.

Level 재적용 + 세분화

기존 SNMError도 Level 기반으로 동작하고 있었는데요. 이 부분을 조금더 세분화해 나타냈습니다. 단순히 user와 developer로 나누지 않고 에러를 어떻게 처리하면 되는지 동작을 더 명확하게 나타냅니다.

enum Level {
  case critical // 치명적인 오류
  case notExistSession  // 세션이 존재하지 않음
	case notifyUser // 유저에게 알림 
	case retryable // 다시 시도할 수 있음
	case logOnly // 로그만 남김 
}

notExistSession는 notifyUser와 합칠까말까 고민했습니다.

notExistSession는 세션이 종료(만료)되어 앱의 로그인 화면으로 사용자를 보내야 하고(앱의 흐름을 이탈) notifyUser는 단순히 사용자에게 에러가 발생했음을 알려야 합니다.(앱의 흐름을 이탈하지 않음) 둘의 처리 성격이 많이 다르기 때문에 분리하는 게 맞다고 생각했습니다.

어차피 열거형이니 개방 폐쇄 원칙에 취약한건 매한가지 아니냐고 질문하실수도 있겠습니다.

열거형의 특성으로 인해 코드 레벨에서 개방 폐쇄 원칙에 취약한 것은 사실이지만, Source 기반 처리와 다른점은 Level의 추가 가능성이 훨씬 적다는 점입니다.

Firebase 서비스를 새로 사용하게 된다면 Source에 추가해야 하지만, Level 기반 처리는 추가할 필요가 없습니다.

또한 Presenter에서는 일관성 있는 처리를 할 수 있게 됩니다.

Source 기반 처리에서는 메서드마다 사용하는 Source를 정리해 일일이 에러처리를 해야합니다. 이는 개선전 SNMError에도 동일하게 나타난 문제점이었습니다.

Presenter에서는 구체적인 에러 타입을 확인할 필요없이 오직 Level만 확인하고 에러를 처리합니다.

// any Presenter... 

// mutating func configure(handler: @escaping (SNMError.Level) -> Void)

private func configureErrorHandlers() {
    fetchErrorHandler.configure { [weak self] level in
        switch level {
        case .notifyUser:
          self?.didReachEndOfNotificationList()
        default:
          break
      }
   }
}

Context 추가

SNMLogger에는 에러가 발생한 지점을 호출한 시점에 자동으로 기록하고 있습니다.

handler와 logger를 결합하면서 이 기능이 정상적으로 동작하지 않는 문제가 생겼습니다.

SNMLogger를 호출하는 위치는 SNMErrorHandler의 handle(:) 입니다. 따라서 SNMErrorHandler를 통한 모든 에러는 발생 위치가 handle(:)로 기록됩니다.

 func handle(_ error: any Error) {
        guard let error = error as? SNMError else {
            SNMLogger.error("Not SNMError:", error.localizedDescription)
            return
        }        
        SNMLogger.error(
            error.errorDescription ?? error.localizedDescription
        )
    }

이를 해결하기 위해 아래와 같이 에러 핸들러에서 file의 이름과 function의 이름을 파라미터로 받을 수 있도록 설정했습니다.

func handle(
   _ file: String = #file, 
   _ function: String = #function, 
   _ error: any Error
) 

하지만 handler의 역할은 에러를 처리하는 로직을 담당하는 것입니다. 에러가 발생한 시점을 아는 것은 역할 외의 정보를 알게 되는 것이라고 판단되어 이 방법을 적용하지 않았습니다.

에러의 발생지를 기록하는 역할은 SNMError가 담당하게 되었습니다.

SNMError에 에러가 발생한 지점을 담은 Context라는 구조체를 만들어 생성자를 호출하는 시점에 file, function을 기록합니다.

struct SNMError: LocalizedError {
    let level: Level
    let error: any Error
    let context: Context

    init(
        level: Level,
        error: any Error,
        file: String = #file,
        function: String = #function
    ) {
        self.level = level
        self.error = error
        self.context = Context(file: file, function: function)
    }

    var errorDescription: String? {
        "\(level.rawValue) - \(error.localizedDescription)"
    }

    struct Context {
        let file: String
        let function: String
    }
}

이제 handler에서 발생지점을 명확하게 기록할 수 있게 되었습니다.

    /// 로그 출력이 default입니다.
    func handle(_ error: any Error) {
        guard let error = error as? SNMError else {
            SNMLogger.error("Not SNMError:", error.localizedDescription)
            return
        }
        
        SNMLogger.error(
            file: error.context.file,
            function: error.context.function,
            error.errorDescription ?? error.localizedDescription
        )
    }

Firebase Log 시스템과 결합

현재 릴리즈 모드일 때 Firebase의 Analytics를 사용하고 있습니다.

Firebase가 기본적으로 제공하는 로그 이외의 기능은 전혀 사용하고 있지 않았습니다.

SNMLogger는 로컬에만 로그를 기록합니다. 배포 후에는 사용자에게 어떤 에러가 발생했는지 알 방법이 전혀 없었습니다. 따라서 지속적인 앱 관리를 위해서는 Firebase에도 로그를 남기는 기능이 반드시 필요하다고 생각했습니다.

처음에는 Error handler에서 직접 Firebase를 import해 로그를 기록하는 방식을 적용했습니다.

팀원이 SNMLogger와 결합해 관리하면 SRP를 더 지킬수 있지 않겠냐는 좋은 의견을 주셔서

SNMLogger를 확장해 firebaseLog를 남길 수 있도록 했습니다.

#if !DEBUG
import FirebaseCoreExtension

extension SNMLogger {
    static func firebaseLog(
        level: FirebaseLoggerLevel,
        code: Int,
        _ message: String...
    ) {
        FirebaseLogger.log(
            level: level,
            service: "com.hgd1004.SniffMeet",
            code: "I-SNM\(String(format: "%6d", code))", // iOS는 I로 시작, SNM은 서비스 이름 3자리, 6자리 숫자코드
            message: message.joined(separator: " ")
        )
    }
}
#endif
func handle(_ error: any Error) {
	// ,,, 중략 
#if !DEBUG
            SNMLogger.firebaseLog(
                level: .error,
                code: error.level.code,
                error.errorDescription ?? error.localizedDescription
            )
#endif

}

결과

Context 결과와 함께 로그가 잘 출력됩니다.

1

세션이 만료되었다는 에러가 발생하면 자동으로 회원가입 뷰로 이동하게 됩니다.

2

firebase에서도 로그가 잘 기록되었는지 확인해보았습니다.

SNMLogger라는 이름으로 잘 찍히고 있었습니다.

3

추가 사항

최종 목표는 통합 에러 로깅 시스템을 구축하는 것이었습니다.

이를 위해서는 Usecase에서 SNMError로 매핑하는 작업이 선행되어야 합니다. 현재 구조에서는 한 가지 문제점이 존재하기 때문입니다.

SNMError가 아닌 에러는 에러의 Context를 기록하고 있지 않기 때문에 에러의 근원지를 찾지 못합니다.

이러한 에러로 SNMErrorHandler를 통해 핸들링을 하게 되면 에러의 Context가 SNMHandler로 기록됩니다.

 func handle(_ error: any Error) {
        guard let error = error as? SNMError else {
            SNMLogger.error("Not SNMError:", error.localizedDescription)
            return
        }
        switch error.level {
        case .notExistSession:
            sessionErrorHandler.handle(error.error)
        case .fatal, .notifyUser, .retryable, .logOnly:
            customErrorHandler.handle(error)
        }
 }

현재는 아래와 같이 SNMError인 경우와 그렇지 않은 경우를 나눠 처리하도록 했지만, 중복되는 코드가 많아지기에 고민되는 부분입니다.

Task { [weak self] in
            guard let self else { return }
            do {
                try await self.interactor?.deleteNotifcation(
                    notificationID: deleteNoti.id
                )
            } catch let error as SNMError {
                fetchErrorHandler.handle(error)
            } catch {
                fetchErrorHandler.handle(
                    SNMError(level: .logOnly, error: error)
                )
            }
        }

Usecase에서 에러를 매핑하는 작업도 상당히 중복된 부분이 많았습니다.

SupabaseDBError는 보통 .retryable, SessionError는 .notExistSession로 매핑됩니다.

SNMError의 생성자를 통해 특정 에러는 특정 level로 매핑되도록 구현하려 했으나, 최종적으로는 적용하지 않았습니다. 일관성이 떨어져 SNMError를 사용하는 동료들에게 혼란을 줄 수 있다는 점과 확장성이 떨어진다는 점이 이유였습니다.

이 부분은 더 고민해보고 개선할 필요가 있을 것 같습니다.