의존성 주입(DI)은 객체가 의존성을 직접 생성하지 않고 외부에서 받는 기술입니다. DI는 IoC(제어의 역전) 원칙의 구현체이며 Dagger, Hilt 및 Swinject의 기초입니다. 의존성 주입은 코드 결합도를 줄이고, 테스트를 단순화하며, 아키텍처를 유연하게 만듭니다. Android에서는 Google의 Dagger Hilt가 DI 표준이고, iOS에서는 Swinject 또는 수동 주입이 사용됩니다. 자세한 내용은 Android DI 가이드를 참조하세요.
핵심 요점
의존성 주입은 객체가 생성자, 세터 또는 인터페이스를 통해 의존성(서비스, 리포지토리, 구성)을 받고, new로 직접 생성하지 않는 기술입니다. DI의 목표는 클래스 간의 결합도를 줄이는 것입니다. 클래스가 의존성을 직접 생성하면 특정 구현에 강하게 결합되어 테스트와 수정이 어려워집니다. DI를 사용하면 클래스는 추상화(프로토콜/인터페이스)에 대해 작업하고, 구체적인 구현은 외부에서 제공됩니다.
세 가지 주입 방식 — 생성자 주입(init/constructor 통해), 세터 주입(속성/setter 통해), 인터페이스 주입(인터페이스 메서드를 통해). 생성자 주입이 선호되는 방식입니다. 의존성이 시그니처에 명확하게 표시되고 객체가 항상 유효한 상태로 생성됩니다. 세터 주입은 기본값이 있는 선택적 의존성에 사용됩니다. 인터페이스 주입은 드물며 주로 DI 컨테이너에 사용됩니다.
| DI 유형 | 방식 | 사용 시기 | 예시 |
|---|---|---|---|
| 생성자 | 초기화 매개변수 | 필수 의존성 | init(service: ServiceProtocol) |
| 속성 | 클래스 속성 | 선택적 의존성 | var service: ServiceProtocol? |
| 메서드 | 메서드 매개변수 | 임시 의존성 | func doWork(with service: Service) |
DI 컨테이너 — 의존성의 생성과 생명주기를 관리하는 라이브러리입니다. 컨테이너는 유형 등록(각 추상 유형을 구체적인 구현에 매핑)과 해결된 의존성을 가진 객체를 생성하는 팩토리를 포함합니다. Android에서는 Dagger/Hilt, iOS에서는 Swinject, Needle, Dip이 사용됩니다. 컨테이너는 범위(Scope)를 관리할 수 있습니다: 싱글톤(애플리케이션당 하나의 인스턴스), 기능 범위(화면별) 또는 각 요청 시 새 객체.
Dagger Hilt는 Google의 Dagger를 래핑한 Android용 표준 DI 라이브러리입니다. Hilt는 Dagger를 단순화합니다: 수동 컴포넌트 생성을 제거하고 @HiltAndroidApp, @AndroidEntryPoint 및 @Module을 추가합니다. Hilt는 Android 생명주기와 통합되어 ViewModel, Activity, Fragment, Service, BroadcastReceiver가 어노테이션을 통해 의존성을 받을 수 있습니다. 코드 생성은 컴파일 타임에 이루어지며 Dagger가 컴포넌트 구현을 생성하여 런타임 오버헤드가 없습니다.
// 애플리케이션 클래스
@HiltAndroidApp
class MyApp : Application()
// 모듈 — 의존성 생성 방법을 정의
@Module
@InstallIn(SingletonComponent::class)
object NetworkModule {
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient = OkHttpClient.Builder().build()
@Provides
@Singleton
fun provideApiService(client: OkHttpClient): ApiService {
return Retrofit.Builder()
.baseUrl("https://api.example.com")
.client(client)
.build()
.create(ApiService::class.java)
}
}
// ViewModel이 생성자를 통해 의존성 수신
@HiltViewModel
class MainViewModel @Inject constructor(
private val apiService: ApiService
) : ViewModel() {
private val _state = MutableStateFlow(MainState.Loading)
val state: StateFlow<MainState> = _state.asStateFlow()
fun loadData() {
viewModelScope.launch {
_state.value = MainState.Success(apiService.getData())
}
}
}
// Activity — @AndroidEntryPoint가 DI 활성화
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
private val viewModel: MainViewModel by viewModels()
}
Dagger 컴포넌트 및 범위 — @Singleton(전체 애플리케이션), @ActivityScoped(Activity별), @FragmentScoped(Fragment별), @ViewModelScoped(ViewModel별). 범위 선택이 객체의 수명을 결정합니다. @Singleton — 프로세스당 하나의 인스턴스, OkHttpClient 및 데이터베이스에 적합합니다. @ActivityScoped — Activity가 살아있는 동안 객체가 유지되며 화면 수준 의존성에 사용됩니다. @ViewModelScoped — Hilt 2.45+의 새로운 기능으로, ViewModel이 살아있는 동안 객체가 유지되어 코루틴 범위에 편리합니다.
Swinject는 iOS용 인기 있는 오픈 소스 DI 프레임워크입니다. Swinject는 Container, Assemblies 및 다양한 범위를 제공합니다. Dagger와 달리 Swinject는 런타임에 작동하며 의존성이 코드 생성 없이 동적으로 해결됩니다. 이로 인해 Swinject는 설정이 쉽지만 디버깅이 더 어렵습니다: 해결되지 않은 의존성 오류는 런타임에만 나타납니다. Swinject는 생성자 주입, 속성 주입 및 메서드 주입을 지원합니다.
import Swinject
// Assembly — 등록 그룹
class NetworkAssembly: Assembly {
func assemble(container: Container) {
container.register(NetworkServiceProtocol.self) { _ in
NetworkService()
}.inObjectScope(.container) // singleton
container.register(UserRepositoryProtocol.self) { r in
UserRepository(
networkService: r.resolve(NetworkServiceProtocol.self)!
)
}
}
}
// ViewModel이 생성자 주입을 통해
class ProfileViewModel: ObservableObject {
private let repository: UserRepositoryProtocol
init(repository: UserRepositoryProtocol) {
self.repository = repository
}
@Published var user: User?
func loadUser() {
repository.fetchUser { [weak self] user in
self?.user = user
}
}
}
// AppDelegate 또는 App에서 DI 설정
let assembler = Assembler([NetworkAssembly(), ServiceAssembly()])
let viewModel = assembler.resolver.resolve(ProfileViewModel.self)!
// UIKit ViewController를 위한 속성 주입
container.register(UserViewController.self) { r in
let vc = UserViewController()
vc.viewModel = r.resolve(ProfileViewModel.self)
return vc
}
Swinject 범위 — .transient(매번 새 객체), .container(컨테이너당 싱글톤), .graph(기본값 — 하나의 의존성 그래프 내에서 객체 공유). iOS 애플리케이션의 경우 .container와 .transient로 충분합니다. Swinject는 또한 Assembler를 지원하여 모듈형 아키텍처를 위한 Assembly 그룹화를 제공합니다. 테스트를 위해 Assembly를 MockAssembly로 대체하여 프로덕션 코드 변경 없이 의존성 대체가 가능합니다.
DI vs Service Locator — 두 패턴 모두 의존성 관리 문제를 해결하지만 방식이 다릅니다. DI는 객체에 의존성을 주입합니다. Service Locator는 전역 레지스트리를 제공하며 객체가 스스로 의존성을 요청합니다. DI는 생성자(또는 세터)를 통해 의존성을 명시적으로 선언합니다. Service Locator는 의존성을 숨기며 메서드 내부에서 요청되어 시그니처의 정보성이 낮아집니다. DI는 테스트가 더 쉽습니다: 생성자에 모크를 전달하기만 하면 됩니다. Service Locator는 각 테스트마다 전역 레지스트리 설정이 필요합니다.
| 특성 | 의존성 주입 | Service Locator | 수동 주입 |
|---|---|---|---|
| 의존성 명시성 | 생성자 내 | 메서드 본문에 숨김 | 명시적 |
| 테스트 | 생성자에 모크 | Locator 설정 | 생성자에 모크 |
| 설정 복잡성 | DI 컨테이너 필요 | 전역 레지스트리 | 수동 생성 |
| 런타임 오버헤드 | Dagger — 컴파일 타임 | 런타임 조회 | 없음 |
DI vs 수동 주입 — DI 컨테이너 없이 의존성은 팩토리나 AppDelegate에서 수동으로 생성됩니다. 5-10개 클래스의 경우 수동 주입이 더 간단하며 Dagger나 Swinject를 배울 필요가 없습니다. 50개 이상의 클래스에서는 수동 주입이 문제가 됩니다: 5-6개 매개변수가 있는 생성자, 복잡한 생성 순서, 코드 중복. DI 컨테이너는 이러한 프로세스를 자동화하고 명확한 생명주기 관리를 제공합니다. 컨테이너 없는 수동 주입은 소규모 프로젝트와 프로토타입에 적합합니다.
생성자 주입 — 표준. 필수 의존성에는 항상 생성자 주입을 사용하세요. 이렇게 하면 의존성이 명확해지고 객체가 항상 작동 가능한 상태가 됩니다. 세터 주입은 선택적 의존성에만 사용합니다(예: delegate 또는 listener). 인터페이스 주입은 자체 DI 라이브러리를 작성하는 경우가 아니라면 사용하지 마세요. 생성자 주입은 객체가 유효한 상태로 생성됨을 보장하는 유일한 방법입니다.
하나의 클래스 — 하나의 책임. 클래스 생성자에 5개 이상의 매개변수가 필요한 경우, 클래스가 단일 책임 원칙을 위반하고 있을 가능성이 높습니다. 더 적은 의존성을 가진 여러 클래스로 분할하세요. 징후: 6개의 다른 서비스를 가진 ServiceManager 클래스를 작성하고 있다면 God Object 안티패턴입니다. 비즈니스 로직을 Use Cases(Interactors)로 추출하고 각각 1-2개의 의존성을 갖도록 하세요.
// ❌ 나쁨: 6개 의존성 — God Object
class ProfileViewModel @Inject constructor(
private val api: ApiService,
private val db: Database,
private val analytics: Analytics,
private val prefs: Preferences,
private val location: LocationProvider,
private val notification: NotificationManager
)
// ✅ 좋음: 1-2개 의존성을 가진 Use Cases
class ProfileViewModel @Inject constructor(
private val loadProfileUseCase: LoadProfileUseCase,
private val trackAnalyticsUseCase: TrackAnalyticsUseCase
)
범위와 생명주기 — 각 의존성에 적합한 범위를 선택하세요. 싱글톤: OkHttpClient, 데이터베이스, SharedPreferences. 기능 범위: 리포지토리, Use Cases(상태가 없는 경우). Transient: Value Objects, DateFormatter, 파서. 범위 지정 오류는 일반적인 문제입니다: 화면 상태를 저장하는 싱글톤은 메모리 누수를 유발합니다. Android에서는 Hilt의 @ActivityScoped가 이를 해결하고, Swinject에서는 .container를 주의해서 사용하세요.
자주 묻는 질문
new는 클래스 간에 강한 결합을 만듭니다 — 코드를 변경하지 않고 구현을 교체할 수 없습니다. 테스트가 어려워집니다: 실제 서비스 대신 모크를 주입할 수 없습니다. SRP가 위반됩니다: 클래스가 비즈니스 로직과 의존성 생성 모두에 책임을 갖습니다. DI는 외부에서 의존성을 주입하고 추상화에 대해 작업함으로써 이러한 문제를 해결합니다.
Dagger Hilt는 Google의 표준으로, 코드 생성이 있는 컴파일 타임 DI, 더 나은 성능 및 Jetpack 통합을 제공합니다. Koin은 런타임 DI로 설정이 쉽지만 느리고 런타임 오류가 발생합니다. 프로덕션 프로젝트에는 Hilt를 선택하세요. Koin은 프로토타입과 소규모 애플리케이션에 적합합니다.
아니요. iOS에서는 Swinject(런타임, 인기), Needle(Uber의 컴파일 타임), Dip(경량), Weaver(Sourcery 기반)를 사용할 수 있습니다. Apple은 내장 DI 컨테이너를 제공하지 않지만 init을 통한 수동 주입이 표준 관행입니다. SwiftUI의 경우 외부 라이브러리 없이 Environment 또는 @StateObject를 통한 수동 DI로 충분한 경우가 많습니다.
네. 생성자를 통한 수동 주입은 프레임워크 없는 DI입니다. Service Locator는 프레임워크 없는 대안입니다. 팩토리와 Factory Method도 DI의 형태입니다. 프레임워크(Dagger, Swinject)는 일상적인 등록과 의존성 해결을 자동화하지만, 10-20개 클래스의 경우 수동 DI로 충분합니다.
DI는 제어의 역전(IoC) 원칙을 구현하는 기술(템플릿)입니다. GoF 패턴과 달리 DI는 엄격한 3-4 클래스 구조가 없습니다. DI는 의존성을 구성하는 방법이지 디자인 패턴이 아닙니다. DI 컨테이너(Dagger, Swinject)는 이 기술을 자동화하는 프레임워크입니다.
요약
턴키 방식의 모바일 애플리케이션을 개발해 드립니다
IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.