Reactive GraphQL 마스터 노트 시리즈 6편. Spring Security GraphQL 통합, JWT·OAuth2 인증, 메서드·필드 레벨 권한 제어, Query Depth·Complexity 제한으로 DoS 방어, @GraphQlTest로 단위 테스트, HttpGraphQlTester로 통합 테스트, Subscription 테스트까지.
이 글은 Reactive GraphQL 마스터 노트 시리즈의 여섯 번째 편입니다. 1~5편이 기능이었다면, 이번엔 그것을 안전하게·검증 — Security·Testing.
GraphQL 단일 엔드포인트 = 보안 표면 다름. Query Complexity 공격·N+1 폭발 등 고유 위협. 테스트는 @GraphQlTest + HttpGraphQlTester.
처음 Security·Testing이 어렵게 느껴지는 이유
처음 이 단원이 어렵게 느껴지는 이유는 두 가지예요. 첫째, GraphQL 고유 보안 위협이 막연합니다. 둘째, Tester 도구가 한 번에 등장합니다.
해결법은 한 가지예요. "Query Complexity 공격 방어 + 표준 인증·인가" + "@GraphQlTest = 단위 / HttpGraphQlTester = 통합" 두 줄.
Spring Security 통합
implementation 'org.springframework.boot:spring-boot-starter-security'
@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {
@Bean
public SecurityWebFilterChain filterChain(ServerHttpSecurity http) {
return http
.authorizeExchange(auth -> auth
.pathMatchers("/graphql", "/graphiql").permitAll()
.anyExchange().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
.build();
}
}
GraphQL 엔드포인트 자체는 permitAll·내부에서 @PreAuthorize로 제어.
JWT 인증
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.example.com
@QueryMapping
public Mono<User> me(@AuthenticationPrincipal Jwt jwt) {
String userId = jwt.getSubject();
return userRepo.findById(userId);
}
OAuth2 Resource Server. JWT 자동 검증·@AuthenticationPrincipal 주입.
메서드 레벨 권한
@MutationMapping
@PreAuthorize("hasRole('ADMIN')")
public Mono<User> deleteUser(@Argument String id) {
return userRepo.deleteById(id);
}
@MutationMapping
@PreAuthorize("authentication.name == #userId")
public Mono<Profile> updateProfile(@Argument String userId, @Argument ProfileInput input) {
return profileService.update(userId, input);
}
@EnableMethodSecurity 활성. SpEL 표현식.
필드 레벨 권한
@SchemaMapping(typeName = "User", field = "email")
@PreAuthorize("hasRole('ADMIN') or authentication.name == #user.id")
public String email(User user) {
return user.getEmail();
}
특정 필드 = 특정 사용자만. 민감 정보 보호.
여기서 정말 중요한 시험 함정 — GraphQL 필드 레벨 보안 강력. REST와 다른 강점. 같은 객체·다른 사용자 = 다른 필드.
Query Complexity — DoS 방어
# 악의적 쿼리 — 깊이 무한
{
user(id: "1") {
posts {
author {
posts {
author {
posts {
# ...
}
}
}
}
}
}
}
여기서 정말 중요한 시험 함정 — GraphQL 단일 엔드포인트 = 무한 깊이 공격. 서버 폭주. Depth·Complexity 제한 필수.
Depth Limit
@Bean
public GraphQlSourceBuilderCustomizer depthLimit() {
return builder -> builder.configureGraphQl(graphQlBuilder ->
graphQlBuilder.instrumentation(new MaxQueryDepthInstrumentation(10))
);
}
쿼리 깊이 10 초과 시 거부.
Complexity Limit
@Bean
public GraphQlSourceBuilderCustomizer complexity() {
return builder -> builder.configureGraphQl(graphQlBuilder ->
graphQlBuilder.instrumentation(new MaxQueryComplexityInstrumentation(1000))
);
}
각 필드 비용 합산. 1000 초과 시 거부.
Rate Limit
@Bean
public WebFilter rateLimitFilter(RateLimiter limiter) {
return (exchange, chain) -> {
String clientId = extractClientId(exchange);
if (!limiter.tryAcquire(clientId)) {
exchange.getResponse().setStatusCode(HttpStatus.TOO_MANY_REQUESTS);
return exchange.getResponse().setComplete();
}
return chain.filter(exchange);
};
}
클라이언트별 요청 한도. Redis Rate Limiter·Bucket4j 등.
Introspection 제한
@Bean
public Instrumentation noIntrospectionInstrumentation() {
return NoIntrospectionGraphQlFieldVisibility.NO_INTROSPECTION_FIELD_VISIBILITY;
}
운영 환경 = Schema 노출 X. 공격자가 가능 쿼리 발견 어려움.
CORS
spring:
graphql:
cors:
allowed-origins: "https://example.com"
allowed-methods: "POST, OPTIONS"
allowed-headers: "*"
allow-credentials: true
브라우저 직접 호출 시.
SQL Injection 방어
@QueryMapping
public Flux<Post> searchPosts(@Argument String title) {
# X — 직접 쿼리 (위험)
# return repo.findByQuery("SELECT * FROM posts WHERE title LIKE '%" + title + "%'");
# O — 매개변수 사용
return repo.findByTitleContaining(title);
}
JPA·R2DBC·Spring Data 기본 매개변수 = 안전.
Persisted Queries (Trusted Documents)
클라이언트 → 미리 등록된 쿼리 ID만 전송
서버 → 등록된 쿼리만 실행
여기서 시험 함정이 하나 있어요. Persisted Queries = 운영 권장. 클라이언트가 임의 쿼리 X. 등록된 안전한 쿼리만. Apollo·Relay에서 자동.
테스트 — @GraphQlTest
단위 테스트:
@GraphQlTest(UserController.class)
class UserControllerTest {
@Autowired
private GraphQlTester tester;
@MockBean
private UserService userService;
@Test
void getUser() {
User user = new User("1", "Alice", "alice@x.com");
when(userService.findById("1")).thenReturn(Mono.just(user));
tester.document("""
query {
user(id: "1") {
id
name
email
}
}
""")
.execute()
.path("user.name").entity(String.class).isEqualTo("Alice")
.path("user.email").entity(String.class).isEqualTo("alice@x.com");
}
}
Spring Slice Test. 컨트롤러만 로드·빠름.
HttpGraphQlTester — 통합 테스트
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class IntegrationTest {
@Autowired
private HttpGraphQlTester tester;
@Test
void createUser() {
tester.document("""
mutation CreateUser($input: CreateUserInput!) {
createUser(input: $input) {
id
name
}
}
""")
.variable("input", Map.of("name", "Alice", "email", "alice@x.com"))
.execute()
.path("createUser.name").entity(String.class).isEqualTo("Alice")
.path("createUser.id").hasValue();
}
}
전체 환경. 실제 HTTP 호출.
WebSocketGraphQlTester — Subscription
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT)
class SubscriptionTest {
@Autowired
private WebSocketGraphQlTester wsTester;
@Test
void postCreated() {
Flux<Post> posts = wsTester.document("""
subscription {
postCreated {
id
title
}
}
""")
.executeSubscription()
.toFlux("postCreated", Post.class);
StepVerifier.create(posts.take(2))
.expectNextMatches(p -> p.getTitle().equals("First"))
.expectNextMatches(p -> p.getTitle().equals("Second"))
.verifyComplete();
}
}
Subscription도 자동 테스트.
인증 테스트
@Test
@WithMockUser(roles = "ADMIN")
void adminMutation() {
tester.document("mutation { deleteUser(id: \"1\") }")
.execute()
.errors().verify(); # 에러 없음
}
@Test
@WithMockUser(roles = "USER")
void userMutation_forbidden() {
tester.document("mutation { deleteUser(id: \"1\") }")
.execute()
.errors()
.satisfy(errors ->
assertThat(errors).anyMatch(e ->
e.getErrorType().equals(ErrorType.FORBIDDEN))
);
}
Spring Security 통합. 권한별 동작 검증.
에러 검증
@Test
void notFound() {
tester.document("""
query {
user(id: "999") { name }
}
""")
.execute()
.errors()
.satisfy(errors ->
assertThat(errors).hasSize(1)
.first()
.extracting(ResponseError::getMessage)
.isEqualTo("User not found")
);
}
Performance 테스트
@Test
void complexQueryRejected() {
String deepQuery = """
query {
user(id: "1") {
posts {
author {
posts {
# ... 깊이 11+
}
}
}
}
}
""";
tester.document(deepQuery)
.execute()
.errors()
.satisfy(errors ->
assertThat(errors).anyMatch(e ->
e.getMessage().contains("max depth"))
);
}
Depth·Complexity 제한 검증.
보안 체크리스트
✓ Spring Security 통합 (JWT·OAuth2)
✓ @PreAuthorize 메서드 보안
✓ 필드 레벨 보안 (@SchemaMapping + @PreAuthorize)
✓ Depth Limit (10~15)
✓ Complexity Limit (1000)
✓ Rate Limit (클라이언트별)
✓ Introspection 운영 OFF
✓ CORS 명시적
✓ Persisted Queries (운영 권장)
✓ HTTPS (TLS)
✓ 로그·메트릭
✓ Audit (민감 작업)
시험 직전 한 번 더 — 자주 헷갈리는 함정 모음
여기까지가 6편의 핵심입니다. 시험 직전 또는 실무에서 헷갈릴 때 다시 펼쳐 볼 수 있게 압축 노트로 마무리할게요.
- Spring Security 통합 —
@EnableWebFluxSecurity - JWT·OAuth2 Resource Server
@AuthenticationPrincipal Jwt jwt@PreAuthorize+@EnableMethodSecurity- 필드 레벨 보안 —
@SchemaMapping+@PreAuthorize(강점) - Query Complexity 공격 = GraphQL 고유
- Depth Limit (
MaxQueryDepthInstrumentation) - Complexity Limit (
MaxQueryComplexityInstrumentation) - Rate Limit = WebFilter
- Introspection 운영 OFF
- CORS·SQL Injection (매개변수)
- Persisted Queries = 등록된 쿼리만 (운영 권장)
@GraphQlTest= 단위 (컨트롤러만)HttpGraphQlTester= 통합 (실제 HTTP)WebSocketGraphQlTester= Subscriptiontester.document().execute().path()검증@WithMockUser권한별 테스트- 에러 검증 —
.errors().satisfy() - 운영 — TLS·짧은 토큰·Persisted·Audit·Rate Limit·Depth·Complexity
시리즈 다른 편
- 1편 — 기본 개념·Schema
- 2편 — Query·Mutation·Variables
- 3편 — Subscription·실시간 구독
- 4편 — Spring for GraphQL
- 5편 — Reactive GraphQL
- 6편 — Security·Testing (현재 글)
- 7편 — 고급 (DataLoader·Federation·운영)
공식 문서: Spring GraphQL Testing / Spring Security GraphQL 에서 더 깊이.
다음 글(7편, 마지막)에서는 고급 — DataLoader·N+1 깊이, Federation, Caching, Persisted Queries, 운영 모범 사례까지 시리즈 마무리.