본문으로 건너뛰기
버튼 하나 뒤에서

4장

포인트 통장

적립과 사용이 동시에 몰리면 포인트 잔액은 맞을까요? 잔액 숫자만 고치는 것과 입출금 내역을 남기는 것, 무엇이 다를까요?

  1. 1포인트 통장에는 잔액 숫자 하나만 적혀 있어요.

  2. 2두 사람이 동시에 잔액을 읽고, 각자 100P를 더해 적으려 해요.

  3. 3둘 다 "5,100"으로 덮어써서, 한 사람 몫 100P가 사라졌어요.

  4. 4입출금 내역을 한 줄씩 남기면, 합계로 잔액을 언제든 다시 맞춰 볼 수 있어요.

잔액 숫자만 고치기

통장처럼 내역 남기기

기록 불러오는 중…

…

잔액 숫자만 고쳤더니 사라진 포인트 (200건 동시에)

실제 서버와 데이터베이스에 보내요: 시작 5,000P에서 150명이 100P씩 적립, 50명이 100P씩 사용, 모두 동시에. 하나도 빠짐없이 셌다면 5,000 + 15,000 − 5,000 = 15,000P예요.

왜 15,000P가 아니라 …일까요?

있어야 할 잔액은 요청을 하나도 빠짐없이 셌을 때의 값이고, 실제로 남은 잔액은 끝나고 잔액 칸에 적혀 있던 값이에요. 잔액 숫자만 고치는 방식에선 여러 사람이 같은 순간에 같은 옛 잔액을 읽고, 각자 계산한 값을 그 위에 덮어써요. 그래서 먼저 적은 사람의 몫이 지워져요.

먼저 두 명만

가읽음 5,000P+100 → 5,100P 적음나읽음 5,000P+100 → 5,100P 적음잔액 칸5,100P5,200P여야 해요
  1. 가와 나가 동시에 잔액을 읽어요. 둘 다 5,000P를 봐요.
  2. 가: 5,000 + 100 = 5,100P를 적어요.
  3. 나: 5,000 + 100 = 5,100P를 적어요. 가가 적은 5,100P 위에 덮어써요.
있어야 할 잔액
5,000 + 100 + 100 = 5,200P
실제로 남은 잔액
5,100P
사라진 포인트
100P (가의 몫)

이제 200명

기록 불러오는 중…

내역이 있으면 언제든 다시 맞춰 볼 수 있어요

잔액 숫자만 있으면 틀려도 틀린 줄 몰라요. 입출금 내역을 한 줄씩 남겨 두면, 내역을 전부 더한 값과 잔액 칸을 비교해서(회계 용어로 '대사') 어긋남을 바로 잡아낼 수 있어요.

잔액 숫자만 고치기

잔액 칸
…
내역 합계
…
차이
…

내역은 다 남았는데 잔액 칸만 틀렸어요. 대사가 없었다면 아무도 몰랐을 거예요.

통장처럼 내역 남기기

잔액 칸
…
내역 합계
…
차이
…

한 명씩 차례로 처리해서 조금 느리지만, 잔액과 내역이 언제나 같아요.

버튼을 누르면 마지막 실행의 잔액 칸과 내역 합계를 데이터베이스에서 직접 다시 세요.

0원 밑으로는 못 써요

통장 방식은 잔액을 확인하는 순간부터 다 쓸 때까지 그 통장을 잠가요. 그래서 "잔액 확인 → 사용" 사이에 다른 사용이 끼어들어 잔액이 마이너스가 되는 일이 없어요.

기록 불러오는 중…

숫자로 본 결과

…
잔액 숫자만 고칠 때 사라진 포인트
적립 150건(+100P)·사용 50건(−100P)을 동시에. 내역 합계와 잔액 칸이 이만큼 어긋났어요.
…
통장 방식에서 어긋난 포인트 (잔액 − 내역 합계)
…
잔액보다 많이 쓰려고 하면
기록 불러오는 중…. 로컬 API(NestJS) + MySQL 8.4, 맥북 한 대에서 잰 값이라 실행마다 사라지는 양이 달라요. 기록 파일: ledger-recorded-run.json
개발자용 자세히lost update, read-modify-write, SELECT … FOR UPDATE, append-only 원장, 대사(reconciliation), 실제 DB 행

흐름 따라가기

비유 ↔ 용어

잔액 칸
PointWallet.balance
입출금 내역
PointEntry: 추가만 하는 원장 (append-only)
덮어쓰기
애플리케이션에서 읽고 계산해서 쓰는 read-modify-write → lost update
통장 방식
한 트랜잭션: SELECT balance … FOR UPDATE(행 잠금) → 0 미만 검사 → INSERT PointEntry → UPDATE balance = balance + ?
맞춰 보기
대사(reconciliation): balance vs SUM(PointEntry.amount)

요청 흐름

  1. 브라우저 → API simulatePoints(200)이 POST /points/simulate { requests: 200 }를 보내요.
    • 다른 실험 중이면 409, 쿨다운 중이면 429 (IP당 10초 6번도 429)
  2. API → MySQL PointsService.simulateBothModes가 덮어쓰기 → 통장 순서로 돌려요. 지갑마다 먼저 잔액 5,000과 opening 내역 한 줄로 리셋해요.
  3. API → MySQL 요청 200개를 동시에 던져요. 적립 +100 세 번에 사용 −100 한 번 꼴이라, 정상이면 15,000이에요.
    • 덮어쓰기 applyByOverwrite: 잔액 읽기 → 내역 INSERT → balance = 읽은 값 + amount로 UPDATE (트랜잭션 없음)
    • 통장 applyByLedger: 트랜잭션 안에서 FOR UPDATE로 지갑 행 잠금 → 0 미만이면 거절 → 내역 INSERT → balance = balance + amount
  4. API → MySQL 끝나면 reconcile()이 잔액 칸과 내역 합계를 비교해요. 통장 쪽은 잔액보다 100 많은 금액을 써 보고 거절되는지도 확인해요.
  5. API → 브라우저 200 { results: [overwrite, ledger] }. 요청마다 읽은 시각, 쓴 시각, 본 잔액이 들어 있어요.
  6. 브라우저 → API 아래 표는 GET /points/:mode/entries?limit=8로 실제 행을 읽어요.

DB에는 이렇게 남아요

PointWallet 행 (한 번 실행한 예)
idnamebalanceupdatedAtSUM(amount)PointEntry 행 수
47sim-overwrite49002026-10-20 05:00:35.15715000201
48sim-ledger150002026-10-20 05:00:35.40515000201

200건 실험 직후 읽었어요. 오른쪽 두 칸은 PointEntry를 센 값이에요.

PointEntry 행 (같은 실행)
idwalletNameamountreasoncreatedAt
8875sim-overwrite5000opening2026-10-20 05:00:35.080
8876sim-overwrite100earn2026-10-20 05:00:35.121
8877sim-overwrite100earn2026-10-20 05:00:35.121
8883sim-overwrite-100spend2026-10-20 05:00:35.121

sim-overwrite 지갑의 앞부분이에요. 시각은 UTC예요.

표를 이렇게 읽어요

  • 덮어쓰기 쪽도 내역 201줄(opening + 200)은 다 남았고 합계도 15,000이에요. 잔액 칸만 4,900이에요.
  • 마지막에 쓴 요청이 옛 잔액 5,000을 읽고 100을 쓴 값을 덮어쓴 거예요. 대사가 이 어긋남(−10,100)을 잡아내요.

한계·운영에선

  • 지갑 한 행을 잠그므로 한 사람에게 요청이 몰리면 줄이 길어져요 (위 시간 비교).
  • 수백 개가 같은 잠금을 기다려서 트랜잭션 대기·제한 시간을 15초로 늘려 뒀어요 (기본 2초/5초).
  • 실무에선 잔액 칸을 아예 두지 않고 SUM으로만 계산하거나, 잔액은 캐시로 두고 매일 밤 대사 잡으로 검증해요.

개발자 도구로 보면

  • Network에는 POST /points/simulate 한 건이에요. 요청 200개는 서버 안에서 동시에 돌아요.
  • 응답의 reconciliation.difference가 0이 아니면 장부와 잔액이 어긋난 거예요.

코드·문서

-- 통장 방식 (한 트랜잭션)
SELECT balance FROM PointWallet WHERE name = ? FOR UPDATE;  -- 다른 요청은 여기서 대기
-- balance + amount < 0 이면 거절
INSERT INTO PointEntry (walletName, amount, reason) VALUES (?, ?, ?);
UPDATE PointWallet SET balance = balance + ? WHERE name = ?;
COMMIT;

-- 대사
SELECT w.balance, (SELECT SUM(amount) FROM PointEntry e WHERE e.walletName = w.name) AS entriesSum
FROM PointWallet w WHERE w.name = ?;

PointEntry 최근 행 (실제 DB)

sim-overwrite

idamountreasoncreatedAt
아직 안 불러왔어요

sim-ledger

idamountreasoncreatedAt
아직 안 불러왔어요