Flutter 개발 가이드라인: 핵심 원칙과 프로젝트 구조 🏗️

woogi·2025년 4월 27일
0

Flutter 개발 가이드

목록 보기
2/5
post-thumbnail

안녕하세요, 우기입니다! 오늘부터 Flutter 개발 가이드라인 시리즈의 첫 번째 파트를 시작합니다. 이번 글에서는 모든 Flutter 프로젝트의 근간이 되는 핵심 원칙과 효과적인 프로젝트 구조에 대해 알아보겠습니다.

들어가며 🌱

복잡한 모바일 앱을 개발하다 보면 코드베이스가 빠르게 증가하고, 이를 관리하는 것이 점점 어려워집니다. 특히 팀 단위로 개발할 때는 일관된 코드 스타일과 구조가 없다면 프로젝트는 금세 유지보수하기 어려운 상태가 됩니다.

제가 다양한 규모의 Flutter 프로젝트를 진행하며 깨달은 것은, 명확한 원칙과 잘 설계된 구조가 장기적으로 프로젝트의 성공을 좌우한다는 점입니다. 이 글에서는 제가 실제 프로젝트에서 적용해 효과를 본 원칙들을 공유하려고 합니다.

핵심 원칙 🧭

Flutter 개발에 있어 가장 중요한 네 가지 원칙은 다음과 같습니다:

1. 가독성 (Readability)

가독성이란 코드가 얼마나 쉽게 이해될 수 있는지를 의미합니다. 코드는 컴퓨터가 실행하는 것이지만, 결국 사람이 작성하고 유지보수하는 것입니다.

가독성이 좋은 코드의 특징:

  • 명확한 변수명과 함수명 사용
  • 복잡한 로직을 작은 단위로 분리
  • 주석보다는 자체적으로 설명이 되는 코드 지향
  • 일관된 코드 스타일과 포맷팅

2. 예측 가능성 (Predictability)

예측 가능성은 코드의 동작이 얼마나 예상 가능한지를 의미합니다. 개발자가 코드의 한 부분을 보고 그 동작을 정확히 예측할 수 있어야 합니다.

예측 가능한 코드의 특징:

  • 명확한 입출력 관계
  • 숨겨진 부작용(side effects) 최소화
  • 일관된 에러 처리 패턴
  • 표준화된 상태 관리 방식

3. 응집성 (Cohesion)

응집성은 연관된 코드가 함께 유지되는 정도를 의미합니다. 높은 응집성을 가진 모듈은 단일 책임을 가지며, 관련된 기능들이 적절히 그룹화되어 있습니다.

높은 응집성의 특징:

  • 각 클래스와 함수가 하나의 명확한 책임만 가짐
  • 관련 기능이 논리적으로 그룹화됨
  • 특정 기능 변경 시 제한된 범위만 수정 필요
  • 특정 도메인/기능과 관련된 모든 코드가 인접하게 위치

4. 낮은 결합도 (Low Coupling)

결합도는 코드의 다른 부분 간의 의존성 정도를 의미합니다. 낮은 결합도는 각 부분이 독립적으로 작동할 수 있고, 한 부분의 변경이 다른 부분에 미치는 영향이 최소화됨을 의미합니다.

낮은 결합도의 특징:

  • 모듈 간 인터페이스가 명확하게 정의됨
  • 의존성 주입을 통한 컴포넌트 분리
  • 추상화를 통한 구현 세부사항 은닉
  • 구체적인 구현보다 인터페이스에 의존

효과적인 프로젝트 구조 📂

Flutter 프로젝트에서 효과적인 코드 구조를 설계하기 위해 기능 중심(feature-first) 접근 방식을, 클린 아키텍처 원칙과 결합하는 것을 추천합니다.

기능 중심 폴더 구조

다음은 중규모 이상의 Flutter 프로젝트에 적합한 기본 폴더 구조입니다:

lib/
├── core/                 # 핵심 유틸리티 및 공통 기능
│   ├── config/           # 앱 구성, 상수, 환경 설정
│   ├── di/               # 의존성 주입 설정
│   ├── network/          # 네트워크 관련 유틸리티
│   ├── storage/          # 로컬 스토리지 관련 기능
│   ├── theme/            # 앱 테마 정의
│   └── utils/            # 범용 유틸리티 및 확장 함수
│
├── features/             # 앱의 주요 기능 모듈
│   ├── auth/             # 인증 관련 기능
│   │   ├── data/         # 데이터 레이어
│   │   │   ├── datasources/  # 데이터 소스 구현
│   │   │   ├── models/       # DTO/모델 클래스
│   │   │   └── repositories/ # 저장소 구현
│   │   ├── domain/       # 도메인 레이어
│   │   │   ├── entities/ # 비즈니스 엔티티
│   │   │   ├── repositories/ # 저장소 인터페이스
│   │   │   └── usecases/ # 유스케이스 정의
│   │   └── presentation/ # UI 레이어
│   │       ├── pages/    # 화면 위젯
│   │       ├── providers/# 상태 관리 (Riverpod, BLoC 등)
│   │       └── widgets/  # 화면별 커스텀 위젯
│   │
│   └── feature_name/     # 다른 기능도 동일한 구조 적용
│
├── shared/               # 여러 feature 간 공유 컴포넌트
│   ├── widgets/          # 공통 위젯
│   └── models/           # 공통 모델
│
└── main.dart             # 앱 진입점

이 구조의 핵심 원칙은 다음과 같습니다:

  1. 코드를 타입별이 아닌 기능별로 그룹화: 모든 위젯을 'widgets/' 폴더에 넣는 대신, 각 기능별로 관련 위젯, 모델, 상태 관리 코드를 함께 배치합니다.

  2. 레이어 분리: 각 기능 내에서 data, domain, presentation 레이어를 분리하여 관심사를 명확하게 구분합니다.

  3. 공통 코드 중앙화: 여러 기능에서 공유되는 코드는 'core/' 또는 'shared/' 폴더에 배치합니다.

클린 아키텍처 적용

위 구조는 클린 아키텍처의 원칙을 Flutter에 맞게 조정한 것입니다. 각 레이어의 역할은 다음과 같습니다:

1. 도메인 레이어 (Domain Layer)

  • 비즈니스 로직의 핵심
  • 플랫폼 및 프레임워크에 독립적
  • 엔티티, 유스케이스, 저장소 인터페이스 포함
// domain/entities/user.dart
class User {
  final String id;
  final String name;
  final String email;
  
  User({required this.id, required this.name, required this.email});
}

// domain/repositories/auth_repository.dart
abstract class AuthRepository {
  Future<User?> getCurrentUser();
  Future<User> signIn(String email, String password);
  Future<void> signOut();
}

// domain/usecases/signin_usecase.dart
class SignInUseCase {
  final AuthRepository repository;
  
  SignInUseCase(this.repository);
  
  Future<User> execute(String email, String password) {
    return repository.signIn(email, password);
  }
}

2. 데이터 레이어 (Data Layer)

  • 도메인 레이어에 정의된 저장소 인터페이스 구현
  • API 호출, 로컬 데이터베이스 액세스 등 데이터 조작 담당
  • 데이터 모델과 엔티티 간의 변환 처리
// data/models/user_model.dart
class UserModel {
  final String id;
  final String name;
  final String email;
  
  UserModel({required this.id, required this.name, required this.email});
  
  factory UserModel.fromJson(Map<String, dynamic> json) {
    return UserModel(
      id: json['id'],
      name: json['name'],
      email: json['email'],
    );
  }
  
  User toEntity() => User(id: id, name: name, email: email);
}

// data/datasources/auth_remote_datasource.dart
class AuthRemoteDataSource {
  final Dio dio;
  
  AuthRemoteDataSource(this.dio);
  
  Future<UserModel> signIn(String email, String password) async {
    final response = await dio.post('/auth/signin', data: {
      'email': email,
      'password': password
    });
    
    return UserModel.fromJson(response.data);
  }
}

// data/repositories/auth_repository_impl.dart
class AuthRepositoryImpl implements AuthRepository {
  final AuthRemoteDataSource remoteDataSource;
  final AuthLocalDataSource localDataSource;
  
  AuthRepositoryImpl({
    required this.remoteDataSource,
    required this.localDataSource,
  });
  
  
  Future<User> signIn(String email, String password) async {
    final userModel = await remoteDataSource.signIn(email, password);
    await localDataSource.cacheUser(userModel);
    return userModel.toEntity();
  }
  
  // Other implementations...
}

3. 프레젠테이션 레이어 (Presentation Layer)

  • UI 컴포넌트와 상태 관리 담당
  • 도메인 레이어의 유스케이스 호출
  • 사용자 입력 처리 및 화면 표시
// presentation/providers/auth_state.dart (예시: Riverpod 사용)

class AuthState with _$AuthState {
  const factory AuthState.initial() = _Initial;
  const factory AuthState.loading() = _Loading;
  const factory AuthState.authenticated(User user) = _Authenticated;
  const factory AuthState.unauthenticated() = _Unauthenticated;
  const factory AuthState.error(String message) = _Error;
}

// presentation/providers/auth_notifier.dart
class AuthNotifier extends StateNotifier<AuthState> {
  final SignInUseCase _signInUseCase;
  final SignOutUseCase _signOutUseCase;
  final GetCurrentUserUseCase _getCurrentUserUseCase;
  
  AuthNotifier({
    required SignInUseCase signInUseCase,
    required SignOutUseCase signOutUseCase,
    required GetCurrentUserUseCase getCurrentUserUseCase,
  }) : _signInUseCase = signInUseCase,
       _signOutUseCase = signOutUseCase,
       _getCurrentUserUseCase = getCurrentUserUseCase,
       super(const AuthState.initial());
  
  Future<void> checkAuthStatus() async {
    state = const AuthState.loading();
    final user = await _getCurrentUserUseCase.execute();
    state = user != null 
        ? AuthState.authenticated(user) 
        : const AuthState.unauthenticated();
  }
  
  Future<void> signIn(String email, String password) async {
    state = const AuthState.loading();
    try {
      final user = await _signInUseCase.execute(email, password);
      state = AuthState.authenticated(user);
    } catch (e) {
      state = AuthState.error(e.toString());
    }
  }
  
  Future<void> signOut() async {
    state = const AuthState.loading();
    await _signOutUseCase.execute();
    state = const AuthState.unauthenticated();
  }
}

// presentation/pages/login_page.dart
class LoginPage extends ConsumerWidget {
  
  Widget build(BuildContext context, WidgetRef ref) {
    final authState = ref.watch(authProvider);
    
    return Scaffold(
      body: authState.maybeWhen(
        loading: () => const CircularProgressIndicator(),
        error: (error) => Text('Error: $error'),
        orElse: () => LoginForm(),
      ),
    );
  }
}

의존성 주입 (Dependency Injection)

클린 아키텍처를 효과적으로 구현하기 위해서는 의존성 주입이 필수적입니다. Flutter에서는 다음과 같은 방법으로 구현할 수 있습니다:

  1. GetIt - 서비스 로케이터 패턴 기반 의존성 주입
  2. Provider/Riverpod - 상태 관리 솔루션을 통한 의존성 주입
  3. injectable - 코드 생성을 활용한 의존성 주입

예시 (GetIt 사용):

// lib/core/di/service_locator.dart
final GetIt getIt = GetIt.instance;

Future<void> setupDependencies() async {
  // 외부 의존성
  final sharedPreferences = await SharedPreferences.getInstance();
  getIt.registerSingleton<SharedPreferences>(sharedPreferences);
  
  getIt.registerSingleton<Dio>(() {
    final dio = Dio(BaseOptions(baseUrl: 'https://api.example.com'));
    // 인터셉터 등 설정...
    return dio;
  }());
  
  // 데이터 소스
  getIt.registerLazySingleton<AuthRemoteDataSource>(
    () => AuthRemoteDataSource(getIt<Dio>())
  );
  
  getIt.registerLazySingleton<AuthLocalDataSource>(
    () => AuthLocalDataSource(getIt<SharedPreferences>())
  );
  
  // 저장소
  getIt.registerLazySingleton<AuthRepository>(
    () => AuthRepositoryImpl(
      remoteDataSource: getIt<AuthRemoteDataSource>(),
      localDataSource: getIt<AuthLocalDataSource>(),
    )
  );
  
  // 유스케이스
  getIt.registerLazySingleton<SignInUseCase>(
    () => SignInUseCase(getIt<AuthRepository>())
  );
  
  getIt.registerLazySingleton<SignOutUseCase>(
    () => SignOutUseCase(getIt<AuthRepository>())
  );
  
  getIt.registerLazySingleton<GetCurrentUserUseCase>(
    () => GetCurrentUserUseCase(getIt<AuthRepository>())
  );
  
  // 상태 관리
  getIt.registerFactory<AuthBloc>(
    () => AuthBloc(
      signInUseCase: getIt<SignInUseCase>(),
      signOutUseCase: getIt<SignOutUseCase>(),
      getCurrentUserUseCase: getIt<GetCurrentUserUseCase>(),
    )
  );
}

위의 코드에서:

  • registerSingleton: 앱 전체에서 단일 인스턴스를 공유 (즉시 생성)
  • registerLazySingleton: 첫 호출 시에만 인스턴스 생성 (지연 로딩)
  • registerFactory: 매번 새로운 인스턴스 생성

앱의 시작 부분(main.dart)에서 의존성 설정을 초기화합니다:

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await setupDependencies();
  runApp(MyApp());
}

그리고 필요한 곳에서 의존성을 가져와 사용합니다:

class LoginPage extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (_) => getIt<AuthBloc>(),
      child: LoginView(),
    );
  }
}

실전 팁과 권장사항 💡

1. 일관된 파일 명명 규칙 사용하기

// 위젯 파일
auth_page.dart        // 페이지 위젯
login_form.dart       // 컴포넌트 위젯

// 기능 파일
auth_repository.dart  // 인터페이스
auth_repository_impl.dart  // 구현체

// 상태 관리 파일
auth_state.dart       // 상태 정의
auth_notifier.dart    // 상태 컨트롤러

2. import 관리 - 절대 경로 vs 상대 경로

Flutter 프로젝트에서 import 경로 관리는 코드 가독성과 유지보수성에 영향을 미치는 중요한 요소입니다. 절대 경로와 상대 경로는 각각 장단점이 있어 팀 내에서 합의하여 일관되게 사용하는 것이 중요합니다.

절대 경로 사용 (추천):

1. Dart/Flutter SDK imports
import 'dart:async';
import 'package:flutter/material.dart';

// 2. 외부 패키지 imports
import 'package:dio/dio.dart';
import 'package:get_it/get_it.dart';

// 3. 내부 imports (절대 경로 사용)
import 'package:my_app/features/auth/domain/entities/user.dart';
import 'package:my_app/features/auth/domain/repositories/auth_repository.dart';
import 'package:my_app/core/utils/date_formatter.dart';

장점:

  • 파일 이동 시에도 import 문이 유효함
  • 파일의 정확한 위치를 한눈에 파악 가능
  • 디렉토리 구조가 깊어져도 가독성 유지
  • IDE의 자동 import와 더 잘 작동

물론 상대 경로를 선호하는 팀도 있습니다:

상대 경로 사용

import '../domain/entities/user.dart';
import '../domain/repositories/auth_repository.dart';
import '../../../core/utils/date_formatter.dart';

장점:

  • 같은 모듈 내 파일임을 시각적으로 표현
  • 경로가 더 짧아 코드 작성 시 편리함
  • 모듈 이름 변경 시 코드 수정이 덜 필요함

어떤 방식을 선택하든, 팀 내에서 일관된 접근 방식을 유지하는 것이 가장 중요합니다.

3. 확장 가능성 고려하기

프로젝트가 성장함에 따라 구조도 확장될 수 있어야 합니다. 예를 들어:

  • 모노레포 고려: 대형 프로젝트의 경우 melos와 같은 도구를 사용하여 모듈식 구조 채택
  • 스케일링 전략: 기능이 너무 커지면 하위 기능으로 분리 (예: features/auth/ → features/auth/login/, features/auth/signup/ 등)

4. 테스트 적합성 고려하기

좋은 프로젝트 구조는 테스트하기 쉬워야 합니다:

test/
├── features/
│   └── auth/
│       ├── data/
│       │   ├── datasources/
│       │   └── repositories/
│       ├── domain/
│       │   └── usecases/
│       └── presentation/
│           ├── pages/
│           └── providers/
└── shared/
    └── widgets/

각 레이어와 컴포넌트에 대한 테스트를 쉽게 작성할 수 있도록 프로젝트 구조를 설계하세요.

결론 🎯

Flutter 프로젝트에서 핵심 원칙을 따르고 잘 구조화된 프로젝트 설계를 채택하면 다음과 같은 장점이 있습니다:

  • 유지보수성 향상: 코드 변경의 영향 범위가 제한적이어서 유지보수가 용이해집니다.
  • 협업 효율성 증대: 팀원들이 코드베이스를 더 쉽게 이해하고 기여할 수 있습니다.
  • 테스트 용이성: 컴포넌트의 명확한 책임 분리로 단위 테스트가 더 쉬워집니다.
  • 확장성: 새로운 기능을 추가할 때 기존 코드를 크게 변경할 필요가 없습니다.

좋은 아키텍처는 단순히 폴더 구조를 나누는 것이 아니라, 코드의 흐름과 책임을 명확히 하는 것입니다. 이 가이드라인이 여러분의 Flutter 프로젝트를 더 관리하기 쉽고 확장 가능하게 만드는 데 도움이 되길 바랍니다.

다음 파트에서는 가독성, 예측 가능성, 응집성을 코드 레벨에서 어떻게 구현할 수 있는지 더 자세히 살펴보겠습니다.


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

0개의 댓글