LabHub
배우기 러닝패스 코스

マイクロサービスアーキテクチャ

ネットワーク呼び出しは関数呼び出しではない

LabHub 에서 이어서 보기

한국어 원문으로 표시합니다.

한 줄 요약

동기 호출에서 기본값으로 반드시 정해야 하는 세 가지는 타임아웃, 재시도 조건, 그리고 재시도하지 않을 조건이다.

概念マップ: 바깥에서 안으로 갈수록 짧아야・아예 시작하지 않는 것・실패가 계속되면 시도 자체를 멈춥니다.・비율과 최소 표본

왜 이게 필요했나

모놀리스 안의 함수 호출은 실패 모드가 하나였습니다. 예외를 던지거나 값을 돌려주거나. 네트워크 호출은 실패 모드가 다섯 가지입니다. 연결 실패, 연결은 됐지만 응답 없음, 느린 응답, 절반만 온 응답, 그리고 가장 고약한 것 — 상대는 처리했는데 응답만 유실된 경우입니다.

마지막 경우가 왜 고약하냐면, 호출자는 "안 됐다"와 "됐는데 모른다"를 구별할 방법이 없기 때문입니다. 이 구별 불가능성이 뒤에 나올 멱등성과 사가의 출발점입니다.

어떻게 동작하나

타임아웃부터 봅시다. 기본값이 없는 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 는 계속 일하고 있습니다. 버려질 작업에 자원을 쓰는 것입니다. 타임아웃은 바깥이 크고 안쪽이 작아야 합니다. 그리고 남은 예산을 헤더나 데드라인으로 아래로 전달하면 더 좋습니다.

타임아웃은 계층마다 다르게

한 요청이 여러 계층을 지날 때 타임아웃이 바깥에서 안으로 갈수록 짧아야 합니다. 반대면 바깥이 먼저 포기하고, 안쪽은 아무도 안 기다리는 답을 계속 만듭니다.

브라우저        30s
  게이트웨이    10s     ← 바깥보다 짧다
    API         6s
      결제 서비스 3s
        DB       1s

각 계층에 재시도가 있으면 곱해집니다. API 가 3초 타임아웃에 2회 재시도면 최악 9초이고, 게이트웨이의 10초를 거의 다 씁니다. 타임아웃 × (재시도+1) 이 바깥 타임아웃보다 작아야 합니다.

기한을 헤더로 전달하는 방법도 있습니다(deadline propagation). gRPC 는 기본으로 지원하고, HTTP 에서는 X-Request-Deadline 같은 헤더를 직접 만듭니다. 남은 시간이 0에 가까우면 아예 시작하지 않는 것 이 자원을 아낍니다.

회로 차단기가 여는 조건

재시도만으로는 무너진 하류를 살릴 수 없습니다. 오히려 부하를 더합니다. 회로 차단기는 실패가 계속되면 시도 자체를 멈춥니다.

닫힘(정상) ──실패율 임계 초과──> 열림(즉시 실패)
    ↑                              │
    └──성공──  반열림(몇 개만 보내 봄) <──일정 시간 뒤──┘

임계는 개수가 아니라 비율과 최소 표본 으로 정합니다. "10개 중 5개 실패" 는 의미 있지만 "2개 중 1개 실패" 는 우연입니다.

# 예: 20건 이상일 때, 실패율 50% 넘으면 30초 열림
minimumNumberOfCalls: 20
failureRateThreshold: 50
waitDurationInOpenState: 30s
permittedNumberOfCallsInHalfOpenState: 3

열렸을 때 무엇을 돌려줄지 가 설계입니다. 캐시된 옛 값, 축소된 응답, 또는 명확한 오류. 아무 계획 없이 500 을 던지면 회로 차단기가 있으나 마나입니다.

부분 실패를 응답에 담기

여러 서비스를 불러 화면 하나를 만들 때, 하나가 실패했다고 전체를 실패시키지 않습니다. 무엇이 없는지 알려 주는 응답 이 낫습니다.

{
  "order": {"id": 1043, "amount": 52000},
  "recommendations": null,
  "degraded": ["recommendations"]
}

화면은 추천 영역만 비우고 나머지를 보여 줍니다. 이렇게 하려면 필수와 선택을 미리 나눠 두어야 합니다. 주문 정보는 필수, 추천은 선택. 이 구분이 없으면 모든 호출이 필수가 되어 가용성이 곱해집니다.

다음 실습에서 할 것

의도적으로 느리고 의도적으로 실패하는 다운스트림을 상대로, 타임아웃을 걸고, 지수 백오프와 지터를 구현하고, 4xx 는 재시도하지 않게 만들고, 마지막에 재시도 예산을 계산해 봅니다.