-
[Flutter] 실제 Repository 패턴 프로젝트 구조 파헤치기#1 (Data, Domain)Flutter/project 2024. 1. 22. 19:18
창업 아이템이 정해지고 나서 Flutter를 학습했기 때문에 프로젝트의 모든 과정이 새로웠다. 특히 아키텍처의 도입은 볼륨이 커질수록 생각만큼 매끄럽게 적용하기 어려웠습니다. 이때 제가 겪었던 시행착오들이 다음 개발의 인사이트가 될 수 있을 거라 기대하며 실제 프로젝트의 아키텍처, 폴더 구조를 정리하는 시간을 가져보겠습니다.
❗️주의: 스스로 학습하며 구성한 프로젝트라 현직자 분들이 보시면 이상하게 느끼 실수 있습니다. "쟤는 저런 식으로 해봤구나" 정도로만 봐주시고 만약 틀린 부분을 발견하셨다면 피드백 남겨주시면 감사하겠습니다
이전에 관심사에 대해 다뤘던 글에서 나는 'Layer-First'에 기반한 아키텍처및 프로젝트 구조를 구성했었다고 말했다. 아키텍처에는 정답이 없으며 '팀의 구조를 따라간다'라는 말을 했었고 결국 나만의 아키텍처와 프로젝트 구조를 만들었다고 했다.
[Flutter] 아키텍처를 구성할때 feature가 우선일까, Layer가 우선일까?
레포지토리 패턴으로 구성된 리버팟 아키텍처로 프로젝트를 진행하다가 크게 아키텍처 구조를 갈아엎은 적이 있었다. 이유는 어떤 특정한 기준으로 아키텍처 구조를 잡았다가 볼륨이 커지면서
nomal-dev.tistory.com
✓ 구성
프로젝트를 구성할 때 레이어와 관계없는 공용 모듈이나 레이어에 포함시키기 어려운 부분들은 시각적으로 분리시키고 싶었다.
그래서 레이어들은 모두 tasks라는 폴더의 하위 폴더로 두었고, 나머지는 특성에 맞게 폴더를 구성해서 tasks와 같은 수준의 경로에 위치시켰다.
tasks > 'common'의 경우에는 Feature-first의 개념을 섞은 부분이다.
pagination에 관한 로직들은 조회가 있는 대부분의 관심사에 쓰인다. 또한 조회는 api를 통해 실행되지만 그 뒤로는 domain-application-presntation을 거치기 때문에 데이터 레이어를 제외하곤 모든 레이어에 걸쳐있다. 모든 layer 각각에 common으로 존재하기에는 페이지네이션이라는 기능이 작지가 않고, 특정 관심사의 하위 기능으로 종속되는 페이지네이션도 있지만 여러 관심사에 공용모듈처럼 쓰이는 페이지네이션도 있었다. 정리하자면 다음과 같다.
- 외부라이브러리의 로직이 아닌 도메인 서비스에 존재하는 기능
- 특정 관심사에 종속되어있지 않지만 각각의 레이어의 common으로 두기엔 존재감이 뚜렷하며 작지 않은 기능
이러한 특징 때문에 특별히 pagination만 feature-first의 느낌으로 따로 빼서 관리하는 방식을 사용했다.
데이터 레이어가 없기 때문에 레이어를 나누는 것은 의미 없다고 생각해서 필요한 폴더를 생성해서 계층에 구애받지 않고 자유롭게 작성했다.
본격적으로 리버팟 아키텍처가 적용된 부분을 알아보겠습니다.
모든 레이어 간의 이동은 riverpod을 이용해 의존성을 주입하는 방식으로 진행했습니다.
"이해를 위해 모든 예시는 '장바구니' 관련 기능으로 통일하겠습니다!"✍🏼 장바구니에 음식 담기 & 장바구니 조회하기 & 음식 수량 조절하기
✍🏼 재고 체크 & 단일 품목 삭제 & 전체 품목 삭제
해당 글은 레포지토리 패턴이 적용된 리버팟 아키텍처를 베이스로 하며 구현 경험 위주로 작성되어 있습니다.
레포지토리 패턴 및 리버팟 아키텍처에 대해 궁금하시다면 아래 글을 참고해 주세요.[Flutter] Repository pattern + Riverpod feat.안드레아
로그인 회원가입 등 구현에 대해 이야기하기 전, 아키텍처에 관해 이야기를 먼저 해야 구현 부분을 매끄럽게 이어갈 수 있겠다고 생각했다. 아키텍처에 대해 진지하게 생각한 것은 한 백엔드
nomal-dev.tistory.com
✓ Data Layer
데이터 레이어 > menus 관심사 > repository 레포지토리 패턴의 핵심으로 볼 수 있는 repository가 있는 레이어다.
이전에, 데이터 레이어는 repository의 집합체이며 네트워크 통신에 필요한 '코드 베이스'로서 존재한다고 했었다.
장바구니에 필요한 기능은 총 6개이기 때문에 관련된 비동기 api 호출 함수도 6개가 된다.
데이터레이어에서 관심사는 베이스경로를 따라가도록 했다.
메뉴를 담거나 삭제하는 부분과 장바구니 내에서 이루어지는 로직의 베이스 경로가 달랐기 때문에 아래와 같은 형태가 나왔었다.
하지만 '메뉴' 관심사의 경우 장바구니에서 동작하는 api와 메뉴 탭에서 동작하는 api가 성격이 매우 다르기 때문에 구별하기 쉽도록 공통된 성격을 기준으로 api를 묶어 repository를 분할해서 작업했다.
그럼 repository 파일의 코드는 어떻게 구성되어 있는지 살펴보자
@Riverpod(keepAlive: true) UserCartRepository userCartRepository(UserCartRepositoryRef ref) { final dio = ref.watch(dioProvider); final String url = 'http://$ip/users'; return UserCartRepository(baseUrl: url, dio: dio); } class UserCartRepository { UserCartRepository({ required this.baseUrl, required this.dio, }); final String baseUrl; final Dio dio; Future<Result<UserCartMapper?, CustomExceptions>> getUserCart() { return runCatchingExceptions(() async { final resp = await dio.get( "$baseUrl/유저 카트 목록", options: Options(headers: {'accessToken': 'true'}), ); List<dynamic> cartMenu = resp.data['cartMenu']; if (cartMenu.isEmpty) { return null; } else { return UserCartMapper.fromJson(resp.data); } }); } Future<Result<void, CustomExceptions>> deleteOneCartItem( {required int cartMenuId}) { return runCatchingExceptions(() async { await dio.delete( "$baseUrl/카트 아이템 하나 제거", options: Options(headers: {'accessToken': 'true'}), ); }); } Future<Result<InefficientMenusModel, CustomExceptions>> checkMenuStocks( {required CheckMenuStocksModel model}) async { return runCatchingExceptions(() async { final resp = await dio.post( "$baseUrl/메뉴 재고 체크", data: model.toJson(), options: Options(headers: {'accessToken': 'true'}), ); return InefficientMenusModel.fromJson(resp.data); }); } Future<Result<void, CustomExceptions>> cartMenusContControl({ required CartMenusCountHandingModel model, }) async { return runCatchingExceptions(() async { await dio.put( '$baseUrl/수량 조절', options: Options(headers: {'accessToken': 'true'}), data: model.toJson(), ); }); } }
- 사용된 패키지
- riverpod_annotation:^1.0.4
- riverpod_generator: ^1.0.4
- dio: ^4.0.6
📌 수평적 확장
api를 추가하거나 삭제할 일이 생기면 repository에서 코드를 간단하게 수정하면 된다. 데이터 로직에 관한 코드를 수평으로 확장이 가능하다는 것은 개발 및 유지보수 관점에서 큰 장점이다. 수평적 확장은 모든 레이어에 공통적인 장점이다.
📌 비지니스 로직과 api 연동 로직 분리
repository의 주요 역할은 비지니스 로직 api 연동을 분리하는 것이다. 이미 service를 알고 있다고 가정하고 repository를 설명하자면, service를 api를 연동하는 부분과 비지니스 로직을 분리했는데 api 부분을 repository라고 부르고 비지니스 로직 부분을 service라고 부르는 것이라고 생각하면 편하다.
이에 대한 이점은 아키텍처를 설명할 때 서술해 뒀으므로 글의 맨 앞으로 돌아가서 참고해 주시면 감사하겠습니다
api 연동 할 때 에러 핸들링 방법에 대해 궁금하다면 아래 글을 참고해주세요[Flutter] 에러 핸들링, Custom Exception 관리하기
에러가 일어나지 않는 애플리케이션은 세상에 존재할 수없다. 로그인할 때조차도 값을 잘못 입력하면 '비밀번호가 틀렸습니다' 라며 에러가 발생한다. 일반적으로 http통신을 사용해본 적이 있
nomal-dev.tistory.com
✓ Domain - Layer
도메인 레이어 > 관심사 > model, mapper 📌 json (역)직렬화 (serialization)
json 객체 예시 도메인 서버와 REST API를 통해 GET요청 등을 하면 여러 포맷이 있지만 보통 json형식으로 데이터를 전송해 준다.
이렇게 받은 데이터를 비지니스 로직에 사용하기 위해선 역직렬화를 통해 객체화하는 작업이 필요하다.
반대로 사용자에게 받은 데이터를 서버에 전달하기 위해선 직렬화를 통해 json 포맷으로 변환해서 전송하는 작업을 수행해야 한다.
이렇게 객체화에 사용되는 클래스를 model class라고 한다.
하지만 서버에서 받지 못하는 데이터이지만 클라이언트에서 보여줘야 한다거나, 서버에 전송하지 않더라도 사용자 입력 등으로 인해 클라이언트에서만 계산되는 작업 등과 같이 클라이언트 내부에서 데이터를 가지고 있으려면 이를 저장해 둘 객체가 필요하다.
나 같은 경우는 이럴 때 사용할 클래스를 mapper class라고 지칭했다.
위의 설명대로라면 mapper 클래스는 오히려 클라이언트 내부 로직과 관련 있는 application-layer이나 유저와 상호작용이 일어나는 presentation-layer에 두는게 맞는 것처럼 보입니다. 하지만 어떤 비즈니스 로직 혹은 유저와 상호작용으로 인해 데이터를 정적으로 업데이트만 할 뿐, 직접적으로 상태(status)를 변경하는 것과는 거리가 있다고 생각해서 비슷한 성격의 역할을 하는 model과 함께 도메인 레이어에 위치시켰습니다
도메인의 경우에는 직렬화 역할과 역직렬화 역할이 있기 때문에 반드시 api 하나당 한 개일 필요는 없다. 위의 다이어그램처럼 한 api에 두 개가 될 수도 있고, POST 등의 body값 또는 네트워크 통신 반환값이 void나 원시타입처럼 객체화가 필요 없고 데이터를 1회성으로 소모만 할 뿐이라면 class를 안 만들 수도 있다.
(이런 일은 거의 일어나지 않는다)실제 코드를 보면 다음과 같이 생겼다.
part 'user_cart_mapper.g.dart'; @JsonSerializable() class UserCartMapper { final int? storeId; final String? storeName; final List<CartMenuMapper>? cartMenu; final int totalCost; // 서버에서 받진 않지만 클라이언트에서 필요한 데이터 필드 UserCartMapper({ this.storeId, this.storeName, this.cartMenu, this.totalCost = 0, }); UserCartMapper copyWith({ int? storeId, String? storeName, List<CartMenuMapper>? cartMenu, int? totalCost, }) { return UserCartMapper( storeId: storeId ?? this.storeId, storeName: storeName ?? this.storeName, cartMenu: cartMenu ?? this.cartMenu, totalCost: totalCost ?? this.totalCost, ); } factory UserCartMapper.fromJson(Map<String, dynamic> json) => _$UserCartMapperFromJson(json); } ...
- 사용된 패키지
- json_annotation: ^4.8.1
- json_serializable: ^6.7.1
코드를 자세히 보면 중간에 copyWith()이라는 메서드가 있다.
아까 데이터를 정적으로 업데이트한다는 얘기를 잠깐 했었는데 이때 업데이트를 담당하는 메서드가 바로 copyWith()이다.
- copyWith()
- 기존의 객체를 변경하지 않고 지정된 속성만 변경해 새로운 객체를 생성하는 메서드
- 불변성(immutability)을 유지하면서 객체의 일부 속성을 변경할 수 있게 해 준다
위와 같이 코드를 작성한 후에 터미널에 "flutter pub run build_runner watch" 또는 "flutter pub run build_runner build"라고 입력하면 g.dart 파일이 생성된다. 만약 g.dart파일을 생성하지 않는다면 아래와 같은 코드를 직접 타이핑해야 한다.
// GENERATED CODE - DO NOT MODIFY BY HAND part of 'user_cart_mapper.dart'; // ************************************************************************** // JsonSerializableGenerator // ************************************************************************** UserCartMapper _$UserCartMapperFromJson(Map<String, dynamic> json) => UserCartMapper( storeId: json['storeId'] as int?, storeName: json['storeName'] as String?, cartMenu: (json['cartMenu'] as List<dynamic>?) ?.map((e) => CartMenuMapper.fromJson(e as Map<String, dynamic>)) .toList(), totalCost: json['totalCost'] as int? ?? 0, ); Map<String, dynamic> _$UserCartMapperToJson(UserCartMapper instance) => <String, dynamic>{ 'storeId': instance.storeId, 'storeName': instance.storeName, 'cartMenu': instance.cartMenu, 'totalCost': instance.totalCost, }; CartMenuOptionMapper _$CartMenuOptionMapperFromJson( Map<String, dynamic> json) => CartMenuOptionMapper( detailOptionId: json['detailOptionId'] as int?, cartMenuOptionId: json['cartMenuOptionId'] as int?, cartMenuOptionName: json['cartMenuOptionName'] as String?, cartMenuOptionPrice: json['cartMenuOptionPrice'] as int?, ); Map<String, dynamic> _$CartMenuOptionMapperToJson( CartMenuOptionMapper instance) => <String, dynamic>{ 'cartMenuOptionId': instance.cartMenuOptionId, 'detailOptionId': instance.detailOptionId, 'cartMenuOptionName': instance.cartMenuOptionName, 'cartMenuOptionPrice': instance.cartMenuOptionPrice, }; CartMenuMapper _$CartMenuMapperFromJson(Map<String, dynamic> json) => CartMenuMapper( isSoldOut: json['isSoldOut'] as bool? ?? false, cartMenuId: json['cartMenuId'] as int, menuId: json['menuId'] as int, menuName: json['menuName'] as String, menuImgUrl: json['menuImgUrl'] as String, cartMenuQuantity: json['cartMenuQuantity'] as int, cartMenuPrice: json['cartMenuPrice'] as int, menuDetailOption: (json['menuDetailOption'] as List<dynamic>) .map((e) => CartMenuOptionMapper.fromJson(e as Map<String, dynamic>)) .toList(), remainCount: json['remainCount'] as int? ?? 0, updateMenuPrice: json['updateMenuPrice'] as int? ?? 0, ); Map<String, dynamic> _$CartMenuMapperToJson(CartMenuMapper instance) => <String, dynamic>{ 'cartMenuId': instance.cartMenuId, 'menuId': instance.menuId, 'menuName': instance.menuName, 'menuImgUrl': instance.menuImgUrl, 'cartMenuQuantity': instance.cartMenuQuantity, 'cartMenuPrice': instance.cartMenuPrice, 'isSoldOut': instance.isSoldOut, 'updateMenuPrice': instance.updateMenuPrice, 'remainCount': instance.remainCount, 'menuDetailOption': instance.menuDetailOption, };
📌 타입 체크
model을 작성하면 string, int 등 올바른 타입으로 받았는지 체크가 가능하며 동적 타입으로 넘어오는 경우 dart에 맞게 타입변환을 할 수도 있다. 타입체크는 데이터의 무결성을 보장하고 코드의 안정성을 강화하는 측면에서 중요한 부분을 차지한다. 타입체크를 하지 않아 json 형식이 그대로 service단으로 가서 타입 에러 형태로 비지니스 로직에 영향을 준다면 레이어를 나눈 의미가 희석되게 된다.
📌 응답 객체 통일화
이 부분은 레이어와 관련성이 떨어지지만 코드에 등장하니 설명을 덧붙이도록 하겠습니다.
도메인에서 반환되는 model의 경우는 서버에서 에러를 반환하지 않는 경우를 의미한다. 에러의 경우 도메인 서버에서 지정한 에러를 비롯해 여러 에러가 발생할 수 있고 어떤 에러가 어떤 상황에 터질지는 예측할 수 없다. 그래서 에러 핸들러를 모든 api통신 로직을 거치게 해서 정상적인 반환값은 Success객체에 담고 에러가 터지면 예외클래스를 담고 있는 Failure 객체로 반환하는 Result 객체를 만들어 service에서는 데이터와 예외상황을 직접 받는 것이 아니라 Result 객체로 받을 수 있도록 구성했었다.
- Result 객체를 사용하는 이유
- 개발자가 명시적으로 에러를 처리할 수 있으며, 코드의 가독성이 향상된다.
- 특정 상황에 따라 실패한 경우에만 특정 동작을 수행하도록 하면서 유연하게 에러를 대처할 수 있다.
(application 레이어 설명 시 참고)
result 패턴이 적용된 모습
글이 너무 길어질 거 같아 나눠서 작성하도록 하겠습니다. 이어질 글은 Application & Presentation Layer와 그 외 공용모듈에 관한 것입니다. 부족한 글 읽어주셔서 감사합니다!
[Flutter] 실제 Repository 패턴 프로젝트 구조 파헤치기#2 (Application)
❗️주의: 스스로 학습하며 구성한 프로젝트라 현직자 분들이 보시면 이상하게 느끼 실수 있습니다. "쟤는 저런 식으로 해봤구나" 정도로만 봐주시고 만약 틀린 부분을 발견하셨다면 피드백 남
nomal-dev.tistory.com
이 글이 꼭 정답은 아닙니다. 잘못된 부분이나 부족한 부분을 알려주시면 학습 후 수정하겠습니다.
'Flutter > project' 카테고리의 다른 글
[Flutter] 위젯, 뷰, 컴포넌트, 스크린 어떤 차이일까? UI 구조를 잡아보자! (0) 2024.01.30 [Flutter] 실제 Repository 패턴 프로젝트 구조 파헤치기#3 (Presentation-Layer) (0) 2024.01.30 [Flutter] 관심사가 우선일까, 레이어가 우선일까? (0) 2024.01.18 [Flutter] 실제 Repository 패턴 프로젝트 구조 파헤치기#2 (Application-Layer) (3) 2024.01.17 [Flutter] 에러 핸들링, Custom Exception 관리하기 (1) 2024.01.15