목차
이 글은 REST를 쓰던 사람이 GraphQL을 만났을 때 무엇이 달라지는지, 그리고 무엇을 내주게 되는지까지만 본다. 스키마를 작성하고 서버를 구현하는 이야기는 다루지 않는다.
화면 하나에 API를 다섯 번 부른다
주문 상세 화면을 만든다. 화면에 필요한 건 이렇다. 주문 정보, 주문한 사람 이름, 상품 이름과 이미지, 배송 상태, 이 상품의 리뷰 평점.
지금까지 만든 REST API로 그리면 이렇게 된다.
호출이 다섯 번이고, 그중 뒤의 넷은 첫 응답을 받아야 무엇을 부를지 안다. 주문을 받아봐야 userId와 productId를 알기 때문이다. 왕복이 순서대로 쌓이니 화면이 늦게 뜬다. 모바일 네트워크라면 더 눈에 띈다.
이걸 **과소 조회(under-fetching)**라고 부른다. 한 번에 받은 게 화면에 필요한 것보다 적어서, 부족한 만큼 더 부르는 상황이다.
반대 방향의 낭비도 있다
같은 화면에서 반대 문제도 같이 일어난다. GET /users/1024가 돌려주는 건 이렇다.
{
"id": "1024",
"name": "김개발",
"email": "dev@example.com",
"phone": "010-0000-0000",
"address": { "zipCode": "00000", "line1": "...", "line2": "..." },
"createdAt": "2025-01-02T09:00:00Z",
"lastLoginAt": "2026-05-26T14:30:00Z",
"marketingAgreed": true
}이 화면에 필요한 건 name 하나다. 나머지는 전부 버려진다. 이게 **과다 조회(over-fetching)**다.
REST에서 이걸 줄이는 방법이 없지는 않다. 화면에 맞춘 엔드포인트를 따로 만들거나(GET /orders/77/detail-view), 원하는 필드를 파라미터로 받거나(?fields=name) 한다. 그런데 둘 다 대가가 있다. 앞의 방식은 화면이 늘 때마다 엔드포인트가 늘고, 화면이 바뀌면 서버를 고쳐야 한다. 뒤의 방식은 결국 필드 선택 문법을 직접 만드는 일이 된다.
GraphQL은 이 두 문제를 같은 방식으로 푼다. 무엇을 받을지를 클라이언트가 적어 보내게 하는 것이다.
클라이언트가 필요한 모양을 적어 보낸다
GraphQL 요청은 이렇게 생겼다.
query {
order(id: "77") {
id
amount
user { name }
product {
name
imageUrl
rating
}
shipment { status }
}
}응답은 이렇다.
{
"data": {
"order": {
"id": "77",
"amount": 12000,
"user": { "name": "김개발" },
"product": { "name": "키보드", "imageUrl": "...", "rating": 4.5 },
"shipment": { "status": "SHIPPED" }
}
}
}두 가지가 눈에 띈다.
응답이 요청을 닮았다. 요청에 적은 필드만, 적은 모양 그대로 돌아온다. user에 name만 적었으니 email도 phone도 안 온다.
한 번에 끝났다. 다섯 번 왕복하던 게 한 번이다. 서버는 여전히 사용자·상품·배송을 각각 가져와야 하지만, 그 왕복이 네트워크 밖이 아니라 서버 안에서 일어난다. 클라이언트와 서버 사이의 왕복만 줄어든다.
그리고 화면이 바뀌어도 서버를 안 고친다. 리뷰 개수가 더 필요해지면 클라이언트가 쿼리에 reviewCount 한 줄을 더한다. 스키마에 이미 있는 필드라면 서버 배포가 필요 없다.
엔드포인트가 하나다
REST와 GraphQL의 표면적인 차이가 여기서 가장 크게 드러난다.
REST에서는 URL이 무엇을 받을지 말한다. /orders/77이라는 주소 자체가 “77번 주문”을 뜻한다. 그래서 앞 글들에서 URL 짓는 규칙을 그렇게 오래 정한 것이다.
GraphQL에서는 URL이 아무것도 말하지 않는다. 대개 /graphql 하나이고, 무엇을 받을지는 본문에 담긴 쿼리가 정한다. 그래서 URL 설계라는 작업 자체가 사라지고, 대신 스키마 설계라는 작업이 생긴다. 어떤 타입이 있고 어떤 필드로 이어지는지를 서버가 미리 정의해두면, 클라이언트는 그 안에서 조합한다.
앞 글에서 JSON에 스키마가 없어서 계약이 코드나 문서로 밀려난다고 했는데, GraphQL은 그 지점을 다르게 잡는다. 스키마가 서버에 있고, 클라이언트가 그걸 조회할 수 있다. 필드 이름과 타입이 기계가 읽을 수 있는 형태로 공개되니, 도구가 자동완성을 해주고 잘못된 필드를 요청하면 미리 걸러진다. 계약이 형식 안으로 들어온 셈이다.
여기까지는 이득이다. 그런데 얻은 만큼 내주는 것들이 있다.
대가 하나: HTTP 캐시를 잃는다
REST가 조용히 누리던 게 하나 있다. URL이 곧 캐시 키였다는 것.
GET /products/55는 언제 불러도 같은 상품이다. 그래서 브라우저도, CDN도, 중간의 프록시도 이 응답을 저장해뒀다가 다음에 그대로 돌려줄 수 있다. URL 하나가 키가 되니 캐시 헤더만 붙이면 그 구조를 그대로 쓸 수 있다(캐시 글이 그 헤더를 다룬다).
GraphQL은 보통 같은 URL에 POST로 요청한다. 캐시 입장에서는 모든 요청이 같은 주소로 오는 데다, POST는 상태를 바꿀 수 있는 메서드라 기본적으로 캐시 대상이 아니다. 중간 계층의 캐시가 통째로 놀게 된다.
우회 방법이 없지는 않다. 쿼리를 서버에 미리 등록해두고 짧은 식별자로 GET 호출하는 방식(persisted query)을 쓰면 URL 기반 캐시를 일부 되찾을 수 있다. 클라이언트 쪽 GraphQL 라이브러리들은 대개 자체 캐시를 갖고 있어서 브라우저 안에서는 재사용이 된다.
요점은 공짜로 얻던 것을 이제 설정해서 얻어야 한다는 것이다. 캐시가 성능의 큰 축인 서비스라면 이 비용을 먼저 계산해야 한다.
대가 둘: N+1이 서버 안으로 들어온다
앞에서 “왕복이 서버 안에서 일어난다”고 했는데, 그 안에서 무슨 일이 벌어지는지를 봐야 한다.
주문 목록을 이렇게 요청한다고 하자.
query {
orders(first: 20) {
id
user { name }
}
}서버는 주문 20건을 가져온 다음, 각 주문의 user를 채워야 한다. 순진하게 구현하면 주문마다 사용자 조회가 한 번씩 나간다. 쿼리 1번 + 20번이다.
이 모양이 낯익다면 맞다. ORM 글의 ORM도 결국 SQL을 만든다 대목에서 본 N+1 문제와 같은 구조다. 거기서는 객체 코드의 반복문 하나가 쿼리 폭탄이 됐고, 여기서는 쿼리의 중첩 한 줄이 같은 일을 한다.
같은 문제가 층만 바꿔 나타난 것이다. 그리고 GraphQL 쪽이 조금 더 까다롭다. ORM의 N+1은 서버 코드를 읽으면 어디서 터지는지 보이지만, GraphQL에서는 어떤 쿼리가 올지 서버가 미리 모른다. 클라이언트가 중첩을 어떻게 짜느냐에 따라 부하가 달라진다.
해법의 방향은 같다. 하나씩 가져오지 말고 모아서 한 번에 가져오는 것이다. GraphQL 진영에서는 이 역할을 하는 도구를 DataLoader라고 부르는데, 같은 처리 주기에 들어온 조회 요청을 모았다가 한 번에 던지는 방식이다. ORM에서 페치 조인이나 배치 사이즈로 푸는 것과 목적이 같다.
GraphQL을 얹으면 쿼리가 줄어든다고 생각하기 쉬운데, 줄어드는 건 클라이언트와 서버 사이의 왕복이지 DB 쿼리가 아니다. 오히려 아무 대비 없이 얹으면 DB 부하가 늘어날 수 있다. 네트워크 왕복을 DB 부하와 맞바꾼 것이 되지 않게 하려면 배칭이 전제다.
대가 셋: 요청 비용을 클라이언트가 정한다
REST에서는 엔드포인트마다 무슨 일을 하는지 서버가 안다. GET /orders/77이 얼마나 무거운지 측정할 수 있고, 느리면 그 엔드포인트를 고치면 된다.
GraphQL에서는 클라이언트가 쿼리를 조립하기 때문에 서버가 예상하지 못한 조합이 올 수 있다.
query {
orders(first: 100) {
user {
orders(first: 100) {
product {
reviews(first: 100) { user { name } }
}
}
}
}
}문법적으로 아무 문제 없는 쿼리인데, 실행하면 서버가 감당하기 어려운 양이 된다. 타입끼리 서로를 참조할 수 있으면 이런 중첩을 계속 깊게 만들 수 있다.
그래서 GraphQL 서버는 대개 제한 장치를 같이 둔다.
| 장치 | 무엇을 막나 |
|---|---|
| 깊이 제한 | 중첩을 몇 단계까지만 허용 |
| 복잡도 제한 | 필드마다 비용을 매기고 합계가 넘으면 거절 |
| 등록된 쿼리만 허용 | 미리 승인한 쿼리 외에는 실행 안 함 |
| 타임아웃 | 오래 걸리는 실행을 끊는다 |
REST에서 size 파라미터에 상한을 뒀던 것과 성격이 같은 방어인데, 막아야 할 표면이 훨씬 넓다. 파라미터 하나가 아니라 쿼리 구조 전체가 입력이기 때문이다.
이 대가는 누가 클라이언트인가에 따라 무게가 달라진다. 우리 팀의 웹·앱만 부르는 내부 API라면 악의적인 쿼리를 크게 걱정하지 않아도 되고, 실수로 무거운 쿼리를 짠 경우는 리뷰에서 걸린다. 반면 외부에 공개하는 API라면 이 방어가 선택이 아니다.
에러도 다르게 말한다
상태 코드와 에러 응답에서 “200 OK인데 본문에 error가 있는 응답”을 문제로 다뤘다. 그런데 GraphQL의 기본 동작이 정확히 그 모양이다.
{
"data": { "order": { "id": "77", "user": null } },
"errors": [
{ "message": "사용자를 찾을 수 없습니다", "path": ["order", "user"] }
]
}HTTP 상태는 성공이고, 실패는 errors 배열에 담긴다. 상태 코드와 에러 응답의 기준으로 보면 안티패턴처럼 보이지만, 여기서는 이유가 있다. 쿼리 하나에 여러 자원이 섞여 있어서 일부만 성공하는 경우가 생긴다. 주문은 잘 가져왔는데 사용자 조회만 실패한 위 응답에 상태 코드 하나를 붙이려면, 200도 404도 정확하지 않다.
그래서 GraphQL은 판정을 상태 코드가 아니라 본문에 두고, 대신 어느 경로에서 실패했는지를 path로 알려준다.
문제는 이게 상태 코드로 얻던 이점들을 못 쓰게 만든다는 것이다. 상태 코드로 실패율을 세는 모니터링이 GraphQL 엔드포인트에서는 안 통한다. 실패를 세려면 응답 본문의 errors를 들여다보는 계측을 따로 붙여야 한다.
두 방식의 규약이 다른 것이지, 한쪽이 다른 쪽을 반박하는 게 아니다. REST 안에서 “200에 error”를 쓰면 여전히 문제다. 그건 HTTP가 이미 갖고 있는 판정 수단을 안 쓰는 것이기 때문이다.
틀린 선택이 아니라 다른 선택
정리하면 GraphQL이 REST의 상위 호환은 아니다. 가져가는 것과 내주는 것이 분명하다.
| REST | GraphQL | |
|---|---|---|
| 무엇을 받을지 정하는 쪽 | 서버 (엔드포인트가 모양을 고정) | 클라이언트 (쿼리가 모양을 정한다) |
| 화면이 바뀔 때 | 엔드포인트를 고치거나 추가 | 대개 클라이언트 쿼리만 수정 |
| HTTP 캐시 | URL이 키라서 헤더만 붙이면 된다 | 별도 장치가 필요하다 |
| 부하 예측 | 엔드포인트 단위로 측정 가능 | 쿼리 조합에 따라 달라진다 |
| 에러 판정 | 상태 코드 | 본문의 errors |
| 계약 | 코드·명세 파일에 있다 | 스키마에 있고 조회할 수 있다 |
어느 쪽이 유리한지는 상황이 정한다.
GraphQL이 값을 하기 쉬운 쪽은 화면 종류가 많고 자주 바뀌는 경우, 클라이언트가 여럿이라 각자 필요한 필드가 다른 경우, 그리고 자원 사이 연결을 따라다니는 조회가 많은 경우다. 여기서는 왕복을 줄이는 이득이 크고, 서버를 안 고치고 화면을 바꾸는 이득도 크다.
REST가 여전히 편한 쪽은 화면과 API가 거의 일대일로 붙어 있는 경우, 캐시가 성능의 핵심인 경우, 파일 업로드나 다운로드가 많은 경우, 그리고 소비자가 적어서 엔드포인트가 잘 안 늘어나는 경우다.
둘을 배타적으로 고를 필요도 없다. 대시보드처럼 조합이 복잡한 화면에만 GraphQL을 두고 나머지는 REST로 두는 구성도 흔하다. 다만 두 벌을 유지하는 비용은 실제로 든다. 인증 처리, 에러 형식, 모니터링을 각각 두 번씩 맞춰야 한다. 이 비용을 감수할 만큼 이득이 분명한지가 판단 기준이 된다.
정리
| 무슨 문제를 푸나 | 과소 조회(호출을 여러 번)와 과다 조회(안 쓰는 필드까지) |
| 어떻게 푸나 | 무엇을 받을지 클라이언트가 쿼리로 적어 보낸다. 응답이 요청을 닮는다 |
| URL | 대개 엔드포인트 하나. URL 설계 대신 스키마 설계를 한다 |
| 캐시 | URL이 캐시 키가 아니게 된다. 공짜로 얻던 걸 설정해서 얻어야 한다 |
| N+1 | ORM에서 본 문제가 층을 바꿔 나타난다. 배칭이 전제 |
| 부하 | 쿼리 조합이 입력이라 깊이·복잡도 제한이 필요하다 |
| 에러 | 상태 코드가 아니라 errors 배열. 부분 성공 때문이지만 모니터링을 다시 짜야 한다 |
| 선택 기준 | 상위 호환이 아니라 교환. 화면 다양성과 캐시 의존도를 먼저 본다 |
두 방식 다 결국 같은 제약 위에 있다. 한 번 공개한 것은 되돌리기 어렵다. 스키마에 넣은 필드도 REST의 응답 필드와 마찬가지로 누군가 이미 쓰고 있다.
다음 글은 그 제약을 정면으로 다룬다. 이미 나간 API를 어떻게 깨지 않고 바꾸는가, 그리고 그래도 갈라야 할 때 무엇을 하는가.