경험/이슈

[JUnit5] @WithMockUser에서 username이 의도대로 작동 안 함

호야_ 2023. 10. 24. 13:08
728x90

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`를 사용할 필요가 없으며, 해당 어노테이션은 그렇게 적용되지도 않았다.

 

이전에 테스트를 공부할 때, "테스트하기 어려운 영역을 분리하자"라는 말이 조금 추상적이고 이해가 잘 안 됐었는데 이번 케이스를 통해 그 개념에 대해 되돌아볼 수 있는 기회가 되었다. 실제 프로젝트의 경험은 때때로 이론적인 지식을 명확하게 해주는 좋은 리마인더가 될 때가 많다.

 

 

 

언제나 잘못된 설명이나 부족한 부분에 대한 피드백은 환영입니다🤍

728x90