REST vs GraphQL vs gRPC — 세 API 스타일의 결정 트리

자원·쿼리·함수 호출 — 같은 문제를 푸는 세 가지 다른 모델

웹 API를 만들 때 REST·GraphQL·gRPC 세 가지 주요 선택지가 있다. 본 글은 각 스타일의 통신 모델·메시지 형식·클라이언트 코드 차이, 사용 시나리오별 적합성, 운영·관측성 trade-off, MINERVA가 REST를 선택한 근거를 정리한다. api-fundamentals 글의 REST 깊이 위에 두 대안을 비교한다.

Engineering
저자

Kwangmin Kim

공개

2026년 05월 06일

1 왜 세 스타일을 비교하는가

API 기초가 REST·HTTP·JSON에 깊이 집중했지만 GraphQL·gRPC는 표 한 줄로만 언급된다. 그러나 실무에서는 어떤 스타일을 선택하느냐가 시스템 전체의 모양을 결정한다 — 클라이언트 SDK, 캐싱, 인증, 모니터링까지 다 영향을 받는다.

본 글은 세 스타일의 결정 근거를 정리한다. MINERVA 04편 FastAPI 서빙이 REST(엄밀히는 RESTful HTTP API)를 선택한 것이 정확히 이 결정 위에 있다.

2 핵심 정의

세 스타일
  • REST (Representational State Transfer): 자원을 URL로 식별하고 HTTP 메서드(GET/POST/PUT/DELETE)로 조작. 응답은 보통 JSON. 가장 널리 쓰임
  • GraphQL: 클라이언트가 필요한 필드를 쿼리로 명시. 단일 엔드포인트(/graphql)에 POST. 응답은 쿼리에 정확히 매칭
  • gRPC: Protocol Buffers(protobuf)로 함수 시그니처를 정의하고 HTTP/2 위에서 함수 호출. 응답은 이진 직렬화 (텍스트 아님)

세 스타일이 같은 문제(서버 로직을 클라이언트에 노출)를 풀지만 모델링 단위가 다르다:

스타일 모델링 단위 비유
REST 자원(URL) + 메서드(HTTP verb) 파일 시스템 (디렉토리·파일을 GET/POST/DELETE)
GraphQL 타입 + 쿼리 DB 쿼리 (필요한 컬럼만 SELECT)
gRPC 함수 호출 (RPC) 같은 프로세스 안 함수 호출처럼

3 REST — 자원 중심

# FastAPI 예시 (MINERVA 04편 패턴)
from fastapi import FastAPI
from pydantic import BaseModel

app = FastAPI()

class Query(BaseModel):
    text: str
    user_id: str | None = None

@app.post("/agents/qna_chatbot/run")
def run(query: Query):
    return {"text": "답변", "run_id": "abc"}

@app.get("/agents/qna_chatbot/documents")
def list_documents():
    return [{"id": "doc1", "title": "..."}]
# 클라이언트 — 일반 HTTP
curl -X POST http://localhost:8000/agents/qna_chatbot/run \
    -H "Content-Type: application/json" \
    -d '{"text": "질문", "user_id": "u-1"}'

curl http://localhost:8000/agents/qna_chatbot/documents

강점: - HTTP 친화 — 캐싱·로드밸런싱·CORS·HTTPS·인증이 모두 일반 HTTP 인프라 그대로 - 학습 곡선 낮음 — curl·브라우저로 즉시 테스트 - 도구 풍부 — Postman·Swagger·HTTPie

약점: - 클라이언트가 over-fetching: 응답에 필요 없는 필드가 따라옴 (필드 골라 받기 어려움) - N+1 문제: 사용자 목록 + 각 사용자의 게시글이 필요하면 N+1번 호출 - 타입 안전성: 응답 schema가 코드에 강제되지 않음 (OpenAPI/Pydantic 도입으로 보완 가능)

4 GraphQL — 쿼리 중심

# Strawberry GraphQL (Python)
import strawberry
from fastapi import FastAPI
from strawberry.fastapi import GraphQLRouter

@strawberry.type
class Document:
    id: str
    title: str
    content: str

@strawberry.type
class Query:
    @strawberry.field
    def documents(self) -> list[Document]:
        return [Document(id="doc1", title="...", content="...")]

    @strawberry.field
    def document(self, id: str) -> Document:
        return Document(id=id, title="...", content="...")

schema = strawberry.Schema(query=Query)
app = FastAPI()
app.include_router(GraphQLRouter(schema), prefix="/graphql")
# 클라이언트가 필요한 필드만 쿼리
query {
  documents {
    id
    title          # content는 받지 않음 — over-fetching 회피
  }
}
# 단일 엔드포인트 POST
curl -X POST http://localhost:8000/graphql \
    -H "Content-Type: application/json" \
    -d '{"query": "{ documents { id title } }"}'

강점: - 클라이언트가 필요한 필드만 선택 (over-fetching 회피) - 한 요청으로 여러 자원 조합 (N+1 문제 자연 해결) - 강한 타입 시스템 (스키마가 계약) - introspection — 클라이언트 SDK 자동 생성

약점: - 캐싱 어려움 — 모든 요청이 POST /graphql이라 HTTP 캐시 무의미 - 학습 곡선 — 쿼리 언어·resolver·DataLoader 등 새 개념 - 서버 복잡도 ↑ — query parser·resolver·N+1 방지(DataLoader) 직접 관리 - 도구 생태계 — REST보다 작음 - DDoS 위험 — 악의적 깊은 중첩 쿼리 가능 (방어 로직 필요)

적합한 경우: 모바일 앱처럼 클라이언트가 다양한 필드 조합을 필요로 하고 네트워크 대역폭이 제한적일 때, 또는 여러 도메인을 한 번에 조합해야 할 때 (BFF 패턴).

5 gRPC — 함수 호출 중심

// agent.proto — Protocol Buffers schema
syntax = "proto3";

package minerva;

service QnaChatbot {
  rpc Run(QueryRequest) returns (RunResponse);
  rpc Stream(QueryRequest) returns (stream StreamEvent);   // server streaming
}

message QueryRequest {
  string text = 1;
  string user_id = 2;
}

message RunResponse {
  string text = 1;
  string run_id = 2;
  int32 latency_ms = 3;
}

message StreamEvent {
  string type = 1;
  string text = 2;
}
# 코드 생성
python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. agent.proto
# → agent_pb2.py, agent_pb2_grpc.py 자동 생성
# 서버
import grpc
from concurrent import futures
import agent_pb2
import agent_pb2_grpc

class QnaChatbotServicer(agent_pb2_grpc.QnaChatbotServicer):
    def Run(self, request, context):
        return agent_pb2.RunResponse(
            text=f"답변: {request.text}",
            run_id="abc",
            latency_ms=100,
        )

    def Stream(self, request, context):
        for token in ["안", "녕", "하", "세", "요"]:
            yield agent_pb2.StreamEvent(type="token", text=token)


server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
agent_pb2_grpc.add_QnaChatbotServicer_to_server(QnaChatbotServicer(), server)
server.add_insecure_port("[::]:50051")
server.start()
# 클라이언트 — 함수 호출처럼
import grpc
import agent_pb2
import agent_pb2_grpc

with grpc.insecure_channel("localhost:50051") as channel:
    stub = agent_pb2_grpc.QnaChatbotStub(channel)
    response = stub.Run(agent_pb2.QueryRequest(text="질문", user_id="u-1"))
    print(response.text)

강점: - Protocol Buffers 이진 직렬화 — JSON 대비 30~50% 작고 5~10× 빠름 - 강한 타입 + 스키마 계약 (.proto가 단일 진실) - 코드 자동 생성 — 클라이언트 SDK 무료 - HTTP/2 multiplexing — 한 연결로 동시 많은 요청 - 4가지 호출 모드 (unary, server stream, client stream, bidi stream)

약점: - 브라우저 직접 사용 어려움 (gRPC-Web으로 우회 가능) - 학습 곡선 — protobuf 문법, codegen 도구 - 디버깅 — 이진 형식이라 curl로 즉시 확인 어려움 (grpcurl 같은 별도 도구) - 인프라 — HTTP/2 지원이 필수, 일부 LB·프록시가 까다로움

적합한 경우: 마이크로서비스 간 내부 통신, 모바일 앱-서버 통신, 성능 critical (저지연 + 고처리량) 요구 시. 외부 공개 API에는 부담스러움.

6 직접 비교 — 같은 작업

같은 “사용자 정보 + 그 사용자의 게시글 목록을 받아오기”를 세 스타일로 구현.

6.1 REST — 두 번 호출 (또는 합친 엔드포인트)

GET /users/42                              # → user 정보
GET /users/42/posts                        # → user의 게시글 목록

또는 합친 엔드포인트:

GET /users/42?include=posts                # → 한 번에. 단 over-fetching 가능

6.2 GraphQL — 한 번에 필요한 필드만

query {
  user(id: "42") {
    name                                    # name만
    posts(limit: 10) {
      title                                 # 제목만
    }
  }
}

응답이 정확히 쿼리 모양 그대로:

{"data": {"user": {"name": "Alice", "posts": [{"title": "..."}]}}}

6.3 gRPC — 함수 호출

user = stub.GetUser(GetUserRequest(id="42"))
posts = stub.ListUserPosts(ListUserPostsRequest(user_id="42", limit=10))

# 또는 합친 RPC
response = stub.GetUserWithPosts(Request(id="42", limit=10))

각 스타일이 같은 결과를 다른 모양으로 표현. 클라이언트의 작업 패턴이 어떤지에 따라 자연스러운 선택이 달라진다.

7 결정 트리

1. 클라이언트가 브라우저인가?
   ├── Yes → REST 또는 GraphQL
   │   ├── 단순한 CRUD가 대부분 → REST
   │   └── 다양한 필드 조합·여러 도메인 한 번에 → GraphQL
   └── 서버 간 내부 통신 → gRPC 검토

2. 외부 공개 API인가?
   ├── Yes → REST (학습 곡선·생태계 친화)
   └── 내부 → gRPC 또는 GraphQL

3. 성능 critical (지연·대역폭)인가?
   ├── Yes → gRPC (이진 + HTTP/2)
   └── No → REST 또는 GraphQL

4. 캐싱·HTTP 인프라가 중요한가?
   ├── Yes → REST (HTTP 메서드별 캐시 자연)
   └── No → 셋 다 가능

5. 클라이언트 SDK 자동 생성이 중요한가?
   ├── Yes → gRPC (codegen) 또는 GraphQL (introspection)
   └── No → REST + OpenAPI도 codegen 가능

8 MINERVA가 REST를 선택한 근거

MINERVA 04편 라우터가 REST + JSON. 결정 트리로 보면:

결정 MINERVA 답 선택
클라이언트 = 브라우저 Yes (React 프론트엔드) REST 또는 GraphQL
단순 CRUD vs 다양한 필드 조합 단순 — /run, /stream, /feedback 같이 명확한 자원·동사 REST
외부 공개 API 사내 사용자 대상이지만 표준 도구로 디버깅 용이성 중요 REST
성능 critical LLM 호출이 1~10초로 가장 큰 비중 — API 직렬화는 중요하지 않음 REST 충분
HTTP 인프라 활용 Azure Container Apps + nginx — HTTP 친화 환경 REST

SSE 스트리밍과의 시너지: SSE도 HTTP 위 단방향이라 REST 라우터와 자연스럽게 같은 도메인. gRPC server stream은 더 강력하지만 브라우저 호환을 위해 gRPC-Web이 필요.

REST + JSON + SSE 조합이 MINERVA 워크로드(LLM 응답 받기·사용자 피드백 보내기·메트릭 조회)에 가장 단순한 답이다.

9 운영·관측성 비교

항목 REST GraphQL gRPC
디버깅 curl·브라우저 즉시 GraphiQL UI grpcurl·BloomRPC
로깅 URL + 메서드로 자연 분류 모든 요청이 /graphql POST — query 본문 파싱 필요 RPC 메서드 이름 직접
메트릭 엔드포인트별 latency·error rate 단일 endpoint — query 종류 분리 어려움 메서드별 메트릭 자연
캐싱 (CDN) HTTP 메서드 따라 자연 어려움 (POST 응답 캐시 안 함) HTTP/2 push·자체 캐싱
Rate limiting 엔드포인트별 자연 query 복잡도 점수 기반 (구현 부담) 메서드별 자연
인증 Authorization 헤더 같은 헤더 + query 안에 권한 분기 metadata 헤더
보안 (DDoS) 엔드포인트별 보호 깊은 중첩 쿼리 차단 직접 구현 메서드별 + reflection 비활성화

REST가 운영 관측성에서 가장 단순하다 — 이미 표준 도구가 풍부하다. GraphQL은 운영 모니터링이 가장 까다로운 편.

10 자주 발생하는 오류 패턴

WRONG:

GET /users/42                                # 50개 필드를 모두 응답에 포함

CORRECT:

GET /users/42?fields=name,email             # 필드 선택 (구현 부담)
# 또는 GraphQL로 전환 검토

REST는 over-fetching이 자연스럽게 발생. 모바일 앱 등 대역폭 제약 환경이면 sparse fieldsets 도입(JSON:API 표준) 또는 GraphQL 전환 검토.

WRONG:

@strawberry.type
class User:
    @strawberry.field
    async def posts(self) -> list[Post]:
        return await db.query("SELECT * FROM posts WHERE user_id=$1", self.id)
        # 사용자 100명 쿼리 시 → DB 호출 100번

CORRECT:

from strawberry.dataloader import DataLoader

async def batch_load_posts(user_ids: list[int]) -> list[list[Post]]:
    rows = await db.query("SELECT * FROM posts WHERE user_id = ANY($1)", user_ids)
    grouped = {uid: [] for uid in user_ids}
    for row in rows:
        grouped[row.user_id].append(row)
    return [grouped[uid] for uid in user_ids]

posts_loader = DataLoader(load_fn=batch_load_posts)

@strawberry.type
class User:
    @strawberry.field
    async def posts(self) -> list[Post]:
        return await posts_loader.load(self.id)   # 자동 batch

GraphQL의 N+1은 DataLoader로 해결. 직접 구현하지 않으면 사용자 100명 쿼리가 DB 호출 100번 폭발.

WRONG:

channel = grpc.insecure_channel("api.example.com:50051")    # 운영에 평문

CORRECT:

credentials = grpc.ssl_channel_credentials()
channel = grpc.secure_channel("api.example.com:443", credentials)
# 또는 TLS + token authentication

gRPC는 기본적으로 평문. 운영에서는 반드시 TLS. insecure_channel은 로컬 개발에서만.

WRONG:

@app.post("/agents/qna/run")
def run(req):
    if not req.text:
        return {"error": "text required"}, 200      # 200으로 에러 반환

CORRECT:

from fastapi import HTTPException

@app.post("/agents/qna/run")
def run(req):
    if not req.text:
        raise HTTPException(400, detail="text required")
    return {"text": "답변"}

REST의 강점은 HTTP 상태 코드. 400(잘못된 요청)·401(인증)·404(없음)·500(서버)을 정확히 사용해야 클라이언트·로드밸런서·모니터링이 자연스럽게 처리한다.

11 정리

영역 REST GraphQL gRPC
모델링 자원 + HTTP 메서드 타입 + 쿼리 함수 호출
응답 JSON JSON (쿼리 모양) Protobuf 이진
클라이언트 모든 환경 모든 환경 (라이브러리) gRPC 라이브러리 (브라우저는 gRPC-Web)
캐싱 HTTP 표준 어려움 자체
학습 곡선 낮음 중간 중간~높음
적합 환경 외부 공개 API, 단순 CRUD 모바일·다양한 필드 조합 내부 마이크로서비스, 성능 critical
MINERVA 적용 선택됨 검토 X 검토 X

12 응용 분야

시나리오 추천
외부 공개 API (Stripe, GitHub 등) REST
모바일 앱 BFF (Backend For Frontend) GraphQL
마이크로서비스 간 내부 통신 gRPC
LLM Agent (단순 CRUD + 스트리밍) REST + SSE (MINERVA 패턴)
실시간 시세 push REST + SSE 또는 WebSocket
게임 서버 (저지연 양방향) gRPC bidi stream 또는 WebSocket

13 관련 주제

선행 학습

MINERVA 시리즈 응용

Subscribe

Enjoy this blog? Get notified of new posts by email: