스프링의 핵심 원리중 하나인 IOC(제어의 역전)에 따라, 의존성이 필요한 객체는 필요한 객체를 내부에서 직접 생성하지 않고, 스프링 프레임워크에서 이미 생성해 놓은 객체를 외부로부터 주입받게 된다.
아래는 스프링의 IOC 원칙이 적용되지 않은 코드의 예시이다.
class EngineA implements Engine { ... }
class HandleA implements Handle { ... }
public class Car{
private Engine engine;
private Handle handle;
public Car(){ // 생성자에서 의존성 객체를 직접 생성함.
this.engine = new EngineA(); // EngineA는 Engine 인터페이스의 구현체
this.handle = new HandleA(); // HandleA는 Handle 인터페이스의 구현체
}
}
생성자에서 new
키워드와 함께 EngineA
, HandleA
객체를 직접 생성하여 필드에 연결시키는 모습을 볼 수 있다.
아래는 스프링의 IOC 원칙에 따라 스프링으로부터 의존성을 주입받는 코드의 예시이다.
@Component
public class Car{
@Autowired
private Engine engine;
@Autowired
private Handle handle;
}
우선 new
키워드가 사라진 것이 눈에 띈다.
객체 내부에서 의존성 객체를 생성하지 않으므로, 두 객체의 결합도가 떨어지게 된다.
조금만 더 구체적으로 설명하자면, 만약 위 2개의 코드에서 Engine
이 클래스가 아니라 인터페이스라고 생각해보자.
Engine
인터페이스의 구현체를 새롭게 교체해야 하는 경우, IOC가 적용되지 않은 예시에서는 Car 클래스 내부 생성자를 직접 수정해 주어야 한다.
하지만 IOC가 적용된 아래 예시에서는 스프링에서 Engine
, Handle
인터페이스의 구현체를 IOC 컨테이너에서 자동으로 찾아서 주입해 주기 때문에 Car 클래스의 코드를 수정할 필요가 없어진다.
객체간 결합도가 떨어져서 수정이 용이해 지는 것이다.
스프링은 어플리케이션을 실행할 때, 빈 객체들을 생성해 IOC 컨테이너에 등록하는 과정을 거친다.
이 과정에서 빈들 사이의 의존 관계를 파악하고, 의존성을 주입하는 과정이 진행된다.
스프링에서 두 빈 간의 의존관계를 설정하는, 즉 의존성을 주입하는 방법으로는 크게 3가지가 존재한다.
1. 생성자 주입(Constructor Injection)
2. 수정자 주입(Setter Injection)
3. 필드 주입(Field Injection)
@Component
public class PlayStation implements GameConsole{
private static String consoleName = "PlayStation";
private Game game;
@Autowired
public PlayStation(Game game){
this.game = game;
}
@Override
public void run() {
System.out.println(PlayStation.consoleName + "게임기를 작동시킵니다.");
System.out.println("실행 게임 정보\n" + game.getGameData());
game.run();
}
}
DI 관련 코드를 볼 때 눈여겨 보아야 할 부분은 @Autowired
어노테이션이 붙어있는 곳이다.
스프링은 의존성을 주입할 대상을 @Autowired
어노테이션이 붙어있는 메서드, 혹은 필드를 보고 인식하기 때문이다.
생성자 주입을 이용하는 경우, 그 이름에 걸맞게 생성자에 @Autowired
어노테이션이 붙어있는 것을 확인할 수 있다.
PlayStation
이라는 클래스의 객체는 Game
객체(game 필드)를 의존성으로 가진다.
이 game
필드는 생성자에서 초기화 되는데, 생성자의 파라미터로 이미 생성된 Game
객체를 받아오는 방식으로 초기화 되고 있다.
스프링에서는 대략 다음과 같은 과정을 통해 PlayStation
빈을 등록하게 된다.
1. 스프링에서
PlayStation
의 파라미터를 보고PlayStation
과Game
클래스의 의존관계를 파악한다.
2.PlayStation
객체를 만들기 위해Game
객체가 필요하다는 것을 파악했으므로,Game
객체를 먼저 생성해 빈으로 등록한다.
3.PlayStation
객체의 생성자를 호출한다. 이때 파라미터로 앞서 만들어 등록한 Game 빈을 주입해 준다.
@Autowired
어노테이션이 붙지 않아도 자동으로 생성자 주입이 적용된다.@Component
public class PlayStation implements GameConsole{
private static String consoleName = "PlayStation";
private Game game;
@Autowired
public void setGame(Game game){
this.game = game;
}
@Override
public void run() {
System.out.println(PlayStation.consoleName + "게임기를 작동시킵니다.");
System.out.println("실행 게임 정보\n" + game.getGameData());
game.run();
}
}
마찬가지로 @Autowired
어노테이션이 붙어있는 곳에 집중해 보자.
이번엔 생성자가 따로 없고, 대신 @Autowired
어노테이션이 붙은 setter 메서드가 생겼다.
PlayStation
객체가 빈으로 등록되는 과정은 앞서 생성자 주입때 본 것과 유사하다.
차이점은 2가지가 있다.
1. PlayStation
과 Game
사이의 의존 관계를 setter 메서드를 통해 파악한다.
2. PlayStation
객체가 생성되는 시점이 아닌, 생성된 이후(생성자가 호출된 이후)에 의존성이 주입된다.
즉, 다른 빈들이 모두 생성된 다음에 직접적인 의존성 주입이 일어나게 된다.
@Component
public class PlayStation implements GameConsole{
private static String consoleName = "PlayStation";
@Autowired
private Game game;
@Override
public void run() {
System.out.println(PlayStation.consoleName + "게임기를 작동시킵니다.");
System.out.println("실행 게임 정보\n" + game.getGameData());
game.run();
}
}
필드 주입 방식은 생성자도, setter도 사용하지 않는다.
스프링에서 자동으로 숨겨진 생성자나 setter를 만든다거나 하지 않는다.
일반적으로 다른 언어의 경우를 생각했을 때(ex: C++), private로 선언된 필드는 생성자 혹은 public으로 개방되어있는 setter 메서드를 통해서만 외부에서 수정할 수 있다.
이는 PlayStation 클래스 입장에서는 외부 요소인 스프링 프레임워크도 예외가 아니다.
스프링에서는 이러한 제약조건을 우회하기 위해서 자바의 Reflection
이라는 기능을 이용한다.
이 글에서 Reflection
에 대해서 자세히 다루지는 않을 것이기에, Reflection
은 런타임에 객체로부터 클래스 정보를 뽑아내는 기능이라는 것 정도만 간단히 짚고 넘어가겠다.
스프링은 Reflection
을 이용해 객체의 필드 정보를 뽑아내고, 해당 객체 필드의 값을 강제로 수정하는 방식을 통해서 의존성 주입을 진행한다.
스프링은 위와 같이 의존성 주입을 하는 3가지 방법을 제공하지만, 그 중에 생성자 주입을 사용하라고 강력하게 권장하고 있다.
얼핏 보기에는 가장 코드가 간결해지는 필드 주입이 가장 바람직해 보이는데, 스프링은 왜 생성자 주입 사용을 권장하는 것일까?
생성자는 객체가 만들어지는 시점에 딱 한번 호출되는 것이 보장된다.
생성자 호출 이후에는 별도의 setter 메서드가 존재하지 않는 이상 주입 받은 필드에 접근할 수 있는 방법이 없어지기 때문에, 객체의 불변성이 보장되는 것이다.
불변성이 보장되지 않는 주입 방식으로는 수정자 주입이 대표적이다.
수정자 주입이 적용된 아래 코드를 보자.
@Component
public class PlayStation implements GameConsole{
private static String consoleName = "PlayStation";
private Game game;
@Autowired
public void setGame(Game game){
this.game = game;
}
@Override
public void run() {
System.out.println(PlayStation.consoleName + "게임기를 작동시킵니다.");
System.out.println("실행 게임 정보\n" + game.getGameData());
game.run();
}
}
위 코드의 setGame
이라는 메서드는 스프링이 PlayStation
객체를 빈으로 등록한 이후에도 여전히 유효하다.
즉, 다른 코드에서 PlayStation
객체를 사용하다 setGame
메서드를 호출해 버리면 기껏 주입받은 객체가 변경되어 버리는 것이다.
따라서 수정자 주입은 주입 받은 필드의 불변성을 보장할 수 없는 것이다.
또한, 필드의 불변성 보장을 위해 final 키워드를 붙이는 경우 반드시 생성자 주입을 사용해야만 한다.
수정자 주입, 필드 주입 모두 실제 객체가 생성된 이후에 의존성 주입이 진행되므로, final 키워드를 붙이면 컴파일 에러가 발생하게 된다.
이것은 필드 주입을 사용할 때 보통 문제가 된다.
@Component
public class PlayStation implements GameConsole{
private static String consoleName = "PlayStation";
@Autowired
private Game game;
@Override
public void run() {
System.out.println(PlayStation.consoleName + "게임기를 작동시킵니다.");
System.out.println("실행 게임 정보\n" + game.getGameData());
game.run();
}
}
어플리케이션을 일반적으로 실행할 때에는 스프링 프레임워크 위에서 동작하므로 별 문제가 없지만, 테스트 코드를 실행할 때에는 실행 환경이 달라지게 된다.
따라서 유닛 테스트를 작성할 때, 순수 자바 코드만으로 테스트를 짜기 곤란한 경우가 발생한다.
public class PlayStationTest {
@Test
public void test1 {
PlayStation ps = new PlayStation();
ps.run();
...
}
}
위와 같은 테스트 코드를 실행하면 ps
객체의 game
필드가 null
이 되어 에러가 발생하게 된다.
스프링의 의존성 주입은 스프링 어플리케이션이 실행되고 빈들을 IOC 컨테이너에 등록하는 과정에서 진행된다.
위 코드처럼 순수 자바 코드로 객체를 새로 생성해 버리는 경우 해당 객체는 스프링의 통제 바깥에 있는 객체가 되고, 스프링은 해당 객체에 대해 아무런 의존성 주입도 실행하지 않는다.
위 테스트 코드를 제대로 동작하게 만드려면 다음과 같이 작성해야 한다.
public class PlayStationTest {
@Test
public void test1 {
AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(ConsoleGameApplication.class);
PlayStation ps = context.getBean(PlayStation.class);
ps.run();
...
}
}
스프링에 등록된 빈을 강제로 끄집어 내는 코드인데, 이 테스트 코드의 문제점은 테스트 코드가 특정 프레임워크에 종속되어 버린다는 것이다. (AnnotationConfigApplicationContext
객체는 스프링 프레임워크에서 구현해 놓은 객체임.)
특정 프레임워크에 종속되는 것은 기본적으로 지양해야 하는 테스트 코드의 특성상, 위 코드는 좋은 테스트 코드라고 하기 힘들다.
생성자 주입을 사용하는 경우, 다음과 같이 순수 자바 코드를 통해 테스트를 짤 수 있게 된다.
public class PlayStationTest {
@Test
public void test1 {
PlayStation ps = new PlayStation(new Game("Pacman"));
ps.run();
...
}
}
위와 같이 개발자가 직접 구현한 순수 자바 코드만 가지고 테스트 코드가 짜여진 것을 확인할 수 있다.
이 문제는 스프링 2.6 버전부터 순환참조 자체가 금지되면서 해결되었다.
참고 : https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-2.6-Release-Notes#circular-references-prohibited-by-default
@Component
class foo {
@Autowired
private bar barObj;
public void hello(){
barObj.hello();
}
}
@Component
class bar {
@Autowired
private foo fooObj;
public void hello(){
fooObj.hello();
}
}
위 코드는 어플리케이션을 실행시키는 시점에는 정상적으로 구동하지만, 두 객체중 어느 하나라도 hello()
메서드를 실행하는 순간 스택 오버플로우 에러가 발생하게된다.
즉, hello()
메서드가 실행되기 전까지는 이러한 문제가 있는지 개발자가 인지하기 어려워지게 되고, 이는 디버깅 과정에 악영향을 끼치게 된다.
얼핏 보기엔 서로가 서로를 @Autowired
로 주입받기에 빈을 등록하는 과정에서 미리 순환참조를 탐지할 수 있을 것 같지만, 그렇지 않다.
foo
, bar
는 모두 객체 생성 시점 당시에는 서로가 필요 없다.
왜냐하면 생성자가 따로 정의되어있지 않기 때문이다.
foo f = new foo();
bar b = new bar();
위와 같은 코드는 전혀 오류를 일으킬 이유가 없다는 것이다.
필드 주입, 수정자 주입의 경우 실제 의존성 주입이 다른 빈 객체들이 만들어진 이후에 진행된다.
foo
, bar
모두 객체 생성 자체에는 문제가 없으므로, 일단 객체가 정상적으로 생성된다.
그 이후에 의존성 주입과정 역시 서로가 서로의 객체를 주입받으면서 아무런 문제가 없이 끝나게 된다.
생성자 주입은 상황이 조금 다르다.
@Component
class foo {
private bar barObj;
public foo(bar barObj) { this.barObj = barObj; }
public void hello(){
barObj.hello();
}
}
@Component
class bar {
private foo fooObj;
public bar(foo fooObj) { this.fooObj = fooObj; }
public void hello(){
fooObj.hello();
}
}
생성자 주입은 의존성 주입이 일어나는 시점이 다른 주입 방식과는 다르다.
생성자를 통해 객체를 만드는 과정에서 바로 의존성 주입이 일어난다.
스프링이 foo
빈을 먼저 생성한다고 할 때, 다음 과정을 거치게 된다.
foo
의 생성자를 실행하기 위해서는 bar
객체를 주입해야 하네? IOC 컨테이너에 bar
객체가 있나?
없구나! 그럼 bar
객체를 먼저 생성해야겠다.
bar
객체를 만드려고 생성자를 실행시키려 했더니, foo
객체를 주입해야 하네? IOC 컨테이너에 foo
객체가 있나?
없구나! 그럼 foo
객체를 먼저 생성해야겠다.
스프링은 위 4단계 과정을 반복하면서 순환 참조를 탐지하고, 어플리케이션이 실행되는 시점(빈을 등록하는 시점)에 미리 오류메시지를 뱉어내게 된다.
즉, 어플리케이션이 본격적으로 구동되기 이전에 미리 오류를 수정할 기회가 생기는 것이다.