앞선 글에서는 Equatable 을 통해 도메인 레이어의 Entity 을 값으로 비교하는 방식을 알아봤다.
이번에는 UseCase와 Repository 추상 클래스를 구현하며 그 안에서 사용되는 dartz
, Either
, typedef
, Failure
을 알아보려고 한다.
클린아키텍처에서 도메인 계층에는 Repository 추상 클래스를 사용한다.
보통 도메인 레이어에서는 비즈니스 로직을 정의하는데 이때 외부에서 데이터를 가져오거나 저장하는 작업이 어떻게 이루어지는지 그 세부 구현에 대해서는 몰라도 된다. 따라서 이런 접근을 위해 Repository Pattern 을 사용한다.
이 Repository 패턴은 도메인 레이어에서는 Repository 의 추상 클래스만 정의하고, 구체적 구현을 data layer 에서 구현하는 것이다.
그 예로 AuthenticationRepository
추상 클래스는 다음처럼 작성할 수 있다.
아래의 Repository 는 유저 생성 및 유저 가져오기 작업을 가진다.
여기서 배울 수 있는 점은 리턴 타입으로 쓰인 ResultVoid
, ResultFuture<List<User>>
이다. 이 타입은 자체적으로 내장된 것이 아닌 반복되는 타입 정의에 개발자가 별명을 붙여서 사용한 것(typedef)인데 다음 챕터들에서 알아볼 것이다.
abstract class AuthenticationRepository {
const AuthenticationRepository();
ResultVoid createUser({
required String createdAt,
required String name,
required String avatar,
});
ResultFuture<List<User>> getUsers();
}
dartz
패키지는 Dart 에서 함수형 프로그래밍을 쉽게 구현하도록 하는 패키지이다.
함수형 프로그래밍은 불변성(immutability) 과 순수 함수(pure function) 특징을 가진다.
이러한 특징 덕분에 코드의 예측 가능성이 높고, 테스트가 쉬워진다. Either<L, R>
도 이러한 철학을 반영한 타입으로, 기존의 try-catch
보다 더 명확하게 에러 처리 흐름을 표현할 수 있다. 이는 실패 시에는 L(Left) 을 성공 시에는 R(Right) 을 리턴한다는 타입이다.
예를 들어 Either<Failure, User>
은 실패하면 Failure 을 성공하면 User 을 담는다는 뜻이다.
dart 에서 typedef
는 길거나 반복되는 타입 정의에 별명을 붙이는 기능인데, 다음처럼 사용할 수 있다.
typedef ResultFuture<T> = Future<Either<Failure, T>>;
typedef ResultVoid = ResultFuture<void>;
위와 같은 typedef 을 통해 기존에는
Future<Either<Failure, List<User>>>
로 썼던 코드를 ResultFuture<List<User>>
로 간결하게 표현할 수 있다.
기존에 내가 했던 에러처리는 다음처럼 진행했었다.
try {
final user = await repository.getUser();
} catch (e) {
// 실패 처리
}
이때 함수형 에러 처리를 하면 더 간결하게 표현할 수 있다.
위에서 배웠던 Either 을 이때 사용할 수 있다.
아래 코드를 보면 repository.getUsers()의 타입은 ResultFuture<List<User>>
즉, 타입 정의에 따르면 Future<Either<Failure, List<User>>>
이다. 그러면 result.fold()
을 통해 만약 failure 이면 에러를 출력하고 List 이면 유저 수를 출력하도록 할 수 있다.
final result = await repository.getUsers();
result.fold(
(failure) => print('에러: ${failure.message}'),
(users) => print('유저 수: ${users.length}'),
);
자연스럽게 사용하고 있던 Failure 은 사실 오류 타입을 개발자가 정해둔 것이었다.
core/errors/failure.dart 에 다음처럼 정의를 하였다.
아래와 같은 코드를 통해 다양한 실패를 하나의 타입(Either<Failure, T>)으로 통합해서 처리할 수 있다
예를 들어 서버 에러, 캐시 실패, 인증 실패 등을 각각 ApiFailure, CacheFailure, AuthFailure 등으로 관리하여 에러를 구조화할 수 있다.
abstract class Failure extends Equatable {
final String message;
final int statusCode;
}
class ApiFailure extends Failure {
const ApiFailure({required super.message, required super.statusCode});
}
클린 아키텍처에서 UseCase(유즈케이스)는 앱의 비즈니스 로직을 캡슐화하는 핵심 요소이다.
즉, "앱에서 일어나는 행동 하나하나(예: 유저 생성하기, 유저 리스트 가져오기)"를 UseCase 단위로 정의한다.
UseCase 를 그냥 CreateUser, GetUsers .. 등으로 바로 작성해도 되지만, 그 전에 UseCase 의 베이직 클래스를 제작하면 더욱 구조화된 설계가 가능하다.
예를 들어, 특정 파라미터를 받아야 동작하는 UseCase 가 있고, 파라미터 없이 동작하는 UseCase 가 있을 수 있다.
특정 파라미터를 받아야 동작하는 유즈케이스의 베이스 클래스는 다음과 같은 추상 클래스로 정의할 수 있다.
abstract class UsecaseWithParams<Type, Params> {
const UsecaseWithParams();
ResultFuture<Type> call(Params params);
}
그리고 파라미터 없이 동작하는 유즈케이스를 정의할 때 쓰는 베이스 클래스는 다음과 같은 추상 클래스로 정의할 수 있다.
abstract class UsecaseWithoutParams<Type> {
const UsecaseWithoutParams();
ResultFuture<Type> call();
}
이렇게 베이스 유즈케이스를 정의하면 사용할 때 상속해서 사용할 수 있다.
domain/usecases/create_user.dart
class CreateUser extends UsecaseWithParams<void, CreateUserParams> {
const CreateUser(this._repository);
final AuthenticationRepository _repository;
ResultVoid call(CreateUserParams params) async =>
_repository.createUser(
createdAt: params.createdAt,
name: params.name,
avatar: params.avatar,
);
}
domain/usecases/get_users.dart
class GetUsers extends UsecaseWithoutParams<List<User>> {
const GetUsers(this._repository);
final AuthenticationRepository _repository;
ResultFuture<List<User>> call() async => _repository.getUsers();
}
최근에 Flutter 클린아키텍처 구현에 해당 코드가 있어, 어려움을 느꼈는데 쉽게 설명해주셔서 이해 잘 되었습니다. 감사합니다