면접 대비 겸 스프링 공부를 다시 복습하던 중 DI에 대한 내용을 재정리 해보았다.
IoC
: 제어의 역전. 객체의 생성과 관리 및 의존성에 대한 제어권을 개발자가 아닌 프레임워크가 가지도록 하는 개념
DI
: 의존성 주입. IoC의 구체적 구현 방식. 의존성을 외부에서 주입해 객체간의 의존성을 느슨하게 결합시킨다.
객체를 직접 생성하면 수정이 필요할 때 하나하나 직접 다 고쳐줘야 하지만, DI를 통해 외부에서 주입받게 되면 외부에서 한번만 수정을 한 뒤 전달을 받으면 되기 때문에 확장성과 유지보수성이 좋아진다.
Bean
: 스프링 IoC 컨테이너
에서 관리되는 객체. 생성에서 제거까지 lifecycle 전체를 스프링이 관리한다.
- Bean 등록 방법
- XML 설정 파일에서 <bean> 설정
- 클래스 위에 @Component, @Service, @Controller 등 활용
- @Configuration 클래스 안에서 @Bean으로 등록
스프링 컨테이너는 Bean을 생성하고 필요한 곳에 Bean을 주입(DI)한다. 개발자 대신 스프링이 Bean의 생명주기와 의존성을 알아서 관리하기 때문에 유지보수가 편리하고, 개발자는 비즈니스 로직에 집중할 수 있게 된다.
@Service
public class UserService {
// final을 붙일 수 있다
private final UserRepository userRepository;
private final RedisRepository redisRepository;
//@Autowired 생략 가능
public UserController(UserRepository userRepository, RedisRepository redisRepository) {
this.userService = userService;
this.redisRepository = redisRepository;
}
//...
}
final
선언을 통해 객체가 변경이 안되게 할 수 있다.@Service
public class UserService {
private UserRepository userRepository;
private RedisRepository redisRepository;
@Autowired
public setUserRepository(UserRepository userRepository) {
this.userService = userService;
}
@Autowired
public setRedisRepository(RedisRepository redisRepository) {
this.redisRepository = redisRepository;
}
//...
}
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
@Autowired
private RedisRepository redisRepository;
//...
}
스프링 공식문서의 Dependency Injection에서는
"The Spring team generally advocates constructor injection"이라고 생성자 주입방식을 대체로 권장한다. 그 이유에 대해 알아보자.
생성자 주입방식은 객체가 생성될 때 생성자를 한 번만 호출해 의존성을 주입하고 그 이후에는 변경되지 않는다.
그리고 객체를 final로 선언할 수 있어서 한 번 할당되면 다른 값으로 변경할 수 없다.
불변성을 통해 일관성과 안정성을 제공하고, 여러 스레드에서 동시에 사용하더라도 상태 변경으로 인한 경쟁조건이 발생하지 않아 Thread-safe한 객체가 된다.
수정자 주입을 사용할 시, 객체를 변경할 수 있기 때문에 객체의 상태를 예측하기 어렵게 되어 유지보수가 힘들게 된다. 불필요하게 객체의 수정 가능성을 열어둘 필요가 없다.
따라서 생성자 주입 방식을 사용하면서 final
로 선언해 불변성을 확보하는 것이 좋다.
수정자나 필드주입을 사용하면 주입받는 객체가 null
인 상태로 남아있을 수 있다. 이 상태에서 해당 객체를 사용하려고 하면 NullPointException이 발생하게 된다.
하지만 생성자 주입방식을 사용하면 필요한 의존성이 모두 주입되어야 해당 객체를 생성할 수 있다. 그래서 객체가 null일 경우 컴파일 시점이나 런타임과정에서 객체 생성 시점에 에러가 발생해 NullPointException을 방지할 수 있다.
생성자 주입방식을 사용하면 주입하는 객체를 바꿀 때 마다 매번 생성자 코드를 수정해야한다.
하지만 lombok의 @RequiredArgsConstructor를 사용하면 final이나 @NotNull이 붙은 필드의 생성자를 자동으로 만들어 준다. 생성자가 하나일 때 @Autowired가 생략 가능한데, 해당 생성자를 lombok이 자동으로 만들어 주니 그냥 필드만 final로 구현하면 자동으로 주입이 된다.
따라서 생성자 주입 방식을 보다 편하게 사용할 수 있다.
단, IDE 설정에 따라 @RequiredArgsConstructor를 사용 시 생성자가 보이지 않아서 의존성 명시성 측면에서는 단점이 있을 수 있다.
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final RedisRepository redisRepository;
//...
}
필드 주입 방식은 해당 필드가 private이라면 직접 접근해서 값을 변경할 수 없다.(그렇다고 public이나 protected로 변경하면 캡슐화가 깨지는 문제가 발생한다.) 필드 주입방식으로 단위테스트를 진행 시 NullPointException이 발생한다.
생성자 주입 방식을 사용하면 필요한 의존성을 쉽게 주입할 수 있다.
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
public void login() {
// ...
}
// ...
}
public class UserServiceTest {
UserRepository mockUserRepository = mock(UserRepository.class);
UserService userService = new UserService(mockUserRepository);
@Test
public void testLogin() {
userService.login();
//...
}
}
단위 테스트 진행 시, mock객체나 원하는 객체를 만들어서 쉽게 테스트 진행이 가능하다.
(수정자 주입 방식도 이 장점에는 해당된다.)
@Service
public class ServiceA {
@Autowired
private ServiceB serviceB;
public void runA(){
serviceB.runB();
}
}
@Service
public class ServiceB {
@Autowired
private ServiceA serviceA;
public void runB(){
serviceA.runA();
}
}
위의 코드에서 runA() 메서드를 실행시키면 runB()와 runA()가 계속 서로를 호출해 JVM의 stack메모리가 가득 차서 StackOverflow 에러가 발생한다.
문제는 필드 주입과 수정자 주입은 객체의 생성 이후에 의존성이 주입되기 때문에 서버가 구동 되고 해당 코드가 호출되기 전 까지 문제를 알 수 없다는 것이다.
하지만 생성자 주입방식은 컴파일 시점이나 객체 생성 시점에 순환 참조 에러를 예방할 수 있다.
new serviceA(new serviceB(new serviceA(new serviceB(...))))
Bean에 등록하기 위해 객체를 생성하는 과정에서 이미 위와같은 순환참조 문제로 인해 BeanCurrentlyInCreationException
이 발생해 서버 구동 전에 미리 문제를 알 수 있게 된다.
스프링부트 2.6부터는 순환참조가 기본적으로 허용되지 않도록 바뀌어 필드주입이나 수정자 주입에서도 생성자 주입방식과 동일한 시점에 에러가 발생하게 바뀌었다.
⭐ 결론
생성자 주입 방식은 객체의 불변성이 유지되면서 NPE와 순환참조 문제를 미리 예방할 수 있고, 단위 테스트 작성에 용이하고 lombok 어노테이션을 통해 간단하게 구현이 가능하다.
DI, 의존성 주입을 통해 객체지향의 SOLID 원칙 중 OCP와 DIP 원칙을 지킬 수 있다.
- OCP : 자신의 확장에는 열려있고 주변 환경의 변화에는 닫혀있어야 한다는 원칙
- DIP : 변경이 잦고 구체적인 것보다는 변경이 없는 추상화된 것에 의존해야한다는 원칙
public class Switch {
// private Game game = new Zelda();
private Game game = new Mario();
// ...
}
@Configuration
public class SwitchConfig {
@Bean
public Switch switch() {
return new Switch(new Mario());
}
}
//----------------------------------------------------------
public class Switch {
private Game game;
public Switch(Game game) {
this.game = game;
}
// ...
}
SwitchConfig를 통해 Switch 객체가 Bean으로 등록되고 스프링의 IoC 컨테이너에 의해 관리된다. DI를 적용해 Switch 클래스가 객체를 생성하지 않고 외부에서 주입받게 된다.
Game을 변경할 때 Switch 클래스에 수정이 필요없으므로 OCP도 지키면서 코드의 확장성을 높일 수 있게 된다.
Switch는 추상화된 Game에 의존하여 DIP를 지키면서 객체간 결합도가 느슨해져 유연성을 높일 수 있게 되었다.
단위 테스트를 할 때 실제 Game 객체 대신 테스트용 Mock Game 객체를 주입할 수 있어 테스트의 용이성이 향상된다.