본문 바로가기
카테고리 없음

오버페칭이 싫어 GraphQL로 갔다가 만난 것들

by 테크구루스 2026. 7. 5.

오버페칭이 싫어 GraphQL로 갔다가 만난 것들을, 실제 전환을 겪으며 알게 된 장단점 위주로 정리한 글입니다.

 

오버페칭이 싫어 GraphQL로 갔다가 만난 것들
오버페칭이 싫어 GraphQL로 갔다가 만난 것들

REST에서 반복되던 오버페칭과 언더페칭

지금도 기억나는 화면이 하나 있습니다. 2년 전 사내 커머스 앱의 상품 상세 페이지였는데요, 이 화면 하나를 그리려고 클라이언트가 REST 엔드포인트를 세 번이나 호출하고 있었습니다. 상품 기본 정보를 받으려 /products/{id} 를 부르고, 리뷰 요약을 받으려 /products/{id}/reviews 를 부르고, 판매자 배지를 받으려 다시 /sellers/{id} 를 부르는 식이었죠. 화면 하나에 요청 세 번, 그것도 앞 응답이 와야 뒤 요청 주소를 만들 수 있어서 순차로 이어지곤 했습니다.

이렇게 필요한 데이터를 한 번에 못 받아 여러 엔드포인트를 여러 번 두드리는 상황을 언더페칭(under-fetching)이라고 부릅니다. 목록에서 상세로 들어갈 때 특히 티가 났는데요, 목록에서 이미 받은 이름·가격을 상세에서 또 받고, 정작 상세에만 필요한 필드를 얻으려 다시 요청을 얹는 식이라 왕복이 계속 늘어났습니다. 반대 방향의 낭비도 만만치 않았습니다. /products/{id} 응답은 필드가 마흔 개가 넘었는데, 정작 상세 상단 카드가 실제로 쓰는 값은 이름·가격·썸네일·재고 정도였습니다. 나머지 물류 코드, 내부 정산 필드, 관리자 메모까지 통째로 실려 왔죠. 필요 없는 필드까지 전부 받아 오는 이 낭비가 오버페칭(over-fetching)입니다.

 

숫자로 보면 더 뼈아팠습니다. 당시 상세 진입 한 번당 내려오는 JSON이 평균 46KB였는데, 화면이 실제로 소비하는 값만 추리면 6KB가 채 안 됐습니다. 특히 지하철에서 3G 신호가 흔들리던 시절, 이 40KB의 군더더기는 첫 화면 지연으로 고스란히 돌아왔습니다. 모바일에서는 이 낭비가 배터리와 데이터 요금으로도 이어지니 더 예민할 수밖에 없었죠. 서버 스펙을 새로 하나 파서 상세 전용 응답을 만들어 볼까 고민도 했습니다. 이른바 BFF(Backend For Frontend)처럼 화면 전용 API를 두는 방식인데요, 화면마다 전용 엔드포인트를 찍어 내다 보면 API 목록이 끝없이 불어나고, 화면이 바뀔 때마다 서버 배포가 따라붙는다는 걸 이미 다른 프로젝트에서 뼈아프게 겪은 뒤였습니다. 필드 하나 더 필요하다고 서버에 요청하고 배포를 기다리는 그 왕복이 매번 며칠씩 걸렸죠. 그래서 클라이언트가 스스로 필요한 모양을 정하는 다른 길을 찾기 시작했습니다.

GraphQL은 무엇을 바꿔 주는가

GraphQL의 핵심 발상은 단순합니다. 서버가 응답 모양을 정하는 게 아니라, 클라이언트가 필요한 필드만 골라 하나의 쿼리로 요청한다는 것이죠. 엔드포인트도 화면마다 나뉘지 않고 보통 /graphql 하나로 통일됩니다. 앞의 상세 화면을 GraphQL로 옮기면 이렇게 요청이 정리됩니다.

query 상품상세($id: ID!) {   # 화면이 쓰는 필드만 선언
  product(id: $id) {
    name
    price
    thumbnail
    stock
    reviewSummary { average count }
    seller { name badge }
  }
}

세 번 오가던 요청이 한 번으로 줄고, 응답에는 선언한 필드만 담깁니다. 마흔 개 필드 중 쓰지도 않던 정산 코드는 아예 내려오지 않죠. 여기서 눈여겨볼 점은 product 안에 reviewSummaryseller 가 중첩되어 있다는 것입니다. REST라면 별도 엔드포인트를 또 불러야 했을 연관 데이터를, GraphQL은 하나의 쿼리 안에서 관계를 타고 그래프처럼 함께 가져옵니다. 언더페칭이 문법 차원에서 풀리는 셈이죠.

게다가 GraphQL은 스키마 기반이라 각 필드의 타입이 서버 쪽에 명시되어 있습니다. priceInt 인지 Float 인지, seller 가 널 가능인지가 스키마에 박혀 있으니, 프런트에서는 그 스키마로 타입을 자동 생성해 오타나 필드 이름 실수를 빌드 단계에서 잡을 수 있었습니다. 예전에는 응답 필드 이름이 슬쩍 바뀌면 런타임에 화면이 비어야 알아챘는데, 이제는 타입 검사에서 바로 빨간 줄이 그어지니 마음이 한결 놓였습니다.

 

rest vs graphql 비교
rest vs graphql 비교

정리하면 GraphQL이 바꿔 준 지점은 대략 이렇습니다.

  • 단일 엔드포인트: 화면이 늘어도 /graphql 하나로 받고, 새 화면이 새 엔드포인트를 요구하지 않죠.
  • 필요한 필드만: 오버페칭이 구조적으로 사라지고, 언더페칭도 한 쿼리 안에서 관계를 타고 해결됩니다.
  • 스키마라는 계약: 타입이 문서이자 검증기라, 프런트와 백엔드가 필드 이름을 두고 실랑이할 일이 크게 줄었습니다.

실제로 상세 화면을 옮긴 뒤 응답 크기가 46KB에서 7KB 안팎으로 떨어졌고, 요청 수도 셋에서 하나로 줄었습니다. 여기까지만 보면 정말 은탄환처럼 느껴졌죠. 문제는 그 다음부터였습니다.

갈아탄 뒤에 새로 생긴 숙제

전환 첫 주에 스테이징 DB의 쿼리 로그를 보다가 눈을 의심했습니다. 상품 목록 한 페이지(20개)를 여는데 DB 쿼리가 60개 넘게 찍히고 있었거든요. 원인은 그 유명한 N+1 문제였습니다. GraphQL은 필드마다 리졸버(resolver)가 붙는데, 목록 20개를 도는 동안 각 상품의 seller 리졸버가 매번 판매자 조회 쿼리를 따로 날렸던 것이죠. 상품 목록 1번 + 판매자 20번 + 리뷰요약 20번, 오버페칭을 없애려다 오히려 DB 쿼리가 폭증한 셈이었습니다. 응답 자체는 가벼워졌는데 DB CPU가 전보다 올라가 있었으니 아이러니였죠.

해결은 DataLoader였습니다. 요청 한 번(tick) 안에서 같은 종류의 조회를 모아 두었다가, 한 번에 배치로 묶어 던지고 결과를 나눠 주는 도구입니다. 덤으로 같은 키 조회는 캐시해 중복을 없애 줍니다.

// 판매자 조회 배치 로더 — id 목록을 모아 한 번에 조회
const 판매자로더 = new DataLoader(async (ids) => {
  const rows = await db.seller.findMany({ where: { id: { in: ids } } });
  const 맵 = new Map(rows.map((r) => [r.id, r]));
  return ids.map((id) => 맵.get(id));  // 요청 순서대로 반환
});

seller 리졸버가 개별 쿼리 대신 판매자로더.load(id) 를 부르게 바꾸자, 20번 나가던 판매자 조회가 WHERE id IN (...) 한 방으로 접혔습니다. 목록 화면의 DB 쿼리가 60여 개에서 3개로 줄었고, p95 응답이 약 380ms에서 120ms대로 내려갔습니다. 처음 N+1 로그를 봤을 때는 GraphQL을 괜히 도입했나 싶어 아찔했는데, 로더 하나로 반나절 만에 뒤집히는 걸 보고 나서야 안도했죠.

 

graphql의 문제
graphql의 문제

N+1 말고도 새 숙제는 더 있었습니다.

  • HTTP 캐싱을 그대로 못 쓴다: REST는 URL 단위라 CDN·브라우저 캐시를 자연스럽게 얹었는데, GraphQL은 대개 같은 /graphql 주소로 POST를 날려 URL 캐싱이 통하지 않죠. persisted query나 응답 필드 단위 캐시 같은 별도 장치를 얹어야 했습니다.
  • 악의적 쿼리 방어: 클라이언트가 쿼리 모양을 정하니, 관계를 깊게 중첩하면 서버가 통째로 갈릴 수 있습니다. 그래서 쿼리 깊이 제한과 복잡도(cost) 제한을 걸어 일정 점수를 넘는 쿼리는 거부하도록 막았습니다.
  • 서버 구현 부담: 리졸버, 스키마, 로더까지 초기 골격을 짜는 손이 REST보다 확실히 더 들었습니다.

그래서 지금의 제 결론은 이렇습니다. GraphQL은 오버페칭·언더페칭이라는 오래된 통증에는 분명한 처방이지만, 그 대가로 N+1과 캐싱과 보안이라는 새 숙제를 함께 얹어 줍니다. 은탄환은 아니라는 뜻이죠. 화면마다 데이터 요구가 제각각인 앱이라면 저는 다시 GraphQL을 고르겠지만, 단순 CRUD API라면 굳이 이 무게를 지지 않고 REST에 남을 겁니다. 도구를 바꾸는 일은 통증을 없애는 게 아니라 통증의 종류를 갈아 끼우는 일에 가깝다는 걸, 이 전환에서 새삼 배웠습니다.