[BACKEND] 잔액만 고치면 포인트가 사라진다 — 통장 내역이 근거인 이유

적립·사용 200건이 동시에 몰리자 10,100P가 증발했다. 내역 합계와 비교하니 개수가 보였다

2026년 10월 22일 · lim.Chuck

[BACKEND]

  1. [BACKEND] 버튼 하나 뒤에서 — 프론트가 서버를 나누는 법
  2. [BACKEND] 표 100장에 500명 — 선착순 동시성 제어 4가지를 직접 재 봤다
  3. [BACKEND] 로그인이 만료된 순간 요청 5개 — 리프레시 토큰은 왜 한 번만 써야 하나
  4. [BACKEND] 클릭 1만 건 — 느린 이유는 요청 수가 아니라 커밋 횟수였다
  5. [BACKEND] 잔액만 고치면 포인트가 사라진다 — 통장 내역이 근거인 이유
  6. [BACKEND] 결제 버튼을 잠가도 두 번 빠진다 — 같은 시도에는 같은 번호표
  7. [BACKEND] 메뉴판은 5초만 믿는다 — 캐시가 비는 순간과 메뉴가 바뀌는 순간
  8. [BACKEND] 서버에서 이미지를 굽지 않는다 — blue/green으로 배포 중 실패 0번

포인트 잔액은 숫자 하나처럼 보인다. 읽고, 더하고, 그 결과를 다시 쓰면 된다. 그 세 단계 사이에 다른 요청이 끼어들면 먼저 더한 값은 사라진다. 버튼 하나 뒤에서 4장은 잔액 칸만 고치는 방식과, 내역을 남기고 잔액을 그 내역에 맞추는 방식을 같은 지갑에 200건씩 넣어 비교했다.

이전 글: [BACKEND] 클릭 1만 건


실험

시작 잔액은 5,000P다. 요청 200개가 동시에 들어온다. 네 번 중 세 번은 +100P, 한 번은 −100P라서 적립 150건, 사용 50건이다.

1
기대 잔액 = 5,000 + 150×100 − 50×100 = 15,000P

시작 잔액이 충분해서, 정상적으로 직렬화되면 거절되는 요청은 없다. 끝난 뒤 잔액 칸과 내역 합계를 비교한다. 둘의 차이가 사라진 포인트다.

방식 잔액 칸 내역 합계 사라진 포인트 시간
읽고 덮어쓰기 4,900P 15,000P 10,100P 0.11초
통장 (잠금 + 내역 + 증가) 15,000P 15,000P 0P 0.26초

다른 실행에서는 덮어쓰기로 사라진 양이 9,900P였다. 재현이 매번 같지 않다.


덮어쓰기는 마지막에 쓴 사람이 이긴다

1
2
3
4
5
6
7
8
const wallet = await prisma.pointWallet.findUniqueOrThrow({ where: { name } });
if (wallet.balance + amount < 0) return rejected();

await prisma.pointEntry.create({ data: { walletName: name, amount, reason } });
await prisma.pointWallet.update({
  where: { name },
  data: { balance: wallet.balance + amount },
});

잔액을 읽고, 메모리에서 더한 뒤, 그 계산 결과를 잔액 칸에 그대로 넣는다. 1장의 쿠폰 카운터와 같은 lost update다.

1
2
3
4
5
A가 5,000을 읽음
B도 5,000을 읽음
A가 5,100을 씀
B가 5,100이 아니라 5,000+100=5,100을 다시 씀
→ A의 +100은 잔액에서 사라짐

200건이 각자 자기가 읽은 값으로 덮어쓰면, 중간에 반영된 적립과 사용이 잔액에 남지 않는다. 이번 측정에서는 15,000P가 되어야 할 잔액이 4,900P로 끝났다.

중요한 점은 이 방식도 내역 행은 남겼다는 것이다. 그래서 사후에 이렇게 말할 수 있다.

1
2
3
내역 합계 15,000P
잔액 칸    4,900P
차이      10,100P

내역이 없었다면 잔액 4,900P는 그냥 현재 잔액이다. 얼마가 사라졌는지, 사라졌는지조차 모른다. 동시성 버그는 실행마다 양이 달라서, 테스트를 한 번 통과했다고 없는 버그가 아니다. 잔액과 내역 합계를 비교하는 대사가 실패를 숫자로 고정한다.

1
2
3
4
5
6
const entriesSum = sum._sum.amount ?? 0;
return {
  balance: wallet.balance,
  entriesSum,
  difference: wallet.balance - entriesSum,
};

차이가 0이 아니면 장부가 틀린 것이다. 잔액 칸만 믿는 시스템에는 이 비교 자체가 없다.


통장 방식은 한 행을 잠그고 증가만 한다

1
2
3
4
5
6
7
8
9
10
11
12
await prisma.$transaction(async (tx) => {
  const [wallet] = await tx.$queryRaw`
    SELECT balance FROM PointWallet WHERE name = ${name} FOR UPDATE`;

  if (Number(wallet.balance) + amount < 0) return rejected();

  await tx.pointEntry.create({ data: { walletName: name, amount, reason } });
  await tx.pointWallet.update({
    where: { name },
    data: { balance: { increment: amount } },
  });
});

세 가지가 한 트랜잭션 안에 있다.

  1. SELECT ... FOR UPDATE로 그 지갑 행을 잠근다. 다른 요청은 이 트랜잭션이 끝날 때까지 같은 행을 읽지 못한다.
  2. 잠근 뒤에 0 미만인지 본다. 잔액보다 큰 사용은 내역을 남기지 않고 거절한다.
  3. 내역을 추가하고, 잔액은 읽은 값을 덮어쓰지 않고 balance + amount로 증가시킨다.

측정에서는 잔액 15,000P와 내역 합계 15,000P가 같았다. 끝난 뒤 15,100P를 쓰려는 요청은 거절됐다. 잠금 안에서 잔액을 다시 읽기 때문에, 그 사이에 잔액이 바뀐 상태를 보고 판단한다.

대가는 속도다. 0.11초가 0.26초가 됐다. 지갑 한 행을 잠그므로 한 사람에게 요청이 몰리면 줄이 길어진다. 1장에서 본 것처럼 그 줄은 커넥션을 붙잡고 선다. 포인트처럼 사용자마다 지갑이 나뉘어 있으면 서로 다른 행이라 영향이 작고, 한 지갑에 몰리는 이벤트성 적립이면 비관적 락의 줄이 그대로 드러난다.


잔액 칸은 필수가 아니다

통장 방식을 골랐지만, 잔액 칸을 잠그는 것만이 방법은 아니다.

방법 맞는 곳 비용
잔액 칸을 덮어쓰기 없음. 이번 실험에서 포인트가 사라짐 가장 빠름
잠금 후 내역 추가 + increment 지갑 단위 경합이 크지 않을 때 한 행에 줄이 섬
잔액은 저장하지 않고 SUM(amount)만 사용 내역이 진실이어야 할 때 내역이 길면 합계가 느려짐
잔액 칸은 캐시, 주기적으로 합계와 대사 읽기가 많고 잠시 어긋남을 감수할 때 대사 주기가 오차의 상한

원장(PointEntry)은 추가만 한다. 적립 행을 나중에 수정하지 않는다. 잘못 적립했으면 반대 부호 행을 하나 더 넣는다. 그러면 합계를 다시 계산해도 과거가 지워지지 않는다.

잔액 칸은 그 합계를 빨리 보여 주려고 적어 둔 결과다. 결과가 근거와 다르면 근거를 따른다. 이번 실험에서 근거가 없었던 것은 아니다. 덮어쓰기 방식도 내역은 남겼고, 그래서 10,100P가 사라진 사실을 셀 수 있었다. 없는 것은 잔액을 그 근거에 맞추는 쓰기와, 어긋남을 실패로 보는 검사였다.


이 장에서 가져온 것

  1. balance = 읽은 값 + amount는 동시에 쓰면 먼저 쓴 금액을 지운다.
  2. 사라진 양은 실행마다 달라진다. 9,900P일 수도 있고 10,100P일 수도 있다. 한 번의 성공으로 동시성 테스트를 통과시키면 안 된다.
  3. 내역이 있어야 “합계 15,000인데 잔액 4,900”이라고 말할 수 있다. 내역이 없으면 4,900이 맞아 보인다.
  4. 잠금과 increment는 그 어긋남을 쓰기 시점에 막는다. 대신 한 지갑에 몰리면 느려진다.

다음은 5장 결제다. 버튼을 잠가도 한 번만 눌러도 두 번 결제되는 이유와, 같은 시도에 같은 번호표를 붙이면 왜 한 번만 결제되는지를 본다.


참고