Architecture

GraphQL 실전 가이드 - REST를 넘어서는 API 설계

백엔드 개발자 김승원 2026. 4. 13. 09:43

들어가며

"프론트엔드에서 필요한 데이터가 바뀔 때마다 API를 새로 만들어야 하나요?" 백엔드 개발자라면 이 질문에 공감하실 것입니다. 모바일 앱은 화면별로 필요한 데이터가 다르고, 웹은 또 다른 조합이 필요합니다. REST API로는 Over-fetching(불필요한 데이터까지 전달)과 Under-fetching(여러 API를 호출해야 원하는 데이터를 조합) 문제가 반복됩니다.

GraphQL은 이 문제를 클라이언트가 필요한 데이터만 정확히 요청하는 방식으로 해결합니다. 하지만 GraphQL이 항상 REST보다 좋은 것은 아닙니다. 이 글에서는 Spring for GraphQL을 활용한 실전 구현부터 N+1 문제 해결, Subscription을 이용한 실시간 통신, 그리고 REST와 GraphQL을 언제 각각 선택해야 하는지까지 균형 잡힌 시각으로 다루겠습니다.

1. GraphQL vs REST - 핵심 차이

항목 REST GraphQL
엔드포인트 리소스별 복수 (/users, /posts) 단일 (/graphql)
데이터 결정권 서버가 응답 구조 결정 클라이언트가 필요한 필드 선택
Over-fetching 자주 발생 없음
Under-fetching 자주 발생 (N+1 호출) 한 번의 쿼리로 해결
버전 관리 /api/v1, /api/v2 스키마 진화 (버전 불필요)
캐싱 HTTP 캐싱 쉬움 별도 캐싱 전략 필요
파일 업로드 Multipart로 간편 별도 처리 필요
학습 곡선 낮음 중간~높음
에러 처리 HTTP 상태 코드 항상 200, errors 필드

2. Schema 설계 - GraphQL의 핵심

GraphQL에서 Schema는 API의 계약서(Contract)입니다. Schema-First 접근법으로 .graphqls 파일에 타입을 정의합니다.

schema.graphqls

type Query {
    # 게시글 목록 (페이징)
    posts(page: Int = 0, size: Int = 10): PostConnection!
    # 게시글 상세
    post(id: ID!): Post
    # 작성자 조회
    author(id: ID!): Author
}

type Mutation {
    createPost(input: CreatePostInput!): Post!
    updatePost(id: ID!, input: UpdatePostInput!): Post!
    deletePost(id: ID!): Boolean!
}

type Subscription {
    postCreated: Post!
    commentAdded(postId: ID!): Comment!
}

type Post {
    id: ID!
    title: String!
    content: String!
    status: PostStatus!
    author: Author!
    comments: [Comment!]!
    tags: [Tag!]!
    createdAt: String!
    updatedAt: String!
}

type Author {
    id: ID!
    name: String!
    email: String!
    posts: [Post!]!
    postCount: Int!
}

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

type Tag {
    id: ID!
    name: String!
}

enum PostStatus {
    DRAFT
    PUBLISHED
    ARCHIVED
}

# 커서 기반 페이징
type PostConnection {
    content: [Post!]!
    totalElements: Int!
    totalPages: Int!
    hasNext: Boolean!
}

input CreatePostInput {
    title: String!
    content: String!
    tagIds: [ID!]
}

input UpdatePostInput {
    title: String
    content: String
    status: PostStatus
}

3. Spring for GraphQL 연동

의존성 추가 (build.gradle)

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-graphql'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-validation'
    runtimeOnly 'org.postgresql:postgresql'

    // GraphiQL (개발용 IDE)
    // spring.graphql.graphiql.enabled=true 로 활성화
}

application.yml

spring:
  graphql:
    graphiql:
      enabled: true   # /graphiql 경로로 접근
    schema:
      locations: classpath:graphql/**/  # .graphqls 파일 위치
      printer:
        enabled: true
    websocket:
      path: /graphql  # Subscription용 WebSocket 경로

Controller 구현

@Controller
@RequiredArgsConstructor
public class PostController {

    private final PostService postService;

    @QueryMapping
    public PostConnection posts(@Argument int page, @Argument int size) {
        Page<Post> postPage = postService.findAll(
            PageRequest.of(page, size, Sort.by("createdAt").descending())
        );
        return PostConnection.from(postPage);
    }

    @QueryMapping
    public Optional<Post> post(@Argument Long id) {
        return postService.findById(id);
    }

    @MutationMapping
    public Post createPost(@Argument CreatePostInput input,
                           @AuthenticationPrincipal UserDetails user) {
        return postService.create(input, user.getUsername());
    }

    @MutationMapping
    public Post updatePost(@Argument Long id, 
                           @Argument UpdatePostInput input) {
        return postService.update(id, input);
    }

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

Author 필드 리졸버

@Controller
@RequiredArgsConstructor
public class AuthorController {

    private final AuthorService authorService;
    private final PostRepository postRepository;

    @QueryMapping
    public Optional<Author> author(@Argument Long id) {
        return authorService.findById(id);
    }

    // Post.author 필드를 리졸빙
    @SchemaMapping(typeName = "Post", field = "author")
    public Author authorOfPost(Post post) {
        return authorService.findById(post.getAuthorId())
            .orElseThrow();
    }

    // Author.postCount 커스텀 필드
    @SchemaMapping(typeName = "Author", field = "postCount")
    public int postCount(Author author) {
        return postRepository.countByAuthorId(author.getId());
    }
}

4. N+1 문제 해결 - @BatchMapping

GraphQL에서 가장 흔한 성능 문제는 N+1 쿼리입니다. 게시글 10건을 조회하면서 각각의 author를 개별 쿼리로 가져오면 1(게시글) + 10(작성자) = 11번의 쿼리가 발생합니다.

문제 상황 (@SchemaMapping)

// 이렇게 하면 N+1 발생!
@SchemaMapping(typeName = "Post", field = "author")
public Author authorOfPost(Post post) {
    // 게시글마다 개별 쿼리 실행
    return authorService.findById(post.getAuthorId()).orElseThrow();
}
// 게시글 10건 → author 쿼리 10번 추가 발생

해결 방법 (@BatchMapping)

@Controller
@RequiredArgsConstructor
public class PostBatchController {

    private final AuthorService authorService;
    private final CommentRepository commentRepository;
    private final TagRepository tagRepository;

    // N+1 해결: 한 번의 쿼리로 모든 author를 가져옴
    @BatchMapping(typeName = "Post", field = "author")
    public Map<Post, Author> authorsOfPosts(List<Post> posts) {
        List<Long> authorIds = posts.stream()
            .map(Post::getAuthorId)
            .distinct()
            .toList();

        // 1번의 IN 쿼리로 모든 author 조회
        Map<Long, Author> authorMap = authorService.findByIds(authorIds)
            .stream()
            .collect(Collectors.toMap(Author::getId, Function.identity()));

        return posts.stream()
            .collect(Collectors.toMap(
                Function.identity(),
                post -> authorMap.get(post.getAuthorId())
            ));
    }

    // comments도 배치로 처리
    @BatchMapping(typeName = "Post", field = "comments")
    public Map<Post, List<Comment>> commentsOfPosts(List<Post> posts) {
        List<Long> postIds = posts.stream()
            .map(Post::getId).toList();

        // 1번의 쿼리로 모든 댓글 조회
        List<Comment> allComments = commentRepository
            .findByPostIdIn(postIds);

        Map<Long, List<Comment>> commentMap = allComments.stream()
            .collect(Collectors.groupingBy(Comment::getPostId));

        return posts.stream()
            .collect(Collectors.toMap(
                Function.identity(),
                post -> commentMap.getOrDefault(
                    post.getId(), Collections.emptyList())
            ));
    }

    // tags도 배치로 처리
    @BatchMapping(typeName = "Post", field = "tags")
    public Map<Post, List<Tag>> tagsOfPosts(List<Post> posts) {
        List<Long> postIds = posts.stream()
            .map(Post::getId).toList();

        Map<Long, List<Tag>> tagMap = tagRepository
            .findTagsByPostIds(postIds);

        return posts.stream()
            .collect(Collectors.toMap(
                Function.identity(),
                post -> tagMap.getOrDefault(
                    post.getId(), Collections.emptyList())
            ));
    }
}

N+1 해결 전후 비교

방식 게시글 10건 + author + comments 조회 시
@SchemaMapping (개별) 1(posts) + 10(author) + 10(comments) = 21 쿼리
@BatchMapping (배치) 1(posts) + 1(authors IN) + 1(comments IN) = 3 쿼리

5. Subscription - 실시간 통신

GraphQL의 Subscription은 서버에서 클라이언트로 실시간 데이터를 푸시하는 기능입니다. WebSocket 위에서 동작합니다.

WebSocket 설정

// build.gradle에 추가
implementation 'org.springframework.boot:spring-boot-starter-websocket'

Subscription Controller

@Controller
@RequiredArgsConstructor
public class PostSubscriptionController {

    private final Sinks.Many<Post> postSink = 
        Sinks.many().multicast().onBackpressureBuffer();

    private final Sinks.Many<Comment> commentSink = 
        Sinks.many().multicast().onBackpressureBuffer();

    // 새 게시글 생성 이벤트 구독
    @SubscriptionMapping
    public Flux<Post> postCreated() {
        return postSink.asFlux();
    }

    // 특정 게시글의 새 댓글 이벤트 구독
    @SubscriptionMapping
    public Flux<Comment> commentAdded(@Argument Long postId) {
        return commentSink.asFlux()
            .filter(comment -> comment.getPostId().equals(postId));
    }

    // 다른 서비스에서 이벤트 발행
    public void publishPostCreated(Post post) {
        postSink.tryEmitNext(post);
    }

    public void publishCommentAdded(Comment comment) {
        commentSink.tryEmitNext(comment);
    }
}

// PostService에서 게시글 생성 시 이벤트 발행
@Service
@RequiredArgsConstructor
public class PostService {

    private final PostRepository postRepository;
    private final PostSubscriptionController subscriptionController;

    @Transactional
    public Post create(CreatePostInput input, String username) {
        Post post = Post.builder()
            .title(input.getTitle())
            .content(input.getContent())
            .authorId(getAuthorId(username))
            .status(PostStatus.DRAFT)
            .build();

        Post saved = postRepository.save(post);

        // 실시간 알림 발행
        subscriptionController.publishPostCreated(saved);

        return saved;
    }
}

클라이언트 구독 예시

// JavaScript (Apollo Client)
const POST_CREATED = gql`
  subscription OnPostCreated {
    postCreated {
      id
      title
      author {
        name
      }
      createdAt
    }
  }
`;

// React 컴포넌트에서
const { data, loading } = useSubscription(POST_CREATED);

6. 에러 핸들링

GraphQL은 항상 HTTP 200을 반환하고, 에러는 응답 body의 errors 필드에 담깁니다. Spring for GraphQL에서는 예외를 체계적으로 처리할 수 있습니다.

// 커스텀 예외 정의
public class PostNotFoundException extends RuntimeException {
    private final Long postId;

    public PostNotFoundException(Long postId) {
        super("게시글을 찾을 수 없습니다: " + postId);
        this.postId = postId;
    }
}

// GraphQL 예외 리졸버
@ControllerAdvice
public class GraphQLExceptionHandler {

    @GraphQlExceptionHandler
    public GraphQLError handlePostNotFound(
            PostNotFoundException ex, DataFetchingEnvironment env) {
        return GraphQLError.newError()
            .errorType(ErrorType.NOT_FOUND)
            .message(ex.getMessage())
            .path(env.getExecutionStepInfo().getPath())
            .extensions(Map.of(
                "code", "POST_NOT_FOUND",
                "postId", ex.getPostId()
            ))
            .build();
    }

    @GraphQlExceptionHandler
    public GraphQLError handleValidation(
            ConstraintViolationException ex, DataFetchingEnvironment env) {
        String message = ex.getConstraintViolations().stream()
            .map(v -> v.getPropertyPath() + ": " + v.getMessage())
            .collect(Collectors.joining(", "));

        return GraphQLError.newError()
            .errorType(ErrorType.BAD_REQUEST)
            .message("입력값 검증 실패: " + message)
            .path(env.getExecutionStepInfo().getPath())
            .extensions(Map.of("code", "VALIDATION_ERROR"))
            .build();
    }

    @GraphQlExceptionHandler
    public GraphQLError handleAccessDenied(
            AccessDeniedException ex, DataFetchingEnvironment env) {
        return GraphQLError.newError()
            .errorType(ErrorType.FORBIDDEN)
            .message("접근 권한이 없습니다.")
            .path(env.getExecutionStepInfo().getPath())
            .extensions(Map.of("code", "ACCESS_DENIED"))
            .build();
    }
}

에러 응답 예시

{
  "data": {
    "post": null
  },
  "errors": [
    {
      "message": "게시글을 찾을 수 없습니다: 999",
      "path": ["post"],
      "extensions": {
        "classification": "NOT_FOUND",
        "code": "POST_NOT_FOUND",
        "postId": 999
      }
    }
  ]
}

7. 보안 - 쿼리 복잡도 제한

GraphQL은 클라이언트가 쿼리를 자유롭게 작성할 수 있기 때문에, 악의적으로 깊은 중첩 쿼리를 보내서 서버를 다운시킬 수 있습니다.

// 위험한 쿼리 예시 (중첩 공격)
// query {
//   post(id: 1) {
//     author {
//       posts {
//         author {
//           posts {
//             author { ... 무한 반복 }
//           }
//         }
//       }
//     }
//   }
// }

// 해결 1: 최대 쿼리 깊이 제한
@Configuration
public class GraphQLSecurityConfig {

    @Bean
    public RuntimeWiringConfigurer runtimeWiringConfigurer() {
        return wiringBuilder -> wiringBuilder
            .directiveWiring(new SchemaDirectiveWiring() {});
    }

    @Bean
    public WebGraphQlInterceptor queryComplexityInterceptor() {
        return (request, chain) -> {
            // 쿼리 깊이 체크
            String query = request.getDocument().toString();
            int depth = calculateQueryDepth(query);
            if (depth > 5) {
                throw new RuntimeException(
                    "쿼리 깊이 초과: 최대 5단계까지 허용");
            }
            return chain.next(request);
        };
    }
}

// 해결 2: application.yml에서 제한
// spring:
//   graphql:
//     schema:
//       introspection:
//         enabled: false  # 프로덕션에서 스키마 조회 비활성화

8. 테스트 코드

@GraphQlTest(PostController.class)
class PostControllerTest {

    @Autowired
    private GraphQlTester graphQlTester;

    @MockBean
    private PostService postService;

    @Test
    void 게시글_목록을_조회한다() {
        // given
        Post post = Post.builder()
            .id(1L).title("테스트").content("내용")
            .status(PostStatus.PUBLISHED).build();
        given(postService.findAll(any()))
            .willReturn(new PageImpl<>(List.of(post)));

        // when & then
        graphQlTester.documentName("posts")  // src/test/resources/graphql-test/posts.graphql
            .variable("page", 0)
            .variable("size", 10)
            .execute()
            .path("posts.content[0].title")
            .entity(String.class)
            .isEqualTo("테스트");
    }

    @Test
    void 게시글을_생성한다() {
        // given
        Post created = Post.builder()
            .id(1L).title("새 글").content("내용").build();
        given(postService.create(any(), any())).willReturn(created);

        // when & then
        graphQlTester.documentName("createPost")
            .variable("input", Map.of(
                "title", "새 글",
                "content", "내용"
            ))
            .execute()
            .path("createPost.id")
            .entity(String.class)
            .isEqualTo("1");
    }

    @Test
    void 존재하지_않는_게시글을_조회하면_에러를_반환한다() {
        // given
        given(postService.findById(999L))
            .willThrow(new PostNotFoundException(999L));

        // when & then
        graphQlTester.documentName("post")
            .variable("id", 999)
            .execute()
            .errors()
            .satisfy(errors -> {
                assertThat(errors).hasSize(1);
                assertThat(errors.get(0).getMessage())
                    .contains("찾을 수 없습니다");
            });
    }
}

9. REST vs GraphQL - 선택 가이드

상황 추천 이유
클라이언트가 다양 (웹, 앱, 제3자) GraphQL 각 클라이언트가 필요한 데이터만 선택
간단한 CRUD API REST 오버엔지니어링 방지
마이크로서비스 간 통신 REST (또는 gRPC) 서비스 간에는 고정된 계약이 효율적
복잡한 연관 관계 데이터 GraphQL 중첩 조회를 한 번의 요청으로 해결
실시간 알림 필요 GraphQL Subscription WebSocket 기반 실시간 이벤트
파일 업/다운로드 REST Multipart 처리가 훨씬 간편
HTTP 캐싱이 중요 REST GET + URL 기반 캐싱 자연스러움
빠른 프로토타이핑 GraphQL 프론트엔드와 독립적 개발 가능

하이브리드 접근법

실무에서는 REST와 GraphQL을 함께 사용하는 것이 가장 현실적입니다.

// REST: 파일 업로드, 웹훅, 단순 CRUD
@RestController
@RequestMapping("/api/files")
public class FileController {
    @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    public FileResponse upload(@RequestPart MultipartFile file) { ... }
}

// GraphQL: 복잡한 데이터 조회, 실시간
@Controller
public class DashboardController {
    @QueryMapping
    public Dashboard dashboard() {
        // 여러 도메인의 데이터를 한 번에 조합
        // posts + comments + analytics + userInfo
    }
}

마치며

GraphQL은 REST의 한계를 해결하는 강력한 도구이지만, 모든 상황에서 REST를 대체하는 은탄환은 아닙니다. 이 글의 핵심을 정리하면 다음과 같습니다.

  • Spring for GraphQL은 @QueryMapping, @MutationMapping, @SubscriptionMapping으로 직관적인 개발을 지원합니다.
  • N+1 문제는 GraphQL에서 가장 흔한 성능 이슈이며, @BatchMapping으로 해결할 수 있습니다.
  • Subscription을 활용하면 WebSocket 기반의 실시간 데이터 푸시가 가능합니다.
  • 에러 핸들링은 @GraphQlExceptionHandler로 체계적으로 처리하고, extensions에 에러 코드를 포함하세요.
  • 보안을 위해 쿼리 깊이 제한, 복잡도 분석, 프로덕션 스키마 조회 비활성화를 반드시 적용하세요.
  • REST와 GraphQL을 함께 사용하는 하이브리드 접근법이 실무에서 가장 현실적입니다.

GraphQL 도입을 고민하고 계시다면, 가장 복잡한 조회 API 하나부터 시작해보세요. 그 효과를 체감하면 자연스럽게 적용 범위가 넓어질 것입니다.