DI(Dependency Injection)
객체지향 프로그래밍에서
객체 간의 의존성을 외부에서 주입하여 객체 간의 결합도를 낮추고, 코드의 재사용성과 유연성을 높이는 패턴
이로써 객체가 스스로 직접 의존성을 생성하거나 관리하는 대신, 외부에서 필요한 의존성을 주입받아서 사용한다.
객체는 자신이 사용할 객체의 구현을 몰라도 되고, 인터페이스나 추상 타입에 의존한다.
강합 결합?
한 클래스가 다른 클래스의 구현에 직접적으로 의존하는 상황으로, 두 클래스 간의 관계를 변경하거나 유지보수가 어렵다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class Engine {
public void start() {
System.out.println("Engine start!");
}
}
public class ElectricEngine extends Engine {
@Override
public void start() {
System.out.println("Electric engine started");
}
}
public class Car {
private Engine engine;
public Car() {
this.engine = new Engine();
}
public void startCar() {
engine.start();
System.out.println("Car started!");
}
}
Car 클래스는 Engine 객체의 구현에 의존한다.
만약 Engine이 변경되거나 다른 종류의 엔진을 사용해야 하는 경우, Car의 코드를 변경해야 한다.
그리고 테스트를 하는 경우 실제 Engine 객체가 필요하기 때문에 테스트가 복잡해질 수 있다.
구체적인 구현?
클래스의 실제 행동이나 기능을 수행하는 코드를 의미한다.
Engine는 기본 엔진을 나타내는 클래스이고, ElectricEngine은 전기 엔진을 나타내는 구체적인 구현이다.
각 클래스는 start() 메서드를 다르게 구현할 수 있다.
Car 클래스가 특정한 Engine 객체를 직접 생성할 때, Car는 Engine의 구현에 의존하게 된다.
만약 Engine의 클래스가 변경 되면 Car도 변경될 수 있다.
Car가 ElectricEngine을 사용해야 한다면, 코드를 수정해야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
public class Car {
private Engine engine;
public Car() {
this.engine = new ElectricEngine();
}
public void startCar() {
engine.start();
System.out.println("Car started");
}
}
이러한 방식은 Car가 특정한 엔진 구현에 강하게 결합되어 있어 유연성이 떨어진다.
따라서, 새로운 엔진이 추가될 때마다 Car 클래스를 수정해야 한다.
Car 클래스는 항상 ElectricEngine클래스만 사용하게되며, 만약 다른 엔진(GasolineEngine)을 사용하고 싶은 경우, Car클래스를 수정해야 한다.
또한 새로운 엔진이 생길 때마다 해당 엔진으로 변경하기 위해 Car 클래스를 고치는 것은 개방-폐쇄 원칙을 위반한다.
이러한 문제를 해결하기 위해 의존성 주입을 사용할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
public class Car {
private Engine engine;
public Car(Engine engine) {
this.engine = engine;
}
public void startCar() {
engine.start();
System.out.println("Car started");
}
}
Car 클래스는 Engine 인터페이스에 의존하며, 실제 구현체를 생성자를 통해 주입받는다.
이 방식이 의존성 주입(DI) 패턴이다.
이를 통해 유연성이 높아지며, Car클래스는 ElectricEngine 등 다양한 종류의 엔진을 사용할 수 있다.
또한 테스트 시 모의 객체(Mock Object)등을 주입하여 테스트할 수 있기 때문에 단위 테스트 작성에 용이하다.
1
2
3
4
5
6
7
8
Engine regularEngine = new Engine();
Car car1 = new Car(regularEngine);
Engine electricEngine = new ElectricEngine();
Car car2 = new Car(electricEngine);
car1.startCar();
car2.startCar();
의존성 주입 방법
1. 생성자 주입
1
2
3
4
5
6
7
8
9
10
public class UserService {
// final로 선언해 불변성을 보장한다.
private UserRepository userRepository;
// 의존성을 생성자를 통해 주입
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
}
- 의존성을 생성자에서 받아오기 때문에, 불변성이 보장되고, 테스트가 용이하다.
- 생성자 주입은 의존성 주입 실패 시 컴파일 에러를 감지할 수 있다.
2. Setter 주입
1
2
3
4
5
6
7
8
public class UserService {
private UserRepository userRepository;
public void setUserRepository(UserRepository userRepository) {
this.userRepository = userRepository;
}
}
- 선택적인 의존성 주입이 가능하다.
- 의존성을 주입받을 필드에 대한 수정 가능성이 필요할 때 유용하다.
- 하지만 의존성을 변경할 수 있는 상태로 유지하게 된다.
- 객체가생성된 후, 세터 메서드를 호출하지 않으면 의존성이 설정되지 않아 불완전한 상태가 될 수 있다.
3. field 주입
@Autowired 어노테이션을 사용하여 의존성을 필드에 직접 주입한다. 필드에 @Autowired를 붙이면, 스프링이 자동으로 해당 타입이 빈을 찾아서 주입한다.
1
2
3
4
public class UserService {
@Autowired
private UserRepository userRepository;
}
- 필드에 직접 의존성을 주입한다.
- 필드 주입은 테스트나 유지보수 시 유연성이 떨어질 수 있어, 일반적으로 생성자 주입이 권장된다.
@Autowired
스프링 프레임워크에서 사용되는 어노테이션으로, 의존성 주입을 자동으로 처리한다.
스프링 컨테이넉가 클래스의 필드, 생성자, 또는 메서드에 필요한 의존성으로 자동으로 주입한다.
의존성 주입의 동작 원리
- 스프링은 컨테이너에서 관리되는 빈들 간의 의존성을 자동으로 주입한다.
- @Autowired는 스프링의 IoC 컨테이너에서 적합한 빈을 찾아 주입한다.
- 타입 기반 주입 방식으로, @Autowired가 선언된 필드나 생성자에 타입이 일치하는 빈을 찾아 자동으로 주입한다.
Controller - Service - Repository
해당 요청을 다음 클래스인 서비스에 넘겨주기 위해, private final 타입으로 서비스 멤버 변수를 정의한다.
왜? final로 정의할까?
final을 사용하여 선언된 필드는 객체가 생성된 이후 변경할 수 없기 때문에
- 의존성이 확장되고,
- 주입된 객체가 이후에 변경되지 않는다.
final로 선언된 필드는 생성자에서 반드시 초기화되어야 한다.
즉, 생성자에서 주입받는 의존성이 고정되고 그 의존성이 절대 변하지 않기 때문에, 해당 클래스가 무엇을 의존하는지 명확하게 보여준다.
1
2
3
4
5
6
7
public class UserService {
private final UserRepository userRepository; // 의존성 고정
public UserService(UserRepository userRepository) { // 생성자 주입
this.userRepository = userRepository;
}
}
이 코드를 통해 UserService가 생성될 때 UserRepository를 주입받고, 이후 userRepository필드가 변경되지 않음을 확인할 수 있다.
따라서, UserService는 UserRepository에 의존한다.
final 필드는 한 번 주입된 의존성이 불변임을 나타낸다. 따라서 해당 객체가 생애 주기 동안 의존성을 변경할 수 없다. 하지만, final이 아닌 필드의 경우 의존성이 코드 중간에 바뀔 가능성이 있다.
따라서,
final을 사용하면 서비스 클래스와 컨트롤러 클래스가 한 번 매핑되고, 그 후 맵핑이 바뀌지 않는다.
또한 @Autowired와 생성자 주입을 통해 컨트롤러와 서비스의 생명주기를 연결하고, 서비스와 컨트롤러가 같이 생성되도록 한다.
의존성 주입은 클래스가 다른 클래스의 구현에 의존하지 않고, 인터페이스나 추상 클래스에 의존함으로써 클래스 간 결합도를 낮춘다.
의존성 주입을 사용하면 테스트 시, 실제 객체 대신 Mock객체나 Stub 객체를 주입하여 단위 테스트가 더 용이해진다. 객체의 생성과 사용을 분리하여, 코드의 유연성과 재사용성을 높일 수 있다.