Security Testing — 보안이 적용된 API를 어떻게 테스트할까
인증이 필요한 API를 테스트할 때, 매번 로그인 과정을 거쳐야 할까요?
보안이 적용된 API를 테스트하는 것은 까다롭습니다. 인증 과정을 매번 수행하면 테스트가 느려지고, 인증 로직의 변경이 모든 테스트에 영향을 줍니다. Spring Security Test는 이 문제를 해결하는 다양한 도구를 제공합니다.
설정
보안 테스트 도구를 사용하려면 spring-security-test 의존성이 필요합니다.
// build.gradle
testImplementation 'org.springframework.security:spring-security-test'
@WithMockUser — 가장 간단한 방법
@WithMockUser는 가짜 인증 객체를 SecurityContext에 설정합니다. UserDetailsService를 호출하지 않습니다.
기본 설정부터 살펴보겠습니다. @WebMvcTest에서 보안 설정을 포함시키려면 @Import가 필요합니다.
@WebMvcTest(MemberController.class)
@Import(SecurityConfig.class)
class MemberControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private MemberService memberService;
@WithMockUser를 테스트 메서드에 붙이면 해당 테스트가 인증된 상태로 실행됩니다.
@Test
@WithMockUser // 기본값: username="user", roles="USER"
void 인증된_사용자가_프로필을_조회한다() throws Exception {
given(memberService.getProfile(any())).willReturn(new ProfileDto("user", "닉네임"));
mockMvc.perform(get("/api/profile"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.nickname").value("닉네임"));
}
@Test
@WithMockUser(username = "admin", roles = {"ADMIN"})
void 관리자가_회원_목록을_조회한다() throws Exception {
mockMvc.perform(get("/api/admin/members"))
.andExpect(status().isOk());
}
@WithMockUser를 붙이지 않으면 인증되지 않은 상태로 요청합니다. 이것으로 보안 규칙이 정상 동작하는지 검증합니다.
@Test
void 인증되지_않은_사용자는_접근할_수_없다() throws Exception {
mockMvc.perform(get("/api/profile"))
.andExpect(status().isUnauthorized());
}
}
@WithUserDetails — 실제 UserDetailsService 사용
실제 UserDetailsService를 호출하여 인증 객체를 생성합니다. 커스텀 UserDetails의 추가 필드가 필요할 때 유용합니다.
@Test
@WithUserDetails(value = "admin@example.com", userDetailsServiceBeanName = "customUserDetailsService")
void 실제_사용자_정보로_테스트() throws Exception {
mockMvc.perform(get("/api/me"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.email").value("admin@example.com"));
}
@WithUserDetails를 사용하려면 해당 사용자가 DB에 존재해야 하므로, @SpringBootTest와 함께 사용하거나 테스트용 UserDetailsService를 모킹합니다.
SecurityMockMvcRequestPostProcessors
MockMvc 요청에 보안 관련 설정을 추가하는 유틸리티입니다.
CSRF 토큰
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
@Test
@WithMockUser
void CSRF_토큰과_함께_POST_요청() throws Exception {
mockMvc.perform(post("/api/members")
.with(csrf()) // CSRF 토큰 추가
.contentType(MediaType.APPLICATION_JSON)
.content("{\"name\": \"심정훈\"}"))
.andExpect(status().isCreated());
}
사용자 설정 (인라인)
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
@Test
void 인라인으로_사용자_설정() throws Exception {
mockMvc.perform(get("/api/profile")
.with(user("admin").roles("ADMIN")))
.andExpect(status().isOk());
}
HTTP Basic 인증
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic;
@Test
void HTTP_Basic_인증() throws Exception {
mockMvc.perform(get("/api/profile")
.with(httpBasic("user", "password")))
.andExpect(status().isOk());
}
JWT 테스트
JWT 기반 인증을 테스트하는 방법은 여러 가지가 있습니다.
방법 1: 실제 JWT 토큰 생성
JwtTokenProvider를 주입받아 테스트용 토큰을 생성하는 방식입니다. 실제 JWT 검증 로직까지 테스트할 수 있습니다.
@SpringBootTest
@AutoConfigureMockMvc
class JwtAuthTest {
@Autowired private MockMvc mockMvc;
@Autowired private JwtTokenProvider jwtTokenProvider;
@Test
void JWT_토큰으로_인증된_요청() throws Exception {
String token = jwtTokenProvider.createToken("user@example.com", List.of("ROLE_USER"));
mockMvc.perform(get("/api/profile")
.header("Authorization", "Bearer " + token))
.andExpect(status().isOk());
}
만료된 토큰으로 요청하면 401이 반환되는지도 검증합니다.
@Test
void 만료된_토큰으로_요청하면_401() throws Exception {
String expiredToken = jwtTokenProvider.createExpiredToken("user@example.com");
mockMvc.perform(get("/api/profile")
.header("Authorization", "Bearer " + expiredToken))
.andExpect(status().isUnauthorized());
}
}
방법 2: SecurityContext 직접 설정
@Test
void SecurityContext를_직접_설정하여_JWT_우회() throws Exception {
CustomUserDetails userDetails = new CustomUserDetails(
new Member(1L, "user@example.com", "닉네임", Role.USER));
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
mockMvc.perform(get("/api/profile")
.with(request -> {
SecurityContextHolder.getContext().setAuthentication(auth);
return request;
}))
.andExpect(status().isOk());
}
방법 3: 커스텀 WithSecurityContext
@WithMockUser가 제공하는 기본 User 객체로는 부족할 때(커스텀 UserDetails의 추가 필드가 필요한 경우), 직접 SecurityContext를 생성하는 어노테이션을 만들 수 있습니다.
먼저 커스텀 어노테이션을 정의합니다.
@Retention(RetentionPolicy.RUNTIME)
@WithSecurityContext(factory = WithMockCustomUserSecurityContextFactory.class)
public @interface WithMockCustomUser {
String email() default "user@example.com";
String role() default "USER";
}
팩토리 클래스에서 어노테이션 속성을 읽어 SecurityContext를 생성합니다.
public class WithMockCustomUserSecurityContextFactory
implements WithSecurityContextFactory<WithMockCustomUser> {
@Override
public SecurityContext createSecurityContext(WithMockCustomUser annotation) {
SecurityContext context = SecurityContextHolder.createEmptyContext();
CustomUserDetails userDetails = new CustomUserDetails(
new Member(1L, annotation.email(), "테스트유저", Role.valueOf(annotation.role())));
context.setAuthentication(new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities()));
return context;
}
}
테스트에서는 @WithMockUser처럼 간단하게 사용합니다.
@Test
@WithMockCustomUser(email = "admin@example.com", role = "ADMIN")
void 커스텀_어노테이션으로_인증() throws Exception {
mockMvc.perform(get("/api/admin/members"))
.andExpect(status().isOk());
}
인가 테스트 패턴
하나의 엔드포인트에 대해 "허용/거부/미인증" 세 가지 시나리오를 모두 테스트하는 것이 좋습니다.
@Nested
class 권한_테스트 {
@Test
@WithMockUser(roles = "ADMIN")
void 관리자는_회원을_삭제할_수_있다() throws Exception {
mockMvc.perform(delete("/api/admin/members/1").with(csrf()))
.andExpect(status().isOk());
}
@Test
@WithMockUser(roles = "USER")
void 일반_사용자는_회원을_삭제할_수_없다() throws Exception {
mockMvc.perform(delete("/api/admin/members/1").with(csrf()))
.andExpect(status().isForbidden());
}
미인증 상태에서의 접근도 반드시 테스트해야 보안 규칙이 올바르게 동작함을 확인할 수 있습니다.
@Test
void 인증되지_않은_사용자는_401() throws Exception {
mockMvc.perform(delete("/api/admin/members/1").with(csrf()))
.andExpect(status().isUnauthorized());
}
}
이 세 가지 케이스가 모두 통과해야 보안 규칙이 올바르게 동작한다고 확신할 수 있습니다.
@WebMvcTest vs @SpringBootTest
| 항목 | @WebMvcTest | @SpringBootTest + @AutoConfigureMockMvc |
|---|---|---|
| 로드 범위 | 컨트롤러 + 보안 설정 | 전체 애플리케이션 |
| 속도 | 빠름 | 느림 |
| 서비스 계층 | @MockBean 필요 | 실제 빈 사용 |
| 적합한 테스트 | 컨트롤러 단위 테스트 | 통합 테스트 |
// @WebMvcTest에서 보안 설정 포함
@WebMvcTest(MemberController.class)
@Import(SecurityConfig.class)
class MemberControllerTest { ... }
주의할 점
@WithMockUser의 principal이 커스텀 UserDetails가 아닌 문제
@WithMockUser가 생성하는 principal은 Spring Security의 기본 User 객체입니다. 컨트롤러에서 @AuthenticationPrincipal CustomUserDetails user로 받으면 ClassCastException이 발생합니다. 커스텀 UserDetails가 필요한 테스트에서는 @WithMockUser 대신 커스텀 WithSecurityContext를 사용해야 합니다.
CSRF 토큰 누락으로 POST/PUT/DELETE 테스트가 403을 반환하는 문제
CSRF 보호가 활성화된 상태에서 csrf() 없이 POST 요청을 보내면 403 Forbidden 이 반환됩니다. 권한 문제인 줄 알고 SecurityConfig를 수정하는 실수를 하기 쉽습니다. 상태 변경 요청에는 반드시 .with(csrf())를 추가해야 합니다.
@WebMvcTest에서 SecurityFilterChain 빈을 찾지 못하는 문제
@WebMvcTest는 컨트롤러 계층만 로드하기 때문에, SecurityConfig 클래스가 자동으로 포함되지 않습니다. @Import(SecurityConfig.class)를 명시하지 않으면 Spring Boot가 기본 보안 설정을 적용하여, 실제 운영 환경과 다른 보안 규칙으로 테스트하게 됩니다.
정리
| 항목 | 설명 |
|---|---|
| @WithMockUser | 가짜 인증 객체로 간단하게 인증 상태 설정 (기본 User 타입) |
| @WithUserDetails | 실제 UserDetailsService를 호출하여 인증 객체 생성 |
| csrf() | POST/PUT/DELETE 테스트 시 CSRF 토큰 자동 추가 |
| JWT 테스트 | 실제 토큰 생성 또는 커스텀 WithSecurityContext 사용 |
| @WebMvcTest | @Import(SecurityConfig.class)로 보안 설정 명시적 포함 필요 |
| 인가 테스트 | 허용/거부/미인증 세 가지 시나리오 모두 검증 |