김영한 님의 스프링 핵심 원리 - 기본편
을 들으며 실습하던 중, @MockBean
과 @SpyBean
의 차이를 볼 수 있는 트러블 슈팅을 경험했습니다.
제가 실습하던 부분은 List
, Map
을 이용한 전략 패턴
이었습니다.
Spring
을 이용하다 보면 종종 해당 타입의 Bean 들이 모두 필요한 경우
가 있을 수 있습니다.
제가 생각한 예시는 민간 인증서 서비스
로, 사용자가 인증할 때 인증 수단 중 어떤 것을 사용할지 동적으로 결정되어야 하기 때문입니다.
사용자 선택에 따라 카카오
, 네이버
, 토스
등 인증 수단이 달라지기 때문입니다.
그래서 위 인증 서비스
예시를 전략 패턴
을 이용해 간단히 만들어 보면 공부되겠다 싶어 아래처럼 코드를 구성하였습니다.
CertificationType
public enum CertificationType {
KAKAO, NAVER, PASS, TOSS
}
Certification
// 전략 패턴을 위한 인터페이스
public interface Certification {
void doCertification();
CertificationType getCertificationType();
}
// 인증 수단별 구현체 (네이버, Pass, 토스는 생략)
@Component
public class KakaoCertification implements Certification{
@Override
public void doCertification() {
System.out.println("I'm kakao certification");
}
// 각 인증 수단별 올바른 타입을 반환
@Override
public CertificationType getCertificationType() {
return CertificationType.KAKAO;
}
}
CertificationService
@Service
public class CertificationService {
private final Map<CertificationType, Certification> certifications;
public CertificationService(List<Certification> certifications) {
// 나중 설명을 위해 객체 속성 print
certifications.forEach(System.out::println);
certifications.forEach(c -> System.out.println(c.getCertificationType()));
// 인증 타입에 따라 Map 에 잘 넣어주기
this.certifications = certifications.stream()
.collect(Collectors.toMap(
Certification::getCertificationType, Function.identity()
));
}
// Map 에 넣어진 걸로 인증 진행
public void doCertification(CertificationType type) {
certifications.get(type).doCertification();
}
}
필요한 클래스를 구성한 후 아래처럼 Mock
을 이용해 실제 메서드가 호출되었는지 확인
하는 테스트 코드를 만들었습니다.
@SpringBootTest
public class StrategyPatternTest {
@MockitoBean KakaoCertification kakao;
@MockitoBean NaverCertification naver;
@MockitoBean PassCertification pass;
@MockitoBean TossCertification toss;
@Autowired
CertificationService certificationService;
@Test
void doTest() {
Arrays.stream(CertificationType.values())
.forEach(certificationService::doCertification);
for (Certification certification : List.of(kakao, naver, pass, toss)) {
verify(certification, times(1)).doCertification();
}
}
}
여담으로 우리가 기존 사용하던
@MockBean
은
spring-boot-test
3.4.0 버전부터deprecated
되어
3.6.0 버전에는 없어질 것이라 하고@MockitoBean
사용을 권장하고 있습니다.
하지만 실행하니 CertificationService 의 생성자에서 에러가 발생
해 테스트가 실패하였습니다.
Failed to load ApplicationContext for [...]
...
Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'certificationService' ... : Constructor threw exception
at org.springframework.beans.factory.support.ConstructorResolver.instantiate(ConstructorResolver.java:321)
...
이를 확인하고자 생성자에 CertificationType
을 확인해보니 아래처럼 Null
임을 확인했습니다.
@Service
public class CertificationService {
private final Map<CertificationType, Certification> certifications;
public CertificationService(List<Certification> certifications) {
// 직접 제공된 Bean 들 확인
certifications.forEach(System.out::println);
// CertificationType 도 직접 확인
certifications.forEach(c -> System.out.println(c.getCertificationType()));
/* ... 생략 ... */
}
}
kakaoCertification // ? 객체면 hashCode 뒤에 붙을텐데 뭐지?
naverCertification // 난 toString 건든 적 없는데?
passCertification // passCertification@7d90644f 이렇게?
tossCertification
null // ?? Null?? 왜 비어있음?
null
null
null
문제의 원인은 @MockBean
그 자체에 있었습니다.
Mock
의 사전적 정의는 모조품
으로, Mockito
는 특정 Bean
들의 행동을 감시하고 재정의 할 수 있는 모조품 Bean
을 제공합니다.
때문에 KakaoCertification
, NaverCertification
등을 테스트에서 @MockBean
으로 구성할 경우, 각 실제 Bean
의 메서드가 실행되지 않고 모조품의 메서드만 실행됩니다.
즉, @MockBean
들의 getCertificationType()
메서드 행동을 정의하지 않았기 때문에 Null
이 반환되었고 그래서 예외가 발생했던 것 이었습니다.
(Map 의 key 에 Null 인 값을 중복으로 넣으려 했기 때문에 예외 발생)
이를 해결하는 방법은 다양하나, 가장 1 차원적인 생각으로 @MockBean 들의 행동을 모두 정의
하는 방법이 있습니다.
@Test
void doTest() {
// MockBean 행동 모두 정의
when(kakao.getCertificationType()).thenReturn(CertificationType.KAKAO);
when(naver.getCertificationType()).thenReturn(CertificationType.NAVER);
/* ... */
}
하지만 이는 동일한 코드를 너무 많이 작성하고 무엇보다 Bean 들이 내가 구현한 대로 작동하는지 확인
하는 저의 테스트 의도와 맞지 않았습니다.
그래서 @MockBean
대신 @SpyBean
을 이용하였습니다.
@SpringBootTest
public class StrategyPatternTest {
@MockitoSpyBean KakaoCertification kakao;
@MockitoSpyBean NaverCertification naver;
@MockitoSpyBean PassCertification pass;
@MockitoSpyBean TossCertification toss;
@Autowired
CertificationService certificationService;
@Test
void doTest() {
Arrays.stream(CertificationType.values())
.forEach(certificationService::doCertification);
List.of(kakao, naver, pass, toss).forEach(
c -> verify(c, times(1)).doCertification()
);
}
}
@SpyBean
은 @MockBean
처럼 행동을 감시하고 재정의
할 수 있는 것을 동일하지만, Spring
에서 제공한 Bean
에 기반한 모조 Bean
입니다.
즉, @MockBean
은 행동을 정의 하지 않으면
앞선 상황처럼 Null
을 뱉어내지만, @SpyBean
은 우리가 만든 행동 그대로 작동하는 모조품인 것입니다.
이 둘의 차이점을 간단하게 정리하면 아래 표처럼 나타낼 수 있습니다.
@MockBean | @SpyBean | |
---|---|---|
내부 동작 | 완전한 Mock (모조품) 임. 실제 Spring Bean 과 관련 없음. | 우리가 구성한 Spring Bean 을 감싸서 동작 |
기본 method call | 실제 메서드가 호출되지 않음. 정의하지 않으면 Null 을 반환함. | 실제 메서드가 호출됨. 특별히 Mock 의 행동을 지정하면 그대로 행동함. |
원래 @MockBean
과 @SpyBean
의 다른점은 알고 있었지만 이를 실제로 경험한 것은 처음이었습니다.
깊고 좁게 공부하는 것도 중요하지만, 다양한 트러블 슈팅에 대처하기 위해 넓게 아는 것도 중요하다 느꼈습니다.