Flutter 개발 가이드라인: 낮은 결합도와 유지보수성 🛠️

woogi·2025년 4월 29일
0

Flutter 개발 가이드

목록 보기
4/5

안녕하세요, 우기입니다! Flutter 개발 가이드라인 시리즈의 세 번째 글에서는 '낮은 결합도'의 중요성과 이를 통해 유지보수성을 높이는 다양한 방법에 대해 자세히 알아보겠습니다.

개요

복잡한 앱을 개발할수록 코드베이스의 결합도 관리는 장기적인 프로젝트 성공에 결정적인 영향을 미칩니다. 이번 글에서는 Flutter 앱에서 결합도를 효과적으로 관리하는 구체적인 방법과 패턴을 살펴보겠습니다.

이 글을 통해 얻을 수 있는 것:

  • 결합도의 의미와 영향 이해

  • 다양한 결합도 감소 패턴 및 기법

  • 실제 프로젝트에 적용 가능한 구체적인 예시

  • 효과적인 아키텍처 구성 방법

결합도란 무엇인가? 🔗

결합도(Coupling)는 코드의 한 부분이 다른 부분에 얼마나 의존하는지를 나타내는 척도입니다. 높은 결합도는 다음과 같은 문제를 일으킵니다:

  • 변경의 파급 효과: 한 부분의 변경이 다른 많은 부분에 영향을 미침
  • 테스트 어려움: 의존성이 높은 코드는 격리하여 테스트하기 어려움
  • 재사용성 저하: 다른 모듈과 강하게 결합된 코드는 재사용하기 어려움
  • 이해 복잡성 증가: 코드 간의 관계가 복잡할수록 전체 시스템을 이해하기 어려움

반면, 낮은 결합도는 다음과 같은 이점을 제공합니다:

  • 변경의 격리: 한 모듈의 변경이 다른 모듈에 미치는 영향 최소화
  • 병렬 개발 가능: 팀원들이 독립적으로 작업 가능
  • 단위 테스트 용이성: 의존성이 명확하게 제어되어 테스트가 쉬움
  • 코드 재사용성 향상: 독립적인 모듈은 다른 프로젝트에서도 쉽게 재사용 가능

결합도의 종류와 수준 📊

모든 결합도가 동일하게 생성되는 것은 아닙니다. 결합도에는 다양한 종류와 수준이 있으며, 이를 이해하면 코드를 더 효과적으로 구성할 수 있습니다.

결합도 수준 (낮은 것부터 높은 것까지)

  1. 데이터 결합도: 모듈 간에 단순 데이터만 공유 (가장 낮은 결합도)
  2. 스탬프 결합도: 데이터 구조를 공유하지만 내부 구조에는 의존하지 않음
  3. 제어 결합도: 한 모듈이 다른 모듈의 내부 흐름에 영향을 줌
  4. 외부 결합도: 모듈이 외부에서 정의된 데이터 형식에 의존
  5. 공통 결합도: 모듈이 글로벌 데이터를 공유
  6. 내용 결합도: 한 모듈이 다른 모듈의 내부 구현에 직접 접근 (가장 높은 결합도)

Flutter 앱을 개발할 때는 가능한 한 데이터 결합도나 스탬프 결합도 수준을 유지하는 것이 좋습니다.

Flutter에서 흔히 보이는 높은 결합도의 예

// ❌ 내용 결합도: 다른 클래스의 비공개 멤버에 직접 접근
class UserProfileScreen extends StatelessWidget {
  
  Widget build(BuildContext context) {
    final userManager = UserManager();
    // userManager의 내부 구현에 직접 접근
    return Text('Welcome, ${userManager._currentUser.name}');
  }
}

// ❌ 공통 결합도: 전역 상태에 의존
class CartButton extends StatelessWidget {
  
  Widget build(BuildContext context) {
    // 전역 변수 사용
    return Badge(
      count: GlobalState.cartItems.length,
      child: IconButton(
        icon: Icon(Icons.shopping_cart),
        onPressed: () {
          // 전역 함수 호출
          GlobalState.navigateToCart();
        },
      ),
    );
  }
}

// ❌ 제어 결합도: 한 클래스가 다른 클래스의 흐름 제어
class LoginScreen extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () {
        final authService = AuthService();
        if (authService.login()) {
          // 다른 클래스의 내부 상태에 기반한 제어 흐름
          if (authService.isFirstLogin) {
            Navigator.pushNamed(context, '/onboarding');
          } else {
            Navigator.pushNamed(context, '/home');
          }
          // 다른 클래스의 내부 상태 직접 변경
          authService.isFirstLogin = false;
        }
      },
      child: Text('로그인'),
    );
  }
}

결합도를 낮추는 핵심 패턴 🛠️

1. 의존성 주입 (Dependency Injection)

의존성 주입은 클래스가 자신의 의존성을 직접 생성하지 않고 외부에서 받아오는 패턴입니다.

// ❌ 높은 결합도: 직접 의존성 생성
class UserRepository {
  final ApiClient apiClient = ApiClient();
  final LocalStorage storage = LocalStorage();
  
  Future<User> getUser(String id) async {
    try {
      final userData = await apiClient.get('/users/$id');
      final user = User.fromJson(userData);
      await storage.save('last_viewed_user', user);
      return user;
    } catch (e) {
      throw Exception('사용자 정보를 가져오지 못했습니다');
    }
  }
}

// ✅ 낮은 결합도: 의존성 주입을 위한 클래스 설계
class UserRepository {
  final ApiClient apiClient;
  final StorageService storage;
  
  UserRepository({
    required this.apiClient,
    required this.storage,
  });
  
  Future<User> getUser(String id) async {
    try {
      final userData = await apiClient.get('/users/$id');
      final user = User.fromJson(userData);
      await storage.save('last_viewed_user', user);
      return user;
    } catch (e) {
      throw Exception('사용자 정보를 가져오지 못했습니다');
    }
  }
}

// ❌ 불완전한 의존성 주입: 사용 위치에서 의존성을 직접 생성
// 이 방식은 여전히 구체적인 구현체(ApiClient, SharedPrefsStorage)에 의존합니다
final repository = UserRepository(
  apiClient: ApiClient(),
  storage: SharedPrefsStorage(await SharedPreferences.getInstance()),
);

// ✅ 진정한 의존성 주입: 중앙 집중식 의존성 관리
// DI 컨테이너 설정 (예: 앱 시작 시점)
Future<void> setupDependencies() async {
  // 필요한 의존성을 앱 시작 시점에 한 번만 생성
  final sharedPrefs = await SharedPreferences.getInstance();
  final storageService = SharedPrefsStorage(sharedPrefs);
  final apiClient = ApiClient();
  
  // 의존성 주입 컨테이너(GetIt) 등록
  getIt.registerSingleton<StorageService>(storageService);
  getIt.registerSingleton<ApiClient>(apiClient);
  getIt.registerSingleton<UserRepository>(
    UserRepository(
      apiClient: apiClient,
      storage: storageService,
    ),
  );
}

// 사용 시점 (예: 위젯 내부)
Widget build(BuildContext context) {
  // 미리 생성된 인스턴스를 가져옴 (구현 세부 사항 모름)
  final userRepository = getIt<UserRepository>();
  
  return FutureBuilder<User>(
    future: userRepository.getUser('123'),
    builder: (context, snapshot) {
      // UI 구현...
    },
  );
}

의존성 주입의 장점:

  • 테스트 용이성: 테스트 시 실제 구현체 대신 모의 객체(mock)를 주입할 수 있습니다
  • 유연성: 구현체를 쉽게 교체할 수 있습니다 (예: 스토리지 방식 변경)
  • 관심사 분리: 객체 생성과 사용을 명확히 분리합니다

2. 인터페이스를 통한 추상화

구체적인 구현보다 인터페이스(추상 클래스)에 의존하는 방식으로 설계합니다.

// 인터페이스 정의
abstract class AuthService {
  Future<User?> getCurrentUser();
  Future<bool> signIn(String email, String password);
  Future<void> signOut();
}

// 구현체 1: Firebase 인증
class FirebaseAuthService implements AuthService {
  final FirebaseAuth _auth = FirebaseAuth.instance;
  
  
  Future<User?> getCurrentUser() async {
    final firebaseUser = _auth.currentUser;
    if (firebaseUser == null) return null;
    // Firebase 사용자를 앱 User 모델로 변환
    return User(
      id: firebaseUser.uid,
      email: firebaseUser.email ?? '',
      name: firebaseUser.displayName ?? '',
    );
  }
  
  
  Future<bool> signIn(String email, String password) async {
    try {
      await _auth.signInWithEmailAndPassword(
        email: email,
        password: password,
      );
      return true;
    } catch (e) {
      return false;
    }
  }
  
  
  Future<void> signOut() => _auth.signOut();
}

// 구현체 2: 자체 백엔드 인증
class ApiAuthService implements AuthService {
  final ApiClient _apiClient;
  
  ApiAuthService(this._apiClient);
  
  
  Future<User?> getCurrentUser() async {
    try {
      final token = await _getStoredToken();
      if (token == null) return null;
      
      final response = await _apiClient.get(
        '/user/me',
        headers: {'Authorization': 'Bearer $token'},
      );
      return User.fromJson(response);
    } catch (e) {
      return null;
    }
  }
  
  // 나머지 구현...
}

// 사용하는 클래스는 구체적인 구현이 아닌 인터페이스에 의존
class AuthBloc {
  final AuthService _authService;
  
  AuthBloc(this._authService);
  
  Future<void> checkAuthStatus() async {
    final user = await _authService.getCurrentUser();
    // 비즈니스 로직...
  }
  
  Future<bool> login(String email, String password) async {
    return _authService.signIn(email, password);
  }
}

이 방식의 장점:

  • 구현 교체 용이성: 인터페이스만 유지하면서 구현체를 쉽게 변경할 수 있습니다
  • 테스트 용이성: 테스트를 위한 가짜 구현체를 쉽게 만들 수 있습니다
  • 명확한 계약: 인터페이스가 필요한 기능을 명확히 정의합니다

3. 매개체 패턴 (Mediator Pattern)

직접적인 참조 대신 중앙 조정자를 통해 컴포넌트 간 통신을 구현합니다.

// 이벤트 버스 구현
class EventBus {
  static final EventBus _instance = EventBus._internal();
  factory EventBus() => _instance;
  EventBus._internal();
  
  final _controller = StreamController<Event>.broadcast();
  
  Stream<T> on<T extends Event>() {
    return _controller.stream.where((event) => event is T).cast<T>();
  }
  
  void fire(Event event) {
    _controller.add(event);
  }
  
  void dispose() {
    _controller.close();
  }
}

// 이벤트 정의
abstract class Event {}

class UserLoggedInEvent extends Event {
  final User user;
  UserLoggedInEvent(this.user);
}

class CartUpdatedEvent extends Event {
  final List<CartItem> items;
  CartUpdatedEvent(this.items);
}

// 컴포넌트 A: 이벤트 발신자
class AuthService {
  Future<void> login(String username, String password) async {
    // 로그인 로직...
    final user = User(id: '123', name: username);
    EventBus().fire(UserLoggedInEvent(user));
  }
}

// 컴포넌트 B: 이벤트 수신자 (완전히 분리됨)
class UserProfileWidget extends StatefulWidget {
  
  _UserProfileWidgetState createState() => _UserProfileWidgetState();
}

class _UserProfileWidgetState extends State<UserProfileWidget> {
  User? user;
  late StreamSubscription _subscription;
  
  
  void initState() {
    super.initState();
    _subscription = EventBus().on<UserLoggedInEvent>().listen((event) {
      setState(() {
        user = event.user;
      });
    });
  }
  
  
  void dispose() {
    _subscription.cancel();
    super.dispose();
  }
  
  
  Widget build(BuildContext context) {
    return user == null
        ? Text('로그인이 필요합니다')
        : Text('안녕하세요, ${user!.name}님');
  }
}

// 컴포넌트 C: 다른 이벤트 수신자
class CartIndicator extends StatefulWidget {
  
  _CartIndicatorState createState() => _CartIndicatorState();
}

class _CartIndicatorState extends State<CartIndicator> {
  int itemCount = 0;
  late StreamSubscription _subscription;
  
  
  void initState() {
    super.initState();
    _subscription = EventBus().on<CartUpdatedEvent>().listen((event) {
      setState(() {
        itemCount = event.items.length;
      });
    });
  }
  
  
  void dispose() {
    _subscription.cancel();
    super.dispose();
  }
  
  
  Widget build(BuildContext context) {
    return Badge(
      count: itemCount,
      child: Icon(Icons.shopping_cart),
    );
  }
}

매개체 패턴의 장점:

  • 결합도 감소: 컴포넌트 간 직접적인 의존성이 제거됩니다
  • 유연성: 새로운 컴포넌트를 쉽게 추가하고 기존 코드 변경 없이 이벤트를 구독할 수 있습니다
  • 관심사 분리: 컴포넌트는 자신의 기능에만 집중할 수 있습니다

4. 서비스 로케이터 패턴

서비스 로케이터는 필요한 서비스를 중앙에서 관리하고 제공하는 패턴입니다. Flutter에서는 GetIt과 같은 라이브러리를 사용하여 구현할 수 있습니다.

// GetIt 설정
final getIt = GetIt.instance;

void setupDependencies() {
  // 싱글톤으로 등록
  getIt.registerSingleton<ApiClient>(ApiClient());
  getIt.registerSingleton<StorageService>(
    SharedPrefsStorage(await SharedPreferences.getInstance()),
  );
  
  // 팩토리로 등록 (매번 새 인스턴스)
  getIt.registerFactory<AuthService>(
    () => FirebaseAuthService(),
  );
  
  // 지연 싱글톤 등록 (처음 사용할 때 초기화)
  getIt.registerLazySingleton<UserRepository>(
    () => UserRepository(
      apiClient: getIt<ApiClient>(),
      storage: getIt<StorageService>(),
    ),
  );
}

// 사용 방법
class UserProfileScreen extends StatelessWidget {
  
  Widget build(BuildContext context) {
    final userRepository = getIt<UserRepository>();
    
    return FutureBuilder<User>(
      future: userRepository.getUser('123'),
      builder: (context, snapshot) {
        // UI 구현...
      },
    );
  }
}

서비스 로케이터의 장점:

  • 중앙 집중식 의존성 관리: 모든 의존성을 한 곳에서 설정하고 관리할 수 있습니다
  • 코드 간소화: 복잡한 의존성 체인을 단순화합니다
  • 유연성: 런타임에 서비스 구현체를 교체할 수 있습니다

그러나 서비스 로케이터는 '서비스를 찾는' 의존성을 만들기 때문에, 순수한 의존성 주입보다 결합도가 약간 높다는 점을 인식해야 합니다.

5. 클린 아키텍처 적용

더 큰 규모의 앱에서는 클린 아키텍처를 적용하여 시스템적으로 결합도를 관리할 수 있습니다. 앞서 첫 번째 글에서 소개한 레이어 분리를 통해 결합도를 체계적으로 관리합니다.

lib/
├── domain/            # 비즈니스 규칙 및 엔티티 (가장 안쪽 레이어)
│   ├── entities/      # 비즈니스 엔티티
│   ├── repositories/  # 저장소 인터페이스
│   └── usecases/      # 비즈니스 유스케이스
│
├── data/              # 데이터 관련 구현 (중간 레이어)
│   ├── datasources/   # 데이터 소스 구현
│   ├── models/        # DTO 모델
│   └── repositories/  # 저장소 구현
│
└── presentation/      # UI 및 상태 관리 (가장 바깥쪽 레이어)
    ├── pages/         # 화면 위젯
    ├── widgets/       # 재사용 가능한 위젯
    └── blocs/         # 상태 관리

각 레이어는 안쪽 레이어에만 의존해야 하며, 바깥쪽 레이어는 안쪽 레이어로 직접 의존성을 갖지 않아야 합니다. 이는 의존성 규칙을 통해 결합도를 체계적으로 관리합니다.

예를 들어:

  • domain 레이어는 다른 어떤 레이어에도 의존하지 않습니다.
  • data 레이어는 domain 레이어에만 의존합니다.
  • presentation 레이어는 domain 레이어에만 의존합니다 (직접적으로 data 레이어에 의존하지 않음).

이러한 구조는 핵심 비즈니스 로직을 UI나 데이터 소스 변경으로부터 보호합니다.

결합도 관리를 위한 실용적인 팁 💡

1. 모델 변환 레이어 활용

외부 API나 데이터베이스 모델을 앱 내부 모델로 변환하는 레이어를 만들어 외부 의존성으로부터 앱 로직을 보호합니다.

// 외부 API 응답 모델
class UserApiResponse {
  final String id;
  final String fullName;
  final String emailAddress;
  final String avatarUrl;
  final Map<String, dynamic> metadata;
  
  UserApiResponse({
    required this.id,
    required this.fullName,
    required this.emailAddress,
    required this.avatarUrl,
    required this.metadata,
  });
  
  factory UserApiResponse.fromJson(Map<String, dynamic> json) {
    return UserApiResponse(
      id: json['user_id'],
      fullName: json['name'],
      emailAddress: json['email'],
      avatarUrl: json['profile_picture'] ?? '',
      metadata: json['additional_data'] ?? {},
    );
  }
}

// 앱 내부 도메인 모델
class User {
  final String id;
  final String name;
  final String email;
  final String? avatar;
  
  User({
    required this.id,
    required this.name,
    required this.email,
    this.avatar,
  });
}

// 변환 로직
extension UserMapper on UserApiResponse {
  User toDomain() {
    return User(
      id: id,
      name: fullName,
      email: emailAddress,
      avatar: avatarUrl.isNotEmpty ? avatarUrl : null,
    );
  }
}

// 사용 예시
class UserRepository {
  final ApiClient _apiClient;
  
  UserRepository(this._apiClient);
  
  Future<User> getUser(String id) async {
    final response = await _apiClient.get('/users/$id');
    final apiUser = UserApiResponse.fromJson(response);
    return apiUser.toDomain(); // 외부 모델을 내부 모델로 변환
  }
}

이 접근 방식의 장점:

  • API 응답 형식이 변경되어도 앱 내부 로직은 보호됩니다
  • 도메인 모델은 비즈니스 로직에 필요한 정보만 포함합니다
  • 데이터 소스를 변경해도 앱의 나머지 부분은 영향을 받지 않습니다

2. 자주 변경되는 코드 격리

자주 변경될 가능성이 높은 코드(예: UI 레이아웃, API 호출, 외부 라이브러리 사용)를 격리하여 변경의 영향 범위를 제한합니다.

// 외부 라이브러리를 위한 어댑터 패턴 사용
abstract class AnalyticsService {
  void logEvent(String name, {Map<String, dynamic>? parameters});
  void setUserProperty(String name, String value);
  void logScreenView(String screenName);
}

// Firebase 분석 구현
class FirebaseAnalyticsService implements AnalyticsService {
  final FirebaseAnalytics _analytics = FirebaseAnalytics.instance;
  
  
  void logEvent(String name, {Map<String, dynamic>? parameters}) {
    _analytics.logEvent(name: name, parameters: parameters);
  }
  
  
  void setUserProperty(String name, String value) {
    _analytics.setUserProperty(name: name, value: value);
  }
  
  
  void logScreenView(String screenName) {
    _analytics.logScreenView(screenName: screenName);
  }
}

// 가짜 분석 구현 (개발/테스트용)
class FakeAnalyticsService implements AnalyticsService {
  
  void logEvent(String name, {Map<String, dynamic>? parameters}) {
    debugPrint('📊 Event: $name, Parameters: $parameters');
  }
  
  
  void setUserProperty(String name, String value) {
    debugPrint('📊 User Property: $name = $value');
  }
  
  
  void logScreenView(String screenName) {
    debugPrint('📊 Screen View: $screenName');
  }
}

어댑터 패턴을 사용하면 분석 제공업체를 변경하거나, 다른 분석 옵션을 추가하거나, 테스트 중에 분석을 비활성화하기가 훨씬 쉬워집니다.

3. 위젯 캡슐화로 UI 결합도 낮추기

Flutter UI 결합도를 낮추기 위해 위젯을 적절히 캡슐화하고 명확한 인터페이스를 정의합니다.

// ❌ 높은 결합도: 너무 많은 책임과 세부 구현 노출
class ProductCard extends StatelessWidget {
  final Product product;
  final CartBloc cartBloc;
  final FavoritesBloc favoritesBloc;
  
  
  Widget build(BuildContext context) {
    return Card(
      child: Column(
        children: [
          Image.network(product.imageUrl),
          Text(product.name),
          Text('\$${product.price}'),
          Row(
            children: [
              IconButton(
                icon: Icon(Icons.favorite),
                onPressed: () {
                  // 블록 직접 사용
                  favoritesBloc.add(ToggleFavoriteEvent(product.id));
                },
              ),
              ElevatedButton(
                onPressed: () {
                  // 장바구니 추가 로직 직접 구현
                  cartBloc.add(AddToCartEvent(product));
                  ScaffoldMessenger.of(context).showSnackBar(
                    SnackBar(content: Text('${product.name} 추가됨')),
                  );
                },
                child: Text('장바구니에 추가'),
              ),
            ],
          ),
        ],
      ),
    );
  }
}

// ✅ 낮은 결합도: 콜백을 통한 상호작용, 상세 구현 은닉
class ProductCard extends StatelessWidget {
  final String name;
  final String imageUrl;
  final double price;
  final bool isFavorite;
  final VoidCallback onFavoriteToggle;
  final VoidCallback onAddToCart;
  
  const ProductCard({
    Key? key,
    required this.name,
    required this.imageUrl,
    required this.price,
    required this.isFavorite,
    required this.onFavoriteToggle,
    required this.onAddToCart,
  }) : super(key: key);
  
  
  Widget build(BuildContext context) {
    return Card(
      child: Column(
        children: [
          Image.network(imageUrl),
          Text(name),
          Text('\$${price.toStringAsFixed(2)}'),
          Row(
            children: [
              IconButton(
                icon: Icon(
                  isFavorite ? Icons.favorite : Icons.favorite_border,
                  color: isFavorite ? Colors.red : null,
                ),
                onPressed: onFavoriteToggle,
              ),
              ElevatedButton(
                onPressed: onAddToCart,
                child: Text('장바구니에 추가'),
              ),
            ],
          ),
        ],
      ),
    );
  }
}

// 사용 예시
BlocBuilder<FavoritesBloc, FavoritesState>(
  builder: (context, favoritesState) {
    return BlocBuilder<ProductsBloc, ProductsState>(
      builder: (context, productsState) {
        return ListView.builder(
          itemCount: productsState.products.length,
          itemBuilder: (context, index) {
            final product = productsState.products[index];
            final isFavorite = favoritesState.favoriteIds.contains(product.id);
            
            return ProductCard(
              name: product.name,
              imageUrl: product.imageUrl,
              price: product.price,
              isFavorite: isFavorite,
              onFavoriteToggle: () {
                context.read<FavoritesBloc>().add(
                  ToggleFavoriteEvent(product.id),
                );
              },
              onAddToCart: () {
                context.read<CartBloc>().add(
                  AddToCartEvent(product),
                );
                ScaffoldMessenger.of(context).showSnackBar(
                  SnackBar(content: Text('${product.name} 추가됨')),
                );
              },
            );
          },
        );
      },
    );
  },
)

이 접근 방식의 장점:

  • ProductCard 위젯은 특정 상태 관리 솔루션에 의존하지 않습니다
  • 테스트가 훨씬 쉬워집니다(단순히 콜백이 호출되는지 확인)
  • 위젯을 다른 화면이나 프로젝트에서 쉽게 재사용할 수 있습니다
  • UI 변경이 비즈니스 로직에 영향을 미치지 않습니다

4. 상태 관리와 라우팅 추상화

상태 관리 라이브러리나 라우팅 시스템에 대한 직접적인 의존성을 추상화 레이어 뒤에 숨깁니다.

// 라우팅 추상화
abstract class AppNavigator {
  void navigateToProductDetails(String productId);
  void navigateToCart();
  void navigateToCheckout();
  void pop();
  Future<T?> showDialog<T>(Widget dialog);
}

// Navigator 2.0 구현
class FlutterAppNavigator implements AppNavigator {
  final GlobalKey<NavigatorState> navigatorKey;
  
  FlutterAppNavigator(this.navigatorKey);
  
  NavigatorState get _navigator => navigatorKey.currentState!;
  
  
  void navigateToProductDetails(String productId) {
    _navigator.pushNamed('/product/$productId');
  }
  
  
  void navigateToCart() {
    _navigator.pushNamed('/cart');
  }
  
  
  void navigateToCheckout() {
    _navigator.pushNamed('/checkout');
  }
  
  
  void pop() {
    _navigator.pop();
  }
  
  
  Future<T?> showDialog<T>(Widget dialog) {
    return showDialog<T>(
      context: _navigator.context,
      builder: (context) => dialog,
    );
  }
}

// 비즈니스 로직에서 사용
class ProductDetailController {
  final AppNavigator navigator;
  final CartRepository cartRepository;
  
  ProductDetailController({
    required this.navigator,
    required this.cartRepository,
  });
  
  void addToCartAndNavigate(Product product) async {
    await cartRepository.addToCart(product);
    navigator.navigateToCart();
  }
}

이 접근 방식의 장점:

  • 라우팅 구현(Navigator 1.0, 2.0, GoRouter, Auto Route 등)을 쉽게 교체할 수 있습니다
  • 비즈니스 로직을 테스트할 때 라우팅을 쉽게 모의할 수 있습니다
  • 라우팅 로직을 중앙 집중화하여 일관성을 보장합니다

결합도 측정 및 개선 📏

코드베이스의 결합도를 체계적으로 개선하려면 현재 상태를 측정하고 점진적으로 개선해야 합니다.

결합도 식별 방법

  1. 의존성 그래프 분석: 주요 클래스와 모듈 간의 의존성 그래프를 그려 "핫스팟"(과도한 의존성이 있는 곳)을 식별합니다.

  2. 변경 영향 평가: 특정 클래스나 모듈을 변경할 때 영향을 받는 다른 부분을 추적합니다. 영향이 광범위하다면 결합도가 높다는 신호입니다.

  3. 코드 리뷰 체크리스트: 다음과 같은 질문을 통해 결합도 문제를 식별합니다.

    • 이 클래스가 너무 많은 다른 클래스에 의존하는가?
    • 글로벌 상태나 싱글톤에 과도하게 의존하는가?
    • 구현 세부 사항이 인터페이스를 통해 적절히 추상화되어 있는가?
    • 위젯이 너무 많은 책임을 가지고 있는가?

점진적 개선 전략

대부분의 프로젝트에서는 결합도 문제를 한 번에 모두 해결하기 어렵습니다. 다음과 같은 점진적 접근법을 고려하세요:

  1. 새 코드에 대한 높은 기준 적용: 신규 코드는 낮은 결합도 패턴을 철저히 따르도록 합니다.

  2. 리팩토링 우선순위 지정: 가장 자주 변경되거나 문제가 많은 영역부터 리팩토링을 시작합니다.

  3. 인터페이스 도입: 높은 결합도를 가진 클래스의 인터페이스를 추출하여 점진적으로 의존성을 인터페이스로 전환합니다.

  4. 어댑터 패턴 활용: 기존 코드를 바로 변경하기 어렵다면, 어댑터 패턴을 사용하여 새 인터페이스에 맞게 조정합니다.

  5. 지속적인 교육: 팀 전체가 낮은 결합도의 중요성을 이해하고 관련 패턴을 적용할 수 있도록 지속적으로 교육합니다.

실제 프로젝트에서의 균형 찾기 ⚖️

과도한 추상화와 지나친 단순함 사이의 균형을 찾는 것이 중요합니다. 다음 지침이 도움이 될 수 있습니다:

언제 추상화해야 하는가

  1. 변경 가능성이 높은 경우: 자주 변경될 가능성이 있는 코드는 추상화를 통해 변경의 영향 범위를 제한할 수 있습니다.
// API 클라이언트 추상화 예시
abstract class UserApi {
  Future<Map<String, dynamic>> getUser(String id);
  Future<List<Map<String, dynamic>>> getUserFriends(String id);
}

// 현재 API 버전 구현
class UserApiV1 implements UserApi {
  final Dio dio;
  
  UserApiV1(this.dio);
  
  
  Future<Map<String, dynamic>> getUser(String id) async {
    final response = await dio.get('/v1/users/$id');
    return response.data;
  }
  
  
  Future<List<Map<String, dynamic>>> getUserFriends(String id) async {
    final response = await dio.get('/v1/users/$id/friends');
    return List<Map<String, dynamic>>.from(response.data);
  }
}
  1. 테스트가 중요한 경우: 단위 테스트에서 모의 객체로 교체해야 하는 의존성은 추상화가 필수적입니다.
// 테스트 가능한 저장소 추상화
abstract class AuthRepository {
  Future<User?> getCurrentUser();
  Future<bool> signIn(String email, String password);
}

// 비즈니스 로직 클래스는 구체적인 구현체가 아닌 인터페이스에 의존
class AuthViewModel {
  final AuthRepository _repository;
  
  AuthViewModel(this._repository);
  
  Future<bool> login(String email, String password) async {
    // 실제 구현이나 모의 객체 모두 사용 가능
    return _repository.signIn(email, password);
  }
}

// 테스트 코드
test('로그인 성공 시 true를 반환해야 함', () async {
  // given
  final mockRepository = MockAuthRepository();
  when(mockRepository.signIn(any, any))
    .thenAnswer((_) async => true);
    
  final viewModel = AuthViewModel(mockRepository);
  
  // when
  final result = await viewModel.login('test@example.com', 'password');
  
  // then
  expect(result, isTrue);
});

추상화를 피해야 하는 경우

추상화는 코드 복잡성을 증가시키므로 모든 상황에서 필요하지는 않습니다. 다음과 같은 경우에는 추상화를 피하는 것이 좋습니다:

  1. 간단한 CRUD 작업: 단순 데이터 처리 로직에는 과도한 추상화가 불필요할 수 있습니다.
// ❌ 과도한 추상화: 단순 데이터 처리에 불필요한 복잡성 추가
abstract class UserPreferences {
  Future<void> saveUsername(String username);
  Future<String?> getUsername();
  Future<void> clearUsername();
}

class SharedPrefsUserPreferences implements UserPreferences {
  final SharedPreferences _prefs;
  
  SharedPrefsUserPreferences(this._prefs);
  
  
  Future<void> saveUsername(String username) async {
    await _prefs.setString('username', username);
  }
  
  
  Future<String?> getUsername() async {
    return _prefs.getString('username');
  }
  
  
  Future<void> clearUsername() async {
    await _prefs.remove('username');
  }
}

// ✅ 단순한 접근: 간단한 작업에 더 적합
class UserPrefs {
  final SharedPreferences _prefs;
  
  UserPrefs(this._prefs);
  
  Future<void> saveUsername(String username) async {
    await _prefs.setString('username', username);
  }
  
  Future<String?> getUsername() {
    return Future.value(_prefs.getString('username'));
  }
  
  Future<void> clearUsername() async {
    await _prefs.remove('username');
  }
}
  1. 안정적인 API: 자주 변경되지 않는 내부 API는 직접 사용해도 무방할 수 있습니다.
// ❌ 불필요한 추상화: Flutter의 안정적인 API에 대한 과도한 래핑
abstract class DialogService {
  Future<bool?> showConfirmationDialog(String title, String message);
}

class FlutterDialogService implements DialogService {
  final BuildContext context;
  
  FlutterDialogService(this.context);
  
  
  Future<bool?> showConfirmationDialog(String title, String message) {
    return showDialog<bool>(
      context: context,
      builder: (context) => AlertDialog(
        title: Text(title),
        content: Text(message),
        actions: [
          TextButton(
            onPressed: () => Navigator.of(context).pop(false),
            child: const Text('취소'),
          ),
          TextButton(
            onPressed: () => Navigator.of(context).pop(true),
            child: const Text('확인'),
          ),
        ],
      ),
    );
  }
}

// ✅ 더 단순한 접근: 안정적인 Flutter API는 직접 사용
Future<void> confirmAndDeleteAccount(BuildContext context) async {
  final confirmed = await showDialog<bool>(
    context: context,
    builder: (context) => AlertDialog(
      title: const Text('계정 삭제'),
      content: const Text('정말 계정을 삭제하시겠습니까?'),
      actions: [
        TextButton(
          onPressed: () => Navigator.of(context).pop(false),
          child: const Text('취소'),
        ),
        TextButton(
          onPressed: () => Navigator.of(context).pop(true),
          child: const Text('삭제'),
        ),
      ],
    ),
  );
  
  if (confirmed == true) {
    // 계정 삭제 로직...
  }
}
  1. 소규모 프로젝트: 작은 프로젝트에서는 과도한 추상화가 복잡성만 증가시킬 수 있습니다.
// ❌ 소규모 프로젝트에서 과도한 아키텍처
// 복잡한 클린 아키텍처 구현
class GetUserUseCase implements UseCase<User, GetUserParams> {
  final UserRepository repository;
  
  GetUserUseCase(this.repository);
  
  
  Future<Either<Failure, User>> call(GetUserParams params) {
    return repository.getUser(params.userId);
  }
}

class GetUserParams extends Equatable {
  final String userId;
  
  GetUserParams(this.userId);
  
  
  List<Object> get props => [userId];
}

// 호출 코드
final result = await getUserUseCase(GetUserParams('123'));
result.fold(
  (failure) => handleError(failure),
  (user) => showUser(user),
);

// ✅ 소규모 프로젝트에 더 적합한 단순한 접근
class UserService {
  final http.Client client;
  
  UserService(this.client);
  
  Future<User> getUser(String id) async {
    try {
      final response = await client.get(Uri.parse('https://api.example.com/users/$id'));
      
      if (response.statusCode == 200) {
        return User.fromJson(jsonDecode(response.body));
      } else {
        throw Exception('사용자를 가져오지 못했습니다');
      }
    } catch (e) {
      rethrow;
    }
  }
}

// 호출 코드
try {
  final user = await userService.getUser('123');
  showUser(user);
} catch (e) {
  handleError(e);
}

실용적인 접근법

  1. 점진적 추상화: 처음부터 모든 것을 추상화하지 말고, 필요에 따라 추상화 레이어를 추가합니다.

  2. YAGNI(You Aren't Gonna Need It) 원칙: 정말 필요할 때까지 추상화를 미룹니다.

  3. 코드 중복 허용: 때로는 약간의 코드 중복이 과도한 추상화보다 나을 수 있습니다.

  4. 팀 역량 고려: 팀의 기술적 역량과 프로젝트 마감일에 맞는 수준의 추상화를 선택합니다.

결론 🎯

낮은 결합도는 장기적으로 유지보수 가능하고 확장 가능한 Flutter 앱의 핵심입니다. 이 글에서 살펴본 다양한 패턴과 기법을 적용하면 다음과 같은 이점을 얻을 수 있습니다:

  • 변경에 강한 코드: 한 부분의 변경이 다른 부분에 미치는 영향이 최소화됩니다
  • 향상된 테스트 용이성: 의존성을 모의할 수 있어 단위 테스트가 쉬워집니다
  • 코드 재사용성: 독립적인 컴포넌트는 다른 프로젝트에서도 쉽게 재사용할 수 있습니다
  • 팀 협업 개선: 개발자들이 서로의 코드에 미치는 영향을 최소화하며 병렬로 작업할 수 있습니다

결합도 관리는 단순한 기술적 선택을 넘어, 장기적으로 개발 생산성과 코드 품질에 영향을 미치는 아키텍처 원칙입니다. 프로젝트의 복잡성과 팀의 역량에 맞게 적절한 수준의 추상화와 결합도 관리 전략을 선택하세요.

다음 글에서는 Flutter 앱의 상태 관리 패턴에 대해 더 자세히 알아보겠습니다.


다음 포스트: Flutter 개발 가이드라인: 효과적인 상태 관리 패턴
Flutter에서 사용할 수 있는 다양한 상태 관리 솔루션의 장단점과 적합한 사용 사례를 자세히 알아봅니다.

profile
안녕하세요! 👋 저는 6년차 Flutter 개발자 우기입니다.

0개의 댓글