[CS300 #209] GraphQL — 클라이언트가 필요한 모양을 직접 묻는 API
컴퓨터공학 300 주제 시리즈의 209번째 글이다. 전체 지도는 여기.
한 줄 요약
GraphQL 은 서버가 타입 시스템(스키마)으로 데이터 그래프를 선언하고, 클라이언트가 필요한 필드만 골라 질의하면 서버가 필드별 리졸버를 실행해 질의와 같은 모양의 JSON 으로 돌려주는 질의 언어이자 실행 규칙이다.
왜 필요한가
REST API 로 “글 목록 화면”을 만든다고 하자. 화면에는 글 제목과 작성자 이름만 필요하다.
GET /posts는 본문까지 다 준다. 필요 없는 데이터까지 받는다(over-fetching).- 작성자 이름은 없어서
GET /users/1,GET /users/2… 를 글마다 또 부른다(under-fetching, 요청 폭포). - 모바일 화면과 웹 화면이 필요한 필드가 달라 엔드포인트가 화면 수만큼 늘어난다.
GraphQL 은 이 문제를 “클라이언트가 원하는 모양을 질의로 적어 보내는” 방식으로 푼다. 대신 서버 쪽에 새로운 문제(N+1 질의, 비용 통제, 캐싱)를 넘겨받는다. 둘 다 알아야 쓸지 말지 판단할 수 있다.
핵심 개념
스키마: 타입으로 그린 그래프
type Query {
posts(first: Int = 10): [Post!]!
post(id: ID!): Post
}
type Mutation {
createPost(title: String!, body: String!): Post!
}
type Post {
id: ID!
title: String!
body: String!
author: User!
}
type User {
id: ID!
name: String!
posts: [Post!]!
}
! 는 null 이 될 수 없다는 뜻이다. 스키마는 계약이자 문서다. GraphQL 명세는 스키마를 질의로 조회하는 인트로스펙션(__schema, __type)을 정의하므로, 도구가 스키마를 읽어 자동 완성·문서·타입 생성을 해 준다.
질의: 응답과 같은 모양
query PostList {
posts(first: 2) {
title
author { name }
}
}
{
"data": {
"posts": [
{ "title": "글1", "author": { "name": "이작가" } },
{ "title": "글2", "author": { "name": "김작가" } }
]
}
}
연산은 세 종류다. 읽기인 query, 쓰기인 mutation, 서버가 이벤트를 밀어 주는 subscription. 명세는 최상위 mutation 필드를 순서대로 하나씩 실행하도록 정해, 여러 쓰기가 섞여도 순서가 보장된다. query 필드는 병렬로 실행해도 된다.
실행: 필드마다 리졸버
서버는 질의를 파싱하고, 스키마에 비추어 검증한 뒤, 선택된 필드마다 리졸버 함수를 호출한다. posts 리졸버가 글 목록을 돌려주면, 각 글에 대해 title 리졸버와 author 리졸버가 호출된다. 이 구조가 유연함의 원천이자 N+1 문제의 원천이다.
posts ──▶ [글1, 글2, 글3, 글4, 글5]
│ │ │ │ │
author author author author author ← 글마다 DB 조회 = 1 + N 번
해법은 같은 실행 단계에서 요청된 키를 모았다가 한 번에 조회하는 배치 로더다. 자바스크립트 생태계의 DataLoader 가 대표적이고, graphql.org 의 성능 안내도 이 방식을 소개한다.
오류와 HTTP
GraphQL 응답은 data 와 errors 를 함께 가질 수 있다. 일부 필드만 실패하면 그 필드는 null 이 되고 이유가 errors 배열에 들어간다. 부분 성공이 정상적인 결과 형태라는 점이 REST 와 다르다. 그래서 HTTP 상태 코드만 보는 모니터링으로는 GraphQL 오류를 놓치기 쉽다. 응답 본문의 errors 를 따로 집계해야 한다.
보통 엔드포인트 하나(/graphql)에 POST 로 질의를 보낸다. URL 이 하나라서 HTTP 캐시와 CDN 을 그대로 쓰기 어렵다. 이를 보완하려고 질의를 미리 등록해 두고 해시나 ID 로 부르는 persisted query 를 쓰기도 한다.
비용 통제
클라이언트가 질의를 자유롭게 짤 수 있으면, 악의든 실수든 비싼 질의도 짤 수 있다.
query Deep {
posts { author { posts { author { posts { author { name } } } } } }
}
그래서 운영 환경에서는 다음을 둔다.
- 질의 깊이·복잡도 제한, 목록 필드의 최대 개수 강제
- 공개 API 가 아니라면 미리 등록된 질의만 허용
- 필드 단위 권한 검사 (리졸버 안에서, 또는 스키마 지시어로)
- 운영 환경의 인트로스펙션 공개 여부 결정
REST 와의 비교
| 항목 | REST | GraphQL |
|---|---|---|
| 엔드포인트 | 자원마다 | 보통 하나 |
| 응답 모양 | 서버가 정함 | 클라이언트가 고름 |
| HTTP 캐시 | 자연스럽게 활용 | 별도 설계 필요 |
| 오류 | 상태 코드 | errors 배열 (부분 성공 가능) |
| 타입·문서 | OpenAPI 등 별도 | 스키마가 곧 문서 |
| 주의점 | 화면별 엔드포인트 증가 | N+1, 비용 통제 |
GraphQL 이 REST 를 대체하는 관계는 아니다. 화면 종류가 많고 데이터 관계가 복잡한 클라이언트 중심 서비스에서 이점이 크고, 단순한 CRUD 나 서버 간 통신에서는 REST 나 gRPC 가 더 간단할 수 있다.
직접 해 보기
라이브러리 없이 GraphQL 식 실행 모델을 흉내 낸다. 선택한 필드만 돌려주고, author 필드를 단순 리졸버와 배치 로더로 각각 풀어 DB 호출 수를 비교한다.
# 아주 작은 GraphQL 식 실행기: 선택한 필드만, 필드마다 리졸버 호출
AUTHORS = {1: "김작가", 2: "이작가"}
POSTS = [{"id": i, "title": f"글{i}", "author_id": 1 + i % 2} for i in range(1, 6)]
db_calls = []
def fetch_authors(ids): # DB 한 번 = 이 함수 한 번
db_calls.append(sorted(ids))
return {i: {"id": i, "name": AUTHORS[i]} for i in ids}
class Loader:
"""같은 단계에서 요청된 키를 모아 한 번에 가져온다 (DataLoader 아이디어)."""
def __init__(self, batch_fn): self.batch_fn, self.queue, self.cache = batch_fn, set(), {}
def load(self, key):
if key not in self.cache: self.queue.add(key)
return lambda: self.cache[key] # 나중에 값을 꺼낼 '약속'
def dispatch(self):
if self.queue: self.cache.update(self.batch_fn(self.queue)); self.queue = set()
def run(selection, batched):
db_calls.clear()
loader = Loader(fetch_authors)
rows, pending = [], []
for p in POSTS: # Query.posts 리졸버
row = {f: p[f] for f in selection["posts"] if f != "author"}
if "author" in selection["posts"]: # Post.author 리졸버
if batched:
pending.append((row, loader.load(p["author_id"])))
else:
row["author"] = fetch_authors({p["author_id"]})[p["author_id"]]
rows.append(row)
loader.dispatch()
for row, get in pending:
a = get(); row["author"] = {f: a[f] for f in selection["posts"]["author"]}
return {"data": {"posts": rows}}
q = {"posts": {"title": None, "author": {"name": None}}} # { posts { title author { name } } }
r = run(q, batched=False); print("단순 리졸버 DB 호출", len(db_calls), "번:", db_calls)
r = run(q, batched=True); print("배치 로더 DB 호출", len(db_calls), "번:", db_calls)
print(r["data"]["posts"][:2])
실행 결과:
단순 리졸버 DB 호출 5 번: [[2], [1], [2], [1], [2]]
배치 로더 DB 호출 1 번: [[1, 2]]
[{'title': '글1', 'author': {'name': '이작가'}}, {'title': '글2', 'author': {'name': '김작가'}}]
글 5개에 작성자 조회가 5번 일어나던 것이 1번으로 줄었다. 중복된 작성자 ID 도 한 번만 조회한다. 응답에는 질의에서 고른 title 과 author.name 만 들어 있고 id 는 빠졌다. 실제 서버(graphql-js, graphql-java, Strawberry 등)도 이 두 가지, 즉 선택 필드만 실행하는 것과 배치 로딩을 같은 원리로 처리한다.
현업에서는
- BFF(Backend for Frontend): 여러 마이크로서비스의 REST·gRPC API 앞에 GraphQL 게이트웨이를 두고, 프론트엔드는 화면에 필요한 모양을 한 번에 가져가는 구성이 흔하다.
- 느린 질의 추적: 엔드포인트가 하나라 “어느 API 가 느린가”를 URL 로 구분할 수 없다. 연산 이름(
query PostList)을 필수로 붙이게 하고, 연산 이름별로 지연과 오류를 집계한다. - N+1 은 로그에서 보인다: 질의 한 번에 같은 형태의 SQL 이 수십 번 찍히면 배치 로더가 빠진 리졸버다. 홈랩에 띄운 작은 GraphQL 서버라도 DB 질의 로그를 켜고 한 번 보면 바로 드러난다.
- 스키마 변경 관리: 필드를 지울 때는
@deprecated로 먼저 표시하고, 사용량이 0 이 된 뒤 제거한다. 스키마 비교 도구를 CI 에 넣어 깨지는 변경을 막는다.
확인 문제
- over-fetching 과 under-fetching 을 예로 설명하고, GraphQL 이 이를 어떻게 줄이는지 말하라.
- GraphQL 에서 N+1 문제가 생기는 이유와 대표적인 해법은?
- query 와 mutation 의 최상위 필드 실행 순서는 어떻게 다른가.
- GraphQL API 에서 HTTP 상태 코드만으로 오류를 모니터링하면 안 되는 이유는?
- 공개되지 않은 내부용 GraphQL API 에서 비싼 질의를 막는 방법 두 가지를 들라.
풀이
- 목록 화면에 제목만 필요한데 본문까지 받는 것이 over-fetching, 작성자 이름을 얻으려고 추가 요청을 반복하는 것이 under-fetching 이다. GraphQL 은 필요한 필드와 관계를 한 질의에 적어 한 번에 받는다.
- 목록의 각 항목마다 하위 필드 리졸버가 따로 실행되기 때문이다. 같은 단계의 키를 모아 한 번에 조회하는 배치 로더(DataLoader)로 푼다.
- mutation 최상위 필드는 명세상 순서대로 직렬 실행되고, query 필드는 병렬로 실행될 수 있다.
- 부분 실패가 있어도 HTTP 200 과 함께
errors배열로 오기 때문이다. - 깊이·복잡도 제한, 미리 등록된(persisted) 질의만 허용. 목록 크기 상한도 답이다.
더 읽을거리 (References)
- GraphQL Foundation, GraphQL Specification (October 2021): https://spec.graphql.org/October2021/
- GraphQL, Learn: https://graphql.org/learn/
- GraphQL, Performance: https://graphql.org/learn/performance/