나의 개발일지
[트러블 슈팅] Spring 환경에서 @WebMvcTest를 활용한 컨트롤러 테스트 진행하기 - @WebMvcTest 를 사용하는 환경에서 AOP 활용 본문
[트러블 슈팅] Spring 환경에서 @WebMvcTest를 활용한 컨트롤러 테스트 진행하기 - @WebMvcTest 를 사용하는 환경에서 AOP 활용
사각분무기 2024. 3. 27. 21:38HotShare 프로젝트도 막바지에 다다랐습니다. 새로운 기능을 개발하기엔 애매한 시간입니다. 1차 프로젝트 때도 괜히 막바지에 무리를 해서 기능들을 우겨넣었다가 장애가 잔뜩 생겨서 제때 마감을 못할 뻔 했기에 조심스러워졌습니다.
그래서 테스트 코드를 작성해보기로 했습니다. 다른 팀원분들 또한 저와 같은 계획을 가지신 터라 이미 서비스 클래스들에 대한 테스트 코드를 작성하고 계시더라구요. 저는 대신 컨트롤러 클래스를 진행하기로 했습니다.
문제
@WebMvcTest(CashLogController.class)
@MockBean(JpaMetamodelMappingContext.class)
public class CashLogControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private LoginArgumentResolver argumentResolver;
@MockBean
private JwtProvider jwtProvider;
@MockBean
private RefreshTokenRepository refreshTokenRepository;
@MockBean
BearerAuthorizationExtractor bearerExtractor;
private static final MemberTokens MEMBER_TOKENS = new MemberTokens("refreshToken", "accessToken");
private static final Cookie COOKIE = new Cookie("refresh-token", MEMBER_TOKENS.getRefreshToken());
private final String baseUrl = "/api/v1/cashLog";
@MockBean
private CashLogService cashLogService;
@BeforeEach
void setUp() {
given(refreshTokenRepository.existsById(any())).willReturn(true);
doNothing().when(jwtProvider).validateTokens(any());
given(jwtProvider.getSubject(any())).willReturn("1");
}
private ResultActions performShowMyCashLogs() throws Exception {
return mockMvc.perform(get(baseUrl + "/me")
.queryParam("page", "0")
.queryParam("size", "1")
.header(AUTHORIZATION, MEMBER_TOKENS.getAccessToken())
.cookie(COOKIE)
.contentType(APPLICATION_JSON));
}
@Test
@DisplayName("내 캐시 내역 페이지를 조회한다.")
void showMyCashLogs() throws Exception {
// given
when(cashLogService.findMyPageList(any(), any())).thenReturn(MyCashLogResponse.of("test", 1000000L, "test@gmail.com", null));
// when
final ResultActions resultActions = performShowMyCashLogs();
// then
final MvcResult mvcResult = resultActions.andExpect(status().isOk())
.andDo(print())
.andReturn();
}
}

401 에러가 발생했습니다.
error message 는 Unauthorized 라고 떴습니다.
Unauthorized 라는 오류는 권한이 없다는 뜻이고 보통 Spring Security 환경에서 인증을 받지 못한 사용자에게 발생하는 오류입니다.
문제는 저희 프로젝트의 코드에서 Spring Security 를 통한 유저 관리를 하지 않는다는 점입니다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.cors(c -> c.configure(http))
.csrf(c -> c.disable())
.headers((headers) -> headers
.addHeaderWriter(new XFrameOptionsHeaderWriter(
XFrameOptionsHeaderWriter.XFrameOptionsMode.SAMEORIGIN))) // h2 콘솔 사용 설정
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(a -> a
.requestMatchers(
"/**"
).permitAll()
.anyRequest().authenticated()
);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
이번의 저희 프로젝트 HotShare 에서는 위와 같은 Security 설정값을 사용합니다.
사실상 모든 접근에 권한을 부여합니다.
이 말은 즉 모든 접근은 별도의 인증 절차를 필요로 하지 않는다는 뜻입니다.
저희 팀이 Spring Security 에서 실질적으로 활용하는 기능은 PasswordEncoder 뿐입니다.
Spring Security 를 통한 로그인을 하지 않기 때문에 테스트 환경에서 해당하는 로그인 환경을 만드는 것도 어렵습니다.
해결
@WithMockUser 어노테이션을 사용함으로써 해결할 수 있었습니다.
전체 애플리케이션 컨텍스트를 로드하는 @SpringBootTest 와는 다르게 @WebMvcTest 어노테이션은 웹 레이어의 컴포넌트만을 로드하여 테스트 환경을 구성할 수 있습니다.
이를 통해 더욱 가벼운 단위 테스트를 진행할 수 있지만 Spring Security 도 웹 레이어의 컴포넌트로 간주하여 기본값으로 설정되어버린다고 합니다.
By default, tests annotated with @WebMvcTest will also auto-configure Spring Security and MockMvc (include support for HtmlUnit WebClient and Selenium WebDriver). For more fine-grained control of MockMVC the @AutoConfigureMockMvc annotation can be used.
위는 @WebMvcTest 의 소스 코드에 주석으로 달려있는 내용입니다.
Spring Security 를 가져오는 것 자체는 괜찮지만 기존의 저희 Config 값을 불러오지 않는다는 것이 문제입니다.
이를 해결하는 방법으로는 @Import 어노테이션을 활용하여 기존에 활용하던 Config 클래스를 주입해주거나 아예 @TestConfiguration 어노테이션을 단 테스트 전용 Config 클래스를 작성해주는 방법이 있다고 하네요.
저의 경우 모든 접근에 인증을 부여하는 기존의 저희 방식과 비슷하다고 생각하였기 때문에 @WithMockUser 를 채택했습니다.

Spring Security 문제는 해결이 됬습니다.
하지만 아쉬웠습니다.
테스트 자체는 통과하지만 과연 우리 인증 기능이 제대로 작동하는걸까?
혹시나 싶어 코드에서 인증 기능들에 대한 항목을 모두 제외하고 테스트를 진행해보았습니다.
private ResultActions performShowMyCashLogs() throws Exception {
return mockMvc.perform(get(baseUrl + "/me")
.queryParam("page", "0")
.queryParam("size", "1")
// .header(AUTHORIZATION, MEMBER_TOKENS.getAccessToken())
// .cookie(COOKIE)
.contentType(APPLICATION_JSON));
}
문제
private ResultActions performShowMyCashLogs() throws Exception {
return mockMvc.perform(get(baseUrl + "/me")
.queryParam("page", "0")
.queryParam("size", "1")
// .header(AUTHORIZATION, MEMBER_TOKENS.getAccessToken())
// .cookie(COOKIE)
.contentType(APPLICATION_JSON));
}
위의 주석 처리된 부분이 바로 인증의 핵심이 되는 코드입니다.
액세스 토큰과 리프레시 토큰을 활용한 인증 방법을 사용하는 저희 프로젝트는 쿠키에 리프레시 토큰을, 헤더에 엑세스 토큰을 넣는 방식을 사용합니다.

결과는 성공입니다.
성공하면 안되는 부분이 성공했기 때문에 실패라고 말할 수 있습니다.
원인 파악
어딘가 작동이 되지 않는 부분이 있는 것 같았습니다.
가령 인증을 담당하는 부분이 말입니다.
저희는 MemberOnlyChecker 라는 클래스를 통해 인증을 처리하고 있습니다.
메소드의 파라미터 중 @Auth 라는 어노테이션에 의해 주입된 Accessor 의 값이 로그인한 유저의 값이 아닐 경우 INVALID_AUTHORITY 에러를 반환하도록 되어있습니다.
@Aspect
@Component
public class MemberOnlyChecker {
@Before("@annotation(com.example.hotsix_be.auth.MemberOnly)")
public void check(final JoinPoint joinPoint) {
System.out.println("================================== it's MemberOnlyChecker ==================================");
Arrays.stream(joinPoint.getArgs())
.filter(Accessor.class::isInstance)
.map(Accessor.class::cast)
.filter(Accessor::isMember)
.findFirst()
.orElseThrow(() -> new AuthException(INVALID_AUTHORITY));
}
}
코드는 다음과 같은 형식으로 작성되어 있습니다.
제대로 작동하고 있는지 확인하기 위해 위처럼 sout 문도 넣어줬습니다.
그리고 확인 결과 그 어디에도 해당 출력문을 찾아볼 수 없었습니다.

제가 테스트에 대해 잘못 이해하고 있는건가 싶어 테스트하려고 하는 컨트롤러 메소드에도 비슷한 출력문을 넣어봤습니다.

제가 현재 테스트하고자 하는 엔드 포인트의 메서드명은 showMyCashLogs입니다.
이는 MemberOnlyChecker 가 작동하고 있지 않다는 명확한 반증입니다.
시도 1
그렇다면 MemberOnlyChecker 를 주입 받지 못했기 때문인 것 같습니다.
웹 레이어의 컴포넌트만을 로드하는 @WebMvcTest 의 특성상 해당 클래스를 로드하지 않았을 수 있습니다.
@Import 어노테이션을 활용하여 MemberOnlyChecker를 직접 주입해줬습니다.

여전히 작동하지 않습니다.
해결
@EnableAspectJAutoProxy(proxyTargetClass = true) 어노테이션을 추가해줌으로써 해결했습니다.
개발 환경에선 해당 어노테이션을 사용해본 적이 없기 때문에 눈치채지 못했지만 기본값으로써 사용되고 있는 부분이라고 합니다.
다만 테스트 환경에선 기본값이 아니기 때문에 직접 만든 AOP 클래스를 활용하고 싶을 경우에는 해당 어노테이션이 필요합니다.

무사히 실패하는 것을 확인할 수 있습니다.

MemberOnlyChecker 도 정상적으로 작동하고 있음을 확인할 수 있습니다.
아래는 최종 코드입니다.
@WebMvcTest(CashLogController.class)
@MockBean(JpaMetamodelMappingContext.class)
@WithMockUser
@Import(MemberOnlyChecker.class)
@EnableAspectJAutoProxy(proxyTargetClass = true)
public class CashLogControllerTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private LoginArgumentResolver argumentResolver;
@MockBean
private JwtProvider jwtProvider;
@MockBean
private RefreshTokenRepository refreshTokenRepository;
@MockBean
BearerAuthorizationExtractor bearerExtractor;
private static final MemberTokens MEMBER_TOKENS = new MemberTokens("refreshToken", "accessToken");
private static final Cookie COOKIE = new Cookie("refresh-token", MEMBER_TOKENS.getRefreshToken());
private final String baseUrl = "/api/v1/cashLog";
@MockBean
private CashLogService cashLogService;
@BeforeEach
void setUp() {
given(refreshTokenRepository.existsById(any())).willReturn(true);
doNothing().when(jwtProvider).validateTokens(any());
given(jwtProvider.getSubject(any())).willReturn("1");
}
private ResultActions performShowMyCashLogs() throws Exception {
return mockMvc.perform(get(baseUrl + "/me")
.queryParam("page", "0")
.queryParam("size", "1")
.header(AUTHORIZATION, MEMBER_TOKENS.getAccessToken())
.cookie(COOKIE)
.contentType(APPLICATION_JSON));
}
@Test
@DisplayName("내 캐시 내역 페이지를 조회한다.")
void showMyCashLogs() throws Exception {
// given
when(cashLogService.findMyPageList(any(), any())).thenReturn(MyCashLogResponse.of("test", 1000000L, "test@gmail.com", null));
// when
final ResultActions resultActions = performShowMyCashLogs();
// then
final MvcResult mvcResult = resultActions.andExpect(status().isOk())
.andDo(print())
.andReturn();
}
}'Language > Java' 카테고리의 다른 글
| [Java] Gdal 라이브러리 사용하기 - 2. TempFile to Dataset 변환 및 후처리 (0) | 2025.04.29 |
|---|---|
| [Java] Gdal 라이브러리 사용하기 - 1. InputStream to TempFile 변환 및 후처리 (1) | 2025.04.28 |
| [Java] 문자열 클래스 (1) | 2024.03.14 |
| [Java] 직렬화 (0) | 2024.03.08 |
| 오토 박싱 & 오토 언박싱 (1) | 2024.03.06 |