멱등성 키를 넣었는데 왜 같은 인시던트가 두 번 생겼을까?

결론부터 적으면, 키 자체의 문제가 아니라 같은 멱등성 키를 두 개의 서로 다른 경로(REST, gRPC)가 각자의 저장소로 따로 해석했기 때문이었다. 키 값은 멀쩡히 전달됐지만, 그 키를 받아 적는 계약이 전송 경로마다 갈려 있었다.

핵심만 먼저 정리한다.

  • 멱등성 키는 "요청"이 아니라 "업무 이벤트"에서 파생해야 재시도 주체가 달라져도 같은 이벤트로 묶인다. pagerduty-alert-8842처럼 이벤트 ID를 키로 쓰는 편이 워커마다 새 UUID를 찍는 것보다 안전하다 출처.
  • 멱등성 키는 보존 기간이 있는 단기 중복 제거 장치다. 24시간이 지나면 같은 키라도 새 작업으로 처리될 수 있어, 장기 중복 방지는 주문번호·DB 유니크 제약으로 별도로 걸어야 한다 출처.
  • REST는 Idempotency-Key 헤더로, GraphQL은 뮤테이션 입력 필드로, RPC는 protobuf 메시지 필드로 멱등성을 표현하는 게 각 스타일의 계약 관용구에 맞는다.
  • API 스타일을 전환할 때는 멱등성 키의 이름·형식·TTL·동일 키 응답 정책을 그대로 유지해야 재시도 클라이언트가 안전하다.

사고 경위는 이랬다. 사내 인시던트 알림 서비스가 REST로 POST /incidents를 받다가, 내부 트래픽이 늘면서 고처리량 경로만 gRPC로 점진 이관하는 작업을 진행했다. 이관 첫 주, 특정 리전에서 동일 PagerDuty 알림이 인시던트 레코드로 두 번 생성되는 사례가 하루 평균 십여 건 보고됐다. 운영팀 입장에서는 같은 장애에 대해 중복 알림과 중복 대응 티켓이 뜨는 수준이었지만, 온콜 혼선으로 이어져 우선순위를 높여 봤다.

REST 헤더는 멀쩡한데 왜 gRPC 경로에서만 중복이 났을까?

처음 세운 가설은 "네트워크 타임아웃에 의한 단순 재시도"였다. 로그를 보니 클라이언트가 3초 타임아웃 뒤 같은 idempotency_key로 재요청을 보내는 패턴 자체는 정상이었고, REST 경로에서는 두 번째 요청이 200 OK로 최초 생성 결과를 돌려받아 중복이 생기지 않았다. 문제는 gRPC로 전환된 서비스 경로에서만 발생했다.

gRPC 메서드의 요청 메시지를 보니 idempotency_key 필드는 분명 존재했다. 그런데 이 필드를 받는 서버 쪽 저장소 조회 로직이 REST 핸들러가 참조하던 키-응답 캐시 테이블이 아니라, gRPC 서비스가 별도로 띄운 인메모리 캐시를 바라보고 있었다. 즉 같은 키 값이 들어와도 REST와 gRPC가 "서로 다른 장부"에 적고 있었던 것이다. 이는 멱등성 키 설계에서 흔히 놓치는 지점으로, 키는 전송 프로토콜의 문제가 아니라 서버 저장소 설계의 문제라는 지적과 맞닿아 있다 출처.

키가 분명 24시간 안에 들어왔는데 왜 TTL 문제로 의심했을까?

TTL 만료를 먼저 의심한 이유는 중복 생성 건들의 타임스탬프 간격이 22~26시간대에 몰려 있어서였다. 실제로 일부 사례는 큐 적체로 재시도가 24시간 TTL을 넘겨 발생한 것이 맞았다. 하지만 전체 중복 건수의 약 70%는 TTL 범위 안, 즉 키가 분명 살아있는 상태에서 발생했다. 이 비율 때문에 TTL만으로는 설명이 안 됐고, 조사 방향을 저장소 분리 쪽으로 돌렸다.

이 과정에서 확인한 설계 원칙이 유용했다. 멱등성 키는 재시도 방지 장치이지 동시 수정 충돌을 막는 낙관적 잠금이 아니다. 수정 API는 응답의 version 값을 다음 요청에 되돌려 보내게 해서 충돌을 따로 검증하는데, 우리 시스템은 생성 API에서도 이 둘을 섞어 "버전 불일치니 TTL 문제겠지"라고 성급히 결론 내렸던 셈이다 출처.

두 경로의 멱등성 저장소가 다르다는 건 어떻게 확인했나?

재현은 단순했다. 같은 idempotency_key로 REST 엔드포인트와 gRPC 메서드를 순서대로 호출해 각각의 응답을 비교했다. REST로 먼저 생성 요청을 보내면 201 Created가 왔고, 같은 키로 gRPC 메서드를 호출하면 캐시 미스로 새 레코드가 또 생성됐다. 반대로 gRPC를 먼저, REST를 나중에 호출해도 같은 결과였다. 두 저장소의 키 조회 테이블을 덤프해 비교하니 같은 키 값이 서로 다른 레코드 ID와 매핑돼 있었고, 이것으로 "저장소가 분리돼 있다"는 가설이 로그 수준에서 확정됐다.

이 사고에서 REST·GraphQL·RPC 스타일 선택 자체가 문제는 아니었다는 점도 분명히 해 두고 싶다. 공개된 비교 자료 중 하나는 100,000회 요청, 100B·1KB·100KB payload 조건에서 gRPC·REST·GraphQL의 p50을 각각 0.8ms·1.4ms·2.1ms, p99를 2.1ms·5.2ms·8.7ms로 보고했는데, 이런 수치는 요청 수·payload 크기·연결 수·resolver 구현에 따라 달라지므로 조건 없이 "gRPC가 빠르다"로만 인용하면 안 된다 출처. 또 다른 자료는 컨테이너화 환경에서 REST 지연시간 30~150ms·처리량 2,000~5,000req/s, gRPC 지연시간 5~20ms·처리량 15,000~50,000req/s라는 범위를 제시하지만, 이는 여러 자료를 요약한 범위라 자체 환경 벤치마크의 비교축으로 쓰는 게 더 적절하다 출처. 우리 사고의 원인은 지연시간이 아니라 계약 설계였다.

재발을 막으려고 뭘 바꿨고 어떤 지표로 확인했나?

바꾼 것은 저장소 통합과 계약 명세였다. 멱등성 키-응답 매핑을 REST·gRPC가 공유하는 단일 테이블로 통일했고, 이관 플레이북에 따라 .proto 계약에 idempotency_key 필드를 정의하면서도 기존 REST의 Idempotency-Key 헤더 값과 1:1로 같은 저장소 키를 바라보도록 매핑 레이어를 추가했다. 마이그레이션 중 REST와 신규 경로를 병행 운영하면서 고가치 엔드포인트부터 점진 전환하고 중복 엔드포인트를 단계적으로 폐기하는 방식은 플레이북에서 제안하는 흐름과 같다 출처.

확인 지표는 두 가지였다. 하나는 동일 키 중복 생성 건수로, 통합 전 일 평균 12건에서 통합 후 2주간 0건으로 내려간 것을 모니터링 대시보드에서 확인했다. 다른 하나는 계약 테스트였다. 키 없이 보낸 요청은 거부되는지, 같은 키·같은 본문은 같은 응답을 주는지, 같은 키·다른 본문은 충돌로 처리되는지를 리뷰 체크리스트에 명시적으로 넣었는데, 이는 2025년 REST API 테스트 가이드에서 제시하는 계약 테스트 항목과 같은 틀이다 출처. GraphQL 경로가 있었다면 같은 검증을 CreateOrderInput 같은 뮤테이션 입력 필드 단위로 걸었을 것이다.

이 장애에서 남은 기본값은 뭘로 삼았나?

2026년 기준으로 팀이 합의한 기본값은, 멱등성 키 저장소를 전송 프로토콜 단위가 아니라 서비스 단위로 하나만 둔다는 것이다. REST 헤더든 GraphQL 입력 필드든 gRPC 메시지 필드든, 들어오는 입구는 달라도 키를 적는 장부는 하나여야 한다. 그리고 멱등성 키와 동시성 제어(버전 검증)를 같은 문제로 섞지 않는다. 키는 재시도를 합치는 장치고, 버전은 서로 다른 수정 요청의 충돌을 막는 장치다. 마지막으로 API 스타일 전환은 성능 숫자보다 계약 유지가 먼저라는 것, 즉 키 이름·TTL·응답 정책을 전환 전후로 바꾸지 않는 것이 재시도하는 클라이언트를 안전하게 지키는 전제라는 점을 기본값으로 삼았다.

핵심 정리

  • 멱등성 키 중복 생성 사고의 다수는 키 자체보다 전송 경로별로 분리된 저장소 계약에서 비롯된다.
  • 키는 요청이 아니라 업무 이벤트(pagerduty-alert-8842 같은 ID)에서 파생해야 재시도 주체가 달라도 같은 이벤트로 묶인다.
  • 멱등성 키(단기 재시도 방지)와 버전 기반 낙관적 잠금(동시 수정 충돌 방지)은 서로 다른 문제이므로 섞어 진단하면 원인 추적이 늦어진다.
  • API 스타일 전환(REST→gRPC, GraphQL 도입) 시 멱등성 키의 이름·TTL·응답 정책은 그대로 유지해야 한다.
  • 벤치마크 수치(p50 0.8~2.1ms, p99 2.1~8.7ms 등)는 payload·연결 수·resolver 구현 조건이 다르면 그대로 인용하기 어렵다.