Reactive GraphQL — Security·Testing

2026-05-03확률과 통계 마스터 노트

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 = Subscription
  • tester.document().execute().path() 검증
  • @WithMockUser 권한별 테스트
  • 에러 검증 — .errors().satisfy()
  • 운영 — TLS·짧은 토큰·Persisted·Audit·Rate Limit·Depth·Complexity

시리즈 다른 편

공식 문서: Spring GraphQL Testing / Spring Security GraphQL 에서 더 깊이.

다음 글(7편, 마지막)에서는 고급 — DataLoader·N+1 깊이, Federation, Caching, Persisted Queries, 운영 모범 사례까지 시리즈 마무리.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!