읽다 보면 가장 놓치기 쉬운 부분부터 말씀드립니다. 돈은 빠져나가지 않았습니다. 지갑이 두 번 차감된 적도, 카드에 청구가 발생한 적도, 일어나지 않은 작업에 구독 할당량이 쓰인 적도 없습니다.
망가진 것은 화면 위의 숫자였습니다. 일부 계정에서 본인 계정 페이지의 사용량 내역이 높게 표시되었습니다. 저희가 측정한 최악의 계정에서는, 요약 대상인 원장의 2.4배였습니다. 원장 자체는 처음부터 끝까지 정확했습니다. 전혀 다른 곳에서 읽어오는 월간 상한도 마찬가지였습니다. 이것은 표시상의 결함이고, 이미 고쳐졌으며, 원인은 다음과 같습니다.
아무도 쓴 적 없는 것을 찾던 키
사용자의 행동 때문에 일어나는 작업 — 일일 사용량 요약 갱신, 추천 보상 적립, 알림 발송 — 은 요청 안에서 처리되지 않습니다. 요청은 메시지를 기록하고, 백그라운드 컨슈머가 잠시 뒤에 그것을 집어 갑니다. 느린 부수 효과가 빠른 페이지를 느리게 만들지 않는 이유가 이것입니다.
이 구조의 위험은 같은 작업을 두 번 하는 것입니다. 브로커는 문제가 생기면 같은 메시지를 아무렇지 않게 다시 전달하기 때문입니다. 방어책은 인박스입니다. 처리하기 전에 컨슈머가 해당 메시지의 키를 담은 행을 기록해 메시지를 선점하고, 고유 인덱스가 두 번째 선점을 불가능하게 만듭니다.
저희 구현은 한 쌍으로 선점하고 있었습니다. 메시지가 속한 테넌트와 메시지 키의 쌍이었고, 이 쌍을 메시지 봉투가 실어 나르는 테넌트 식별자와 대조해 조회했습니다. 봉투 쪽 값은 채워져 있습니다.
정작 선점 행에는 테넌트 식별자가 전혀 없었습니다. 모두 비어 있었고, 처음부터 그랬습니다. 엔티티 자체의 문서에는 프레임워크가 삽입 시점에 이 값을 찍는다고 그대로 적혀 있었습니다. 한 번도 찍지 않았습니다. 발견한 날 운영 환경에서 측정한 결과, 인박스 3,344행 전부가 비어 있었고, 생산 측 1,451행 중 1,174행에는 실제 테넌트가 들어 있었습니다. 읽는 쪽을 아무도 의심하지 않은 이유가 바로 이것입니다.
즉 조회는 (테넌트, 키)를 요구했고 저장된 행은 (없음, 키)였으니, 일치할 수 없었습니다.
아무것도 실패하지 않았고, 그래서 몇 주 동안 계속됐습니다
절대 일치할 수 없는 조회라면 첫 시도에서 오류를 낼 법합니다. 실제로는 정반대였습니다.
선점은 메시지가 새것이라고 판단했습니다. 이어진 삽입은 고유 인덱스에 부딪혀 중복으로 거부되었습니다. 행이 이미 있었으니 올바른 거부였습니다. 폴백 분기가 그 거부를 잡아내고 선의로 해석했습니다. 다른 전달이 이걸 선점했나 보다, 여기서 할 일은 없다고요. 봉투는 "아직 처리 대기" 상태로 남았고, 아직 대기 중이니 다음 전달이 그것을 집어 다시 작업했습니다.
모든 컨슈머가 대략 45초마다 자기 밀린 작업을 끝없이 다시 처리하고 있었습니다. 스물여섯 개가 그러고 있었습니다. 어느 운영 행은 도착한 지 333시간 뒤에 완료되었고, 그 시간의 대부분 동안 계속해서 완료되기를 반복했습니다.
실제로 망가진 것
그 스물여섯 개 컨슈머 중 넷은 덮어쓰는 대신 누적합니다. 그 넷이 사용량 페이지가 읽는 청구 프로젝션이고, 문제가 드러난 곳도 거기입니다.
이 넷에는 바로 이 상황에 대한 명시적 보호 장치가 있었습니다. 8월에 단일 이벤트가 게이지에 아홉 번 적용된 사고 이후에 작성된 것입니다. 이 보호는 행을 처리하기 전에 성공으로 표시해서, 재전달이 그 표시를 발견하고 멈추도록 합니다. 표시는 매번 정확히 기록되었습니다. 그런데 다음 선점이 같은 망가진 키로 그 표시를 찾았고, 찾지 못했습니다. 이 보장은 출시된 날부터 비어 있었습니다.
이 일이 건드리지 않은 것을 정확히 나열할 가치가 있습니다. "청구 프로젝션"이라는 말은 불안하게 들리지만 사실은 그렇지 않기 때문입니다.
- 원장은 정확했습니다. 모든 AI 호출은 자기 가격 스냅샷을 담은 청구 기록을 정확히 하나 씁니다. 이 경로에서 그 기록을 쓰는 것은 아무것도 없습니다.
- 지갑은 움직이지 않았습니다. 잔액은 원장에서 나오지, 요약에서 나오지 않습니다.
- 상한도 움직이지 않았습니다. 예산 가드는 별도 카운터를 읽고, 이번 달에 얼마를 썼는지에 대한 유일한 진실은 그쪽입니다. 부풀려진 숫자 때문에 일찍 차단된 사람도, 통과되지 말았어야 하는데 통과된 사람도 없습니다.
영향을 받은 요약을 읽는 유일한 대상은 본인 계정 페이지의 사용 내역입니다. 총액과 어디에 쓰였는지의 분해입니다. 영향 범위는 이것이 전부이고, 그 내용은 "높게 표시되었다"는 것입니다.
아무도 보지 않던 나머지 절반
같은 망가진 조회가 두 번째 자리에도 있었습니다. 메시지가 실패했을 때 실행되는 경로입니다. 거기서는 갱신해야 할 행을 끝내 찾지 못했고, 바로 아래에는 "실제로는 도달 불가"라는 주석이 달린 분기가 있었습니다. 모든 실패마다 "이 실패는 최종적이다"를 반환하는 분기였습니다.
그래서 재시도 횟수는 한 번도 올라가지 않았고, 어떤 오염 메시지도 데드 레터 큐에 도달하지 못했습니다. 오류 처리 계층 하나가 통째로 꺼져 있었습니다. 읽는 사람 모두에게 여기는 확인할 것이 없다고 말하는 주석 뒤에서요.
수정
두 조회 모두 이제 컨슈머 그룹 안의 메시지 키를 기준으로 동작합니다. 어차피 고유 인덱스가 강제하는 것이 이것입니다. 메시지 키는 그것을 만들어낸 대상의 식별자에서 파생되므로, 구조적으로 테넌트 간 충돌이 불가능합니다.
값이 찍힌다고 주장하던 문서는 삭제가 아니라 수정했습니다. 그 문장이 바로 함정이었고, 지워버리면 마침내 누군가 확인했다는 사실을 남길 것이 없어집니다.
아홉 개의 테스트가 이 동작을 고정합니다. 그중 여섯 개는 옛 코드에서 실패하며, 옛 코드를 되돌려 확인했습니다.
그 뒤의 정리
부풀려진 행은 스스로 복구됩니다. 워커가 매시간, 이틀 창에 걸쳐 일일 요약을 원장에서 다시 만듭니다. 이 워커는 내내 돌고 있었고 내내 지고 있었습니다. 45초마다 다시 적용하는 컨슈머를 상대로 한 시간에 한 번은 공정한 경주가 아닙니다.
컨슈머가 더는 다시 돌지 않으니 이제 워커가 이깁니다. 아직 그 창 안에 있던 이틀치는 수정이 운영에 반영되자마자 원장에서 다시 만들어졌습니다. 그보다 앞선 이틀은 이미 창 밖으로 벗어났고, 거기 남은 것은 저희 자신의 계정 하나와 다른 곳의 무시할 만한 차이뿐입니다. 그래서 그것을 쫓으려고 운영의 창을 넓히는 대신 그대로 두었습니다. 정리가 완전했던 것처럼 넌지시 말하는 것보다 이렇게 적어두는 편이 낫다고 보았습니다.
여기서 얻은 것
중복 제거 키는 코드가 기록되었다고 믿는 것이 아니라, 인덱스가 실제로 강제하는 것을 반영해야 합니다. 이 키의 두 절반은 서로 다른 필드에서 공급되었고, 어느 쪽도 상대와 대조해 측정된 적이 없었습니다. 이제는 둘 다 측정되었고, 그것을 증명하는 테스트는 틀렸던 버전에서 빨간불이 켜집니다.