Spring Framework에서 생성자 주입을 권장하는 이유!
- 웹 개발/Spring Boot
- 2022. 8. 30. 14:38
안녕하세요.
오늘은 스프링 프레임워크를 사용할 때, 반드시 숙지해야되는 개념인 DI(Dependency Injection), 의존주입에 대한 이야기를 진행해보려고 합니다.
우선 DI라는 개념은 스프링에만 존재하는 개념은 아닙니다. 기존에 다른 프레임워크에서부터 발전해온 개념으로 IOC를 구현하기 위해 사용되던 방법으로 DI를 써왔는 흐름으로 알고 있습니다.
따라서 Spring에서 봤던 DI와 기존 DI간의 매치가 아직 안되신분들이 있을 것 같아서 그것부터 바로잡아 보겠습니다.
Dependency Injection
우선 기존의 의존주입을 보기전에,
의존이라는게 어떤 것인지부터 알아야합니다.
Spring MVC를 토대로 개발을 해왔던 분이시라면 충분히 아시겠지만 다시 정리하는 차원에서
의존이란 아래의 코드를 보았을 때
public class Controller {
private Service service;
public String doSomething() {
return service.doSomething();
}
}
Controller 클래스는 doSomething 메소드를 실행하기 위해서는 Service 클래스에 의존하게 됩니다.
이러한 참조관계를 의존이라고 표현하며 해당 의존객체의 인스턴스를 주입하기 위한 방법이 의존주입입니다.(물론 단순히 코드상에서 new 로 인스턴스화한다고 의존주입이라고 표현하지는 않습니다. 프레임워크의 동작 내에서 이루어져야 합니다.)
기존에는 이런 의존주입을 할 수 있는 방법으로 2가지가 있었습니다.
1. 수정자를 통한 주입(Setter)
public class Controller {
private Service service;
public void setService(Service service) {
this.service = service;
}
public String doSomething() {
return service.doSomething();
}
}
2. 생성자를 통한 주입(Constructor)
public class Controller {
private Service service;
public Controller(Service service) {
this.service = service;
}
public String doSomething() {
return service.doSomething();
}
}
사실 이 두가지는 크게 다르게 받아들이시진 않을 것 같습니다.
어차피 인스턴스화 -> 주입 / 인스턴스화하면서 주입
이렇게 두개의 과정밖에 나뉘어지지 않습니다. 다만 두개의 주입방법이 어떤 특징을 갖는지는 setter와 생성자의 특징을 그대로 이해만 했다면 쉽게 알 수 있습니다.
수정자를 사용하게 된다면 인스턴스화 후 주입단계를 거치지 않게 된다면 doSomething 메소드에서 NullPointerException이 발생할 가능성이 생기게 됩니다. 다만 생성자를 쓰게된다면 직접적으로 null을 주입하지 않는 이상 인스턴스화조차 일어날 수 없으니 NullPointerException에 대한 고려는 하지 않겠죠..
Spring Dependency Injection
사실 스프링에서의 DI라고 크게 다르지는 않습니다.
다만 필드 주입이라는 타입기반의 의존주입이 추가됩니다. @Autowired 어노테이션의 동작방식은 아래의 참조링크를 확인해주세요.
https://beststar-1.tistory.com/40
스프링 의존주입에서 수정자주입과 생성자 주입은 크게 다르지 않습니다. 코드가 거의 동일하겠지만 구분해서 보여드리겠습니다.
1. 수정자를 통한 주입
@RequestMapping("/di")
@RestController
public class FieldInjectionController {
private FieldInjectionService fieldInjectionService;
@Autowired
public void setFieldInjectionService(FieldInjectionService fieldInjectionService) {
this.fieldInjectionService = fieldInjectionService;
}
@GetMapping("/field-injection")
public String fieldInjection() {
fieldInjectionService.fieldInjection();
return "fieldInjection";
}
}
2. 생성자를 통한 주입
@RequestMapping("/di")
@RestController
public class FieldInjectionController {
private FieldInjectionService fieldInjectionService;
public FieldInjectionController(FieldInjectionService fieldInjectionService) {
this.fieldInjectionService = fieldInjectionService;
}
@GetMapping("/field-injection")
public String fieldInjection() {
fieldInjectionService.fieldInjection();
return "fieldInjection";
}
}
근데 생성자 주입은 lombok을 활용하여 생성자를 처리할 수 있기 때문에 아래와 같이 하기도 합니다.
@RequiredArgsConstructor
@RequestMapping("/di")
@RestController
public class FieldInjectionController {
private final FieldInjectionService fieldInjectionService;
@GetMapping("/field-injection")
public String fieldInjection() {
fieldInjectionService.fieldInjection();
return "fieldInjection";
}
}
근데 여기서 한가지 주목해야할 부분이 있습니다. 생성자 주입에서 의존객체에 final 키워드가 붙었습니다. 왜 final을 붙인걸까요??
여기서는 Spring의 Bean의 개념까지 알아야 이해가 될겁니다. 스프링의 빈이란 상태가 별도로 없는 일종의 로직을 처리하는 인스턴스에 불과합니다. 따라서 멱등성이라고 하는 개념을 만족해야하는 인스턴스만이 스프링 빈으로 등록되어야 합니다.(물론 라이프사이클마다 다르지만.. 싱글톤이라는 기본 속성하에 이야기하겠습니다.)
그렇다면 런타임중에 주입된 객체의 변경을 방지하기 위해서는 자바의 기본문법인 final 키워드로 방어처리를 할 수 있게 되는것입니다. 싱글톤 라이프사이클로 관리하고 있는 스프링 빈이 있다면 절대 변경되어서는 안될 인스턴스인 것 입니다. 그런데 어플리케이션이 런타임되는 과정에서 주입되는 인스턴스가 변경되는 setter를 둔다는건 굉장한 리스크를 갖고 개발해야하는 것입니다. 개발자들에 의해 컨벤션이 setter 주입 메소드는 건들지 않는다고 할지라도 정성적인 시스템과 절대로 호출할 수 없는 물리적인 시스템에는 분명한 한계가 있게 됩니다.
여기서 final 키워드를 기존의 의존주입의 영역에서 이야기하면 사실 애매합니다. 의존성이 스프링 빈으로 관리될지 알 수 없을 뿐더러 의존객체가 인터페이스라면 전략패턴으로 구현된 셋팅에 의해 다르게 설정되어야 되는 요구사항이 필요하다면 setter가 필요하게 되어 인스턴스 구현체가 변경될 수 있어야 하니까요. 이런 구조에서는 final에 의한 immutable이 말이 되지 않습니다. immutable에 대한 이야기는 스프링의 아키텍쳐내에서 라이프사이클이 싱글톤인 스프링 빈 인스턴스를 의존주입할 때만 immutable이라는 키워드를 사용해도 좋지 않나 생각합니다.
3. 필드주입(타입기반 주입)
@RequestMapping("/di")
@RestController
public class FieldInjectionController {
@Autowired
private FieldInjectionService fieldInjectionService;
@GetMapping("/field-injection")
public String fieldInjection() {
fieldInjectionService.fieldInjection();
return "fieldInjection";
}
}
사실 제일 쉬운 스프링 의존주입 방법입니다. 그냥 어노테이션 하나만 붙이면 되거든요! 스프링 빈 컨테이너 내에 등록된 빈중에서 타입에 맞는 인스턴스를 주입해주는 방법입니다. 기존에는 쉽게 쉽게 써왔지만 어플리케이션이 점차 모놀리틱해지고 거대해지면서 문제가 발생하게 됩니다.
순환참조.(Circular Reference)
우선 순환참조란 다음의 코드로 설명드리겠습니다.
@Service
public class FieldInjectionService {
@Autowired
private FieldInjection2Service fieldInjection2Service;
public int doSomething() {
System.out.println("fieldInjectionService.doSomething() 호출");
return fieldInjection2Service.doSomething();
}
}
@Service
public class FieldInjection2Service {
@Autowired
private FieldInjectionService fieldInjectionService;
public int doSomething() {
int fieldInjectionServiceResult = fieldInjectionService.doSomething();
System.out.println("fieldInjection2Service.doSomething() 호출");
return fieldInjectionServiceResult;
}
}
이런식으로 두개의 서비스가 서로 참조관계에 있게 된다면 순환참조가 발생하게 됩니다.
그런데 실제 해당 코드를 토대로 스프링 프로젝트를 구동하게 된다면 성공적으로 컴파일, 런타임까지 돌아가게 됩니다. 물론 첫번째 해당 기능 사용자가 아래의 예외를 보기 전까지는 매우 성공적입니다.
필드주입은 이러한 순환참조를 방어할 수는 없습니다.
[ * 스프링 부트 2.6 버전 이후로는 스프링을 구동할 때 순환참조에 대한 이슈가 발생하였다고 구동자체를 못시키게 하는 업데이트 내역이 존재합니다만 스프링부트 2.6 미만의 기존 레거시 프로그램에서는 충분히 순환참조가 발생할 수 있습니다. ]
물론 수정자 주입도 마찬가지의 에러스택을 뱉게 됩니다.
그럼 생성자 주입은 어떻게 될까요??
생성자 주입은 스프링 부트의 버전과 상관없이 순환참조에 대한 힌트를 주고 있습니다.
생성자끼리 서로 참조하고 있기 때문에 인스턴스를 생성하지 못하는 이슈를 어느 단계에서 캐치하게 되고 트리거가 발생하여 해당 구동 실패를 띄우는게 아닌가 싶습니다.
따라서 스프링 가이드에서는 항상 생성자 주입을 통한 개발을 권장하고 있고 이에 대한 어조는 굉장히 강력합니다. always라는 표현을 썻었기도 했습니다.
일단 의존주입과 스프링에서의 DI 까지 일련의 흐름에 대해서 쭉 살펴봤고 관련 자료들도 훑어보면서 구분해야되는 부분과 오해하지 말아야 하는 부분에 대해서 일련의 내용으로 구성해보았습니다.
무작정 생성자 주입을 권장한다고 해서 사용하는 것이 아닌 왜?? 라는 것에 초점을 맞추어서 개발해보는 건 어떨까요?
'웹 개발 > Spring Boot' 카테고리의 다른 글
Spring Boot server.shutdown graceful 옵션 (0) | 2022.09.02 |
---|---|
Spring AOP를 활용하여 서비스 로직의 시간 체크하는 @Annotation 만들기 (0) | 2022.04.11 |
Mac OS 스프링부트 로컬 개발시 부팅 slow 문제 해결 (0) | 2021.12.19 |
@ConfigurationProperties 어노테이션과 @ConstructorBinding을 이용한 Database Configuration 설정. (0) | 2021.05.23 |
ObjectMapper로 LocalDateTime를 Json으로 직렬화하기(Serialize) (0) | 2021.02.04 |