의존성 주입에 대해 설명하기에 앞서, ‘의존’ 이라는 단어에 대한 이해부터 하고 들어가자!
의존이란 무엇인가!
A 클래스에서 B 클래스를 사용하고 있을 때, A 클래스가 B 클래스에 의존한다고 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
public class A {
private B b = new B();
public void performAction() {
b.doSomething();
}
}
public class B {
public void doSomething() {
System.out.println("B is doing something.");
}
}
의존성 주입이란?
다시 의존성 주입으로 돌아와서, 의존성 주입이란 무엇일까?
위처럼 A 클래스에서 B 클래스를 사용할 때 직접 객체를 생성해서 사용하는 것이 아니라, 외부에서 클래스를 주입받아 사용하는 것을 말한다.
클래스를 주입해주는 외부란 어디일까?
그 ‘외부’에서는 객체를 주입하기 위해 애플리케이션 실행 시점에 필요한 객체들을 생성해야 하고, 의존성이 있는 객체 사이의 관계를 파악해 특정 객체를 다른 객체로 주입해주어야 한다.
Spring에서는 이러한 객체들을 Bean이라 부르고, Spring Container에서 이 Bean을 생성하고 관리하는 역할을 한다. 즉, ‘외부’는 스프링 컨테이너라고 이해하면 된다!
아니 다 알겠고, 그럼 의존성 주입이 뭐가 좋은데?! 🤔
의존성 주입으로 코드 결합도를 낮춰 변경에 유연한 코드를 짤 수 있다!
예시를 들어 설명해보겠다. ‘의존’ 개념을 설명할 때 들었던 예시를 다시 가져와보자.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class A {
private B b;
public A() {
this.b = new B();
}
public void performAction() {
b.doSomething();
}
}
public class B {
public B() {}
public void doSomething() {
System.out.println("B is doing something.");
}
}
아래와 같이 B 클래스에서 ‘name’이라는 값을 받아 사용하도록 B 클래스가 수정된다면 어떻게 될까?
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 A {
private B b;
public A() {
this.b = new B("Kate"); // 수정
}
public void performAction() {
b.doSomething();
}
}
public class B {
// 추가
private String name;
// 수정
public B(String name) {
this.name = name;
}
public void doSomething() {
System.out.println("Hi, " + name + "! B is doing something." );
}
}
B 클래스가 변경되어 A 클래스의 코드도 수정해야 하는 상황이 되었다.
만약 B 클래스를 A 클래스 뿐만 아니라 다른 여러 클래스에서도 사용하고 있었다면? 해당 클래스의 코드도 모두 수정해주어야 한다.
의존성 주입을 사용하면, 하나의 수정으로 인해 여러 클래스를 수정해야 하는 상황을 방지할 수 있다. 다음 예시를 살펴보자.
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
26
public class A {
private B b;
// 생성자를 통한 B 클래스 주입
public A(B b) {
this.b = b;
}
public void performAction() {
b.doSomething();
}
}
public class B {
// 추가
private String name;
// 수정
public B(String name) {
this.name = name;
}
public void doSomething() {
System.out.println("Hi, " + name + "! B is doing something." );
}
}
생성자를 통해 B 클래스를 주입받아 사용하도록 수정했다.
A 클래스에서는 B 클래스의 구현과는 상관 없이 외부에서 해당 객체를 주입받아 사용하기만 하면 된다. B 클래스에서 어떤 코드가 수정되더라도 A 클래스(혹은 B 클래스를 사용하는 또 다른 클래스들)에서는 알 필요가 없다!
의존성 주입 방법
자, 그러면 의존성을 주입하는 방법에 대해 알아보자.
1. 생성자 주입
- 생성자를 통해 주입받는 방식이다. 스프링에서 권장하는 방식이다.
- 생성자 주입은 생성자 호출 시점에 딱 한 번 호출된다. 그래서 주입 받은 객체가 변하지 않거나, 객체 주입이 반드시 필요한 경우에 사용할 수 있다. (의존 관계의 변경이 필요한 상황은 거의 없기 때문에 생성자 주입을 권장하는 것)
- 의존성 주입이 많아질 경우, 생성자의 매개변수 수가 증가해 가독성이 떨어질 수 있다.
1 2 3 4 5 6 7 8
@Controller public class Controller { private Service service; public Controller(Service service) { this.service = service; } }
2. Setter 주입
- 객체 생성 후, Setter를 이용해 의존성을 주입받는 방식이다.
- 객체가 생성된 후 의존성을 주입하는 방식이기 때문에, 해당 객체의 불변성이 보장되지 않는다.
- 주입받는 객체가 변경될 가능성이 있는 경우에 사용한다. (하지만 변경될 가능성은 극히 드물다)
1 2 3 4 5 6 7 8
@Controller public class Controller { private Service service; public void setService(Service service) { this.service = service; } }
3. 필드 주입
- @Autowired 어노테이션을 이용해 필드에 직접 주입 받는 방식이다.
- 코드가 간결하고, 주입 로직이 간단하다.
- 객체가 완전히 초기화되기 전에 사용될 수 있어 위험하다.
- 외부에서 접근이 불가능하다는 단점 때문에, 테스트 코드 작성 시 사용하기 어렵다. (그래서 과거에 비해 권장하지 않는 방식이다)
+ IntelliJ에서 필드 주입 방식을 사용하면 ‘Field injection is not recommended’라는 경고 문구가 발생한다.
1
2
3
4
5
@Controller
public class Controller {
@Autowired
private Service service;
}
생성자 주입 방식은 순환 참조 에러를 막을 수 있다!
순환 참조 에러란, 아래 예시와 같이 A 클래스가 B 클래스를 의존하고, 다시 B 클래스가 A 클래스에 의존하는 상황일 때, 두 클래스가 생성될 때 무한루프에 빠질 수 있는 것을 말한다.
1
2
3
4
5
6
7
8
9
public class A {
@Autowired
private B b;
}
public class B {
@Autowired
private A a;
}
A 클래스를 생성할 때 B 클래스를 주입받으려고 하고, 이를 위해 B 클래스가 생성될 때 다시 A 클래스를 주입받으려고 하는 상황이 반복되며 무한 루프에 빠지고, 결국 StackOverflow 에러가 발생할 것이다.
@Autowired를 이용하는 필드 주입 방식은 모든 객체의 생성이 완료된 후에 의존 관계에 따라 의존성이 주입된다. 그렇기 때문에, 애플리케이션 구동 시점에 에러가 발생하지 않아 문제를 발견하지 못하고 서버가 운영될 수 있고, 해당 부분을 호출할 때가 되서야 문제를 발견하게 되는 것이다.
이러한 문제는 생성자 주입을 이용하면 해결할 수 있다!
생성자 주입은 객체가 생성될 때(생성자가 호출되므로) 의존 관계에 따라 의존성이 주입된다. 그래서 애플리케이션을 구동하려고 할 때 에러가 발생할 것이고, 사전에 해결할 수 있는 것이다.
참고
SpringBoot 2.6부터는 기본적으로 순환 참조가 허용되지 않도록 변경되었다. 필드 주입을 사용하더라도 순환 참조 에러가 발생한다면 애플리케이션 로딩 시점에 에러가 발생하게 된다!