Codeit/프로젝트

[모두의 플리] 트러블슈팅 - @AuthenticationPrincipal을 가진 컨트롤러 테스트

leejunkim 2025. 11. 19. 15:05

 

특정 컨텐츠의 시청 세션 목록 조회 컨트롤러의 테스트를 구현해야 했었다:

  // 특정 콘텐츠의 시청 세션 목록 조회 (커서 페이지네이션)
  @GetMapping("/contents/{contentId}/watching-sessions")
  public ResponseEntity<CursorResponseWatchingSessionDto> getWatchingSessionsPerContent(
      @PathVariable UUID contentId,
      @Valid WatchingSessionRequest request,
      @AuthenticationPrincipal CustomUserDetails userDetails
  ) {
    log.info("[실시간 세션] 특정 콘텐츠의 시청 세션 목록 요청 수신. contentId = {}", contentId);
    CursorResponseWatchingSessionDto response = watchingSessionService.getWatchingSessions(
        userDetails.getUser().id(),
        contentId,
        request.watcherNameLike(),
        request.cursor(),
        request.idAfter(),
        request.limit(),
        request.sortDirection(),
        request.sortBy()
    );
    log.info("[실시간 세션] 특정 콘텐츠의 시청 세션 목록 응답 반환. contentId = {}", contentId);
    return ResponseEntity.ok(response);
  }

 

이런식으로 컨트롤러 쪽에서는 @AuthenticacionPrincipal의 userDetails을 받아와서 서비스에 넣어줘야했는데, 일단 테스트 코드는 이렇게 짜봤다:

  @DisplayName("특정 콘텐츠의 시청 세션 목록 조회를 시도한다")
  @Test
  void getWatchingSessionPerContentSuccess() throws Exception {
    // given
    UUID contentId = UUID.randomUUID();
    UUID userId = UUID.randomUUID();
    WatchingSessionDto watchingSessionDto = new WatchingSessionDto(
        UUID.randomUUID(),
        LocalDateTime.now(),
        new UserSummary(userId,"test",null),
        new contentSummary(contentId,null,null,
            null, null,null,null,null
        )
    );
    CursorResponseWatchingSessionDto cursorResponseWatchingSessionDto = new CursorResponseWatchingSessionDto(
        List.of(watchingSessionDto),
        "nextCursor_123",
        userId,true,1L,
        SortBy.createdAt, SortDirection.ASCENDING
    );
    when(watchingSessionService.getWatchingSessions(
        any(UUID.class), eq(contentId),
        any(), any(), any(), anyInt(), any(), any()
    )).thenReturn(cursorResponseWatchingSessionDto);

    // when & then
    ResultActions resultActions = mockMvc.perform(
        get("/api/contents/" + contentId +"/watching-sessions")
            .param("limit", "10")
            .param("sortDirection", "ASCENDING")
            .param("sortBy", "createdAt")
            .contentType(MediaType.APPLICATION_JSON)
            .accept(MediaType.APPLICATION_JSON_VALUE)
    );
    resultActions.andExpect(status().isOk());
  }

 

이렇게 하면 이런식으로 에러가 뜬다:

Request processing failed: java.lang.NullPointerException: Cannot invoke "cohttp://m.codeit.mopl.security.CustomUserDetails.getUser()" because "userDetails" is null
jakarta.servlet.ServletException: Request processing failed: java.lang.NullPointerException: Cannot invoke "cohttp://m.codeit.mopl.security.CustomUserDetails.getUser()" because "userDetails" is null
at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1022)
at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:903)
at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:564)
at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:885)

 

짧은 디버깅을 해봤다:

 

 

AuthenticationPrincipal에서 찾아보는 유저가 애초에 없었기 때문에 userDetails도 null이였다.

그래서 처음에는 user도 mock을 해야하나 싶었다.

구글링을 해보니까, 테스트 시 mock user를 넣어주는 방법은 @WithMockUser, 그리고 @WithUserDetails가 있었다. 

@WithMockUser 시도

  • Spring Security가 제공하는 기본 user 객체 (username, password, role만 있음)를 만들어서 SecurityContext에 넣어준다.
  • 하지만 컨트롤러에는 @AuthenticationPrincipal CustomUserDetails userDetails을 받고 있는데, Spring은 User인 principal 객체를 CustomUserDetails로 캐스팅할 때 강제 형변환이 실패한다.

일단 시도는 해봤다:

@WithMockUser // 추가
@DisplayName("특정 콘텐츠의 시청 세션 목록 조회를 시도한다")
@Test
void getWatchingSessionPerContentSuccess() throws Exception {
    // given
    UUID contentId = UUID.randomUUID();
    UUID userId = UUID.randomUUID();
    ...// 기타 코드

 

역시나 실패했다:

Request processing failed: java.lang.NullPointerException: Cannot invoke "cohttp://m.codeit.mopl.security.CustomUserDetails.getUser()" because "userDetails" is null
jakarta.servlet.ServletException: Request processing failed: java.lang.NullPointerException: Cannot invoke "cohttp://m.codeit.mopl.security.CustomUserDetails.getUser()" because "userDetails" is null
at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1022)
at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:903)

 

또 userDetails이 null이여서 에러가 생겼다.

이때 사용되는 파일은 AuthenticationPrincipalArgumentResolver인데, 궁금해서 디버깅을 해보니까:

  • 여기에서 principal 변수는 null이 아니지만 (org.springframework.security...User 객체가 들어있음 (@WithMockUser로 만들어짐)) 두번째 조건에서 실패한다.
  • 두번째 조건 ClassUtils.isAssignable은 안전하게 형변환을 할 수 있는지 알려주는 건데, 컨트롤러 코드에는 CustomUserDetails을 받지만 SecurityContext에 들어있는건 그냥 깡통 User 객체라서 실패한 것이다.
    • errorOnInvalidType 속성은 기본값이 false여서 그냥 에러를 안던지고 return null로 넘어가서 null을 반환한다

@WithUserDetails?

  • 찾아보니까, 실제로 UserDetailsService 빈을 호출해서 DB(혹은 mock db)에서 유저를 로드하려고 시도한다.
  • 하지만 이러면 @WebMvcTest를 이용한 슬라이스 테스트의 의미가 없어진다 - 슬라이스 테스트는 service/repository 계층을 떼어내고 controller만 테스트하는 것이 목적이니까 통합 테스트처럼 되어버린다.
  • 그래서 WithUserDetails은 사용 하지 않기로 결정했다.

찾은 솔루션

여기저기 블로그랑 자료를 참고하면서 얻은 결론은 테스트용 @WithMockUser 어노테이션을 직접 만드는 것이였다.

package com.codeit.mopl;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import org.springframework.security.test.context.support.WithSecurityContext;

@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory= WithCustomMockUserSecurityContextFactory.class)
public @interface WithCustomMockUser {

  String email() default "test@test.com";
  String password() default "testPassword";
  String name() default "testName";
}
  • 커스텀 어노테이션을 만들었다 - @WithCustomMockUser
    • @WithSecurityContext(factory = ...) -> 어노테이션이 테스트 위에 있을 때 WithCustomMockUserSecurityContextFactory 클래스를 실행해서 SecurityContext를 생성하라고 함
    • email, password등 값을 미리 만들어서 팩토리 클래스로 넘겨줌
import com.codeit.mopl.domain.user.dto.response.UserDto;
import com.codeit.mopl.domain.user.entity.Role;
import com.codeit.mopl.security.CustomUserDetails;
import java.time.LocalDateTime;
import java.util.List;
import java.util.UUID;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.test.context.support.WithSecurityContextFactory;

public class WithCustomMockUserSecurityContextFactory implements WithSecurityContextFactory<WithCustomMockUser> {

  @Override
  public SecurityContext createSecurityContext(WithCustomMockUser annotation) {
    String email = annotation.email();
    String password = annotation.password();
    String name = annotation.name();

    CustomUserDetails customUserDetails = new CustomUserDetails(
        new UserDto(
            UUID.randomUUID(),
            LocalDateTime.now(),
            email, name,null,
            Role.USER,false
        ),
        password
    );
    UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(
        customUserDetails, password, List.of(new SimpleGrantedAuthority("ROLE_" + Role.USER))
    );
    SecurityContext context = SecurityContextHolder.getContext();
    context.setAuthentication(token);
    return context;
  }
}
  • 다음으로 팩토리 클래스를 만들어주었다: WithCustomMockUserSecurityContextFactory
    • 실제 SecurityContext를 생성하고 직접 만든 CustomUserDetails을 주입한

 

이제 그냥 컨트롤러 테스트에 @WithCustomMockUser 어노테이션을 붙혀주었다:

  @WithCustomMockUser
  @DisplayName("특정 콘텐츠의 시청 세션 목록 조회를 시도한다")
  @Test
  void getWatchingSessionPerContentSuccess() throws Exception {
    // given
    UUID contentId = UUID.randomUUID();
    ... // 나머지 코드

 

이제 테스트가 통과한다!

참고