목차
지금까지 본 방식들은 전부 “읽는 쪽이 브라우저다”라는 전제 위에 있었다. 그 전제가 없는 자리를 본다.
필드 이름을 초당 수만 번 다시 보낸다
주문 서비스가 재고 서비스를 부른다. 둘 다 우리가 운영하는 서버이고, 호출은 초당 3만 번쯤 일어난다. 요청 본문은 이렇다.
{"orderId":"1024","itemId":"77","quantity":2}여기서 실제 정보는 1024, 77, 2 세 개다. orderId·itemId·quantity는 매번 똑같은 글자다. 양쪽 서버가 이미 아는 이름을, 요청 3만 개에 3만 번 실어 보낸다.
받는 쪽 일도 만만치 않다. 중괄호를 찾고, 따옴표를 세고, "1024"라는 글자를 숫자로 옮긴다. 이것도 초당 3만 번이다.
브라우저가 읽는 API라면 이건 치를 만한 값이다. JSON 편에서 본 대로 눈으로 읽힌다는 게 JSON의 최대 강점이고, 그 강점이 표준 자리를 가져왔다. 그런데 양쪽 다 우리 서버인 자리에서는 아무도 그 본문을 눈으로 안 읽는다. 강점은 안 쓰고 비용만 낸다.
같은 양식을 미리 나눠 갖는다
관공서 창구를 떠올려 보자. 민원을 자유 형식 편지로 써서 내면 접수원이 매번 읽고 해석해야 한다. 그래서 관공서는 양식 용지를 쓴다. 칸이 정해져 있고, 칸마다 번호가 찍혀 있고, 창구와 민원인이 같은 양식을 들고 있다.
gRPC가 하는 게 정확히 이거다. 주고받을 내용의 양식을 파일 하나에 미리 적어두고 서버와 클라이언트가 그 파일을 나눠 갖는다. 그 파일이 .proto다.
message ReserveRequest {
string order_id = 1;
string item_id = 2;
int32 quantity = 3;
}여기서 = 1, = 2, = 3은 값이 아니다. 칸 번호다. “이 양식에서 1번 칸은 주문 ID이고 3번 칸은 수량이다”라는 뜻이다.
JSON에는 이 파일에 해당하는 게 없다. 스키마가 없다는 대가에서 계약이 형식 밖으로 밀려난다고 했는데, gRPC는 반대 방향으로 간다. 계약을 형식 안으로 끌고 들어온다.
칸 번호만 보내면 이름은 필요 없다
양쪽이 같은 양식을 들고 있으면 “3번 칸에 2”라고만 해도 통한다. 칸 이름은 이미 양식에 인쇄되어 있으니 선 위로 나갈 이유가 없다.
같은 내용인데 위 줄은 대부분이 이름이다.
양식을 미리 나눠 가지면 그 자리가 통째로 사라지고
값과 칸 번호만 남는다.
줄어드는 건 크기만이 아니다. 셋이 같이 움직인다.
- 실어 나르는 양 - 반복되는 이름이 빠진다
- 파싱 비용 - 글자를 스캔해 구조를 알아내는 대신, 길이가 적힌 조각을 순서대로 읽는다
- 지연 - 위 둘이 줄면 호출 하나가 끝나는 시간도 준다
크기 차이는 흔히 말하는 것보다 좁다. HTTP 응답에 gzip을 걸면 반복되는 키가 아주 잘 줄어들기 때문이다. 대신 압축과 해제에 CPU가 붙고, 파싱 비용은 압축으로 안 줄어든다. 그래서 크기만 근거로 gRPC를 고르는 건 대체로 약한 판단이다. 뒤에 나올 코드 생성과 스트리밍이 실제로는 더 큰 이유다.
함수를 부르는 것처럼 보이게 한다
이름의 RPC는 Remote Procedure Call, 원격 프로시저 호출이다. 여기가 REST와 갈리는 지점이다.
REST는 자원으로 말한다. POST /orders/1024/reservations처럼 무엇을 가리키는지를 URL이 적고, 무엇을 할지는 메서드가 말한다. gRPC는 그러지 않는다. 함수를 부른다. 양식 옆에 “이 양식으로 받는 창구”를 같이 적는다.
service Inventory {
rpc Reserve(ReserveRequest) returns (ReserveReply);
}부르는 쪽 코드는 이렇게 된다.
ReserveReply reply = inventory.reserve(request);HTTP 클라이언트를 만들지도, URL을 조립하지도, 상태 코드를 보고 분기하지도 않는다. 그냥 메서드 호출 한 줄이다.
편한 만큼 위험하다. 이 한 줄은 함수처럼 생겼지만 함수가 아니다. 네트워크를 건너므로 느리고, 실패하고, 중간에 끊긴다. 로컬 호출과 똑같이 생긴 탓에 타임아웃도 재시도도 안 정한 채 넘어가기 쉽다. 호출이 원격이라는 사실을 코드가 숨겨 준다는 게 gRPC의 편의이자 함정이다.
계약이 코드로 나온다
.proto 파일 하나를 컴파일러(protoc)에 넣으면 양쪽에서 쓸 코드가 나온다. 서버는 구현할 인터페이스를, 클라이언트는 호출할 스텁을 받는다.
quantity: intquantity: Stringquantity: intquantity: int
달라지는 건 위 두 층뿐이다. 한쪽은 위가 비어 있어 아래 둘이 따로 놀고,
다른 쪽은 한 장에서 두 갈래가 내려와 어긋날 자리가 없다.
이게 왜 큰가. 계약에 들어 있는 것에서 계약은 어딘가에 적혀 있어야 한다고 했고, JSON 편에서 그 자리는 응답 DTO였다. 다만 그건 손으로 맞춘 것이다. 서버 쪽 DTO와 클라이언트 쪽 모델을 각자 사람이 적고, 둘이 어긋나면 실행해 봐야 안다.
gRPC에서는 둘이 같은 파일에서 나온다. 서버가 필드 타입을 바꾸고 양식을 다시 배포하면 클라이언트는 컴파일이 안 된다. 어긋남을 런타임이 아니라 빌드에서 안다.
양식 비유가 여기서 한 걸음 더 간다. 창구 직원이 양식을 보고 “3번 칸에는 숫자만”이라고 외우는 게 아니라, 양식 자체가 숫자 칸이라 다른 걸 적을 수가 없다.
연결 하나로 여러 대화를 흘린다
gRPC는 HTTP/2 위에서만 돈다. 왜 하필 그 버전인지는 HTTP/2는 한 연결에 여러 대화를 섞는다에서 이미 다뤘으니 다시 설명하지 않는다. 여기서 필요한 결과는 하나다. 한 연결 안에서 여러 대화가 동시에, 그리고 오래 흐를 수 있다.
그 덕에 gRPC는 “요청 하나 응답 하나” 말고 네 가지 꼴을 갖는다.
네 칸의 틀은 전부 같다. 달라지는 건 화살표의 개수와 방향뿐이고,
그게 곧 이 네 가지를 가르는 전부다.
| 꼴 | 언제 쓰나 |
|---|---|
| 단항 | 보통의 호출. 앞에서 본 재고 예약이 이 꼴이다 |
| 서버 스트리밍 | 서버가 결과를 나눠서 계속 보낸다. 긴 목록, 진행 상황 알림 |
| 클라이언트 스트리밍 | 클라이언트가 조각을 계속 올린다. 업로드, 지표 수집 |
| 양방향 스트리밍 | 둘이 각자 속도로 주고받는다. 채팅, 실시간 동기화 |
REST로 이걸 하려면 폴링을 돌리거나 WebSocket 같은 다른 기술을 하나 더 얹어야 한다. gRPC에서는 .proto에 stream 한 단어를 붙이는 게 전부다.
여기서 양식 비유는 안 맞는다. 양식 용지는 한 장을 채워 창구에 내는 물건이라, 양쪽이 동시에 계속 주고받는 상태를 설명하지 못한다. 스트리밍은 같은 양식을 나눠 가진 두 사람이 전화를 켜 놓은 쪽에 가깝다. 비유가 여기까지라는 걸 알고 그림을 보는 게 낫다.
대가 하나: 사람이 못 읽는다
선 위를 흐르는 게 바이너리다. 열어봐도 글자가 안 보인다.
지금까지 JSON API를 만들면서 무심코 하던 것들이 전부 안 된다.
| 하던 것 | gRPC에서는 |
|---|---|
curl로 찔러 본다 | 전용 도구(grpcurl 등)가 필요하다 |
| 로그에 요청 본문을 찍는다 | 사람이 읽을 형태로 바꾸는 과정을 따로 둬야 한다 |
| 개발자도구 네트워크 탭에서 응답을 본다 | 본문이 안 읽힌다 |
JSON 편의 결론이 여기서 정확히 뒤집힌다. 거기서는 사람이 읽기 쉽다는 게 표준이 된 이유였는데, gRPC는 그걸 내주고 다른 걸 받는다.
대가 둘: 브라우저가 직접 못 부른다
브라우저의 fetch나 XMLHttpRequest는 HTTP/2 프레임을 직접 다룰 수 없다. 그래서 웹 프런트엔드에서 gRPC 서버를 그대로 부르지 못한다. gRPC-Web이라는 별도 규격과 중간 프록시를 둬서 번역해야 하고, 그렇게 해도 클라이언트 스트리밍과 양방향 스트리밍에는 제약이 남는다.
이 한 줄이 gRPC의 자리를 정한다. 브라우저·모바일 앱·외부 파트너가 부르는 공개 API는 REST가 편하고, 서버끼리 부르는 내부 호출은 gRPC가 편하다.
그래서 GraphQL과는 성격이 다르다. 그쪽은 같은 자리를 놓고 REST와 겨루는 후보였다. gRPC는 대개 REST를 대신하는 게 아니라 REST가 안 닿는 안쪽에 들어간다. 둘 중 하나를 고르는 문제가 아니라 어디에 무엇을 둘지의 문제다.
대가 셋: 양식을 함께 관리해야 한다
.proto는 두 팀이 같이 쓰는 파일이다. 그러니 어디에 둘지부터 정해야 한다. 서버 저장소 안에 두면 클라이언트 팀이 복사해 가고 그 복사본이 낡는다. 별도 저장소로 빼면 버전을 매기고 배포해야 하니 관리 대상이 하나 는다.
그리고 규칙 하나가 특히 세다. 한 번 쓴 칸 번호는 다시 쓰지 않는다.
message ReserveRequest {
string order_id = 1;
reserved 2; // 옛 item_id 자리. 비워둔 채 다시 쓰지 않는다
int32 quantity = 3;
string sku = 4; // 새 칸은 새 번호로
}2번 칸을 지우고 나중에 다른 필드에 2번을 주면, 낡은 양식을 든 클라이언트가 그 칸의 값을 옛 뜻으로 읽는다. 에러가 안 난다. 타입만 맞으면 그냥 다른 값으로 해석되어 조용히 흘러간다. 깨는 변경과 안 깨는 변경에서 본 필드 이름 재활용과 같은 사고인데, 이름이 아니라 번호라서 리뷰에서 눈에 덜 띈다.
반대로 칸을 새로 파는 건 안전하다. 옛 양식을 든 쪽은 모르는 번호를 만나면 그냥 넘긴다. JSON 편에서 “모르는 필드를 무시해야 필드 추가가 안전하다”고 했던 그 조건을, 여기서는 형식이 기본으로 보장한다. 설정 한 줄에 달려 있지 않다.
그래서 실무에선 어떻게 쓰이나
바깥은 REST, 안은 gRPC가 흔한 구성이다. 앞단 게이트웨이가 밖에서 JSON과 REST로 받아, 안쪽 서비스에는 gRPC로 갈아타 전달한다. 브라우저와 파트너는 지금까지 본 규칙을 그대로 쓰고, 내부 호출만 형식을 바꾼다.
.proto를 한곳에 모으고 CI에서 호환성을 검사한다. buf 같은 도구가 이전 버전과 비교해서 “지운 칸”, “번호를 재활용한 칸”, “타입이 바뀐 칸”을 빌드에서 잡아낸다. 위에서 본 사고는 사람 눈으로 거르기 어려운 종류라 기계에 맡기는 편이 확실하다.
타임아웃을 호출마다 정하고, 그게 사슬을 타고 전파되게 둔다. gRPC에는 데드라인이라는 개념이 있어서 A가 2초를 걸고 B를 부르면 남은 시간이 B에서 C로 그대로 넘어간다. A가 이미 포기한 요청을 C가 계속 붙들고 있는 낭비가 사라진다. REST에서 손으로 맞추기 번거로웠던 것이라, 실제로 gRPC를 고르는 이유 중 하나가 된다.
에러는 HTTP 상태 코드가 아니라 gRPC 상태 코드로 온다. NOT_FOUND, INVALID_ARGUMENT, DEADLINE_EXCEEDED 같은 십수 개다. 상태 코드 편에서 정한 원칙 자체는 그대로 통한다. 성공인지 부르는 쪽 잘못인지 서버 잘못인지를 코드가 가르고, 상세는 본문에 담는다. 갈리는 건 원칙이 아니라 값의 목록이다.
모니터링을 다시 짜야 한다는 걸 도입 비용에 넣는다. 지금 쓰는 대시보드와 알림이 대부분 5xx 비율 같은 HTTP 상태 코드 위에 서 있다. gRPC로 옮긴 구간은 그 지표가 안 잡힌다. 여기를 안 세면 장애가 났을 때 그래프가 멀쩡해 보이는 상황을 만난다.
정리
| 무슨 문제를 푸나 | 서로 아는 필드 이름을 매 호출마다 다시 보내고 다시 파싱하는 낭비 |
| 어떻게 푸나 | .proto라는 양식을 양쪽이 미리 나눠 갖고, 선에는 칸 번호와 값만 보낸다 |
| 계약 | 형식 밖이 아니라 안에 있다. 양쪽 코드가 같은 파일에서 생성된다 |
| 어긋남을 언제 아나 | 런타임이 아니라 빌드. 클라이언트가 컴파일에서 막힌다 |
| 호출 꼴 | 단항 외에 서버·클라이언트·양방향 스트리밍. HTTP/2를 쓰는 값이 여기 있다 |
| 사람이 읽기 | 못 읽는다. curl·로그·개발자도구를 전용 도구로 갈아야 한다 |
| 브라우저 | 직접 못 부른다. 그래서 공개 API가 아니라 내부 호출의 자리다 |
| 호환성 | 칸 추가는 안전, 칸 번호 재사용은 조용한 사고. reserved로 막는다 |
| REST와의 관계 | 대체가 아니라 배치. 바깥은 REST, 안은 gRPC가 흔하다 |
방식은 늘었지만 제약은 하나로 모인다. 한 번 공개한 것은 되돌리기 어렵다. URL 이름도, JSON 필드도, GraphQL 스키마도, 그리고 방금 본 칸 번호도 전부 누군가 이미 그 모양에 맞춰 코드를 짜뒀다.
다음 글은 그 제약을 정면으로 다룬다. 이미 나간 API를 어떻게 깨지 않고 바꾸는가, 그리고 그래도 갈라야 할 때 무엇을 하는가.