[앱개발] flutter에 함수형 예외처리 적용해보기.
들어가는 말
쓰리뷰에서는 몇 차례 flutter를 이용한 앱개발 프로젝트를 진행해 왔습니다.
프로젝트로 개발한 앱의 주요 기능이 Spring Boot로 구동되는 백엔드 서버와 API통신을 하면서 이루어졌기 때문에 좀 더 개선된 형태의 API 호출 구조를 만들고자 노력하였습니다.
이러한 노력의 일환으로 flutter 프로젝트에 함수형 예외처리 방식(Fuctional exception handling)을 도입하였습니다.
이번 글에서는 flutter 프로젝트에 함수형 예외처리를 도입한 방식과 이로 인해 어떠한 이득이 있었는지 공유하고자 합니다.
예외처리(Exception Handling)이란?
앱개발 뿐만 아니라 다른 프로그래밍에서도 예외처리는 중요한 개념입니다.
애플리케이션은 다양한 이유로 실패할 수 있으며, 이러한 실패를 적절히 관리하지 않으면 사용자 경험이 저하되고 애플리케이션의 신뢰성이 떨어집니다.
예를 들어, 진행했던 프로젝트 중 하나는 앱이 내부망과 연결되어 통신하는 형태였는데, 이러한 경우 네트워크 연결 오류 예외처리를 제대로 해주지 않게 된다면 빈번한 경우로 프로그램이 충돌이 발생하거나 예기치 않게 종료될 수 있습니다.
또한 네트워크 에러가 발생했을 때 사용자에게 적절한 에러 메시지나 상태 업데이트를 제공하지 않으면, 사용자는 앱이 정상적으로 동작하고 있다고 오해할 수 있습니다.
전통적인 예외 처리 방식은 주로 try-catch
블록을 사용하여 예외가 발생 시 추가 로직을 실행하는 것입니다. 예외가 발생할 수 있는 코드를 try
블록 안에 배치하고, 해당 예외를 catch
블록에서 잡아서 처리하는 구조로 되어 있습니다. flutter에서 try-catch
블록을 사용하는 방식은 간단하게 아래 코드 예시에서 살펴 볼 수 있습니다. flutter는 Dart
라는 객체지향 언어로 작성합니다.
import 'package:flutter/material.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
body: Center(
child: MyButton(),
),
),
);
}
}
class MyButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ElevatedButton(
child: const Text('예외버튼'),
onPressed: () => _simulateError(context),
);
}
void _simulateError(BuildContext context) {
try {
throw Exception('예외발생을 통한 예외입니다.');
} catch (e) {
_showDialog(context, e.toString());
}
}
void _showDialog(BuildContext context, String message) {
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: const Text("에러"),
content: Text(message),
actions: <Widget>[
OutlinedButton(
child: const Text("닫기"),
onPressed: () {
Navigator.of(context).pop();
},
),
],
);
},
);
}
}
위 코드 예시는 아래 DartPad
링크에서 실행해 볼 수 있습니다.
버튼을 클릭하면 try
블록 내에서 Exeption
을 발생시키고 해당 Exception
을 catch
블록 내에서 처리하여 에러 메세지를 담은 다이얼로그를 사용자에게 보여줍니다.
함수형 예외처리(Fuctional exception handling)방식이란?
함수형 프로그래밍 패러다임은 검색해보면 프로그램의 동작을 순수 함수의 집합으로 표현하는 것을 중심
으로 한다고 되어 있습니다. 개념적인 부분은 여기 저기 찾아봐도 명확하게 이해되지 않았습니다.
여러 프로젝트를 수행하면서 경험을 통해 이해하기로는 데이터의 불변성(immutability)을 기반으로 한다는 점입니다.
예를 들어 프로그램 내의 강아지
를 표현하는 객체가 있을 때, 강아지 (흰둥이, 5살) 객체의 데이터를 강아지(흰둥이, 6살)과 같이 변경하고자 합니다.
이때 해당 객체에 접근하여 나이 값을 직접 변경하는 것이 아니라 함수 호출을 통해 새로운 객체(흰둥이, 6살)를 생성하여 반환해주는 방식을 떠올려보았습니다.
이러한 방식은 코드 내의 여기 저기서 해당 객체를 수정하는 것보다 예측이 가능하고 디버깅이 용이해집니다.
함수형 예외처리는 이러한 패러다임에 맞게 발생하는 에러를 예외로 처리는 대신 일반적인 값으로 취급합니다(Error as a value).
물론 프로젝트 진행시에는 코드 구조상 가장 하위 Layer(API 호출 담당)에서는 try-catch
를 사용하여 Exception
을 핸들링 하였고, try-catch
구문 내에서는 프로그램의 다른 상태 들을 변경할 수 있는 어떠한 로직도 실행하지 않고, 곧바로 exception에 해당하는 값(Error as a value)을 반환합니다.
여기서 값이란 아래처럼 간단하게 에러라는 것을 나타낼 수 있는 객체이면 됩니다.
class Failure {
final String message;
Failure(this.message);
}
이를 통해 가장 하위 Layer(API 호출)을 제외하고 핵심 비즈니스 로직이나 UI를 그려주는 코드를 작성할 때에는 try-catch
를 더이상 사용하지 않아도 되고 에러를 함수의 반환값으로 활용할 수 있게 되므로 예외처리를 프로그램의 나머지 부분과 동일한 방식으로 다룰 수 있게 됩니다.
예를 들어 API를 호출 하는 과정에서 예외가 발생할 수 있다고 가정했을 때 기존 코드는 아래와 같습니다.
try {
// 예외가 발생할 수 있는 코드
var result = await apiCallOperation();
} catch (e) {
// 예외 처리 로직
handleError(e);
}
아래 코드에서는 더이상 try-catch
구문을 사용하지 않고 단순히 result 변수에 성공적으로 반환된 데이터 또는 Fail 객체가 담기게 되어 프로그램의 다른 부분에 영향을 미치는 추가 코드들이 작성될 여지가 없게 되고, 성공한 데이터가 반환 되었을 때 처럼 예외 처리 역시 반환된 값을 기반으로 동일한 방식의 처리가 가능합니다.
var result = await apiCallOperation();
// result는 apiCallOperation인 반환하는 데이터 객체일 수도 있고
// 에러를 나타내는 값. Fail 객체일 수도 있다.
결과적으로 2가지 형태 중 하나의 값을 갖게 되는 result를 처리하는 함수형 방식은 아래 예시에서 살펴보겠습니다.
함수형 예외처리 예시
위 예시에서 result
는 API 호출 결과가 담긴 데이터 일 수도 있고 Fail
객체일 수도 있습니다. 프로젝트를 진행하면서 역시 Fail
객체를 만들었었는데 복잡한 부분을 빼고 단순화 시키면 아래와 같은 형태가 됩니다.
abstract class Failure {
final String message;
Failure(this.message);
}
// 서버 에러를 나타내는 클래스
class ServerFailure extends Failure {
ServerFailure(String message) : super(message);
}
// 네트워크 에러를 나타내는 클래스
class NetworkFailure extends Failure {
NetworkFailure(String message) : super(message);
}
다음으로 flutter 프로젝트에 dartz
패키지를 추가해줍니다. 이 패키지는 Dart 언어에서 함수형 프로그래밍 개념을 적용할 수 있게 해주는 라이브러리입니다. 이 라이브러리는 Either
라는 타입을 제공하여 함수의 결과를 성공 데이터 또는 Fail로 표현 할 수 있게 합니다.
먼저, 필요한 패키지를 프로젝트의 pubspec.yaml
파일에 추가합니다.
dependencies:
flutter:
sdk: flutter
http: ^0.13.3
dartz: ^0.10.0
다음으로 API 호출 결과를 담을 클래스를 정의합니다. 간단하게 id와 이름을 가진 User 클래스입니다.
class User {
final int id;
final String name;
User({required this.id, required this.name});
// JSON을 User 객체로 변환
factory User.fromJson(Map<String, dynamic> json) {
return User(
id: json['id'],
name: json['name'],
);
}
}
다음으로 API를 호출하고 결과를 Either
타입으로 반환하는 함수를 구현합니다. JSONPlaceholder
의 fake API를 사용한 간단한 GET
요청 입니다.
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:dartz/dartz.dart;
// API 호출 함수
Future<Either<Failure, User>> fetchUser(int userId) async {
try {
final response = await http.get(Uri.parse('https://jsonplaceholder.typicode.com/users/$userId'));
if (response.statusCode == 200) {
// 성공적으로 사용자 데이터를 받아옴
return Right(User.fromJson(json.decode(response.body)));
} else {
// 서버 에러
return Left(ServerFailure('유저데이터 검색 실패'));
}
} catch (e) {
// 네트워크 에러
return Left(NetworkFailure('네트워크 에러'));
}
}
위 코드에서 fetchUser 함수는 Future<Either<Failure, User>>
타입을 반환합니다. `Either` 타입은 두 가지 경우 중 하나를 나타냅니다. Left
는 에러를, right
는 성공적인 결과를 나타냅니다. 이를 통해 함수가 성공 또는 실패한 경우를 명확하게 처리할 수 있습니다.
이제 main 함수에서 API 호출 함수를 호출해보겠습니다.
void main() async {
Either<Failure, User> result = await fetchUser(1);
result.fold(
(failure) {
// 에러처리
if (failure is ServerFailure) {
print('Server Error: ${failure.message}');
} else if (failure is NetworkFailure) {
print('Network Error: ${failure.message}');
}
},
(user) {
print('User: ${user.name}'); // 성공 결과 처리
}
);
}
위 코드에서 fold
메서드는 Either
타입의 값을 처리할 때 사용됩니다. 첫 번째 인자는 Left
(에러)를 처리하는 함수이며, 두 번째 인자는 Right
(성공결과) 를 처리하는 함수입니다.
위 예제 코드에 flutter UI를 적용한 방식은 아래 DartPad 링크에서 테스트 해볼 수 있습니다.
함수형 예외처리를 통해 개선된 점
위의 예제에서 처럼 함수형 예외처리는 에러를 값으로 취급하고, 이를 통해 프로그램의 실행 흐름을 제어합니다. 즉, 에러 발생 시 예외를 던지는 대신, 에러 정보를 담고 있는 값(객체)를 반환하여 호출자가 이를 기반으로 적절한 조치를 취할 수 있습니다. 이러한 접근 방식으로 이해 예측 가능한 프로그램 흐름을 유지하고, 사이드 이펙트를 최소화 할 수 있었습니다.
프로젝트에 함수형 예외처리를 적용하면서 개선된 점을 돌아보면 크게 아래와 같습니다.
- 명시적인 에러처리
위의 예제에서 처럼 함수형 예외 처리 방식에서는Either
를 통해 명시적으로 성공 타입과 실패타입을 알 수 있습니다.
Future<Either<Failure, User>> fetchUser(int userId) async {
// ...
}
Either<Failure, User> result = await fetchUser(1);
이처럼 에러가 발생할 수 있는 모든 지점이 명시적으로 표현되어 디버깅 및 유지보수 시 프로그램의 어느 부분에서 에러 발생 가능성이 있는지, 그리고 어떻게 대응해야 하는지를 더 명확하게 알 수 있었습니다.
- 향상된 가독성과 유지보수성
에러 처리 로직이 흩어지는 것이 아니라, 함수의 반환값으로 명확하게 표현되므로 코드를 이해하고 유지보수하기 쉬워졌습니다. 특히 실제 프로젝트에서는 API 호출 Layer, 비즈니스 로직 처리 Layer, UI Layer 등 작게 잡아도 3개의 Layer 이상으로 코드 구조가 생기는데, 이러한 경우 에러를 값으로 표현하고 다룬 다는 점에서 패턴의 동일성을 유지 할 수 있었습니다.