gRPC — 앞 글에서 비판한 구조와 닮았는데 왜 정당한가
앞 글에서 모든 요청을 POST /api 하나로 보내는 Level 0 설계를 비판했다. 요청의 내용이 바디 안에 숨어 있어 중간 참여자 전부가 아무 판단도 못 하게 되고, 그래서 **"HTTP를 쓰는 것이 아니라 HTTP를 터널로 쓰는 것"**이라고 썼다.
그런데 gRPC의 실제 요청은 이렇게 생겼다.
POST /user.UserService/GetUser HTTP/2
content-type: application/grpc+proto
te: trailers
(바이너리 바디)
- 메서드는 POST 하나뿐이다. 조회든 삭제든 전부 POST다
- 경로는 리소스가 아니라 함수 이름이다.
/user.UserService/GetUser - 바디는 사람이 읽을 수 없다. 브라우저 개발자도구로 열어도 바이트 덩어리다
- 캐시되지 않고, 브라우저에서 직접 호출할 수도 없다
겉모습이 Level 0과 거의 같다. 그런데 gRPC는 널리 쓰이고, 그 선택이 잘못됐다는 이야기는 잘 나오지 않는다.
그러면 왜 이것은 정당한가?
이 질문에 답하는 것이 이 글의 목적이다. 답은 두 갈래다.
- Level 0의 진짜 문제는 POST를 쓴 것이 아니라 약속이 아무 데도 없다는 것이었다. gRPC는 그 빈자리를 스키마로 채운다
- REST의 이점들은 브라우저·공개 API·캐시를 전제한다. 그것들이 없는 환경에서는 포기할 것이 없으므로, 대가가 대가가 아니다
순서는 이렇다 — 무엇을 중심에 둔 방식인지 보고(정의) → 스키마와 HTTP/2로 무엇을 얻고 무엇을 치르는지 따지고(이유) → 어느 경계에 무엇을 둘지 판단한다.
앞 글의 Level 0 비판과 중간 참여자 목록은 REST API — 제약 조건과 RESTful 설계의 의미에 있다. 이 글은 그 논의를 전제로 시작한다.
1. 정의 — 무엇을 중심에 두는가
1-1. 리소스 중심과 동작 중심
둘의 차이는 "무엇을 먼저 놓는가"에서 갈린다.
| REST | RPC | |
|---|---|---|
| 중심에 두는 것 | 리소스 (명사) | 동작 (동사) |
| 무엇을 먼저 정하는가 | /users/123이라는 것이 있다 | GetUser라는 함수가 있다 |
| 동작의 종류 | 정해진 몇 개 (GET·POST·PUT·DELETE…) | 제한 없음 |
| 새 기능을 추가하면 | 리소스가 늘거나 표현이 바뀐다 | 함수가 하나 늘어난다 |
REST에서 동작의 종류가 제한돼 있다는 것은 불편이 아니라 핵심이다. 앞 글에서 본 것처럼, 종류가 몇 개뿐이고 각각의 의미가 표준으로 정해져 있어서 중간 참여자들이 그 의미를 안다. 캐시할지 재시도할지 판단할 수 있는 이유가 이 제한에 있다.
RPC는 반대다. 함수는 몇 개든 만들 수 있고 이름도 마음대로 짓는다. 대신 바깥에서는 그 이름이 무슨 뜻인지 알 수 없다. TransferMoney가 안전한지, 여러 번 불러도 되는지, 캐시해도 되는지를 프록시가 알 방법이 없다.
RPC 자체는 1980년대부터 있던 오래된 발상이고, 목표는 늘 같았다.
네트워크 호출을 로컬 함수 호출처럼 보이게 하는 것.
직렬화도, 전송도, 에러 처리도 숨겨진다. 개발자는 그냥 함수를 부른다. 네트워크가 있다는 사실이 코드에서 사라지는 것이 이 방식의 지향점이다.
1-2. gRPC는 세 가지의 조합이다
gRPC는 RPC라는 오래된 발상에 두 가지를 결합한 것이다.
| 구성 요소 | 역할 |
|---|---|
| RPC | 원격 함수 호출이라는 인터페이스 모델 |
| Protocol Buffers | 인터페이스를 정의하는 스키마 + 바이너리 직렬화 |
| HTTP/2 | 스트림 위에서 메시지를 여러 번 주고받는 전송 |
세 요소가 각각 다른 것을 담당한다. 이 글의 2장은 가운데 둘이 무엇을 주고 무엇을 가져가는지를 따지는 부분이다.
1-3. 그래서 Level 0과 무엇이 다른가
이제 처음의 질문으로 돌아간다. 두 요청의 겉모습이 닮았다면, 실제로 다른 것은 무엇인가.
Level 0에서 이런 요청을 보낸다고 하자.
POST /api HTTP/1.1
Content-Type: application/json
{"action":"getUser","userId":123}
여기서 물어보자. action에 어떤 값이 올 수 있는가? 각각은 무슨 필드를 요구하는가? userId는 숫자인가 문자열인가?
답은 문서(그런 게 있다면)와 사람의 기억뿐이다. 오타를 내도 컴파일은 통과하고, 서버가 필드 이름을 바꿔도 클라이언트는 모른다. 어긋난 것을 알게 되는 시점은 런타임이고, 대개는 사용자가 먼저 발견한다.
gRPC는 그 자리를 .proto 파일이 채운다.
syntax = "proto3";
package user;
service UserService {
rpc GetUser (GetUserRequest) returns (User);
rpc WatchUserEvents (WatchRequest) returns (stream UserEvent);
}
message GetUserRequest {
int64 user_id = 1;
}
message User {
int64 id = 1;
string name = 2;
string email = 3;
}
이 파일 하나가 서버와 클라이언트 양쪽의 코드를 생성한다. 서버는 GetUser(GetUserRequest) -> User라는 인터페이스를 구현하지 않으면 컴파일되지 않고, 클라이언트는 그 타입이 아닌 것을 넘길 수 없다. 어긋남이 런타임이 아니라 빌드 시점에 잡힌다.
정리하면 이렇게 된다.
REST는 HTTP라는 공용 규약에 기대고, gRPC는 스키마라는 전용 규약을 직접 만든다. 둘 다 규약이 있고, Level 0만 규약이 없었다.
앞 글에서 Level 0을 비판한 이유는 POST를 써서가 아니라 아무도 읽을 수 없는 곳에 계약을 두었기 때문이다. gRPC도 중간 참여자는 읽지 못하지만, 양 끝단은 그 계약을 코드 수준에서 공유한다.
여기에 한 겹이 더 있다.
HTTP 규약은 런타임에 확인되고(잘못 보내면 400), 스키마 규약은 컴파일 타임에 강제된다.
같은 "계약"이라도 어긋남을 발견하는 시점이 다르다. 이 차이가 gRPC가 주는 것의 핵심이고, 동시에 2-1에서 볼 대가의 출처이기도 하다.
1-4. 에러도 자체 체계를 갖는다
HTTP를 터널로 쓴다는 성격은 에러 처리에서 다시 드러난다. gRPC는 실패를 HTTP 상태 코드가 아니라 자체 코드 체계로 표현한다.
| gRPC 코드 | 대략 대응되는 HTTP |
|---|---|
NOT_FOUND | 404 |
INVALID_ARGUMENT | 400 |
PERMISSION_DENIED | 403 |
UNAUTHENTICATED | 401 |
UNAVAILABLE | 503 |
DEADLINE_EXCEEDED | 504 |
호출이 실패해도 **HTTP 층에서는 대개 200 OK**이고, 실제 결과는 응답 뒤에 붙는 트레일러의 grpc-status에 담긴다.
이건 이상한 설계가 아니라 일관된 귀결이다. HTTP를 터널로 쓰기로 했으면 상태 코드도 그 터널 안에서 따로 가져야 한다. 다만 대가가 따라온다. HTTP 상태 코드 글에서 정리한 것처럼 4xx/5xx는 재시도 여부의 판단 기준이었는데, 바깥에서 보면 전부 200이므로 그 판단을 중간 참여자가 할 수 없다. 재시도 정책은 gRPC 클라이언트 라이브러리 안으로 들어간다.
2. 이유 — 무엇을 얻고 무엇을 치르는가
2-1. 스키마가 주는 것과 그 대가
얻는 것은 앞에서 본 대로다.
- 컴파일 타임 계약 — 필드 이름 오타, 타입 불일치, 빠뜨린 필드가 빌드에서 잡힌다
- 코드 생성 — 요청/응답 타입, 클라이언트 스텁, 서버 인터페이스가 자동으로 만들어진다. 직렬화 코드를 손으로 쓰지 않는다
- 문서와 코드가 갈라지지 않는다 —
.proto가 곧 문서이고 곧 구현이다
그러면 대가는 무엇인가. "스키마를 관리해야 해서 귀찮다" 정도로 생각하면 실제 문제를 놓친다. 진짜 대가는 배포 순서에서 나온다.
서버와 클라이언트를 정확히 같은 순간에 배포하는 것은 불가능하다.
필드를 하나 추가한다고 하자. 서버를 먼저 올리든 클라이언트를 먼저 올리든, 한쪽은 새 스키마이고 다른 쪽은 아직 옛 스키마인 구간이 반드시 생긴다. 그리고 클라이언트가 모바일 앱이라면 그 구간은 몇 분이 아니라 몇 달이다. 사용자가 앱을 업데이트하지 않으면 옛 스키마로 계속 호출한다.
그래서 Protocol Buffers는 하위 호환을 전제로 설계돼 있다. 그 장치가 필드 번호다.
message User {
int64 id = 1;
string name = 2;
string email = 3;
}
직렬화될 때 필드 이름은 나가지 않는다. 번호만 나간다. name 대신 2가 실린다. 이 성질에서 진화 규칙이 따라 나온다.
| 변경 | 안전한가 | 이유 |
|---|---|---|
| 필드 추가 | ✅ | 옛 클라이언트는 모르는 번호를 그냥 무시한다 |
| 필드 이름 변경 | ✅ | 번호가 같으면 같은 필드다. 이름은 코드 생성에만 쓰인다 |
| 필드 번호 변경 | ❌ | 옛 쪽이 그 번호를 다른 의미로 읽는다 |
| 필드 번호 재사용 | ❌ | 삭제된 필드의 번호를 새 필드에 붙이면 옛 데이터가 엉뚱하게 해석된다 |
| 필드 삭제 | ⚠️ | reserved로 번호를 막아둬야 나중에 누가 재사용하지 않는다 |
message User {
reserved 3; // email을 지웠다면 번호를 막아둔다
reserved "email";
int64 id = 1;
string name = 2;
string nickname = 4; // 3이 아니라 새 번호를 쓴다
}
여기서 대가의 정체가 드러난다.
"양쪽을 같이 고쳐야 한다"는 부담이 사라진 것이 아니라, "스키마 진화 규칙을 지킨다"는 부담으로 바뀐 것이다.
규칙을 지키면 배포 순서에 상관없이 안전하다. 지키지 않으면 조용히 잘못된 값이 읽힌다. 그리고 이 문제는 gRPC만의 것이 아니다.
같은 문제가 REST에도 똑같이 있다. 응답 필드를 지우거나 이름을 바꾸면 옛 클라이언트가 깨진다. 다만 REST는 계약이 명시적이지 않아서 깨진 것을 런타임에야 안다. gRPC는 계약이 파일로 있으니 규칙도 명시적이지만, 그만큼 규칙을 지킬 책임도 명시적이다.
나머지 대가들
- 디버깅이 어렵다 — 바이너리라
curl로 찔러볼 수 없고 브라우저 개발자도구로 내용을 볼 수 없다.grpcurl같은 전용 도구와 리플렉션 설정이 필요하다 - 중간 참여자가 개입하지 못한다 — 앞 글에서 나열한 것 그대로다. 캐시도, 경로별 라우팅도, 엔드포인트별 응답 시간 집계도 어렵다
- 빌드 파이프라인이 무거워진다 — 코드 생성 단계가 붙고,
.proto파일을 여러 저장소가 공유하므로 그 파일 자체의 버저닝과 배포 체계가 필요해진다
2-2. HTTP/2가 가능하게 한 것 — 스트리밍
오해 정정 ① — 멀티플렉싱은 요청을 하나로 합치는 것이 아니다
HTTP/2 이야기에서 자주 어긋나는 지점이다. 멀티플렉싱은 여러 요청을 하나로 묶어 보내는 것이 아니라, 각자 독립적인 스트림으로 뒤섞어 보내는 것이다. 요청 3개는 여전히 요청 3개이고, 서버도 3개로 처리한다.
그래서 gRPC가 HTTP/2에서 얻은 것은 "요청이 합쳐져서 빨라졌다"가 아니다. 다른 쪽이다.
HTTP/1.1은 "요청 하나에 응답 하나"가 전제였다
이건 구현의 한계가 아니라 프로토콜 차원의 전제였다. 요청을 보내면 응답 하나가 오고, 그것으로 그 쌍은 끝난다.
HTTP/2의 스트림은 이 전제를 깬다. 스트림 하나가 열려 있는 동안 메시지가 여러 번 오갈 수 있다. gRPC의 네 가지 호출 방식이 여기서 나온다.
| 방식 | 모양 | 예 |
|---|---|---|
| Unary | 1 : 1 | 사용자 조회. REST와 같은 모양 |
| 서버 스트리밍 | 1 : N | 대량 조회 결과를 흘려보내기 |
| 클라이언트 스트리밍 | N : 1 | 로그·측정값을 계속 올리고 마지막에 결과 하나 |
| 양방향 스트리밍 | N : N (동시에) | 채팅, 실시간 협업 |
서버 스트리밍이 값을 하는 지점
결과가 10만 건이라고 하자. REST에서는 페이지네이션으로 나눠서 여러 번 왕복해야 한다. 매 왕복마다 요청·인증·연결 오버헤드가 붙고, 클라이언트는 전부 받을 때까지 아무것도 시작하지 못하거나 페이지 단위로만 처리한다.
gRPC는 연결 하나로 계속 흘려보내고, 클라이언트는 도착하는 대로 처리한다. 첫 결과를 받는 시점이 훨씬 빠르고, 전체를 메모리에 쌓아둘 필요도 없다.
양방향이 푸는 제약
HTTP를 다룬 글들에서 계속 짚어온 제약이 하나 있다. 항상 클라이언트가 먼저 말해야 한다는 것. 서버는 자기가 하고 싶을 때 말을 걸 수 없고, 클라이언트가 물어봐야만 답할 수 있다.
양방향 스트리밍에서는 이 제약이 풀린다. 스트림이 열려 있는 동안 양쪽이 아무 때나 메시지를 보낼 수 있다.
같은 제약을 웹에서 푸는 다른 방법들(폴링·SSE·WebSocket)과 그 선택 기준은 폴링 vs 롱폴링 vs SSE vs WebSocket에서 다룬다. HTTP/2의 스트림 자체는 HTTP 버전별 차이에 있다.
WebSocket과의 차이는 한 줄로 정리된다.
WebSocket은 바이트를 주고받고 그 안의 형식은 각자 정해야 하지만, gRPC의 스트리밍은 타입이 붙어 있다.
stream UserEvent라고 선언하면 양쪽 코드가 그 타입으로 생성된다. 메시지 형식을 따로 합의하고 파싱 코드를 손으로 쓰는 단계가 사라진다.
2-3. 직렬화 효율 — 다만 과장하지 않는다
JSON은 필드 이름을 매번 문자열로 실어 나른다.
[
{ "id": 1, "name": "keyboard", "price": 39000 },
{ "id": 2, "name": "mouse", "price": 19000 }
]
응답이 10만 건이면 "name"이라는 문자열이 10만 번 반복된다. Protobuf는 이름 대신 번호를 보내므로 그 반복이 없다.
파싱 방식도 다르다. JSON은 문자열을 처음부터 훑으며 토큰을 나누고 구조를 만들어야 하지만, Protobuf는 바이트 위치를 계산해 필요한 필드를 바로 읽는다.
여기서 반드시 단서를 달아야 한다.
오해 정정 ② — "gRPC는 빠르다"를 무조건적 장점으로 옮기지 않는다
대부분의 웹 서비스에서 직렬화는 병목이 아니다.
요청 하나에 DB 쿼리가 50ms 걸리고 외부 API 호출이 200ms 걸리는데, 직렬화에서 1ms를 아끼는 것은 아무것도 바꾸지 못한다. 그런 서비스에서 "빠르다"를 이유로 gRPC를 고르면, 얻는 것은 측정되지 않고 치르는 값(디버깅 난이도, 빌드 복잡도, 브라우저 제약)만 남는다.
이 이점이 실제로 값을 하는 곳은 따로 있다.
- 초당 수만 건이 오가는 내부 서비스 간 통신 — 여기서는 1ms가 곱해져서 의미 있는 숫자가 된다
- 메시지가 크거나 매우 잦은 스트리밍 — 누적되면 대역폭 차이가 실제로 보인다
- 모바일 환경의 대역폭 제약 — 페이로드 크기가 사용자 경험에 직접 닿는다
성능은 조건부 이점이지 기본 이점이 아니다.
3. 판단 — 어느 경계에 무엇을 두는가
3-1. 브라우저 경계가 결정적이다
오해 정정 ③ — gRPC는 브라우저에서 그냥 쓸 수 없다
이유는 두 가지다.
- gRPC는 응답 본문 뒤에 붙는 트레일러 헤더를 쓴다. 1-4에서 본
grpc-status가 거기 담긴다. 그런데 브라우저의fetch는 트레일러를 노출하지 않는다 - 스트림을 직접 열고 닫고 흐름을 제어해야 하는데, 브라우저는 그 수준의 HTTP/2 제어를 JavaScript에 주지 않는다
그래서 결론은 분명하다.
순수한 gRPC가 브라우저까지 그대로 오는 경우는 없다. 경계에서 반드시 무언가가 바뀐다.
바뀌는 방식이 둘 있다.
① grpc-web + 프록시
브라우저용으로 축소한 프로토콜을 쓰고, 중간에 프록시(Envoy 등)가 그것을 진짜 gRPC로 번역한다. 프록시의 grpc_web 필터가 그 번역기 역할을 한다.
제약이 분명하다.
- 클라이언트 스트리밍과 양방향 스트리밍은 지원되지 않는다. Unary와 서버 스트리밍만 된다
- 프록시가 반드시 필요하다. 인프라 구성 요소가 하나 늘어난다
- 생성된 코드가 클래스 형태라 평범한 객체처럼 다루지 못한다
// grpc-web — 생성된 클래스의 getter로 접근한다
const req = new GetUserRequest();
req.setUserId(123);
client.getUser(req, {}, (err, res) => {
if (err) return console.error(err.code, err.message);
console.log(res.getName(), res.getEmail()); // res.name 이 아니다
});
② Connect-RPC
같은 .proto를 쓰되 전송 방식을 여러 개 지원하는 방향이다. 브라우저 친화적인 자체 프로토콜은 평범한 HTTP POST에 JSON으로 나간다.
curl로 찔러볼 수 있고 개발자도구에서 내용이 보인다- 프록시가 필요 없다
- 응답이 평범한 객체다
// Connect — 평범한 객체와 async iteration
const res = await client.getUser({ userId: 123n });
console.log(res.name, res.email);
// 서버 스트리밍도 for await로 순회한다
for await (const event of client.watchUserEvents({ userId: 123n })) {
console.log(event.type, event.occurredAt);
}
두 코드의 차이가 곧 두 접근의 차이다. .proto로 계약을 관리한다는 이점은 유지하면서, 브라우저 쪽에서는 평범한 HTTP처럼 보이게 만드는 것이 최근 실무에서 더 자주 택하는 방향이다.
3-2. 서버 쪽 코드는 어떻게 생겼는가
경계 이야기를 하기 전에, 안쪽이 어떤 모습인지 짧게 본다.
서버 — Unary와 서버 스트리밍
import * as grpc from '@grpc/grpc-js';
const userService = {
// Unary — 요청 하나에 응답 하나
async getUser(call, callback) {
const user = await db.users.findById(call.request.userId);
if (!user) {
// HTTP 404가 아니라 gRPC 자체 코드로 실패를 알린다
return callback({ code: grpc.status.NOT_FOUND, message: 'user not found' });
}
callback(null, { id: user.id, name: user.name, email: user.email });
},
// 서버 스트리밍 — 응답을 여러 번 나눠 흘려보낸다
watchUserEvents(call) {
const unsubscribe = eventBus.subscribe(call.request.userId, (event) => {
call.write({ type: event.type, occurredAt: event.at }); // 여러 번 호출한다
});
// 클라이언트가 끊으면 반드시 정리한다. 안 하면 구독이 새어 나간다
call.on('cancel', () => {
unsubscribe();
});
},
};
const server = new grpc.Server();
server.addService(userProto.UserService.service, userService);
server.bindAsync('0.0.0.0:50051', grpc.ServerCredentials.createInsecure(), () => {});
서버 대 서버 클라이언트
const client = new userProto.UserService(
'user-service:50051',
grpc.credentials.createInsecure(),
);
// Unary — 로컬 함수처럼 보이는 것이 RPC의 지향점이다
const user = await promisify(client.getUser.bind(client))({ userId: 123 });
// 서버 스트리밍 — 도착하는 대로 처리한다
const stream = client.watchUserEvents({ userId: 123 });
stream.on('data', (event) => handle(event));
stream.on('error', (err) => {
if (err.code === grpc.status.UNAVAILABLE) scheduleReconnect();
});
user-service:50051처럼 서비스 이름으로 호출하는 것이 이 방식이 놓이는 자리를 보여준다. 브라우저도 CDN도 없는, 안쪽 통신이다.
3-3. 그래서 무엇을 기준으로 고르는가
| 질문 | REST 쪽 | gRPC 쪽 |
|---|---|---|
| 클라이언트가 누구인가 | 브라우저, 서드파티 | 내가 통제하는 서비스 |
| 캐시 효과가 큰 읽기가 많은가 | 많다 | 별로 없다 |
| 호출량은 | 보통 | 초당 수만 건 |
| 통신 모양은 | 요청-응답이면 충분하다 | 스트리밍이 필요하다 |
| 계약은 어떻게 공유하는가 | 문서로 소통한다 | 양쪽 다 우리 팀이라 코드로 공유할 수 있다 |
| 깨진 것을 언제 알고 싶은가 | 런타임이어도 감수한다 | 빌드 시점에 잡고 싶다 |
표를 관통하는 문장은 이것이다.
앞 글에서 나열한 REST의 이점들 — CDN 캐시, 브라우저 지원,
curl로 찔러보기, 경로별 라우팅, 엔드포인트별 모니터링 — 은 전부 공개 API와 브라우저 클라이언트를 전제한다.
내부 서비스 간 통신에는 CDN도 없고 브라우저도 없고 서드파티도 없다. 그러니 포기할 것이 없고, 따라서 대가가 대가가 아니다. 도입부의 질문에 대한 답이 이것이다. gRPC가 Level 0과 겉모습이 같아도 정당한 이유는, Level 0이 잃은 것들을 애초에 갖고 있지 않은 환경에서 쓰이기 때문이다.
반대로 말하면 그 환경을 벗어나면 같은 선택이 그대로 손해가 된다. 공개 API를 gRPC로 내면 앞 글에서 본 손실을 그대로 떠안는다.
3-4. 오해 정정 ④ — 양자택일이 아니다
현실의 배치는 대개 이렇게 생겼다.
브라우저 ──REST/JSON──▶ [API 게이트웨이] ──gRPC──▶ [서비스 A] ──gRPC──▶ [서비스 B]
│
└── 캐시·인증·라우팅은 여기까지
바깥은 REST, 안은 gRPC. 경계에서 형식이 바뀐다.
이 그림이 이 글의 결론이다. 둘 중 하나를 고르는 문제가 아니라 어디에 무엇을 쓸 것인가의 문제다. 그리고 그 판단의 기준은 매번 같다.
이 구간에 중간 참여자가 있는가. 있다면 그들이 읽을 수 있어야 하고, 없다면 읽히지 않아도 된다.
또 하나의 선택지인 GraphQL은 다음 글에서 다룬다.
3-5. 정리
이 글을 다섯 줄로 압축하면 이렇다.
- gRPC의 요청은 Level 0과 겉모습이 닮았지만, 계약이 있는 자리가 다르다. Level 0은 문서와 기억, gRPC는
.proto와 생성된 코드. - HTTP 규약은 런타임에 확인되고 스키마 규약은 컴파일 타임에 강제된다. 어긋남을 발견하는 시점이 다른 것이 가장 큰 차이다.
- 스키마의 대가는 귀찮음이 아니라 배포 순서 문제다. 그래서 필드 번호와 진화 규칙이 있고, 같은 문제는 REST에도 있다.
- HTTP/2가 준 것은 "빨라짐"이 아니라 요청 하나에 응답 하나라는 전제를 깬 것이다. 네 가지 호출 방식이 거기서 나온다.
- 성능은 조건부 이점이고, 브라우저 경계에서는 반드시 무언가가 바뀐다. 바깥은 REST, 안은 gRPC가 현실적인 배치다.
처음의 질문에 답하면 이렇게 된다.
gRPC가 Level 0과 닮았는데도 정당한 이유는, Level 0의 문제가 POST를 쓴 것이 아니라 계약이 아무 데도 없었던 것이기 때문이다. gRPC는 그 자리를 스키마로 채웠고, 그러고도 남는 손실 — 캐시·브라우저·관측 — 은 애초에 그것들이 없는 구간에서 쓰이므로 손실이 되지 않는다.
관련 글
- REST API — 제약 조건과 RESTful 설계의 의미 — Level 0 비판과 중간 참여자 목록. 이 글의 전제.
- HTTP 상태 코드 — gRPC가 왜 자체 에러 코드 체계를 갖게 되는가.
- HTTP 메서드 — 안전성, 멱등성, 그리고 중간 참여자들과의 약속 — 모든 호출이 POST일 때 무엇이 사라지는가.
- HTTP 버전별 차이 — 스트림과 멀티플렉싱의 원문.
- 폴링 vs 롱폴링 vs SSE vs WebSocket — "서버가 먼저 말을 걸 수 없다"는 제약을 웹에서 푸는 방법들.
- 웹 서버와 WAS — 게이트웨이가 놓이는 자리.
- GraphQL — 또 다른 선택지.