갱신 손실(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 안의 문제만이 아니에요. 사용자가 웹 화면에서 폼을 열어 두고 오래 있다가 저장하면, 그 사이 다른 사람이 바꾼 내용을 덮어써요. 같은 문제가 사람 시간 규모로 일어나는 거고, 해법도 같아요 — 버전을 폼에 실어 보내면 됩니다.

처방

  1. 단순 증감이면 원자적 갱신으로 바꾸세요. 읽어서 계산하지 말고 DB 안에서 계산하세요. 조건이 필요하면 WHERE 에 넣으세요.
  2. 계산이 복잡하면 낙관적 잠금을 쓰세요. 버전 컬럼을 두고, 영향받은 행 수를 반드시 확인하고, 0 이면 재시도하세요.
  3. 경합이 확실한 자리는 비관적 잠금이 나아요. 대신 잠근 뒤에는 짧게 끝내고, 그 안에서 외부 호출을 하지 마세요.
  4. ORM 의 낙관적 잠금 기능을 켜세요. 버전 필드 하나로 대부분의 사고가 막혀요.
  5. 부분 갱신을 쓰세요. 바뀐 필드만 UPDATE 하면 다른 필드를 덮어쓰는 사고가 줄어요.
  6. 사용자 편집 화면도 같은 방식으로 보호하세요. 폼에 버전을 실어 보내고, 저장 시점에 어긋나면 "다른 사람이 먼저 수정했습니다"를 보여 주세요. 조용히 덮는 것보다 훨씬 낫습니다.
  7. 금액·재고처럼 손실이 눈에 띄지 않는 값은 정합성 검사를 따로 두세요. 이력의 합과 현재 값을 대조하는 배치가 조용한 손실을 잡아 줍니다.

지금 확인해 보세요

조용한 손실을 잡아내는 법:

코드
· 이력의 합과 현재 값이 맞는가 — 정합성 배치
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;

핵심 한 줄읽은 값을 근거로 쓰지 마라. 쓰는 순간 그 값은 낡았을 수 있다.

브루니가 적어요

색을 바꾸며 숨는 카멜레온이에요. 운영에서 실제로 터지는 문제를 한 편씩 적어요. 같은 실수를 두 번 하지 않으려고 쓰는 기록이에요.

트랜잭션·락·DB 운영 글 21편 모두 보기 →