REST API를 사용할 때 "이 화면에는 이 필드가 필요 없는데..."하면서 불필요한 데이터를 받고 있다면, 클라이언트가 필요한 것만 골라서 요청할 수 있는 방법이 있을까요?

GraphQL이란

GraphQL은 Facebook이 만든 API 쿼리 언어입니다. REST와 달리 클라이언트가 필요한 데이터의 구조를 직접 정의 하여 요청합니다.

GRAPHQL
# REST: GET /api/articles/1 → 모든 필드 반환
# GraphQL: 필요한 필드만 요청
query {
  article(id: 1) {
    title
    author {
      name
    }
    comments {
      content
    }
  }
}

의존성과 설정

JAVA
// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-graphql'
implementation 'org.springframework.boot:spring-boot-starter-web'
YAML
# application.yml
spring:
  graphql:
    graphiql:
      enabled: true    # GraphiQL UI 활성화 (개발용)
    schema:
      locations: classpath:graphql/  # 스키마 파일 위치

스키마 정의 (Schema-First)

GRAPHQL
# src/main/resources/graphql/schema.graphqls

type Query {
    article(id: ID!): Article
    articles(page: Int = 0, size: Int = 10): ArticlePage!
    searchArticles(keyword: String!): [Article!]!
}

type Mutation {
    createArticle(input: CreateArticleInput!): Article!
    updateArticle(id: ID!, input: UpdateArticleInput!): Article!
    deleteArticle(id: ID!): Boolean!
}

이어서 나머지 구현 부분입니다.

GRAPHQL
type Article {
    id: ID!
    title: String!
    content: String!
    author: Author!
    comments: [Comment!]!
    createdAt: String!
}

type Author {
    id: ID!
    name: String!
    email: String!
    articles: [Article!]!
}

이어서 보안 및 인증 관련 설정을 추가합니다.

GRAPHQL
type Comment {
    id: ID!
    content: String!
    author: Author!
    createdAt: String!
}

type ArticlePage {
    content: [Article!]!
    totalElements: Int!
    totalPages: Int!
}

이어서 나머지 구현 부분입니다.

GRAPHQL
input CreateArticleInput {
    title: String!
    content: String!
    authorId: ID!
}

input UpdateArticleInput {
    title: String
    content: String
}

Controller 구현

@QueryMapping — 쿼리 해결

JAVA
@Controller
@RequiredArgsConstructor
public class ArticleController {

    private final ArticleService articleService;

    @QueryMapping
    public Article article(@Argument Long id) {
        return articleService.findById(id);
    }

이어서 @QueryMapping을 적용한 나머지 구현부입니다.

JAVA
    @QueryMapping
    public ArticlePage articles(
            @Argument int page, @Argument int size) {
        return articleService.findAll(PageRequest.of(page, size));
    }

    @QueryMapping
    public List<Article> searchArticles(@Argument String keyword) {
        return articleService.search(keyword);
    }
}

@MutationMapping — 변경 처리

JAVA
@Controller
@RequiredArgsConstructor
public class ArticleMutationController {

    private final ArticleService articleService;

    @MutationMapping
    public Article createArticle(
            @Argument CreateArticleInput input) {
        return articleService.create(input);
    }

이어서 @MutationMapping을 적용한 나머지 구현부입니다.

JAVA
    @MutationMapping
    public Article updateArticle(
            @Argument Long id,
            @Argument UpdateArticleInput input) {
        return articleService.update(id, input);
    }

    @MutationMapping
    public boolean deleteArticle(@Argument Long id) {
        articleService.delete(id);
        return true;
    }
}

@SchemaMapping — 중첩 필드 해결

JAVA
@Controller
@RequiredArgsConstructor
public class ArticleSchemaController {

    private final AuthorService authorService;
    private final CommentService commentService;

    // Article.author 필드를 해결
    @SchemaMapping(typeName = "Article")
    public Author author(Article article) {
        return authorService.findById(article.getAuthorId());
    }

    // Article.comments 필드를 해결
    @SchemaMapping(typeName = "Article")
    public List<Comment> comments(Article article) {
        return commentService.findByArticleId(article.getId());
    }
}

클라이언트가 author 필드를 요청하지 않으면 author() 메서드는 호출되지 않습니다. 필요한 데이터만 로딩하는 것입니다.

DataLoader — N+1 문제 해결

10개의 글 목록에서 각 글의 작성자를 조회하면 10번의 쿼리가 발생합니다. DataLoader는 이를 1번의 배치 쿼리로 줄여줍니다.

BatchLoaderRegistry로 등록

JAVA
@Configuration
public class DataLoaderConfig {

    @Bean
    public BatchLoaderRegistry batchLoaderRegistry(
            AuthorService authorService) {
        BatchLoaderRegistry registry = new BatchLoaderRegistry();

        registry.forTypePair(Long.class, Author.class)
            .registerMappedBatchLoader((authorIds, env) -> {
                // 한 번의 쿼리로 모든 작성자 조회
                Map<Long, Author> authors =
                    authorService.findByIds(authorIds);
                return Mono.just(authors);
            });

        return registry;
    }
}

Controller에서 DataLoader 사용

JAVA
@Controller
public class ArticleSchemaController {

    @SchemaMapping(typeName = "Article")
    public CompletableFuture<Author> author(
            Article article, DataLoader<Long, Author> authorDataLoader) {
        // 개별 호출이 아닌 배치로 모아서 처리
        return authorDataLoader.load(article.getAuthorId());
    }
}
PLAINTEXT
// Before (N+1):
SELECT * FROM authors WHERE id = 1;
SELECT * FROM authors WHERE id = 2;
...
SELECT * FROM authors WHERE id = 10;

// After (DataLoader):
SELECT * FROM authors WHERE id IN (1, 2, ..., 10);  // 1번의 쿼리

보안

JAVA
@Controller
@RequiredArgsConstructor
public class SecureArticleController {

    @QueryMapping
    public Article article(@Argument Long id) {
        return articleService.findById(id);  // 모두 접근 가능
    }

    @MutationMapping
    @PreAuthorize("hasRole('EDITOR')")
    public Article createArticle(@Argument CreateArticleInput input) {
        return articleService.create(input);
    }

이어서 보안 및 인증 관련 설정을 추가합니다.

JAVA
    @MutationMapping
    @PreAuthorize("hasRole('ADMIN')")
    public boolean deleteArticle(@Argument Long id) {
        articleService.delete(id);
        return true;
    }

    // 현재 사용자의 데이터만 반환
    @QueryMapping
    @PreAuthorize("isAuthenticated()")
    public List<Article> myArticles(
            @AuthenticationPrincipal UserDetails user) {
        return articleService.findByAuthorEmail(user.getUsername());
    }
}

에러 처리

JAVA
@Controller
public class ArticleController {

    @QueryMapping
    public Article article(@Argument Long id) {
        return articleService.findById(id);
        // 예외 발생 시 GraphQL 에러 응답으로 변환
    }
}

// 커스텀 에러 처리
@Component
public class CustomGraphQlExceptionHandler
        implements DataFetcherExceptionResolver {

이어서 리액티브 방식의 비동기 호출을 구현합니다.

JAVA
    @Override
    public Mono<List<GraphQLError>> resolveException(
            Throwable exception, DataFetchingEnvironment env) {

        if (exception instanceof NotFoundException) {
            GraphQLError error = GraphqlErrorBuilder.newError(env)
                .message(exception.getMessage())
                .errorType(ErrorType.NOT_FOUND)
                .build();
            return Mono.just(List.of(error));
        }

        return Mono.empty();  // 기본 처리에 위임
    }
}

GraphQL vs REST 선택 기준

기준GraphQLREST
데이터 요구사항화면마다 다름일정함
클라이언트 종류모바일 + 웹 + API주로 하나
응답 최적화필요한 필드만전체 반환
캐싱복잡 (POST 기반)간단 (URL 기반 HTTP 캐싱)
학습 곡선높음낮음
파일 업로드별도 처리 필요자연스러움
적합한 상황복잡한 데이터 그래프단순 CRUD

주의할 점

1. DataLoader를 적용하지 않으면 N+1 쿼리 문제가 REST보다 심각하다

GraphQL은 클라이언트가 자유롭게 중첩 필드를 요청할 수 있어, @SchemaMapping으로 연관 데이터를 해결할 때 건건이 DB 쿼리를 실행합니다. 주문 100건의 사용자 정보를 조회하면 100번의 쿼리가 발생합니다. DataLoader로 배치 로딩을 적용하지 않으면 성능이 REST보다 나빠집니다.

2. 쿼리 깊이 제한을 설정하지 않으면 서버가 DDoS 공격에 취약하다

악의적인 클라이언트가 { user { friends { friends { friends { ... } } } } } 같은 깊은 중첩 쿼리를 보내면 서버 리소스가 폭발적으로 소모됩니다. graphql.servlet.maxQueryDepth나 커스텀 Instrumentation으로 쿼리 깊이와 복잡도를 제한해야 합니다.

3. 에러 처리가 REST와 다르게 동작하여 클라이언트가 혼란을 겪을 수 있다

GraphQL은 에러가 발생해도 HTTP 200을 반환하고, 응답 JSON의 errors 필드에 에러 정보를 담습니다. REST에 익숙한 클라이언트 개발자가 HTTP 상태 코드만 확인하면 에러를 놓칩니다. 에러 응답 형식과 처리 방법을 API 문서에 명확히 기술해야 합니다.

정리

  • GraphQL 은 클라이언트가 필요한 데이터만 요청하여 Over-fetching/Under-fetching을 해결합니다.
  • @QueryMapping, @MutationMapping으로 쿼리와 변경을 처리하고, @SchemaMapping으로 중첩 필드를 해결합니다.
  • DataLoader 로 N+1 문제를 배치 로딩으로 해결합니다. 필수로 적용하세요.
  • Spring Security의 @PreAuthorize를 그대로 사용할 수 있습니다.
  • 모든 API를 GraphQL로 만들 필요는 없습니다. 복잡한 데이터 그래프에 적합하고, 단순 CRUD에는 REST가 더 나을 수 있습니다.
댓글 로딩 중...