마이크로서비스 아키텍처 · 동기 통신(REST·gRPC)과 그 대가 · 이론
네트워크 호출은 함수 호출이 아니다
한 줄 요약
동기 호출에서 기본값으로 반드시 정해야 하는 세 가지는 타임아웃, 재시도 조건, 그리고 재시도하지 않을 조건이다.
왜 이게 필요했나
모놀리스 안의 함수 호출은 실패 모드가 하나였습니다. 예외를 던지거나 값을 돌려주거나. 네트워크 호출은 실패 모드가 다섯 가지입니다. 연결 실패, 연결은 됐지만 응답 없음, 느린 응답, 절반만 온 응답, 그리고 가장 고약한 것 — 상대는 처리했는데 응답만 유실된 경우입니다.
마지막 경우가 왜 고약하냐면, 호출자는 "안 됐다"와 "됐는데 모른다"를 구별할 방법이 없기 때문입니다. 이 구별 불가능성이 뒤에 나올 멱등성과 사가의 출발점입니다.
어떻게 동작하나
타임아웃부터 봅시다. 기본값이 없는 HTTP 클라이언트가 많고, 없으면 사실상 무한 대기입니다. 무한 대기가 위험한 이유는 스레드나 커넥션이 묶이기 때문입니다. 다운스트림이 5초씩 느려지면 업스트림의 커넥션 풀이 먼저 마릅니다. 그러면 다운스트림과 전혀 상관없는 요청까지 실패합니다. 이것이 연쇄 장애의 가장 흔한 시작점입니다.
타임아웃 값은 어림짐작하지 말고 상대의 p99 에서 출발합니다. 상대 p99 가 120ms 라면 300~500ms 근처가 합리적입니다. p99 의 10배로 잡으면 타임아웃이 있으나 마나가 됩니다.
재시도는 조건이 있습니다. 재시도가 의미 있는 것은 일시적 오류뿐입니다 — 연결 실패, 타임아웃, 503, 429. 400, 401, 404 같은 확정적 오류는 몇 번을 보내도 같은 답입니다. 그리고 재시도는 반드시 지수 백오프와 지터를 함께 씁니다. 고정 간격 재시도는 천둥 소리 무리를 만듭니다.
여기 숫자 하나를 기억해 두면 좋습니다. 주문 서비스 인스턴스가 20대이고 초당 100건을 처리하는데 maxAttempts 를 5 로 두면, 다운스트림이 흔들리는 순간 결제 서비스가 받는 요청은 초당 최대 10,000건입니다. 100 x 20 x 5. 재시도는 부하를 곱합니다.
gRPC 를 쓰면 달라지느냐 하면, 직렬화가 빨라지고 스트리밍이 생기지만 위의 세 문제는 그대로입니다. 다만 데드라인이 프로토콜 1급 개념이고 홉을 넘어 전파된다는 점은 REST 보다 낫습니다.
현장에서 만나는 모습
호출 체인이 A → B → C 일 때 타임아웃을 각각 3초로 두는 실수가 흔합니다. 그러면 A 는 최대 3초를 기다리는데 B 는 C 를 3초 기다리므로, A 의 타임아웃이 먼저 터져도 B 와 C 는 계속 일하고 있습니다. 버려질 작업에 자원을 쓰는 것입니다. 타임아웃은 바깥이 크고 안쪽이 작아야 합니다. 그리고 남은 예산을 헤더나 데드라인으로 아래로 전달하면 더 좋습니다.
다음 실습에서 할 것
의도적으로 느리고 의도적으로 실패하는 다운스트림을 상대로, 타임아웃을 걸고, 지수 백오프와 지터를 구현하고, 4xx 는 재시도하지 않게 만들고, 마지막에 재시도 예산을 계산해 봅니다.