241129 TIL

나고수·2024년 12월 1일
0

2024 TIL

목록 보기
86/94
post-thumbnail

① 배운 것

Clean Architecture 도입 관련 TIL

1. 문제 상황 발견

도메인 엔티티의 필드명이 변경되면서 머지 충돌 발생했습니다:

  • A개발자: 기존 엔티티 필드명(gold)으로 뷰 개발
  • B개발자: 새로운 필드명(goldAmount)으로 엔티티 변경 및 뷰 개발
  • 결과: 동일한 뷰에 대한 코드가 충돌하며 병합이 어려워짐
// 개발자 A의 코드
class CurrencyWidget extends ConsumerWidget {
  Widget build(BuildContext context, WidgetRef ref) {
    final currency = ref.watch(currencyProvider);
    return Text('${currency.gold}');  // gold 필드 직접 참조
  }
}

// 개발자 B의 코드 (엔티티 변경 후)
class Currency {
  final int goldAmount;  // gold -> goldAmount로 변경
}
// => 머지 충돌 발생!

2. 근본적인 원인

  • 프레젠테이션 레이어(뷰)가 도메인 레이어(엔티티)를 직접 참조
  • 레이어 간 결합도가 높아 도메인 모델 변경이 UI 코드에 직접적인 영향
  • 관심사 분리가 제대로 이루어지지 않음
// BAD: 도메인 엔티티를 뷰에서 직접 사용
class CurrencyWidget extends ConsumerWidget {
  Widget build(BuildContext context, WidgetRef ref) {
    final currency = ref.watch(currencyProvider);
    return Text('${currency.gold}');  // 도메인 레이어에 직접 의존
  }
}

3. 해결 방향

  • UI는 UIState를 통해서만 데이터를 표시
  • 도메인 모델 변경이 UI 코드에 직접적인 영향을 주지 않도록 분리
  • 각 레이어의 책임을 명확히 구분
// GOOD: UIState를 통한 데이터 표시
class CurrencyUIState {
  final String formattedGold;
  
  CurrencyUIState({required this.formattedGold});
  
  factory CurrencyUIState.fromEntity(Currency currency) {
    return CurrencyUIState(
      formattedGold: '${currency.gold} Gold',
    );
  }
}

4. 해결 방안 (단계별 접근)

4.1 완전한 클린 아키텍처 적용

  • Data Layer: DTO, DataSource, Repository 구현체
  • Domain Layer: Entity, Repository 인터페이스, UseCase
  • Presentation Layer: UIState, ViewModel, View
  • 장점: 완벽한 관심사 분리
  • 단점: 대규모 리팩토링 필요
// Data Layer
class CurrencyDTO {
  final int amount;
}

// Domain Layer
class Currency {
  final int gold;
}

abstract class CurrencyRepository {
  Future<Currency> getCurrency();
}

// Use Case
class GetCurrencyUseCase {
  final CurrencyRepository repository;
  Future<Currency> execute() => repository.getCurrency();
}

// Presentation Layer
class CurrencyViewModel extends StateNotifier<CurrencyUIState> {
  final GetCurrencyUseCase useCase;
}

4.2 UseCase만 도입

  • 기존 구조를 유지하면서 UseCase 레이어만 추가
  • 중간 정도의 리팩토링으로 개선 효과
  • 도메인 로직의 캡슐화 달성
// 기존 Provider에 UseCase 추가
class CurrencyProvider extends StateNotifier<Currency> {
  // UseCase 스타일 메서드
  Future<Currency> getCurrency() async {
    // 비즈니스 로직
  }
}

4.3 현재 구조에서 최소 개선

  • Provider에 UseCase 스타일의 함수 추가
  • 뷰는 이 함수들을 통해서만 도메인 데이터 접근
  • 가장 빠르게 적용 가능한 방법
// Provider에 접근 메서드 추가
class CurrencyProvider extends StateNotifier<Currency> {
  int getGoldAmount() => state.gold;  // 엔티티 직접 노출 대신 메서드 제공
}

// View
class CurrencyWidget extends ConsumerWidget {
  Widget build(BuildContext context, WidgetRef ref) {
    final provider = ref.watch(currencyProvider.notifier);
    return Text('${provider.getGoldAmount()}');  // 메서드를 통한 안전한 접근
  }
}

5. 주요 고민점과 해결

5.1 UseCase 인터페이스 필요성

  • Repository는 의존성 역전을 위해 인터페이스 필요
  • UseCase는 도메인 레이어 내부이므로 선택적으로 결정
  • 테스트나 다양한 구현이 필요한 경우만 인터페이스 사용
// Optional: UseCase Interface
abstract class GetCurrencyUseCase {
  Future<Currency> execute();
}

// Implementation
class GetCurrencyUseCaseImpl implements GetCurrencyUseCase {
  final CurrencyRepository repository;
  
  
  Future<Currency> execute() => repository.getCurrency();
}

5.2 비동기 처리 방식

  • Repository, UseCase 모두 Future 반환이 일반적
  • async 함수의 return값은 자동으로 Future로 래핑됨
  • 데이터 접근의 비동기 특성을 명시적으로 표현
// Future 반환 예시
Future<Currency> getCurrency() async {
  final dto = await dataSource.getCurrency();  // Future<DTO>
  return Currency(gold: dto.amount);  // 자동으로 Future<Currency>로 래핑
}

5.3 상태 관리 방식

  • StateNotifier: 수동 상태 관리, 더 세밀한 제어 가능
  • AsyncNotifier: 자동 상태 관리, 보일러플레이트 감소 - loading, error, data 상태를 자동으로 관리
  • 테스트를 고려하면 의존성 주입이 여전히 중요 - viewModel에서 Usecase를 외부에서 주입받으면 테스트 용이
// StateNotifier
class CurrencyNotifier extends StateNotifier<CurrencyUIState> {
  Future<void> loadCurrency() async {
    state = const CurrencyUIState.loading();
    try {
      final currency = await useCase.execute();
      state = CurrencyUIState.data(currency);
    } catch (e) {
      state = CurrencyUIState.error(e.toString());
    }
  }
}

// AsyncNotifier

class CurrencyViewModel extends _$CurrencyViewModel {
  
  Future<CurrencyUIState> build() async {
    final currency = await ref.read(getCurrencyUseCaseProvider).execute();
    return CurrencyUIState.fromEntity(currency);
  }
}

6. 배운 점

  • 아키텍처 설계의 중요성 인식
  • 각 레이어별 책임 영역 구분 방법
  • 레이어 분리의 실제적 이점
  • 점진적 개선의 가치
  • Provider 패턴의 올바른 사용법
  • Future/async/await의 정확한 동작 방식
  • 테스트 용이성과 의존성 주입의 관계

7. 향후 고려사항

  • 점진적인 리팩토링 계획 수립
  • 테스트 전략 수립
  • 팀 컨벤션 정립
  • 지속적인 아키텍처 개선 방안 모색
  • 문서화 및 가이드라인 작성
  • 새로운 기능 개발시 클린 아키텍처 적용 방안
  • 기존 코드의 점진적 마이그레이션 전략

이렇게 코드 예시와 상세한 설명을 함께 포함하여 작성하면, 나중에 다시 볼 때도 전체적인 맥락과 구체적인 구현 방법을 쉽게 이해할 수 있을 것 같습니다.

② 회고 (restropective)
아직 갈 길이 멀다고 느끼지만 클린 아키텍처에 대해 진지하게 공부한 경험이 도움이 된 것 같다. 단순히 개념만 이해하는 것을 넘어 실제로 내 프로젝트에 적용할 방법을 고민하고 실천했다는 점이 뿌듯하다.

처음 이 문제를 고민하게 된 계기는 다른 작업자와 머지할 때 코드 충돌이 너무 자주 발생했기 때문이다. 왜 충돌이 발생하는지, 어떻게 하면 이를 줄일 수 있을지 고민하던 중 구조적인 문제가 있다고 판단했고 더 깊이 들여다보게 되었다. 충돌이 발생했을 때 단순히 “음 충돌이 났네, 대충 해결하고 넘어가자”라고 생각하는 대신, 이를 개선할 방법을 찾아본다는 점에서 스스로 성장했다고 느낀다. 대충 해결하고 넘어갈 수도 있었지만 협업 효율을 높이기 위해 고민했다는 점이 특히 의미 있다고 생각한다.

③ 개선을 위한 방법
이미 많이 진행된 회사 프로젝트에 완전한 클린 아키텍처를 적용하는 건 현실적으로 어려워서 특정 부분에만 적용해봤다. 클린 아키텍처는 이론적으로는 매력적이지만, 현업에서 이를 완벽히 구현하려고 하면 보일러플레이트 코드가 많아진다는 부정적인 의견도 존재한다.

실제로 한 부분에만 완전한 클린 아키텍처를 적용했을 때, 데이터 레이어의 DTO, 도메인 레이어의 Entity, 프레젠테이션 레이어의 UiState 등 하나의 로직에만 세 가지 모델 클래스가 필요했다. 그리고 이들을 매핑하는 과정도 번거롭게 느껴졌다. 지금까지의 경험으로는 DTO를 거의 수정하지 않고 바로 뷰로 전달해도 큰 문제가 없었기 때문에 더 그렇게 느껴졌던 것 같다.

클린 아키텍처는 개발자들 사이에서도 의견이 분분한 주제라 앞으로 더 다양한 사례와 의견을 들어볼 필요가 있을 것 같다.

profile
되고싶다

0개의 댓글