Dependency Injection with “GetIt” .aka DI
Flutter에서 의존성 주입(DI) 완벽 가이드: GetIt을 활용한 실전 예제
모바일 앱 개발을 하다 보면 클래스 간의 의존성이 복잡하게 얽히는 경험을 한 번쯤 해보셨을 겁니다. 한 클래스를 수정했는데 연쇄적으로 다른 클래스들까지 수정해야 하는 상황, 테스트 코드를 작성하려는데 의존성 때문에 막막한 상황. 이런 문제들을 해결하기 위한 핵심 개념이 바로 **의존성 주입(Dependency Injection, DI)**입니다.
이 글에서는 DI의 기본 개념부터 Flutter에서 GetIt 라이브러리를 활용한 실전 구현까지, 단계별로 알아보겠습니다.
의존성 주입이란?
의존성 주입은 객체 간의 결합도를 낮추고 유연성을 높이는 디자인 패턴입니다. 핵심은 간단합니다. 객체가 필요한 의존성을 스스로 생성하는 것이 아니라, 외부에서 생성하여 주입받는 방식입니다.
왜 의존성 주입이 필요할까?
실제 코드로 문제 상황을 살펴보겠습니다.
class Member {
AuthService service = AuthService();
}
이 코드의 문제는 무엇일까요? Member 클래스가 AuthService를 직접 생성하고 있습니다. 만약 AuthService의 생성자가 변경된다면 어떻게 될까요?
// AuthService가 변경됨
class AuthService {
AuthService(ApiClient client); // 생성자에 파라미터 추가!
}
이 순간, Member 클래스도 함께 수정해야 합니다. 더 큰 문제는 Member를 사용하는 모든 코드가 영향을 받는다는 것입니다. 이것이 **강한 결합(Tight Coupling)**의 문제점입니다.
의존성 주입의 장단점
장점
- 테스트가 쉬워집니다: Mock 객체를 주입하여 독립적인 단위 테스트 작성이 가능합니다
- 코드 재사용성 증가: 같은 클래스를 다양한 의존성과 함께 재사용할 수 있습니다
- 유연한 구조: 인터페이스를 활용하면 다양한 구현체로 쉽게 교체할 수 있습니다
단점
- 초기 학습 곡선: 처음 접하면 개념이 다소 어렵게 느껴질 수 있습니다
- 코드 추적의 어려움: 객체가 어디서 생성되는지 추적하기 어려울 수 있습니다
- 복잡성 증가: 클래스 수가 늘어나고 구조가 복잡해질 수 있습니다
하지만 장기적으로 보면 이러한 단점들은 유지보수성과 확장성이라는 더 큰 이점으로 상쇄됩니다.
DI 구현 방법
의존성을 주입하는 방법은 크게 세 가지가 있습니다:
- 생성자 주입 (Constructor Injection) ⭐ 가장 권장되는 방법
- 필드 주입 (Field Injection)
- 세터 주입 (Setter Injection)
이 글에서는 가장 널리 사용되는 생성자 주입을 중심으로 설명하겠습니다.
기본 DI 패턴 적용하기
Before: DI 적용 전
class Member {
AuthService service = AuthService(); // 직접 생성
}
After: DI 적용 후
class Member {
final AuthService service;
Member(this.service); // 외부에서 주입받음
}
이제 Member 클래스는 AuthService의 생성 방법을 알 필요가 없습니다. 단지 주입받아 사용할 뿐입니다.
더 나아가 인터페이스를 활용하면 다양한 구현체를 주입할 수 있어 더욱 유연한 설계가 가능합니다.
// 추상화
abstract class AuthServiceInterface {
void authenticate();
}
// 다양한 구현체
class FirebaseAuthService implements AuthServiceInterface { ... }
class CustomAuthService implements AuthServiceInterface { ... }
// Member는 구체적인 구현을 몰라도 됨
class Member {
final AuthServiceInterface service;
Member(this.service);
}
GetIt으로 한 단계 더 나아가기
수동으로 의존성을 주입하다 보면 곧 한계에 부딪힙니다. 의존성이 복잡해질수록 관리가 어려워지기 때문입니다.
// 이런 코드가 계속 늘어난다면?
class InjectionCore {
Member createMember() {
final apiClient = ApiClient();
final authService = AuthService(apiClient);
return Member(authService);
}
}
이때 필요한 것이 의존성 주입 컨테이너입니다. Flutter에서는 GetIt이 가장 인기 있는 선택입니다.
GetIt의 특징
- 빠른 성능: O(1) 시간 복잡도로 의존성 조회
- 낮은 학습 곡선: 다른 DI 라이브러리에 비해 사용하기 쉬움
- Context 독립적: Provider와 달리 BuildContext가 필요 없음
- 어노테이션 지원: injectable 패키지와 함께 사용하면 자동 코드 생성 가능
GetIt 실전 활용
설치
dependencies:
get_it: ^7.6.0
injectable: ^2.3.0
dev_dependencies:
injectable_generator: ^2.4.0
build_runner: ^2.4.0
1단계: 모듈 정의
@module
abstract class ViewModelModule {
Member member(AuthService service) => Member(service);
}
@module 어노테이션은 의존성을 어떻게 생성할지 정의합니다. Member가 AuthService에 의존한다는 것을 명시적으로 선언합니다.
2단계: 주입 가능한 클래스 표시
@injectable
class AuthService {
void printText() {
print("hello");
}
}
@injectable 어노테이션으로 GetIt 컨테이너에 등록 가능한 클래스임을 표시합니다.
3단계: 의존성을 가진 클래스 구현
class Member {
final AuthService service;
Member(this.service);
void consumeB() {
service.printText();
}
}
4단계: 코드 생성
flutter pub run build_runner build --delete-conflicting-outputs
5단계: 사용하기
class DIConsumer {
// GetIt에서 의존성을 자동으로 해결하여 제공
final Member member = getIt<Member>();
void consumeA() {
member.consumeB(); // AuthService를 몰라도 사용 가능!
}
}
놀랍지 않나요? DIConsumer는 Member가 AuthService에 의존한다는 사실을 전혀 모릅니다. GetIt이 모든 의존성을 자동으로 해결해줍니다.
DI vs IoC: 헷갈리는 개념 정리
많은 개발자들이 **의존성 주입(DI)**과 **제어의 역전(IoC, Inversion of Control)**을 같은 것으로 혼동합니다. 하지만 이 둘은 명확히 다릅니다.
IoC(제어의 역전)란?
IoC는 프로그램의 제어 흐름이 역전되는 설계 원칙입니다. 일반적으로는 개발자가 작성한 코드가 프로그램의 흐름을 제어합니다. 하지만 IoC에서는 프레임워크나 컨테이너가 제어권을 가집니다.
제어역전 적용 전
1. 객체 생성
2. 필요한 의존성 객체 직접 생성
3. 의존성 객체 메서드 호출
제어역전 적용 후
1. 객체 생성 (컨테이너가 담당)
2. 의존성 자동 주입 (컨테이너가 담당)
3. 의존성 객체 메서드 호출
그래서 차이는?
“Inversion of Control은 너무 일반적인 용어이기 때문에 사람들이 혼동합니다. 그래서 우리는 더 구체적인 이름인 Dependency Injection을 선택했습니다.”
— Martin Fowler
간단히 말하면:
- IoC: 제어권을 외부에 위임하는 설계 원칙 (What)
- DI: IoC를 구현하는 구체적인 디자인 패턴 (How)
객체지향 설계로 완성하기
지금까지의 코드도 좋지만, 인터페이스를 활용하면 더욱 견고한 설계가 가능합니다.
인터페이스 정의
abstract class MemberInterface {
void consumeB();
}
구현체
class Member implements MemberInterface {
final AuthService service;
Member(this.service);
@override
void consumeB() {
service.printText();
}
}
모듈 수정
@module
abstract class ViewModelModule {
// 구체 타입이 아닌 인터페이스를 반환
MemberInterface member(AuthService service) => Member(service);
}
사용
class DIConsumer {
// 인터페이스에 의존
final MemberInterface member = getIt<MemberInterface>();
void consumeA() {
member.consumeB();
}
}
이렇게 하면 DIConsumer는 Member의 구체적인 구현을 전혀 몰라도 됩니다. 필요하다면 Member를 다른 구현체로 교체해도 DIConsumer는 영향을 받지 않습니다. 이것이 캡슐화와 은닉화의 힘입니다.
마치며
의존성 주입은 처음에는 복잡해 보일 수 있지만, 이해하고 나면 코드의 품질을 획기적으로 향상시킬 수 있는 강력한 도구입니다.
핵심 포인트 정리:
- DI는 객체 간 결합도를 낮추는 디자인 패턴
- GetIt은 Flutter에서 DI를 쉽게 구현할 수 있게 도와주는 라이브러리
- 인터페이스와 함께 사용하면 더욱 유연한 설계 가능
- IoC는 설계 원칙, DI는 그것을 구현하는 구체적인 방법
여러분의 Flutter 프로젝트에 DI를 도입하면 테스트가 쉬워지고, 유지보수가 편해지며, 코드의 확장성이 크게 향상될 것입니다.
참고 자료:
