Spring BootSpring SecurityTestingJWT

Spring Security: JWT 인증이 필요한 API의 완벽한 통합 테스트 환경 구축기

로그인이 필수인 API를 테스트할 때 매번 토큰을 발급받아야 하는 번거로움을 피하고, 우아하게 인증 컨텍스트를 주입(Mocking)하여 테스트하는 방법을 다룹니다.

Srue2026년 4월 10일
Spring Security: JWT 인증이 필요한 API의 완벽한 통합 테스트 환경 구축기

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단계: 커스텀 어노테이션 생성

우선 사용할 패키지에 새로운 어노테이션을 만듭니다.

WithAuthUser.java
@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를 구현합니다.

WithAuthUserSecurityContextFactory.java
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 테스트 코드로 돌아와 볼까요?

ProfileControllerTest.java
@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 테스트 작성에 고통받고 계셨다면, 무조건 '나만의 커스텀 시큐리티 어노테이션'을 도입해 보시기를 강력히 추천합니다!