GraphQL이 무엇인지, 쿼리·뮤테이션·스키마가 어떻게 동작하는지, 언제 REST 대신 GraphQL을 사용할지 — 실용적인 장단점과 예제를 함께 배웁니다.

GraphQL은 쿼리 언어이자 API를 위한 런타임입니다. 간단히 말해: 앱(웹, 모바일, 또는 다른 서비스)이 명확하고 구조화된 요청으로 API에 데이터를 요구하면 서버가 그 요청에 맞는 응답을 반환하는 방식입니다.
많은 API는 클라이언트가 고정된 엔드포인트가 반환하는 것을 그대로 받아들이게 만듭니다. 이로 인해 흔히 두 가지 문제가 발생합니다:
GraphQL을 사용하면 클라이언트가 정확히 필요한 필드만 요청할 수 있습니다. 서로 다른 화면(또는 다른 앱)이 같은 데이터에서 다른 "조각"을 필요로 할 때 특히 유용합니다.
GraphQL은 보통 클라이언트 앱과 데이터 소스 사이에 위치합니다. 그 데이터 소스는 다음과 같을 수 있습니다:
GraphQL 서버는 쿼리를 받고 요청된 각 필드를 적절한 장소에서 어떻게 가져올지 결정한 뒤 최종 JSON 응답을 조립합니다.
GraphQL을 맞춤형 응답 형태를 주문하는 것으로 생각해 보세요:
GraphQL에 대해 흔한 오해를 조금 정리하면:
이 핵심 정의—쿼리 언어 + API를 위한 런타임—만 기억하면 나머지 개념을 이해하는 데 좋습니다.
GraphQL은 현실적인 제품 문제를 해결하기 위해 만들어졌습니다: 팀들이 실제 UI 화면에 맞추기 위해 API를 과도하게 조정하느라 시간을 많이 쓰고 있었습니다.
전통적인 엔드포인트 기반 API는 불필요한 데이터를 보내거나 필요한 데이터를 얻기 위해 추가 호출을 하게 만드는 선택을 강요합니다. 제품이 커질수록 이 마찰은 페이지 로딩 지연, 복잡한 클라이언트 코드, 프론트엔드와 백엔드 팀 간의 고통스러운 조정으로 드러납니다.
오버패칭은 엔드포인트가 "완전한" 객체를 반환하는 경우 발생합니다. 예를 들어 모바일 프로필 화면은 이름과 아바타만 필요할 수 있는데 API가 주소, 설정, 감사 필드 등까지 모두 반환하면 대역폭을 낭비하고 사용자 경험을 해칠 수 있습니다.
언더패칭은 반대의 경우입니다: 하나의 뷰에 필요한 모든 것을 담은 단일 엔드포인트가 없어 클라이언트가 여러 요청을 하고 결과를 합쳐야 합니다. 이는 레이턴시를 증가시키고 부분 실패 가능성을 높입니다.
많은 REST 스타일 API는 변화에 대응해 새 엔드포인트를 추가하거나 버전 관리(v1, v2, v3)를 사용합니다. 버전 관리는 필요할 수 있지만 오래된 클라이언트가 오래된 버전을 계속 사용하면서 유지보수 부담이 커집니다.
GraphQL의 접근법은 스키마에 필드와 타입을 점진적으로 추가해 기존 필드를 안정적으로 유지하는 것입니다. 이로 인해 새로운 UI 요구를 지원하기 위해 굳이 "새 버전"을 만들 필요성이 줄어드는 경우가 많습니다.
현대 제품은 단일 소비자만 있는 경우가 거의 없습니다. 웹, iOS, Android, 파트너 통합 등 각기 다른 데이터 형태가 필요합니다.
GraphQL은 각 클라이언트가 별도의 엔드포인트를 만들지 않고도 정확히 필요한 필드를 요청할 수 있도록 설계되었습니다.
GraphQL API는 스키마로 정의됩니다. 서버와 모든 클라이언트 사이의 약속으로 생각하세요: 어떤 데이터가 있고, 어떻게 연결되며, 무엇을 요청하거나 변경할 수 있는지 나열합니다. 클라이언트는 엔드포인트를 추측하지 않고 스키마를 보고 특정 필드를 요청합니다.
스키마는 타입(예: User, Post)과 필드(예: name, title)로 구성됩니다. 필드는 다른 타입을 가리킬 수 있고, 이를 통해 GraphQL은 관계를 모델링합니다.
간단한 Schema Definition Language(SDL) 예시는 아래와 같습니다:
type User {
id: ID!
name: String!
posts: [Post!]!
}
type Post {
id: ID!
title: String!
body: String
author: User!
comments: [Comment!]!
}
type Comment {
id: ID!
text: String!
author: User!
post: Post!
}
스키마가 강한 타입을 가지므로 GraphQL은 요청을 실행하기 전에 검증할 수 있습니다. 예를 들어 클라이언트가 스키마에 없는 필드(Post.publishDate)를 요청하면 서버는 애매한 동작 대신 명확한 오류로 요청을 거부하거나 부분적으로 처리할 수 있습니다.
스키마는 확장되도록 설계되어 있습니다. 보통 새 필드 추가(예: User.bio)는 기존 클라이언트를 깨뜨리지 않습니다. 필드를 제거하거나 변경하는 경우에는 민감하므로 팀은 보통 먼저 필드를 폐기(deprecate)하고 클라이언트를 점진적으로 마이그레이션합니다.
GraphQL API는 일반적으로 단일 엔드포인트(예: /graphql)를 통해 노출됩니다. 여러 리소스에 대한 여러 URL(/users, /users/123, /users/123/posts) 대신 한 곳으로 쿼리를 보내고 필요한 데이터를 정확히 기술합니다.
쿼리는 기본적으로 필드의 "쇼핑 목록"입니다. 간단한 필드(id, name)뿐만 아니라 같은 요청에서 중첩 데이터(예: 사용자의 최근 게시물)도 요청할 수 있으며, 필요 없는 필드는 내려받지 않습니다.
작은 예시는 다음과 같습니다:
query GetUserWithPosts {
user(id: "123") {
id
name
posts(limit: 2) {
id
title
}
}
}
GraphQL 응답은 예측 가능합니다: 반환되는 JSON은 쿼리 구조를 그대로 반영합니다. 프론트엔드에서는 데이터가 어디에 나타날지 추측할 필요가 없고 다양한 응답 형식을 파싱할 필요도 적습니다.
단순화된 응답 예시는 다음과 같습니다:
{
"data": {
"user": {
"id": "123",
"name": "Sam",
"posts": [
{ "id": "p1", "title": "Hello GraphQL" },
{ "id": "p2", "title": "Queries in Practice" }
]
}
}
}
필드를 요청하지 않으면 포함되지 않고, 요청하면 해당 위치에서 기대할 수 있습니다—이로써 GraphQL 쿼리는 각 화면이나 기능이 필요한 것을 깔끔하게 가져오는 방법이 됩니다.
쿼리는 읽기용이고, 뮤테이션은 GraphQL API에서 데이터를 변경하는 방법입니다(생성, 수정, 삭제).
대부분의 뮤테이션은 다음 패턴을 따릅니다:
input 객체처럼 구조화된 입력을 보냅니다(예: 업데이트할 필드).GraphQL 뮤테이션은 보통 데이터를 의도적으로 반환합니다. 단순히 success: true만 반환하지 않는 이유는 다음과 같습니다:
일반적인 설계는 업데이트된 엔티티와 오류를 모두 포함하는 "페이로드" 타입입니다.
mutation UpdateEmail($input: UpdateUserEmailInput!) {
updateUserEmail(input: $input) {
user {
id
email
}
errors {
field
message
}
}
}
UI 중심 API의 좋은 규칙은: 다음 상태를 렌더링하는 데 필요한 것을 반환하라(예: 업데이트된 user와 errors). 이렇게 하면 클라이언트가 단순해지고 무슨 것이 변경되었는지 추측할 필요가 없으며 실패를 우아하게 처리하기 쉽습니다.
스키마는 무엇을 물을 수 있는지를 설명합니다. 리졸버는 실제로 그것을 어떻게 가져오는지 설명합니다. 리졸버는 스키마의 특정 필드에 연결된 함수입니다. 클라이언트가 그 필드를 요청하면 GraphQL은 해당 리졸버를 호출해 값을 가져오거나 계산합니다.
GraphQL은 요청된 형태를 걷으면서 쿼리를 실행합니다. 각 필드에 대해 매칭되는 리졸버를 찾아 실행합니다. 일부 리졸버는 이미 메모리에 있는 객체의 속성을 반환할 뿐이고, 다른 리졸버는 데이터베이스, 다른 서비스 호출, 여러 소스를 결합할 수도 있습니다.
예를 들어 스키마에 User.posts가 있다면 posts 리졸버는 userId로 posts 테이블을 쿼리하거나 별도의 Posts 서비스에 호출할 수 있습니다.
리졸버는 스키마와 실제 시스템을 잇는 접착제입니다:
이 매핑은 유연합니다: 스키마가 일관성을 유지하는 한 백엔드 구현을 변경해도 클라이언트 쿼리 형태는 바뀌지 않습니다.
리졸버는 필드별로, 리스트의 항목별로 실행될 수 있기 때문에 많은 작은 호출을 유발하기 쉬워 N+1 패턴으로 이어질 수 있습니다. 이런 경우 응답이 느려집니다.
일반적인 해결책은 배칭과 캐싱(예: ID를 모아서 한 번에 가져오기)이며, 클라이언트가 요청하도록 권장하는 중첩 필드를 의도적으로 통제하는 것입니다.
권한 검사는 리졸버(또는 공유 미들웨어)에서 시행되는 경우가 많습니다. 리졸버는 문맥(context)을 통해 누가 요청하는지와 무엇에 접근하는지를 알기 때문입니다. 검증은 두 수준에서 이루어집니다: GraphQL은 타입/형태 검증을 자동으로 처리하고, 리졸버는 비즈니스 규칙(예: "이 필드는 관리자만 설정할 수 있다")을 강제합니다.
GraphQL에 익숙하지 않은 사람들을 놀라게 하는 점 중 하나는 요청이 "성공"하면서도 오류를 포함할 수 있다는 것입니다. GraphQL은 필드 지향적이기 때문에 일부 필드를 해결할 수 있고 일부는 못 할 수 있어 부분 데이터가 반환될 수 있습니다.
전형적인 GraphQL 응답은 data와 errors 배열을 모두 포함할 수 있습니다:
{
"data": {
"user": {
"id": "123",
"email": null
}
},
"errors": [
{
"message": "Not authorized to read email",
"path": ["user", "email"],
"extensions": { "code": "FORBIDDEN" }
}
]
}
이것은 유용합니다: 클라이언트는 가지고 있는 것을 렌더링(예: 사용자 프로필)하면서 누락된 필드를 처리할 수 있습니다.
data는 종종 null입니다.오류 메시지는 디버깅용이 아니라 최종 사용자를 위해 작성하세요. 스택 트레이스, 데이터베이스 이름, 내부 ID 등을 노출하지 마세요. 좋은 패턴은:
messageextensions.coderetryable: true)상세한 오류는 요청 ID와 함께 서버 사이드에서 로그에 남겨 조사할 수 있게 하세요.
웹과 모바일이 공유하는 작은 오류 "계약"을 정의하세요: 공통 extensions.code 값(UNAUTHENTICATED, FORBIDDEN, BAD_USER_INPUT 등), 언제 토스트를 보여줄지 vs 인라인 필드 오류를 보여줄지, 부분 데이터를 어떻게 처리할지 등. 여기에 일관성이 있으면 각 클라이언트가 자체적으로 규칙을 만들지 않게 됩니다.
구독은 GraphQL이 데이터 변경을 클라이언트로 푸시하는 방식입니다. 클라이언트가 반복해서 묻지 않아도 서버가 변경점을 전송할 수 있게 지속 연결(대부분 WebSocket)을 유지합니다.
구독은 쿼리와 비슷하게 보이지만 결과가 단일 응답이 아닙니다. 결과의 스트림이며 각 항목은 이벤트를 나타냅니다.
내부적으로 클라이언트는 특정 토픽(예: 채팅 앱의 messageAdded)에 "구독"합니다. 서버가 이벤트를 발행하면 연결된 구독자들은 구독의 선택 집합(selection set)에 맞춘 페이로드를 받습니다.
구독은 변경을 즉시 기대하는 경우 빛을 발합니다:
폴링은 클라이언트가 N초마다 "새로운 것이 있나?"를 묻습니다. 단순하지만 아무 변화가 없을 때 요청을 낭비하고 지연이 느껴질 수 있습니다.
구독은 서버가 즉시 업데이트를 전송하므로 불필요한 트래픽을 줄이고 체감 속도를 개선할 수 있지만, 연결 유지와 실시간 인프라 운영이 필요합니다.
업데이트가 드물거나 실시간으로 급하지 않거나 배치하기 쉬운 경우에는 구독이 과도한 복잡성을 초래할 수 있습니다. 구독은 실시간이 제품 요구사항일 때만 사용하는 것이 좋습니다.
GraphQL은 종종 "클라이언트에게 힘을 준다"고 묘사되지만, 그 힘에는 비용이 따릅니다. 사전에 절충점을 이해하면 언제 GraphQL이 적합한지 판단하는 데 도움이 됩니다.
가장 큰 장점은 유연한 데이터 페칭입니다: 클라이언트는 필요한 필드만 요청할 수 있어 오버패칭을 줄이고 UI 변경을 빠르게 만들 수 있습니다.
또 다른 큰 이점은 스키마에 의한 강한 계약입니다. 스키마는 타입과 사용 가능한 연산의 단일 진실 소스가 되어 협업과 툴링을 향상시킵니다.
팀은 종종 프론트엔드 생산성이 향상되는 것을 경험합니다. 프론트엔드 개발자는 새로운 엔드포인트를 기다리지 않고 반복할 수 있고, Apollo Client 같은 도구는 타입 생성과 데이터 페칭을 간소화합니다.
GraphQL은 캐싱을 더 복잡하게 만들 수 있습니다. REST에서는 캐싱이 종종 "URL별"이지만, GraphQL은 같은 엔드포인트를 여러 쿼리가 공유하므로 캐싱은 쿼리 형태, 정규화된 캐시, 서버/클라이언트 설정에 의존합니다.
서버 측에는 성능 함정이 있습니다. 작아 보이는 쿼리가 많은 백엔드 호출을 촉발할 수 있으므로 리졸버를 신중히 설계해야 합니다(배칭, N+1 회피, 비용이 큰 필드 제어 등).
학습 곡선도 있습니다: 스키마, 리졸버, 클라이언트 패턴은 엔드포인트 기반 API에 익숙한 팀에게 낯설 수 있습니다.
클라이언트가 많은 것을 요청할 수 있으므로 GraphQL API는 쿼리 깊이 및 복잡도 제한을 통해 남용이나 실수로 인한 과도한 요청을 방지해야 합니다.
인증과 권한은 라우트 수준뿐 아니라 필드별로 시행되어야 합니다. 서로 다른 필드에 서로 다른 접근 규칙이 있을 수 있기 때문입니다.
운영적으로는 GraphQL을 이해하는 로깅, 트레이싱, 모니터링에 투자하세요: 오퍼레이션 이름, 변수(주의해서), 리졸버 타이밍, 오류율을 추적해 느린 쿼리와 회귀를 조기에 발견할 수 있습니다.
GraphQL과 REST는 모두 앱이 서버와 통신하게 하지만 그 구조는 매우 다릅니다.
REST는 리소스 기반입니다. /users/123 또는 /orders?userId=123 같은 여러 엔드포인트를 호출해 데이터를 가져옵니다. 각 엔드포인트는 서버가 결정한 고정된 데이터 구조를 반환합니다.
REST는 또한 HTTP 의미론(GET/POST/PUT/DELETE, 상태 코드, 캐싱 규칙)에 의존합니다. 단순 CRUD를 하거나 브라우저/프록시 캐시에 크게 의존할 때 REST가 자연스럽게 느껴질 수 있습니다.
GraphQL은 스키마 기반입니다. 여러 엔드포인트 대신 보통 하나의 엔드포인트를 사용하고, 클라이언트가 원하는 필드를 기술한 쿼리를 보냅니다. 서버는 그 쿼리를 스키마에 대해 검증하고 쿼리 형태에 맞는 응답을 반환합니다.
이러한 "클라이언트 주도 선택"이 GraphQL이 오버패칭과 언더패칭을 줄이는 이유입니다(특히 여러 관련 모델에서 데이터를 모아야 하는 UI 화면에서 유용).
다음 경우 REST가 더 적합할 수 있습니다:
많은 팀이 두 가지를 혼합해 사용합니다:
실무 질문은 "어느 쪽이 더 낫나?"가 아니라 "이 사례에서 최소한의 복잡도로 어떤 방식이 적합한가?"입니다.
GraphQL API 설계는 화면을 만드는 사람들을 위한 제품으로 다루면 가장 쉽습니다. 작게 시작하고 실제 사용 사례로 검증한 뒤 필요에 따라 확장하세요.
핵심 화면들(예: "상품 목록", "상품 상세", "결제")을 나열하세요. 각 화면에 필요한 정확한 필드와 지원하는 상호작용을 적어보세요.
이렇게 하면 "갓 쿼리(god queries)"를 피하고 오버패칭을 줄이며 필터링, 정렬, 페이징이 필요한 곳을 명확히 할 수 있습니다.
먼저 핵심 타입(예: User, Product, Order)과 관계를 정의하세요. 그런 다음:
addToCart, placeOrder)데이터베이스 명명보다 비즈니스 언어 네이밍을 선호하세요. placeOrder는 createOrderRecord보다 의도를 더 잘 전달합니다.
네이밍을 일관되게 유지하세요: 단수는 항목(product), 복수는 컬렉션(products). 페이징은 보통 하나를 선택합니다:
초기에 결정하세요. 응답 구조를 형성합니다.
GraphQL은 스키마에 설명(description)을 직접 지원합니다—필드, 인자, 엣지 케이스에 설명을 추가하세요. 문서에 페이징과 일반적인 오류 시나리오 예제도 몇 개 넣으세요. 잘 설명된 스키마는 인트로스펙션과 API 익스플로러를 훨씬 유용하게 만듭니다.
GraphQL을 시작하는 것은 잘 지원되는 도구 몇 가지를 선택하고 신뢰할 수 있는 워크플로우를 세우는 일입니다. 한 번에 모든 것을 도입할 필요는 없습니다—하나의 쿼리를 엔드투엔드로 작동시키고 확장하세요.
스택과 얼마나 많은 "배터리 포함"을 원하는지에 따라 선택하세요:
실용적 첫 단계: 작은 스키마(몇 개의 타입 + 하나의 쿼리)를 정의하고, 리졸버를 구현하고, 실제 데이터 소스(심지어 메모리 스텁이라도)와 연결하세요.
빠르게 아이디어에서 작동하는 API로 가려면 Koder.ai 같은 비브 코딩 플랫폼이 React 프론트엔드와 Go + PostgreSQL 백엔드를 스캐폴딩해 GraphQL 스키마/리졸버를 채팅으로 반복하고 준비되면 소스 코드를 내보낼 수 있게 도와줍니다.
프론트엔드에서는 의견이 강한 컨벤션을 원하느냐 유연성을 원하느냐에 따라 선택이 달라집니다:
REST에서 마이그레이션할 경우 하나의 화면이나 기능에 GraphQL을 먼저 도입하고 접근 방식이 입증될 때까지 나머지는 REST로 유지하세요.
스키마를 API 계약처럼 다루세요. 유용한 테스트 계층은 다음과 같습니다:
이해를 심화하려면 다음을 이어가세요:
GraphQL은 API를 위한 쿼리 언어이자 런타임입니다. 클라이언트는 필요한 필드들을 정확히 기술한 쿼리를 보내고, 서버는 그 형태를 반영한 JSON 응답을 반환합니다.
클라이언트와 하나 이상의 데이터 소스(데이터베이스, REST 서비스, 서드파티 API, 마이크로서비스) 사이에 위치한 계층으로 생각하면 됩니다.
GraphQL이 주로 해결하는 문제는 다음과 같습니다:
클라이언트가 필요한 필드(중첩 필드 포함)만 요청하도록 허용함으로써 불필요한 데이터 전송을 줄이고 클라이언트 코드를 단순화할 수 있습니다.
GraphQL은 다음이 아니다:
API 계약과 실행 엔진으로 바라보고, 저장소나 성능 만능약으로 보지 않는 것이 좋습니다.
대부분의 GraphQL API는 단일 엔드포인트(종종 /graphql)를 노출합니다. 여러 URL 대신 다양한 동작(쿼리/뮤테이션)을 하나의 엔드포인트로 보냅니다.
실무적 의미: 캐싱과 관찰성은 보통 URL이 아니라 오퍼레이션 이름 + 변수를 기준으로 고려됩니다.
스키마는 API 계약입니다. 다음을 정의합니다:
User, Post)User.name)User.posts)스키마가 을 가지기 때문에 서버는 쿼리를 실행하기 전에 유효성을 검사하고, 존재하지 않는 필드 요청에 대해 명확한 오류를 제공할 수 있습니다.
쿼리는 읽기 연산입니다. 필요한 필드를 명시하면 응답 JSON이 쿼리 구조와 일치합니다.
팁:
query GetUserWithPosts).posts(limit: 2)).뮤테이션은 쓰기 연산(생성/수정/삭제)입니다. 일반적인 패턴:
input 객체를 보냄단순한 success: true 대신 데이터를 반환하면 UI가 즉시 상태를 업데이트하고 캐시를 일관되게 유지하는 데 도움이 됩니다.
리졸버는 각 필드를 어떻게 가져올지 정의하는 필드 수준 함수입니다.
실무에서 리졸버는 다음을 수행할 수 있습니다:
권한 검사는 보통 리졸버(또는 공통 미들웨어)에서 수행됩니다. 리졸버는 누가 어떤 데이터를 요청하는지 문맥(context)을 알기 때문입니다.
예를 들어 100명의 사용자에 대해 각각의 게시물을 별도로 불러오면 N+1 문제가 발생할 수 있습니다.
일반적인 해결책:
리졸버 타이밍을 측정하고 한 요청에서 반복되는 다운스트림 호출을 주시하세요.
GraphQL은 일부 필드는 성공적으로 해결되고 일부는 실패할 수 있어 부분 데이터와 errors 배열을 동시에 반환할 수 있습니다. 이는 특정 필드에 권한 문제가 있거나 다운스트림 타임아웃이 발생한 경우에 유용합니다.
좋은 관행:
message 사용extensions.code 값 사용(예: FORBIDDEN, BAD_USER_INPUT)클라이언트는 부분 데이터를 표시할지 전체 실패로 처리할지를 정책으로 정해야 합니다.