2화
스냅샷

1
11월 셋째 주 금요일 오전 아홉 시 오십팔 분, 유도현은 결제팀 자리에서 쿠폰 서비스 대시보드를 세 개의 모니터에 나눠 띄워놓고 있었다.
블랙 위크 오픈까지 2분. 열 시 정각에 쿠폰 10만 장이 선착순으로 풀린다. 마케팅이 이 이벤트에 쓴 돈이 4억이라고 했다. 광고는 이미 일주일 전부터 돌고 있었고, 어제 목요일 열 시에 한 리허설에서는 초당 2만 요청까지 문제없이 받아냈다.
쿠폰 서비스의 구조는 단순했다. 쿠폰 재고를 Redis 카운터 하나로 관리하고, 요청이 들어오면 DECR로 하나 깎고, 0 아래로 내려가면 품절. DB는 발급 기록만 뒤에서 비동기로 쌓는다. 이 구조를 켜고 끄는 게 coupon-inventory-cache 플래그였다. 플래그가 꺼지면 예전 방식으로 돌아간다. 쿠폰 재고 행을 SELECT FOR UPDATE로 잠그고, 깎고, 커밋. 초당 2만 요청이 행 하나를 잠그겠다고 줄을 서는 방식.
9월 이후로 회사는 많이 바뀌었다. 한유리는 퇴사했다. 서지안은 “게이트”라는 승인 봇을 만들었다. 프로덕션 플래그 변경은 승인자 두 명이 필요하고, 미승인 변경은 24시간 뒤 마지막 승인 상태로 자동 원복된다. 도현은 결제팀 CI 토큰의 권한을 플래그 하나짜리로 줄였고, 배포 체크리스트를 장식이 아닌 것으로 다시 썼다. 지안은 그 체크리스트에 “여전히 장식”이라고 리뷰 코멘트를 남겼다가 지운 적이 있다. 도현은 지운 걸 알고 있었다. 알림 메일은 지워지지 않으니까.
09:58, #blackweek 채널에 지안의 메시지가 올라왔다.
09:58:12 서지안 쿠폰 캐시 플래그 켜져 있는 거 다들 확인하셨죠? 저는 이 구조 여전히 반대입니다만, 켜져 있으면 버팁니다.
도현은 대시보드의 플래그 상태 패널을 봤다. ON. 어제 아침에 켠 그대로였다. 아래에는 두 줄이 더 있었다.
ON 변경 #4471 · PR 승인 연동 완료 · 11/19 09:31 마지막 승인 스냅샷 · 10/27 14:12
날짜가 달랐다. 패널 갱신이 늦는 건가. 도현은 새로고침을 눌렀다. 날짜는 그대로였지만 초록색 ON도 그대로였다. 옆 모니터에서 카운트다운이 1분 아래로 내려갔다.
10:00:00. 트래픽 그래프가 수직으로 섰다.
10:00:08. DB 대시보드에 처음 보는 색이 생겼다. 행 잠금 대기 시간. 8초 전까지 0이었던 그래프가 계단을 올라가고 있었다.
10:00:30. coupon-api p99 지연 시간이 10초를 넘었다.
도현은 플래그 상태 패널을 다시 봤다. OFF.
2
“인시던트 커맨더 제가 잡겠습니다.” 브리지 콜에서 지안이 말했다. 목소리가 평소보다 낮았다. “도현 씨, 플래그 누가 껐어요?”
“모르겠어요. 09:58에 팀장님 메시지 보고 확인했을 때는 켜져 있었어요. 지금 이력 보는 중이에요.”
스위치보드 감사 로그가 떴다.
coupon-inventory-cache · ON → OFF · 09:59:41 · actor: gate-bot (on behalf of: 서지안)
콜이 잠깐 조용해졌다. 도현은 그 침묵이 무슨 뜻인지 알았다. 다들 같은 화면을 보고 있었다.
“게이트 봇이 껐어요.” 도현이 말했다. “자동 원복이요.”
“원복이 왜 지금 돌아.” 지안이 말했다. 질문이 아니라 혼잣말에 가까웠다. “일단 켭니다. 브레이크글라스 제가 씁니다. 세훈 님, 사후 승인 부탁드려요.”
도현이 켜기 버튼 앞에서 멈췄다. 옛 경로에서 이미 발급한 쿠폰 수가 Redis 재고에는 반영되지 않았다. 그대로 켜면 재고를 넘겨 발급할 수 있었다. 신규 발급을 막고, 남은 발급 트랜잭션을 정리한 뒤 두 경로의 기록을 대조해 캐시 재고를 맞췄다. 플래그는 10:19에 다시 켜졌다. 밀린 기록 처리가 끝나고 DB 부하가 정상으로 돌아온 건 10:31이었다. 장애 시간 31분.
쿠폰 10만 장 중 실제로 발급된 건 6천 장이었다. 나머지 9만 4천 장은 이벤트 페이지가 타임아웃을 뿜는 동안 아무도 받지 못했다. 마케팅은 발급 내역을 확인할 때까지 이벤트를 재개하지 않기로 했다. 마케팅팀이 SNS를 닫아버린 건 10:40이었다. 정오에는 “선착순이라더니 서버가 선착순”이라는 문장이 커뮤니티에 돌아다니고 있었다.
브리지 콜이 끝나고 도현은 자리에서 일어나지 않았다. 09:59:41이라는 숫자가 계속 걸렸다. 어디서 본 숫자였다.
3
토요일 아침, 도현은 숙취를 안고 노트북을 열었다. 금요일 밤에 혼자 마셨다. 그 술집에는 가지 않았다.
플랫폼 본부장 강민석이 금요일 저녁에 결제팀 리드 박세훈에게 전화를 했다는 얘기를 세훈이 도현에게 해줬다. 민석은 월요일 포스트모템 전에 초안을 먼저 위에 올려야 하고, 초안에는 “게이트 봇 운영자의 설정 판단 착오 및 수동 개입 가능성”이라는 문장이 들어가 있다고 했다. 운영자는 서지안이었다.
“수동 개입이요?”
“봇 로그에 on behalf of 서지안이라고 찍혀 있잖아. 본부장님은 9월 이후로 장애는 사람이 만든다고 믿는 분이 됐어. 그리고 지안 팀장이 그 구조 반대한 거 설계 리뷰 기록에 다 남아 있고. 오픈 2분 전에 ‘반대입니다만’이라고 쓴 것도 남아 있고.”
“그건 그냥 잔소리인데요.”
“본부장님한테는 동기야.”
도현은 세훈의 말을 곱씹으면서 감사 로그의 원본을 뽑았다. 콘솔 화면에는 한 줄이었지만 API로 뽑으면 필드가 더 있었다. 9월에 지안이 하던 일을 그대로 따라 하고 있다는 걸 도현은 알았다.
{
"flag": "coupon-inventory-cache",
"env": "production",
"from": true, "to": false,
"at": "2026-11-20T09:59:41+09:00",
"actor": { "type": "service", "name": "gate-bot", "owner": "seo.jian" },
"reason": "auto-revert: change #4479 unapproved for 24h",
"restored_snapshot": "snap-20261027-1412"
}
change #4479. 도현은 스위치보드에서 그 번호를 찾았다.
#4479 · coupon-inventory-cache · targeting rule 추가:
user.tag == internal-test제외 · 2026-11-19 09:59:41 · actor: 유도현 (console)
목요일 09:59:41. 리허설 오픈 19초 전. 마케팅에서 “내부 테스트 계정이 쿠폰을 먹고 있다”고 해서 도현이 콘솔에서 타겟팅 규칙 하나를 급하게 추가한 것이었다. 플래그를 켜고 끄는 게 아니라 규칙 한 줄이었다. 승인이 필요한 변경이라고 생각하지 않았다. 게이트 봇은 다르게 생각했다.
09:59:43 gate-bot #4479 변경에 승인이 필요합니다. 승인자 2명이 24시간 내에 반응하지 않으면 마지막 승인 스냅샷으로 원복됩니다. 👍 로 승인.
그 메시지는 #blackweek-rehearsal 채널의 스레드 안에 있었다. 스레드 위로는 리허설 카운트다운과 “트래픽 들어온다”와 “2만 찍었다”와 “내부 계정 제외됐어요?”가 200줄쯤 쌓여 있었다. 반응 0개. 도현 자신도 읽지 않았다.
그러니까 09:59:41의 정확한 24시간 뒤에 봇이 돈 건 우연이 아니었다. 도현이 만든 시각이었다.
거기까지는 이해가 됐다. 이해가 안 되는 건 그다음이었다. 미승인 변경은 규칙 한 줄이었다. 원복이라면 그 규칙 한 줄을 지우고 플래그는 ON인 채로 남겨야 했다. 왜 플래그 자체가 꺼졌나.
restored_snapshot: snap-20261027-1412. 10월 27일 오후 두 시 십이 분. 오픈 직전 패널에서 봤던 날짜였다. 도현은 그 스냅샷을 열었다. 플래그 OFF. 10월 말 카나리 테스트를 끝내고 콘솔에서 껐을 때 찍힌 스냅샷이었다. 화면 갱신이 늦었던 게 아니었다. 봇이 가지고 있는 승인 상태가 정말로 거기서 멈춰 있었다.
목요일 09:31에 플래그를 켠 변경 #4471은 어디 갔나. 그건 승인됐다. PR #2210에 지안과 세훈이 승인을 남겼고, CI가 토큰으로 플래그를 켰고, 봇은 그 PR 승인을 자동으로 연결해서 #4471을 승인 처리했다. 도현은 봇 스레드에서 그 기록도 찾았다. “#4471 PR 승인 연동 완료. 승인자: 서지안, 박세훈.”
승인은 됐는데 스냅샷은 없었다.
도현은 지안의 게이트 봇 저장소를 열었다. 처음 여는 저장소였다. 주석이 거의 없었다. 지안다웠다.
gate/approve.py에 승인 경로가 두 개 있었다. 콘솔에서 👍 두 개가 모이면 도는 approve_by_reaction(), 그리고 PR 승인을 끌어오는 sync_pr_approvals(). 앞의 함수는 마지막 줄에서 snapshots.save(flag, state)를 불렀다. 뒤의 함수는 변경 상태를 approved로 바꾸고 끝났다. save가 없었다.
gate/revert.py는 짧았다.
def revert(change):
snap = snapshots.latest_approved(change.flag)
switchboard.apply(change.flag, snap.state, reason=f"auto-revert: change #{change.id} unapproved for 24h")
latest_approved는 10월 27일을 돌려줬다. PR 경로로 승인된 #4471은 approved 표시만 됐지 스냅샷 테이블에는 한 줄도 남기지 않았으니까.
그러니까 이렇게 된 것이었다. 도현이 승인 없이 규칙 한 줄을 고쳤다. 아무도 봇 메시지를 안 읽었다. 24시간 뒤 봇이 원복을 돌렸다. 봇은 PR 경로로 승인된 상태를 스냅샷으로 남기지 않는 버그가 있어서 한 달 전 상태로 되돌렸다. 한 달 전 상태는 OFF였다. 19초 뒤에 10만 명이 들어왔다.
도현의 변경과 지안의 버그였다. 둘 중 하나만 없었어도 장애는 나지 않았다. 그리고 민석의 초안에는 그중 하나만 들어 있었다.
도현은 노트북을 덮고 한참 앉아 있었다. 월요일에 입을 다물면 어떻게 되는지는 계산이 됐다. #4479는 봇 로그의 reason 필드 안에 숫자로만 있었다. 봇 코드를 읽지 않으면 스냅샷 버그도 안 보이고, 스냅샷 버그가 안 보이면 #4479가 왜 플래그를 껐는지 아무도 묻지 않는다. 민석의 초안은 그대로 올라간다. “봇 운영자의 판단 착오.” 지안의 이름으로.
9월 옥상에서 지안이 했던 말이 생각났다. 안 나왔으면 말했을 거야. 그러고 나서 규정에 내 이름도 적었겠지.
4
월요일 오후 두 시, 포스트모템 회의실.
민석은 초안을 화면에 띄우고 읽었다. 도현은 그 문서를 처음 봤다. “게이트 봇의 자동 원복 기능이 블랙 위크 오픈 직전에 동작하도록 설정되어 있었음. 봇 운영자는 해당 구조에 대해 설계 단계부터 반대 의견을 표명해 왔으며, 오픈 2분 전 채널에 관련 메시지를 게시함. 수동 개입 여부는 확인 중이나, 봇 설정 판단의 책임 소재는 명확함.”
“지안 씨.” 민석이 말했다. “9월에 지안 씨가 그랬죠. 실수가 아니면 따져야 한다고. 저는 그 말이 맞다고 생각했어요. 그래서 이번에도 따지는 겁니다. 봇이 왜 하필 09:59:41에 돌았는지, 설명해 주세요.”
지안은 노트북을 열지 않고 있었다. 도현은 그게 이상하다고 생각했다. 그러다 깨달았다. 지안은 봇 로그의 reason 필드를 이미 봤을 것이다. #4479가 누구 변경인지도 봤을 것이다. 그리고 지금 아무 말도 하지 않고 있었다.
“제가 설명하겠습니다.” 도현이 말했다.
회의실이 도현을 봤다. 지안도.
도현은 화면을 자기 노트북으로 넘겼다. 감사 로그 원본. reason 필드. #4479. 목요일 09:59:41의 콘솔 변경, actor 유도현. 반응 0개짜리 봇 스레드. 그리고 approve.py의 두 함수와 revert.py의 세 줄.
“봇이 09:59:41에 돈 건 제가 목요일 09:59:41에 규칙을 고쳤기 때문이에요. 승인 필요한 변경인 줄 몰랐고, 봇이 알려줬는데 안 읽었어요. 그건 제 겁니다. 그리고 봇이 규칙 한 줄만 되돌리지 않고 플래그를 통째로 끈 건 PR 경로 승인이 스냅샷을 안 남기는 버그 때문이에요. 그건 봇 코드 문제고요. 두 개가 합쳐져서 났어요. 하나만 있었으면 안 났어요.”
“수동 개입은요.” 민석이 물었다.
“없어요. on behalf of는 봇 소유자를 찍는 필드예요. 9월에 CI 토큰이 제 이름을 찍었던 거랑 같은 거예요. 그때 본부장님도 그 자리에 계셨잖아요.”
민석이 지안을 봤다. “버그, 맞아요?”
“맞아요.” 지안이 처음으로 입을 열었다. “PR 승인 경로에서 스냅샷 저장을 안 해요. 제 버그예요. 10월에 만들 때 PR 경로는 나중에 붙인 거라 빠뜨렸어요. 테스트도 콘솔 경로만 있어요.”
“그럼 누구 책임이에요.”
“9월에 제가 그랬죠. 실수를 숨기지 않게 하려고 쓰는 원칙이라고.” 지안은 민석을 똑바로 봤다. “이번엔 실수예요. 두 사람의 실수고, 그 두 실수가 24시간 뒤에 만나도록 방치한 시스템이에요. 원칙 쓰세요. 그거 쓰라고 있는 거예요.”
민석은 한참 안경을 만졌다. 그러고는 초안 파일을 닫았다.
“액션 아이템부터 다시 씁시다.”
액션 아이템은 넷이었다. 스냅샷 버그 수정과 PR 경로 테스트. 미승인 변경 알림을 스레드가 아니라 변경자 본인의 DM으로. 자동 원복은 해당 변경이 건드린 필드만 되돌리고, 이후 변경과 충돌하면 사람에게 확인 요청. 그리고 대형 이벤트 오픈 전후 두 시간은 자동 원복 동결.
5
월요일 밤 열한 시 사십 분, 도현은 회사 뒷골목 술집의 유리문을 열었다. 회식은 없었다. 그냥 왔다.
지안이 안쪽 테이블에 있었다. 소주 한 병이 반쯤 비어 있었다.
“먼저 시작했어.”
“늦으면 그러라면서요.”
도현은 맞은편에 앉았다. 사장님이 잔을 하나 더 갖다 줬다. 지안이 도현의 잔을 먼저 채우고 자기 잔을 채웠다. 순서는 지켰다.
“봇 코드 읽었어?”
“주석이 없어서 오래 걸렸어요.”
“주석 달아서 PR 올려.”
“장식이라면서요.”
“장식이라도 있으면 뭘 안 읽었는지는 보이잖아.”
도현은 잔을 비웠다. TV에서는 소리를 죽인 야구 재방송이 나오고 있었다. 11월에 야구 재방송을 트는 술집이었다.
“팀장님은 왜 아무 말도 안 했어요. #4479 보셨잖아요. 제 이름 찍힌 거.”
지안은 대답하기 전에 잔을 비웠다.
“네가 말할지 보고 싶었어.”
“안 했으면요.”
“그럼 내가 했겠지. 로그가 있으니까.” 지안이 병을 들었다. “근데 네가 하는 게 나았어. 나는 9월에 남의 이름으로 찍힌 로그를 뒤집어 본 사람이고, 너는 이번에 네 이름으로 찍힌 로그를 남 앞에 꺼낸 사람이야. 그건 다른 거야.”
“칭찬이에요?”
“아니. 다음 회식은 목요일이야. 열한 시 반.”
“싫어요.”
도현은 잔을 내밀었다. 지안이 채웠다.