Dependency Injection
목차
- DI란 무엇인가?
- DI의 장단점
- DI 구현 방법
- 순수 Dart로 DI 구현하기
- GetIt 사용하기
- Injectable로 자동화하기
- IoC와 DI의 차이
- 인터페이스를 활용한 고급 패턴
DI란 무엇인가?
**Dependency Injection(의존성 주입, 이하 DI)**은 객체 간의 결합도를 낮추고 유연성을 높이는 디자인 패턴입니다.
핵심 개념
DI는 외부에서 객체 간의 의존성을 결정해줍니다. 즉, 객체를 직접 생성하는 것이 아닌 외부에서 생성한 후 주입시켜주는 방식입니다.
// ❌ DI 없이: 직접 생성 (강한 결합)
class Member {
AuthService service = AuthService(); // 직접 생성
}
// ✅ DI 사용: 외부에서 주입 (느슨한 결합)
class Member {
AuthService service;
Member(this.service); // 생성자를 통해 주입받음
}
왜 중요한가?
위의 첫 번째 예시에서 AuthService의 생성자가 변경되면 어떻게 될까요?
// AuthService가 변경됨
class AuthService {
final ApiClient client;
AuthService(this.client); // 생성자에 파라미터 추가!
}
// ❌ DI 없는 코드: 컴파일 에러 발생!
class Member {
AuthService service = AuthService(); // 에러: AuthService() requires 1 argument
}
// ✅ DI 사용: Member 코드는 변경 불필요
class Member {
AuthService service;
Member(this.service); // 그대로 사용 가능
}
DI를 사용하면 Member 클래스는 AuthService의 구현 세부사항을 몰라도 되며, AuthService의 변경이 Member에 직접적인 영향을 주지 않습니다.
DI의 장단점
장점
1. Unit Test가 용이해짐
의존성을 Mock 객체로 쉽게 대체할 수 있습니다.
// 테스트에서 Mock 객체 주입
class MockAuthService extends AuthService {
@override
Future<User> login(String email, String password) async {
return User(id: '1', name: 'Test User');
}
}
// 테스트
void main() {
test('Member uses auth service', () {
final mockService = MockAuthService();
final member = Member(mockService); // Mock 주입
// 실제 네트워크 호출 없이 테스트 가능
});
}
2. 코드의 재사용성 증가
같은 클래스를 다른 구현체와 함께 사용할 수 있습니다.
// 프로덕션: 실제 API 사용
final prodMember = Member(RealAuthService());
// 개발: Mock API 사용
final devMember = Member(MockAuthService());
// 테스트: Test Double 사용
final testMember = Member(TestAuthService());
3. 객체 간 의존성 감소
인터페이스를 통해 구현체에 대한 의존성을 제거할 수 있습니다.
// 추상화된 인터페이스에 의존
abstract class AuthService {
Future<User> login(String email, String password);
}
class Member {
final AuthService service; // 구체적 구현이 아닌 인터페이스에 의존
Member(this.service);
}
// 어떤 구현체든 사용 가능
class RealAuthService implements AuthService { /* ... */ }
class MockAuthService implements AuthService { /* ... */ }
단점
1. 초기 러닝 커브
DI 개념과 사용법을 학습하는 데 시간이 필요합니다.
2. 주입된 객체들의 코드 추적이 어려움
의존성이 외부에서 주입되므로 객체 생성 흐름을 파악하기 어려울 수 있습니다.
// 이 Member는 어떤 AuthService를 사용하는지 코드만으로는 알기 어려움
final member = getIt<Member>();
3. 복잡성 증가
단일 책임 원칙을 따르다 보면 클래스 수가 증가하고 초기 설정이 복잡해질 수 있습니다.
DI 구현 방법
1. Constructor Injection (생성자 주입) ⭐ 권장
가장 많이 사용되고 권장되는 방법입니다.
class Member {
final AuthService service;
// 생성자를 통해 의존성 주입
Member(this.service);
void doSomething() {
service.login('user@example.com', 'password');
}
}
// 사용
final authService = AuthService();
final member = Member(authService);
장점:
- 의존성이 명확하게 드러남
- 불변성(immutability) 보장 가능 (
final사용) - 필수 의존성 강제 (생성자 파라미터로 명시)
- 테스트가 쉬움
2. Setter Injection (세터 주입)
class Member {
late AuthService service;
void setService(AuthService service) {
this.service = service;
}
}
// 사용
final member = Member();
member.setService(AuthService());
단점:
- 객체가 불완전한 상태로 존재할 수 있음
service를 설정하지 않으면 런타임 에러 발생 가능- 의존성이 숨겨져 있음
3. Field Injection (필드 주입)
class Member {
@inject // 프레임워크가 자동으로 주입
late AuthService service;
}
단점:
- 의존성이 더욱 숨겨져 있어 파악하기 어려움
- 테스트 작성이 복잡함
- 불변성 보장 불가능
순수 Dart로 DI 구현하기
GetIt과 같은 라이브러리 없이 순수 Dart로 DI를 구현해봅시다.
DI 사용 전
class Member {
AuthService service = AuthService();
void login() {
service.login('user@example.com', 'password');
}
}
문제점:
AuthService의 생성자가 변경되면Member도 수정해야 함AuthService의 의존성이 추가되면Member에서도 처리해야 함- 테스트 시 Mock 객체로 대체 불가능
// AuthService에 의존성이 추가되면
class AuthService {
final ApiClient client;
AuthService(this.client); // 파라미터 추가!
}
// Member도 수정해야 함
class Member {
AuthService service = AuthService(ApiClient()); // 수정 필요
}
DI 사용 후
class Member {
final AuthService service;
Member(this.service); // 외부에서 주입받음
void login() {
service.login('user@example.com', 'password');
}
}
// 사용
final apiClient = ApiClient();
final authService = AuthService(apiClient);
final member = Member(authService);
개선점:
AuthService의 변경이Member에 영향을 주지 않음- 테스트 시 Mock 객체 주입 가능
- 의존성이 명확하게 드러남
수동 DI 컨테이너 만들기
여러 곳에서 같은 인스턴스를 사용하려면 DI 컨테이너를 만들 수 있습니다.
class ServiceLocator {
// 싱글톤 패턴
static final ServiceLocator _instance = ServiceLocator._internal();
factory ServiceLocator() => _instance;
ServiceLocator._internal();
// 의존성 저장
late final ApiClient apiClient;
late final AuthService authService;
// 초기화
void setup() {
apiClient = ApiClient();
authService = AuthService(apiClient);
}
}
// main.dart에서 초기화
void main() {
ServiceLocator().setup();
runApp(MyApp());
}
// 사용
class Member {
final AuthService service = ServiceLocator().authService;
void login() {
service.login('user@example.com', 'password');
}
}
문제점:
- 모든 의존성을 수동으로 등록해야 함
- 복잡한 의존성 그래프는 관리하기 어려움
- 보일러플레이트 코드가 많음
이러한 문제를 해결하기 위해 GetIt을 사용합니다.
GetIt 사용하기
GetIt이란?
GetIt은 Flutter에서 가장 널리 사용되는 서비스 로케이터(Service Locator) 라이브러리입니다.
특징
- O(1) 검색 속도: 매우 빠른 성능
- Context 독립적: UI 트리에 의존하지 않음
- 간편한 사용법: 다른 DI 라이브러리 대비 낮은 러닝 커브
- 유연성: 다양한 등록 방식 지원
설치
# pubspec.yaml
dependencies:
get_it: ^7.6.0
기본 사용법
import 'package:get_it/get_it.dart';
final getIt = GetIt.instance;
void main() {
// 1. 의존성 등록
setupDependencies();
runApp(MyApp());
}
void setupDependencies() {
// 싱글톤 등록
getIt.registerSingleton<ApiClient>(ApiClient());
// 다른 의존성을 주입받는 서비스 등록
getIt.registerSingleton<AuthService>(
AuthService(getIt<ApiClient>()),
);
}
// 사용
class Member {
final AuthService service = getIt<AuthService>();
void login() {
service.login('user@example.com', 'password');
}
}
다양한 등록 방식
1. Singleton (싱글톤)
앱 전체에서 하나의 인스턴스만 사용합니다.
getIt.registerSingleton<ApiClient>(ApiClient());
// 어디서든 같은 인스턴스
final client1 = getIt<ApiClient>();
final client2 = getIt<ApiClient>();
print(client1 == client2); // true
사용 시기:
- 네트워크 클라이언트 (Dio, HttpClient)
- 데이터베이스 인스턴스
- 로거, 설정 관리자
2. LazySingleton (지연 싱글톤)
처음 요청될 때 인스턴스를 생성하고, 이후 재사용합니다.
getIt.registerLazySingleton<DatabaseHelper>(
() => DatabaseHelper(),
);
// 처음 호출 시 생성
final db1 = getIt<DatabaseHelper>(); // DatabaseHelper 생성
final db2 = getIt<DatabaseHelper>(); // 같은 인스턴스 재사용
사용 시기:
- 초기화 비용이 크지만 항상 필요하지 않은 서비스
- 조건부로 사용되는 서비스
3. Factory (팩토리)
매번 새로운 인스턴스를 생성합니다.
getIt.registerFactory<Member>(
() => Member(getIt<AuthService>()),
);
// 매번 새로운 인스턴스
final member1 = getIt<Member>();
final member2 = getIt<Member>();
print(member1 == member2); // false
사용 시기:
- 상태를 가지지 않는 서비스
- 매번 다른 초기 상태가 필요한 객체
- ViewModel, UseCase 등
GetIt 사용 전후 비교
사용 전
class InjectionCore {
Member createMember() {
final apiClient = ApiClient();
final authService = AuthService(apiClient);
return Member(authService);
}
}
// 사용
final injectionCore = InjectionCore();
final member = injectionCore.createMember();
문제점:
InjectionCore에 모든 생성 로직이 집중됨AuthService의 의존성이 변경되면InjectionCore도 수정해야 함- 코드 추적이 어려워짐
GetIt 사용 후
// 등록
void setupDependencies() {
getIt.registerSingleton<ApiClient>(ApiClient());
getIt.registerSingleton<AuthService>(
AuthService(getIt<ApiClient>()),
);
getIt.registerFactory<Member>(
() => Member(getIt<AuthService>()),
);
}
// 소비
class DIConsumer {
final Member member = getIt<Member>(); // 의존성을 몰라도 됨
void useMemebr() {
member.consumeB();
}
}
개선점:
- 각 클래스의 의존성이 명확히 분리됨
Member를 사용하는 쪽은AuthService의 존재를 몰라도 됨- GetIt이 의존성 그래프를 자동으로 해결함
Injectable로 자동화하기
Injectable은 GetIt 등록을 자동화하는 코드 생성 라이브러리입니다.
설치
# pubspec.yaml
dependencies:
get_it: ^7.6.0
injectable: ^2.3.0
dev_dependencies:
build_runner: ^2.4.0
injectable_generator: ^2.4.0
기본 사용법
1. 클래스에 어노테이션 추가
// auth_service.dart
import 'package:injectable/injectable.dart';
@injectable // GetIt에 자동 등록
class AuthService {
final ApiClient client;
AuthService(this.client); // Injectable이 자동으로 주입
void printText() {
print("hello");
}
}
2. Module 정의 (선택사항)
복잡한 생성 로직이나 외부 라이브러리를 등록할 때 사용합니다.
// app_module.dart
import 'package:injectable/injectable.dart';
@module
abstract class AppModule {
// Dio 인스턴스 설정
@singleton
Dio get dio {
final dio = Dio();
dio.options.baseUrl = 'https://api.example.com';
return dio;
}
// SharedPreferences (비동기)
@preResolve // 비동기 팩토리 메서드 표시
Future<SharedPreferences> get prefs => SharedPreferences.getInstance();
}
3. 설정 파일 생성
// injection.dart
import 'package:get_it/get_it.dart';
import 'package:injectable/injectable.dart';
import 'injection.config.dart';
final getIt = GetIt.instance;
@InjectableInit()
void configureDependencies() => getIt.init();
4. 코드 생성
flutter pub run build_runner build --delete-conflicting-outputs
이 명령어는 injection.config.dart 파일을 생성합니다.
5. main.dart에서 초기화
void main() {
configureDependencies(); // 의존성 초기화
runApp(MyApp());
}
6. 사용
class DIConsumer {
final Member member = getIt<Member>();
void consumeA() {
member.consumeB();
}
}
Injectable vs 수동 등록 비교
수동 등록
void setupDependencies() {
getIt.registerSingleton<ApiClient>(ApiClient());
getIt.registerSingleton<AuthService>(AuthService(getIt<ApiClient>()));
getIt.registerFactory<Member>(() => Member(getIt<AuthService>()));
}
단점:
- 의존성이 많아지면 코드가 길어짐
- 등록 순서를 직접 관리해야 함
- 실수하기 쉬움
Injectable 사용
@injectable
class ApiClient { }
@injectable
class AuthService {
AuthService(ApiClient client);
}
@injectable
class Member {
Member(AuthService service);
}
// 자동 생성된 코드가 모든 등록을 처리
@InjectableInit()
void configureDependencies() => getIt.init();
장점:
- 어노테이션만 추가하면 자동 등록
- 의존성 순서 자동 해결
- 실수 가능성 감소
다양한 Injectable 어노테이션
// 싱글톤
@singleton
class ApiClient { }
// 지연 싱글톤 (기본값)
@lazySingleton
class DatabaseHelper { }
// 팩토리 (기본값)
@injectable
class Member { }
// 환경별 등록
@dev
@injectable
class MockAuthService implements AuthService { }
@prod
@injectable
class RealAuthService implements AuthService { }
IoC와 DI의 차이
많은 사람들이 IoC(Inversion of Control)와 DI를 동일하게 생각하지만, 이 둘은 분명히 다릅니다.
IoC (제어의 역전)
IoC는 제어 흐름의 주체가 외부로 바뀌는 것을 의미하는 설계 원칙입니다.
일반적인 제어 흐름
class OrderProcessor {
void process() {
// 개발자가 직접 제어
final validator = OrderValidator(); // 내가 생성
final repository = OrderRepository(); // 내가 생성
validator.validate(); // 내가 호출
repository.save(); // 내가 호출
}
}
프로그래머가 객체 생성과 메서드 호출을 직접 제어합니다.
IoC 적용 후
class OrderProcessor {
final OrderValidator validator;
final OrderRepository repository;
// 외부(프레임워크/컨테이너)가 생성해서 주입
OrderProcessor(this.validator, this.repository);
void process() {
validator.validate();
repository.save();
}
}
// 설정 (IoC 컨테이너가 제어)
void setup() {
getIt.registerFactory(() => OrderValidator());
getIt.registerFactory(() => OrderRepository());
getIt.registerFactory(() => OrderProcessor(
getIt<OrderValidator>(),
getIt<OrderRepository>(),
));
}
객체 생성과 생명주기 관리를 **외부(GetIt, 프레임워크)**가 제어합니다.
제어역전 적용 전후 비교
// 제어역전 적용 전
1. 객체 생성 (개발자가 직접)
2. 의존성 객체 생성 (개발자가 직접)
3. 의존성 객체 메서드 호출
// 제어역전 적용 후
1. 객체 생성 (외부 컨테이너)
2. 의존성 객체 주입 (외부 컨테이너)
3. 의존성 객체 메서드 호출
DI는 IoC의 구현 방법
- IoC: “무엇을 할 것인가” (설계 원칙)
- 제어권을 외부로 위임한다
- DI: “어떻게 할 것인가” (디자인 패턴)
- 의존성을 외부에서 주입한다
Martin Fowler의 말:
“Inversion of Control은 너무 일반적인 용어이기 때문에 사람들은 그것을 혼동한다. 그 결과 다양한 IoC 옹호자들과 많은 논의를 거쳐 Dependency Injection이라는 이름을 정했다.”
IoC의 다른 구현 방법들
DI 외에도 IoC를 구현하는 방법은 다양합니다:
1. Service Locator Pattern
final service = ServiceLocator.get<UserService>();
2. Factory Pattern
final service = ServiceFactory.createUserService();
3. Template Method Pattern
abstract class BaseService {
void process() {
load();
doProcess(); // 서브클래스에서 구현 (제어가 역전됨)
save();
}
void doProcess(); // 추상 메서드
}
4. Event-Driven Architecture
// 이벤트 발생
eventBus.fire(UserLoggedInEvent());
// 프레임워크가 리스너 호출 (제어 역전)
eventBus.on<UserLoggedInEvent>().listen((event) {
// 처리
});
인터페이스를 활용한 고급 패턴
추상화의 중요성
구체적인 구현이 아닌 인터페이스에 의존하면 다형성을 활용할 수 있습니다.
인터페이스 없이
@injectable
class Member {
final AuthService service; // 구체 클래스에 직접 의존
Member(this.service);
void consumeB() {
service.printText();
}
}
문제점:
AuthService의 구체적 구현에 의존- 다른 구현체로 교체하기 어려움
- 테스트 시 실제
AuthService가 필요함
인터페이스 사용
// 1. 인터페이스 정의
abstract class MemberInterface {
void consumeB();
}
abstract class AuthServiceInterface {
void printText();
}
// 2. 구현체
@Injectable(as: AuthServiceInterface) // 인터페이스로 등록
class AuthService implements AuthServiceInterface {
@override
void printText() {
print("hello");
}
}
@Injectable(as: MemberInterface)
class Member implements MemberInterface {
final AuthServiceInterface service; // 인터페이스에 의존
Member(this.service);
@override
void consumeB() {
service.printText();
}
}
// 3. 사용
class DIConsumer {
final MemberInterface member = getIt<MemberInterface>();
void consumeA() {
member.consumeB();
}
}
인터페이스의 장점
1. 캡슐화 & 은닉화
abstract class UserRepository {
Future<User> getUser(String id);
}
// 구현 세부사항 숨김
@Injectable(as: UserRepository)
class UserRepositoryImpl implements UserRepository {
final ApiClient _client; // private
final DatabaseHelper _db; // private
UserRepositoryImpl(this._client, this._db);
@override
Future<User> getUser(String id) async {
// 복잡한 내부 로직
try {
return await _client.get('/users/$id');
} catch (e) {
return await _db.getUser(id);
}
}
}
// 사용하는 쪽은 인터페이스만 알면 됨
class UserService {
final UserRepository repository;
UserService(this.repository); // 구현 세부사항 몰라도 됨
}
2. 다형성
환경에 따라 다른 구현체를 사용할 수 있습니다.
abstract class AuthServiceInterface {
Future<User> login(String email, String password);
}
// 개발 환경: Mock 데이터
@dev
@Injectable(as: AuthServiceInterface)
class MockAuthService implements AuthServiceInterface {
@override
Future<User> login(String email, String password) async {
return User(id: '1', email: email, name: 'Test User');
}
}
// 프로덕션: 실제 API
@prod
@Injectable(as: AuthServiceInterface)
class RealAuthService implements AuthServiceInterface {
final ApiClient client;
RealAuthService(this.client);
@override
Future<User> login(String email, String password) async {
final response = await client.post('/auth/login', {
'email': email,
'password': password,
});
return User.fromJson(response.data);
}
}
// 사용하는 쪽은 동일
class LoginUseCase {
final AuthServiceInterface service; // 어떤 구현체든 OK
LoginUseCase(this.service);
}
3. 테스트 용이성
// 테스트용 Mock 구현
class TestAuthService implements AuthServiceInterface {
@override
Future<User> login(String email, String password) async {
if (email == 'test@test.com') {
return User(id: '1', email: email, name: 'Test');
}
throw Exception('Invalid credentials');
}
}
// 테스트
void main() {
test('LoginUseCase test', () async {
final testService = TestAuthService();
final useCase = LoginUseCase(testService); // Mock 주입
final user = await useCase.execute('test@test.com', 'password');
expect(user.id, '1');
});
}
4. 유지보수성
한 구현체의 변경이 다른 코드에 영향을 주지 않습니다.
// AuthService 구현 변경
@Injectable(as: AuthServiceInterface)
class AuthService implements AuthServiceInterface {
final ApiClient client;
final TokenStorage storage; // 새로운 의존성 추가
AuthService(this.client, this.storage); // 생성자 변경
@override
Future<User> login(String email, String password) async {
final user = await client.post('/auth/login', {
'email': email,
'password': password,
});
await storage.saveToken(user.token); // 새로운 로직
return user;
}
}
// Member는 전혀 영향 받지 않음!
class Member implements MemberInterface {
final AuthServiceInterface service; // 그대로
Member(this.service); // 그대로
@override
void consumeB() {
service.printText(); // 그대로
}
}
Module과 함께 사용하기
@module
abstract class ViewModelModule {
// 인터페이스로 등록
@injectable
MemberInterface member(AuthServiceInterface service) => Member(service);
}
실전 예제
전체 흐름
// 1. 인터페이스 정의
abstract class AuthServiceInterface {
Future<User> login(String email, String password);
void logout();
}
abstract class MemberInterface {
void performAction();
}
// 2. 구현체
@Injectable(as: AuthServiceInterface)
class AuthService implements AuthServiceInterface {
final ApiClient client;
AuthService(this.client);
@override
Future<User> login(String email, String password) async {
final response = await client.post('/auth/login', {
'email': email,
'password': password,
});
return User.fromJson(response.data);
}
@override
void logout() {
print("Logging out...");
}
}
@Injectable(as: MemberInterface)
class Member implements MemberInterface {
final AuthServiceInterface service;
Member(this.service);
@override
void performAction() {
service.logout();
}
}
// 3. DI 설정
@module
abstract class AppModule {
@singleton
ApiClient get client => ApiClient();
}
// injection.dart
@InjectableInit()
void configureDependencies() => getIt.init();
// 4. main.dart
void main() {
configureDependencies();
runApp(MyApp());
}
// 5. 사용
class MyWidget extends StatelessWidget {
final MemberInterface member = getIt<MemberInterface>();
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () => member.performAction(),
child: Text('Perform Action'),
);
}
}
베스트 프랙티스
1. 인터페이스 사용
// ✅ 좋은 예: 인터페이스에 의존
abstract class Repository { }
@Injectable(as: Repository)
class RepositoryImpl implements Repository { }
// ❌ 나쁜 예: 구체 클래스에 의존
@injectable
class RepositoryImpl { }
2. 생성자 주입 사용
// ✅ 좋은 예
@injectable
class Service {
final Repository repository;
Service(this.repository);
}
// ❌ 나쁜 예
@injectable
class Service {
late Repository repository;
void setRepository(Repository repo) {
repository = repo;
}
}
3. 적절한 생명주기 선택
// ✅ 싱글톤: 상태를 공유하는 서비스
@singleton
class ApiClient { }
// ✅ 팩토리: 상태를 가지지 않는 UseCase
@injectable
class LoginUseCase { }
4. Module 활용
// ✅ 외부 라이브러리나 복잡한 설정은 Module로
@module
abstract class NetworkModule {
@singleton
Dio get dio => Dio(BaseOptions(
baseUrl: 'https://api.example.com',
));
}