코인 잔액이 자꾸 틀어졌다
커머스 앱의 웹툰 서비스를 맡고 있었습니다. 유저가 코인을 사서 회차를 열어보는 구조였고, 코인은 매출과 바로 연결됐습니다.
"코인이 사라졌어요" 문의가 계속 늘었습니다. 코인에 만료 기능이 붙으면서 드러난 문제였는데, 만료가 원인은 아니었습니다. 원래 있던 문제를 만료가 보이게 만들었을 뿐입니다.
잔액을 저장할까, 매번 더할까
코인 잔액을 다루는 방법은 크게 둘입니다.
매번 히스토리를 합산합니다. 적립과 차감 기록만 남기고, 잔액이 필요할 때마다 전부 더합니다. 진실이 하나뿐이라 어긋날 수가 없습니다. 대신 조회가 O(n)입니다. 거래가 쌓일수록 느려집니다.
잔액을 따로 저장합니다. 읽기가 O(1)로 끝납니다. 대신 진실이 둘이 됩니다. 히스토리와 잔액이 계속 같은 값을 가리키도록 관리해야 합니다.
두 번째가 나쁜 선택은 아닙니다. 파생값을 미리 계산해두는 건 정당한 성능 판단입니다. 문제는 그 선택에 붙는 대가를 치르지 않는 것입니다. 저장한 잔액이 히스토리와 갈라지지 않으려면 원자성과 단일 통로가 필요한데, 우리 시스템에는 둘 다 없었습니다.
잔액과 히스토리가 따로 놀았다
먼저 짚어둘 게 있습니다. 히스토리가 없었던 게 아닙니다. 잔액도 있었고 히스토리도 있었습니다. 문제는 둘이 서로를 확인하지 않았다는 것이었습니다.
잔액은 여러 곳에서 단순 증감으로 고쳐지고 있었습니다. 새 기능을 붙일 때도, 관리자 도구에서도 잔액을 직접 건드렸습니다. 히스토리는 그때그때 기록만 남겼습니다. 트랜잭션으로 묶여 있지 않아서, 스케줄 작업이 중간에 실패하면 잔액만 바뀌고 히스토리가 빠지는 것도 가능했습니다.
그래서 값이 틀어져도 어디서 틀어졌는지 알 수가 없었습니다. 저는 끝내 단일 원인을 특정하지 못했습니다.
대가는 원자성이다
잔액을 저장하기로 했다면, 갱신이 원자적이어야 합니다.
가장 흔한 실수는 읽고, 계산하고, 쓰는 것입니다.
const user = await users.findOne({ _id: userId }); // balance: 100
await users.updateOne(
{ _id: userId },
{ $set: { balance: user.balance - 30 } }
);
두 요청이 동시에 들어오면 둘 다 100을 읽고, 둘 다 70을 씁니다. 30이 두 번 빠져야 하는데 한 번만 빠집니다. lost update입니다.
읽은 값을 쓰지 말고, 데이터베이스에 연산을 시켜야 합니다.
await balances.updateOne({ userId }, { $inc: { balance: -30 } });
UPDATE balances SET balance = balance - 30
WHERE user_id = ? AND balance >= 30; -- 영향 행 수로 성공 판정
락이 실제로 하는 일
우리 시스템은 MongoDB였습니다. MongoDB는 단일 문서 연산이 원자적입니다. $inc 한 줄이면 락을 직접 잡을 필요가 없습니다. WiredTiger가 문서 단위로 동시성을 제어하고, 쓰기 충돌이 나면 내부적으로 재시도합니다.
그래서 관계형 DB에서 쓰는 SELECT ... FOR UPDATE 같은 명시적 행 락이 MongoDB에는 없습니다. 대신 조건을 필터에 넣습니다.
// 잔액이 충분할 때만 차감. 조건이 안 맞으면 아무 일도 일어나지 않는다
const res = await balances.findOneAndUpdate(
{ userId, balance: { $gte: 30 } },
{ $inc: { balance: -30 } },
{ returnDocument: "after" }
);
if (!res) throw new Error("잔액 부족");
필터와 갱신이 한 연산 안에서 원자적으로 처리됩니다. 관계형에서 UPDATE ... WHERE balance >= 30을 쓰고 영향 행 수로 판정하는 것과 같은 역할이고, 발상은 compare-and-swap입니다.
버전 필드를 쓰는 낙관적 락도 같은 모양입니다.
const res = await docs.updateOne(
{ _id, version: expected },
{ $set: next, $inc: { version: 1 } }
);
if (res.matchedCount === 0) {
// 남이 먼저 바꿨다. 다시 읽고 재시도
}
문제는 바꿔야 할 문서가 둘 이상일 때입니다. 히스토리를 넣고 잔액을 갱신하는 건 서로 다른 컬렉션에 대한 두 번의 쓰기입니다. 문서 단위 원자성은 여기까지 보장해주지 않습니다. 그 사이에서 프로세스가 죽으면 정확히 우리가 겪던 상태가 됩니다.
MongoDB 4.0부터는 다중 문서 트랜잭션을 쓸 수 있습니다. 레플리카셋 구성이 필요합니다.
await session.withTransaction(async () => {
await history.insertOne(entry, { session });
await balances.updateOne(
{ userId },
{ $inc: { balance: entry.amount } },
{ session }
);
});
withTransaction은 일시적 충돌이 나면 콜백을 자동으로 다시 실행합니다. 그래서 콜백은 여러 번 돌아도 괜찮게 써야 합니다. 안에서 외부 API를 호출하면 안 되는 이유이기도 합니다.
나머지 주의점은 관계형 DB와 같습니다. 트랜잭션은 자원을 커밋까지 붙잡습니다. 길어지면 다른 요청이 밀립니다. MongoDB는 기본 트랜잭션 수명이 60초로 제한되어 있고, 넘기면 중단됩니다.
격리 수준별로 무엇이 보장되는지는 따로 정리해 뒀습니다.
창구를 하나로
원자 연산과 트랜잭션이 있어도, 잔액을 건드릴 수 있는 곳이 열 군데면 소용없습니다. 불변식을 강제하려면 강제할 지점이 하나여야 합니다.
async function applyCoinChange(p: {
userId: string;
amount: number;
reason: string;
idempotencyKey: string;
}) {
const dup = await history.findOne({ idempotencyKey: p.idempotencyKey });
if (dup) return dup;
await history.insertOne({ ...p, createdAt: new Date() });
return balances.findOneAndUpdate(
{ userId: p.userId },
{ $inc: { balance: p.amount } },
{ upsert: true, returnDocument: "after" }
);
}
멱등성 키에는 unique 인덱스가 반드시 필요합니다. 없으면 같은 키로 동시에 들어온 요청이 둘 다 통과합니다.
재계산은 본체가 아니라 백업이다
여기까지가 정답입니다. 그런데 당시 저는 원인을 특정하지 못한 상태였습니다. 어디서 새는지 모르는 채로 정답만 적용하면, 이미 틀어진 데이터는 그대로 남고 아직 모르는 경로가 있어도 알 방법이 없습니다.
그래서 히스토리를 기준으로 잔액을 검증하고, 다르면 다시 계산하도록 만들었습니다.
이건 원자성의 대체재가 아닙니다. 모르는 버그에 대한 백업입니다. 원자 연산과 트랜잭션이 아는 실패를 막는다면, 재계산은 모르는 실패를 수습합니다.
그리고 하나 더 중요한 게 있습니다. 보정할 때마다 알림을 남겨야 합니다. 조용히 고치기만 하면 시스템은 멀쩡해 보이는데 버그는 영영 남습니다. 어긋난 유저와 시각과 차이를 기록해두면 그게 곧 누락 경로의 지도가 됩니다. 히스토리는 유저에게도 보이기 때문에 "이거 했는데 기록이 없어요" 같은 문의도 같은 신호로 쓸 수 있었습니다.
발상 자체는 쿠버네티스 컨트롤러와 같습니다. 원하는 상태와 실제 상태를 계속 대조해서 수렴시키는 것. 코인은 그 작은 버전이었습니다.
한계도 분명합니다. 이건 잔액과 히스토리가 어긋나는 것만 잡습니다. 히스토리 자체가 빠지면 안정적으로 틀린 값을 줍니다.
재계산도 스스로 틀릴 수 있다
재계산은 저장된 잔액을 읽고, 히스토리를 합산하고, 둘을 비교합니다. 그런데 두 읽기가 서로 다른 순간을 보면, 그 사이에 들어온 정상 커밋 때문에 멀쩡한 값을 불일치로 판단합니다. 고칠 필요가 없는 걸 고칩니다.
일관된 스냅샷 안에서 읽어야 합니다. MongoDB에서는 트랜잭션을 열고 readConcern: "snapshot"으로 읽으면 두 읽기가 같은 순간을 봅니다. 관계형이라면 REPEATABLE READ 이상이 같은 역할을 합니다. 합산 자체가 여러 문서를 훑는 작업이라, 스냅샷이 없으면 합계도 흔들립니다.
기존 불일치는 유저 쪽으로 기울여 맞췄다
이미 틀어진 데이터가 남아 있었습니다. 방향을 정해야 했습니다.
잔액이 히스토리보다 적으면 채웠습니다. 많으면 그대로 두고 보정 기록을 남겼습니다. 어느 쪽이든 유저 코인을 뺏지 않았습니다. 코인 몇 개보다 신뢰가 비쌉니다. 대부분은 조용히 맞췄고, 문의를 주셨던 분들께는 따로 보상했습니다.
관련 문의는 0건이 됐습니다.
몇 년 전의 나
이 글에서 제일 하고 싶은 이야기입니다.
첫 스타트업에서 저는 히스토리 기반 재계산이 느리다는 이유로 결과값을 직접 저장하고 고치는 방식으로 바꿨습니다. 그 뒤로 예상 못 한 버그와 문의가 따라왔습니다.
몇 년 뒤에 남이 만든 같은 구조를 물려받았습니다. 이번엔 함정이 보였습니다.
그래서 저는 결과값을 미리 계산해두는 게 나쁜 선택이라고 생각하지 않습니다. 성능을 위한 정당한 판단입니다. 문제는 정합성 장치 없이 지름길만 가져오는 것입니다. 원자 갱신, 단일 통로, 재계산 안전망. 이게 없으면 두 개의 진실이 갈라지는 걸 막을 방법이 없습니다.
지금이라면
세 겹으로 짭니다. 각 층이 막는 실패가 다릅니다.
원자 연산과 트랜잭션으로 잔액을 항상 정확하게 유지합니다. 읽기는 O(1)입니다. 재계산은 백그라운드 안전망으로 내리고, 보정할 때마다 알림을 남깁니다. 스냅샷으로 재계산을 싸게 만듭니다. 특정 시점 잔액을 저장하고 이후 이벤트만 재생하면 됩니다.
트랜잭션만 있으면 모르는 버그에 약합니다. 재계산만 있으면 읽기가 느립니다. 둘을 겹쳐야 합니다.
당시 제 몫은 안정화와 관측 확보까지였습니다. 다음 단계는 쓰기 경로를 완전히 단일화하고 트랜잭션으로 묶는 것이었습니다.