LabHub
배우기 러닝패스 코스

필드 번호를 바꿨더니 옛 클라이언트가 조용히 틀린 값을 읽었다 · 호출 네 가지와 죽는 방법 열여섯 가지 · 이론

호출 네 가지와 죽는 방법 열여섯 가지

LabHub 에서 이어서 보기

한 줄 요약

gRPC 호출은 단항·서버 스트리밍·클라이언트 스트리밍·양방향 네 가지이고, 모든 호출은 열일곱 개 상태 코드 중 하나로 끝난다. 데드라인은 기본이 없어 무한정 기다릴 수 있고, 재시도는 정책을 적어야 비로소 동작한다.

왜 이게 필요했나

앞 두 모듈은 메시지 하나의 바이트를 다뤘다. 그런데 gRPC 의 사고는 메시지 안보다 호출의 경계에서 더 자주 난다 — 응답이 영영 안 오는데 아무도 끊지 않는 호출, 서버는 성공했다는데 클라이언트는 실패로 보는 호출, 같은 결제를 두 번 보내는 재시도. [핵심 개념 문서](https://grpc.io/docs/what-is-grpc/core-concepts/)는 이 경계를 "클라이언트와 서버가 각각 독립적으로 성공 여부를 판단하며, 그 결론이 다를 수 있다" 고 못 박는다. 서버는 "응답을 다 보냈다" 고 결론 내고, 클라이언트는 "데드라인 뒤에 도착했다" 고 결론 낼 수 있다. 이 문장을 받아들이면 나머지 규칙이 전부 따라 나온다.

어떻게 동작하나

호출 네 가지. 서비스 정의는 .protorpc 로 적는다.

service OrderService {  rpc GetOrder (GetOrderRequest) returns (Order);                       // 단항  rpc ListOrders (ListRequest) returns (stream Order);                  // 서버 스트리밍  rpc UploadEvents (stream Event) returns (UploadSummary);              // 클라이언트 스트리밍  rpc Chat (stream ChatMessage) returns (stream ChatMessage);           // 양방향}

단항(unary)은 함수 호출과 같다 — 요청 하나, 응답 하나. 서버 스트리밍은 요청 하나에 응답 여럿을 스트림으로 받고, 클라이언트 스트리밍은 그 반대다. 양방향은 두 스트림이 독립이라 읽고 쓰는 순서를 양쪽이 마음대로 정한다 — 서버가 다 받고 답할 수도, 한 개 받고 한 개 답하는 핑퐁도 된다. 문서가 보장하는 것은 한 호출 안에서의 메시지 순서다. 스트림마다 순서는 지켜지지만 두 스트림 사이의 순서는 없다.

상태 코드. [상태 코드 문서](https://grpc.io/docs/guides/status-codes/)는 0 부터 16 까지 열일곱 개를 정의한다. 라이브러리가 만드는 것과 애플리케이션만 만드는 것이 나뉜다는 점이 중요하다. INVALID_ARGUMENT·NOT_FOUND·ALREADY_EXISTS·FAILED_PRECONDITION·ABORTED·OUT_OF_RANGE·DATA_LOSS 는 라이브러리가 절대 만들지 않는다 — 이 코드를 봤다면 반드시 서버 코드가 돌려준 것이다.

| 코드 | 뜻 | 누가 만드나 |
|---|---|---|
| OK 0 | 성공 | |
| CANCELLED 1 | 호출자가 취소 | 라이브러리·앱 |
| INVALID_ARGUMENT 3 | 시스템 상태와 무관하게 잘못된 인자 | 앱만 |
| DEADLINE_EXCEEDED 4 | 데드라인 안에 못 끝남 — 끝났을 수도 있다 | 라이브러리·앱 |
| NOT_FOUND 5 | 요청한 것이 없음 | 앱만 |
| PERMISSION_DENIED 7 | 권한 없음 (자원 고갈에는 쓰지 않는다) | |
| RESOURCE_EXHAUSTED 8 | 할당량·용량 소진 | |
| FAILED_PRECONDITION 9 | 상태를 고치기 전엔 재시도 금지 | 앱만 |
| ABORTED 10 | 상위 수준에서 재시도(트랜잭션 재시작) | 앱만 |
| UNIMPLEMENTED 12 | 메서드 없음 | |
| INTERNAL 13 | 불변식이 깨짐 — 심각한 오류 전용 | |
| UNAVAILABLE 14 | 일시적, 백오프 후 재시도 가능 — 비멱등 호출은 안전하지 않을 수 있다 | |
| UNAUTHENTICATED 16 | 인증 자격 없음 | |

세 코드의 구분 지침이 문서에 그대로 있다. 이 호출만 다시 하면 되면 UNAVAILABLE, 읽고-고치고-쓰기 순서를 처음부터 다시 해야 하면 ABORTED, 시스템 상태를 사람이 고치기 전까지 재시도해선 안 되면 FAILED_PRECONDITION. rmdir 이 비어 있지 않은 디렉터리에서 실패하는 것이 마지막 경우다. 그리고 DEADLINE_EXCEEDED 설명의 한 문장을 기억해야 한다 — 상태를 바꾸는 작업이라면 작업이 성공적으로 끝났더라도 이 코드가 돌아올 수 있다. 응답이 늦게 도착한 것뿐일 수 있다.

[오류 처리 문서](https://grpc.io/docs/guides/error/)는 라이브러리가 만드는 코드의 상황도 표로 준다. 서버 핸들러가 예외를 던지면 UNKNOWN, 메서드가 없으면 UNIMPLEMENTED, 서버가 내려가는 중이면 UNAVAILABLE, 요청 protobuf 를 파싱하지 못하면 INTERNAL. 표준 오류 모델은 코드와 문자열 메시지뿐이고, 구조화된 상세가 필요하면 google.rpc.Status 를 트레일러에 싣는 확장 모델을 쓴다 — 다만 프록시와 로거는 그 안을 보지 못하고 HTTP/2 헤더 압축 효율이 떨어진다는 비용이 적혀 있다.

데드라인. [데드라인 문서](https://grpc.io/docs/guides/deadlines/)의 첫 규칙은 "gRPC 는 기본 데드라인을 두지 않는다" 다. 클라이언트가 안 정하면 영원히 기다릴 수 있으므로 항상 현실적인 값을 정하라고 한다. 데드라인은 시각(point in time)이고 타임아웃은 기간(duration)인데, 언어마다 API 가 다르지만 뜻은 같다. 데드라인이 지나면 클라이언트는 DEADLINE_EXCEEDED 로 호출을 실패시키고, 서버는 그 호출을 자동으로 취소(CANCELLED)한다. 그런데 서버 애플리케이션이 하던 일을 멈추는 것은 서버 코드의 책임이다 — 라이브러리는 핸들러를 중단시킬 방법이 없으므로, 긴 작업은 주기적으로 취소 여부를 확인해야 한다.

전파가 핵심이다. 내 서버가 다른 서버를 부를 때 원래 클라이언트의 데드라인을 물려줘야 한다. Java 와 Go 는 기본으로 전파하고 C++ 는 켜야 한다고 문서가 적는다. 시각을 그대로 넘기면 두 서버의 시계가 어긋날 수 있으므로, gRPC 는 이미 흐른 시간을 뺀 타임아웃으로 바꿔 넘긴다. 문서의 예시가 그림 그대로다 — 클라이언트가 2초를 줬고 사용자 서버가 0.5초를 쓴 뒤 과금 서버를 부르면, 과금 서버는 1.5초를 받는다.

취소. [취소 문서](https://grpc.io/docs/guides/cancellation/)에 따르면 클라이언트는 언제든 관심을 잃었다고 알릴 수 있고, 데드라인 만료와 I/O 오류도 취소를 일으킨다. 취소는 상류로 전파되는 것이 이상적이라 Java·Go·C++ 는 나가는 호출을 자동으로 취소한다. 핵심 개념 문서의 경고 한 줄 — 취소 전에 이미 바뀐 것은 되돌리지 않는다.

재시도. [재시도 문서](https://grpc.io/docs/guides/retry/)는 오해가 많은 부분을 분명히 한다. 재시도는 기본으로 켜져 있지만 기본 정책은 없다. 정책이 없으면 gRPC 는 "투명 재시도" 만 한다 — 호출이 클라이언트를 떠나지 않았으면 무제한, 서버 라이브러리까지 갔지만 애플리케이션 로직이 보지 않았으면 딱 한 번. 서버가 처리했을 가능성이 있으면 재시도하지 않는다. 응답 헤더를 받는 순간 호출은 확정(committed)되고 더는 재시도하지 않는다.

정책은 서비스 설정에 메서드 단위로 적는다.

"retryPolicy": {  "maxAttempts": 4,  "initialBackoff": "0.1s",  "maxBackoff": "1s",  "backoffMultiplier": 2,  "retryableStatusCodes": ["UNAVAILABLE"]}

백오프에는 ±20% 지터가 붙어 초기 0.1초면 실제로는 80~120ms 사이가 된다. 재시도가 서버를 다시 눕히지 않도록 retryThrottling(maxTokens·tokenRatio)이 있어, 실패마다 토큰이 1 줄고 성공마다 tokenRatio 만큼 늘며 절반 밑으로 떨어지면 재시도를 멈춘다. 그리고 상태 코드 문서의 UNAVAILABLE 설명에 있는 한 줄이 정책 설계의 전부다 — 비멱등 작업은 재시도가 안전하지 않을 수 있다. 결제 생성 같은 호출을 retryableStatusCodes 에 넣으려면 서버가 멱등성 키로 중복을 막고 있어야 한다.

헬스체크와 메타데이터. [헬스체크 문서](https://grpc.io/docs/guides/health-checking/)는 표준 서비스 health/v1 을 정의한다. 단항 Check 는 중앙 모니터링용이고 스트리밍 Watch 는 클라이언트가 붙어 상태 변화를 받는 용도다. 서비스 이름별로 SERVING·NOT_SERVING 을 알리고, 빈 문자열은 서버 전체를 뜻한다. 클라이언트가 healthCheckConfig 를 켜면 Watch 가 건강하다고 하기 전까지 요청을 보내지 않고, 건강하지 않아지면 보내기를 멈춘다. Watch 가 UNIMPLEMENTED 로 실패하면 헬스체크를 끈다. [메타데이터 문서](https://grpc.io/docs/guides/metadata/)는 HTTP/2 헤더로 실리는 키-값 쌍을 설명한다 — 키는 ASCII 이고 대소문자를 구별하지 않으며 grpc- 로 시작할 수 없고, 바이너리 값 키는 -bin 으로 끝난다. 헤더는 첫 메시지 앞에, 트레일러는 서버가 호출을 닫을 때 보낸다.

현장에서 만나는 모습

가장 비싼 사고는 데드라인 없는 호출이다. 하류 서비스가 멈추자 상류의 스레드가 전부 응답을 기다리며 쌓였고, 결국 상류까지 죽었다. gRPC 가 기본 데드라인을 두지 않는다는 문장을 몰랐던 것이 원인이었다. 데드라인을 주고 전파하면, 하류가 늦어도 상류는 정해진 시간 안에 DEADLINE_EXCEEDED 로 돌아온다.

두 번째는 "성공했는데 실패" 다. 주문 생성이 서버에서 커밋됐지만 응답이 데드라인을 넘겨 클라이언트는 DEADLINE_EXCEEDED 를 받았고, 재시도 정책에 그 코드가 들어 있어 같은 주문이 두 번 만들어졌다. 문서가 DEADLINE_EXCEEDED 설명에 "끝났을 수도 있다" 고 적어 둔 그 경우다. 멱등성 키 없이 재시도 목록을 넓히면 안 된다.

세 번째는 헬스체크 오해다. 로드밸런서가 Check 를 부르는데 서비스 이름을 잘못 적어 항상 NOT_SERVING 을 받았고, 트래픽이 0 이었다. 빈 문자열이 서버 전체를 뜻한다는 것을 알면 한 줄로 끝나는 설정이다.

다음 퀴즈에서 확인할 것

네 가지 호출의 차이, 라이브러리가 절대 만들지 않는 상태 코드, UNAVAILABLE·ABORTED·FAILED_PRECONDITION 의 구분, 데드라인이 시계 오차를 피하는 방법, 재시도 정책이 없을 때 실제로 일어나는 일, 헬스체크의 빈 문자열과 메타데이터 키 규칙을 묻는다.