특정 컨텐츠의 시청 세션 목록 조회 컨트롤러의 테스트를 구현해야 했었다:
// 특정 콘텐츠의 시청 세션 목록 조회 (커서 페이지네이션)
@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();
... // 나머지 코드
이제 테스트가 통과한다!

참고
'Codeit > 프로젝트' 카테고리의 다른 글
| Elastisearch 토이 프로젝트 만들어보기 (프로젝트를 위한 준비..) (0) | 2025.11.27 |
|---|---|
| [모두의 플리] 고도화 - redis를 이용한 분산환경 고려 (pub/sub) (0) | 2025.11.26 |
| [모두의 플리] 트러블 슈팅 - 웹소켓 환경에서 유저 정보 가져오기 (0) | 2025.11.26 |
| [Monew] 트러블슈팅 - 뉴스 기사 백업 (0) | 2025.09.16 |
| [Monew] 트러블슈팅 - CICD (0) | 2025.09.16 |