[트러블슈팅] @Controller 레이어 테스트 SecurityContext - f-lab-edu/jshop GitHub Wiki

@Controller 레이어의 단위테스트를 진행하며 겪은 삽질겸 트러블 슈팅이다.

문제

Controller 에서, 현재 유저정보를 알기 위해 SecurityContext 를 사용하는데, 단위 테스트에서는 SecurityContext를 아무리 설정해보려 해도 설정이 되지 않는 문제였다.

현재 상황

우선 문제가 일어난 상황을 알아야 하니, 현재 프로젝트의 상황부터 설명한다.

프로젝트에서 Jwt 를 사용한 로그인 방식을 사용하고 있다.

JwtFilter를 시큐리티 필터 체인에 추가해, 정상적인 토큰을 가진다면 토큰에서 유저정보를 추출해 CustomUserDetails 를 만들고 SecurityContextHolderUsernamePasswordAuthenticationToken 을 저장한다.

// JwtFilter.java
Authentication authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null,
            customUserDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authToken);

그리고, Controller 에서 SecurityContextHolder 에서 컨텍스트를 가져와 Principal 을 사용한다. 이때 좀더 편하게 사용하기 위해 @AuthenticationPrincipal어노테이션으로 Principal 을 직접 받아 사용한다.

이 어노테이션의 역할은 SecurityContextHolder.getContext().getAuthentication().getPrincipal() 을 대신 수행해 파라미터로 Principal 을 주입해준다.

// AddressController.java
public void saveAddress(@RequestBody @Valid CreateAddressRequest createAddressRequest,
        @AuthenticationPrincipal CustomUserDetails userDetails) {

그리고, 이 PrincipaluserDetails 가 제대로 값이 들어있는지 확인해 값에 문제가 있다면 예외를 던지는 방식이다.

테스트 코드 작성 1. MockMvc standaloneSetup 빌드해 사용

@Controller 계층의 단위테스트를 위해 Mockito를 사용해 MockMvc 를 빌드해 사용했다.

// AddressControllerTest.java
@ExtendWith(MockitoExtension.class)
public class AddressControllerTest {

    @Mock
    private AddressService addressService;

    @InjectMocks
    private AddressController addressController;

    @Captor
    private ArgumentCaptor<CreateAddressRequest> saveAddressDtoCaptor;

    @Captor
    private ArgumentCaptor<User> userCaptor;

    private MockMvc mockMvc;

    @BeforeEach
    public void beforeEach() {
        mockMvc = MockMvcBuilders
            .standaloneSetup(addressController)
            .setControllerAdvice(GlobalExceptionHandler.class)
            .build();
    }

    @Test
    public void 정상주소추() throws Exception {
        // given
        ...
        CustomUserDetails customUserDetails = new CustomUserDetails(user);

        UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(customUserDetails, "password", customUserDetails.getAuthorities());
        SecurityContext context = SecurityContextHolder.createEmptyContext();
        context.setAuthentication(authToken);
        ...

        // when
        ResultActions perform = mockMvc.perform(MockMvcRequestBuilders
            .post("/api/address")
            .with(securityContext(context))
            .contentType(MediaType.APPLICATION_JSON)
            .content(requestBody.toString()));
       ...
    }

하지만 설정으로 실행하니,mockMvc.perform 에서 아무리 SecurityContext 를 적용하려 해도 적용되지 않았다.

필터와 동일한 방식으로 SecurityContext 를 설정하고, perform 에 이 시큐리티를 적용해 보았지만, 정작 컨트롤러에서는 SecurityContext 를 확인할 수 없었다.

아래 사진은 AddressController 에서 다음 코드로 확인한 결과다.

// AddressController.java
System.out.println(SecurityContextHolder.getContext());
System.out.println(userDetails.getUser());

스크린샷 2024-06-13 16 06 43

테스트 코드에서 @WithMockUser , SecurityMockMvcRequestPostProcessors.authentication, SecurityMockMvcRequestPostProcessors.user 등을 사용해봐도 SecurityContext 는 전달되지 않았다.

곰곰히 생각해보니 SecurityContext 는 시큐리티의 기능인데, 현재 단위테스트에서는 시큐리티와 관련된 설정이 없기 때문에 이렇게 동작했다는것을 알게되었다.

진짜 너무 컨트롤러에 대한 단위테스트여서, 필수적으로 필요했던 시큐리티를 사용하지 못한것이다.

테스트 코드 작성 2. @WebMvcTest

Controller 에서 스프링 시큐리티를 사용하기 위해 다른 테스트 방법을 찾아봤다.

@SpringBootTest 는 컨트롤러 테스트에는 너무 무거울것 같았고 스프링 시큐리티를 기본으로 포함하는 @WebMvcTest 가 가장 적당했다.

의존이 필요한 AddressServiceMockBean 으로 등록해줬다.

@WebMvcTest(AddressController.class)
public class AddressControllerTest2 {

    @MockBean
    private AddressService addressService;

    @Autowired
    private MockMvc mockMvc;

시큐리티 설정 트러블슈팅

Controller의 메서드에서 SecurityContext 가 잡히는지 로그를 찍어보려 했다.

하지만

스크린샷 2024-06-13 16 41 22

403 에러와 함께 컨트롤러까지 가지도 못하고 종료되어 버렸다.

원인을 찾아보니, csrf 필터가 동작하는데, 이 토큰정보를 전달해주지 않아 생긴 문제였다.

이 문제를 해결하기 위해 csrf 필터가 disable된 filterChain 을 리턴하는 TestSecurityConfig 를 만들어 사용했다. (추후 알게된 사실이지만, mockMvc.perform에 with(csrf()) 를 넣어주면 해결되긴 했다.)

컨트롤러에서 SecurityContext 확인

csrf 문제를 해결하고, 진짜 SecurityContext 를 살펴보기 위해 1. 에서 했던 방법과 동일하게 context를 전달해 주었다.

CustomUserDetails customUserDetails = new CustomUserDetails(u);

        UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(customUserDetails, null, customUserDetails.getAuthorities());
        SecurityContext context = SecurityContextHolder.createEmptyContext();
        context.setAuthentication(authToken);

        // when
        ResultActions perform = mockMvc.perform(MockMvcRequestBuilders
            .post("/api/address")
            .with(csrf())
            .with(securityContext(context))
            .contentType(MediaType.APPLICATION_JSON)
            .content(requestBody.toString()));

그리고 Controller에서 SecurityContext 를 잘 받는것을 확인할 수 있었다.

스크린샷 2024-06-13 16 51 27

정리

컨트롤러 레이어의 단위테스트를 진행하며, SecurityContext 를 어떻게 설정해줄지에 대한 문제였다.

처음에는 진짜 Controller 하나만 테스트하는 너무 작은 단위 테스트여서 스프링 시큐리티가 동작하지 않았다.

하지만 비즈니스로직에 스프링 시큐리티를 배제할 수 없었기 때문에 스프링 시큐리티를 참여해야 겠다고 생각헀다.

@WebMvcTest 를 쓰기로 하고 겪은 여러가지 문제들도 있었다.

어쨌든 이번 경험으로 스프링 시큐리티에 대한 이해가 한층 늘은것 같다.

사실 Jwt 구현을 하면서도 SecurityContext의 목적에 대해 잘 모르고 있었고, 컨트롤러 레이어에서도 그냥 받아서 사용할 수 있었기 때문에 큰 생각없이 사용하고 있었다.

JwtFilter가 왜 Username필터 앞에 오는지, 만약 토큰이 제대로 되어있지 않아도 왜 doFilter 로 넘기는지, 토큰이 검증되면 왜 Context를 설정하는지에 대한 개념이 와닿지 않았었다.

대충 설명하자면 토큰이 있다면 SecurityContext를 설정해 로그인 핉터(UsernamePassword)에서 로그인 동작을 넘어가고, Context가 없다면 로그인 필터에서 로그인 동작을 수행하기 때문이다.

하지만 테스트를 진행하며 개념에 대한 이해가 필요한 시점이 오니 이게 발목을 잡았다.

덕분에 살짝 뇌빼고 쓰던 스프링 시큐리티에 대한 이해도도 올라가게 된것 같다.

참고

https://velog.io/@cieroyou/WebMvcTest와-Spring-Security-함께-사용하기

⚠️ **GitHub.com Fallback** ⚠️