DI는 무엇이고 어떻게 구현하는 것일까?
의존성 (의존 관계, Dependency)
--
의존성은
객체 지향 프로그래밍에서
한 객체가 다른 객체에 대해 얼마나 의존적인지를 나타내는 개념이다.
class A {
int a = 10;
public void test() {
System.out.println(a);
}
}
class B {
A aaa = new A();
aaa.test();
}
위 코드를 보면
B 클래스 내부에서 A 클래스의 객체를 직접 생성하여 사용하는 구조로
B 클래스는 A 클래스에 의존하고 있는 형태가 된다.
A 클래스의 내용이 변하면 B 클래스도 이에 따라 변하게 되는 것이다.
즉, 의존 관계라는 것은
B가 A를 의존하고 있을 때, A가 변하면 B에도 영향을 미친다는 것이다.
A클래스의 a값을 변경하면 B클래스에서 aaa.test() 호출할 때의 결괏값도 변경된 값으로 나오게 된다.
A클래스의 test() 메서드명을 변경하면 B클래스에서도 aaa.test()로 호출하는 것이 아니라 변경된 메서드 명으로 바꿔줘야 호출할 수 있게 된다.
의존 관계 표현 기호
B는 A에 의존한다. = B -> A
B는 A에 의존성 주입한다. = B <- A
의존 관계는 소프트웨어 개발에서 매우 중용한데,
만약 의존 관계를 잘못 관리하게 되면 코드의 유지보수나 테스트가 어려워질 수 있다.
의존 관계 특징
- 단방향 의존성 : A는 B를 의존하지만 B는 A를 의존하지 않는다.
- 양방향 의존성 : A도 B를 의존하고 B도 A를 의존한다.
의존 관계의 문제점
- 높은 결합도 : 한쪽이 변경되면 다른 쪽도 영향을 받기 때문에 코드의 유지보수성과 확장성이 낮아진다.
- 테스트 : 의존 관계인 경우 독립적인 객체로 사용이 불가능하니 단위 테스트처럼 독립적인 테스트가 어렵다.
의존 관계에서 테스트가 어려워지는 이유
1. 직접 객체 생성으로 인한 테스트 어려움
의존 관계의 객체를 테스트하려면, 해당 객체가 의존하는 객체도 필요하므로 모든 의존하는 객체를 미리 준비하여 해당 객체들도 함께 테스트를 진행해야 한다.
객체를 테스트하려면 해당 객체가 의존하는 객체 또한 정상임을 확인해야 하기 때문이다.
즉, 독립적으로 테스트를 하는 단위 테스트가 아닌 통합 테스트처럼 여러 객체를 모두 포함하는 복잡한 테스트를 하게 될 수 있다.
2. 외부 리소스에 대한 의존성
A -> B -> (DB 호출, 파일 시스템)과 같은 상황일 때 (A객체가 B를 의존하고 B객체가 외부 리소스를 사용하는 상황)
A객체를 테스트한다면, B 객체가 실제로 외부 자원을 사용해야 테스트가 정상 동작을 할 것이다.
다만 외부 리소스를 포함한 테스트는 시간이 오래 걸리거나, 리소스 사용의 성공 여부에 따라 테스트가 실패할 수도 있다.
즉, 테스트 중에 실제 DB 연결이 필요하다면 매우 많은 시간이 소요되고, 외부 환경에 영향을 받기 때문에 불안정해질 수 있다.
3. 독립적인 테스트가 불가능
외존 관계가 명확한 상황이 아니면 독립적인 테스트가 불가능해진다.
A객체가 B객체에 의존하고, B객체가 C객체에 의존하는 상황에서
A객체를 테스트하려면 B와 C까지 모두 준비해야 하므로 독립으로 A객체만 테스트가 불가능해진다.
4. Mocking의 어려움
Mocking을 사용하여 의존하는 객체와 동일한 동작을 하는 가짜 객체를 구현하여 의존하는 객체를 대신하여 사용하는 방법으로 테스트하기 어려울 수 있다.
class A { private B b; public A() { this.b = new B(); // B 객체를 직접 생성 } public String doSomething() { return b.doSomethingElse(); } }
위 코드처럼 A객체는 B객체를 내부에서 직접 생성하기 때문에, 이미 B객체를 확정적으로 의존한다는 것이다.
그래서 테스트할 때 B객체를 교체하거나 Mocking할 방법이 없게 된다.
--
의존 주입 (DI, Dependency Injection)
--
DI는
객체 지향 프로그램에서 객체 간의 의존성을 관리하는 기법 중 하나로,
"제어의 역전(IoC, Inversion of Control)"라는 설계 원칙의 구현 방법 중 하나다.
제어의 역전(IoC)는
객체지향 프로그램에서 중요한 설계 원칙 중 하나로,
객체가 자신이 사용할 의존성을 직접 제어하지 않고,
외부에서 이를 관리하는 방식으로 설계하는 것이다.
즉, 객체 간의 흐름(제어 흐름)을 개발자가 아닌 프레임워크나 외부 환경이 담당하게 함으로써
결합도를 줄이고 코드의 유연성을 높이는 것을 목표로 한다.
객체가 직접 내부에서 의존성을 생성하고 해당 의존성을 사용하여 제어하게 되면
해당 객체는 다른 객체에 대한 많은 책임을 갖게 되어 결합도가 높아지고,
변경에 취약한 구조가 될 수 있다.
그래서 IoC에서는 해당 제어 흐름이 "역전"되어,
객체가 아니라 프레임워크나 컨테이너가 객체의 생명 주기와 의존성 관리를 담당하게 하여
객체는 그저 자신의 역할에 충실하면 되고, 필요한 의존성은 의부에서 주입하게 하는 것이다.
즉, DI의 주된 목적은
객체가 다른 객체에 대한 의존성을 직접 관리하지 않도록 구성하여, 객체 간에 결합도를 느슨하게 만들어
코드의 유연성과 재사용성을 높이기 위함이다.
대표적인 DI 방식
생성자 주입 (Constructor Injection)
의존성을 객체의 생성자를 통해 주입하여
객체가 생성될 때 필요한 의존성 객체를 외부에서 함께 전달받아 사용한다.
public class A {
private B b;
public A(B b) {
this.b = b; // 생성자를 통해 의존성 주입
}
}
생성자에서 바로 "this.b = new B()"로 객체를 생성하지 않고
매개변수를 통해 외부로부터 객체를 받아 생성하게 된다.
DI의 특징
- 런타임 시점에서 의존 관계가 드러나지 않는다.
- 의존관계는 제3의 존재(외부)가 결정한다.
- 객체 내부에서 사용해야 할 객체는 외부에서 제공한다.
세터 주입 (Setter Injection)
의존성 객체를 설정자 메서드(setter)를 통해 주입받아 생성하고 사용한다.
public class A {
private B b;
public void setB(B b) {
this.b = b; // 세터 메서드를 통해 의존성 주입
}
}
필드 주입 (Field Injection)
보통 프레임워크(Spring)에서 제공하는 어노테이션을 사용하여
의존성 객체를 필드에 직접 주입하여 사용한다.
(Spring에서는 @Autowired 어노테이션을 통해 필드 주입하는 방식을 많이 사용한다.)
public class A {
@Autowired
private B b; // 필드에 직접 의존성 주입
}
필드에 바로 객체를 생성하여 사용하는 것처럼 보여 DI(외부에서 의존성 주입)가 아닌 것 같지만
해당 어노테이션을 사용해 외부 컨테이너가 해당 필드에 의존성을 주입하는 것으로
해당 의존성 객체의 생성을 IoC 컨테이너에게 위임하여
외부(IoC 컨테이너)에서 해당 의존성을 주입하게 되는 것이다.
(new키워드로 직접 생성하는 것이 아니다.)
--
인터페이스를 사용한 DI
--
기존 DI 방식을 사용하면
객체가 직접 의존성을 관리하지 않기 때문에 객체 간의 결합도를 어느 정도 낮추(느슨)게 되지만
그래도 여전히 구체적인 구현 클래스(의존하는 클래스)에 직접 의존하는 것은 맞다.
DI를 사용하면 직접 객체를 생성하지 않고 외부로부터 주입받기 때문에 의존성을 낮춘다면서 여전히 직접 의존하고 있다는 것은 무슨 말인가?
DI의 기본 개념은 객체가 직접 의존성을 생성하지 않고 외부에서 주입받는다는 것이다.
즉, 객체가 스스로 의존성을 관리하거나 생성하지 않고, 외부에서 필요한 의존성을 받아와 사용하는 것이다.
여기서 직접 의존하지 않는다는 것은 "의존성을 직접 생성하지 않는다"는 뜻이지,
구체적인 클래스에 대한 의존이 완전히 없어지는 것은 아니다.
"구체적인 클래스"는 무엇인가?
인터페이스나 추상 클래스가 아닌 실제 특정 동작을 수행하는 코드가 구현된 클래스를 의미한다.
// 기존 DI의 생성자 주입 코드 public class ServiceA { public void execute() { System.out.println("ServiceA is executing..."); } } public class Client { private final ServiceA serviceA; // 구체적인 클래스에 의존 // 생성자 주입 public Client(ServiceA serviceA) { this.serviceA = serviceA; } public void performTask() { serviceA.execute(); } }
위 코드를 보면
생성자의 매개변수를 통해 객체를 외부에서 받아 사용하도록 구성했지만
여전히 ServiceA라는 구체적인 클래스(객체)를 사용하기 위해 의존하고 있는 것은 그대로다.
즉, DI를 통해 Client가 ServiceA를 직접 생성하지 않도록 하여 객체 간의 결합도를 낮췄지만,
여전히 해당 클래스 간의 결합은 존재하여 직접적인 의존성은 남아있는 것이다.
이러한 상황을 더욱 보완하여 직접적인 의존성을 더욱 낮추기 위해
기존 DI 방식에서 인터페이스를 추가하는 방식이 생기게 되었다.
기존 DI 방식에서 구체적인 클래스를 직접 객체에서 의존하는 것이 아니라
인터페이스에서 의존하게 만든 다음 객체에서는 해당 인터페이스를 통해 구체적이었던 클래스를 사용하는 방식으로
결국 해당 객체는 구체적인 클래스에 직접적으로 의존하지 않고 인터페이스를 통해 간접적으로 사용하게 된다.
예시 코드
// 기존 DI 방식 (인터페이스 X)
public class ServiceA {
public void execute() {
System.out.println("ServiceA is executing...");
}
}
public class Client {
private final ServiceA serviceA; // 구체적인 클래스(ServiceA)에 의존
// 생성자 주입
public Client(ServiceA serviceA) {
this.serviceA = serviceA;
}
public void performTask() {
serviceA.execute();
}
}
// 인터페이스를 사용하여 결합도 낮추기
public interface Service {
void execute();
}
public class ServiceA implements Service {
@Override
public void execute() {
System.out.println("ServiceA is executing...");
}
}
public class Client {
private final Service service; // 인터페이스에 의존
// 생성자 주입
public Client(Service service) { // 다형성을 이용하여 ServiceA를 인자로 주입
this.service = service;
}
public void performTask() {
service.execute(); // 구체적인 구현체는 모름
}
}
여기서 메인 모듈과 하위 모듈의 개념을 "상속" 개념으로 이해하면 안 된다.
그냥 의존성의 방향과 관련된 용어다.
--
의존관계 역전 원칙 (DIP, Dependency Inversion Principle)
--
DIP는
SOLID 원칙 중 하나로
의존 관계를 반전시키는 것을 목표로 한다.
특히 상위 수준 모듈과 하위 수준 모듈 간의 의존성을 바꾸는 것에 초점이 맞춘다.
DIP 규칙
- 상위 모듈은 하위 모듈에 의존하면 안 된다. (둘 다 추상화에 의존)
- 추상화는 세부사항(구현)에 의존하면 안 된다. (세부사항은 추상화에 따라 달라짐)
즉, A클래스는 B클래스의 구체적인 구현에 의존하지 않고
인터페이스나 추상 클래스를 통해 상호 작용하도록 구현해야 한다.
이를 통해 구체적인 구현(B클래스)이 변경되더라고 상위 수준 모듈은 영향을 받지 않기 때문에
유연성과 확장성이 높아지게 된다.
이러한 개념을 살펴보면 DI 구현과 비슷하게 느껴지는데
바로 DI는 DIP의 구현을 돕는 기술로 DIP를 실현하기 위한 방법 중 하나라고 볼 수 있다.
그래서 DI를 구현하면 자동으로 DIP도 구현된다는 것이다.
--
IoC, DI, DIP 정리
--
제어의 역전 (IoC, Inversion of Control)은
제어의 흐름을 역전시키는 개념으로
객체가 스스로 필요한 것을 찾아 사용하지 않고
외부에서 객체의 동작을 관리하고 필요한 것을 주입하는 방식을 말한다.
이 말은
객체 자신이 직접 필요한 객체를 생성해서 사용하는 방식이 아닌
외부 컨테이너(프레임워크 등)가 필요한 객체의 생명 주기와 의존성을 관리하여 생성하고, 이를 주입한다.
(꼭 프레임워크 등의 도움 없이 수동으로 외부 주입을 해도 IoC를 수행한 것이다.)
예시
- 기존 방식 : 객체가 직접 new 키워드를 사용하여 다른 객체를 생성 후 사용
- IoC 방식 : 외부 컨테이너가 객체를 생성하고 이를 필요한 객체에게 주입해 줌 (외부에서 주입)
의존성 주입 (DI, Dependency Injection)은
의존성을 외부에서 주입하는 방법론으로
객체가 스스로 필요한 것을 생성하지 않고, 필요한 객체를 외부에서 받아서 사용하는 것이다.
외부에서 주입하는 방식
- 생성자 주입
- 세터(메서드) 주입
- 필드 주입
예시
- DI가 없는 경우 : 객체가 스스로 new 키워드를 사용하여 다른 객체를 생성하여 의존성 해결
- DI가 있는 경우 : 외부에서 필요한 의존성을 주입받음
의존관계 역전 원칙 (DIP, Dependency Inversion Principle)은
기존 의존 관계를 추상화에 의존하도록 만들어,
구체적인 구현에 의존하는 것이 아닌 인터페이스나 추상 클래스에 의존하도록 설계하는 원칙이다.
즉, 상위 모듈(비즈니스 로직 담당)은 하위 모듈(구체적인 구현부)에 의존하지 않고,
추상화된 인터페이스에 의존해야 한다.
예시
- DIP 위반 : 클래스가 구체적인 클래스에 의존
- DIP 적용 : 클래스가 추상 인터페이스에 의존하고, 구체적인 구현은 인터페이스를 통해 구현된 상태
정리
일반적으로 DI를 구현하면
자동으로 IoC를 적용한 것과 동일하게 되며
여기서 DI를 추상화(인터페이스)를 사용하여 구현하면
추가로 DIP도 적용한 것과 동일하게 된다.
--
'Terminology' 카테고리의 다른 글
[디자인 패턴] 싱글톤 패턴 (Singleton) (0) | 2024.10.30 |
---|---|
디자인 패턴 (1) | 2024.10.28 |
SOLID 원칙 ( 객체 지향 설계 원칙) (1) | 2024.10.26 |
객체지향 프로그래밍 (OOP, Object-Oriented Programming) (0) | 2024.10.25 |
CI / CD 파이프라인 (0) | 2024.07.05 |