(수칙을 어기게 되었습니다.. 앞다리도 달아드릴게요)
요새 취준에 돌입하며 이력서에 기입한 프로젝트 코드를 둘러봤다.
flutter 앱 프로젝트로, Clean Architecture + MVVM의 구조를 이루고 있다.
내가 어떻게 코딩했었나 상기시키며
Clean Architecture에 대한 개념 공부를 다시 하고있었다.
그러던 중, 내가 직접 구성한 Architecture가 의존성 문제를 가지고 있음을 알게되었다.
물론 서비스가 운영되고 기능하는데는 전혀 문제가 없었지만.. Clean Architecture의
"의존성의 방향을 철저하게 규제한다"
라는 대 전제를 어기게 된다.
누가 내 코드를 그렇게까지 뜯어볼까 싶겠지만..
문제를 알면서도 넘기기에는 찜찜하기도하고,
정말 만약의 경우 기업에서 이것을 발견해 면접에서 공격당할까봐
무섭기도 해서 그냥 고쳐보려 마음먹었다.
서비스의 구조를 domain / data / presentation 계층(명칭은 다양하게 바뀔 수 있음)
으로 나누어 설계한 아키텍쳐로, 의존성의 방향이 항상 domain 쪽을 향한다는 특징이 있음.
이 아키텍쳐는 워낙 유명하기도 하고, 훌륭하게 설명된 글이 많으니
조금만 검색해봐도 쉽게 개념과 예시코드를 볼 수 있다. (포스팅 참고)
여기서 의존성 방향에 대해 조금 더 이야기해보자.
A -> B의 의존성의 방향을 가진다는 것은 곧
A가 B를 참조하거나 가져다 쓰고있다는 뜻이다.
좀 더 이해하기 쉽고 단순하게 표현하자면,
A 파일 어딘가에 B파일이 import 되었다.
라고 요약할 수 있겠다.
이를테면 이러한 상황이다.
class CafejariApp을 가진 이 파일은
go_router, pull_to_refresh, sliding_up_panel 이라는 패키지,
그리고 같은 모듈 안에 있는 map_state.dart 라는 파일에 있는
무언가(클래스나 함수 등등)에 의존성을 가지는 파일이다.
clean architecture는 계층분리 + 의존성의 방향을 일관되게
유지함으로써 가지는 가장 큰 장점은 이거다.
분리된 로직 구성
테스트가 용이하다든지, 유지보수가 좋다든지 하는 말과 모두 일맥상통한다.
A -> B의 의존성이 보장된다는 것은
A를 아무리 고쳐도 B를 고칠 필요는 없다는 것이며,
B는 A가 어떻게 실행되는지 전혀 몰라도 된다는 뜻이다.
따라서 목업데이터로 테스트를 진행한다든지,
특정 계층의 수정이 필요할 때 최소한의 변경만으로 가능하게 되는 것이다.
Clean Architecture에서 의존성의 방향을 유지하는 것의 중요성을 알게 됐을것이다.
일반적으로 그 방향성은 아래와 같은 모양을 가진다.
presentation(UI) -> domain
data(remote/local) -> domain
이 방향성이 제대로 설정되었다면,
presentation 계층에 있는 파일이 data 계층에 있는 파일을 import하거나
domain 계층에 있는 파일이 presentation 계층에 있는 파일을 import하는 등
잘못된 방향의 의존성이 존재해서는 안된다.
하지만 앱을 설계할 당시, Clean Architecture를 잘못 이해한건지..
내 프로젝트 코드에서는 이를 잘못 적용했다.
먼저 내 코드의 구조는 대충 이렇다.
이렇게 폴더로 data / domain / presentation 계층을 분리했고,
data에는 서버 통신 서비스를 구현한 remote와
이를 domain과 연결할 repository를 구현했다.
domain에는 앱 데이터 단위인 entity와
repository에서 받은 데이터를 가공해 줄 use case들을 구현했다.
repository에서는 DTO를 통해 서버와 통신하는 역할을,
use case에서는 이 DTO를 앱 Entity로 바꿔주는 역할을 부여해줬다.
한쪽의 역할이 너무 커지면 다른 한쪽의 존재 의미가 옅어지기 때문이었다.
repository | use case |
---|---|
![]() | ![]() |
여기서 문제가 발생한다. DTO는 http통신을 위한 객체이므로
data 계층에 정의되어있다. 하지만 use case의 역할이
DTO를 Entity로 바꿔주는 것이므로, 어쩔수 없이 DTO 파일을 import하게된다.
아래는 domain에 위치한 어떤 use case의 import 목록이다.
domain이 data를 의존하고 있는 것이다.
물론 작동에는 문제가 없지만..
'clean'한 architecture를 위해 고쳐주자.
가장 먼저 data 계층에 있던 repository interface를
domain 계층으로 옮겨주었다.
이 repository interface를 두는 이유 역시 의존성 때문이다.
domain 계층에 이 interface를 둠으로써 자세한 구현은
신경쓰지 않고 use case를 작성할 수 있다.
아래는 domain 계층에 구현된 repository interface의 일부이다.
import 'package:cafejari_flutter/domain/entity/app_config/app_config.dart';
import 'package:cafejari_flutter/domain/entity/cafe/cafe.dart';
import 'package:cafejari_flutter/domain/entity/challenge/challenge.dart';
import 'package:cafejari_flutter/domain/entity/push/push.dart';
import 'package:cafejari_flutter/domain/entity/request/request.dart';
import 'package:cafejari_flutter/domain/entity/shop/shop.dart';
import 'package:cafejari_flutter/domain/entity/user/user.dart';
/// app_config(기본 설정) 데이터 repository의 interface
abstract interface class AppConfigRepository {
// LOCAL
Future<bool> getIsInstalledFirstTime();
putIsInstalledFirstTime(bool isInstalled);
Future<bool> getIsReviewSubmitted();
putIsReviewSubmitted(bool isSubmitted);
Future<bool> getIsFlagButtonTapped();
putIsFlagButtonTapped(bool isTapped);
Future<DateTime> getLastPopUpTime();
putLastPopUpTime(DateTime datetime);
// REMOTE
Future<Versions> fetchVersion();
}
/// cafe application api와 통신하는 repository의 interface
abstract interface class CafeRepository {
// REMOTE
Future<Cafes> fetchMapCafe({required double latitude, required double longitude, required int zoomLevel});
Future<Cafe> retrieveCafe({required int cafeId});
Future<Cafes> fetchSearchCafe({required String query, double? latitude, double? longitude});
Future<Cafes> fetchRecommendedCafe({required double latitude, required double longitude});
Future<OccupancyRateUpdates> fetchMyOccupancyUpdate({required String accessToken});
Future<Map<int, OccupancyRateUpdates>> fetchMyTodayOccupancyUpdate({required String accessToken});
Future<NaverSearchCafes> fetchNaverSearchResult({required String query});
Future<Locations> fetchLocation();
Future<OccupancyRateUpdate> postOccupancyRateAsUser(
{required String accessToken,
required double occupancyRate,
required int cafeFloorId});
Future<OccupancyRateUpdate> postOccupancyRateAsGuest(
{required double occupancyRate,
required int cafeFloorId});
Future<void> postCATI(
{required String accessToken,
required int cafeId,
required int openness,
required int coffee,
required int workspace,
required int acidity});
}
.
.
.
자세한 코드는 볼 것 없고,
맨위에 import를 보면 모든 의존성이 domain계층안에서만 생긴다.
그리고 이 repository의 구현을 data 계층이 미뤄버리면 끝이다.
아래 data계층에 있는 repository 구현체를 보자.
import 'package:cafejari_flutter/core/exception.dart';
import 'package:cafejari_flutter/data/remote/api_service.dart';
import 'package:cafejari_flutter/data/remote/dto/app_config/app_config_response.dart';
import 'package:cafejari_flutter/domain/entity/app_config/app_config.dart';
import 'package:cafejari_flutter/domain/repository.dart';
import 'package:hive_flutter/hive_flutter.dart';
/// app_config repository의 구현부
class AppConfigRepositoryImpl implements AppConfigRepository {
final String boxLabel = "local";
final String isInstalledFirstTimeKey = "isInstalledFirstTime";
final String isReviewSubmittedKey = "IsReviewSubmitted";
final String isFlagButtonTappedKey = "isFlagButtonTapped";
final String lastPopUpTimeKey = "lastPopUpTime";
APIService apiService;
AppConfigRepositoryImpl(this.apiService);
// LOCAL
@override
Future<bool> getIsInstalledFirstTime() async {
final Box<dynamic> box = await Hive.openBox(boxLabel);
return await box.get(isInstalledFirstTimeKey) ?? true;
}
@override
putIsInstalledFirstTime(bool isInstalled) async {
final Box<dynamic> box = await Hive.openBox(boxLabel);
await box.put(isInstalledFirstTimeKey, isInstalled);
}
@override
Future<bool> getIsReviewSubmitted() async {
final Box<dynamic> box = await Hive.openBox(boxLabel);
return await box.get(isReviewSubmittedKey) ?? false;
}
.
.
.
여기서도 import 부분을 보면 domain계층이나 data계층 내부에서만
의존성이 생기는 모습을 볼 수 있다.
이렇게 repository를 추상화해 interface와 구현체로 나눔으로써
data - domain 계층 간 의존성의 방향을 바꿔주었는데,
이를 '의존성 역전'이라고 한다.
기존 코드가 문제였던 이유는 use case의 역할때문에
어쩔 수 없이 data 계층의 DTO를 참조했기 때문이었다.
따라서 use case와 repository의 역할을 조금 바꿔줬다.
기존 repository: DTO객체를 만들어 통신하고 DTO를 그대로 반환
기존 use case: 반환된 DTO를 서비스 Entity로 변환 + 정렬 + 예외처리바뀐 repository: DTO객체를 만들어 통신하고 DTO를 서비스 Entity로 바꿔 반환
바뀐 use case: Entity를 받아 정렬+예외처리 후 반환
즉, DTO -> Entity 변환과정을 repository로 위임해 준 것이다.
보이는 것처럼 repository는 역할이 늘어 길어졌고
use case는 역할이 줄어 간략해졌다.
결과적으로 아래 import 부분을 보면
use case에서의 data계층 의존성이 없어진 것을 볼 수 있다.
간단해 보이지만 대 공사였다.
수정사항을 적용해 앱을 버전업하고 push했다.
이로써 진짜 'Clean Architecture'가 되었다.
비록 포폴용 코드지만 고쳐놓으니 꽤 후련하다.
이제 두번째 프로젝트도 조금 손봐야지..