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")# 단일 엔드포인트 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 — 두 번 호출 (또는 합친 엔드포인트)
또는 합친 엔드포인트:
6.2 GraphQL — 한 번에 필요한 필드만
응답이 정확히 쿼리 모양 그대로:
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 자주 발생하는 오류 패턴
GET /users/42 # 50개 필드를 모두 응답에 포함
CORRECT:
GET /users/42?fields=name,email # 필드 선택 (구현 부담)
# 또는 GraphQL로 전환 검토
REST는 over-fetching이 자연스럽게 발생. 모바일 앱 등 대역폭 제약 환경이면 sparse fieldsets 도입(JSON:API 표준) 또는 GraphQL 전환 검토.
@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) # 자동 batchGraphQL의 N+1은 DataLoader로 해결. 직접 구현하지 않으면 사용자 100명 쿼리가 DB 호출 100번 폭발.
CORRECT:
credentials = grpc.ssl_channel_credentials()
channel = grpc.secure_channel("api.example.com:443", credentials)
# 또는 TLS + token authenticationgRPC는 기본적으로 평문. 운영에서는 반드시 TLS. insecure_channel은 로컬 개발에서만.
@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 관련 주제
선행 학습
- API 기초 – REST·HTTP·JSON 깊이
- WebSocket vs SSE – 실시간 통신의 다른 차원
MINERVA 시리즈 응용
- MINERVA FastAPI 서빙 (04) – REST + SSE 조합
- MINERVA BaseAgent 계약 (02-0) – Pydantic 모델 = REST 응답 schema
- MINERVA A/B 실험 (06) – monitoring/ab REST 엔드포인트