GraphQL은 REST API의 한계를 뛰어넘는 강력한 쿼리 언어지만, 이런 유연함은 보안 측면에서 양날의 검이 될 수 있다 생각합니다
특히 프로덕션 환경에서 GraphQL을 안전하게 운영하려면 보안과 인증에 특별한 주의가 필요합니다.
제가 학습하면서 GraphQL을 다루며 얻은 경험과 인사이트를 나누고자 합니다.

GraphQL 보안은 마치 집의 보안 시스템과 비슷합니다.
현관문을 통과했다고 모든 방에 들어갈 수 있게 해서는 안 되죠.
각 방마다 적절한 열쇠가 필요한 것처럼, GraphQL에서도 여러 단계의 보안 장치가 필요합니다.
인증은 "당신이 누구인지" 확인하는 과정입니다.
GraphQL 엔드포인트에 접근하기 전, 사용자의 신원을 검증하는 첫 관문이죠.
JWT나 OAuth 같은 표준 메커니즘을 활용하면 효과적인 인증 시스템을 구축할 수 있습니다.
권한 부여는 "무엇을 할 수 있는지" 결정하는 과정입니다.
인증된 사용자라 할지라도, 특정 데이터에 접근하거나 특정 작업을 수행할 권한이 있는지 확인해야 합니다.
예를 들어, 일반 사용자는 자신의 프로필만 수정할 수 있고, 관리자는 모든 사용자의 프로필을 관리할 수 있도록 설정하는 것이죠.
스프링 시큐리티는 자바 생태계에서 보안을 다루는 강력한 프레임워크입니다.
GraphQL과 함께 사용하면 견고한 인증 시스템을 구축할 수 있어요.
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true, securedEnabled = true)
public class SecurityConfig {
@Bean
DefaultSecurityFilterChain springWebFilterChain(HttpSecurity http) throws Exception {
return http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(requests ->
requests.requestMatchers("/graphql").authenticated()
)
.httpBasic(withDefaults())
.build();
}
@Bean
public static InMemoryUserDetailsManager userDetailsService() {
User.UserBuilder userBuilder = User.builder();
UserDetails user = userBuilder.username("user")
.password(passwordEncoder().encode("password"))
.roles("USER")
.build();
return new InMemoryUserDetailsManager(user);
}
@Bean
public static PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
이 설정은 /graphql 엔드포인트에 접근하려면 인증이 필요하다고 선언합니다.
실제 프로덕션 환경에서는 InMemoryUserDetailsManager 대신 데이터베이스 기반 사용자 관리를
구현하는 것이 좋다 생각합니다.
때로는 일부 쿼리는 공개하고, 민감한 데이터를 다루는 쿼리만 보호하고 싶을 수 있습니다.
이런 경우 메서드 수준에서 보안을 적용할 수 있어요.
@Component
public class MyGraphQLDataFetcher implements GraphQLQueryResolver {
@PreAuthorize("hasRole('USER')")
public String sensitiveQuery() {
return "관리자 전용 데이터입니다";
}
// 인증 없이 접근 가능한 쿼리
public String publicQuery() {
return "누구나 볼 수 있는 데이터입니다";
}
}
이 방식을 사용하면 GraphQL 스키마 수준에서는 모든 쿼리가 동일하게 보이지만, 실제 실행 시 권한 체크가 이루어집니다.
사용자 경험은 유지하면서도 보안은 강화할 수 있는 좋은 방법입니다.
GraphQL Directive는 스키마 정의 시 추가적인 메타데이터를 제공하는 강력한 기능입니다.
이를 활용하면 코드 수준이 아닌 스키마 수준에서 인증 로직을 적용할 수 있어요.
public class AuthenticationDirective implements SchemaDirectiveWiring {
@Override
public GraphQLFieldDefinition onField(SchemaDirectiveWiringEnvironment<GraphQLFieldDefinition> environment) {
GraphQLFieldDefinition fieldDefinition = environment.getFieldDefinition();
GraphQLObjectType parentType = (GraphQLObjectType) environment.getFieldsContainer();
// 원래의 DataFetcher를 가져옵니다
DataFetcher<?> originalDataFetcher = environment.getCodeRegistry()
.getDataFetcher(parentType, fieldDefinition);
// 인증 검사를 수행하는 새로운 DataFetcher를 생성합니다
DataFetcher<?> authDataFetcher = dataFetchingEnvironment -> {
// 현재 인증 정보를 확인합니다
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
// 사용자가 인증되었는지 확인합니다
if (authentication == null ||
!authentication.isAuthenticated() ||
"anonymousUser".equals(authentication.getPrincipal())) {
throw new IllegalAccessException("Access Denied: Authentication required");
}
// 사용자가 인증되었다면 원래의 DataFetcher로 데이터를 가져옵니다
return originalDataFetcher.get(dataFetchingEnvironment);
};
// 변경된 DataFetcher를 등록합니다
environment.getCodeRegistry()
.dataFetcher(parentType, fieldDefinition, authDataFetcher);
return fieldDefinition;
}
}
이 Directive를 스키마에 적용하면 다음과 같이 사용할 수 있습니다.
type Query {
publicData: String
privateData: String @authenticated
}
이렇게 하면 privateData 필드에 접근할 때만 인증 체크가 이루어집니다.
스키마만 보더라도 어떤 필드가 보호되는지 명확히 알 수 있어 개발자 경험도 향상됩니다.
GraphQL의 유연성은 매우 복잡한 쿼리가 가능하다는 의미이기도 합니다.
이런 복잡한 쿼리는 서버에 과도한 부하를 줄 수 있으므로, 쿼리 복잡도를 제한하는 것이 중요합니다.
private QueryComplexityCalculator newQueryComplexityCalculator(ExecutionContext executionContext) {
return QueryComplexityCalculator.newCalculator()
.fieldComplexityCalculator(fieldComplexityCalculator)
.schema(executionContext.getGraphQLSchema())
.document(executionContext.getDocument())
.operationName(executionContext.getExecutionInput().getOperationName())
.variables(executionContext.getCoercedVariables())
.build();
}
예를 들어, 특정 깊이 이상의 중첩 쿼리를 막을 수 있습니다.
# 이런 극단적인 중첩 쿼리를 방지할 수 있습니다
query {
user(id: "1") {
friends {
friends {
friends {
friends {
# ... 무한히 중첩될 수 있음
}
}
}
}
}
}
장시간 실행되는 쿼리는 서버 리소스를 독점할 수 있습니다.
쿼리 실행 시간에 제한을 두어 이를 방지할 수 있습니다.
@Component
public class TimeoutInstrumentation extends SimpleInstrumentation {
private static final long TIMEOUT_MS = 5000; // 5초
@Override
public InstrumentationContext<ExecutionResult> beginExecution(InstrumentationExecutionParameters parameters) {
long startTime = System.currentTimeMillis();
return SimpleInstrumentationContext.whenCompleted((result, throwable) -> {
long executionTime = System.currentTimeMillis() - startTime;
if (executionTime > TIMEOUT_MS) {
// 로깅이나 모니터링 알림을 보낼 수 있습니다
log.warn("Query execution time exceeded limit: {}ms", executionTime);
}
});
}
}
실제 프로덕션 환경에서는 타임아웃에 도달했을 때 쿼리 실행을 강제 종료하는 로직을 추가하는 것이 좋습니다.
프로덕션 환경에서는 모든 임의 쿼리를 허용하기보다, 미리 검증된 쿼리만 허용하는 것이 안전합니다.
이를 쿼리 화이트리스트라고 합니다.
@Component
public class QueryWhitelistValidator implements Validator {
private final Set<String> allowedQueries;
public QueryWhitelistValidator() {
// 허용된 쿼리의 해시값을 저장
this.allowedQueries = Set.of(
"a1b2c3d4e5f6", // getUserProfile 쿼리의 해시
"g7h8i9j0k1l2" // updateUserSettings 뮤테이션의 해시
);
}
@Override
public ValidationResult validate(String query) {
String queryHash = calculateHash(query);
if (allowedQueries.contains(queryHash)) {
return ValidationResult.valid();
}
return ValidationResult.invalid("쿼리가 허용 목록에 없습니다");
}
private String calculateHash(String query) {
// 쿼리의 해시값 계산 로직
}
}
이 방식은 클라이언트 애플리케이션에서 미리 정의된 쿼리만 사용해야 하지만, 보안성은 크게 향상됩니다.
GraphQL에서 자주 발생하는 N+1 쿼리 문제는 성능 이슈일 뿐만 아니라
잠재적인 보안 취약점이 될 수 있습니다.
데이터베이스에 과도한 부하를 줄 수 있기 때문이죠.
@Component
public class UserDataLoader {
private final BatchLoader<Long, User> userBatchLoader;
private final DataLoader<Long, User> dataLoader;
public UserDataLoader(UserRepository userRepository) {
this.userBatchLoader = ids -> {
List<User> users = userRepository.findAllByIdIn(ids);
Map<Long, User> userMap = users.stream()
.collect(Collectors.toMap(User::getId, Function.identity()));
return CompletableFuture.supplyAsync(() ->
ids.stream()
.map(userMap::get)
.collect(Collectors.toList())
);
};
this.dataLoader = DataLoaderFactory.newDataLoader(userBatchLoader);
}
public DataLoader<Long, User> getDataLoader() {
return dataLoader;
}
}
이렇게 구현한 DataLoader를 리졸버에서 사용하면 여러 개의 개별 쿼리 대신 단일 배치 쿼리를 실행하여 성능을 크게 향상시킬 수 있습니다.
보안은 단순히 예방만으로는 부족합니다.
지속적인 모니터링과 이상 징후 감지가 필수적입니다.
@Component
public class SecurityMonitoringInstrumentation extends SimpleInstrumentation {
@Override
public InstrumentationContext<ExecutionResult> beginExecution(InstrumentationExecutionParameters parameters) {
String query = parameters.getQuery();
Map<String, Object> variables = parameters.getVariables();
// 쿼리 패턴 분석
if (containsSuspiciousPattern(query)) {
log.warn("Suspicious query pattern detected: {}", query);
// 보안 팀에 알림 전송 로직
}
// 요청 빈도 분석
String clientIp = getClientIp();
if (isRateLimitExceeded(clientIp)) {
log.warn("Rate limit exceeded for IP: {}", clientIp);
// 일시적 차단 로직
}
return SimpleInstrumentationContext.noOp();
}
private boolean containsSuspiciousPattern(String query) {
// 의심스러운 패턴 탐지 로직
}
private boolean isRateLimitExceeded(String clientIp) {
// 속도 제한 검사 로직
}
}
실제 서비스에서는 여러 권한 수준(일반 사용자, 프리미엄 사용자, 관리자 등)을 갖는
사용자들이 API에 접근합니다.
이런 상황에서 어떻게 보안을 설계할지 예제를 통해 살펴보겠습니다.
// 1. 권한 수준을 정의하는 Enum
public enum Role {
USER, // 일반 사용자
PREMIUM, // 프리미엄 사용자
ADMIN // 관리자
}
// 2. 커스텀 보안 애노테이션 생성
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequiresRole {
Role[] value();
}
// 3. DataFetcher에 권한 체크 로직 구현
@Component
public class ProductResolver implements GraphQLQueryResolver {
private final ProductService productService;
@Autowired
public ProductResolver(ProductService productService) {
this.productService = productService;
}
// 모든 사용자가 접근 가능
public List<Product> getPublicProducts() {
return productService.getPublicProducts();
}
// 프리미엄 사용자와 관리자만 접근 가능
@RequiresRole({Role.PREMIUM, Role.ADMIN})
public List<Product> getPremiumProducts() {
return productService.getPremiumProducts();
}
// 관리자만 접근 가능
@RequiresRole(Role.ADMIN)
public List<Product> getAllProducts() {
return productService.getAllProducts();
}
}
// 4. AOP를 사용한 권한 검사 Aspect
@Aspect
@Component
public class SecurityAspect {
@Around("@annotation(requiresRole)")
public Object checkRole(ProceedingJoinPoint joinPoint, RequiresRole requiresRole) throws Throwable {
// 현재 인증된 사용자 정보 가져오기
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication == null || !authentication.isAuthenticated()) {
throw new AccessDeniedException("인증이 필요합니다");
}
// 사용자 권한 확인
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();
boolean hasRequiredRole = false;
for (Role requiredRole : requiresRole.value()) {
String roleString = "ROLE_" + requiredRole.name();
if (authorities.stream().anyMatch(a -> a.getAuthority().equals(roleString))) {
hasRequiredRole = true;
break;
}
}
if (!hasRequiredRole) {
throw new AccessDeniedException("이 작업을 수행할 권한이 없습니다");
}
// 권한 검사를 통과하면 원래 메서드 실행
return joinPoint.proceed();
}
}
// 5. GraphQL 오류 핸들러 구현
@Component
public class CustomGraphQLErrorHandler implements GraphQLErrorHandler {
@Override
public List<GraphQLError> processErrors(List<GraphQLError> errors) {
return errors.stream()
.map(this::processError)
.collect(Collectors.toList());
}
private GraphQLError processError(GraphQLError error) {
if (error instanceof ExceptionWhileDataFetching) {
ExceptionWhileDataFetching exceptionError = (ExceptionWhileDataFetching) error;
Throwable exception = exceptionError.getException();
// 보안 관련 예외는 클라이언트에 자세한 정보를 노출하지 않음
if (exception instanceof AccessDeniedException) {
return new SimpleGraphQLError("권한이 부족합니다");
} else if (exception instanceof AuthenticationException) {
return new SimpleGraphQLError("인증이 필요합니다");
}
// 로깅
log.error("GraphQL 데이터 조회 중 오류 발생", exception);
}
return error;
}
// 클라이언트에 반환할 간소화된 에러
private static class SimpleGraphQLError implements GraphQLError {
private final String message;
public SimpleGraphQLError(String message) {
this.message = message;
}
@Override
public String getMessage() {
return message;
}
@Override
public List<SourceLocation> getLocations() {
return null;
}
@Override
public ErrorClassification getErrorType() {
return ErrorType.ValidationError;
}
}
}
// 6. 실제 GraphQL 스키마 정의 예시
/*
type Query {
# 모든 사용자 접근 가능
publicProducts: [Product!]!
# 프리미엄 사용자와 관리자만 접근 가능
premiumProducts: [Product!]!
# 관리자만 접근 가능
allProducts: [Product!]!
}
type Product {
id: ID!
name: String!
price: Float!
description: String
# 관리자만 볼 수 있는 필드
cost: Float @requiresRole(role: "ADMIN")
margin: Float @requiresRole(role: "ADMIN")
}
*/
다양한 권한 수준을 가진 사용자들이 접근하는 API를 안전하게 구현하는 방법을 보여줍니다.
특히 주목할 점은
GraphQL API 보안은 일회성 작업이 아닌 지속적인 과정입니다.
마치 건강 관리와 같이 꾸준한 관심과 노력이 필요합니다.
GraphQL의 강력함과 유연성을 활용하면서도 보안을 소홀히 하지 않는다면, 안전하고 효율적인 API를 구축할 수 있습니다.
마치 훌륭한 요리사가 날카로운 칼을 조심스럽게 다루듯, GraphQL이라는 강력한 도구를 안전하게 다뤄야 한다는것을 배웠습니다.