
특정 클래스를 구현할 때, 다른 클래스의 기능도 필요하다면 어떻게 해야 할까?
- 라이브러리와 프레임워크 - 게시글을 읽고 오는 것을 권장드립니다.
- 제어의 역전(Inversion of Control, IoC)
- 조합(Composition)
- Hilt와 Koin의 기본 사용법
한 프로그램에는 여러 기능들의 수행이 필요할 것입니다. 실제로 우리가 사용하는 다수의 프로그램에는 사용자 데이터를 관리하는 기능, 네트워크 요청을 처리하는 기능, 데이터를 저장하는 기능 등이 함께 존재하곤 합니다.
이러한 기능들을 한 곳에서 거대한(monolithic) 코드로 작성한다면, 유지보수성이 떨어지고 재사용이 어려워질 것입니다. 따라서 일반적으로 개발을 할 때는 기능 별로 나누어 코드를 작성합니다.
이것과 관련된 개념이 컴포넌트입니다. 컴포넌트가 될 수 있는 것들로 클래스, 라이브러리, 플러그인 등이 있습니다.
클래스(Class) : 특정 기능을 수행하는 독립적인 코드 블록라이브러리(Library) : 여러 컴포넌트가 포함된 패키지 형태의 재사용 가능한 코드 집합플러그인(Plugin) : 애플리케이션의 기능을 확장할 수 있도록 독립적으로 배포되는 소프트웨어 구성 요소이 요소들 중 클래스를 활용하여, 아래의 컴포넌트 정의를 파헤쳐보겠습니다.
특정 기능을 수행하며, 여러 애플리케이션에서 변경 없이 재사용될 수 있도록 설계된 독립적인 모듈
컴포넌트를 개발한 사람과 그것을 사용하는 애플리케이션의 개발자가 다를 수 있음을 의미합니다.
애플리케이션에서 로깅 기능을 구현하기 위해 Logger 클래스를 만들었습니다.
class Logger {
fun log(message: String) {
println("[LOG] $message")
}
}
Logger 클래스를 인스턴스화하고 log 메서드를 호출함으로써 로그를 남길 수 있습니다. Logger를 활용하는 모든 애플리케이션 개발 과정에서, 동일한 방식으로 로그를 남길 수 있습니다.
class UserService {
private val logger = Logger()
fun createUser(name: String) {
logger.log("User $name created.")
}
}
사용자는 Logger의 원본 소스 코드를 수정하지 않고 그대로 재사용해야 합니다. 단, Logger 작성자가 허용하는 방식으로 확장은 가능합니다.
원본 소스 코드를 수정하지 않고도, 상속(Inheritance)과 조합(Composition) 등을 활용해 기능을 확장할 수 있습니다.
예를 들어, 아래와 같이 Logger를 확장하여 파일에도 로그를 남기도록 변경할 수 있습니다.
class FileLogger(private val filePath: String) : Logger() {
override fun log(message: String) {
super.log(message) // 콘솔에도 출력
File(filePath).appendText("[LOG] $message\n")
}
}
의존하다=변경에 영향을 받는다
앞선 설명 중 Logger 컴포넌트를 재사용한 코드를 다시 살펴보도록 하겠습니다.
class UserService {
// 문제점?
private val logger = Logger()
fun createUser(name: String) {
logger.log("User $name created.")
}
}
UserService를 효율적으로 구현하기 위해, 이미 만들어져 있는 Logger를 재사용하였습니다.
즉 UserService는 원활한 동작을 위해 Logger에 의존하고 있던 것입니다. 만약 이 상태에서 Logger에 이상이 생기면, UserService는 정상적으로 동작하지 않을 것입니다.
의존을 통해 컴포넌트의 재사용성이라는 이득을 보았으나, 이 코드에는 문제점이 존재합니다.
현재 방식대로 UserService 내부에서 특정 의존성을 직접 생성하면 강한 결합(Coupling)으로 인한 테스팅 및 유지보수의 어려움을 겪을 수 있습니다.
만약 다른 방식의 로깅 시스템을 도입해야 한다면, 기존 UserService 코드를 직접 수정해야 하는 불편함이 생길 것입니다. 단위 테스트를 수행할 때도 테스트의 독립성과 유연성이 떨어지게 됩니다. Logger의 실제 구현이 항상 포함되어, 의도적으로 Logger의 동작을 제어하기 어려워지기 때문입니다.
따라서 컴포넌트 제작 시 의존성이 필요한 상황에서는 다른 방법을 고려해야 합니다.
필요한 컴포넌트를 내부에서 직접 생성하지 말고, 외부로부터 제공 받아보자!
앞서 살펴본 문제를 해결하기 위해, 필요한 의존성을 직접 생성했던 기존의 방식을 생성자를 통해 공급받도록 하는 방식으로 변경해보겠습니다. 다른 방식으로 setter 메서드를 활용한 주입과 인터페이스를 활용한 주입 등이 있는데, 여기서는 설명하지 않겠습니다.
class UserService(private val logger: Logger) {
fun createUser(name: String) {
logger.log("User $name created.")
}
}
fun main() {
val logger = Logger() // 수동으로 Logger 객체 생성
val userService = UserService(logger) // UserService에 Logger 객체 주입
userService.createUser("Alice")
}
전의 코드와는 달리 객체가 자신의 의존성을 직접 생성하거나 관리하지 않게 되었습니다.
즉, 의존성 생성 및 관리 등의 제어권이 외부 시스템으로 넘어가게 된 것입니다.
따라서, DI는 IoC를 구현하는 방식 중 하나로 볼 수 있습니다. 여기서 제어(의존성 관리)는 위의 예시처럼 직접 수동으로 처리(Pure DI, Manual DI)할 수도 있고, 외부 프레임워크(DI 프레임워크)가 대신 처리하도록 할 수도 있습니다.
생성자 주입을 통해, UserService의 강한 결합을 조금이나마 개선하였습니다. 그러나 클래스의 재사용성, 테스트 가능성, 유연성을 더 향상시키기 위해 더 고려해봐야 할 것이 있습니다.
만약
UserService측에서 다른 종류의Logger를 필요로 한다 가정했을 때, 아래와 같이 코드를 작성하면 적절할까?
class UserService(
private val logger2: Logger2,
) {
fun createUser(name: String) {
logger2.log("logloglog $name")
}
}
이 때 생각해볼 수 있는 것이 SOLID 원칙 중 의존 역전 원칙(DIP)입니다.
- 상위 모듈은 하위 모듈에 의존하지 않아야 하고, 추상화에 의존해야 한다.
- 추상화가 세부 사항에 의존하지 않아야 하고, 세부사항이 추상화에 의존해야 한다.
참고)
상위 모듈:가져다 쓰는 쪽=하위 모듈:가져다 쓰이는 쪽
- 가져다 쓰는 쪽(호출자) : 다른 객체나 모듈의 메서드나 기능을 호출.
- 핵심 로직을 담당하는 중요한 쪽으로, 변경 사항이 적어야 함
- 예시의
UserService
- 가져다 쓰이는 쪽(수신자) : 호출자가 보낸 메시지를 받음.
- 변경 사항이 많은 덜 중요한 쪽
- 예시의
Logger- 단순 로깅 수행, 보조적
현재 코드는 상위 모듈인 UserService가 하위 모듈인 Logger에 직접적으로 의존하고 있습니다. 이 상태에서 Logger의 구체적인 구현체를 변경하거나 여러 종류의 Logger를 사용하게 된다면, 핵심적인 역할을 수행하며 변경 사항이 적어야 할 상위 모듈이 하위 모듈의 변경에 의해 영향을 받는 문제가 발생할 것입니다. 로깅 방식은 상대적으로 변경되는 경우가 잦을텐데, 변경 시마다 핵심 로직인 UserService의 코드까지 계속 변경하는 것은 부담으로 다가올 것입니다.
해결 방안으로 추상화를 활용해볼 수 있습니다. 구체적인 구현체를 바꾸더라도 상위 모듈에는 영향을 주지 않도록 하는 것입니다.
interface Logger {
fun log(message: String)
}
class DefaultLogger : Logger {
override fun log(message: String) {
println("DefaultLogger: $message")
}
}
class ConsoleLogger : Logger {
override fun log(message: String) {
println("ConsoleLogger: $message")
}
}
class UserService(private val logger: Logger) {
fun createUser(name: String) {
logger.log("User $name created.")
}
}
fun main() {
val logger = ConsoleLogger() // 하위 모듈인 수신자
val userService = UserService(logger) // 상위 모듈인 호출자
userService.createUser("Alice") // UserService는 ConsoleLogger의 log 메서드를 호출
}
이렇게 DIP를 적용하여, 상위 모듈과 하위 모듈이 추상화된 Logger 타입에 의존하고, 세부 구현 사항에 의존하지 않도록 할 수 있습니다. 한 차원 더 재사용성, 테스트 가능성, 유연성이 향상되었습니다.

DIP와 DI를 다시 정리해보겠습니다. 두 개념은 약칭만 비슷하지, 서로 다른 의미를 가지고 있습니다.
DIP (의존성 역전 원칙)
고수준 모듈은 저수준 모듈에 직접 의존해서는 안 되고, 추상화에 의존해야 한다는 원칙
DI (의존성 주입)
객체의 의존성을 외부에서 주입하는 기법
두 개념은 원칙과 기법이라는 차이가 존재합니다. 원활한 이해를 위해, DIP와 DI 간 관계를 규정하는 문장을 하나 살펴보도록 하겠습니다.
DI는 DIP를 실현하기 위한 수단 중 하나
??? : 그럼 DI를 실현하기 위해 DIP를 따른다고 보는 것은 안 되는 거야??
설계 원칙 -> 지키고자 하는 약속DI : 구현 기법 -> 약속을 준수하기 위한 방법
DI를 사용하면 DIP를 준수할 수 있으나, 사실 DI 없이도 DIP를 따를 수 있음.
(추상 팩토리 패턴, 서비스 로케이터 패턴 등)
DI를 한다고 해서 DIP를 따르는 것이 아니다.
(구체적인 타입의 객체를 주입하는 것은 DIP를 충족하지 못함)
참고)
구현 기법을 통해설계 원칙을 따르게 된다.개발에서
설계 원칙은 소프트웨어 개발의 기본 지침으로서 작용합니다. 그리고구현은설계를 작동하는 코드로 변환하는 행위입니다. 구현을 통해 설계를 실현시키는 만큼, 구현 방식은 설계 원칙을 따르는 과정에서 사용되는 수단이라고 생각해볼 수 있을 것 같습니다.
객체 간의 의존성을 자동으로 해결해보자!
IoC 컨테이너는 애플리케이션에 필요한 객체를 생성하고 생명주기를 관리해주는 역할을 해줍니다.
앞서 DI를 설명하면서, 의존성 주입 과정을 수동으로 처리할 수도 있고, 외부 프레임워크에 자동으로 맡길 수도 있다고 언급하였습니다.
언뜻 봐서는 자동으로 하는 것이 좋아보이는데, 사실 수동으로 의존성을 주입하는 것 역시 장점이 있습니다.
하지만 애플리케이션의 규모가 커지게 된다면, 수동으로 주입해주는 방법에는 불편함이 따를 것입니다. 대표적으로 의존 관계가 깊어지게 되면 한 곳에서 변경 사항이 발생 시 유지보수가 어려워지는 문제가 발생할 수 있습니다.
class Logger {
fun log(message: String) {
println("Log: $message")
}
}
// 계층 하나 추가
class UserRepository(private val logger: Logger) {
fun createUser(name: String) {
logger.log("User $name created.")
}
}
class UserService(private val userRepository: UserRepository) {
fun createUser(name: String) {
logger.log("User $name created.")
}
}
fun main() {
// 의존성 수동 주입
val logger = Logger()
val userRepository = UserRepository(logger)
val userService = UserService(userRepository, logger)
userService.createUser("Kame")
println(userService.getUser(1))
}
따라서 앱의 규모가 커지는 것을 고려하여, 해당 작업을 외부 프레임워크에 맡겨보는 것도 좋을 것입니다. 외부 프레임워크를 사용하면, 개발자는 의존성 관리의 부담을 줄일 수 있기 때문입니다.
이 때 사용할 수 있는 수단이 IoC 컨테이너를 기반으로 한 DI 프레임워크입니다. 제어의 역전을 통해 개발자 대신 의존성 관리를 담당해주는 수단입니다.
여기서 관리 활동에 해당되는 것으로 의존성 주입, 생명주기 관리가 있습니다.
의존성 주입 : 객체가 필요한 다른 객체를 찾아 자동으로 주입
UserService 생성 시, 필요한 UserRepository 인스턴스를 자동으로 찾아 주입생명주기 관리 : 개발자 대신 컨테이너가 객체의 생성과 소멸을 알아서 처리
Logger(), UserRepository(logger), UserService(userRepository)를 직접 호출하면서 객체를 생성하는 과정을 컨테이너가 대신 수행하며, 소멸 역시 컨테이너가 담당따라서 DI 프레임워크를 사용하면 아래와 같이 수동적으로 인스턴스를 만들어 주입해주고 생명주기를 관리하는 작업을 자동으로 진행해주게 될 것입니다.
val logger = Logger()
val userRepository = UserRepository(logger)
val userService = UserService(userRepository)
우리가 사용하는 DI 프레임워크에서는 자동 DI에 특화된 여러 기능들을 제공합니다. 사용법을 살펴보며 어떻게 IoC 컨테이너의 원리를 따르는지 살펴보도록 하겠습니다.
대표적인 것들로, Hilt와 Koin이 있습니다. (이 글에서는 IoC를 만족하는 사용 예시만 간단히 알아보며, 자세한 원리와 사용법은 다른 포스트에서 다루도록 하겠습니다.)
Google이 공식적으로 지원하는 Dagger 기반 DI 프레임워크
Android 컴포넌트의 생명주기를 자동으로 관리하고, 필요한 의존성을 자동으로 주입하는 기능을 제공합니다.
@HiltAndroidApp 어노테이션을 Application 클래스에 추가하여 Hilt를 초기화합니다. Hilt가 의존성 관리와 객체 생명주기를 관리할 수 있도록 앱에 Hilt를 연결하는 과정입니다.
@HiltAndroidApp
class MyApplication : Application()
다른 객체에 제공할 의존성들을 정의하는 Module을 설정합니다. @Module과 @Provides 혹은 @Binds를 사용하여 의존성을 제공할 수 있습니다. 또한, @InstallIn 어노테이션을 활용하여 모듈의 생명주기를 자동으로 관리할 수 있습니다.
@Module
@InstallIn(SingletonComponent::class)
object AppModule {
@Provides
fun provideLogger(): Logger = Logger()
}
@Module: Hilt에 의존성 객체를 제공하는 모듈을 정의@Provides: Hilt에게 특정 객체를 어떻게 생성할지 정의@Binds를 사용할 수도 있음@InstallIn: 의존성을 제공할 컴포넌트를 지정하여, 해당 컴포넌트의 생명주기 관리에 따라 의존성 객체를 자동으로 관리SingletonComponent: 앱 전체 생명주기 동안 하나의 인스턴스를 유지ActivityComponent: Activity 생명주기 동안 객체를 관리자동 주입@Inject를 사용하여 의존성 주입을 자동으로 설정합니다. Hilt는 생성자에 @Inject 어노테이션이 달린 객체를 자동으로 찾아 주입합니다.
class UserRepository @Inject constructor(
private val logger: Logger
) {
fun createUser(name: String) {
logger.log("User $name created.")
}
}
class UserService @Inject constructor(
private val userRepository: UserRepository
) {
fun createUser(name: String) {
userRepository.createUser(name)
}
}
@Inject: Hilt가 Logger와 UserRepository를 자동으로 주입하게 설정@AndroidEntryPoint를 사용하여 Activity나 Fragment에서 의존성을 자동으로 주입받습니다.
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
// Hilt가 자동으로 필드 주입
@Inject lateinit var userService: UserService
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// 의존성 주입 후 사용
userService.createUser("Kame")
}
}
@AndroidEntryPoint: Hilt가 이 Activity에서 의존성을 자동으로 주입하도록 설정@Inject를 사용해 userService 자동 필드 주입실용적인 Kotlin 및 Kotlin Multiplatform 전용 DI 프레임워크
Kotlin DSL을 사용하여 간결하고 직관적인 의존성 주입을 지원
Koin에서는 의존성을 제공할 모듈을 정의해야 합니다. single을 사용하여 객체를 싱글턴으로 관리할 수 있습니다.
val appModule = module {
single { Logger() } // Singleton으로 Logger 객체 제공
single { UserRepository(get()) } // Logger를 주입하여 UserRepository 객체 제공
single { UserService(get()) } // UserRepository를 주입하여 UserService 객체 제공
}
module: Koin의 모듈을 정의하여 제공할 의존성을 설정single: 객체를 하나의 인스턴스로 앱 전체에서 공유(싱글턴)factory: 객체를 요청할 때마다 새로운 인스턴스를 생성scoped: 객체를 특정 범위(예: Activity)에서만 유지하고 그 범위가 끝나면 소멸하도록 설정get()을 사용하여 의존성 주입앱에서 Koin을 초기화하여 의존성 주입을 사용할 준비를 합니다.
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidContext(this@MyApplication)
modules(appModule)
}
}
}
startKoin: Koin을 초기화하고, 앱의 context와 모듈을 설정Activity에서는 by inject() 구문으로 Koin에서 의존성을 주입받을 수 있습니다.
class MainActivity : AppCompatActivity() {
// Koin으로 의존성 주입
private val userService: UserService by inject()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// 의존성 주입 후 사용
userService.createUser("Kame")
}
}
by inject(): Koin이 UserService 객체를 자동으로 주입userService.createUser("Kame")로 의존성 주입 후에 로직을 수행정리) IoC 컨테이너의 특성이 드러나는 두 프레임워크의 활용 방식
- Hilt
- 의존성 주입:
@Inject어노테이션을 통해 의존성 객체를 자동으로 주입- 생명주기 관리:
@InstallIn을 사용하여 특정 안드로이드 컴포넌트 생명주기 동안 의존성을 관리- Koin
- 의존성 주입:
get(),by inject()를 사용하여 의존성을 자동으로 주입- 생명주기 관리:
single,factory,scoped를 통해 객체의 생명주기를 조정
single: 애플리케이션의 전체 생명주기 동안 하나의 인스턴스를 유지factory: 매 호출 시마다 새로운 인스턴스를 생성scoped: 특정 범위 내에서 객체를 유지 (예: 특정 화면 생명주기)
사실 IoC의 원리를 활용하여, 의존성을 해결하는 다른 방식도 존재합니다.
바로 Service Locator 패턴을 활용하는 것입니다.
서비스를 제공하는
중앙 저장소를 만들어, 컴포넌트가 필요로 하는 의존성을직접요청
DI 프레임워크와는 다르게 객체가 의존성을 주입받는 것이 아니라, 필요할 때 Service Locator에서 직접 가져오는 방식입니다. 이를 통해 객체들은 구체적인 의존성 구현을 알 필요 없이 필요한 서비스를 요청할 수 있습니다.
의존성을 등록하고 제공하는 중앙 집중식 레지스트리 역할을 합니다.
object ServiceLocator {
private val dependencies = mutableMapOf<Class<*>, Any>()
fun <T : Any> register(clazz: Class<T>, service: T) {
dependencies[clazz] = service
}
// 서비스 제공
fun <T : Any> resolve(clazz: Class<T>): T {
@Suppress("UNCHECKED_CAST")
return dependencies[clazz] as? T
?: throw IllegalArgumentException("Service not found for ${clazz.name}")
}
}
Service Locator를 활용하여 객체가 필요한 의존성을 직접 가져와 사용합니다.
UserService클래스는 ServiceLocator를 통해 Logger 객체를 조회하여 활용합니다.
이 방식은 UserService가 Logger의 구체적인 구현에 대해 알 필요가 없게 만들어, 상위 모듈과 하위 모듈 간의 결합도를 낮춥니다.
class UserService {
private val logger: Logger = ServiceLocator.resolve(Logger::class.java)
fun createUser(name: String) {
logger.log("User $name created.")
}
}
fun main() {
// 서비스 로케이터에 Logger 등록
ServiceLocator.register(Logger::class.java, ConsoleLogger())
val userService = UserService()
userService.createUser("Alice")
}
Service Locator는 DI 프레임워크에 비해서 간단하고 직관적이라는 장점이 있습니다.
하지만 코드에서 보이듯, ServiceLocator라는 또 다른 의존성이 생기기에 완전한 분리는 이루지 못하게 됩니다. 이로 인한 몇 가지 한계점이 있습니다.
Service Locator를 사용하면 객체가 어떤 의존성을 사용하는지 코드만으로 명확히 알기 어렵습니다. 의존성 해결이 ServiceLocator.resolve()를 통해 이루어지므로, 특정 객체가 어떤 서비스와 연결되는지를 컴파일 타임이 아닌 런타임에서만 확인할 수 있습니다.
Service Locator는 전역적으로 공유 상태를 가지므로, 단위 테스트에서 서비스를 교체하기 어려운 문제가 발생할 수 있습니다 (예시 코드에서 싱글턴으로 생성된 ServiceLocator 참고). 특히, 테스트마다 특정 의존성을 설정해야 하는 경우, 독립적인 테스트 환경을 구축하기 어렵습니다.
의존성이 잘못 등록되거나 누락된 경우, 컴파일 타임이 아닌 런타임에서 오류가 발생합니다. 이러한 특성 때문에, 코드 실행 전에는 의존성 문제를 쉽게 감지할 수 없다는 단점이 있습니다.
참고)
앞서 DI 프레임워크로 정의했던Koin은 특수적인 케이스입니다.
사실 구현 상으로는 서비스 로케이터의 특성도 포함하고 있기 때문입니다.런타임에 필요한 의존성을 조회하고 주입한다는 특성을 가져, 의존성 해결 과정에서 문제가 생기면 런타임에서 오류가 발생합니다.
지금까지 알아본 내용들의 키워드를 정리해보겠습니다.
https://stackoverflow.com/questions/1557781/whats-the-difference-between-the-dependency-injection-and-service-locator-patte
https://martinfowler.com/articles/injection.html
https://www.scholarhat.com/tutorial/designpatterns/what-is-ioc-container-or-di-container