Spring Security: JWT 인증이 필요한 API의 완벽한 통합 테스트 환경 구축기
로그인이 필수인 API를 테스트할 때 매번 토큰을 발급받아야 하는 번거로움을 피하고, 우아하게 인증 컨텍스트를 주입(Mocking)하여 테스트하는 방법을 다룹니다.

JWT 인증이 필요한 API의 완벽한 통합 테스트 환경 구축기
이전 글 JWT Refresh Token 전략에서 토큰의 생명주기와 재발급 로직을 탄탄하게 구축했습니다. 그런데 막상 비즈니스 로직(예: 내 정보 수정, 주문하기 등) API를 검증하려니 큰 산을 만났습니다.
이 API들은 "로그인된 사용자(토큰 보유자)"만 접근할 수 있기 때문에, 단순히 API 엔드포인트를 때리는 통합 테스트를 작성하면 여지없이 401 Unauthorized를 뱉어냅니다.
오늘은 테스트 환경에서 귀찮게 로그인 API 호출 -> 토큰 추출 -> 헤더에 삽입 -> 대상 API 호출이라는 길고 번거로운 과정을 거치지 않고, 빠르고 우아하게 인증 객체를 주입(Mocking)하여 단위/통합 테스트를 작성하는 '삽질 기록'을 공유하려 합니다.
가장 비효율적인 방법: 매번 로그인 통신하기
처음 통합 테스트를 짤 때는 매우 정직하게 코딩했습니다.
@Test
void 내_정보_수정_성공() throws Exception {
// 1. 유저 강제 회원가입 및 DB 적재
// 2. 로그인 API(/api/v1/auth/login) 호출하여 JWT 토큰 파싱
String token = getJwtToken("testUser", "password");
// 3. 발급받은 토큰을 헤더에 넣고 실제 타겟 API 호출
mockMvc.perform(post("/api/v1/profile")
.header(HttpHeaders.AUTHORIZATION, "Bearer " + token)
.content(...))
.andExpect(status().isOk());
}이 방식은 운영 환경과 100% 동일한 흐름을 타기 때문에 안심은 되지만, 테스트 수행 속도가 지옥을 보여줍니다. 단순한 내 정보 수정 로직 하나 검증하려는데 네트워크 레이어를 타는 로그인 통신과 Bcrypt 패스워드 검증 연산이 매번 일어났기 때문입니다.
구원 투수: @WithMockUser 의 아쉬움
Spring Security 테스트 모듈을 뒤져보면 @WithMockUser라는 굉장히 직관적인 어노테이션을 지원합니다.
@Test
@WithMockUser(username = "1", roles = "USER")
void 내_정보_수정_성공() { ... }이렇게 달아주면 SecurityContext에 가짜 유저가 박힙니다. 야호! 하고 돌렸는데 곧바로 ClassCastException이 떨어집니다.
메시지를 뜯어보면 SecurityContext에 들어간 principal 타입이 우리가 기대한 것과 달랐습니다.
이유는 제 프로젝트의 인증 전략 때문이었습니다. 저를 포함한 많은 실무 프로젝트에서는 컨트롤러 단에서 @AuthenticationPrincipal을 이용해 단순히 문자열(username)이 아닌 커스텀 유저 객체 정보(예: CustomUserDetails)를 매핑하여 꺼내어 씁니다.
@PostMapping("/profile")
public ResponseEntity<?> updateProfile(@AuthenticationPrincipal CustomUserDetails user) {
Long userId = user.getId(); // <--- @WithMockUser는 일반 User 객체를 넣어버려서 여기서 캐스팅 에러 펑!!
}나만의 인증 컨텍스트 만들기: 커스텀 @WithAuthUser
결국 우리 프로젝트의 커스텀 인증 정보(CustomUserDetails)를 SecurityContext에 예쁘게 끼워 넣어주는 전용 테스트 어노테이션을 만들어야 했습니다.
1단계: 커스텀 어노테이션 생성
우선 사용할 패키지에 새로운 어노테이션을 만듭니다.
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithAuthUserSecurityContextFactory.class)
public @interface WithAuthUser {
String id() default "1";
String email() default "test@srue.kr";
String role() default "ROLE_USER";
}2단계: SecurityContext Factory 구현
이 어노테이션이 달렸을 때, 스프링 시큐리티의 전역 저장소인 SecurityContext에 방금 명시한 정보들을 우리 입맛에 맞게 커스텀 객체로 포장해주는 Factory를 구현합니다.
public class WithAuthUserSecurityContextFactory implements WithSecurityContextFactory<WithAuthUser> {
@Override
public SecurityContext createSecurityContext(WithAuthUser annotation) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
// 1. 프로젝트에서 사용하는 실제 CustomUserDetails 인스턴스 생성
CustomUserDetails principal = new CustomUserDetails(
Long.valueOf(annotation.id()),
annotation.email(),
annotation.role()
);
// 2. JWT 필터가 동작한 후와 동일하게 Authentication 객체 생성
Authentication auth = new UsernamePasswordAuthenticationToken(
principal,
"password",
principal.getAuthorities()
);
// 3. SecurityContext에 주입!
context.setAuthentication(auth);
return context;
}
}아름다워진 테스트 코드
자, 이제 이 모든 세팅을 마치고 본래 작성하던 컨트롤러 API 테스트 코드로 돌아와 볼까요?
@WebMvcTest(ProfileController.class)
class ProfileControllerTest {
@Autowired MockMvc mockMvc;
@Test
@WithAuthUser(id = "100", email = "hello@srue.kr")
void 내_정보_수정_성공_테스트() throws Exception {
mockMvc.perform(post("/api/v1/profile")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"nickname\": \"새로운닉네임\"}"))
.andExpect(status().isOk());
}
}이제 더 이상 느려터진 로그인 API를 선행 호출할 필요가 없습니다. @WithAuthUser 한 줄만 달아주면 스프링이 알아서 토큰을 파싱하고 인가(Authorization) 처리를 마친 것과 완벽히 동일한 상태로 컨트롤러 내부를 활보하게 만들어 줍니다.
실제로 같은 테스트를 로그인 선행 호출 방식과 @WithAuthUser 방식으로 각각 돌려보면 수행시간 차이가 분명합니다.
물론 토큰 파싱 자체의 Filter 단락 테스트는 별도로 진행해야 하지만, 수백 개가 넘어가는 비즈니스 API들의 통합/단위 테스트에서는 이 커스텀 어노테이션이 엄청난 쾌적함과 생산성을 가져다주었습니다.
JWT 기반의 폐쇄적인 API 테스트 작성에 고통받고 계셨다면, 무조건 '나만의 커스텀 시큐리티 어노테이션'을 도입해 보시기를 강력히 추천합니다!