Dependency Inject (DI/의존성 주입)
의존성 주입이란 객체 간의 의존성을 관리하는 디자인 패턴으로, 객체의 생성과 사용의 관심을 분리하는 것이 주된 목적이다.
객체가 필요로 하는 의존성을 외부에서 주입하여 생성하게 되며
코드의 재사용성이 향상 되고, 테스트 용이성은 높이며, 코드의 유지 관리가 용이해진다.
- 코드 재사용성 향상: 컴포넌트 간의 결합도가 낮아지면, 각 컴포넌트를 독립적으로 재사용 할 수 있다.
- 테스트 용이성: 테스트 시에 실제 객체 대신 모의 객체를 주입할 수 있어, 테스트를 보다 쉽게 수행 할 수 있다.
- 코드 유지 관리 용이성: 의존성이 명확하게 분리되어 있으면, 코드를 이해하고 수정하기 쉬워진다.
예를 들어 객체 A가 객체 B에 의존하고 있을 때, 객체 B를 A의 생성자 또는 메서드에 인자로 전달하여 A가 B의 *인스턴스를 생성하지 않도록 한다. 이로 인해 객체 A는 객체 B의 구현에 의존하지 않고, 대신 B의 인터페이스에만 의존하게 된다.
* 인스턴스: 일반적으로 실행 중인 임의의 프로세스, 클래스의 현재 생성된 오브젝트
또한 다음과 같이 SOLID 원칙 중 D에 해당하는 의존 역전 원칙(Dependency Inversion Principle, DIP)을 지켜 만들어야 한다.
- 상위 모듈은 하위 모듈에서 어떤 것도 가져오지 않아야 한다. (상위 모듈과 하위 모듈 모두 추상화에 의존)
- 추상화는 세부 사항에 의존해서는 안된다. (세부사항이 추상화에 의존)
DI의 단점
하지만 DI를 구현하게 되면 모듈들이 분리되므로 클래스가 늘어나 복잡성이 증가되고, 약간의 런타임 패널티가 발생할 수 있다는 단점이 존재한다.
1. 클래스 수 및 복잡성 증가
- DI 컨테이너와 구성 요소 증가: DI를 구현하기 위해서는 보통 의존성들을 관리하는 컨테이너(예: Spring의 ApplicationContext, Dagger, Hilt 등)를 사용하게 된다. 이 컨테이너는 객체의 생성과 의존성 주입을 자동으로 관리하지만, 이를 위해 각 객체의 의존성을 명시적으로 선언하고 관리하는 구성 파일이나 설정 클래스가 증가하게 되어 코드의 복잡도가 증가한다.
- 설정 클래스와 컴포넌트 분리: 의존성 주입을 위해 각 클래스의 의존 관계를 선언하는 설정 파일이나 인터페이스가 추가되므로, 코드의 구조가 더 세분화되고 각 클래스가 무엇을 하는지 파악하기 어려워질 수 있다.
2. 런타임 패널티 (Performance Overhead)
DI는 객체를 런타임에 생성하고, 필요한 의존성을 주입하는 방법으로 동작한다. 이 과정에서 추가적인 계산이 필요하여 실행 시간이 증가할 수 있다.
- 객체 생성 및 주입 과정: DI 컨테이너는 객체를 생성할 때 각 클래스의 의존성을 주입하기 위해 추가적인 메타데이터를 처리해야 하므로, 객체의 초기화 시간이 길어질 수 있다. 특히 의존성 트리가 복잡하거나, 객체가 많을수록 이 과정에서 소요되는 시간이 늘어날 수 있있다.
- 리플렉션(reflection) 사용: DI 프레임워크에서는 객체의 타입을 동적으로 파악하거나 의존성을 주입할 때 리플렉션을 사용할 수 있다. 리플렉션은 컴파일 시간에 정보가 아닌 런타임에 객체의 메타데이터를 확인하므로 성능 저하를 일으킬 수 있다.
- 프록시 객체 생성: DI 프레임워크는 종종 프록시(proxy) 패턴을 사용하여 객체의 메서드를 대신 호출하게 만들 수 있다. 이 경우 메서드 호출이 실제 객체를 거치지 않고 프록시 객체를 통해 처리되므로 추가적인 메서드 호출 오버헤드가 발생할 수 있다.
3. 디버깅과 테스트의 어려움
- DI를 사용하면 객체가 자동으로 주입되므로, 오히려 의존성 관계를 추적하거나 문제를 파악하는 데 어려움이 생길 수 있다. DI를 통해 객체들이 서로 의존하는 방식은 런타임에 결정되므로, 코드 흐름을 직관적으로 이해하거나 디버깅하기가 어려울 수 있다.
- 테스트의 경우, 객체가 의존하는 다른 객체들이 주입되어야 하기 때문에 테스트를 위한 목(mock) 객체를 만들어야 할 필요가 있다. 복잡한 의존성 그래프가 있을 경우, 테스트 환경을 설정하는 데 시간이 더 걸릴 수 있다.
이처럼 단점을 방지하기 위해서는 DI를 적절히 사용하려면 적절한 설계가 필요하고, 각 객체의 역할과 관계를 신중하게 관리해야 한다.
[참고] 리플렉션 (reflection)
리플렉션은 프로그램이 실행 중에 구체적인 클래스 타입을 알지 못하더라도 객체의 정보를 얻거나 수정할 수 있는 기능을 말한다.
런타임에 객체의 메타데이터(클래스, 메서드, 필드, 선언된 어노테이션 등)에 접근할 수 있으며, 이는 동적으로 클래스의 속성과 메서드를 조회하거나 조작할 수 있게 해준다. (많은 언어에서 제공되지만, 특히 Java와 Kotlin에서 많이 사용된다.)
주로 프레임워크에서 의존성 주입, ORM(Object Relational Mapping) 라이브러리, 테스트 도구 등에서 사용된다.
리플렉션은 정적 호출에 비해 느리며, 메모리 사용량이 많을 수 있다. 또한 런타임에 각종 객체를 생성하거나 이 과정에서 오류가 발생할 수 있으므로 정적 타입 체크의 이점을 잃게 된다.
ex)
Kotlin 리플렉션에서는 declaredMemberProperties로 필드를 조회할 수 있으며, (메서드는 declaredFunctions로 조회)
isAccessible = true로 설정하여 private 필드에도 접근 가능하다.
import kotlin.reflect.full.declaredMemberProperties
import kotlin.reflect.jvm.isAccessible
class MyClass(private var name: String)
fun main() {
val myObject = MyClass("initial value")
val property = MyClass::class.declaredMemberProperties.find { it.name == "name" }
property?.isAccessible = true
println("필드 값: ${property?.get(myObject)}")
// 필드 값 수정
if (property is kotlin.reflect.KMutableProperty<*>) {
property.setter.call(myObject, "new value")
}
println("수정된 필드 값: ${property?.get(myObject)}")
}
해당 방법을 이용하면 private으로 설정된 함수도 테스트가 가능해진다!
하지만 리플렉션은 일반적으로 private 필드나 메서드에 접근할 수 있도록 설계된 것이 아니기 때문에, 너무 남발하면 코드의 안전성과 가독성을 떨어뜨릴 위험이 있으므로 이 경우 가능하면 public 함수나 다른 의존성 주입 방식으로 구현하여야 한다.
의존성 주입 구현
의존성 주입은 다음과 같은 세 가지 방식으로 구현할 수 있다.
1. 생성자 주입
객체가 생성될 때 필요한 의존성을 생성자의 매개변수로 전달하는 방식으로, 객체가 생성되는 시점에 모든 의존성을 갖추게 되므로 객체의 상태를 안정적으로 유지할 수 있다.
하지만 이 경우 선언 후 객체를 변경하기 힘드므로 테스트가 어려울 수 있고, 서로가 서로를 참조하는 순환 의존성이 발생할 수 있으므로 주의가 필요하다.
// 의존성 클래스
class Dependency {
fun doSomething() = "Doing something"
}
// 생성자 주입을 사용하는 클래스
class MyClass(private val dependency: Dependency) {
fun performAction(): String {
return dependency.doSomething()
}
}
fun main() {
// 객체 생성 시 의존성 주입
val dependency = Dependency()
val myClass = MyClass(dependency)
// MyClass 사용
println(myClass.performAction()) // Output: Doing something
}
[참고] 순환 의존성
https://while1.tistory.com/249#google_vignette
2. Setter 메서드를 통한 주입
객체가 생성된 이후 필요한 의존성을 세터 메소드를 통해 주입하는 방식이다. 객체가 생성된 이후에도 의존성을 변경할 수 있지만, 설정 전에 객체를 사용할 경우 불완전한 상태에 놓일 수 있으므로 주의가 필요하다.
// 의존성 클래스
class Dependency {
fun doSomething() = "Doing something"
}
// Setter 주입을 사용하는 클래스
class MyClass {
private var dependency: Dependency? = null
// Setter 메서드를 통한 의존성 주입
fun setDependency(dependency: Dependency) {
this.dependency = dependency
}
fun performAction(): String {
return dependency?.doSomething() ?: "Dependency not set"
}
}
fun main() {
val myClass = MyClass()
// 아직 의존성이 설정되지 않은 상태
println(myClass.performAction()) // Output: Dependency not set
// Setter 메서드를 통해 의존성 주입
val dependency = Dependency()
myClass.setDependency(dependency)
// MyClass 사용
println(myClass.performAction()) // Output: Doing something
}
3. interface를 통합 주입
특정 인터페이스를 구현한 클래스를 통해 의존성을 주입하는 방식이다. *유연성이 높지만 구현이 복잡할 수 있다.
*유연성이 높다: 인터페이스 기반 의존성 주입을 통해 서로 다른 클래스(구현체)를 같은 인터페이스 타입으로 교체해 사용할 수 있다. 즉, 하나의 인터페이스를 여러 가지 방식으로 구현한 다양한 클래스들을 필요에 따라 쉽게 교체할 수 있는 유연성을 가지게 된다.
// 의존성을 위한 인터페이스 정의
interface Dependency {
fun doSomething(): String
}
// 인터페이스 구현 클래스 A
class DependencyA : Dependency {
override fun doSomething() = "DependencyA doing something"
}
// 인터페이스 구현 클래스 B
class DependencyB : Dependency {
override fun doSomething() = "DependencyB doing something"
}
// 의존성을 인터페이스로 주입받는 클래스
class MyClass(private val dependency: Dependency) {
fun performAction(): String {
return dependency.doSomething()
}
}
fun main() {
// DependencyA를 주입받은 MyClass 객체 생성
val dependencyA = DependencyA()
val myClassA = MyClass(dependencyA)
println(myClassA.performAction()) // Output: DependencyA doing something
// DependencyB를 주입받은 MyClass 객체 생성
val dependencyB = DependencyB()
val myClassB = MyClass(dependencyB)
println(myClassB.performAction()) // Output: DependencyB doing something
}
'Programming > Architecture' 카테고리의 다른 글
Circular Dependency(순환 의존성)과 해결법 (1) | 2024.11.12 |
---|---|
[Architecture] MVC, MVP, MVVM Pattern (0) | 2024.01.13 |
SDK vs API (0) | 2023.04.11 |
프레임워크(Framework) vs 라이브러리(Library) (0) | 2023.04.11 |
댓글