-
[Flutter] 실제 Repository 패턴 프로젝트 구조 파헤치기#2 (Application-Layer)Flutter/project 2024. 1. 17. 21:07
이전 글에선 Data-Layer와 Domain-Layer를 실제 코드와 함께 정리했었습니다. 데이터 레이어와 도메인 레이어는 대부분 api 통신에 관한 코드들로 구성되어 있었습니다. 이번 글에서는 클라이언트단의 비즈니스 로직들을 다루는 Application-Layer 부분을 다뤄보겠습니다. 제 프로젝트를 기반으로 설명하는 거라 아키텍처와 무관한 내용이 포함될 수 있습니다.
❗️주의: 스스로 학습하며 구성한 프로젝트라 현직자 분들이 보시면 이상하게 느끼 실수 있습니다. "쟤는 저런 식으로 해봤구나" 정도로만 봐주시고 만약 틀린 부분을 발견하셨다면 피드백 남겨주시면 감사하겠습니다
해당 글은 이전 글에서 계속 이어집니다. 이해가 필요하신 분은 아래의 글을 참고해 주세요!
[Flutter] 실제 Repository 패턴 프로젝트 구조 파헤치기#1 (Data, Domain)
창업 아이템이 정해지고나서 Flutter를 학습했기때문에 프로젝트의 모든 과정이 새로웠다. 특히 아키텍처의 도입은 볼륨이 커질수록 생각만큼 매끄럽게 적용하기 어려웠다. 내가 겪었던 시행착
nomal-dev.tistory.com
✓Application Layer
애플리케이션 레이어는 여러 개의 service로 이루어져 있고 service는 business 로직을 다룬다. 그럼 비즈니스 로직은 어떤 걸 뜻하는 걸까? 사전적 정의는 다음과 같다.
- 비즈니스 로직
- 비즈니스 영역에서 필요한 작업이나 결정에 대한 규칙과 논리
개발적 측면으로 생각하자면 다음과 같이 대입이 가능하다.
'비즈니스 영역' = 소프트웨어의 목적 또는 해결하고자 하는 대상
'필요한 작업이나 결정' = 요구되는 기능
'규칙과 논리' = 비즈니스 로직을 구성하는 코드위의 말대로 비즈니스 로직 "소프트웨어의 목적을 달성하기 위한 기능을 코드로서 나타낸 것"이라면 이러한 기능이 실제로 실행되는 api 연동 단계, 즉 데이터레이어의 레포지토리가 비즈니스 로직을 담당한다고 생각할 수 있다.
하지만 레포지토리는 말 그대로 연동만 했을 뿐, 실제로 기능을 실행하는 곳은 서비스 부분이다.
- service
- 역할 1: 상태 변경, 데이터 변경 및 UI 반영에 필요한 api가 있는 repository들을 액세스 한 후, api를 적절히 조합해 비즈니스 로직을 만들고 상황에 맞게 상태(state)를 반환
- 역할 2: 데이터 직렬화와 같은 역할을 하는 repository와 위젯 상태를 관리하고 업데이트하는 controller의 작업에 개입하지 않고 repository와 controller사이에서 중개자 역할을 함
그럼 실제로 어떻게 구현했는지 예시와 코드를 보며 함께 알아보자.
✍🏼 장바구니 조회 : 현재 장바구니에 있는 메뉴 목록을 보여준다.
📌 업데이트할 상태(State) 작성
[Flutter] 상태관리는 어떻게 해야하는 걸까? feat. sealed class
퍼블리싱만 하던 단계에선 상태관리가 무엇인지 신경 쓰지 않고 setState()를 남발하면서 만들었었다. 하지만 api와 연동할 때쯤에 프로젝트가 난잡해져서 결국 눈물을 머금고 setState()를 걷어내다
nomal-dev.tistory.com
현재 글은 위의 상태 관리 방법을 토대로 작성되었습니다. 필요하시다면 참고해 주세요
먼저 해당 기능을 통해 반환될 상태가 어떤 것이 있는지 생각해봐야 한다.
기본적으로 네트워크 상태를 고려하고, 와이어프레임에서 요구하는 화면을 보면서 또 다른 상태가 필요한지 결정할 수 있다.
그럼 지금 필요한 상태들은 다음과 같다.
- CartState 목록
- data: 필요한 데이터를 잘 받아온 경우
- error: 에러가 발생한 경우
- loading: 데이터를 받아오는 상태에 있는 경우
- empty: 담은 메뉴가 하나도 존재하지 않아 데이터를 빈배열로 반환받은 경우
나 같은 경우에는 빈배열을 체크하는 단계도 비즈니스 로직의 일부분이라고 생각했기 때문에 장바구니가 비어있는 것도 하나의 상태로 반환받도록 설계했다.
코드로 표현하면 다음과 같다.
@freezed sealed class Cart with _$Cart { const factory Cart.data([UserCartMapper? mapper]) = CartData; const factory Cart.empty() = CartEmpty; const factory Cart.loading() = CartLoading; const factory Cart.error(ErrorMapper mapper) = CartError; }
🤷🏻♂️ "근데 왜 model이 아닌 mapper를 반환하나요?"
제 주관적인 의견이며 단순히 저의 편의를 위해 했던 방식입니다. 이 글과는 아무런 관련도 없습니다.
model과 mapper의 차이점은 이전 글에서 다뤘었다. mapper는 서버에서 반환해 주는 데이터 이외에 클라이언트에서 유저에게 보여줘야 하는 데이터가 있을 때 필요한 필드를 추가 정의한 class다.
장바구니 조회 데이터와 UI은 다음과 같다.
- 가게 id
- 가게 이름
- 장바구니 메뉴 List
- 장바구니 메뉴 id
- 메뉴 id
- 메뉴 이름
- 메뉴 이미지 url
- 남은 메뉴 수량
- 유저가 담은 메뉴 수량
- 메뉴 가격
- 메뉴 옵션 List
- 장바구니 메뉴 옵션 id
- 메뉴 옵션 id
- 메뉴옵션 이름
- 메뉴옵션 가격
데이터와 UI를 비교하면 서버로부터 받은 데이터를 그대로 유저에게 보여주기엔 부족한 점이 있다는 것을 알 수 있다.
- 추가로 필요한 데이터
- (메뉴 가격 + 메뉴 옵션 가격) * 현재 수량
- 총 결제 예정 금액 == 1번 값들을 모두 더한 값
실제 앱에선 sold out 유무 등이 있는데 여기에선 수량 부분만 다루겠습니다
1번의 경우는 메뉴 가격과 옵션 가격을 따로 보여주는 기능은 없기 때문에 기존에 있는 '메뉴 가격' 필드에 옵션 가격을 더해서 사용할 수 있었지만 2번의 경우 새로운 필드가 필요했다.
이렇게 클라이언트에서만 사용되는 새로운 필드가 필요하면 model대신 mapper로 class를 만들어서 사용했었다.
개인적으로 mapper를 통해 클라이언트에만 보여줘야 하는 데이터가 있는지 없는지를 알 수 있는 것은 유지보수 관리 측면에서 큰 도움이 되었다.
📌 Service class 작성하기
다음은 CartService class를 작성한 코드다.
설명을 위해 '장바구니 조회 메서드' 뿐만 아니라 '전체 메뉴 삭제 메서드'도 작성했습니다.
class CartService { final UserCartRepository userCartRepository; final MenuStoreCartRepository menuStoreCartRepository; CartService({ required this.userCartRepository, required this.menuStoreCartRepository, }); //📌 카트 전체 리스트 불러오기 Future<Cart> getUserCart() async { final result = await userCartRepository.getUserCart(); final value = switch (result) { Success(value: final value) => CartData(value), Failure(exception: final e) => CartError(ErrorMapper(message: e.toString())), }; if (value is CartError) { return value; } value as CartData; // 빈 카트인 경우 if (value.mapper == null) { return const CartEmpty(); } // 카트에 1개이상 담겨있는 경우 int totalCost = 0; for (var cartMenu in value.mapper!.cartMenu!) { totalCost += cartMenu.updateMenuPrice; } return CartData(value.mapper!.copyWith( totalCost: totalCost, )); } //📌 카트 메뉴 삭제 - 전체 Future<Cart> deleteAllCartMenus() async { final result = await menuStoreCartRepository.deleteAllCartMenus(); final value = switch (result) { Success() => const CartEmpty(), Failure(exception: final e) => CartError(ErrorMapper(message: e.toString())), }; return value; } }
조회 메서드 기준으로 class의 내용을 요약하면 다음과 같다.
- Service에서 사용할 api가 작성된 레포지토리를 서비스 클래스 필드에 정의한다.
- class에 해당 api를 실행하는 메서드를 작성한다.
- Result를 반환받고 Success, Failure에 따라 네트워크 통신 상태를 반환하는 코드를 작성한다.
- 추가적으로 Success일 때, 장바구니가 비었을 경우와 1개 이상 존재하는 경우를 나눠서 반환한다.
- 빈 카트인 경우: CartEmpty 상태를 반환
- 메뉴가 1개 이상 존재하는 경우: 메뉴 가격(+옵션 가격)들을 모두 더해 총 결제예정금액을 계산하고 CartData 상태로 반환
📌 Result 패턴
아키텍처 설명과는 무관하지만 코드에 있기 때문에 추가했습니다.
Result 패턴에 관한 이야기는 에러핸들링 때 다뤘으니 아래 글을 참고해 주세요[Flutter] 에러 핸들링, Custom Exception 관리하기
에러가 일어나지 않는 애플리케이션은 세상에 존재할 수없다. 로그인할 때조차도 값을 잘못 입력하면 '비밀번호가 틀렸습니다' 라며 에러가 발생한다. 일반적으로 http통신을 사용해본 적이 있
nomal-dev.tistory.com
레이어를 나누면서 가장 중요하게 생각했었던 부분이 "어떻게 하면서버와의 의존성을 최소한으로 줄일 수 있을까?"였다.
그렇기 때문에 서버의 로직이 있는 Data-layer와 클라이언트의 로직이 시작되는 Application-layer를 확실하게 구분할 수 있는 방법을 알아보다가 result 패턴이라는 것을 알게 되었다. 서두에 등장했던 다이어그램을 다시 살펴보자.
Repository는 어떤 api를 연동하든 Result 객체를 반환하고 있고,
Service는 어떤 Repository를 주입하든 Reuslt 객체를 받고 있다.
이러한 특징 덕분에 호출된 측과 호출한 측 간의 의존성을 최소화하며, 레이어 간의 독립성이 강화된다.
코드를 보면 좀 더 명확하게 알 수 있다.
- 장바구니 조회 메서드
- final result = await userCartRepository.getUserCart();
- final value = switch (result) {
Success(value: final value) => CartData(value),
Failure(exception: final e) =>
CartError(ErrorMapper(message: e.toString())),
};
- final value = switch (result) {
- final result = await userCartRepository.getUserCart();
- 장바구니 메뉴 전체 삭제 메서드
- final result = await menuStoreCartRepository.deleteAllCartMenus();
- final value = switch (result) {
Success() => const CartEmpty(),
Failure(exception: final e) =>
CartError(ErrorMapper(message: e.toString())),
};
- final value = switch (result) {
- final result = await menuStoreCartRepository.deleteAllCartMenus();
위의 코드를 보면 다른 레포지토리의 메서드를 실행하는데 모두 같은 객체를 반환받고 있다.
서비스 측면에서는 레포지토리의 메서드가 정확히 어떤 걸 반환하는지 알 필요가 없다.
단순히 네트워크 통신 상태에 있어 성공, 실패에 대한 객체를 반환받을 것만 알고 있을 뿐이다.
다시 말해 레이어 간의 통신에서 발생하는 에러가 뚜렷한 형태로 전달되고, 이에 대해 명확하게 처리 로직을 정의할 수 있게 된다.
📌 의존성 주입(Dependency Injection)
- final UserCartRepository userCartRepository;
final MenuStoreCartRepository menuStoreCartRepository;
CartService({
required this.userCartRepository,
required this.menuStoreCartRepository,
});
service 클래스를 작성했으니 이제 의존성을 주입할 차례다.
우리는 이미 서비스 클래스를 생성할 때 레포지토리의 인스턴스를 제공할 것이라고 생성자를 통해 명시해 뒀다.
여기에 사용될 인스턴스를 의존성 주입을 통해 진행한다. 이때 리버팟이 사용된다.
의존성 주입(DI)이 포함된 전체 코드
part 'cart_service.g.dart'; @riverpod CartService cartService(CartServiceRef ref) { final userCartRepo = ref.read(userCartRepositoryProvider); final menuStoreCartRepo = ref.read(menuStoreCartRepositoryProvider); return CartService( userCartRepository: userCartRepo, menuStoreCartRepository: menuStoreCartRepo); } class CartService { final UserCartRepository userCartRepository; final MenuStoreCartRepository menuStoreCartRepository; CartService({ required this.userCartRepository, required this.menuStoreCartRepository, }); //📌 카트 전체 리스트 불러오기 Future<Cart> getUserCart() async { final result = await userCartRepository.getUserCart(); final value = switch (result) { Success(value: final value) => CartData(value), Failure(exception: final e) => CartError(ErrorMapper(message: e.toString())), }; if (value is CartError) { return value; } value as CartData; // 빈 카트인 경우 if (value.mapper == null) { return const CartEmpty(); } // 카트에 1개이상 담겨있는 경우 int totalCost = 0; for (var cartMenu in value.mapper!.cartMenu!) { totalCost += cartMenu.updateMenuPrice; } return CartData(value.mapper!.copyWith( totalCost: totalCost, )); } //📌 카트 메뉴 삭제 - 전체 Future<Cart> deleteAllCartMenus() async { final result = await menuStoreCartRepository.deleteAllCartMenus(); final value = switch (result) { Success() => const CartEmpty(), Failure(exception: final e) => CartError(ErrorMapper(message: e.toString())), }; return value; } }
✍🏼 의존성 주입을 사용하는 이유
다수의 클래스로 이루어진 레이어 간의 분리를 잘하는 방법은 얼마나 클래스 간의 결합도를 최소화하는가에 있다.
이러한 이유로 의존성 주입을 활용해 디자인패턴을 구성한다.
의존성 주입에 대한 자세한 이야기는 좀 더 학습한 후에 따로 작성해 보겠습니다.
이렇게 서비스를 구성하는 방법까지 알아보았습니다. 제 프로젝트를 소개함과 동시에 개념에 대해 설명하다 보니 글이 너무 길어져서 presentation에 대한 이야기는 다음에 이어서 진행하겠습니다. 프로젝트를 구성하는 단계는 이론적이고 조금 추상적인 면이 있어서 제 생각을 온전히 전달하기가 까다로운 것 같습니다.
취준생이라 부족한 점이 많은데 틀린 부분이 있다면 편하게 피드백 주시면 감사하겠습니다!
긴 글 읽어주셔서 감사합니다 🙇🏻♂️
이 글이 꼭 정답은 아닙니다. 잘못된 부분이나 부족한 부분을 알려주시면 학습 후 수정하겠습니다.
'Flutter > project' 카테고리의 다른 글
[Flutter] 실제 Repository 패턴 프로젝트 구조 파헤치기#1 (Data, Domain) (1) 2024.01.22 [Flutter] 관심사가 우선일까, 레이어가 우선일까? (0) 2024.01.18 [Flutter] 에러 핸들링, Custom Exception 관리하기 (1) 2024.01.15 [Flutter] Repository 패턴과 아키텍처feat.Riverpod (0) 2024.01.14 [Project] 로그인&회원가입 기능을 설계할 때, UX관점에서 고려해야하는 부분 (0) 2024.01.12 - 비즈니스 로직