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이 떨어집니다.

이유는 제 프로젝트의 인증 전략 때문이었습니다. 저를 포함한 많은 실무 프로젝트에서는 컨트롤러 단에서 @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) 처리를 마친 것과 완벽히 동일한 상태로 컨트롤러 내부를 활보하게 만들어 줍니다.

물론 토큰 파싱 자체의 Filter 단락 테스트는 별도로 진행해야 하지만, 수백 개가 넘어가는 비즈니스 API들의 통합/단위 테스트에서는 이 커스텀 어노테이션이 엄청난 쾌적함과 생산성을 가져다주었습니다.

JWT 기반의 폐쇄적인 API 테스트 작성에 고통받고 계셨다면, 무조건 '나만의 커스텀 시큐리티 어노테이션'을 도입해 보시기를 강력히 추천합니다!