🔐Spring Security가 적용된 Controller 단위 테스트에서 403 에러 해결
2025. 7. 1. 18:27

Spring Security가 적용된 프로젝트에서 컨트롤러 단위 테스트를 진행하였습니다.

`200` 응답을 기대하는 상황에서 `403` 에러가 발생한 상황에 대해 설명하고,

이를 해결한 방법을 공유하고자 합니다.

0️⃣ 해당 프로젝트의 구조 및 인증 방식 소개

해당 프로젝트는 Spring Security 기반의 인증 구조를 가지고 있으며, JWT 기반으로 인증 및 권한 관리를 처리합니다.

클라이언트는 로그인 이후에 발급된 AccessToken을 Authorization 헤더에 포함시켜서 요청해야 하고, 서버는 JwtFilter를 통해서 해당 토큰을 파싱하고 인증 처리를 수행합니다.

if (jwt != null && jwt.startsWith("Bearer ")) {
    jwt = jwt.substring(7);

    // db기반으로 블랙리스트에 토큰 유무 검사
    if (refreshTokenRepository.isBlacklisted(jwt)) {
        throw new ApiException(ErrorType.TOKEN_BLACKLISTED.getDesc(), ErrorType.TOKEN_BLACKLISTED);
    }

    // JwtUtil을 통한 Claims 파싱 및 인증 정보 추출
    Claims claims = jwtUtil.validateToken(jwt);
    String userId = claims.getSubject();
    String email = claims.get("email", String.class);
    UserRole userRole = UserRole.of(claims.get("userRole", String.class));
}

즉, JwtFilter는 토큰 유효성 검사 뿐만 아니라, 내부적으로 refreshTokenRepository 빈을 통해 DB에 저장된 블랙리스트를 조회하고, JwtUtil 빈을 통하여 Claims 파싱 및 인증 객체 생성까지 수행합니다. 이처럼 JwtFilter는 테스트 환경에서 별도의 의존성 주입 없이 단독으로 동작할 수 없으며, 여러 인증 관련 Bean들에 강하게 의존하고 있습니다.


1️⃣ 테스트 중 발생한 에러 상황

PaymentController의 단건 결제 승인 API를 테스트하기 위해 @WebMvcTest 기반의 단위 테스트를 작성했습니다.

@WebMvcTest는 Spring MVC의 웹 계층(Controller)만을 테스트할 수 있도록 도와주는 어노테이션입니다. Service나 Repository 같은 빈들은 로딩되지 않고, 명시적으로 MockBean으로 주입해야 합니다. 또한, 기본적으로 Spring Security 설정이 함께 활성화됩니다.

`403 Forbidden` 에러는 SecurityFilterChain 설정에 의해 요청이 차단되었음을 의미하며,

이는 현재 테스트 환경에서 Spring Security가 인증되지 않은 요청을 거부했기 때문입니다.


2️⃣ 각 문제의 해결 과정

✅ 문제 1. JwtFilter 내부의 의존성 문제 -> 테스트에서 JwtFilter 제외

@WebMvcTest는 컨트롤러 관련 빈만 로딩하고, 그 외의 서비스나 레파지토리는 자동으로 로딩하지 않습니다.

그러나 현재 JwtFilter는 `refreshTokenRepository`와 `JwtUtil`에 의존하고 있기 때문에

테스트 환경에서는 필터가 정상적으로 돌아가지 않았고, 인증 실패로 인한 403 Forbidden 에러가 발생하게 되었습니다.

테스트 코드에 해당 빈들을 주입하는 것도 가능하지만,

단위 테스트에서는 보안 필터 자체를 우회하는 것이 더 적절하다고 판단하여, JwtFilter를 테스트에서 제외하였습니다. 

@WebMvcTest(controllers = PaymentController.class, excludeFilters = {
    @ComponentScan.Filter(type = FilterType.ASSIGNABLE_TYPE, classes = {JwtFilter.class})
})

✅ 문제 2. 인증되지 않은 사용자 요청 -> @WithMockUser 사용

JwtFilter를 제거하니 인증 필터는 작동하지 않았지만,

여전히 컨트롤러에서는 인증객체를 필요로 하기 때문에 에러가 발생하였습니다.

이를 위해서 가짜 인증 유저인 척! 해주는 @WithMockUser를 주입하였습니다.

해당 어노테이션을 사용하기 위해서는 의존성 코드가 필요합니다.

dependencies {
        testImplementation 'org.springframework.security:spring-security-test'
}

처음에는 다음과 같이 기본 사용자 역할을 설정하였습니다.

@WithMockUser(username = "123", roles = "USER")

그러나 여전히 403 에러가 났고, role에 대한 eunm 값을 확인해보니, seller,parner,admin 값만 가지고 있음을 확인하였습니다

또한 실제 보안 설정인 SecurityConfig에서는 결제 api에 대하여 `hasRole("SELLER")라는 조건이 설정되어 있었습니다.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.csrf(AbstractHttpConfigurer::disable)
        .authorizeHttpRequests(authorize -> authorize
            .requestMatchers("/api/v1/auth/**").permitAll()
            .requestMatchers("/api/v1/payments/**").hasRole("SELLER")
            ...
        );
    ...
}

따라서 권한 문자열을 Enum값과 보안 조건에 맞게 수정하였습니다.

@WithMockUser(username = "123", roles = "SELLER")

✅ 문제 3. CSRF 보호 정책으로 인한 거부 -> with(csrf()) 코드 추가

여전히 403 에러가 떠 있는 상황이였고, 문제 상황을 파악하기 위하여 `application-test.yml` 파일에 로깅 설정을 추가하였습니다.

logging:
  level:
    root: debug
2025-07-01 17:59:17 [Test worker] DEBUG o.s.security.web.csrf.CsrfFilter - Invalid CSRF token found for http://localhost/api/v1/payments/one-time
2025-07-01 17:59:17 [Test worker] DEBUG o.s.s.w.a.AccessDeniedHandlerImpl - Responding with 403 status code

해당 로그를 통하여 현재 테스트 실패(HTTP 403)는 CSRF 토큰이 유효하지 않아서 발생한 것임을 확인하였습니다.

CSRF(Cross-Site Request Forgery)는 사용자 의도하지 않은 요청을 웹 애플리케이션에 강제로 보내게 만드는 공격으로, 주로 사용자가 로그인 된 상태에서 공격자가 의도한 악성 요청이 사용자의 권한으로 서버에 전달되어 원치 않는 동작이 실행됩니다.

하지만 이 프로젝트는 쿠키가 아닌 Authorization 헤더로 인증하는 JWT 기반 인증을 사용하기 때문에 CSRF 공격 대상이 아닙니다. 그래서 CSRF 보호를 굳이 유지할 필요가 없기 때문에 SecurityConfig에 아래와 같이 CSRF 보호를 끄는 설정을 해두었는데

http.csrf(AbstractHttpConfigurer::disable)

CSRF 토큰이 유효하지 않다는 에러가 발생하였습니다.

그런데 지금 테스트에서 `disable()`이 적용되지 않는 이유는!

@WebMvcTest는 전체 Spring Context가 아니라 웹 레이어 일부만 로딩하기 때문에,

테스트에서는 이 설정이 누락된 Security 설정이 임시로 로딩되므로 CSRF 검사가 활성화 된 것입니다.

이에 대하여 mockMVC에 `.with(csrf())` 코드를 추가하여 테스트 요청에는 CSRF 토큰을 추가하여 문제를 해결하였습니다.

mockMvc.perform(post("/api/v1/payments/one-time")
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))

3️⃣ 최종적으로 테스트 코드 성공!

최종적으로 아래와 같이,

JwtFilter를 제외하고 @WithMockUser로 권한을 부여하고, CSRF 토큰까지 포함한 테스트 코드를 작성하여

테스트를 성공하였습니다.

@Test
    @WithMockUser(username = "123", roles = "SELLER")  // 인증된 사용자인 척
    void 단건결제승인_요청시_성공응답을_반환한다() throws Exception {
        // given
        PaymentConfirmRequest request = new PaymentConfirmRequest("paymentKey123",123L,"orderId123",10000);
        PaymentConfirmResponse mockResponse = new PaymentConfirmResponse("paymentId123","orderId123",PaymentStatus.COMPLETE,"2025-07-01T10:00:00",10000,"KRW",PaymentMethod.EASY_PAY_CARD,"1234","https://receipt.url");

        given(paymentService.confirmPayment(any())).willReturn(mockResponse);

        // when & then
        mockMvc.perform(post("/api/v1/payments/one-time")
                .with(csrf())
                .contentType(MediaType.APPLICATION_JSON)
                .content(objectMapper.writeValueAsString(request)))
            .andExpect(status().isOk())
            .andExpect(jsonPath("$.status").value(200))
            .andExpect(jsonPath("$.message").value("단건 결제가 승인되었습니다."));
    }

4️⃣ @WithMockUser로 해결되지 않는 인증 문제 발견!

Controller에서 api를 테스트하던 중, @WithMockUser로 해결되지 않는 인증 문제를 발견하였습니다.

사용자 본인의 결제 내역을 조회하는 api인데,

아래의 코드를 사용하여 토큰에서 로그인 한 seller의 정보를 추출하는 방식으로 사용자를 인증합니다.

@AuthenticationPrincipal CustomUserDetail userDetail,

인증 문제를 해결하기 위하여 @WithMockUser를 통하여 가짜 객체를 주입하였으나

@WithMockUser(username = "123", roles = "SELLER")
Resolved Exception:
Type = java.lang.NullPointerException

CustomUserDetail에는 @WithMockUser가 적용되지 않기 때문에, UserDetail = null 이 됨에따라, NPE가 발생하였습니다.

자세히 말하자면, @WithMockUser는 Spring Security가 제공하는 기본 user 객체만을 생성하기 때문에

컨트롤러에서 @AuthenticationPrincipal로 받고 있는 CustomUserDetail을 생성하지 못해서 null을 주입하게 된 것입니다.

 

그래서 @WithMockUser 코드를 제거해주면, SecurityContext에 인증 객체가 전혀 없기 때문에 401 에러가 뜹니다.

Status
필요:200
실제   :401

이를 위하여 @AuthenticationaPrincipal(expression = "username") 방식으로 String 타입으로 간접 추출하는 방법도 있으나,

이는 컨트롤러가 로그인 유저 정보를 받는 실제 환경과 테스트간의 괴리를 만들 수 있다고 판단하였습니다.

따라서 테스트에서 실제 인증 환경과 동일한 구조를 유지하고자,

CustomUserDetail 객체를 직접 생성하고, UsernamePasswordAuthenticationToken을 이용하여

SecurityContext에 명시적으로 인증 객체를 주입하여 테스트 코드를 성공적으로 실시하였습니다.

CustomUserDetail userDetail = new CustomUserDetail("123", "test@email.com", UserRole.SELLER);
Authentication auth = new UsernamePasswordAuthenticationToken(
    userDetail, null, List.of(new SimpleGrantedAuthority("ROLE_SELLER"))
);
mockMvc.perform(get("/api/v1/payments/mypayments")
    .with(authentication(auth)))

5️⃣ 회고

이번 Controller 단위 테스트를 진행하며 @WebMvcTest에 맞는 보안 설정을 어떻게 구성해야 할지 고민해야 했습니다. 특히 실제 애플리케이션과 테스트 환경에서 Spring Security 설정이 다르게 동작한다는 사실을 몰랐기 때문에 초반에는 많은 시행착오를 겪었습니다.

예를 들어, 애플리케이션에서는 http.csrf().disable() 설정으로 CSRF 보호를 비활성화했지만, 테스트에서는 해당 설정이 반영되지 않아서 별도로 .with(csrf()) 처리를 해야했습니다. csrf처럼 필터에 추가로 설정한 부분들은 실제 앱과 테스트 환경에서 다르게 동작한다는 것을 알 수 있었습니다.

또한 JwtFilter가 정상적으로 동작하지 않는 상황에서, 이를 테스트에 포함시키기 위해 필요한 빈들을 모두 주입할 것인지, 아니면 테스트 대상에서 제외할 것인지 고민이 필요했습니다. 결론적으로 이 테스트는 컨트롤러의 기능만 검증하는 단위 테스트였기 때문에, 보안 필터를 우회하고 테스트 대상 로직에만 집중하는 전략을 선택했습니다.

이번 테스트를 통해, 테스트 환경은 실제 환경과 동일하게 구성할 수 없으며, 테스트 목적에 따라 어떤 구성을 선택할지 판단하는 것은 전적으로 개발자의 몫임을 느낄 수 있었습니다.

 

 

LIST