3화

오프보딩

강민석 시점

사무실에서 지안과 도현 곁에 앉아 로그를 확인하는 민석과 뒤편의 빈 자리

1

1월 4일 월요일 오전 아홉 시 십 분, 강민석은 새해 첫 출근을 재무팀장의 전화로 시작했다.

“본부장님, 12월 정산이 두 배예요.”

“두 배가 뭡니까.”

“가맹점 정산 원장에 12월 건이 두 번 들어가 있어요. 전부요. 가맹점 대시보드에도 두 번 찍히고 있고, 오늘 아침 지급 배치가 상한 체크에 걸려서 자동으로 멈췄어요. 안 멈췄으면 두 배로 송금할 뻔했어요.”

민석은 전화를 끊고 인시던트 채널을 열었다. 이미 SEV1이 선언되어 있었다. 인시던트 커맨더는 서지안. 연휴 사흘 동안 쌓인 가맹점 문의가 CS 채널에 400건이었다. “12월 정산 금액이 이상합니다”가 절반, “그냥 두 배로 주시면 안 되나요”가 나머지 절반이었다.

민석은 지난 넉 달을 생각했다. 9월에 결제가 26분 죽었고, 11월에 쿠폰이 31분 죽었고, 이제 1월에 정산이 두 배가 됐다. 앞선 두 번 모두 그가 포스트모템 회의실 상석에 앉았고, 두 번 모두 처음 짚은 방향이 틀렸다. 9월에는 블레임리스를 말하다가 고의를 놓쳤고, 11월에는 고의를 찾다가 실수 둘을 하나로 몰았다. 이번에는 아무 말도 하지 말자고, 민석은 재무팀장의 전화를 끊으며 결심했다. 그 결심은 09:31에 깨졌다.

09:31:08 유도현 정산 원장 감사 로그 뽑았습니다. 1월 1일 00:05:12부터 00:41:50까지 INSERT 81,204건. actor: han.yuri (api_token)

민석은 그 이름을 세 번 읽었다.

2

한유리는 9월 셋째 주에 퇴사했다. 징계위원회를 열면 소문이 나고, 소문이 나면 감사 대응이 어려워진다는 이유로 민석은 퇴사 처리를 하루 만에 끝내라고 인사팀에 말했다. 유리도 그걸 원했다. 마지막 날 유리는 노트북을 반납하고 사원증을 반납하고 아무에게도 인사하지 않고 나갔다. 민석은 그 과정에 서명한 사람이었다.

그리고 그 이름이 새해 첫날 자정 5분에 프로덕션 정산 원장에 8만 건을 넣었다.

“보안팀 부르세요.” 민석이 브리지 콜에서 말했다. 아무 말도 하지 않겠다는 결심은 정확히 한 문장 만에 끝났다. “퇴사자 계정으로 프로덕션 접근이면 침해 사고예요. 대표님께 제가 보고합니다. 경찰 신고 여부는 보안팀 판단 듣고요.”

“본부장님.” 지안의 목소리였다. “아직 뭔지 몰라요.”

“actor가 han.yuri잖아요.”

“9월에는 actor가 유도현이었고 11월에는 actor가 저였어요. 둘 다 아니었고요.”

민석은 입을 다물었다. 그 말이 맞았다. 그리고 그 말이 맞다는 게 지금 그를 더 불안하게 했다. 이번에도 로그의 이름이 아니라면, 누구인가. 이번에도 로그의 이름이 맞다면, 그건 민석이 하루 만에 내보낸 사람이 넉 달 뒤에 돌아왔다는 뜻이었다.

“일단 복구부터 합니다.” 지안이 말했다. “도현 씨, 그 토큰으로 INSERT된 81,204건을 원본하고 대조해서 격리해요. 기록은 보존하고요. 지급 배치는 멈춘 채로 둡니다. 재무팀에는 오늘 지급 없다고 제가 말할게요.”

격리는 10:40에 끝났다. 가맹점 대시보드는 11시에 정상으로 돌아왔다. 지급 배치는 하루 늦게 돌기로 했다. 가맹점들에게는 “정산 데이터 검증 중”이라는 공지가 나갔다. 사실이었다.

정오에 대표가 민석을 불렀다. 대표의 책상 위에는 민석이 12월에 올린 플랫폼 재구축 예산안이 있었다. 예산안 3페이지에는 “최근 3개월 2회의 SEV1 장애, 레거시 결제 시스템의 구조적 한계”라고 적혀 있었다.

“강 본부장, 넉 달에 세 번이야.”

“네.”

“세 번 다 강 본부장 예산안에 도움이 되는 장애고.”

민석은 그 문장이 무슨 뜻인지 이해하는 데 2초가 걸렸다. 이해하고 나서는 아무 말도 하지 못했다.

“의심하는 게 아니야. 감사팀이 물어볼 거라는 거야. 내가 먼저 물어보는 게 나으니까 묻는 거고.”

“제가 만든 장애는 없습니다.”

“그럼 이번 건 누가 만들었는지 수요일까지 가져와.”

3

민석은 그날 오후 SRE 자리 옆에 의자를 끌어다 앉았다. 지안은 쳐다보지 않았고, 도현은 한 번 쳐다봤다. 민석은 아무 말도 하지 않았다. 이번에는 정말로.

지안이 첫 번째로 연 건 토큰이었다. han.yuri 이름이 붙은 API 토큰은 토큰 관리 콘솔에 없었다. 콘솔에 있는 유리의 토큰은 9월 퇴사 당일 전부 폐기됐고, 폐기 기록이 남아 있었다. 그런데 정산 원장 API는 그 토큰을 받아들였다.

“원장 API 전용 토큰이에요.” 지안이 말했다. “통합 콘솔 도입이 8월인데, 그 전에 만든 건 등록 안 된 게 있어요. 오프보딩 체크리스트는 콘솔에 있는 것만 폐기하고요.”

“그럼 콘솔 밖 토큰은 몇 개예요.”

“몰라요. 그게 문제고요.”

두 번째는 IP였다. 10.20.3.14. 사내 배치 서버 batch-03이었다. 민석은 외부에서 그 서버로 들어왔을 수도 있지 않느냐고 물었다. 도현이 접속 기록을 열었다. 새해 첫날의 대화형 로그인은 없었다. 원장 호출은 자정에 시작된 프로세스 하나에서 나가고 있었다.

도현이 batch-03에 접속했다. crontab을 열었다.

0 9 * * *  /opt/jobs/ledger-verify --report
0 0 1 * *  /opt/jobs/ledger-verify --fix

매일 아침 아홉 시에 리포트, 매월 1일 자정에 보정. /opt/jobs/ledger-verify의 소유자는 yuri였다. 코드는 사내 깃의 개인 저장소 yuri/ledger-verify에 있었다. 마지막 커밋은 8월 21일. 커밋 메시지는 “매월 1일 정산 원장 검증 및 보정, 매일 9시 리포트 DM”.

“유령이네요.” 도현이 말했다.

민석은 그 말의 뜻을 물으려다 참았다.

코드는 200줄이었다. –report는 이번 달 결제와 환불의 원장 누락을 찾아 유리의 슬랙 DM으로 보냈다. –fix는 지난달을 검사하되, 넣을 수 있는 것은 누락된 결제 행뿐이었다. 환불은 별도로 모아 ‘수동 복구 필요’라고 보고했다. 원장에 행을 넣은 뒤 가맹점별 잔액까지 다시 계산하는 기능은 없었다.

“1월 1일이면 지난달이 12월이죠.” 민석이 말했다.

“결제 이벤트는 12월 걸 읽었어요. 그런데 원장 조회는.”

도현이 로그 두 줄을 나란히 놓았다.

source range: 2026-12-01 ≤ paid_at < 2027-01-01 · payments 81,204 · refunds 0 ledger lookup: period 2027-00 · found 0

“2027년 0월이 어디 있어요.”

도현이 코드에서 두 군데를 짚었다. 원본 이벤트를 읽는 날짜 범위와 원장에 붙이는 월 이름을 따로 계산하고 있었다.

now = datetime.now()
end = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
start = (end - timedelta(days=1)).replace(day=1)
period = f"{now.year}-{now.month - 1:02d}"

날짜 범위는 달의 첫날에서 하루를 빼서 구했다. 그쪽은 12월이 맞았다. 월 이름은 숫자에서 1만 뺐다. 1월이면 now.month - 1은 0이었다. “2027-00” 원장은 비어 있었고, 12월 결제 8만 1,204건이 전부 누락으로 분류됐다.

“중복이면 API에서 막아야죠.” 민석이 말했다.

도현은 원장 API의 중복 검사 조건을 띄웠다. 월과 이벤트 ID를 묶어 검사했다. 같은 이벤트라도 월이 다르면 들어갔다. 월 이름이 유효한지 확인하는 코드도 없었다. 잡은 “2027-00”으로 8만 건을 넣었고, 가맹점 대시보드와 지급 배치는 월 이름 대신 원본 결제일로 집계했다. 12월이 두 번 나왔다.

“10월, 11월, 12월 1일에도 돌았을 텐데요.” 민석이 물었다.

“돌았어요.” 지안이 잡 로그를 열었다. “10월 1일 diff 0건, 11월 1일 diff 0건, 12월 1일 diff 0건. 넣을 게 없으니까 아무 일도 안 했어요. 1월 1일에 연도가 넘어가면서 처음으로 월 계산이 깨진 거예요. 이 잡은 작년 7월에 만들어졌고, 1월을 한 번도 안 겪어봤어요.”

“리포트는요. 매일 아홉 시에 DM을 보냈다면서요.”

“비활성 계정으로 보내려고 했어요.” 도현이 알림 모듈의 로그를 열었다. “실패해도 잡은 성공으로 끝나요. 메시지 본문하고 전송 오류는 여기 파일에만 남고요. 넉 달 동안 받는 사람 없는 리포트를 매일 만든 거예요.”

민석은 의자에 기대앉았다. 유리는 돌아오지 않았다. 유리가 두고 간 것이 돌아온 것이었다. 그리고 그것을 두고 가게 한 사람은, 하루 만에 퇴사 처리를 끝내라고 한 사람이었다.

지안이 잡 로그를 더 위로 올렸다. 9월, 8월. 그러다 멈췄다.

2026-08-29 09:00:03 ledger-verify –report · period 2026-08 · diff 412 · DM sent to @yuri

민석은 그 숫자를 알고 있었다. 9월 포스트모템에서 지안이 화면에 띄웠던 숫자였다. 8월 28일 마이그레이션에서 날아간 환불 412건.

“다음 날 알았어요.” 지안이 조용히 말했다. “9월에 유리 씨가 그랬죠. 다음 날 알았다고. 이게 알려준 거예요. 자기가 만든 잡이 자기 실수를 다음 날 아침 아홉 시에 알려줬고, 유리 씨는 그걸 고치는 대신 숨겼어요. 그리고 이 잡은 그 뒤로도 매일 아홉 시에 돌았어요. 만든 사람이 나간 뒤에도.”

“9월 1일에는 왜 안 고쳐졌습니까.”

도현이 그날의 –fix 결과를 열었다. 환불 412건, 자동 보정 0건, 수동 복구 필요 412건. 민석은 한 줄 아래의 통지 대상도 봤다. 역시 유리 하나였다.

“이것도 혼자 받았군요.”

지안은 대답 대신 로그를 저장했다.

4

화요일 오후, 지안이 유리에게 전화를 걸었다. 민석은 그 자리에 있겠다고 했고, 지안은 스피커폰을 켰다.

유리는 세 번째 신호에 받았다. 목소리는 9월 회의실에서 들었던 것보다 가벼웠다.

“팀장님. 무슨 일이에요.”

“batch-03에 크론 두 줄 두고 갔어요.”

침묵이 5초쯤 이어졌다.

“ledger-verify요.”

“네.”

“아.” 유리가 숨을 내쉬었다. “잊고 있었어요. 정말로요. 그건 7월에 제가 저 보려고 만든 거라 어디 문서에도 없어요. 마지막 날에는 노트북 반납하는 것만 생각했고요. 무슨 일이 났어요?”

“월을 2027-00으로 계산했어요. 12월 결제를 전부 누락으로 보고 다시 넣었고요. 81,204건요.”

“죄송해요.” 잠깐 있다가. “제 이름이 또 나왔네요.”

“이번엔 유리 씨가 아닌 걸 알아요. 그걸 확인하려고 건 거예요.”

“팀장님.” 유리가 말했다. “그 잡이 저한테 412건 알려줬어요. 8월 29일 아침에. 저 그 DM 보고 30분쯤 앉아 있었어요. 그때 팀장님한테 갔으면 됐는데.”

“알아요.”

“리포트 쪽은 남겨주면 안 될까요. 보정은 다시 봐야겠지만요. 누락은 찾아줬어요. 제가 보고도 말을 안 한 거지.”

전화가 끊기고 나서 민석은 한참 동안 스피커폰을 보고 있었다.

“본부장님.” 지안이 말했다. “수요일 포스트모템 문서, 이번엔 제가 안 쓸게요.”

“그럼 누가.”

“본부장님이요.”

5

수요일 오후 두 시, 포스트모템 회의실.

민석은 상석에 앉지 않았다. 화면을 자기 노트북으로 넘기고, 처음부터 읽었다. 대표도 뒤에 앉아 있었다.

“남아 있던 월간 잡이 2027-00이라는 잘못된 월을 만들었습니다. 원장 API는 그 값을 받아들였고, 월이 다르다는 이유로 중복 결제를 넣었습니다. 실행 권한은 퇴사 때 빠뜨린 레거시 토큰에서 나왔습니다. 오프보딩 체크리스트는 토큰 관리 콘솔 안만 확인하고 있었습니다.”

민석은 다음 줄에서 잠깐 멈췄다.

“그리고 9월에 퇴사 처리를 하루 만에 끝내라고 지시한 사람은 접니다. 그 지시가 오프보딩 체크리스트를 형식적으로 만들었습니다. 인수인계 항목에 ‘개인 자동화 스크립트 목록’이 있었는데 그 칸은 비어 있었고, 저는 그 문서에 서명했습니다.”

대표가 물었다. “고의였습니까.”

“퇴사 후 접속한 흔적은 발견하지 못했습니다. 잡은 8월의 코드 그대로 실행됐습니다. 최초 보고에서 침해 사고라고 단정한 부분은 정정하겠습니다.”

“예산안 3페이지는.”

“장애 두 건을 재구축 근거로 쓴 건 사실입니다. 그건 지우겠습니다. 재구축이 필요하면 장애 없이도 설명이 돼야 합니다.”

지안이 손을 들었다. “그 페이지는 남기세요. 이번에 찾은 레거시 토큰하고 원장 API도 예산에 넣고요. 일부러 장애를 냈다는 근거는 없어요. 인수인계를 서두른 책임은 방금 적으셨고요.”

민석은 지안을 봤다. 넉 달 만에 처음으로, 지안이 그를 보고 있었다.

액션 아이템에는 콘솔 밖 토큰과 주인 없는 잡 조사, 인수인계 빈칸에 대한 서명 차단, 자동 리포트의 팀 채널 이관과 전송 실패 알림이 들어갔다. 원장 API에는 월 검증과 월을 넘어선 중복 방지를 추가하기로 했다. 도현은 ledger-verify를 맡았다. 날짜 경계와 환불 제외 동작을 검증하고, 보정 기능은 검토가 끝날 때까지 끈 채 SRE 팀 공식 잡으로 가져오기로 했다.

민석은 조사 대상 스프레드시트를 열었다. 토큰 아래에는 ‘인증서 — 확인 필요’라는 줄도 있었다. 지안이 토큰 조사와 나눠서 처리하자고 말했다. 민석은 토큰 항목까지만 선택해 담당자를 넣었다. 그 아래 칸은 비어 있었다.

6

수요일 밤 열한 시 반, 회사 뒷골목 술집.

민석은 유리문 앞에서 잠깐 서 있었다. 안쪽 테이블에 둘이 보였다. 도현이 지안의 잔을 먼저 채우고 자기 잔을 채우고 있었다.

들어가지 말까, 하고 민석은 생각했다. 그러다 유리문을 열었다.

둘이 동시에 그를 봤다. 지안이 사장님에게 손짓을 했고, 잔이 하나 더 왔다.

“본부장님 온콜 아니시죠.”

“저는 온콜이 뭔지도 몰라요.”

“그럼 드세요.”

지안이 민석의 잔을 먼저 채우고, 도현의 잔을 채우고, 자기 잔을 채웠다. 민석은 그 순서를 봤다. 무슨 뜻인지는 몰랐지만, 순서가 있다는 건 알았다.

“수요일에 마셔도 됩니까.” 민석이 물었다.

“온콜 아닌 주에는요.” 도현이 말했다. “규정에 그렇게 돼 있어요. 쓴 사람이 옆에 있고요.”

TV에서는 소리를 죽인 야구 재방송이 나오고 있었다. 1월이었다.