본문 바로가기

프로젝트/Techfork

[26/01/22] 오늘의 개발 일지 - 개발자 토큰 API 구현 및 Spring Security Testing 삽질

오늘은 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 직접 생성

상황에 맞는 방법을 선택하면 테스트 코드가 훨씬 깔끔해질 것 같습니다.