-
[Flutter] 에러 핸들링, Custom Exception 관리하기Flutter/project 2024. 1. 15. 19:00
에러가 일어나지 않는 애플리케이션은 세상에 존재할 수없다. 로그인할 때조차도 값을 잘못 입력하면 '비밀번호가 틀렸습니다' 라며 에러가 발생한다. 일반적으로 http통신을 사용해본 적이 있다면 statusCode(상태코드)를 확인하고 try/catch를 이용한 에러핸들링은 접해본 적이 있을 것이다. 하지만 실제로 협업을 하다 보면 사내 또는 팀에서 정한 커스텀 에러코드를 사용하는 일이 훨씬 많다. 이번시간에는 내가 효율적으로 에러 핸들링을 하기 위해 어떻게 예외처리를 했는지 정리하려고 한다.
보통 강의를 보게되면 상태코드를 통한 에러처리만 가르쳐 준다. 틀린 것은 아니지만 팀 프로젝트에 쓰기엔 부족하다. 에러가 일어나게 되는 상황은 매우 다양한데 서버 측에서도 그런 다양한 에러들을 성격에 맞게 커스텀하며 관리를 하는 게 일반적이며, 어떤 경우엔 클라이언트단의 요구에 따라 일부러 에러를 일으켜줄 때도 있다.
토스페이먼츠 에러코드 일부 만약 도메인서버에서 에러코드를 사용하지않는다하더라도 위와 같이 토스페이먼츠 SDK를 사용하게돼서 에러 처리를 하려고 보니 에러코드로 에러핸들링을 하게끔 되어있다면? 어떤 개발환경이든 에러코드를 사용한 예외처리+에러핸들링을 하게되는 상황은 벌어지게된다.
그럼 먼저 에러코드가 어디에, 어떤 형식으로 우리와 마주하게되는 지 알아보자.
✓ Exception Model 정의 (Dio 기준)
사내 관행에따라 조금씩 다르겠지만 기본적으로 서버에서 커스텀 에러를 반환해줄때 다음과 같은 내용으로 구성된다.
- errorCode: 서버에서 지정한 문자열이나 숫자로서 에러 구분을 위한 코드 Client에 반환하기도 하고 서버 내부에서도 예외처리를 위해 정해 놓는 경우도 있다.
- timestamp: 에러가 발생한 시간, 로그 분석 및 디버깅에 사용
- statusCode: HTTP 응답 상태코드를 커스텀하게 사용하기위해 존재
- msg: 에러를 설명한 메시지, 로그 분석 및 디버깅에 사용
- path: 에러가 발생한 요청 경로
error response api 통신을 할 때, 기본적으로 try/catch를 이용해 예외처리를 한다.
이때 http 에러는 DioError 객체를 통해 받게 된다.
DioError를 들여다보면 통신에 성공했을 때 받는 response과 같은 객체를 return 받는 것을 알 수 있고,
이를 통해 서버가 정의한 에러 객체를 확인할 수 있다.
통신이 성공했을 때 json 객체를 모델화하는 것과 마찬가지로 에러에도 response을 통해 json 객체를 받을 수 있기 때문에 에러객체의 양식만 알고 있다면 model class를 작성할수있다.
좌: 통신에 성공했을때 모델화 하는 전형적인 코드, 우: error Response를 모델화하는 코드 import 'package:json_annotation/json_annotation.dart'; part 'exception_model.g.dart'; @JsonSerializable() class ExceptionModel { final String errorCode; final String timestamp; final int statusCode; final String msg; final String path; ExceptionModel({ required this.errorCode, required this.timestamp, required this.msg, required this.statusCode, required this.path, }); factory ExceptionModel.fromJson(Map<String, dynamic> json) => _$ExceptionModelFromJson(json); }
✓ CustomException with @freezed
에러 코드를 받았다면 이를 통해 예외처리를 해야 한다.
class DioError implements Exception
DioError클래스를 살펴보면 Exception을 implements 하는 것을 알 수 있다.
이점을 이용하면 Exception클래스를 implements 한 CustomException을 작성하면 DioError가 발생했을 시 반환받을 예외를 errorCode에 따라 다르게 받을 수 있다. 그렇기 때문에 DioError가 발생하면 errorCode에 따라 해당되는 객체를 반환하기 위해 CustomExecpions 클래스에 반환받을 인스턴스를 미리 정의해 두고 상황에 따라 예외처리를 하는 방식이 좋다.
에러코드는 서버와 클라이언트 간의 약속이다. 에러코드는 빌드 등의 이유로 외부로부터 값이 변경될 일은 없기 때문에 불변성이 보장된다.
불변성을 보장한다면 패턴매칭이 가능해지므로 패턴매칭 메서드를 제공하는 freezed를 사용하면 다음과 같이 작성할수있다.
part 'exceptions.freezed.dart'; @freezed class CustomExceptions with _$CustomExceptions implements Exception { const factory CustomExceptions.invalid(String message) = Invalid; const factory CustomExceptions.expiredSmsCode(String message) = ExpiredSmsCode; const factory CustomExceptions.missMatchSmsCode(String message) = MissMatchSmsCode; const factory CustomExceptions.userNotFound() = UserNotFound; const factory CustomExceptions.emailNotFound() = EmailNotFound; const factory CustomExceptions.emailExistsWithAnotherSocialType() = EmailExistsWithAnotherSocialType; ... }
- const factory
- const는 factory와 함께 사용하면 컴파일타임 상수를 선언하는 키워드이면서 객체의 불변성을 보장하는 역할을 한다.
- factory는 싱글톤 패턴형식으로 서브클래스의 인스턴스를 반환할 때 사용되는 예약어이며,
현재 코드에서는 CustomException을 반환하는 역할을 한다.
이제 exception을 커스텀해서 사용할 수 있게 되었다. 여태까지 한 것을 문자인증 로직에 비유를 하면 아래와 같다.
sms문자인증을 진행 중인 상황에서 확인문자(smsCode)를 잘못입력해 에러 발생한 상황
- try/catch에서 on DioError catch {}를 통해 에러 객체를 받는다.
- errorModel의 fromJson()을 통해 인스턴스로 받은 뒤, errorCode를 추출한다.
- 후처리를 위해 MissMatchSmsCode (CustomException)로 반환한다.
✓ 예외처리 모듈화
통신에 의한 에러는 api 통신이 시작되는 모든 코드에서 발생할 수 있다. 그럼 try/catch가 있는 모든 코드에 예외처리에 대한 로직을 작성할 것인가? 가독성 및 유지보수의 측면으로 조금만 생각해도 머리가 아파온다. 또한 모든 로직에 try/catch가 중복되는 것도 마음에 들지 않는다. 우리는 반복되는 에러 처리 로직을 모듈화 해야 할 필요성이 있다.
📌 errorHandler가 있는 api call() 함수 만들기
try/catch중에 catch시 customExcetion을 반환하는 함수를 만들어야 한다.
조건은 다음과 같다.
- api통신을 시도하는 함수를 파라미터로 받아야 한다.
- JavaScript, Python, Kolin을 비롯해 Dart 언어도 함수를 일급 객체(First-class object)로 취급하기 때문에 함수를 매개변수로서 전달이 가능하다.
- return은 어떤 값이든 가능해야 한다.(제네릭 사용)
- 에러 발생 시, customExcetion을 반환해야 한다.
Future<T> apiCall<T>(Future<T> Function() task) async { try { T value = await task(); } on DioError catch (e){ final errorModel = ExceptionModel.fromJson(e.response!.data); switch (errorModel.errorCode) { case '0000': return const CustomExceptions.userNotFound(); case '0001': return const CustomExceptions.emailNotFound(); case '0002': return const CustomExceptions.emailExistsWithAnotherSocialType(); ... default: return Failure(CustomExceptions.dioUnknown('통신 에러입니다')); } } catch (e) { CustomExceptions.unCaughtByFront('알수없는 에러입니다: ${e.toString()}'); } }
📌 Result 패턴 추가
제 프로젝트에 적용한 패턴이라 추가한 내용입니다. 에러 핸들링이랑 관계없으니 굳이 도입하지 않으셔도 됩니다.
result 패턴은 함수 또는 메서드의 실행 결과를 명시적으로 처리하는 디자인 패턴이다.
적용 방법은 간단하다.
먼저 result라는 추상클래스를 상속하는 success class와 fail class를 만들어 놓는다.
에러가 발생하지않았다면 Success 객체에 데이터를 담아서 반환하고,
에러가 발생하면 Fail 객체에 exception을 담아서 반환한다.
토스페이먼츠 결제 시스템을 도입할때 사용되는 "tosspayments_sdk_flutter" 패키지를 보면
TossPaymentResult라는 추상클래스를 두고 서브클래스로 Success와 Fail을 만든 후, Result 클래스로 두 클래스를 포장하고 있다.
실제로 반환되는 것은 Success와 Fail 객체이며 반환타입으로서 Result를 사용한다.
import 'dart:core'; /// [TossPaymentResult] is an abstract class representing the result of a Toss payment operation. abstract class TossPaymentResult {} /// [Success] class extends [TossPaymentResult] and is used when a Toss payment operation is successful. class Success extends TossPaymentResult { Success(this.paymentKey, this.orderId, this.amount, this.additionalParams); final String paymentKey; final String orderId; final num amount; final Map<String, String>? additionalParams; @override String toString() { return 'paymentKey : $paymentKey\norderId : $orderId\namount : $amount\nadditionalParams: $additionalParams'; } } /// [Fail] class extends [TossPaymentResult] and is used when a Toss payment operation fails. class Fail extends TossPaymentResult { Fail(this.errorCode, this.errorMessage, this.orderId); final String errorCode; final String errorMessage; final String orderId; } class Result { final Success? success; final Fail? fail; const Result({this.success, this.fail}); }
프로젝트에 적용해 보자
나 같은 경우에는 Result에 switch를 이용한 패턴매칭을 적용하기위해 sealed class를 사용했다.
sealed class Result<S, E extends Exception> { const Result(); } final class Success<S, E extends Exception> extends Result<S, E> { const Success(this.value); final S value; } final class Failure<S, E extends Exception> extends Result<S, E> { const Failure(this.exception); final E exception; }
sealed class에 대해 궁금하다면 아래 글을 참고해주세요
[Flutter] 상태관리는 어떻게 해야하는 걸까? feat. sealed class
퍼블리싱만 하던 단계에선 상태관리가 무엇인지 신경 쓰지 않고 setState()를 남발하면서 만들었었다. 하지만 api와 연동할 때쯤에 프로젝트가 난잡해져서 결국 눈물을 머금고 setState()를 걷어내다
nomal-dev.tistory.com
이제 error가 발생하지 않으면 Success객체를, 실패하면 Failure객체를 반환하도록 apiCall()을 수정하면 된다.
아래는 최종적으로 완성된 exception.dart 파일이다.
import 'dart:io'; import 'package:amplify_flutter/amplify_flutter.dart'; import 'package:dio/dio.dart'; import 'package:flutter/services.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; import '../result.dart'; import '../model/exception_model.dart'; part 'exceptions.freezed.dart'; String prefix = '[ERR]'; @freezed class CustomExceptions with _$CustomExceptions implements Exception { const factory CustomExceptions.invalid(String message) = Invalid; const factory CustomExceptions.expiredSmsCode(String message) = ExpiredSmsCode; const factory CustomExceptions.missMatchSmsCode(String message) = MissMatchSmsCode; const factory CustomExceptions.userNotFound() = UserNotFound; const factory CustomExceptions.emailNotFound() = EmailNotFound; ... } Future<Result<T, CustomExceptions>> apiCall<T>(Future<T> Function() task) async { try { T value = await task(); return Success(value); } on DioError catch (e) { String prefix = '[ERR]'; if (e.response == null) { return Failure(CustomExceptions.invalid('$prefix 서버로부터 응답이 없습니다')); } final errorModel = ExceptionModel.fromJson(e.response!.data); switch (errorModel.errorCode) { case '0000': return const Failure(CustomExceptions.userNotFound()); case '0001': return const Failure(CustomExceptions.emailNotFound()); case '0002': return const Failure( CustomExceptions.emailExistsWithAnotherSocialType()); case '0003': return const Failure(CustomExceptions.jwtInvalidToken()); ... case '4510': return const Failure(CustomExceptions.orderAlreadyTakeException()); case '9999': return Failure( CustomExceptions.unCaught('$prefix 서버와 클라이언트간에 문제가 발생했습니다')); default: return Failure(CustomExceptions.dioUnknown('$prefix 통신 에러입니다')); } } catch (e) { return Failure( CustomExceptions.unCaughtByFront('알수없는 에러입니다: ${e.toString()}')); } }
✓ 하나의 CustomException Class, 하나의 ErrorHandling Function
가독성 및 유지보수 측면에서 CustomException Class를 세분화해서 여러 클래스로 만들고 apiCall()도 데이터 소스(도메인 서버, 외부 API 등)마다 다르게 두는 건 어떨지 생각해 본 적 있다. 하지만 이는 조금만 생각해 보면 틀렸다는 것을 알 수 있다.
(나는 코드를 몇 줄 적고 나서야 알았다.)api마다 정확하게 어떤 에러가 발생하는지는 서버 개발자도 알기 힘들다. 그리고 하나만 발생할지 여러 개가 발생할지 어떤 순서로 발생하는지 예측하는 것은 굉장히 어려운 일이다.
하지만 일단 "클라이언트에서 관심사마다 어떤 에러가 발생하는지 알게 되어서 분리 했다"고 가정해 보자.
그럼 다음과 같은 문제점이 발생한다.
📌 중복되는 코드
인증·인가 프로세스가 있는 앱이라면 서버사이드에서 모든 api를 받을 때 nestjs라면 Guard를 통해, spring boot라면 spring Security를 통해 토큰 유효성 검사를 하게 되고 통과하지 못한다면 유효하지 않는 토큰이라는 뜻의 에러를 반환한다.
그렇다면 모든 customException class에 해당 예외처리가 반드시 필요하기 때문에 중복코드가 발생하게 된다.
📌 클래스의 일관성, 유연성이 떨어진다
여러 에러핸들링 함수를 사용하면 동일한 예외에 대해 다른 방식으로 처리될 수도 있다. 만약 예외처리 로직을 변경할 때, 클래스를 분리한 기준을 헤치게 된다면 나눈 의미가 없어지게 될 것이다.
menu repository 코드 일부, "apiCall() -> runCatchingExecptions()" 안드레아가 DDD(도메인 주도 설계) 관점에서 알려준 에러핸들러 제작 방법을 프로젝트에 맞게 변형하면서 겪은 경험들을 바탕으로 작성 해 보았습니다.
이 로직의 가장 큰 매력은 api 통신을 하다가 일어나는 에러는 도메인서비스에 영향을 주지 못한다는 것입니다. 레포지토리 패턴을 기준으로 설명하자면 DataSource Layer(repository)에서 일어나는 에러가 Application Layer(service)까지 넘어오지 않는다는 의미입니다.
에러핸들링이라는 용어조차 생소했던 시절에는 위의 메커니즘을 이해하기가 어려웠지만 프로젝트의 중후반부를 지나면서 깔끔한 레포지토리 로직 작성과 쉬운 확장성 및 유지보수 측면에서 또다시 매력을 느꼈던 로직입니다. DDD를 생각하시고 있다면 한번 사용해보시는걸 추천드립니다.
긴글 읽어주셔서 감사합니다.
레포지토리 패턴이 적용된 리버팟 아키텍처의 대략적인 원리를 알고싶다면 다음 글을 참고 해 주세요
[Flutter] Repository pattern + Riverpod feat.안드레아
로그인 회원가입 등 구현에 대해 이야기하기 전, 아키텍처에 관해 이야기를 먼저 해야 구현 부분을 매끄럽게 이어갈 수 있겠다고 생각했다. 아키텍처에 대해 진지하게 생각한 것은 한 백엔드
nomal-dev.tistory.com
이 글이 꼭 정답은 아닙니다. 잘못된 부분이나 부족한 부분을 알려주시면 학습 후 수정하겠습니다.
'Flutter > project' 카테고리의 다른 글
[Flutter] 관심사가 우선일까, 레이어가 우선일까? (0) 2024.01.18 [Flutter] 실제 Repository 패턴 프로젝트 구조 파헤치기#2 (Application-Layer) (3) 2024.01.17 [Flutter] Repository 패턴과 아키텍처feat.Riverpod (0) 2024.01.14 [Project] 로그인&회원가입 기능을 설계할 때, UX관점에서 고려해야하는 부분 (0) 2024.01.12 [Project] 소셜 로그인은 왜 필요할까요? (0) 2024.01.12