스프링 부트 DI - 필드 주입과 생성자 주입의 초기화 시점
DI(의존성 주입)
- 의존성 주입이란 객체가 스프링 빈에 등록된 다른 객체를 필요로 할 경우 해당하는 객체를 가져오는(주입해주는) 행위이다.
- 전통적인 자바에서는 스스로 객체를 생성하거나 찾지만 스프링 부트에서는 이 과정을 외부에서 처리한다. (SOLID원칙을 지켜 객체 간 결합도를 낮추고 코드의 유연성을 증가함.)
의존성 주입의 유형
- 생성자 주입
- 수정자(setter) 주입
- 필드 주입
- 일반 메소드 주입
포스팅을 하게 된 배경
과거 실무에서 저는 이 필드 주입만을 사용하여 DI를 진행했습니다. 그러나 김영한 선생님의 강의를 들으며 이 필드 주입은 그다지 권장하지 않는 방법이다, 라는 것을 배웠습니다.
- why?
-> 필드 주입을 할 경우 객체가 생성되고 난 뒤에 의존성 주입이 일어나기 때문에 객체가 가변적이게 됩니다.
-> 이는 다른 개발자가 건드릴 가능성이 존재하기 때문에 버그로 이어질 수 있습니다.
-> 또한 테스트에 어려움을 겪습니다.
**LoginServiceImpl.java**
@Autowired
private UserRepository userRepository;
@Autowired
private AccessJwtToken accessJwtToken;
**LoginServiceTest.java**
@BeforeEach
void setUp() {
loginService = new LoginServiceImpl();
//test
try {
Field repository = LoginServiceImpl.class.getDeclaredField("userRepository");
repository.setAccessible(true);
repository.set(loginService, userRepository);
} catch (NoSuchFieldException e) {
throw new RuntimeException(e);
} catch (IllegalAccessException e) {
throw new RuntimeException(e);
}
}
-> 다음과 같이 LoginServiceImpl에 필드 주입한 userRepository는 테스트 코드에서 reflection field 객체를 사용하여 해당 클래스의 userRepository를 찾은 뒤 별도의 세팅을 해주어야 합니다. 이는 가독성과 유지보수성을 떨어뜨리고 setAccessible의 설정은 코드의 캡슐화를 떨어뜨립니다.
-> 반면 생성자 주입을 사용한 코드는 다음과 같습니다.
**LoginServiceImpl.java**
@Service
@RequiredArgsConstructor
public class LoginServiceImpl implements LoginService{
private final UserRepository userRepository;
private final AccessJwtToken accessJwtToken;
-> 생성자 함수가 없어서 의아할 수 있는데 롬복에서 지원하는 @RequiredArgsConstructor 어노테이션이 생성자 함수를 대체합니다. 자동으로 해당 변수를 인자로 한 생성자를 만들어줍니다.
@Autowired
public LoginServiceImpl(UserRepository userRepository, AccessJwtToken accessJwtToken) {
this.userRepository = userRepository;
this.accessJwtToken = accessJwtToken;
}
-> 어노테이션을 사용하지 않는다면 다음과 같은 생성자 함수를 추가하면 됩니다.
**LoginServiceTest.java**
@Mock
private UserRepository userRepository;
@InjectMocks
private LoginServiceImpl loginService;
-> 테스트 코드 내에서는 별도의 전처리 없이 @InjectMocks 를 통해 객체를 생성하고 자동으로 주입받습니다.
-> 때문에 테스트 코드가 직관적이고 간결해질 뿐 아니라 해당 객체(LoginServiceImpl.java)에서도 userRepository는 final로 값을 할당받아 불변성이 유지됩니다.
-> 이는 필드 주입과 반대로 값이 수정되지 않아 명확해져서 버그의 위험을 줄일 수 있습니다.
여기서 멍청한 나의 의문 한가지
-> SI 개발 특성 때문인지 회사에서는 테스트 코드에 대한 가이드가 전무했기 때문에 테스트 코드는 만들지 않았고 필드 주입에 대한 단점이 드러나지 않았습니다.
-> 그래서 테스트 코드는 처음 접한 것이었고 Mockito라는 이 모의 객체를 생성한다는 개념도 생소했습니다.
-> 때문에 저는 이 테스트 코드에서 현재 의존성 주입중인 userRepository, loginServiceImpl을 생성자 주입으로 대체할 수 있지 않을까, 라는 생각을 하였습니다.
LoginServiceImpl.java
...
@Service
public class LoginServiceImpl implements LoginService{
private final UserRepository userRepository;
private final AccessJwtToken accessJwtToken;
@Autowired
public LoginServiceImpl(UserRepository userRepository, AccessJwtToken accessJwtToken) {
this.userRepository = userRepository;
this.accessJwtToken = accessJwtToken;
}
...
-> LoginServiceImpl에서 userRepository, accessJwtToken을 생성자 함수로 의존성 주입받아 사용중인 모습입니다.
LoginServiceTest.java
...
@ExtendWith(MockitoExtension.class)
@RequiredArgsConstructor
class LoginServiceTest {
@Mock
private final UserRepository userRepository;
@InjectMocks
private final LoginServiceImpl loginService;
...
-> LoginServiceTest도 이와 마찬가지로 롬복 어노테이션을 넣고 mock 임시 객체를 할당받는 녀석들을 final 변수로 할당해주면(생성자 주입을 해주면) '오 가독성도 나쁘지 않고 생성자 주입의 장점인 불변성을 가질 수 있겠다!' 하는 생각을 했습니다.
-> 빨간줄도 안뜹니다.
-> 그러나 실행 시 'error: variable userRepository not initialized in the default constructor' 다음과 같은 초기화를 할 수 없다는 오류가 떴습니다.
-> 결론부터 말하자면 Mockito가 임시 객체를 만들기 위해 초기화하는 시점과 final변수의 초기화 시점이 다르기 때문입니다.
@Test
void findUserInfoByUserIdAndUserPw_success() throws Exception {
//given
LoginDto loginDto = new LoginDto("id1", "pw1");
User user = new User();
user.setUserSeq(1);
user.setUserId("id1");
user.setUserPw("pw1");
user.setPhoneNumber("010-1234-5678");
when(userRepository.findByUserIdAndUserPw(loginDto.userId(), loginDto.userPw())).thenReturn(user);
//when
UserVo info = loginService.findUserInfoByUserIdAndUserPw(loginDto);
//then
System.out.println(info);
assertNotNull(info);
assertEquals(1, info.userSeq());
assertEquals("id1", info.userId());
}
-> 해당 테스트 코드는 유저 아이디, 패스워드를 통해 값을 조회할 때(로그인 API) user 객체의 값을 잘 가져올 수 있는지 테스트하는 코드입니다.
-> 원래 userRepository는 데이터베이스에서 값을 조회하여 가져옵니다. 그러나 테스트 코드는 항상 독립적이고 빠르게 운용이 가능해야 합니다.(FIRST법칙 참조)
-> 그래서 데이터베이스 개입 없이 임시 객체(Mockito)를 만들어서 값을 할당해주고(given 과정) 할당된 값과 일치하는지에 대한 검증을 거치게 됩니다.
-> 이 과정에서 mockito는 해당 객체를 초기화하는 과정이 필요하게 되는 것입니다.
-> 즉, Mockito는 @Mock 어노테이션이 붙은 필드를 초기화하는 시점이 객체가 생성된 후(해당 객체를 호출할때) 이지만 final 객체는 객체가 생성될 때 초기화해야 해서 불가능합니다.
@InjectionMocks
- 어노테이션 사용과 수동 주입의 차이
-> Mockito가 모의 객체를 주입하는 방법은 앞서 설명한 어노테이션을 사용하는 방법, 그리고 수동으로 주입하는 방법이 있습니다.
@BeforeEach
void setUp() {
loginService = new LoginServiceImpl(userRepository, new AccessJwtToken());
}
-> 다음과 같이 어노테이션 사용없이 수동으로 초기화가 가능합니다.
-> 수동 주입할 경우, 초기화 코드가 추가되기 때문에 코드가 길어지는 단점이 있지만 주입과정에서 발생하는 문제에 대한 디버깅이나 의존성 주입이 명확하게 드러나는 장점이 있습니다.
-> 제가 진행하는 토이 프로젝트의 경우 로직이 간단하니 어노테이션을 사용하기로 결정했습니다.