클린 아키텍처를 공부하면서 의존성 주입, 의존성 역전 원칙 등등 의존성에 관련된 말이 많이 나왔는데요
의존성 주입이란 무엇인지 처음부터 차근차근 정리해보려고 합니다
🪄 의존성 주입(Dependency Injection)이란?
객체는 다른 객체와 상호작용을 하며 필요한 객체에 의존을 하게 됩니다
예를 들어 Car 클래스가 Engine 클래스를 참조하고 있습니다
이때, Car 클래스는 Engine 클래스에 의존하고 있다고 말할 수 있는데요
그리고 이렇게 클래스를 필요로 하는 것을 의존성(Dependency)이라고 합니다
그런데 이러한 의존성이 많아지면 코드 간 결합도가 높아져 유지보수성이 떨어지고 객체를 재사용하기 어려워집니다
그렇기 때문에 객체 간의 결합도를 낮추기 위해 객체를 생성하는 시점에 필요한 의존성 객체를 외부에서 전달 받는 '의존성 주입'이 필요합니다
즉, 의존성 주입이란 클래스와 클래스 간에 관계를 맺을 때, 내부에서 직접 생성하는 것이 아닌 외부에서 주입을 함으로써 관계를 맺게 만드는 것을 의미합니다
그런데 결합도가 높고 낮고.. 유지보수성이 떨어지고.. 🤯
말로는 와닿지 않기 때문에 코드와 함께 보도록 하겠습니다
의존성 주입이 없는 경우
class Engine {
fun start() {
println("Engine started")
}
}
class Car {
private val engine = Engine() // 직접 Engine을 생성 (의존성 주입 없음)
fun drive() {
engine.start()
println("Car is driving")
}
}
fun main() {
val car = Car()
car.drive() // "Engine started"와 "Car is driving" 출력
}
위 코드에서 Car 클래스 내부에서 직접 Engine 객체를 만들어서 사용하고 관리합니다
이때 Car는 Engine 클래스와 강하게 결합됩니다.
예를 들어, Engine을 다른 종류의 엔진으로 변경할 때 Car 클래스의 코드도 수정해야 합니다
class GasolineEngine {
fun start() {
println("Gasoline Engine started")
}
}
class Car {
private val engine = GasolineEngine() // 엔진 코드 변경
fun drive() {
engine.start()
println("Car is driving")
}
}
fun main() {
val car = Car()
car.drive()
}
단순한 코드에선 한 줄 수정이지만 만약 100대의 차를 엔진을 수정해야 한다면 대공사가 될 것입니다
뿐만 아니라 테스트 시 Engine의 실제 동작을 제어하기 어렵습니다
의존성을 주입한 경우
class Engine {
fun start() {
println("Engine started")
}
}
class Car(private val engine: Engine) { // 생성자 주입
fun drive() {
engine.start()
println("Car is driving")
}
}
fun main() {
val engine = Engine() // 외부에서 Engine 생성
val car = Car(engine) // Car에 Engine 주입
car.drive() // "Engine started"와 "Car is driving" 출력
}
의존성 주입을 사용하여 Car 클래스 내부에서 직접 Engine 객체를 만들지 않고 외부에서 주입받아 사용하게 됩니다
이렇게 되면 Car 클래스가 Engine 클래스의 구체적인 구현에 의존하지 않고 결합도가 낮아지게 됩니다
예를 들어, 의존성을 주입한 경우에 GasolineEngine과 EletricEngine이 필요하게 되었습니다
// Engine 인터페이스 정의
interface Engine {
fun start()
}
class GasolineEngine : Engine {
override fun start() {
println("Gasoline Engine started")
}
}
class ElectricEngine : Engine {
override fun start() {
println("Electric Engine started")
}
}
class Car(private val engine: Engine) { // 생성자로 Engine을 주입받음
fun drive() {
engine.start()
println("Car is driving")
}
}
fun main() {
val gasolineEngine = GasolineEngine()
val carWithGasolineEngine = Car(gasolineEngine) // GasolineEngine 주입
carWithGasolineEngine.drive()
// 출력:
// "Gasoline Engine started"
// "Car is driving"
val electricEngine = ElectricEngine()
val carWithElectricEngine = Car(electricEngine) // ElectricEngine 주입
carWithElectricEngine.drive()
// 출력:
// "Electric Engine started"
// "Car is driving"
}
우선 Engine이라는 interface를 만들어 Car가 구체적인 엔진 타입에 의존하지 않도록 합니다
그리고 Engine 인터페이스를 구현하는 다양한 Engine 클래스인 GasolineEngine 클래스와 EletricEngine 클래스를 생성합니다
이때 Car 클래스는 생성자로 Engine을 주입받습니다
이제 Car는 어떤 Engine을 사용할지 알 필요가 없으며, 외부에서 필요한 Engine을 주입해줄 수 있습니다
-> Engine이 수정되어도 Car 클래스는 영향을 받지 않습니다 !
이제 main 함수에서 Car 클래스의 객체를 만들 때 수정 없이 원하는 Engine 구현체를 외부에서 주입해줄 수 있습니다
-> 외부에서 필요한 엔진 타입을 선택해 주입할 수 있으므로 코드 유연성이 높아지고, 테스트 시에도 다양한 엔진 타입을 쉽게 대체할 수 있습니다.
의존성 주입의 장점
- 코드 유연성 증가
- 의존성 주입을 사용하면 클래스가 구체적인 구현체에 의존하지 않고, 인터페이스나 추상 클래스에 의존하게 됩니다.
이를 통해 구체적인 구현체를 쉽게 교체할 수 있어 코드가 유연해집니다. - ex) Car 클래스가 GasolineEngine 대신 ElectricEngine을 사용하도록 변경해야 할 때, 직접 코드를 수정하지 않고도 새로운 엔진을 주입하여 사용할 수 있습니다.
- 의존성 주입을 사용하면 클래스가 구체적인 구현체에 의존하지 않고, 인터페이스나 추상 클래스에 의존하게 됩니다.
- 재사용성 향상
- 의존성 주입을 통해 클래스를 독립적으로 설계할 수 있으므로, 여러 곳에서 쉽게 재사용할 수 있습니다.
- ex) 같은 Car 클래스를 GasolineEngine뿐만 아니라 ElectricEngine과도 함께 사용할 수 있어, 하나의 클래스가 다양한 상황에 재사용됩니다.
- 테스트 용이성
- 의존성 주입은 테스트를 더 쉽게 만듭니다. 필요한 의존성을 외부에서 주입할 수 있기 때문에, 실제 구현체 대신 모의 객체(Mock), 테스트 객체를 주입하여 테스트할 수 있습니다. 이로 인해 테스트 환경을 제어할 수 있고, 테스트 시 외부 시스템에 대한 의존성을 제거할 수 있어 빠르고 일관된 테스트가 가능합니다.
- ex) Car 클래스가 Engine에 의존할 때, 실제 Engine 구현체를 사용하지 않고 MockEngine을 주입하여 테스트할 수 있습니다. 이를 통해 Engine의 외부 요인 없이 Car의 동작만을 독립적으로 검증할 수 있습니다.
- 결합도 낮추기
- 의존성 주입은 클래스 간 결합도를 낮춰 코드의 유지보수가 용이합니다. 클래스 내부에서 다른 클래스의 객체를 직접 생성하는 방식보다 외부에서 필요한 의존성을 주입받는 방식이 더 낮은 결합도를 유지할 수 있습니다. 결합도가 낮아지면 코드 변경이 필요한 경우에도 영향을 최소화할 수 있습니다.
⚙️ 수동 의존성 주입
이렇게 의존성 주입이 무엇인지, 의존성 주입을 왜 해야하는지에 대해 알아보았는데요
안드로이드에서는 이 의존성을 수동으로 주입하는 경우가 있고,
DI 라이브러리인 Dagger, Hilt 등을 사용하여 자동으로 주입도 가능합니다
그런데 수동 의존성 주입을 하면 왜 뭐가 불편한지를 알아야 라이브러리를 사용할 때의 편안함도 느낄 수 있겠죠? 🤔
그렇기 때문에 수동으로 주입하는 경우를 먼저 알아보고 라이브러리를 통한 관리는 다음 포스팅에 작성해두도록 하겠습니다
수동으로 의존성을 주입하는 방법은 크게 2가지가 있습니다
- 생성자 주입 (Constructor Injection) : 생성자를 통해 의존 객체를 전달 받음
- 세터 주입 (Setter Injection) : 세터 메서드를 통해 의존 객체를 전달 받음
생성자 주입 (Constructor Injection)
class Engine {
fun start() {
println("Engine started")
}
}
class Car(private val engine: Engine) { // 생성자 주입
fun drive() {
engine.start()
println("Car is driving")
}
}
fun main() {
val engine = Engine() // 외부에서 엔진 객체 생성
val car = Car(engine) // Car에 엔진 주입
car.drive()
}
생성자 주입은 클래스의 생성자를 통해 필요한 의존성을 전달하는 방식입니다
Car 클래스는 Engine 클래스에 의존하지만 직접 Engine 객체를 생성하지 않고 생성자 파라미터로 의존 객체를 전달받습니다
이렇게 의존 객체를 생성자로 받아 Engine 클래스에 의존하게 됩니다
생성자 주입을 사용할 때는 종속성이 필요한 시점에 미리 생성할 수 없는 경우가 있습니다
이럴 때는 lazy를 사용하거나 수명 관리를 직접 해주어야 합니다
세터 주입 (Setter Injection)
class Engine {
fun start() {
println("Engine started")
}
}
class Car {
private var engine: Engine? = null
fun setEngine(engine: Engine) { // 의존성 세터 주입
this.engine = engine
}
fun drive() {
engine?.start()
println("Car is driving")
}
}
fun main() {
val engine = Engine() // 엔진 객체 생성
val car = Car()
car.setEngine(engine) // 세터를 통해 엔진 주입
car.drive()
}
세터 주입은 객체 생성 후, 메서드를 통해 의존성을 설정하는 방식입니다
필요할 때 의존성을 선택적으로 주입할 수 있다는 장점이 있지만 주입이 누락될 수 있다는 단점이 있습니다
수동 의존성 주입의 단점
- 코드 복잡성 증가
- 수동으로 의존성을 주입하려면 개발자가 매번 필요한 객체를 생성하고 각 클래스에 전달하는 코드를 작성해야 합니다
- 의존성을 일일이 생성하고 전달하는 코드는 반복적이고 특히 프로젝트가 커질수록 코드 중복이 늘어나고 관리가 어려워집니다
- 유연성이 떨어짐
- 수동으로 의존성을 주입하면 클래스 내부에서 구체적인 구현을 변경하기가 어렵습니다
- ex) MockEngine으로 교체하여 테스트하고 싶다면 모든 관련 코드를 수정해야 합니다
- 복잡한 수명주기와 메모리 관리
- Android와 같은 플랫폼에서는 Activity와 Fragment 등의 수명 주기를 맞춰 객체를 생성하고 해제해야 하는데 수동으로 이를 관리하기 복잡해집니다
- 아래 코드에서 Activity가 파괴될 때 myRepository와 관련된 리소스를 cleanup() 메서드로 해제해야 하는 상황이 생길 수 있습니다
만약 cleanup() 호출을 빠뜨리거나 관리가 제대로 이루어지지 않으면 메모리 누수가 발생할 수 있습니다
즉, Activity가 종료되었음에도 불구하고 myRepository가 메모리에 남아 시스템 자원을 낭비하는 결과를 초래할 수 있습니다
class MyActivity : AppCompatActivity() {
private val myRepository = MyRepository()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// myRepository를 사용하여 데이터 작업 수행
}
override fun onDestroy() {
super.onDestroy()
// myRepository의 메모리를 수동으로 해제해주어야 하는 경우
myRepository.cleanup()
}
}
🍀 마무리
Hilt를 사용해보며 의존성 주입 개념이 뭔가 잘 와닿지 않는 기분이라 처음부터 차근차근 정리중인데 역시 왜 필요한지부터 알아야 더 잘 이해가 되는 것 같네요
수동 의존성 관리 덕분에 Hilt의 필요성과 소중함을 느끼게 된 것 같습니다 :)
'Android' 카테고리의 다른 글
[Android] 프로젝트에 Hilt로 의존성 주입하기 (다온길 마이그레이션) (0) | 2024.10.05 |
---|---|
[Android] Dagger와 Hilt로 자동 의존성 주입 이해하기 (0) | 2024.10.03 |
구글 플레이스토어 첫 앱 출시기 <안뜰> (1) | 2024.09.20 |
[Android] 비즈니스 로직이란? (0) | 2024.09.12 |
[Android] 앱 배포하기04_ AAB 파일 만들기 (0) | 2024.09.04 |