sy/dev
Concept
15 min read

분산 시스템에서는 응답 없음만으로 아무것도 알 수 없다

백엔드 지식 지도 3편. 부분 실패, 타임아웃, 재시도, 멱등성, 과부하 대응, 관측 지표를 하나의 사고방식으로 정리한다.

분산 시스템을 이해하는 출발점은 화려한 기술이 아니다.

가장 먼저 받아들여야 하는 문장은 이것이다.

응답이 없다는 사실만으로는 아무것도 추론할 수 없다.

상대가 요청을 못 받았을 수도 있다. 요청은 받았지만 아직 처리 중일 수도 있다. 처리는 끝났는데 응답만 유실됐을 수도 있다. 응답은 왔지만 중간 프록시가 끊었을 수도 있다. 호출자는 그 차이를 밖에서 알 수 없다.

단일 프로세스에서는 실패가 비교적 선명하다. 프로세스가 살았거나 죽었다. 함수가 값을 반환했거나 예외를 던졌다. 하지만 여러 프로세스가 네트워크로 묶이는 순간 실패는 부분적으로 발생한다. 일부는 살고, 일부는 죽고, 나머지는 그 사실을 모른다.

이게 분산 시스템의 기본 조건이다.

타임아웃은 실패 판정이 아니라 포기 시점이다

타임아웃을 “실패를 감지하는 장치”로 생각하기 쉽다. 반은 맞고 반은 틀리다.

타임아웃이 말해 주는 것은 상대가 실패했다는 사실이 아니다. 내가 더 기다리지 않겠다고 정한 시점이다.

client timeout = 클라이언트가 기다리기를 포기한 시간
server failure = 서버가 처리하지 못한 사실

둘은 다르다.

클라이언트가 3초 후 타임아웃을 냈더라도 서버는 4초 뒤 정상 커밋할 수 있다. 그래서 타임아웃 이후 재시도를 할 때는 항상 중복 가능성을 생각해야 한다.

더 중요한 것은 타임아웃의 계층이다.

호출 timeout < 단계 timeout < 잡 timeout < 회수 timeout

안쪽이 바깥보다 짧아야 한다. 그래야 안쪽이 스스로 포기하고 실패 이유를 기록할 수 있다. 바깥의 큐 시스템이나 프로세스 매니저가 먼저 죽이면 애플리케이션은 “왜 실패했는지”를 남길 기회를 잃는다.

예를 들어 잡의 강제 종료 시간이 10분인데, 내부 HTTP 호출의 total timeout이 없다면 어떻게 될까. 외부 호출 하나가 무한히 늘어지다가 큐 시스템이 잡을 죽인다. 기록에는 “잡 시간 초과”만 남고, 실제 원인이 어느 외부 호출인지 흐려진다.

좋은 시스템은 안쪽에서 먼저 실패한다.

호출 timeout은 전체 예산을 묶지 않는다

호출 하나에 30초 timeout을 걸었다고 전체 작업이 30초 안에 끝나는 것은 아니다.

입력 100개에 대해 외부 API를 100번 호출하면 총 시간은 최대 3000초가 될 수 있다. 호출별 timeout은 개별 호출의 상한일 뿐, 단계 전체의 예산이 아니다.

그래서 단계 예산이 필요하다.

전체 작업 예산: 120초
이미 사용: 45초
남은 시간: 75초
다음 호출 timeout: min(기본값, 남은 시간)

이 남은 시간을 호출 사슬을 따라 흘리는 것을 deadline propagation이라고 한다. Go의 context, gRPC deadline, Python의 contextvar 같은 도구가 이 문제를 푼다.

중요한 것은 모든 함수 인자에 remaining_time을 억지로 뚫는 것이 아니다. 요청 ID, 사용자, 남은 시간 같은 실행 맥락은 주변 상태로 흘러야 한다. 그래야 내부 구현체를 바꿀 때마다 모든 함수 시그니처가 깨지지 않는다.

재시도는 복구 수단이면서 장애 증폭 장치다

재시도는 필요하다.

네트워크는 흔들리고, 일시적 5xx는 생기고, DB 교착은 재시도하면 성공할 수 있다. 하지만 재시도는 공짜가 아니다. 실패한 시스템에 요청을 하나 더 보내는 일이다.

클라이언트가 3회 재시도하고, 프록시가 3회 재시도하고, 앱 내부 HTTP 클라이언트가 다시 3회 재시도하면 최악의 경우 한 요청이 27번으로 증폭된다.

3 × 3 × 3 = 27

문제는 이 증폭이 시스템이 가장 약할 때 발생한다는 점이다. 이미 느려진 서비스에 재시도가 몰리고, 큐가 더 길어지고, 더 많은 요청이 타임아웃되고, 다시 재시도가 늘어난다.

이런 양의 되먹임은 장애를 오래 붙잡아 둔다.

그래서 재시도에는 규칙이 필요하다.

  • 재시도는 한 계층에서만 한다.
  • 멱등한 요청만 자동 재시도한다.
  • 지수 백오프를 둔다.
  • 지터를 넣어 같은 시각에 몰리지 않게 한다.
  • 전체 재시도 예산을 둔다.

특히 지터가 중요하다. 지수 백오프만 있으면 모든 클라이언트가 비슷한 시점에 다시 몰릴 수 있다. full jitter는 0부터 base × 2^n 사이에서 랜덤하게 기다려 재시도를 흩뜨린다.

멱등성 없이는 안전한 재시도가 없다

타임아웃과 재시도 이야기는 결국 멱등성으로 돌아온다.

GET처럼 여러 번 보내도 결과가 같은 요청은 비교적 안전하다. 하지만 POST로 결제, 주문, 포인트 지급, 메시지 발송 같은 일을 한다면 다르다.

요청이 서버에 도착했고 처리도 끝났는데 응답만 유실됐을 수 있다. 클라이언트는 timeout을 받았지만 서버는 이미 커밋했다. 이 상태에서 같은 POST를 다시 보내면 중복 처리가 일어난다.

해법은 멱등키다.

Idempotency-Key: request-123

서버는 이 키를 기준으로 “이 요청은 이미 처리했다”를 기억해야 한다. 그리고 처리 결과를 같은 키에 대해 다시 돌려줄 수 있어야 한다.

멱등성은 단순히 API 문서에 “재시도 가능”이라고 적는 일이 아니다. 데이터베이스의 unique constraint, 처리 완료 테이블, 트랜잭션 경계까지 포함한 설계다.

과부하에서는 빨리 거절하는 것이 낫다

시스템이 감당할 수 없는 요청을 받으면 두 가지 선택이 있다.

하나는 모두 받아서 천천히 처리하는 것이다. 다른 하나는 일부를 빨리 거절하는 것이다.

직관적으로는 전자가 친절해 보인다. 하지만 운영에서는 후자가 더 안전한 경우가 많다.

모두 받아 두면 큐가 길어진다. 큐가 길어지면 요청의 체류 시간이 늘어난다. 체류 시간이 늘어나면 더 많은 클라이언트가 timeout을 내고 재시도한다. 재시도는 다시 큐를 늘린다.

그래서 부하 차단이 필요하다.

처리 가능 용량을 넘은 요청은 빨리 실패시킨다.
이미 만료된 큐 작업은 시작하지 않는다.
중요하지 않은 기능은 별도 풀로 격리한다.

서킷 브레이커도 같은 맥락이다. 외부 서비스가 계속 실패하면 매번 오래 기다리지 않고 잠시 호출을 끊는다. 빠르게 실패시키면 호출자의 스레드와 커넥션을 보호할 수 있다.

격벽도 중요하다. 한 외부 API가 느려졌다고 전체 서비스의 커넥션 풀과 워커 풀이 같이 고갈되면 안 된다. 기능별 큐, 전용 워커, 분리된 커넥션 풀은 모두 같은 아이디어다.

폴백은 성공처럼 보이는 실패를 만든다

폴백은 좋아 보인다. 외부 추천 서비스가 죽으면 인기 상품을 보여 주고, 개인화 모델이 실패하면 기본 랭킹을 보여 주는 식이다.

하지만 폴백에는 위험이 있다.

첫째, 평소에 잘 실행되지 않아 테스트가 부족하다. 정작 전면 장애 때 처음으로 대량 실행되고, 그때 같이 실패할 수 있다.

둘째, 장애를 성공처럼 보이게 한다. HTTP 상태코드는 200인데 사용자는 품질 낮은 결과를 받는다. 액세스 로그만 보면 정상이고, 실제 사용자 경험은 망가져 있다.

그래서 폴백을 넣는다면 강등 자체를 지표로 내보내야 한다.

fallback_used_total
fallback_response_ratio
valid_response_ratio

폴백이 있는 시스템의 SLI는 단순 HTTP 성공률이면 안 된다. “제대로 된 응답의 비율”을 봐야 한다.

평균이 아니라 꼬리를 봐야 한다

분산 시스템에서는 평균 지연 시간이 별로 쓸모없을 때가 많다.

하나의 사용자 요청이 내부적으로 50개 하위 호출을 한다고 해 보자. 그중 하나만 느려도 전체 응답은 느려진다. 하위 호출의 p99가 상위 요청의 일반적인 지연에 영향을 준다.

그래서 꼬리 지연을 봐야 한다.

p95, p99, p999 같은 백분위가 필요하다. 그리고 여러 인스턴스의 p99를 평균 내면 안 된다. 백분위는 평균 낼 수 없다. 올바른 방법은 히스토그램 버킷을 합산한 뒤 다시 백분위를 계산하는 것이다.

관측 지표는 보통 세 가지로 나눠 본다.

  • 메트릭: 집계된 숫자. 알림과 대시보드에 좋다.
  • 로그: 사건의 세부 기록. 고카디널리티 정보에 좋다.
  • 트레이스: 요청 하나가 어떤 경로를 지나갔는지 보여 준다.

서비스는 RED로 본다.

Rate: 요청량
Errors: 오류율
Duration: 지연 시간

자원은 USE로 본다.

Utilization: 사용률
Saturation: 포화도
Errors: 오류

원인보다 증상에 알림을 거는 것이 보통 낫다. 원인은 무한히 많고 계속 바뀐다. 하지만 사용자가 겪는 증상은 비교적 안정적이다. 느리다, 실패한다, 결과가 틀리다.

마무리

분산 시스템은 “절대 실패하지 않는 시스템”을 만드는 지식이 아니다.

실패가 일부에서 시작됐을 때 전체로 번지지 않게 하는 지식이다.

응답 없음만으로는 아무것도 알 수 없다. 그래서 timeout은 포기 시점으로 설계해야 하고, retry는 멱등성과 예산 위에서만 안전하며, 과부하에서는 일부 요청을 빨리 거절해야 한다. 폴백은 조용히 성공처럼 보이면 안 되고, 평균보다 꼬리 지연을 봐야 한다.

다음 편에서는 이 사고방식을 큐와 워커에 적용한다.

핵심 질문은 이것이다.

이 잡이 두 번 실행되면 무슨 일이 일어나는가.

Comments