오늘은 API를 연동하는 프론트엔드/iOS 개발자의 편의를 위하여
개발자 토큰 API를 구현했습니다.
기존 액세스 토큰은 15분으로 금방 만료가 되어버리기에 계속해서 재발급을 해야하는 번거로움이 있습니다.
30일정도의 긴 만료시간을 가지는 액세스 토큰을 발급하여 개발에 지장이 없도록 하였습니다.
오늘은 구현 쪽에서 크게 다룰 건 없는 것 같습니다만..
통합 테스트 구현 과정에서 꽤나 애를 먹었습니다.
https://docs.spring.io/spring-security/reference/6.5/servlet/test/index.html
Testing :: Spring Security
docs.spring.io
위의 공식 문서를 보면 Spring Security의 Testing과 관련한 여러 기능들이 있습니다.
@WithMockUser부터 커스텀 어노테이션, 통합 테스트에서의 JWT 처리까지 삽질했던 내용을 정리해보겠습니다.
@WithMockUser: 가장 간단한 방법
Spring Security Test가 제공하는 기본 어노테이션입니다. SecurityContext에 가짜 인증 정보를 주입해줍니다.
@WebMvcTest(ArticleController.class)
class ArticleControllerTest {
@Autowired
private MockMvc mockMvc;
@Test
void 인증없이_접근하면_401() throws Exception {
mockMvc.perform(get("/api/articles"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(username = "testuser", roles = {"USER"})
void 인증된_사용자는_접근_가능() throws Exception {
mockMvc.perform(get("/api/articles"))
.andExpect(status().isOk());
}
}
권한별 접근 제어 테스트에도 유용합니다.
@Test
@WithMockUser(roles = "ADMIN")
void 관리자만_삭제_가능() throws Exception {
mockMvc.perform(delete("/api/articles/1"))
.andExpect(status().isOk());
}
@Test
@WithMockUser(roles = "USER")
void 일반_사용자는_삭제_불가() throws Exception {
mockMvc.perform(delete("/api/articles/1"))
.andExpect(status().isForbidden());
}
한계점
@WithMockUser는 단순히 SecurityContext에 가짜 Authentication만 넣어줍니다. 실제 DB의 사용자 정보와는 무관합니다.
그래서 컨트롤러에서 @AuthenticationPrincipal로 커스텀 UserDetails를 받는 경우 문제가 생깁니다.
@GetMapping("/me")
public UserResponse getMyInfo(@AuthenticationPrincipal CustomUserDetails user) {
return userService.findById(user.getUserId()); // user가 null이거나 타입 불일치
}
@WithSecurityContext: 커스텀 어노테이션 만들기
프로젝트에 맞는 커스텀 UserDetails를 사용한다면, 직접 어노테이션을 만드는 게 낫습니다.
어노테이션 정의
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockUserSecurityContextFactory.class)
public @interface WithMockCustomUser {
long userId() default 1L;
String email() default "test@example.com";
String role() default "USER";
}
SecurityContext 팩토리 구현
public class WithMockUserSecurityContextFactory
implements WithSecurityContextFactory<WithMockCustomUser> {
@Override
public SecurityContext createSecurityContext(WithMockCustomUser annotation) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
UserPrincipal principal = new UserPrincipal(
annotation.userId(),
annotation.email(),
annotation.role()
);
Authentication auth = new UsernamePasswordAuthenticationToken(
principal, null, principal.getAuthorities()
);
context.setAuthentication(auth);
return context;
}
}
사용
@Test
@WithMockCustomUser(userId = 1L, email = "user@test.com")
void 내_북마크_목록_조회() throws Exception {
mockMvc.perform(get("/api/bookmarks"))
.andExpect(status().isOk());
}
@WithSecurityContext vs JWT 직접 생성
둘 중 뭘 써야 할까요? 테스트 대상에 따라 다릅니다.
관점 @WithSecurityContext JWT 직접 생성
| 테스트 의도 | 비즈니스 로직 검증 | 인증 플로우 자체 검증 |
| 속도 | 빠름 (토큰 파싱 생략) | 느림 (서명, 파싱 포함) |
| 유지보수 | 토큰 구조 변경에 영향 없음 | 토큰 구조 바뀌면 테스트도 수정 |
JWT 직접 생성이 필요한 경우
JWT 필터나 토큰 검증 로직 자체를 테스트할 때입니다.
@Test
void 만료된_토큰은_401_반환() throws Exception {
String expiredToken = createExpiredJwt();
mockMvc.perform(get("/api/articles")
.header("Authorization", "Bearer " + expiredToken))
.andExpect(status().isUnauthorized());
}
@Test
void 잘못된_서명의_토큰은_거부() throws Exception {
String invalidToken = createTokenWithWrongSecret();
mockMvc.perform(get("/api/articles")
.header("Authorization", "Bearer " + invalidToken))
.andExpect(status().isUnauthorized());
}
통합 테스트에서의 문제: ID 불일치
통합 테스트(@SpringBootTest)에서 @WithSecurityContext를 쓰면 문제가 생길 수 있습니다.
문제 상황
@Test
@WithMockCustomUser(userId = 1L) // SecurityContext에 userId = 1L
void 북마크_추가() throws Exception {
// DB에는 User가 없거나, id가 다름
mockMvc.perform(post("/api/bookmarks")
.content("""{"articleId": 100}"""))
.andExpect(status().isOk()); // 서비스에서 User 조회 시 터짐
}
서비스에서 userRepository.findById(userId)를 호출하면 해당 유저가 없어서 예외가 발생합니다.
해결: 테스트 헬퍼 클래스
테스트에서 실제 User를 먼저 생성하고, 그 ID로 JWT를 발급하는 방식입니다.
@Component
public class TestHelper {
@Autowired
private UserRepository userRepository;
@Autowired
private JwtTokenProvider jwtTokenProvider;
public UserFixture createUser() {
return createUser("test@example.com");
}
public UserFixture createUser(String email) {
User user = userRepository.save(User.builder()
.email(email)
.role(Role.USER)
.build());
String token = jwtTokenProvider.createToken(user.getId(), user.getRole());
return new UserFixture(user, token);
}
public record UserFixture(User user, String token) {
public Long id() {
return user.getId();
}
public RequestPostProcessor auth() {
return request -> {
request.addHeader("Authorization", "Bearer " + token);
return request;
};
}
}
}
사용
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class BookmarkIntegrationTest {
@Autowired
private MockMvc mockMvc;
@Autowired
private TestHelper testHelper;
@Test
void 북마크_추가() throws Exception {
var user = testHelper.createUser();
mockMvc.perform(post("/api/bookmarks")
.with(user.auth())
.contentType(APPLICATION_JSON)
.content("""{"articleId": 100}"""))
.andExpect(status().isOk());
}
@Test
void 다른_사용자_북마크는_접근_불가() throws Exception {
var user1 = testHelper.createUser("user1@test.com");
var user2 = testHelper.createUser("user2@test.com");
// user1이 북마크 생성 후, user2가 접근 시도 → 403
}
}
테스트 유형별 정리
슬라이스 테스트 (@WebMvcTest)
- 서비스가 Mock이라 DB 조회가 없습니다
- @WithSecurityContext 커스텀 어노테이션이 적합합니다
- userId가 서비스로 잘 전달되는지만 검증하면 됩니다
@WebMvcTest(BookmarkController.class)
class BookmarkControllerTest {
@MockBean
private BookmarkService bookmarkService;
@Test
@WithMockCustomUser(userId = 1L)
void 북마크_목록_조회() throws Exception {
given(bookmarkService.getBookmarks(1L))
.willReturn(List.of(...));
mockMvc.perform(get("/api/bookmarks"))
.andExpect(status().isOk());
verify(bookmarkService).getBookmarks(1L);
}
}
통합 테스트 (@SpringBootTest + MockMvc)
- 실제 서비스, 실제 DB를 사용합니다
- 테스트 헬퍼 클래스로 User 생성 후 JWT 발급이 적합합니다
@SpringBootTest
@AutoConfigureMockMvc
@Transactional
class BookmarkIntegrationTest {
@Autowired
private TestHelper testHelper;
@Test
void 북마크_전체_흐름() throws Exception {
var user = testHelper.createUser();
// 실제 DB에 저장되고, 조회되는 전체 흐름 검증
}
}
E2E 테스트 (@SpringBootTest + TestRestTemplate)
- 실제 서버가 구동됩니다
- JWT를 직접 생성해서 HTTP 헤더에 담아야 합니다
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class BookmarkE2ETest {
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private TestHelper testHelper;
@Test
void 실제_HTTP_요청() {
var user = testHelper.createUser();
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(user.token());
ResponseEntity<List<BookmarkResponse>> response = restTemplate.exchange(
"/api/bookmarks",
HttpMethod.GET,
new HttpEntity<>(headers),
new ParameterizedTypeReference<>() {}
);
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);
}
}
결론
테스트 종류 인증 처리 방식
| 슬라이스 테스트 (@WebMvcTest) | @WithSecurityContext 커스텀 어노테이션 |
| 통합 테스트 (@SpringBootTest + MockMvc) | 테스트 헬퍼 + JWT |
| E2E 테스트 (@SpringBootTest + TestRestTemplate) | 테스트 헬퍼 + JWT |
핵심은 테스트 대상이 무엇인가입니다.
- 컨트롤러 로직만 검증? → 커스텀 어노테이션
- 실제 DB 흐름까지 검증? → 테스트 헬퍼 + JWT
- 인증 메커니즘 자체 검증? → JWT 직접 생성
상황에 맞는 방법을 선택하면 테스트 코드가 훨씬 깔끔해질 것 같습니다.
'프로젝트 > Techfork' 카테고리의 다른 글
| [26/01/27] 오늘의 개발 일지 - 검색 API 엔드포인트 통합 & 회원탈퇴 API 구현 & Activity 도메인 테스트 코드 작성 (0) | 2026.01.28 |
|---|---|
| [26/01/26] 오늘의 개발 일지 - iOS 전용 카카오 로그인 API 구현 및 사용자 API 구현 (0) | 2026.01.28 |
| [26/01/23] 오늘의 개발 일지 - baseUrl 문제 해결 및 쿠키 도메인 에러 해결 (0) | 2026.01.23 |
| [26/01/20] 오늘의 개발 일지 - CI에서 테스트 실행 및 통합 테스트 컨테이너 환경 개선 (1) | 2026.01.20 |
| [26/01/14] 오늘의 개발 일지 - 카카오 소셜 로그인 구현 (2) | 2026.01.15 |