ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Flutter] Flutter에서 Clean Architecture 구조 설계하기 (feat. get_it)
    Flutter 2024. 5. 14. 03:04
    반응형

    시작하기 전에

    사실 Clean Architecture 구조를 설계한 것을 보면 정석적인 구조를 바탕으로 굉장히 다양한 형태로 구현이 되어 있습니다. 패키지로만 분리되어 있는 경우도 있고, 일부만 모듈로 따로 분리하는 경우도 있는데, 모듈 (혹은 패키지) 간 의존성만 떼어내고 관리할 수 있다면 사실 어떤 구조든 맞다고 생각합니다. (그래서 제가 만든 구조도 틀리지 않다고 생각합니다.. 아마도..) 일단 기본 전제는 아래와 같이 잡았습니다.

    1. 무슨 일이 있어도 presentation 레이어는 data 레이어를 참조할 수 없어야하며, domain 레이어만을 참조할 것
    2. 무슨 일이 있어도 data 레이어는 presentation 레이어에 접근할 수 없어야하며, domain 레이어만을 참조할 것
    3. 그러기 위해 presentation 레이어에서는 data 레이어의 어떤 클래스도 가져올 수 없어야 한다. (data -> presentation 도 마찬가지)
    4. 팀 내에서 정한 룰이 아닌, 아예 코드로서 강제할 수 있어야 한다. (이렇게 쓰지마~ 가 아닌 아예 import가 불가능해야 한다)
    5. get_it을 이용해서 의존성 주입을 시도할 것

    본인이 정한 전제 조건을 지키기 굉장히 까다로웠고, 안될 것이라고 생각했는데, 나름 편법(?) 같은 느낌으로 만들어 봤습니다. 그리고 지금에서야 나름 나쁘지 않은 구조라고 느끼게 만들어 봤습니다. 

     

     

     

     

     

    프로젝트 구조

    사실 이것보다 더 많은 파일이 있지만 가장 핵심되는 파일만 뽑아봤습니다. 이름은 프로젝트를 공개할 수 없어서 구조만 보여주기 위해 아무 이름으로 적었습니다.

    1. project
    최상위 프로젝트이며, app, data, domain 모듈을 참조할 수 있습니다. (app, data, domain이 project를 역참조 하지 않습니다.) get_it을 초기화하고 프로젝트를 실행하기 위한 main.dart와 get_it을 초기화하고 실행시키는 로직이 있는 service_locator.dart가 있습니다.

    2. app 모듈 (Presentation Layer)
    clean architecture의 presentation 레이어를 의미하며, Bloc, Provider, Screen, Widget 등 UI 파일이 들어있고 UI를 그리기 위한 로직이 들어있는 패키지입니다.

    3. domain 모듈 (Domain Layer)
    clean architecture의 Domain 레이어입니다. Data 레이어가 참조할 repository 인터페이스와 Presentation 레이어가 참조해서 UI 화면을 그릴 기준을 잡아주는 Usecase, Data 레이어로부터 데이터를 받아와 화면을 그리기 적합한 형태로 정제된, UI용 데이터인 Entity가 있습니다.

    4. Data 모듈 (Data Layer)
    clean architecture의 Data 레이어입니다. 실제 서버와 통신해서 데이터를 받아올 api 로직이 들어있는 datasource (api), Domain 레이어의 interface를 구현할 repository Implementation 파일, 서버로부터 받아온 데이터를 클래스로 변환한 Dto가 들어 있습니다.

     

     

     

     

     

    1. Project

    일단 핵심 내용은 아래와 같습니다.

    # project pubspec.yaml
    ...
    
    dependencies:
      flutter:
        sdk: flutter
        
      get_it: 7.0.0
    
      data:
        path: packages/data
      domain:
        path: packages/domain
      app:
        path: packages/app
    
    ...

     

    // main.dart
    
    void main() {
      WidgetsFlutterBinding.ensureInitialized();
      ServiceLocator.initializeApp();
    
      runApp(const SomeScreen());
    }
    // service_locator.dart
    
    class ServiceLocator {
      static var di = GetIt.instance;
    
      static void initializeApp() {
        // repository
        di.registerFactory<SomeRepository>(
            () => SomeRepositoryImpl());
    
        // usecase
        di.registerFactory<SomeUsecase>(
          () => SomeUsecase(di()),
        );
    
        // bloc
        di.registerFactory<SomeBloc>(
          () => SomeBloc(di()),
        );
      }
    
      GetIt get() => di();
    }

     

    여기서 드는 의문 하나. 왜 프로젝트와 아키텍쳐를 구성하는 presentation, domain, data 레이어를 따로 분리했을까요? 사실 main.dart는 UI를 직접 그리는 presentation 레이어에 들어있는 것이 맞지 않은가요?

     

    사실 구현하면서 가장 고민하고 문제가 되었던 부분인데, get_it을 사용하기 위해선 get_it을 초기화하고 inject 대상들을 등록해주는 과정이 필요합니다. 이것은 main.dart에서 하게 되는데, 만약 main.dart가 app 모듈 안에 존재한다면?

     

    다른 애들은 상관이 없는데 data 모듈에 있는 클래스들이 문제입니다. 즉 app 모듈 (presentation 레이어) 가 data 모듈에 있는 클래스를 뽑아올 방법이 없어서 위의 코드 중 SomeRepositoryImpl을 불러올 방법이 없습니다. 만약 저걸 부르려고 data 모듈을 app 모듈 내 pubspec.yaml의 dependencies에 추가한다면? 제가 만든 전제 조건을 명확하게 위반하게 됩니다. 

    1. 무슨 일이 있어도 presentation 레이어는 data 레이어를 참조할 수 없어야하며, domain 레이어만을 참조할 것

     

    따라서 제가 정한 구조의 법칙은 아래와 같습니다.

    1. project는 app, domain, data 모듈을 참조한다.
    2. 이에 따라 get_it에 주입 대상을 등록하고 초기화하는 것 역시 project에서 한다.
    3. 이를 위해 project는 main.dart를 가진다.
    4. app 모듈을 이미 참조하고 있으므로 main.dart에 app 모듈의 최초 스크린을 가져와 실행시킨다.
    5. app, domain, data 모듈은 project를 역참조하지 않는다.
    6. 이를 위반할 경우, app이 data를, data가 app 모듈을 바라보게 되는 문제가 발생하므로 반드시 지킨다.

    여담으로 이건 Android에서 Clean Architecture를 적용할 때도 같은 고민을 하긴 했습니다. Dagger Hilt가 정확히 위와 같은 이슈가 있었는데, HiltApplication을 어디에 넣어야 적절할 지 고민을 했는데, project 최상위에 넣고 app 모듈을 따로 분리하면 좀 더 제가 원하는 구조가 되더라구요. 

     

     

     

     

    2. Data 모듈

    사실 이 다음부터는 clean architecture의 기본 구조와 같습니다. data 모듈은 위에서 얘기한대로 repositoryImpl, Dto, api를 가지고 있습니다.

     

    // some.api.dart
    
    class SomeApi {
      Future<Map<String, dynamic>?> getSomeData() async {
        ...
        return result;
      }
    }
    // some.dto.dart
    
    part 'some.dto.g.dart';
    
    @JsonSerializable()
    class SomeDto {
      @JsonKey(name: 'param1')
      int? param1;
    
      @JsonKey(name: 'param2')
      String? param2;
    
      @JsonKey(name: 'param3')
      bool? param3;
    
      SomeDto({this.param1, this.param2, this.pararm3});
    
      factory SomeDto.fromJson(Map<String, dynamic> json) =>
          _$SomeDtoFromJson(json);
    
      Map<String, dynamic> toJson() => _$SomeDtoToJson(this);
    }
    // some.repository.impl.dart
    
    class SomeRepositoryImpl implements SomeRepository {
      var someApi = SomeApi();
    
      @override
      Future<SomeEntity> getSomeData() async {
        var data = await someApi.getSomeData();
        return SomeEntity.fromJson(data);
    }

     

    사실 상 완전 슈도 코드 형태라 위처럼만 작성하면 거의 안 돌아간다고 보시는게 좋을 것 같습니다. (프로젝트 사정 상 공개를 할 수가 없습니다..)

     

    SomeApi

    서버에서, 혹은 DB에서 데이터를 받기 위한 여러 작업을 할 수 있습니다. Retrofit으로 서버 API를 호출할 수도 있고, Hive Database에서 캐싱된 데이터를 꺼낼 수도 있습니다.

     

    SomeDto

    - part 'some.dto.g.dart' 라는 부분이 있는데, build_runner와 json_serializable을 이용해서 generator 파일을 생성하기 위해 선언해 줍니다. 이건 기회가 된다면 따로 설명을 하겠습니다만, 여기서는 일단 넘어가겠습니다. 사실 굳이 저거 없어도 toJson, fromJson 기능을 따로 개발해줘도 됩니다.  

     

    SomeRepositoryImpl

    아마 요게 또 이슈가 될 수 있는데, 왜 Implementation 파일은 Data 레이어에, 인터페이스는 Domain 레이어에 있을까요?

    Clean Architecture는 기본적으로 Domain 레이어가 최상위에 있고, Presentation 레이어와 Data 레이어는 Domain 레이어만을 참조하게 됩니다. 이렇게하는 이유는 UI를 그리는 로직과 Data를 부르는 로직의 분리를 위해서입니다.

     

    만약 https://keykat.api.com/v1/image 라는 API를 이용해서 앱을 개발한다고 했을 때, Presentation 레이어, 즉 UI를 그리는 위치에서 해당 API를 호출하고, 모델을 만들고, 그것을 UI에 그대로 때려박게 만들었다고 생각해 봅시다. 개발은 굉장히 편할 수 있습니다. 그런데 만약 해당 API가 deprecated 혹은 불가피하게 삭제되고 https://keykat.api.com/v2/image 로 바뀌게 된다면?

     

    만들 때는 쉬웠지만 해당 API의 위치를 전부 찾아서 다시 수정하고, 모델도 형태에 맞게 찾아서 다시 수정하고, 밀접하게 연결되어 있던 UI도 다시 수정하고... 파일로 잘 분리되어 있었다면 차라리 괜찮을 수는 있지만, (그럴 리는 없겠지만) 하나의 파일에 모든 로직이 다 들어있다면? 심지어 v2도 망가져서 v3가 갑자기 생긴다면?

     

    그런데 모듈로 분리하게 된다면 이야기가 달라집니다. API가 달라질 때마다 그냥 v2 모듈과 v3 모듈을 새로 만들면 되니까요. 이 때 중요한 것이 Domain 레이어의 Repository 인터페이스입니다. Dto의 형태가 조금 달라질 수는 있지만 근본적인 형태는 그렇게 차이가 나지 않을 것입니다. API의 변환은 마이그레이션의 개념이지 새로운 앱 구축이 아닐테니까요. (아닐 수도 있지만)

     

    따라서 Domain 레이어의 Repository 인터페이스는 아래를 의미한다고 볼 수 있습니다. 

    UI에서 화면을 어떻게 그리는 진 모르지만 이런 데이터가 필요하대. 이런 형태로 내놓으렴. 

     

    즉 Data 레이어가 데이터를 어떻게 전달해줘야 하는 지 알려주는 가이드를 하는 것이 Repository 인터페이스입니다. 그럼 가이드대로 구현체를 개발하는 것은 당연히, API 혹은 Database 호출 로직이 들어있는, 데이터 관련 처리를 하는 Data 레이어가 가지고 있어야겠죠? 이것이 인터페이스와 구현체가 분리된, 흔히 디자인 패턴에서 말하는 브릿지 패턴 (Bridge Pattern) 입니다.

     

     

    반응형

     

    3. Domain 모듈

    위에서 핵심 내용은 거의 다 해서 할 이야기가 따로 없네요.

    // some.repository.dart
    
    abstract class SomeRepository {
      Future<SomeEntity> getSomeData();
    }
    // some.usecase.dart
    
    class SomeUsecase {
      final SomeRepository someRepository;
    
      SomeUsecase(this.someRepository);
    
      Future<SomeEntity> getSomeData() async {
        ...
      }
    }
    // some.entity.dart
    
    part 'some.entity.g.dart';
    
    @JsonSerializable()
    class SomeEntity {
      @JsonKey(name: 'param1')
      int? param1;
    
      @JsonKey(name: 'param2')
      String? param2;
    
      @JsonKey(name: 'param3')
      bool? param3;
    
      SomeEntity({this.param1, this.param2, this.param3});
    
      factory SomeEntity.fromJson(Map<String, dynamic> json) =>
          _$SomeEntityFromJson(json);
    
      Map<String, dynamic> toJson() => _$SomeEntityToJson(this);
    }

     

    SomeRepository

    위에서 이미 이야기를 했으므로 생략하겠습니다.

     

    SomeUsecase

    UI에서 어떤 액션 및 행동에 따라 데이터를 결정해주는 것인데, 예를 들어 홈 화면에 있는 이미지를 가져와서 그리고 싶다면 해당 이미지를 API로부터 호출해야하고, ViewHomeUsecase를 만들어서 해당 API를 호출할 수 있게끔 하는, 액션과 행동 패턴에 따른 사용자 행동을 정의해주는 클래스입니다.

     

    SomeEntity

    SomeDto를 UI에서 사용할 수 있는 형태에 맞게끔 변환한 클래스입니다. 사실 상 최종 결과물인데, 위에선 toJson, fromJson 같은 메서드를 구현해서 대체했지만 좀 더 디테일하게 따지면 Mapper 메서드 및 클래스가 따로 존재해서 Dto -> Entity로 변환하는 기능을 개발하기도 합니다.

     

    서버에서 받아온 Data의 결과물인 Dto와 실제 UI를 그리기 위해 필요한 데이터인 Entity의 형태가 크게 다를 경우엔 Mapper 클래스를 만들어서 UI에서 사용할 수 있는 형태로 정제하는 과정이 필요한데, 해당 프로젝트에선 Dto 형태를 거의 그대로 쓸 수 있어서 따로 구현할 필요 없이 toJson과 fromJson으로도 충분했습니다. 상황에 따라 Mapper를 어떻게 구현할 지 결정하면 될 것 같습니다.

     

    굳이 예시를 들자면 완전 대충 만들었지만 아래와 같은 느낌이라고 할 수 있습니다.

    // 데이터
    class Dto {
    	String? userGrade;
        List<String>? kIgUrlList;
        
        Dto(this.userGrade, this.kIgUrlList);
    }
    
    // 도메인
    class Entity {
    	UserGradeType gradeType;
        String? homeMainImageUrl;
        String? homeBottomImageUrl;
        
        Entity(this.gradeType, this.homeMainImageUrl, this.homeBottomImageUrl);
    }
    
    enum UserGradeType {
    	bronze, silver, gold, platinum
    }
    
    // Mapper
    class Mapper {
    	Future<Entity> toDomain(Dto dto) async {
        	UserGradeType gradeType;
            String? homeMainImageUrl;
            String? homeBottomImageUrl;
            
            if (dto.userGrade == 'silver') {
            	gradeType = UserGradeType.silver;
            } else if (...) {
            	...
            }
            
            homeMainImageUrl = dto.kIgUrlList?[0];
            homeBottomImageUrl = dto.kIgUrlList?[1];
            
            return Entity(gradeType, homeMainImageUrl, homeBottomImageUrl);
        }
    }

     

     

     

     

    4. App 모듈

    앱 모듈은.. 화면 로직은 제쳐두고 굳이 하자면 Bloc 정도만 설명하겠습니다. 

    class  SomeBloc extends Bloc<SomeEvent, SomeState> {
      final SomeUsecase someUsecase;
    
      SomeBloc(this.reservationUsecase) : super(SomeState.initial()) {
        on<SomeEvent>(_getSomeData);
      }
    
      Future<void> _getSomeData(
        SomeEvent event,
        Emitter<SomeState> emit,
      ) async {
        try {
          var someData = await someUsecase.getSomeData();
    
          emit(SomeState.initial(someData));
        } catch (e) {
          emit(SomeState.error());
        }
      }
    }
    abstract class SomeEvent {
      const SomeEvent();
    
      factory SomeEvent.init() = SomeInitEvent;
    }
    
    class SomeInitEvent extends SomeEvent { }
    abstract class SomeState {
      const SomeState();
    
      factory SomeState.initial({
        SomeEntity? someData
      }) = SomeInitialState;
    }
    
    class SomeInitialState extends SomeState {
      final SomeEntity? someData;
    
      SomeInitialState({this.someData});
    }

     

    사실 상 Bloc의 가장 일반적인 형태인데, 눈여겨 볼 점은 SomeUsecase를 가져올 때 파라미터로 가져오고 있고, get_it에서 등록한 SomeUsecase를 불러와 쓰게 됩니다. Bloc도 get_it에 등록해놨으니 get_it의 인스턴스로써 들고올 수 있구요. 이 밖에 따로 특이한 점은 없는 것 같네요. Bloc는 기회가 된다면 따로 설명하겠습니다.

     

     

     

     

    마치며..

    제가 만든 구조를 보여주면서 쭉 설명을 했는데, 솔직히 자신 없습니다. 이렇게 만드는 것이 맞는 지도 잘 모르겠고, 괜히 완전히 틀린 이상한 구조를 보여주는 것은 아닌지, 그래서 괜히 혼동을 주는 건 아닌지...

    원래 사람은 실패하면서 성장하는 것이라고, 이렇게 치부를 공개하면서 누군가 지적해주면서 뭐가 틀렸는지 알게되면서 성장하지 않을까 합니다. 틀린 부분이 있다면, 언제든지 환영입니다. 많이 지적해 주세요. (욕은 안돼요.. 상처받아요..)

    반응형
Designed and Written by keykat.