← Blog
Computer Science / 웹 / 서버와 API

GraphQL — 응답 모양의 통제권을 클라이언트에게 넘긴다는 것

앞의 두 글을 한 줄씩 요약하면 이렇게 된다.

REST는 공용 규약(HTTP)에 기댄다. gRPC는 전용 규약(스키마)을 직접 만든다.

방식은 정반대인데 공통점이 하나 있다. 둘 다 서버가 응답 모양을 정한다. GET /posts/42가 무엇을 돌려줄지는 서버가 정해뒀고, rpc GetUser (GetUserRequest) returns (User)가 무엇을 돌려줄지도 서버가 정해뒀다. 클라이언트는 그 안에서 고를 수는 있어도, 없는 것을 끌어내거나 필요 없는 것을 빼달라고 할 수는 없다.

GraphQL은 세 번째 꼭짓점이다.

GraphQL은 규약의 통제권을 클라이언트에게 넘긴다.

이 한 문장이 이 글의 전부다. 통제권이 옮겨가면 무엇이 해결되고, 무엇을 대가로 요구하는가. 그리고 그 대가를 치를 만한 상황은 어떤 상황인가.

순서는 이렇다 — 무엇을 해결하려 한 것인지 보고(정의) → 통제권을 넘긴 대가가 서버 쪽에서 어떻게 나타나는지 따지고(이유) → 그 대가를 치를 만한지 판단한다.

이 글은 REST API — 제약 조건과 RESTful 설계의 의미gRPC를 전제로 시작한다. 특히 REST 글의 "Level 0이 잃는 것" 목록은 2-3에서 거의 그대로 다시 나온다.


1. 정의 — 두 가지 낭비와 그 해결

1-1. Over-fetching — 받아서 버린다

사용자 목록 화면을 만든다고 하자. 화면에 필요한 것은 이름과 아바타 이미지뿐이다. 그런데 GET /users가 돌려주는 것은 이렇다.

[
  {
    "id": 7,
    "name": "홍길동",
    "avatarUrl": "https://cdn.example.com/a/7.png",
    "email": "user7@example.com",
    "phone": "010-0000-0000",
    "address": { "city": "서울", "street": "...", "zip": "06000" },
    "createdAt": "2024-03-01T09:00:00Z",
    "lastLoginAt": "2026-08-02T21:13:00Z",
    "preferences": { "theme": "dark", "locale": "ko", "notifications": { } }
  }
]

쓰는 것은 두 필드고 나머지는 받아서 버린다. 목록이 100건이면 버리는 양도 100배가 된다. 서버는 그 필드들을 채우려고 조인을 하고 직렬화를 하고, 네트워크는 그것을 실어 나르고, 클라이언트는 파싱해서 메모리에 올린 다음 두 개만 쓴다.

물론 해결책은 있다. GET /users?fields=name,avatarUrl 같은 쿼리 파라미터를 만들거나, 화면 전용 엔드포인트 GET /users/summary를 하나 더 판다. 그런데 이 해결책이 문제의 성격을 오히려 잘 보여준다. 화면이 늘어날 때마다 엔드포인트가 늘거나 파라미터 문법이 복잡해진다. 결국 각 화면이 무엇을 필요로 하는지를 서버가 하나하나 알고 있어야 한다.

1-2. Under-fetching — 한 번으로는 부족하다

반대 방향의 낭비도 있다. 게시글 상세 화면에는 글 본문 · 작성자 정보 · 댓글 목록 · 각 댓글 작성자가 필요하다.

GET /posts/42                    # 누적 1 RTT — 이제야 authorId=7 을 알게 된다
GET /users/7                     # 누적 2 RTT — 작성자 정보
GET /posts/42/comments           # 누적 3 RTT — 이제야 댓글 작성자 id 들을 알게 된다
GET /users/12, GET /users/9      # 누적 4 RTT — 댓글 작성자들

여기서 핵심은 요청이 네 번이라는 것 자체가 아니다.

뒤의 요청은 앞의 응답을 받아야 무엇을 요청할지 알 수 있다. 그래서 병렬로 보낼 수 없고 순차적이다.

/posts/42의 응답에 authorId: 7이 들어 있어야 비로소 /users/7을 부를 수 있다. 네 번의 왕복이 겹쳐지지 않고 그대로 더해진다. 모바일 네트워크에서 왕복 하나가 100ms라면 데이터가 다 모이는 데 400ms가 걸리고, 그동안 화면은 비어 있다.

1-3. 두 문제는 같은 원인의 양면이다

Over-fetching과 Under-fetching은 방향이 반대라서 별개의 문제처럼 보이지만, 원인은 하나다.

서버가 응답 모양을 미리 정해뒀기 때문에, 화면마다 다른 요구에 맞출 수 없다.

응답을 넉넉하게 정해두면 안 쓰는 화면에서 Over-fetching이 되고, 얇게 정해두면 더 필요한 화면에서 Under-fetching이 된다. 어느 쪽으로 정하든 어떤 화면에서는 어긋난다. 하나의 응답 모양으로 모든 화면을 만족시킬 방법이 없기 때문이다.

그래서 REST에서 이 문제를 다루는 방식은 대체로 화면에 맞춘 엔드포인트를 계속 추가하는 것이 된다. /posts/42/detail, /posts/42/detail-for-mobile 같은 것들이다. 동작은 하지만, 화면 수만큼 엔드포인트가 늘고 그 각각을 서버가 관리해야 한다.

1-4. GraphQL의 답 — 필요한 것을 그려서 보낸다

GraphQL은 원인 쪽을 건드린다. 응답 모양을 정하는 주체를 바꾼다.

엔드포인트는 POST /graphql 하나이고, 클라이언트는 필요한 것을 그려서 보낸다.

query PostDetail {
  post(id: 42) {
    title
    body
    author { name avatarUrl }
    comments(first: 20) {
      body
      author { name }
    }
  }
}

응답은 이렇게 온다.

{
  "data": {
    "post": {
      "title": "...",
      "body": "...",
      "author": { "name": "...", "avatarUrl": "..." },
      "comments": [
        { "body": "...", "author": { "name": "..." } }
      ]
    }
  }
}

세 가지가 한꺼번에 달라졌다.

  • 요청과 응답의 모양이 같다. 쿼리에 그린 구조가 그대로 돌아온다
  • 요청한 필드만 온다. emailpreferences도 오지 않는다 — Over-fetching이 사라진다
  • 중첩 관계를 한 번에 가져온다. 왕복이 4회에서 1회가 된다 — Under-fetching이 사라진다
<!-- IMAGE: 위 왼쪽은 응답 필드 중 실제로 쓰는 것(name, avatarUrl)과 버리는 것(email, phone, address, preferences…)을 색으로 구분한 Over-fetching. 위 오른쪽은 GET /posts/42 → /users/7 → /posts/42/comments → /users/12 순차 요청 네 단계와 누적 RTT. 아래는 GraphQL 쿼리와 응답을 나란히 놓아 모양이 같다는 것과 왕복이 1회라는 것 -->

Over-fetching과 Under-fetching, 그리고 GraphQL

여기서 이름의 뜻도 드러난다. 데이터를 그래프로 보고, 클라이언트가 그 그래프를 어디까지 따라갈지 직접 지정한다. post → author, post → comments → author처럼 관계를 타고 들어가는 것이 쿼리의 문법 그 자체다.

조회 외의 것도 같은 자리에서 이루어진다. 데이터를 바꾸는 것은 mutation, 서버가 변경을 밀어주는 것은 subscription이라는 이름을 갖는다.

subscription은 결국 "서버가 먼저 말을 거는" 문제라서, 그 이야기는 폴링 vs 롱폴링 vs SSE vs WebSocket 쪽에 속한다. 이 글에서는 이름만 짚고 넘어간다.

1-5. 오해 정정 ① — "GraphQL은 REST보다 빠르다"

여기까지만 보면 GraphQL이 명백히 나아 보이고, 실제로 "GraphQL이 더 빠르다"는 말이 자주 나온다. 그런데 무엇이 빨라진 것인지 정확히 구분해야 한다.

  • 줄어든 것: 네트워크 왕복 횟수전송량
  • 줄지 않은 것: 서버가 데이터를 모으는 일

post → author → comments → 각 댓글의 author를 한 번에 달라고 했다고 해서, 서버가 DB를 덜 때리는 것이 아니다. 오히려 더 때린다. REST에서는 클라이언트가 네 번 나눠 요청하며 각각을 별도로 처리했지만, GraphQL에서는 그 일을 요청 하나 안에서 전부 해야 한다.

왕복이 클라이언트에서 사라진 것이 아니라, 서버 안쪽으로 옮겨간 것이다.

그리고 옮겨간 자리에서 새로운 문제가 생긴다. 그게 2장 전체의 내용이다.


2. 이유 — 통제권을 넘기면 서버 쪽에서 무슨 일이 생기는가

1장의 질문을 뒤집어보자.

클라이언트가 응답 모양을 정한다는 것은, 서버 입장에서는 무엇을 의미하는가?

답은 이것이다. 서버는 어떤 요청이 올지 미리 알 수 없다. REST에서 GET /posts/42를 구현할 때는 그 함수가 무슨 쿼리를 몇 번 날릴지 코드를 보면 알 수 있었다. GraphQL에서는 알 수 없다. 클라이언트가 어떤 필드를 어떤 깊이로 조합해 보낼지가 런타임에 결정되기 때문이다.

이 하나의 사실에서 이번 장의 세 가지가 전부 파생된다.

2-1. 리졸버는 자기가 몇 번 불릴지 모른다 — N+1

GraphQL 서버는 **필드마다 그 값을 채우는 함수(리졸버)**를 갖는다. Post.author를 채우는 함수, Comment.author를 채우는 함수가 각각 따로 있다.

const resolvers = {
  Query: {
    post: (_, { id }) => db.post.findById(id),          // 쿼리 1회
  },
  Post: {
    comments: (post) => db.comment.findByPostId(post.id), // 쿼리 1회
  },
  Comment: {
    // 이 함수는 "댓글 하나"에 대해 호출된다
    author: (comment) => db.user.findById(comment.authorId),
  },
};

Comment.author를 보자. 코드만 보면 아무 문제가 없다. 댓글 하나를 받아서 작성자 하나를 조회한다.

그런데 이 함수는 댓글 개수만큼 호출된다.

댓글 10개   → 1(post) + 1(comments) + 10 = 12회
댓글 100개  → 1 + 1 + 100                = 102회
댓글 1000개 → 1 + 1 + 1000               = 1002회

코드는 그대로인데 쿼리 수가 데이터 양에 비례한다. 이것이 N+1이다.

해결은 요청을 모아서 한 번에 보내는 것이다. 흔히 DataLoader라 부르는 패턴이 그 일을 한다. 같은 이벤트 루프 틱 안에서 들어온 개별 조회 요청을 모아뒀다가, 한 번의 IN 절로 묶어 처리한다.

// 요청 하나당 하나씩 만든다. 전역으로 두면 사용자 간에 캐시가 섞인다
const userLoader = new DataLoader(async (ids: number[]) => {
  // ids = [12, 9, 12, 7, 9, ...] 처럼 중복 포함해 모여서 들어온다
  const users = await db.user.findByIds([...new Set(ids)]); // 쿼리 1회
  const byId = new Map(users.map((u) => [u.id, u]));
  return ids.map((id) => byId.get(id) ?? null);            // 요청 순서대로 돌려준다
});

const resolvers = {
  Comment: {
    // 호출 횟수는 그대로 100번. 하지만 DB 쿼리는 1번이다
    author: (comment) => userLoader.load(comment.authorId),
  },
};
댓글 100개 (적용 전) → 1 + 1 + 100 = 102회
댓글 100개 (적용 후) → 1 + 1 +   1 =   3회

리졸버가 100번 불리는 것은 그대로다. 달라진 것은 그 100번이 실제 DB 접근 1번으로 접히는 것뿐이다.

오해 정정 ② — N+1은 GraphQL 고유의 문제가 아니다

N+1은 GraphQL이 만들어낸 문제가 아니다. ORM으로 목록을 조회하고 각 항목의 연관 엔티티에 접근하면 똑같이 발생한다. 이 문제는 훨씬 오래됐고, 슬로우 쿼리를 추적하다 만나는 가장 흔한 원인 중 하나다.

같은 문제를 다른 맥락에서 다루는 글들 — 슬로우 쿼리 대처, Spring 핵심의 영속성 컨텍스트 부분.

그러면 GraphQL에서 이 문제가 유독 크게 보이는 이유는 무엇인가. 두 가지다.

  • REST에서는 엔드포인트별로 한 번만 고치면 됐다. GET /posts/42/comments가 느리면 그 함수 안의 쿼리를 조인으로 바꾸면 끝이다. GraphQL에서는 어떤 조합으로 요청이 올지 모르므로, 모든 관계 필드마다 미리 대비해둬야 한다
  • 빠뜨려도 티가 안 난다. 리졸버 코드는 여전히 한 줄이고, 개발 환경의 작은 데이터에서는 빠르게 동작한다. 운영에서 댓글이 많은 게시글이 들어와야 드러난다

REST에서 기본값이던 최적화가, 여기서는 매번 명시적으로 챙겨야 하는 일이 된다.

2-2. 요청 수와 비용의 비례가 깨진다

N+1보다 근본적인 문제가 있다. 먼저 REST 쪽을 보자.

REST에서는 왜 이런 걱정이 없었는가

GET /posts/42          # 요청 1
GET /users/7           # 요청 2
GET /users/7/posts     # 요청 3

GET /posts/42가 무엇을 돌려줄지는 서버가 정해뒀고, 클라이언트는 그 응답에 없는 것을 끌어낼 수 없다. 더 필요하면 다른 요청을 또 보내야 한다.

요청 하나당 비용이 대체로 일정하고, 깊이 파려면 요청 수가 늘어난다. 그리고 요청 수는 셀 수 있다.

그래서 "분당 100회"같은 rate limit이 실제 보호 장치로 동작했다. 요청 수를 막으면 비용이 막힌다.

GraphQL에서는 이 관계가 깨진다

query {
  posts {                    # 100건
    comments {               # 각 100건 → 10,000
      author {
        posts {              # 각 50건 → 500,000
          comments { body }  # ...
        }
      }
    }
  }
}

요청은 여전히 1회다. 그런데 그 안에서 서버가 하는 일은 중첩 한 겹마다 곱해진다. 100 → 10,000 → 500,000. 요청 수 제한은 이것을 전혀 막지 못한다.

그리고 이게 특수한 스키마에서만 가능한 일이 아니다. 위 쿼리가 성립하려면 Post → Comment → User → Post처럼 관계가 순환해야 하는데,

실무 스키마에는 순환 관계가 거의 항상 있다.

글에 작성자가 있고 작성자에게 글 목록이 있으면 그것으로 이미 순환이다. 순환이 있으면 중첩을 원하는 만큼 이어붙일 수 있고, 그러면 쿼리 하나의 비용에 상한이 없다.

그래서 방어 장치를 직접 마련해야 한다

REST에서는 인프라가 알아서 해주던 일이라 신경 쓸 필요가 없었다. GraphQL에서는 애플리케이션이 직접 만든다.

① 깊이 제한 — 중첩 깊이에 상한을 둔다. 가장 단순하고 가장 먼저 넣게 되는 장치다. 다만 깊이가 얕아도 비쌀 수 있어서(리스트 하나에 10만 건) 이것만으로는 부족하다.

② 복잡도 점수 — 필드마다 비용을 매기고 총합에 상한을 둔다. 리스트 필드는 기본 비용 × 예상 개수로 계산한다. 깊이보다 정확하지만, 비용 값을 스키마 전체에 매기고 유지해야 한다.

③ 페이지네이션 강제 — 리스트 필드에 개수 인자를 필수로 만들고 최대치를 정한다. 이것이 없으면 posts { ... } 한 줄로 테이블 전체를 긁어갈 수 있다.

type Query {
  # first 가 필수다. 빼면 스키마 검증 단계에서 거절된다
  posts(first: Int!, after: String): PostConnection!
}

type Post {
  id: ID!
  title: String!
  author: User! @cost(complexity: 1)

  # 리스트는 개수에 비례해 비용을 매긴다 — 5 × first
  comments(first: Int! = 20): [Comment!]! @cost(complexity: 5, multipliers: ["first"])
}

type User {
  id: ID!
  name: String!
  # 여기가 순환이 시작되는 자리다. 그래서 여기에도 상한이 필요하다
  posts(first: Int! = 10): [Post!]! @cost(complexity: 5, multipliers: ["first"])
}

④ 등록된 쿼리만 허용하기 — 클라이언트가 보낼 수 있는 쿼리를 미리 등록해두고, 실행할 때는 그 식별자만 보내게 한다. 흔히 Persisted Query라 부른다. 우리 앱만 쓰는 GraphQL이라면 이것이 가장 확실한 방어다. 등록되지 않은 쿼리는 아예 실행되지 않으므로 위의 곱셈 공격이 성립하지 않는다.

다만 여기에 짚어야 할 아이러니가 있다.

이 순간 "클라이언트가 원하는 것을 자유롭게 조합한다"는 GraphQL의 전제를 스스로 포기하는 것이기도 하다.

보낼 수 있는 쿼리가 미리 정해져 있다면, 그것은 결국 화면별 엔드포인트를 다른 형식으로 관리하는 것에 가깝다. 물론 남는 이점이 있다 — 그 목록을 서버가 아니라 클라이언트가 정의하고, 쿼리를 바꾸는 데 서버 배포가 필요 없다. 하지만 "임의 조합의 자유"는 사라진다. 가장 강력한 방어 장치가 이 방식의 핵심 전제를 깎아내는 것이 실무에서 계속 마주치게 되는 긴장이다.

⑤ 타임아웃과 쿼리 로깅 — 어떤 상한을 걸어도 빠져나가는 쿼리는 생긴다. 실행 시간 상한을 두고, 어떤 쿼리가 얼마나 걸렸는지를 남긴다.

오해 정정 ③ — "엔드포인트가 하나라 관리가 쉽다"

여기서 흔한 기대 하나를 정리할 수 있다. 엔드포인트가 하나로 줄었으니 관리할 것이 줄었다는 기대다.

줄어든 것이 아니라 옮겨간 것이다. 관리 대상이 엔드포인트 목록에서 스키마와 리졸버로 바뀌었을 뿐, 화면이 늘면 스키마의 타입과 필드가 늘고 리졸버도 는다. 게다가 위에서 본 것처럼 REST에서는 필요 없던 통제 장치(깊이·복잡도·페이지네이션 강제·쿼리 등록)를 직접 만들어 유지해야 한다.

2-3. 전부 POST, 전부 200

이제 앞 글들과 정면으로 부딪히는 지점이다.

HTTP 메서드 글에서 **"메서드는 중간 참여자들에게 하는 약속"**이라고 했다. HTTP 상태 코드 글에서는 **"실패를 200으로 감싸면 공용 채널을 버리는 것"**이라고 했다. 그리고 REST 글에서는 모든 요청을 POST /api 하나로 보내는 Level 0을 길게 비판했다.

GraphQL은 정확히 그 구조다.

POST /graphql HTTP/1.1
Content-Type: application/json

{"query":"query { post(id: 42) { title } }"}

조회든 수정이든 POST /graphql이고, 에러가 나도 대개 200 OKerrors 배열을 담아 답한다. 앞 글들의 기준을 그대로 적용하면 비판받아야 할 설계다.

그런데 왜 그렇게 설계했는가

단순한 실수로 보면 실제 이유를 놓친다. 구조적인 원인은 부분 성공이다.

한 요청 안에 여러 필드가 있고 각각을 다른 리졸버가 처리하므로, 일부만 실패하는 일이 자연스럽게 생긴다. 게시글은 잘 가져왔는데 작성자 조회에서 권한이 없고, 댓글은 정상인 상황이 한 응답 안에 공존한다.

{
  "data": {
    "post": {
      "title": "제약 조건으로서의 REST",
      "author": null,
      "comments": [
        { "body": "잘 읽었습니다", "author": { "name": "..." } }
      ]
    }
  },
  "errors": [
    {
      "message": "Forbidden",
      "path": ["post", "author"],
      "extensions": { "code": "FORBIDDEN" }
    }
  ]
}

여기서 물어보자. 이 응답에 붙일 상태 코드는 무엇인가?

  • 200이라고 하면 실패한 부분이 있었다는 사실이 지워진다
  • 403이라고 하면 성공적으로 돌아온 titlecomments가 실패로 취급된다
  • 500은 더 부정확하다

어느 쪽도 정확하지 않다. 원인은 명확하다.

HTTP는 "응답 하나에 결과 하나"를 전제하는데, GraphQL이 그 전제를 깼다.

전제가 깨진 곳에서 상태 코드 하나를 고르라고 하면 무엇을 고르든 정보를 잃는다. 그래서 GraphQL은 결과 표현을 HTTP 밖으로 꺼내서 본문 안에 자체 체계로 두기로 한 것이다. gRPC가 grpc-status를 트레일러에 따로 둔 것과 같은 종류의 선택이다.

그리고 그 자체 체계는 상태 코드가 표현할 수 없는 것을 표현한다. 위 응답의 path: ["post", "author"]가 그것이다.

어느 필드가 실패했는지를 가리킨다. 상태 코드 하나로는 절대 담을 수 없는 정보다.

오해 정정 ④ — 그러니 "전부 200으로 답하는 건 설계 실수"라고 정리하면 절반만 맞다. 부분 성공이라는 구조적 이유가 있고, 그 상황에서는 HTTP의 표현력이 실제로 부족하다.

그래도 잃는 것은 잃는다

이유가 정당하다고 해서 대가가 사라지지는 않는다. 그리고 그 대가 목록은 REST 글에서 Level 0을 비판할 때 나열한 것과 정확히 같다.

참여자무엇을 못 하게 되는가
CDN·프록시POST는 캐시 대상이 아니다. 모든 조회가 원서버로 간다
모니터링실패해도 HTTP는 200이라 에러율이 0%로 집계된다
재시도 로직4xx·5xx 구분이 없어 재시도해도 되는 실패인지 판단할 수 없다
로드밸런서경로가 /graphql 하나뿐이라 경로 기준으로 나눌 수 없다
브라우저전부 POST라 뒤로 가기·북마크가 성립하지 않는다
접근 제어무슨 요청인지 알려면 쿼리 본문을 파싱해야 한다
<!-- IMAGE: ① N+1과 DataLoader가 100번을 1번으로 만드는 구조 ② REST는 URL에 끝이 있어 요청 수로 막을 수 있지만 GraphQL은 중첩이 곱셈이라 요청 1회로 비용이 무한해지는 대비 ③ 전부 POST·200이라 잃는 중간 참여자 목록과, 그 자리를 애플리케이션 계층이 채우는 목록 -->

통제권을 넘긴 대가

Level 0과 다른 점이 있다면, GraphQL은 그 자리를 비워두지 않는다는 것이다. 잃은 기능들을 애플리케이션 계층에서 다시 만든다. 그 이야기가 3장이다.


3. 판단 — 공짜 인프라를 포기하고 직접 만들 만한가

3-1. 잃는 것 중 상당수는 애초에 값이 작을 수 있다

2-3의 표를 보면 GraphQL을 쓸 이유가 없어 보인다. 그런데 그 표의 항목들은 전부 조건부로 값을 하는 것들이다. REST 글에서 "REST의 이점은 특정 조건에서만 값을 한다"고 했던 것과 같은 이야기다. 하나씩 따져보자.

캐시 — 원래 잘 안 걸리는 경우가 많다

CDN 캐시가 값을 하려면 여러 사용자가 같은 응답을 받아야 한다. 그런데 로그인한 사용자의 피드, 내 주문 목록, 내 알림 같은 개인화된 데이터가 대부분인 앱에서는 애초에 공유 캐시가 거의 걸리지 않는다. 잃는다고 하지만 원래 갖고 있지 않았던 것에 가깝다.

그리고 GraphQL은 그 자리를 클라이언트 캐시로 대체한다. 응답을 통째로 저장하는 것이 아니라 필드 단위로 정규화해서 저장하는 방식이다.

쿼리 A의 응답 →  User:7 { name, avatarUrl }   ─┐
                Post:42 { title, author→User:7 } │  같은 저장소에 정규화되어 들어간다
쿼리 B의 응답 →  User:7 { name, email }        ─┘

User:7이라는 하나의 항목에 필드들이 합쳐져 쌓인다. 그래서 다른 화면에서 다른 쿼리로 같은 User:7을 요청해도, 필요한 필드가 이미 있으면 네트워크를 타지 않는다. 어느 한 화면에서 이름을 수정하면 그 사용자를 보여주던 모든 화면이 함께 갱신된다.

HTTP 캐시는 URL 단위이고, 클라이언트 캐시는 필드 단위다. 층위가 다르고, 오히려 더 세밀하다.

URL 단위 캐시가 어떻게 동작하는지는 브라우저 캐싱에 있다. 다만 두 캐시가 사는 곳이 다르다는 점은 분명히 해야 한다. HTTP 캐시는 중간 어디에나 있을 수 있지만, 클라이언트 캐시는 클라이언트에만 있다. 사용자가 100명이면 서버는 여전히 100번 처리한다.

모니터링 — 다른 축으로 본다

엔드포인트별 집계는 못 하지만, GraphQL 전용 모니터링 도구는 쿼리별·필드별 지표를 준다. 어떤 필드가 가장 자주 요청되는지, 어떤 필드의 리졸버가 가장 느린지, 어떤 필드가 아무도 안 쓰는지가 보인다.

엔드포인트별 집계보다 오히려 정밀할 수 있다. REST에서 GET /posts/42가 느릴 때 그 안의 어느 조회가 느린지는 별도 추적을 붙여야 알 수 있지만, 여기서는 필드 단위가 기본 단위다.

재시도 — 대부분 읽기다

4xx·5xx 구분이 없어 중간 참여자가 재시도를 판단하지 못하는 건 사실이다. 다만 실제 트래픽 대부분은 조회이고, 조회는 여러 번 보내도 안전하다. 판단은 클라이언트 라이브러리 안으로 들어간다.

3-2. 그래서 실제로 무슨 일이 일어난 것인가

세 항목을 관통하는 문장이 하나 나온다.

인프라 계층이 하던 일을, 애플리케이션 계층이 다시 구현하는 것이다.

캐시도, 모니터링도, 요청 제한도, 접근 제어도 전부 그렇다. HTTP를 규약으로 쓰면 그 일을 CDN·프록시·로드밸런서가 공짜로 해줬는데, 규약의 통제권을 클라이언트에게 넘기는 순간 그들이 읽을 수 있는 정보가 사라지고, 그래서 같은 일을 직접 만들어야 한다.

그러면 판단 기준은 자연스럽게 하나로 정리된다.

공짜 인프라를 포기하고 직접 만들 만한 이유가 있는가.

"GraphQL이 REST보다 좋은가"가 아니다. 저 질문에 답이 있으면 쓰는 것이고, 없으면 안 쓰는 것이다.

3-3. 값이 나는 경우와 나지 않는 경우

값이 난다값이 나지 않는다
클라이언트 수웹·iOS·안드로이드로 여럿하나뿐
화면별 요구화면마다 필요한 데이터가 크게 다르다대체로 비슷하다
화면 수많고 계속 늘어난다. REST 엔드포인트가 따라서 늘어난다몇 개로 고정
데이터 출처여러 백엔드를 하나로 묶어 보여줘야 한다단일 서비스
캐시 효과개인화 데이터라 원래 공유 캐시가 안 걸린다공개 읽기가 많아 CDN이 크게 효과적이다
팀 규모스키마와 통제 장치를 유지할 여력이 있다작은 팀. 그 여력이 곧 기능 개발 시간이다

오른쪽 열이 많다면 REST로 충분하다. 화면 몇 개짜리 서비스에 클라이언트가 하나뿐이라면, GraphQL이 해결해주는 문제보다 새로 만들어야 하는 장치가 더 많다.

왼쪽에서 가장 무거운 항목은 클라이언트가 여럿이고 화면마다 요구가 다를 때다. 이 상황에서 REST를 고수하면 서버가 클라이언트마다 다른 엔드포인트를 만들어주게 되고, 결국 클라이언트의 화면 사정을 서버가 계속 따라다니게 된다. 통제권을 넘긴다는 것은 그 추적을 그만두는 것이기도 하다.

마지막 줄의 "여러 백엔드를 하나로 묶는다"는 항목도 자주 실제 이유가 된다. 서비스가 여러 개로 쪼개져 있을 때 클라이언트가 각각을 따로 부르는 대신, 앞단에서 하나의 그래프로 합쳐 보여주는 방식이다.

3-4. 세 꼭짓점을 다시 놓고 보면

도입부의 삼각형으로 돌아온다.

규약은 무엇인가응답 모양은 누가 정하는가무엇을 전제하는가
REST공용 규약 (HTTP)서버중간 참여자가 있고, 그들이 읽을 수 있어야 한다
gRPC전용 규약 (스키마)서버중간 참여자가 없다. 양 끝단을 모두 통제한다
GraphQL규약을 클라이언트가 조립한다클라이언트클라이언트가 여럿이고 요구가 제각각이다

셋은 우열 관계가 아니다.

무엇을 규약으로 삼고, 그 통제권을 누구에게 줄 것인가의 선택이다.

그리고 실제 시스템에서는 셋 중 하나를 고르는 일이 드물다. 대개 섞여 있다.

브라우저 · 앱 ──REST 또는 GraphQL──▶ [게이트웨이] ──gRPC──▶ [서비스 A] ──gRPC──▶ [서비스 B]
                                          │
                                          └── 중간 참여자가 있는 구간은 여기까지

바깥은 클라이언트가 다양하고 중간 참여자가 있으니 REST나 GraphQL이고, 안쪽은 양 끝단을 전부 통제하니 gRPC다. 경계에서 형식이 바뀐다. 앞 글에서 내린 결론과 같고, 판단 기준도 같다.

이 구간에 요청을 읽어야 하는 제3자가 있는가. 있다면 그들이 읽을 수 있어야 하고, 없다면 읽히지 않아도 된다. GraphQL은 여기에 한 줄을 더한다 — 그리고 응답 모양을 정할 사람이 서버 쪽에 있는가, 클라이언트 쪽에 있는가.

3-5. 정리

이 글을 다섯 줄로 압축하면 이렇다.

  1. Over-fetching과 Under-fetching은 같은 원인의 양면이다. 서버가 응답 모양을 미리 정해뒀기 때문에 화면마다 다른 요구에 맞출 수 없다.
  2. GraphQL은 그 통제권을 클라이언트에게 넘긴다. 왕복이 줄고 전송량이 줄지만, 서버가 하는 일이 줄어든 것은 아니다.
  3. 서버는 어떤 요청이 올지 미리 알 수 없다. N+1도, 요청 수와 비용의 비례가 깨지는 것도 전부 여기서 나온다.
  4. 전부 POST·전부 200에는 부분 성공이라는 구조적 이유가 있다. 그래도 Level 0에서 잃었던 것을 똑같이 잃는다.
  5. 결국 인프라 계층이 하던 일을 애플리케이션 계층이 다시 만드는 것이다. 판단 기준은 그럴 만한 이유가 있는가 하나뿐이다.

처음의 질문에 답하면 이렇게 된다.

GraphQL은 REST의 개선판이 아니라, 규약의 통제권을 옮긴 다른 선택이다. 통제권이 클라이언트로 가면 화면마다 다른 요구를 서버가 따라다니지 않아도 되지만, 그 대가로 중간 참여자들이 아무것도 읽지 못하게 되고 그들이 해주던 일을 전부 직접 만들어야 한다. 그러니 물어야 할 것은 "GraphQL이 더 좋은가"가 아니라 "우리에게 그 통제권 이동이 필요한가, 그리고 잃는 것을 직접 만들 여력이 있는가"다.


관련 글