SecurityContextHolder에서 유저 정보를 가져오며 테스트 중 겪었던 작은 문제와 그에 대한 대처를 이야기하고자 한다.
같은 경험을 했거나 더 좋은 경험이 있다면 댓글로 ✍️ 🙏
SecurityContextHolder에서 유저 정보를 효율적으로 가져오기
첫 번째 문제 상황
진행하던 프로젝트에서 Spring Security의 `SecurityContextHolder`를 사용해 로그인한 유저의 정보를 가져와야 하는 상황이 생겼다. 흔히 많은 예제에서 username 필드로 email을 사용해서 우선 처음에는 email로 세팅했다. 여기서 작은 문제가 생겼다.
user와 연결된 다른 많은 테이블들이 있었고, 이 테이블들과 join을 할 때마다 email로 먼저 유저 정보를 조회하고, 그 후 user의 id값으로 다시 join을 해야 했다. 이렇게 되면 매번 조회가 두 번이나 발생하게 되는데, 이는 효율성 면에서 좋지 않다고 생각했다.
개선 방향
이 문제를 해결하기 위해 `SecurityContextHolder`에 저장되는 값으로 email대신 user의 id값을 저장하기로 결정했다. 이렇게 하면 user와 연결된 다른 테이블을 조회할 때 email로 중간 조회 없이 바로 id값으로 join을 할 수 있게 된다.
JUnit5와 MySQL의 Auto Increment: 테스트의 함정
두 번째 문제 상황
JUnit5를 이용한 단위 테스트 중 `@WithMockUser(username = "1", roles = "USER") 어노테이션을 사용하여 mock 유저를 생성하며 테스트를 하고 있었다. 그런데 위의 이유 때문에 username을 MySQL의 Auto Increment 값인 id로 세팅하면서 문제가 생겼다.
// 실패한 테스트 케이스
/** 비즈니스 로직 **/
public MissionResponse createMission(MissionCreateServiceRequest request) {
int userId = securityService.getCurrentUserInfo().getUserId();
User user = userReadService.findByOne(userId);
Mission mission = request.toEntity(user);
Mission savedMission = missionRepository.save(mission);
return MissionResponse.of(savedMission);
}
/** 테스트 로직 **/
@DisplayName("미션 내용들을 받아 미션을 생성한다.")
@Test
@WithMockUser(username = "1", roles = "USER")
void createMission() {
// given
User user = createUser("user@daum.net", "user1234!", SnsType.KAKAO, "010-1111-1111");
Mission mission = createMission(user, "운동하기", CHECKED, LocalTime.of(0, 0));
missionRepository.save(mission);
MissionCreateServiceRequest request = MissionCreateServiceRequest.builder()
.missionDescription("책 읽기")
.alertStatus(CHECKED)
.alertTime(LocalTime.of(23, 30))
.build();
// when
MissionResponse missionResponse = missionService.createMission(request);
// then
assertThat(missionResponse)
.extracting("missionDescription", "alertStatus", "alertTime")
.contains("책 읽기", CHECKED, LocalTime.of(23, 30));
List<Mission> missions = missionRepository.findAll();
assertThat(missions).hasSize(2)
.extracting("missionDescription", "alertStatus", "alertTime")
.containsExactlyInAnyOrder(
tuple("운동하기", CHECKED, LocalTime.of(0, 0)),
tuple("책 읽기", CHECKED, LocalTime.of(23, 30))
);
}
이렇게 미션을 생성하는 테스트가 있는데, 테스트 자체를 개별적으로 실행했을 때는 아무런 문제가 발생하지 않았다. 그러나 테스트의 수가 늘어나며 user 데이터 생성 및 클렌징 로직이 추가되면서 테스트 실패의 문제가 발생하기 시작했다. 이는 단위 테스트의 기본 원칙 중 하나인 '테스트는 독립적으로 동작해야 한다'는 원칙을 위배하고 있는 문제였다.
문제의 원인을 파악하기 위해 디버깅을 진행했다. 우선 테스트는 실행 순서를 보장하지 않는다. 첫 번째 실행된 테스트에서 생성된 user의 id값은 1이었고, 이후 실행되는 테스트에서는 `deleteAllInBatch()`를 통해 데이터를 클렌징하는 과정을 거쳤다. 초기에는 이 클렌징 과정으로 인해 AI값이 1로 초기화된다고 생각했으나, 실제로는 그렇지 않았던 것 같다. 그래서 이후 테스트에서 예측하지 못하는 id값이 부여되었다. 결과적으로 id값은 우리가 직접 제어할 수 없는 영역으로 들어가게 되었다.
실패한 개선 방향
처음에는 1차원적으로 "그러면 SecurityContextHolder에 UserInfo라는 객체로 email이랑 id를 둘 다 넣어야겠다"라고 생각했다. 왜냐하면 테스트에서 email은 내가 직접 넣어줄 수 있고 유니크하기 때문에 적합하다고 생각했고 예제에서는 "그래서 다 email을 넣었던 건가?"라고 생각했다.
@Service
public class SecurityService {
public UserInfo getCurrentUserInfo() {
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (principal instanceof UserInfo) {
return (UserInfo) principal;
}
throw new RuntimeException("Unknown principal type: " + principal.getClass().getName());
}
}
그리고는 테스트에서는 어노테이션을 하나 만들어서 우리만의 WithMockUser를 재정의 했다.
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockUserInfoSecurityContextFactory.class)
public @interface WithMockUserInfo {
int userId() default 1;
String email() default "email@gamil.com";
String role() default "USER";
}
public class WithMockUserInfoSecurityContextFactory implements WithSecurityContextFactory<WithMockUserInfo> {
@Override
public SecurityContext createSecurityContext(WithMockUserInfo annotation) {
final SecurityContext securityContext = SecurityContextHolder.createEmptyContext();
UserInfo userInfo = UserInfo.of(annotation.userId(), annotation.email());
final UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
userInfo,
"",
List.of(new SimpleGrantedAuthority(annotation.role()))
);
securityContext.setAuthentication(authenticationToken);
return securityContext;
}
}
어노테이션을 바꾼 테스트
@DisplayName("미션 내용들을 받아 미션을 생성한다.")
@Test
@WithMockUserInfo(userId = 1, email = "user@daum.net")
void createMission() {
// given
User user = createUser("user@daum.net", "user1234!", SnsType.KAKAO, "010-1111-1111");
Mission mission = createMission(user, "운동하기", CHECKED, LocalTime.of(0, 0));
missionRepository.save(mission);
MissionCreateServiceRequest request = MissionCreateServiceRequest.builder()
.missionDescription("책 읽기")
.alertStatus(CHECKED)
.alertTime(LocalTime.of(23, 30))
.build();
// when
MissionResponse missionResponse = missionService.createMission(request);
// then
assertThat(missionResponse)
.extracting("missionDescription", "alertStatus", "alertTime")
.contains("책 읽기", CHECKED, LocalTime.of(23, 30));
List<Mission> missions = missionRepository.findAll();
assertThat(missions).hasSize(2)
.extracting("missionDescription", "alertStatus", "alertTime")
.containsExactlyInAnyOrder(
tuple("운동하기", CHECKED, LocalTime.of(0, 0)),
tuple("책 읽기", CHECKED, LocalTime.of(23, 30))
);
}
이렇게 하면 되겠다고 생각했는데 이건 너무 1차원적인 생각이라는 것을 다 바꾸고 깨달았다.. 애초에 이게 됐으면 처음에 (username = "1")도 됐어야 했다. 그리고 비즈니스 로직에서 email을 쓰지 않고 있어서 email을 추가할 필요도 없었던 것 같다.
테스트하기 어려운 영역을 분리하자 !
다른 접근의 개선 방향
우빈님의 테스트 강의에서 "테스트하기 어려운 영역을 분리하자"라는 말이 생각나서 적용해 봤다.
public MissionResponse createMission(MissionCreateServiceRequest request, int userId) {
User user = userReadService.findByOne(userId);
Mission mission = request.toEntity(user);
Mission savedMission = missionRepository.save(mission);
return MissionResponse.of(savedMission);
}
이렇게 테스트하기 까다로웠던 userId를 외부로 분리했다.
그에 맞게 컨트롤러 테스트(Presentation Layer 테스트)도 수정했다.
@DisplayName("신규 미션을 등록한다.")
@Test
@WithMockUser(roles = "USER")
void createMission() throws Exception {
// given
MissionCreateRequest request = MissionCreateRequest.builder()
.missionDescription("운동하기")
.alertStatus(CHECKED)
.alertTime(LocalTime.of(18, 30))
.build();
given(securityService.getCurrentUserInfo())
.willReturn(createUserInfo());
// when // then
mockMvc.perform(
post("/api/v1/missions/new")
.content(objectMapper.writeValueAsString(request))
.contentType(APPLICATION_JSON)
.with(csrf())
)
.andDo(print())
.andExpect(status().isCreated())
.andExpect(jsonPath("$.statusCode").value("201"))
.andExpect(jsonPath("$.httpStatus").value("CREATED"))
.andExpect(jsonPath("$.message").value("CREATED"));
}
...
private UserInfo createUserInfo() {
return UserInfo.builder()
.userId(1)
.email("")
.build();
}
- BDDMockito의 given을 이용해서 userId를 mocking해서 제어가능하게 만들었다.
- 그래서 더이상 재정의한 어노테이션을 사용할 필요가 없어졌다.
느낀 점
초반에 단위 테스트를 진행하면서 `@WithMockUser` 어노테이션의 필요성과 동작 방식에 대한 이해가 완전하지 않았던 것 같다. 실제로 서비스 레벨의 단위테스트에서는 `@WithMockUser`를 사용할 필요가 없으며, 해당 어노테이션은 그렇게 적용되지도 않았다.
이전에 테스트를 공부할 때, "테스트하기 어려운 영역을 분리하자"라는 말이 조금 추상적이고 이해가 잘 안 됐었는데 이번 케이스를 통해 그 개념에 대해 되돌아볼 수 있는 기회가 되었다. 실제 프로젝트의 경험은 때때로 이론적인 지식을 명확하게 해주는 좋은 리마인더가 될 때가 많다.
언제나 잘못된 설명이나 부족한 부분에 대한 피드백은 환영입니다🤍