비즈니스 현실7 분 읽기

재연결할 때마다 청구가 뒤로 갔습니다

23.9분을 통화한 세션이 4.3분만 차감됐습니다. 원가는 전부 나갔고요. 정산 함수 한 줄이 시간을 되감고 있었습니다.

#reality-check#gotchas#first-principles
Left panel: a session billed 258 seconds. Right panel: the same session actually ran 1,433 seconds.
같은 세션. 왼쪽은 청구된 시간, 오른쪽은 실제로 흐른 시간입니다.

시간을 파는 앱을 만들고 있습니다. 사용자는 통화 분량을 사고, 서버는 쓴 만큼 차감합니다. 이런 구조에서 가장 무서운 건 원가가 나가는데 청구가 안 되는 경우입니다. 버는 쪽이 아니라 새는 쪽이 조용하기 때문입니다.

원가를 다시 재려고 서버 사용량과 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 함수 하나로 옮겼습니다. 세션 행을 잠그고, 원장 증분·지갑 차감·정산 완료 표시를 같은 트랜잭션에서 합니다.

그렇게 하니 부수적으로 얻은 것이 있습니다. 정산이 행 잠금 위에서 돌기 때문에, 함수가 반환된 시점에는 경쟁에서 이긴 쪽이 이미 커밋을 끝냈다는 게 보장됩니다. 그전까지는 "커밋 전 값을 읽을까 봐" 로컬에서 계산값을 들고 다니는 방어 코드가 있었는데, 그 방어가 필요 없어지면서 오히려 새 결함의 자리가 사라졌습니다.

자가진단 세 가지

사용량으로 과금한다면 이 세 가지를 확인해 보세요.

  1. 정산 값이 단조 증가합니까? 클라이언트 보고를 min()으로만 자르면 재연결 한 번에 뒤로 갑니다.
  2. 정리 경로가 정산을 합니까? 상태만 바꾸는 청소 코드는 영구 누수입니다.
  3. 실패한 정산을 다시 찾을 수 있습니까? 어느 질의에도 안 잡히는 상태 조합이 생기면 그 돈은 영영 사라집니다.

솔직한 부분

이 결함은 테스트로는 안 잡혔습니다. 순수 함수 테스트가 39건 있었고 전부 초록이었습니다. 잡아낸 것은 서버 사용량과 DB 기록을 나란히 놓고 본 것이었습니다. 두 숫자가 같은 사건을 다르게 세고 있었으니까요.

그리고 이걸 고치는 과정에서 코드 리뷰를 열두 라운드 돌렸는데, 라운드마다 새 결함이 나왔습니다. 상당수가 직전 라운드의 제 수정이 만든 것이었습니다. 그 이야기는 따로 쓸 만합니다.

지금 여러분의 과금 코드에서 "클라이언트가 보고한 값"을 쓰는 자리를 찾아, 그 값이 이전 값보다 작을 수 있는지 확인해 보세요.

유튜브

재연결할 때마다 청구가 뒤로 갔습니다

관련 글