들어가며
"프론트엔드에서 필요한 데이터가 바뀔 때마다 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 하나부터 시작해보세요. 그 효과를 체감하면 자연스럽게 적용 범위가 넓어질 것입니다.
'Architecture' 카테고리의 다른 글
| API Gateway 패턴 심화 - Spring Cloud Gateway와 Rate Limiting 구현 (0) | 2026.04.08 |
|---|---|
| 헥사고날 아키텍처 완벽 가이드 - 포트와 어댑터로 클린 아키텍처 구현 (0) | 2026.04.08 |
| DDD(Domain-Driven Design) 실전 가이드 - 전략적 설계부터 전술적 구현까지 (2) | 2026.04.07 |
| MSA 관측 가능성(Observability) 완벽 가이드 (0) | 2026.03.31 |
| MSA 관측 가능성(Observability) 완벽 가이드 (0) | 2026.03.30 |