컴퓨터공학 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 에 넣어 깨지는 변경을 막는다.

확인 문제

  1. over-fetching 과 under-fetching 을 예로 설명하고, GraphQL 이 이를 어떻게 줄이는지 말하라.
  2. GraphQL 에서 N+1 문제가 생기는 이유와 대표적인 해법은?
  3. query 와 mutation 의 최상위 필드 실행 순서는 어떻게 다른가.
  4. GraphQL API 에서 HTTP 상태 코드만으로 오류를 모니터링하면 안 되는 이유는?
  5. 공개되지 않은 내부용 GraphQL API 에서 비싼 질의를 막는 방법 두 가지를 들라.

풀이

  1. 목록 화면에 제목만 필요한데 본문까지 받는 것이 over-fetching, 작성자 이름을 얻으려고 추가 요청을 반복하는 것이 under-fetching 이다. GraphQL 은 필요한 필드와 관계를 한 질의에 적어 한 번에 받는다.
  2. 목록의 각 항목마다 하위 필드 리졸버가 따로 실행되기 때문이다. 같은 단계의 키를 모아 한 번에 조회하는 배치 로더(DataLoader)로 푼다.
  3. mutation 최상위 필드는 명세상 순서대로 직렬 실행되고, query 필드는 병렬로 실행될 수 있다.
  4. 부분 실패가 있어도 HTTP 200 과 함께 errors 배열로 오기 때문이다.
  5. 깊이·복잡도 제한, 미리 등록된(persisted) 질의만 허용. 목록 크기 상한도 답이다.

더 읽을거리 (References)