인턴을 시작한 초기 단계에, Spring 프로젝트 코드 분석을 하다가 신기한 점을 발견해서 질문을 한 기억이 있습니다.
Spring프로젝트에서 Controller를 작성할 때, 저는 항상 @Autowired 어노테이션을 사용해 Service의 의존성을 주입하고, 하나의 객체에서 주입 한 객체를 사용하곤 했습니다.
코드 분석을 하는 도중, @Autowired 어노테이션 없이 Service 객체를 주입받아서 사용하는 Controller를 발견해서, 질문을 하게 되었습니다.
이 Controller는 어떻게 @Autowired 어노테이션 없이 서비스 객체를 주입받아 사용하나요?
돌아온 답변은 다음과 같았습니다.
@RequiredArgsConstructor 어노테이션과, 생성자 주입 방식에 대해 공부해보세요
질문을 하고 난 뒤 꽤 긴 시간이 지났지만, 이제야 포스팅을 하게 되었습니다.
지금 생각하니 되게 간단한 내용인데 몰랐던 제가 부끄러워지네요.
그때 정리한 내용에 대해 글을 써 내려가 보겠습니다.
다양한 의존성 주입 방법
Spring Core의 기능 중 DI/IoC라는 기능이 있습니다.
객체를 사용자가 new 키워드를 통해 생성하고, 소멸시키는 과정 필요 없이 의존성을 주입(DI)해 Spring 컨테이너가 Bean들의 생명 주기를 관리해주는 기능(Ioc)입니다.
이 기능을 사용하려면 개발자가 생성한 객체를 Bean으로 등록하고 사용해야 합니다.
Bean등록은 어노테이션을 사용한 자바 코드, xml 파일 등 다양한 방법이 있습니다.
의존성 주입을 통해, 빈 컨테이너에서 특정 빈을 필요로 하는 클래스에 빈과 클래스간의 의존성 주입을 할 수 있습니다.
- 필드 주입
- 수정자 주입 (메서드 주입)
- 생성자 주입
1. 필드 주입
@RestController
public class HelloController {
@Autowired
private HelloRepository helloRepository;
@Autowired
private HelloService helloSerivce;
}
필드 주입은 @Autowired 어노테이션을 이용해 객체를 주입하는 방식입니다.
코드가 간결하다는 장점이 있습니다.
2. 수정자 주입 (메서드 주입)
@RestController
public class HelloController {
private HelloRepository helloRepository;
private HelloService helloService;
@Autowired
public void setHelloRepository(HelloRepository helloRepository) {
this.helloRepository = helloRepository;
}
@Autowired
public void setHelloService(HelloService helloService) {
this.helloService = helloService;
}
}
수정자 주입은 Setter, 혹은 사용자 정의 메서드를 통해 객체 의존 관계를 주입하는 방식입니다.
3. 생성자 주입
@RestController
public class HelloController {
private HelloRepository helloRepository;
private HelloService helloService;
@Autowired
public HelloController(HelloRepository helloRepository, HelloService helloService) {
this.helloRepository = helloRepository;
this.helloService = helloService;
}
}
생성자 주입은 위 코드처럼 사용할 의존 객체를 생성자를 통해 주입받는 방식입니다.
(참고로, 위 코드의 생성자에 선언되어있는 @Autowired 어노테이션은 생성자가 한 개만 있을 경우 생략 가능합니다.)
그럼 왜 생성자 주입을 사용해야 하나요?
사실 여태까지, 의존성을 주입하려면 @Autowired 어노테이션을 통해 코드도 깔끔하고, 매우 편하게 의존성을 주입하고 있었습니다. 하지만 이 방법은 추천되지 않는 방법인 것을 알았습니다.
심지어 사용하는 IDE인 인텔리제이도, @Autowired 어노테이션으로 필드 주입을 사용하면 경고 메시지를 띄워줍니다.
또한 Spring 프레임워크에서도 생성자 주입 사용을 강력하게 권장하고 있습니다.
생성자 주입이 권장되는 이유는 다음과 같습니다.
- 필드 주입의 단점
- 수정자 주입의 단점
- 객체의 불변성 확보
- 테스트 코드 작성의 편리함
- 순환 참조 방지
- 개발자의 의존성 주입 실수 방지 (final 키워드)
1. 필드 주입의 단점
필드 주입을 사용하면 편리하고 코드가 간결하다는 장점이 있지만, 필드 주입으로 객체를 주입하면 외부에서 수정이 불가능하고, 이러한 이유 때문에 테스트 코드를 작성할 때 객체를 수정할 수 없게 됩니다.
필드 주입은 반드시 Spring 같은 DI를 지원하는 프레임워크가 있어야 사용할 수 있습니다.
필드 주입은 @Autowired 어노테이션으로 의존성 주입을 남발할 수 있습니다.
10개 이상의 의존성이 @Autowired 어노테이션으로 주입된 클래스를 생성자 주입 방식으로 리팩토링 한다고 하면, 생성자의 매개변수는 엄청나게 많아집니다.
해당 객체의 생성자 매개변수가 많아진다는 것은 의존성, 결합에 대한 문제가 발생할 가능성도 커지고, 해당 객체의 역할이 많아지면서 단일 책임 원칙에 위배될 수 있습니다.
필드 주입으로 의존성을 주입하면 final 키워드를 통한 선언이 불가능하므로 객체가 mutable 합니다.
2. 수정자 주입의 단점
Setter의 경우 public으로 구현하기 때문에, 관계를 주입받는 객체의 변경 가능성을 열어둡니다.
이런 이유 때문에, 수정자 주입 방식은 주입받는 객체가 변경될 필요성이 있을 때만 사용해야 합니다.
(주입하는 객체를 변경할 경우가 굉장히 드물긴 합니다)
변경의 가능성을 열어두면, 다른 곳에서 임의로 객체를 변경할 수 있기 때문에 에러가 발생할 위험이 높습니다.
(Setter메서드의 경우, immutable 속성을 보장하지 못해서 DTO에서도 사용을 지양하기도 합니다)
3. 객체의 불변성 확보
객체의 생성자는, 객체 생성 시 1회만 호출된다는 게 보장되는 특징이 있습니다.
이 특징 덕분에 주입받은 객체가 불변 객체여야 되거나, 반드시 해당 객체의 주입이 필요한 경우에 사용합니다.
위 코드를 예로 들면, HelloController가 사용하는 HelloRepository와 HelloService를 변경하는 코드는 HelloController 생성자 밖에 없습니다. 즉, 생성자로 한번 의존 관계를 주입하면, 생성자는 다시 호출될 일이 없기 때문에 불변 객체를 보장합니다.
그리고 HelloRepository와 HelloService를 생성자로 주입을 하면, HelloController가 생성되는 시점에 무조건 객체가 생성되어 주입된다는 게 보장됩니다.
4. 테스트 코드 작성의 편리함
메인 코드가 필드 주입으로 작성된 경우, 순수 자바 코드로 단위 테스트를 실행하는 것은 불가능합니다.
메인 코드는 Spring 같은 DI 프레임워크 위에서 동작하지만, 테스트 코드는 그러지 않아서 의존관계 주입이 정상적으로 되지 않아 null상태여서 NullPointError가 발생합니다.
인턴 생활을 하던 중, 필드 주입으로 작성된 메인 코드를 테스트 코드를 통해 테스트할 때, 계속 null이 주입돼서 한참을 고생했던 적이 있습니다.
JavaMailSender를 사용해 이메일을 보내는 MailSender 객체입니다.
public class MailSender {
@Autowired
private JavaMailSender javaMailSender;
public void sendText(String msg, String mailReceiver) {
javaMailSender.sendText(msg, mailReceiver);
}
}
테스트 코드를 다음과 같이 작성했습니다.
public class MailSenderTest {
@Test
public void mailSendTest {
MailSender mailSender = new MailSender();
String mailReceiver = "asdf@test.com";
mailSender.sendText("테스트", mailReceiver);
}
}
여기서 MailSender클래스가 사용하는 JavaMailSender가 계속 null이 되어 이메일이 전송되지 않아 긴 시간을 날렸습니다.
@Autowired로 작성된 필드 주입 방식을 생성자 주입 방식으로 변경하여 해결했습니다.
5. 순환 참조 에러 방지
순환 참조 에러는 A객체가 B객체를 참조하고, B객체가 A객체를 서로 참조하고 있을 때 발생합니다.
필드 주입 방식으로 A, B 객체 사이의 순한 참조 상황을 만들어 보겠습니다.
@Service
public class AService {
@Autowired
private BService bService;
public void hello() {
bService.hello(); //AService 객체가 BService 메서드 호출
}
}
@Service
public class BService {
@Autowired
private AService aService;
public void hello() {
aService.hello(); //AService 객체가 BService 메서드 호출
}
}
위 코드처럼 A 서비스의 hello() 메서드는 B객체를 참조하고, B 서비스의 hello() 메서드는 A객체를 참조합니다.
애플리케이션을 실행하고, AService에서나, BService에서 hello() 메서드를 호출하면, 서로 메서드를 호출하다 결국 StackOverFlow에러가 나서 애플리케이션이 다운됩니다.
여기서 주목할 점은, 이런 에러가 애플리케이션이 실행 중에 발생한다는 점입니다.
즉, 어플리케이션이 실행되고 있을 때, AService나 BService의 hello() 메서드가 호출되지 않으면 에러는 발생하지 않고, 개발자는 틀린 부분을 찾을 수 없습니다.
정확히 말하자면, 순환 참조 에러가 아닌, 순환 참조에 의한 순환 호출 에러라고 할 수 있습니다.
이 에러는 수정자 주입으로 의존성을 주입해도 동일하게 발생합니다.
그럼 생성자 주입 방식으로 수정해보겠습니다.
@Service
public class AService {
private BService bService;
@Autowired
public AService(BService bService) {
this.bService = bService;
}
public void hello() {
bService.hello(); //AService가 BService 메서드 호출
}
}
@Service
public class BService {
private AService aService;
@Autowired
public BService(AService aService) {
this.aService = aService;
}
public void hello() {
aService.hello(); //BService가 AService 메서드 호출
}
}
이 애플리케이션을 실행하면, 실행이 되지 않고 순환 참조 문제가 있다며 예외가 발생합니다.
이렇게 애플리케이션을 실행하기 전에 발생하는 컴파일 에러는 좋은 에러입니다.
즉, 생성자 주입 방식을 사용하면 순환 참조 문제를 해결하는 것이 아니라, 순환 참조 문제를 애플리케이션 실행 시점에 알려줘서, 실제 서비스되기 전에 개발자로 하여금 순환 참조 문제를 해결할 수 있게 해 줍니다.
이러한 이유들로 인해, Spring에서 객체 의존성 주입은 생성자 주입 방식을 사용하는 것이 권장됩니다.
6. 개발자의 의존성 주입 실수 방지 (final 키워드의 사용)
@RestController
public class HelloController {
private HelloRepository helloRepository;
private HelloService helloService;
public setHelloService(HelloService helloService) {
this.helloService = helloService;
}
}
예를 들어 위 코드처럼, HelloRepository와 HelloService를 사용하는 HelloController가 있습니다.
수정자 주입 방식을 통해 setHelloService 메서드로 HelloService에 대한 구현체를 주입해주었습니다.
하지만 HelloRepository에 대한 구현체 주입에 대한 코드를 까먹었습니다. (사람은 항상 실수할 수 있습니다)
애플리케이션을 실행 후, HelloRepository를 사용하는 비즈니스 로직을 실행하게 되면 HelloRepository에 대한 주입이 없어서 NPE(Null Point Exception)이 발생합니다.
여기서 또 주목해야 하는 점은, 애플리케이션이 실행 도중에, HelloRepository를 사용하는 비즈니스 로직이 실행될 때 에러가 발생한다는 점입니다.
평소에는 이 부분이 누락됐는지 모릅니다.
위 코드를 생성자 주입 방식으로 변경해보겠습니다.
@RestController
public class HelloController {
private final HelloRepository helloRepository;
private final HelloService helloService;
@Autowired
public HelloController(HelloRepository helloRepository, HelloService, helloService) {
this.helloRepository = helloRepository;
this.helloService = helloService;
}
}
생성자 주입 방식을 사용하면서 HelloRepository와 HelloService 앞에 final키워드를 붙였습니다.
final 키워드로 선언된 변수는, 초기에 클래스 생성 시 반드시 초기값이 세팅되어야 하고, 변하지 않는 변수라는 것을 의미합니다.
위 코드에서, 생성자 부분에서 HelloRepository에 대한 초기화 코드를 누락한 상태로 애플리케이션을 실행하면, 컴파일 단계에서 에러가 발생합니다.
앞서 말했듯이, 어플리케이션 실행 전의 컴파일 오류는 아주 바람직한 오류입니다.
생성자 주입을 제외한 모든 주입 방법들은, 모두 생성자 이후에 호출이 되므로 final 키워드를 사용하지 못합니다.
참고: Lombok의 @RequiredArgsConstructor 어노테이션
이 글의 시작 부분에서 말한 @RequiredArgsConstructor에 대해서도 간략하게 설명하겠습니다.
@RequiredArgsConstructor어노테이션은 클래스에 선언된 final 변수들, 필드들을 매개변수로 하는 생성자를 자동으로 생성해주는 Lombok 어노테이션입니다.
@RestController
@RequiredArgsConstructor
public class AController {
private final AService aService;
private final BService bService;
}
의존성 주입받고 싶은 AService, BSerivce 필드를 final로 선언해주고, @RequiredArgsController 어노테이션을 붙여주면, 자동으로 Lombok에 의해 생성자가 생성되어 생성자를 통한 주입 방식을 사용할 수 있습니다.
제가 사용하고 있는 IntelliJ의 왼쪽 하단에 세로로 Structure 부분을 클릭하시면, 생성된 생성자를 확인할 수 있습니다.
'Backend > Spring' 카테고리의 다른 글
[Spring] Spring Boot XSS Filter 생성하기 (0) | 2023.12.31 |
---|---|
[Spring] 세션 ID 값이 계속 바뀌는 현상 (0) | 2022.10.26 |
[개발 공부] Spring "Command line is too long" 에러 해결 방법 (0) | 2021.09.27 |
[Spring] AOP 사용 방법 (예제 코드) (4) | 2021.09.09 |
[Spring] DI/IoC (1) | 2021.09.08 |
댓글