Spring Boot 개발자가 GraphQL을 만났을 때 — REST vs GraphQL 패러다임 시프트
서론: 왜 이 글을 쓰는가
Java/Spring Boot 기반의 REST API 환경에서 학습하다가,
Node.js + NestJS + GraphQL(Apollo Federation) 기반의 MSA 환경으로 넘어오게 되었다. 가장 먼저 부딪힌 것은 패러다임의 충돌이었다.
Controller가 없는데 요청이 어떻게 처리되는지, URL이 하나뿐인데 라우팅은 어떻게 되는지, Gateway가 URL이 아니라 "타입"을 보고 분기한다는 게 무슨 뜻인지 — 기존의 학습 방향성이 전혀 통하지 않았다.
이 글은 Spring Boot의 REST API에 익숙한 백엔드 개발자가 GraphQL로 전환할 때 겪는 핵심적인 패러다임 차이를, Spring의 용어와 개념에 1:1로 매핑하며 정리한 글이다. 물론, 환경마다 다른 부분이 있을 수 있고, 틀린 부분이 있을 수 있다 ..
1. 라우팅의 주체가 바뀐다 — URL에서 스키마로
REST와 GraphQL의 가장 근본적인 차이는 "누가 라우팅을 결정하는가"다.
REST: URL × HTTP Method = 라우팅 키
Spring에서는 @RestController + @GetMapping("/api/posts/{id}") 조합이 곧 "이 요청은 어떤 로직을 실행할 것인가"를 결정한다. 게시글 목록은 GET /posts, 삭제는 DELETE /posts/123 — URL 자체가 라우팅 키다.
@RestController
@RequestMapping("/api/posts")
public class PostController {
@GetMapping("/{id}") // GET /api/posts/123
public PostResponse getPost(@PathVariable Long id) { ... }
@DeleteMapping("/{id}") // DELETE /api/posts/123
public void deletePost(@PathVariable Long id) { ... }
}
GraphQL: 단일 엔드포인트 + 쿼리 문서 = 라우팅 키
GraphQL에서는 엔드포인트가 딱 하나다: POST /graphql. 클라이언트가 요청 body에 "이 데이터를 이 형태로 줘"라는 쿼리 문서(query document)를 보내고, 서버의 GraphQL 엔진이 그 문서를 파싱해서 어떤 Resolver를 호출할지 결정한다.
# 클라이언트가 보내는 쿼리 문서
query {
post(id: 123) {
title
author { name }
}
}
라우팅의 주체가 "URL 패턴 매칭"에서 "스키마 기반 자동 디스패치"로 이동한 것이다.
비교 요약
| 관점 | REST (Spring) | GraphQL |
|---|---|---|
| 엔드포인트 수 | 기능별로 수십~수백 개 | 딱 하나 (POST /graphql) |
| 라우팅 키 | URL 경로 + HTTP Method | 쿼리 문서 내용 (필드명) |
| 라우팅 주체 | DispatcherServlet (HTTP 레이어) |
GraphQL 엔진 (애플리케이션 레이어) |
| 요청 형태 | GET /posts/123 |
POST /graphql + body에 쿼리 |
| 응답 결정권 | 서버가 응답 형태를 설계 | 클라이언트가 필요한 필드를 선언 |
💡 Architect's Insight: 라우팅 판단이 HTTP 레이어에서 애플리케이션 레이어로 내려왔다는 것은, 인프라(로드밸런서, CDN)에서 URL 기반으로 하던 캐싱/라우팅/모니터링 전략을 재설계해야 한다는 뜻이기도 하다.
2. Overfetching / Underfetching — 데이터 결정권의 이동
REST의 가장 흔한 비효율 두 가지는 Overfetching과 Underfetching이다.
Overfetching
GET /posts/123을 호출하면 서버가 정해놓은 응답 형태 전체가 내려온다. 게시글 제목만 필요한데 작성자 프로필, 댓글 수, 첨부파일 URL까지 다 내려오면 — 그게 Overfetching이다. 네트워크 대역폭과 클라이언트의 파싱 비용이 낭비된다.
Underfetching
반대로, 게시글 + 작성자 정보를 한 화면에 보여줘야 하는데 /posts/123과 /users/456을 따로 호출해야 한다면 — 그게 Underfetching이다. 네트워크 왕복이 2번 필요하고, 클라이언트에서 응답을 조합하는 코드까지 필요하다.
GraphQL의 해결
GraphQL에서는 클라이언트가 필요한 필드만 선언한다:
{
post(id: 123) {
title # 제목만 필요하면 이것만
author { name } # 작성자 이름도 필요하면 함께
}
}
한 번의 요청으로 정확히 필요한 데이터만 받는다. "서버가 응답 형태를 결정"하는 모델에서 "클라이언트가 응답 형태를 결정"하는 모델로 권력 이동이 일어난 것이다.
| 문제 | REST에서의 양상 | GraphQL에서의 해결 |
|---|---|---|
| Overfetching | 필요 없는 필드까지 전부 내려옴 | 클라이언트가 필요한 필드만 선언 |
| Underfetching | 여러 엔드포인트를 따로 호출 (왕복 N번) | 한 쿼리에 관련 데이터를 함께 요청 |
| 응답 결정권 | 서버 | 클라이언트 |
💡 Architect's Insight: 클라이언트에 데이터 결정권을 넘기면 프론트엔드 개발 속도가 빨라지지만, 서버 입장에서는 "어떤 쿼리가 들어올지 예측할 수 없다"는 새로운 문제가 생긴다. 쿼리 복잡도 제한(depth limit, cost analysis)이 REST에는 없는 GraphQL 고유의 운영 과제다.
3. Controller 없이 요청이 처리되는 원리 — Resolver의 동작
Spring Boot에서 GraphQL로 전환했을 때 가장 혼란스러웠던 부분이 이것이다. Controller가 없는데, 요청은 어떻게 처리되는가?
Spring: DispatcherServlet의 라우팅
Spring에서는 DispatcherServlet이 요청을 받아 HandlerMapping에게 "이 URL + Method에 해당하는 Controller 메서드가 뭐야?"라고 묻는다. HandlerMapping은 서버 부팅 시 @GetMapping, @PostMapping 등의 어노테이션을 스캔해서 "URL 패턴 → 메서드" 매핑 테이블을 미리 만들어둔다.
HTTP 요청
→ DispatcherServlet
→ HandlerMapping이 URL + Method로 메서드 결정
→ Controller 메서드 실행
→ HttpMessageConverter가 JSON으로 직렬화
GraphQL: 엔진의 스키마 기반 디스패치
GraphQL 엔진(Apollo Server 등)도 정확히 같은 일을 하는데, 매핑 키가 다를 뿐이다.
서버가 부팅할 때 스키마와 Resolver 객체를 로딩하면서 "타입.필드명 → Resolver 함수" 매핑 테이블을 만든다. 요청이 들어오면 쿼리 문서를 AST(추상 구문 트리)로 파싱하고, 스키마와 대조해서 해당 Resolver를 호출한다.
POST /graphql (body: query document)
→ GraphQL 엔진
→ 쿼리 파싱 → AST 생성
→ 스키마와 대조: "이 필드는 어떤 타입의 어떤 필드인가?"
→ 매핑된 Resolver 함수 호출
→ 클라이언트가 요청한 형태로 직렬화
예를 들어 스키마에 이렇게 선언되어 있고:
type Mutation {
postsUpdate(boardID: ID, input: PostsUpdateInput!): PostsUpdatePayload
}
Resolver 객체에 이렇게 등록되어 있으면:
{
Mutation: {
postsUpdate: async (_, { boardID, input }, { user, elastic }) => { ... }
}
}
GraphQL 엔진이 부팅 시 "Mutation.postsUpdate → 이 함수"라는 매핑을 만들어둔다. 클라이언트가 mutation { postsUpdate(input: {...}) { ... } }를 보내면, 엔진이 AST를 걸어서 매핑 테이블에서 함수를 찾아 호출한다.
Spring과의 1:1 매핑
| 역할 | Spring (REST) | GraphQL |
|---|---|---|
| 요청 수신 | DispatcherServlet |
GraphQL 엔진 (Apollo Server) |
| 매핑 테이블 | URL 패턴 → Controller 메서드 | 타입.필드명 → Resolver 함수 |
| 매핑 시점 | 부팅 시 어노테이션 스캔 | 부팅 시 스키마 + Resolver 로딩 |
| 매핑 키 | URL + HTTP Method | 타입명 + 필드명 |
| 핸들러 | @Controller 클래스의 메서드 |
Resolver 객체의 함수 |
💡 Architect's Insight: Spring의
HandlerMapping과 GraphQL 엔진의 Resolver 매핑은 본질적으로 같은 역할이다. 차이는 매핑 키가 URL에서 "스키마의 타입.필드"로 바뀌었다는 것뿐이다. 이 이해가 있으면 "Controller가 없는데 어떻게 동작하지?"라는 혼란이 사라진다.
4. API Gateway의 진화 — URL 라우팅에서 타입 시스템 통합으로
MSA 환경에서 Gateway의 역할도 근본적으로 달라진다.
REST: URL 패턴 기반 프록시
Spring Cloud Gateway, Kong, Nginx 같은 REST API Gateway는 URL 패턴을 보고 요청을 각 마이크로서비스로 프록시한다. /api/posts/** → Post Service, /api/users/** → User Service. 라우팅 규칙이 단순하고 명확하다.
GraphQL: 스키마 레벨의 통합 (Apollo Federation)
Apollo Gateway는 이것을 GraphQL 스키마 레벨에서 한다. 각 마이크로서비스(Subgraph)가 자기만의 스키마를 선언하고, Gateway가 부팅할 때 이 스키마들을 합쳐서(Compose) 하나의 Supergraph를 만든다.
[클라이언트]
│
│ { post(id: 1) { title, author { name } } }
│
▼
[Apollo Gateway]
│
│ Gateway가 Supergraph를 보고 판단:
│ "title은 게시글 서비스에서, author.name은 회원 서비스에서"
│
├──→ [게시글 서비스(Subgraph A)] → { title }
└──→ [회원 서비스(Subgraph B)] → { author { name } }
│
│ 결과를 조합해서 클라이언트에 응답
▼
[클라이언트가 받는 응답]
{ post: { title: "...", author: { name: "..." } } }
클라이언트는 Gateway 하나만 바라보고, Gateway가 쿼리를 분석해서 "이 필드는 A 서비스에서, 저 필드는 B 서비스에서 가져와야 해"를 판단하고 각 Subgraph에 부분 쿼리를 날린다.
트레이드오프 비교
| 관점 | REST Gateway | Apollo Gateway (Federation) |
|---|---|---|
| 라우팅 기준 | URL 패턴 | GraphQL 스키마 (타입 + 필드) |
| 통합 수준 | HTTP 프록시 (요청 전달만) | 타입 시스템 레벨 통합 (쿼리 분석 + 조합) |
| 스키마 관리 | 각 서비스가 독립 (OpenAPI 등) | Supergraph로 합성 (충돌 가능성) |
| 복잡도 | 낮음 | 높음 |
| SPOF 위험 | 있음 (Gateway 장애 시) | 동일하게 있음 + 스키마 합성 실패 시 부팅 불가 |
| 조직적 이점 | 서비스 간 API 계약 별도 관리 | 각 팀이 독립적으로 스키마를 발전시킬 수 있음 |
💡 Architect's Insight: REST Gateway는 "요청을 어디로 보낼지"만 결정하지만, Apollo Gateway는 "쿼리를 어떻게 쪼개서 어디로 보내고, 결과를 어떻게 합칠지"까지 결정한다. 이것은 더 강력하지만, Gateway의 장애가 더 치명적이라는 뜻이기도 하다. Federation 환경에서는 Gateway 이중화와 스키마 호환성 테스트가 필수다.
결론: 패러다임이 바뀌면 사고 모델도 바뀌어야 한다
REST에서 GraphQL로의 전환은 단순히 "라이브러리를 바꾸는 것"이 아니다.
라우팅, 데이터 계약, Gateway, 캐싱, 모니터링 — 백엔드 아키텍처의 거의 모든 레이어에서 사고 모델이 바뀌어야 한다.
| 개념 | REST 사고 모델 | GraphQL 사고 모델 |
|---|---|---|
| "이 요청은 어디로 가지?" | URL 패턴을 본다 | 쿼리 문서의 필드를 본다 |
| "응답에 뭐가 들어있지?" | 서버가 정한 형태 | 클라이언트가 선언한 형태 |
| "서비스 간 통합은?" | URL 라우팅 | 타입 시스템 합성 |
| "캐싱은?" | URL + HTTP Method 기반 | 쿼리 해시 기반 (APQ) |
| "핸들러는?" | Controller 메서드 | Resolver 함수 |
Spring Boot에서 @GetMapping을 찾아가던 습관을,
이제는 스키마에서 필드명을 찾고 Resolver를 추적하는 습관으로 바꿔야 한다.
이 사고 전환이 이루어지면, GraphQL 코드베이스에서 "Controller가 없는데 어떻게 동작하지?"라는 혼란은 해소될 수 있다.
다음 글에서는 이 패러다임 위에서 레거시(Express) Resolver와 최신(NestJS) Resolver가 어떻게 한 서비스 안에서 공존하는지, 그리고 APQ(Automatic Persisted Queries)가 GET과 POST를 어떻게 나눠 쓰는지를 다룰 예정이다.
'Engine > GraphQL + Apollo Federation' 카테고리의 다른 글
| [GraphQL - Apollo Federation] GraphQL이 라우팅하는 과정 (1) | 2026.03.16 |
|---|