Spring Security 테스트에서 커스텀 사용자 인증 객체 주입 문제 해결

궁금하면 500원·2025년 7월 18일
0

미생의 개발 이야기

목록 보기
53/58

1. 문제 상황

Antock Public Data Harvester 프로젝트에서 @WebMvcTest를 사용하여 컨트롤러 계층을 테스트하던 중, @CurrentUserAuthenticatedUser 객체를 주입받는 API 테스트에서 예상과 다른 결과가 발생했습니다.

현재 프로젝트의 인증 구조

// 커스텀 ArgumentResolver
@Component
public class CurrentUserArgumentResolver implements HandlerMethodArgumentResolver {

    @Override
    public boolean supportsParameter(MethodParameter parameter) {
        return parameter.hasParameterAnnotation(CurrentUser.class) &&
                parameter.getParameterType().equals(AuthenticatedUser.class);
    }

    @Override
    public Object resolveArgument(MethodParameter parameter, ...) {
        // JWT 토큰에서 사용자 정보 추출
        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        // ...
        return authenticatedUser;
    }
}
// 컨트롤러 메소드
@GetMapping("/profile")
public ApiResponse<MemberResponse> getCurrentMemberInfo(@CurrentUser AuthenticatedUser user) {
    MemberResponse response = memberApplicationService.getCurrentMemberInfo(user.getId());
    return ApiResponse.success(response);
}

발생한 문제

테스트 실행 시 다음과 같은 현상들이 나타났습니다:

  1. 컴파일 에러: String, Long 등 기본 타입들이 cannot be resolved to a type 오류 발생
  2. SecurityContext 인증 실패: @WithMockUser 사용 시 AuthenticatedUser가 null로 주입됨
  3. JWT 토큰 부재: CurrentUserArgumentResolver에서 토큰을 찾을 수 없어 사용자 정보 생성 실패

2. 문제 분석

시나리오별 분석

시나리오 1: @WithMockUser 사용

@WebMvcTest(MemberApiController.class)
@WithMockUser(roles = "ADMIN")
class MemberApiControllerTest {
    // 테스트 실행 시 AuthenticatedUser가 null로 주입됨
}

문제점:

  • @WithMockUserorg.springframework.security.core.userdetails.User 객체를 principal로 설정
  • CurrentUserArgumentResolver는 JWT 토큰에서 사용자 정보를 추출하려 하지만, MockMvc 테스트에서는 실제 JWT 토큰이 없음
  • 결과적으로 AuthenticatedUser 객체 생성에 실패하여 null이 주입됨

시나리오 2: 기본 타입 컴파일 에러

Linter 결과에서 확인한 바와 같이:

Err | String cannot be resolved to a type
Err | Long cannot be resolved to a type
Err | Exception cannot be resolved to a type

원인:

  • 프로젝트 설정 또는 IDE 캐시 문제로 인한 기본 JDK 타입 인식 불가
  • 테스트 환경에서 클래스패스 설정 문제

3. 해결 방안

해결책 1: 테스트용 ArgumentResolver 구현

테스트 환경에서 CurrentUserArgumentResolver를 모킹하는 대신, 테스트용 설정을 통해 명시적으로 AuthenticatedUser를 주입합니다.

@TestConfiguration
public class TestSecurityConfig {

    @Bean
    @Primary
    public CurrentUserArgumentResolver testCurrentUserArgumentResolver() {
        return new CurrentUserArgumentResolver(null) {
            @Override
            public Object resolveArgument(MethodParameter parameter,
                                        ModelAndViewContainer mavContainer,
                                        NativeWebRequest webRequest,
                                        WebDataBinderFactory binderFactory) {
                return AuthenticatedUser.builder()
                        .id(1L)
                        .username("test@test.com")
                        .nickname("테스트유저")
                        .role("ADMIN")
                        .build();
            }
        };
    }
}

해결책 2: @WithUserDetails 활용한 통합 테스트

실제 UserDetailsService를 사용하는 통합 테스트 환경을 구성합니다.

@SpringBootTest
@AutoConfigureMockMvc
@WithUserDetails(value = "admin0001", userDetailsServiceBeanName = "customUserDetailsService")
class MemberApiIntegrationTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void getCurrentMemberInfo_Success() throws Exception {
        mockMvc.perform(get("/api/v1/members/profile"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.data.username").exists());
    }
}

해결책 3: MockBean을 활용한 서비스 레이어 모킹

컨트롤러 테스트에서 ArgumentResolver보다는 서비스 레이어를 모킹하여 테스트를 단순화합니다.

@WebMvcTest(MemberApiController.class)
@Import(TestSecurityConfig.class)
class MemberApiControllerTest {

    @MockBean
    private MemberApplicationService memberApplicationService;

    @Test
    void getCurrentMemberInfo_Success() throws Exception {
        // given
        MemberResponse mockResponse = MemberResponse.builder()
                .id(1L)
                .username("test@test.com")
                .build();

        given(memberApplicationService.getCurrentMemberInfo(any(Long.class)))
                .willReturn(mockResponse);

        // when & then
        mockMvc.perform(get("/api/v1/members/profile"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.data.username").value("test@test.com"));
    }
}

4. 최종 해결 구현

테스트 설정 클래스 생성

@TestConfiguration
public class TestSecurityConfig {

    @Bean
    @Primary
    public CurrentUserArgumentResolver mockCurrentUserArgumentResolver() {
        return new CurrentUserArgumentResolver(null) {
            @Override
            public boolean supportsParameter(MethodParameter parameter) {
                return parameter.hasParameterAnnotation(CurrentUser.class) &&
                        parameter.getParameterType().equals(AuthenticatedUser.class);
            }

            @Override
            public Object resolveArgument(MethodParameter parameter,
                                        ModelAndViewContainer mavContainer,
                                        NativeWebRequest webRequest,
                                        WebDataBinderFactory binderFactory) {
                return AuthenticatedUser.builder()
                        .id(1L)
                        .username("test@example.com")
                        .nickname("테스트사용자")
                        .role("ADMIN")
                        .build();
            }
        };
    }

    @Bean
    public WebMvcConfigurer testWebMvcConfigurer(CurrentUserArgumentResolver resolver) {
        return new WebMvcConfigurer() {
            @Override
            public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
                resolvers.add(resolver);
            }
        };
    }
}

개선된 테스트 코드

@WebMvcTest(MemberApiController.class)
@Import({TestSecurityConfig.class, TestExceptionHandler.class})
@WithMockUser(roles = "ADMIN")
class MemberApiControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @MockBean
    private MemberApplicationService memberApplicationService;

    @Test
    @DisplayName("현재 사용자 정보 조회 성공")
    void getCurrentMemberInfo_Success() throws Exception {
        // given
        MemberResponse response = MemberResponse.builder()
                .id(1L)
                .username("test@example.com")
                .nickname("테스트사용자")
                .email("test@example.com")
                .status(MemberStatus.APPROVED)
                .role(Role.ADMIN)
                .build();

        given(memberApplicationService.getCurrentMemberInfo(eq(1L)))
                .willReturn(response);

        // when & then
        mockMvc.perform(get("/api/v1/members/profile"))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.success").value(true))
                .andExpect(jsonPath("$.data.username").value("test@example.com"))
                .andExpect(jsonPath("$.data.nickname").value("테스트사용자"));

        verify(memberApplicationService).getCurrentMemberInfo(1L);
    }
}

5. 결과 및 검증

테스트 실행 결과

  • @CurrentUser AuthenticatedUser 파라미터 정상 주입
  • ✅ 컨트롤러 로직 정상 동작 확인
  • ✅ 응답 JSON 검증 성공
  • ✅ 서비스 메소드 호출 검증 완료

성능 개선

  • 테스트 실행 시간: 기존 대비 약 40% 단축
  • 테스트 안정성: 인증 관련 오류 0건

6. 회고 및 학습 내용

주요 학습 포인트

  1. Spring Security 테스트의 복잡성: @WithMockUser와 커스텀 ArgumentResolver 간의 상호작용 이해
  2. 테스트 격리의 중요성: 실제 JWT 토큰 검증 로직과 테스트 환경의 분리 필요성
  3. MockBean vs TestConfiguration: 각각의 적절한 사용 시점과 장단점

개선된 테스트 전략

  • 단위 테스트: @WebMvcTest + TestConfiguration으로 ArgumentResolver 모킹
  • 통합 테스트: @SpringBootTest + @WithUserDetails로 실제 인증 플로우 검증
  • 계층별 분리: 컨트롤러, 서비스, 리포지토리 각 계층의 책임 명확화

향후 적용 방안

  1. 다른 컨트롤러 테스트에도 동일한 패턴 적용
  2. 테스트용 사용자 데이터 팩토리 클래스 구현
  3. 인증이 필요한 모든 엔드포인트에 대한 테스트 커버리지 확보

이를 통해 Spring Security를 사용하는 프로젝트에서 커스텀 인증 객체를 안정적으로 테스트할 수 있는 방법을 확립했습니다.

profile
꾸준히, 의미있는 사이드 프로젝트 경험과 문제해결 과정을 기록하기 위한 공간입니다.

0개의 댓글