시간을 파는 앱을 만들고 있습니다. 사용자는 통화 분량을 사고, 서버는 쓴 만큼 차감합니다. 이런 구조에서 가장 무서운 건 원가가 나가는데 청구가 안 되는 경우입니다. 버는 쪽이 아니라 새는 쪽이 조용하기 때문입니다.
원가를 다시 재려고 서버 사용량과 DB 기록을 나란히 놓았습니다. 그러다 한 세션이 눈에 걸렸습니다.
- 시작 13:32:33, 종료 13:56:25 — 실제 23.9분
- 배정 10분
- 차감 4.3분
- 마지막 하트비트 13:56:21 — 끝까지 살아 있었습니다
- 상태: 정상 종료
배정이 10분인데 23.9분을 썼고, 그중 4.3분만 청구됐습니다. 여러분 시스템이라면 어디를 먼저 열어보시겠습니까?
되감기는 한 줄에 있었습니다
정산 함수는 이렇게 생겼습니다.
const elapsed = Math.floor((now - startedAt) / 1000);
const ceiling = Math.min(Math.max(elapsed, 0), granted);
return Math.max(Math.min(Math.floor(reported), ceiling), 0);클라이언트가 보고한 초를 받아 경과 시간과 배정치로 상한을 겁니다. 합리적으로 보입니다. 실제로 이 함수의 주석에는 "협조적인 클라이언트의 보고를 상한으로 자르는 함수"라고 적혀 있었고, 그 역할은 정확히 해내고 있었습니다.
빠진 것은 하한이었습니다.
서버에는 자체복구 경로가 있습니다. 앱이 끊겼다 돌아오면 살아 있는 세션을 되살려 줍니다. 그런데 앱은 재연결할 때 경과 초를 0부터 다시 셉니다. 그 낮은 값이 그대로 반영되면서, 이미 차감한 시간이 없던 일이 됐습니다.
토큰 발급 횟수(=재연결 횟수)와 누수량이 정확히 비례했습니다.
| 재연결 | 차감 | 하트비트 지속 | 잃은 시간 |
|---|---|---|---|
| 3회 | 258초 | 1,428초 | 1,170초 |
| 11회 | 30초 | 1,274초 | 1,244초 |
| 3회 | 309초 | 519초 | 210초 |
전부 합치니 48.1분이었습니다. 같은 기간 실제로 차감된 시간이 49.9분이었으니, 팔아야 할 시간의 절반을 못 받고 있었습니다.
두 번째 구멍은 주석이 이미 알고 있었습니다
같은 데이터를 보다가 하나를 더 찾았습니다. 앱이 종료 신호를 못 보내고 사라진 세션은 서버가 주기적으로 정리하는데, 그 정리 코드가 상태만 바꾸고 원장은 건드리지 않았습니다.
주석에는 이렇게 적혀 있었습니다. "사용자별 정확 정산까지 하는 리퍼는 공개 출하 전 과제다."
정직한 기록입니다. 다만 그 과제가 남아 있는 동안 계속 새고 있었습니다. 13건, 975초였습니다.
고친 방법
한 줄씩 막는 대신 정산 전체를 DB 함수 하나로 옮겼습니다. 세션 행을 잠그고, 원장 증분·지갑 차감·정산 완료 표시를 같은 트랜잭션에서 합니다.
그렇게 하니 부수적으로 얻은 것이 있습니다. 정산이 행 잠금 위에서 돌기 때문에, 함수가 반환된 시점에는 경쟁에서 이긴 쪽이 이미 커밋을 끝냈다는 게 보장됩니다. 그전까지는 "커밋 전 값을 읽을까 봐" 로컬에서 계산값을 들고 다니는 방어 코드가 있었는데, 그 방어가 필요 없어지면서 오히려 새 결함의 자리가 사라졌습니다.
자가진단 세 가지
사용량으로 과금한다면 이 세 가지를 확인해 보세요.
- 정산 값이 단조 증가합니까? 클라이언트 보고를
min()으로만 자르면 재연결 한 번에 뒤로 갑니다. - 정리 경로가 정산을 합니까? 상태만 바꾸는 청소 코드는 영구 누수입니다.
- 실패한 정산을 다시 찾을 수 있습니까? 어느 질의에도 안 잡히는 상태 조합이 생기면 그 돈은 영영 사라집니다.
솔직한 부분
이 결함은 테스트로는 안 잡혔습니다. 순수 함수 테스트가 39건 있었고 전부 초록이었습니다. 잡아낸 것은 서버 사용량과 DB 기록을 나란히 놓고 본 것이었습니다. 두 숫자가 같은 사건을 다르게 세고 있었으니까요.
그리고 이걸 고치는 과정에서 코드 리뷰를 열두 라운드 돌렸는데, 라운드마다 새 결함이 나왔습니다. 상당수가 직전 라운드의 제 수정이 만든 것이었습니다. 그 이야기는 따로 쓸 만합니다.
지금 여러분의 과금 코드에서 "클라이언트가 보고한 값"을 쓰는 자리를 찾아, 그 값이 이전 값보다 작을 수 있는지 확인해 보세요.