갱신 손실(lost update) — 동시에 들어온 포인트 적립이 한 번만 반영될 때
포인트 적립 요청이 두 번 다 성공했는데 잔액에는 한 번만 들어가 있어요. 에러가 없어서 정산 때가 돼서야 알게 되죠. 동시 요청이 한쪽 쓰기를 덮어쓰는 이유와 막는 방법을 정리했어요.
포인트를 적립하는 코드예요. 현재 값을 읽고, 더하고, 저장해요. 지극히 평범한 흐름이고 테스트도 다 통과했어요.
그런데 정산해 보니 적립된 총액이 지급했어야 할 금액보다 적어요. 로그에는 적립 요청이 두 번 다 성공으로 찍혀 있는데, 실제 잔액은 한 번만 반영돼 있어요.
에러가 없으니 알아차릴 방법이 없어요. 사용자가 "포인트가 안 들어왔다"고 문의해야 알게 되고, 그마저도 재현이 안 돼서 "일시적 오류"로 닫히기 쉬워요.
조용한 데이터 손실의 전형이에요. 그리고 돈이나 재고처럼 합계가 맞아야 하는 값에서는, 몇 달 뒤 정산 대사에서야 규모가 드러납니다.
왜 그런가
읽고, 계산하고, 쓰는 세 단계 사이에 다른 트랜잭션이 끼어들 수 있어요.
두 요청이 동시에 잔액 100 을 읽어요. 하나는 10 을 더해 110 을 쓰고, 다른 하나도 100 에 20 을 더해 120 을 써요. 마지막에 쓴 값이 남으니 최종 잔액은 120 이에요. 원래는 130 이어야 하는데 10 이 사라졌어요.
이걸 잃어버린 갱신이라고 불러요. 두 쓰기가 모두 성공했는데 결과는 한 번만 반영된 상태예요.
핵심은 "읽은 시점의 값"을 근거로 새 값을 계산했다는 데 있어요. 그 근거가 쓰는 시점에는 이미 낡았는데, 코드는 그걸 모르고 덮어써요.
기본 격리 수준은 이걸 막아 주지 않아요. READ COMMITTED 에서는 각자 커밋된 값을 읽었으니 아무 규칙도 어기지 않았어요. MySQL 의 REPEATABLE READ 도 못 막아요 — 읽기는 스냅샷이지만 쓰기는 최신값을 보니까요. PostgreSQL 의 REPEATABLE READ 는 감지해서 실패시켜요.
그래서 이 문제는 격리 수준을 올려서 푸는 게 아니라, 쓰는 방식을 바꿔서 푸는 게 맞습니다.
코드로 보면
읽은 값을 근거로 쓰지 않는다:
SELECT balance FROM accounts WHERE id=1; -- 100
-- 애플리케이션에서 100 + 10 계산
UPDATE accounts SET balance = 110 WHERE id=1;
-- 그사이 남이 120 을 썼어도 그냥 덮는다
· 1) 원자적 갱신 — 값을 읽어 오지 않는다 (제일 간단)
UPDATE accounts SET balance = balance + 10 WHERE id=1;
· 2) 낙관적 잠금 — 버전이 그대로일 때만 쓴다
UPDATE accounts SET balance = :new, version = version + 1
WHERE id = 1 AND version = :read_version;
-- 영향받은 행이 0 이면 누가 먼저 바꾼 것 → 다시 읽고 재시도
· 3) 비관적 잠금 — 읽을 때부터 잠근다
SELECT balance FROM accounts WHERE id=1 FOR UPDATE;✕ 문제 지점 · ✓ 해법 · 그대로 돌려 보기 전에 자기 환경에 맞게 고쳐 주세요.
실전에서는
세 가지 해법이 있고, 고르는 기준은 충돌 확률과 계산의 복잡도예요.
원자적 갱신이 가장 쌉니다. balance = balance + 10 처럼 DB 안에서 계산하면 읽기와 쓰기 사이의 틈이 아예 없어요. 단순 증감이면 이걸 쓰세요. 조건이 필요하면 WHERE 에 넣으면 돼요 — balance >= 10 을 조건에 넣으면 잔액 부족도 원자적으로 걸러집니다.
낙관적 잠금은 계산이 복잡해서 애플리케이션에서 해야 할 때 써요. 버전 컬럼을 두고, 읽을 때의 버전이 그대로일 때만 쓰기가 성공하게 해요. 영향받은 행 수가 0 이면 누가 먼저 바꾼 거니 다시 읽고 재시도합니다. 충돌이 드물 때 유리해요 — 평소엔 락 비용이 0 이니까요.
비관적 잠금은 충돌이 잦을 때 써요. 읽을 때부터 잠그니 확실하지만, 락 대기가 생기고 교착 위험도 있어요. 인기 상품의 재고처럼 경합이 확실한 자리에 맞아요.
여기서 자주 하는 실수가 있어요. 낙관적 잠금을 도입하고 재시도를 안 만드는 것. 버전 충돌로 0행이 갱신됐는데 그걸 확인하지 않으면, 실패를 성공으로 착각해요. 원래 문제보다 더 나빠지는 거죠. 영향받은 행 수를 반드시 확인해야 합니다.
ORM 을 쓰면 이 문제가 잘 안 보여요. 엔티티를 불러 필드를 바꾸고 저장하면, ORM 이 UPDATE 문을 만들면서 모든 필드를 덮어써요. 다른 트랜잭션이 바꾼 다른 필드까지 옛 값으로 되돌리는 일이 생겨요. 대부분의 ORM 이 버전 필드 기반 낙관적 잠금을 지원하니 그걸 켜는 게 좋습니다.
그리고 이건 DB 안의 문제만이 아니에요. 사용자가 웹 화면에서 폼을 열어 두고 오래 있다가 저장하면, 그 사이 다른 사람이 바꾼 내용을 덮어써요. 같은 문제가 사람 시간 규모로 일어나는 거고, 해법도 같아요 — 버전을 폼에 실어 보내면 됩니다.
처방
- 단순 증감이면 원자적 갱신으로 바꾸세요. 읽어서 계산하지 말고 DB 안에서 계산하세요. 조건이 필요하면 WHERE 에 넣으세요.
- 계산이 복잡하면 낙관적 잠금을 쓰세요. 버전 컬럼을 두고, 영향받은 행 수를 반드시 확인하고, 0 이면 재시도하세요.
- 경합이 확실한 자리는 비관적 잠금이 나아요. 대신 잠근 뒤에는 짧게 끝내고, 그 안에서 외부 호출을 하지 마세요.
- ORM 의 낙관적 잠금 기능을 켜세요. 버전 필드 하나로 대부분의 사고가 막혀요.
- 부분 갱신을 쓰세요. 바뀐 필드만 UPDATE 하면 다른 필드를 덮어쓰는 사고가 줄어요.
- 사용자 편집 화면도 같은 방식으로 보호하세요. 폼에 버전을 실어 보내고, 저장 시점에 어긋나면 "다른 사람이 먼저 수정했습니다"를 보여 주세요. 조용히 덮는 것보다 훨씬 낫습니다.
- 금액·재고처럼 손실이 눈에 띄지 않는 값은 정합성 검사를 따로 두세요. 이력의 합과 현재 값을 대조하는 배치가 조용한 손실을 잡아 줍니다.
지금 확인해 보세요
조용한 손실을 잡아내는 법:
· 이력의 합과 현재 값이 맞는가 — 정합성 배치
SELECT a.id, a.balance,
coalesce(sum(h.amount), 0) AS from_history
FROM accounts a
LEFT JOIN point_history h ON h.account_id = a.id
GROUP BY a.id, a.balance
HAVING a.balance <> coalesce(sum(h.amount), 0);
· 낙관적 잠금 충돌이 얼마나 나는지 (앱 지표)
갱신된 행 수가 0 인 횟수를 카운터로 올린다
이 값이 높으면 비관적 잠금이나 큐를 검토한다
· 같은 행을 동시에 갱신하는 경합이 있는지
SELECT wait_event_type, wait_event, count(*)
FROM pg_stat_activity WHERE state='active'
GROUP BY 1,2 ORDER BY 3 DESC;핵심 한 줄읽은 값을 근거로 쓰지 마라. 쓰는 순간 그 값은 낡았을 수 있다.