Flutter에서 의존성 주입(Dependency Injection)은 어떻게 구현하나요?
질문
Flutter에서 의존성 주입(Dependency Injection)을 구현하는 방법과 그 이점은 무엇인가요?
답변
의존성 주입(Dependency Injection, DI)은 객체가 필요로 하는 의존성을 외부에서 제공받는 디자인 패턴입니다. Flutter 앱에서 의존성 주입을 적용하면 코드의 재사용성, 테스트 용이성, 유지보수성이 향상됩니다. 이 패턴을 통해 모듈 간의 결합도를 낮추고 관심사를 분리할 수 있습니다.
의존성 주입의 이점
- 테스트 용이성: 의존성을 모의 객체(mock)로 대체하여 단위 테스트를 쉽게 할 수 있습니다.
- 유연성: 동일한 인터페이스를 구현하는 다른 구현체로 쉽게 교체할 수 있습니다.
- 관심사 분리: 객체 생성과 사용을 분리하여 단일 책임 원칙을 지킬 수 있습니다.
- 코드 재사용: 의존성이 낮은 컴포넌트는 다른 프로젝트에서도 쉽게 재사용할 수 있습니다.
- 확장성: 새로운 기능이나 요구사항이 추가될 때 기존 코드 수정 없이 확장이 가능합니다.
Flutter에서 의존성 주입 구현 방법
Flutter에서는 다양한 방법으로 의존성 주입을 구현할 수 있습니다. 각 방법은 복잡성과 기능 면에서 차이가 있습니다.
1. 생성자 주입 (Constructor Injection)
가장 간단한 형태의 의존성 주입 방법으로, 클래스 생성자를 통해 의존성을 전달합니다.
// 서비스 인터페이스
abstract class UserService {
Future<List<User>> getUsers();
}
// 서비스 구현
class ApiUserService implements UserService {
final HttpClient httpClient;
// 생성자를 통한 의존성 주입
ApiUserService(this.httpClient);
@override
Future<List<User>> getUsers() async {
// HTTP 클라이언트를 사용한 구현
return await httpClient.get('/users').then((response) {
// 응답 처리
return response.map((json) => User.fromJson(json)).toList();
});
}
}
// 사용 예시
class UserViewModel {
final UserService userService;
UserViewModel(this.userService);
Future<List<User>> loadUsers() {
return userService.getUsers();
}
}
// 실제 사용
final httpClient = HttpClient();
final userService = ApiUserService(httpClient);
final viewModel = UserViewModel(userService);
2. InheritedWidget 사용
Flutter의 InheritedWidget
을 활용하면 위젯 트리를 통해 의존성을 제공할 수 있습니다.
// 서비스 제공자 위젯
class ServiceProvider extends InheritedWidget {
final UserService userService;
const ServiceProvider({
Key? key,
required Widget child,
required this.userService,
}) : super(key: key, child: child);
static ServiceProvider of(BuildContext context) {
final provider = context.dependOnInheritedWidgetOfExactType<ServiceProvider>();
assert(provider != null, 'ServiceProvider를 찾을 수 없습니다');
return provider!;
}
@override
bool updateShouldNotify(ServiceProvider oldWidget) {
return userService != oldWidget.userService;
}
}
// 사용 예시
class UserListScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
// 위젯 트리에서 서비스를 가져옴
final userService = ServiceProvider.of(context).userService;
return FutureBuilder<List<User>>(
future: userService.getUsers(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return CircularProgressIndicator();
}
final users = snapshot.data ?? [];
return ListView.builder(
itemCount: users.length,
itemBuilder: (context, index) => UserListItem(user: users[index]),
);
},
);
}
}
// 앱 진입점
void main() {
final httpClient = HttpClient();
final userService = ApiUserService(httpClient);
runApp(
ServiceProvider(
userService: userService,
child: MyApp(),
),
);
}
3. Provider 패키지 사용
Flutter 커뮤니티에서 널리 사용되는 provider
패키지는 의존성 주입과 상태 관리를 함께 해결하는 좋은 방법입니다.
import 'package:provider/provider.dart';
void main() {
runApp(
MultiProvider(
providers: [
Provider<HttpClient>(create: (_) => HttpClient()),
ProxyProvider<HttpClient, UserService>(
update: (_, httpClient, __) => ApiUserService(httpClient),
),
ChangeNotifierProxyProvider<UserService, UserViewModel>(
create: (context) => UserViewModel(context.read<UserService>()),
update: (_, userService, previousViewModel) =>
previousViewModel!..updateService(userService),
),
],
child: MyApp(),
),
);
}
// ViewModel
class UserViewModel extends ChangeNotifier {
UserService _userService;
List<User> _users = [];
bool _isLoading = false;
UserViewModel(this._userService);
void updateService(UserService service) {
_userService = service;
}
List<User> get users => _users;
bool get isLoading => _isLoading;
Future<void> loadUsers() async {
_isLoading = true;
notifyListeners();
try {
_users = await _userService.getUsers();
} catch (e) {
// 오류 처리
} finally {
_isLoading = false;
notifyListeners();
}
}
}
// 위젯 사용 예시
class UserListScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final viewModel = context.watch<UserViewModel>();
return Scaffold(
appBar: AppBar(title: Text('사용자 목록')),
body: viewModel.isLoading
? Center(child: CircularProgressIndicator())
: ListView.builder(
itemCount: viewModel.users.length,
itemBuilder: (_, index) => UserListItem(user: viewModel.users[index]),
),
floatingActionButton: FloatingActionButton(
onPressed: () => viewModel.loadUsers(),
child: Icon(Icons.refresh),
),
);
}
}
4. get_it 패키지 사용
get_it
은 서비스 로케이터 패턴을 구현한 패키지로, 전역적으로 의존성을 등록하고 접근할 수 있게 해줍니다.
import 'package:get_it/get_it.dart';
// GetIt 인스턴스 생성
final getIt = GetIt.instance;
// 의존성 등록
void setupDependencies() {
// 싱글톤으로 HttpClient 등록
getIt.registerSingleton<HttpClient>(HttpClient());
// 팩토리로 UserService 등록 (매번 새로운 인스턴스 생성)
getIt.registerFactory<UserService>(() => ApiUserService(getIt<HttpClient>()));
// 싱글톤으로 UserRepository 등록
getIt.registerLazySingleton<UserRepository>(() => UserRepositoryImpl(getIt<UserService>()));
// 싱글톤으로 UserViewModel 등록 (지연 초기화)
getIt.registerLazySingleton(() => UserViewModel(getIt<UserRepository>()));
}
// 앱 진입점
void main() {
setupDependencies();
runApp(MyApp());
}
// 사용 예시
class UserListScreen extends StatefulWidget {
@override
_UserListScreenState createState() => _UserListScreenState();
}
class _UserListScreenState extends State<UserListScreen> {
// 의존성 획득
final viewModel = getIt<UserViewModel>();
@override
void initState() {
super.initState();
viewModel.loadUsers();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('사용자 목록')),
body: viewModel.isLoading
? Center(child: CircularProgressIndicator())
: ListView.builder(
itemCount: viewModel.users.length,
itemBuilder: (_, index) => UserListItem(user: viewModel.users[index]),
),
);
}
}
5. injectable 패키지와 자동 생성
injectable
은 get_it
을 기반으로 하며, 코드 생성을 통해 의존성 등록을 자동화합니다.
import 'package:injectable/injectable.dart';
import 'package:get_it/get_it.dart';
import 'injection.config.dart';
final getIt = GetIt.instance;
@InjectableInit(
initializerName: 'init', // default
preferRelativeImports: true, // default
asExtension: false, // default
)
void configureDependencies() => init(getIt);
// 서비스 클래스에 애노테이션 추가
@injectable
class HttpClient {
// 구현
}
@Injectable(as: UserService)
class ApiUserService implements UserService {
final HttpClient httpClient;
ApiUserService(this.httpClient);
@override
Future<List<User>> getUsers() async {
// 구현
}
}
@lazySingleton
class UserViewModel {
final UserService userService;
UserViewModel(this.userService);
// 구현
}
// 앱 진입점
void main() {
configureDependencies();
runApp(MyApp());
}
6. Riverpod 패키지 사용
Riverpod는 Provider의 개선된 버전으로, 타입 안전성과 테스트 용이성이 향상되었습니다.
import 'package:flutter_riverpod/flutter_riverpod.dart';
// 의존성 provider 정의
final httpClientProvider = Provider<HttpClient>((ref) => HttpClient());
final userServiceProvider = Provider<UserService>((ref) {
final httpClient = ref.watch(httpClientProvider);
return ApiUserService(httpClient);
});
final userRepositoryProvider = Provider<UserRepository>((ref) {
final userService = ref.watch(userServiceProvider);
return UserRepositoryImpl(userService);
});
final userViewModelProvider = StateNotifierProvider<UserViewModel, UserState>((ref) {
final repository = ref.watch(userRepositoryProvider);
return UserViewModel(repository);
});
// 상태와 뷰모델
class UserState {
final List<User> users;
final bool isLoading;
final String? error;
UserState({
required this.users,
this.isLoading = false,
this.error,
});
UserState copyWith({
List<User>? users,
bool? isLoading,
String? error,
}) {
return UserState(
users: users ?? this.users,
isLoading: isLoading ?? this.isLoading,
error: error ?? this.error,
);
}
}
class UserViewModel extends StateNotifier<UserState> {
final UserRepository _repository;
UserViewModel(this._repository) : super(UserState(users: []));
Future<void> loadUsers() async {
state = state.copyWith(isLoading: true, error: null);
try {
final users = await _repository.getUsers();
state = state.copyWith(users: users, isLoading: false);
} catch (e) {
state = state.copyWith(error: e.toString(), isLoading: false);
}
}
}
// 사용 예시
class UserListScreen extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final userState = ref.watch(userViewModelProvider);
final viewModel = ref.read(userViewModelProvider.notifier);
return Scaffold(
appBar: AppBar(title: Text('사용자 목록')),
body: userState.isLoading
? Center(child: CircularProgressIndicator())
: userState.error != null
? Center(child: Text('오류: ${userState.error}'))
: ListView.builder(
itemCount: userState.users.length,
itemBuilder: (_, index) => UserListItem(
user: userState.users[index],
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => viewModel.loadUsers(),
child: Icon(Icons.refresh),
),
);
}
}
// 앱 진입점
void main() {
runApp(
ProviderScope(
child: MyApp(),
),
);
}
의존성 주입 모범 사례
1. 인터페이스 사용하기
구현체보다 인터페이스에 의존하여 유연성을 높이세요.
// 좋은 예시:
abstract class AuthService {
Future<User?> login(String username, String password);
}
class FirebaseAuthService implements AuthService {
@override
Future<User?> login(String username, String password) {
// Firebase 구현
}
}
class MockAuthService implements AuthService {
@override
Future<User?> login(String username, String password) {
// 테스트용 가짜 구현
}
}
// 사용 클래스는 구체적인 구현이 아닌 인터페이스에 의존
class AuthViewModel {
final AuthService _authService;
AuthViewModel(this._authService);
// 메서드 구현
}
2. 책임 분리하기
각 클래스는 하나의 책임만 갖도록 설계하세요.
// 나쁜 예시:
class UserManager {
void saveUser(User user) {
// 데이터베이스 접근
// API 호출
// 캐시 관리
// 이벤트 발생
}
}
// 좋은 예시:
class UserRepository {
final ApiService _apiService;
final DatabaseService _dbService;
UserRepository(this._apiService, this._dbService);
Future<void> saveUser(User user) async {
await _apiService.updateUser(user);
await _dbService.saveUser(user);
}
}
class UserCacheManager {
// 캐시 관련 로직
}
class UserEventManager {
// 이벤트 발생 로직
}
3. 환경별 의존성 구성
개발, 테스트, 프로덕션 환경에 따라 다른 의존성을 주입할 수 있도록 설계하세요.
enum Environment { dev, test, prod }
void setupDependencies(Environment env) {
if (env == Environment.test) {
getIt.registerSingleton<ApiClient>(MockApiClient());
} else if (env == Environment.dev) {
getIt.registerSingleton<ApiClient>(DevApiClient());
} else {
getIt.registerSingleton<ApiClient>(ProdApiClient());
}
// 공통 의존성 등록
getIt.registerSingleton<UserRepository>(
UserRepositoryImpl(getIt<ApiClient>()),
);
}
void main() {
// 환경 변수 또는 빌드 설정에 따라 환경 결정
final env = const String.fromEnvironment('ENV', defaultValue: 'dev');
setupDependencies(
env == 'prod' ? Environment.prod :
env == 'test' ? Environment.test :
Environment.dev
);
runApp(MyApp());
}
4. 테스트 용이성 확보
의존성 주입을 활용하여 쉽게 테스트할 수 있는 코드를 작성하세요.
class UserViewModel {
final UserRepository repository;
UserViewModel(this.repository);
Future<List<User>> getActiveUsers() async {
final users = await repository.getUsers();
return users.where((user) => user.isActive).toList();
}
}
// 테스트 코드
void main() {
test('active users are filtered correctly', () async {
// 모의 객체 생성
final mockRepository = MockUserRepository();
// 모의 동작 설정
when(mockRepository.getUsers()).thenAnswer((_) async => [
User(id: 1, name: 'John', isActive: true),
User(id: 2, name: 'Jane', isActive: false),
User(id: 3, name: 'Bob', isActive: true),
]);
// 테스트 대상 생성 (의존성 주입)
final viewModel = UserViewModel(mockRepository);
// 테스트 실행
final activeUsers = await viewModel.getActiveUsers();
// 검증
expect(activeUsers.length, 2);
expect(activeUsers.every((user) => user.isActive), true);
});
}
결론
Flutter에서 의존성 주입은 코드 품질과 유지보수성을 높이는 중요한 패턴입니다. 단순한 앱에서는 생성자 주입이나 InheritedWidget만으로도 충분할 수 있지만, 앱의 복잡도가 높아질수록 Provider, get_it, Riverpod 같은 전문적인 라이브러리를 사용하는 것이 좋습니다.
의존성 주입은 단순히 기술적인 패턴이 아니라 소프트웨어 설계 철학입니다. 인터페이스에 의존하고, 책임을 명확히 분리하며, 필요한 의존성만 주입받는 SOLID 원칙을 따르면 유지보수하기 쉬운 Flutter 앱을 만들 수 있습니다.
어떤 방법을 선택하든, 앱의 요구사항과 팀의 경험을 고려하여 일관된 방식으로 의존성 주입을 적용하는 것이 중요합니다.