개인 프로젝트에서 모든 로그인 인증 방법은 구현이 완료되었다~! 😁
그렇다면 바로 CRUD 기능을 개발하고 데이터 상태관리를 하면 될까?
물론 해도 되지만 프로젝트에서 외부와 통신을 하는 경우에는 빠질 수 없는 작업이 있다.
바로 Error Handling(예외 처리)이다.
Application을 실행하여 외부와 통신을 하면 사용자가 잘못된 입력을 할 수도 있고, 네트워크 요청이 실패할 수도 있으며, 어딘가에서 문제가 발생할 수도 있다.
그럴때 예외처리는 코드에서 발생할 수 있는 잠재적 오류를 처리하여 앱이 문제없이 작동되도록 해주는 것..!
그러면 Flutter에서는 어떻게 예외 처리를 할 수 있는지 알아보겠다.
Exception Handling with try/catch in Dart and Flutter
예를 들어, IP 주소에서 위치를 가져오는 데 사용할 수 있는 간단한 다트 함수가 있다.
아래 코드는 외부 API에 get 요청을 하고 있는 코드입니다.
만약 성공한다면 Location.fromMap(data)을 반환해주고 실패하면 예외를 적용한다.
// get the location for a given IP using the http package
Future getLocationFromIP(String ipAddress) async {
try {
final uri = Uri.parse('<http://ip-api.com/json/$ipAddress>');
final response = await http.get(uri);
switch (response.statusCode) {
case 200:
final data = json.decode(response.body);
return Location.fromMap(data);
default:
throw Exception(response.reasonPhrase);
}
} on Exception catch (_) {
// make it explicit that this function can throw exceptions
rethrow;
}
}
위의 함수를 사용하려면 아래 코드 처럼 사용할 수 있다.
final location = await getLocationFromIP('122.1.4.122');
print(location);
하지만 위 코드는 정의하지 않은 예외가 발생한다면 문제가 생길 수 있다.
그래서 try/catch를 사용하여 예외처리를 코드를 작성 해봤다.
try {
final location = await getLocationFromIP('122.1.4.122');
print(location);
} catch (e) {
// TODO: handle exception, for example by showing an alert to the user
}
이러면 예외처리를 하였지만 모든 상황에서 어떤 예외를 처리해야하고 어떤 예외인지 알기 어려운 상황이 많다.
물론 위의 방식으로 예외처리를 진행해도 되지만 자세한 정보와 근본적인 원인을 알려면 다른 코드들을 추가해줘야 한 다. 그러면 외부 통신하는 코드를 사용할때마다 그것에 맞는 예외 처리 코드들을 계속하여 작성을 해줘야하는것이다. 물론 이것은 나의 생각이고 개발자마다 생각이 다를 수 있다.
패턴을 사용하는 이유
잠시 Result Pattern에 대해 공부하기 앞서 왜 패턴을 만들고 적용하는 이유를 알아보자.
내 생각은 유지보수성이 가장 크다고 생각한다. 대규모 어플리케이션을 개발할 때 어떠한 기능을 수정하거나 추가한다거나 할 때 기존에 있던 기능을 변경한다면 어디에 코드가 있는 지 확인을 해야 한다.
그럴때 화면에 종속되어 있는 코드이고 그 기능이 여러 화면에서 존재한다고 가정을 하면 모든 View에 있는 코드들을 수정해야 하는 귀찮은 상황이 생길 것 이다.
예를 들어, 로그인하는 기능이라면 화면에서 로그인을 하는 것이 아닌 Service에서는 외부 통신과 담당된 모든 작업들이 존재할 것 이고 나는 Service안에 있는 코드만 변경시키면 모든 화면의 기능은 수정이 되는 것이다.
이것을 우리가 패턴으로 분리하여 유지보수성을 증가시키는 방법이다.
Exception Handling with Result Pattern in Dart and Flutter
위에 문제들 을 해결하기 위해 즉, 함수가 성공할 수도 있고 실패할 수도 있는 결과를 가져오는 것을 명확히 하기 위해 Result Pattern을 사용하면 해결할 수 있다.
다른 언어들을 한 번 봐보자, Kotlin은 Sealed Calss가 있고 Swift는 Enum으로 알려져 있는 언어 특징을 사용하여 결과 유형을 정의한다.
Dart 3.0에서도 결과 유형을 정의할 수 있게 만들었기에 간단한 코드로 결과 유형을 만들 수 있다.
예시를 보면 아래처럼 Result 클래스가 있고 그것을 상속하는 Success, Failure 클래스가 있다.
Success, Failure 클래스는 하위 클래스를 만들지 않기 위해 final class로 정의를 해주자.
그리고 generics을 사용하여 재사용성이 높은 클래스를 만들겠다.
/// Base Result class
/// [S] represents the type of the success value
sealed class Result<S> {
const Result();
}
final class Success<S> extends Result<S> {
const Success(this.value);
final S value;
}
final class Failure<S> extends Result<S> {
const Failure(this.exception);
final Exception exception;
}
Exception까지 받을 수있게 추가한다.
/// Base Result class
/// [S] represents the type of the success value
/// [E] should be [Exception] or a subclass of it
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;
}
그래서 Result 객체를 이용하여 예외처리를 한다면 아래 코드처럼 작성할 수 있다.
// 1. change the return type
Future<result<location, exception="">> getLocationFromIP(String ipAddress) async {
try {
final uri = Uri.parse('<http://ip-api.com/json/$ipAddress>');
final response = await http.get(uri);
switch (response.statusCode) {
case 200:
final data = json.decode(response.body);
// 2. return Success with the desired value
return Success(Location.fromMap(data));
default:
// 3. return Failure with the desired exception
return Failure(Exception(response.reasonPhrase));
}
} on Exception catch (e) {
// 4. return Failure here too
return Failure(e);
}
}
</result<location,>
위의 코드를 보면 통신이 성공한다면 Success 객체가 반환이 되는데 Location의 객체를 담고 있다.
만약 문제가 발생하면 예외가 포함된 실패를 반환하게 된다.
패턴 매칭 & 스위치를 사용하여 결과값을 가져오자.
아래 처럼 코드를 작성할 수 있다.
final result = await getLocationFromIP('122.1.4.122');
final value = switch (result) {
Success(value: final location) => location.toString(),
Failure(exception: final exception) => 'Something went wrong: $exception',
};
print(value);
결과를 처리하기 위해 Dart 3에 도입된 스위치 구문을 사용하여 패턴 매칭을 할 수 있다.
만약 아래처럼 Failure를 입력하지 않는다면 오류 사례를 생략하여 컴파일러 오류가 된다.
왜냐하면 getLocationFromIP 함수는 Result를 반환하는데 Result안에 값은 Success, Failure이 명시되어야 한다.
final value = switch (result) {
Success(value: final location) => location.toString(),
};
print(value);
Result 패턴의 장점
- 명확성 및 안전
- 성공 및 실패 유형을 명시적으로 선언할 수 있다.
- 가능한 결과를 명시적으로 정의하여 코드 가독성과 안전성을 향상한다.
- 오류처리
- 신중한 오류처리, 예외를 제어 흐름 메커니즘으로 사용하는 대신, 결과 패턴은 오류를 정규데이터로 처리할 수 있게 한다.
- 즉, 처리되지 않는 예외의 가능성을 줄인다.
- 디버깅 용이성
- 결과 객체가 실패에 대한 자세한 정보를 전달 할 수 있어 근본 원인을 식별하기에 도움이 된다.
- Server와 Client가 일관성 있게 작동 되어 쉽게 상호작용이 가능하다.
- 어떠한 예외처리를 할 것 인지 서로 일관성 있게 작동이 된다.
그렇다면 Result 패턴은 언제나 사용하면 좋은 것 인가?
아니다. 비동기 함수들을 순차적으로 호출하는 상황을 보면 이해가 쉽다.
Future<int> function1() { ... }
Future<int> function2(int value) { ... }
Future<int> function3(int value) { ... }
Future<int> complexAsyncWork() async {
try {
// first async call
final result1 = await function1();
// second async call
final result2 = await function2(result1);
// third async call (implicit)
return function3(result2);
} catch (e) {
// TODO: Handle exceptions from any of the methods above
}
}
위의 코드를 보면 함수중 하나의 예외가 발생하면 그것을 catch하여 예외 처리를 진행해야 한다.
그러면 아래 코드를 한 번 봐보자.
Future<Result<int, Exception>> function1() { ... }
Future<Result<int, Exception>> function2(int value) { ... }
Future<Result<int, Exception>> function3(int value) { ... }
Future<Result<int, Exception>> complexAsyncWork() async {
// first async call
final result1 = await function1();
if (result1
case Success(value: final value1)) {
// second async call
final result2 = await function2(value1);
return switch (result2) {
// third async call
Success(value: final value2) => await function3(value2),
Failure(exception: final _) => result2, // error
};
} else {
return result1; // error
}
이 코드는 Result 패턴을 사용하여 이루어진 코드인데 가독성이 좋지 않다.
그 이유는 모든 성공||오류 사례를 처리하기 위해 각각의 결과 객체를 수동으로 풀고 추가적으로 코드를 작성해야 하기 때문이다. 그래서 이런 문제를 해결하기 위해 fpdart 패키지같은 라이브러리가 기능을 제공한다.
하지만 fpdart 라이브러리에 대해서 추가적으로 공부하지 않을 것 이다.
추후 그런 상황이 오게 된다면 그 때 공부하겠다.
어떻게 프로젝트에 적용시킬까?
이제 Result 패턴을 사용하는 방법을 알았으니 어떻게 내 개인 프로젝트에 적용시킬 것 인지 고민을 해봤다.
일단 에러 핸들링을 하기 전에 현재 내 앱의 아키텍처를 분석해보자.
아래 이미지를 보게 되면 지금 이용하고 있는 나의 프로젝트 예시이다.
아래 형태는 View안에 어떠한 Action을 할 때를 기준으로 잡았다.
여기서 Service는 외부 통신을 담당하는 역할이다.
카카오 로그인 아키텍처에 맞게 적용하기
ViewModel을 활용하여 Model 객체 생성하기
위의 이미지를 토대로 카카오 로그인에 대한 예시를 작성해보겠다.
로그인 화면에서 카카오 로그인 버튼을 누른다면 LoginViewModel안에서 Service의 카카오로그인을 실행한다.
통신이 성공하면 인증한 유저의 정보를 가져올텐데 그 정보를 Model 객체를 생성하여 데이터를 저장한다.
이 때 객체 생성을 하는 것은 LoginViewModel이 그 역할을 담당한다.
그리고 LoginViewModel이 Model를 참조한다.
Service에서 Model 객체 생성하기
위에 방식과 비슷한데 이번에는 Service에서 데이터를 가져오게 되면 Model를 생성해줄것이다.
그리고 ViewModel에서 Model을 참조하는 방식이다.
Repository
이번 프로젝트에서는 Repository를 활용하여 할 것이다 모든 역할을 Repository가 담당한다.
즉 아래 이미지처럼 흐름이 될 것이다.
그렇다면 에러 핸들링은 Service에서 결과를 Result에 넣어주게 되고 그것을 View에서 가져와서 어떻게 처리해줄 것인지 논리 흐름을 제어해주면 된다.
결론
Result Pattern에 대한 이론적인 부분들과 간단한 예시를 학습하였다.
아직 프로젝트에 적용 하지 않아 이해도가 그리 높지 않지만 이번 프로젝트에서 Result Pattern에 대한 이해도를 높여 다른 프로젝트에서도 사용할 수 있게 만들려고 한다.
Learning curve는 높지만 한 번 이해를 하게 된다면 다른 언어, 프로젝트에서 많이 사용할 수 있는 중요한 패턴이라고 생각한다.
'Flutter' 카테고리의 다른 글
[Flutter] RiverPod 학습 - State management (0) | 2025.01.03 |
---|---|
[Flutter] [Error Handling] Result Pattern (간편로그인 적용) (0) | 2024.12.26 |
[Flutter] [Firbase] 간편 로그인 구현하기(Apple🍎) (0) | 2024.12.24 |
[Flutter] [Firbase] 간편 로그인 구현하기(kakao💬) (1) | 2024.12.19 |
[Flutter] DesignSystem 트러블슈팅 (0) | 2024.12.11 |