여러 트랜잭션이 동시에 데이터를 변경하는 과정에서, 한 트랜잭션의 수정 내용이 다른 트랜잭션의 수정 내용에 의해 덮어씌워져 최종적으로 의도와 다른 값이 저장되는 현상이다.
이 글에서는 MySQL, PostgreSQL, MSSQL에서 Lost Update가 발생하는 상황과 방지 방법을 살펴본다. (낙관적 락은 제외)
두 트랜잭션이 같은 행을 동시에 읽고 수정하면 어떻게 되는지 살펴본다.
-- 초기 상태
balance = 100
-- T1: balance에서 20 차감
-- T2: balance에서 10 차감
-- 기대 결과: 70
-- 실제 결과: 80 또는 90 (어느 쪽이 마지막에 커밋하느냐에 따라)
타임라인으로 보면 다음과 같다.
T1: SELECT balance → 100
T2: SELECT balance → 100
T2: UPDATE accounts SET balance = 90 WHERE id = 1 -- 100 - 10
T2: COMMIT -- balance = 90
T1: UPDATE accounts SET balance = 80 WHERE id = 1 -- 100 - 20 (스냅샷 기준)
T1: COMMIT -- balance = 80 ← T2의 변경이 사라짐
T1은 자신이 읽은 스냅샷(100)을 기준으로 80을 저장하기 때문에, T2가 만든 90이 덮어씌워진다.
MySQL InnoDB의 REPEATABLE READ는 MVCC 기반이다. 트랜잭션 시작 시점의 스냅샷을 고정해서 읽기 때문에, 두 트랜잭션이 같은 값을 읽고 각자 UPDATE하면 마지막 커밋이 이전 변경을 덮어쓴다.
-- T1, T2 동시에 시작
BEGIN; -- T1
BEGIN; -- T2
-- 둘 다 같은 스냅샷 읽음
SELECT balance FROM accounts WHERE id = 1; -- T1: 100
SELECT balance FROM accounts WHERE id = 1; -- T2: 100
-- T2 먼저 커밋
UPDATE accounts SET balance = 90 WHERE id = 1; -- T2
COMMIT; -- T2: balance = 90
-- T1은 스냅샷(100) 기준으로 덮어씀
UPDATE accounts SET balance = 80 WHERE id = 1; -- T1
COMMIT; -- T1: balance = 80 ← T2 변경 손실
MySQL은 UPDATE 실행 시 충돌 여부를 감지하지 않는다. 단순히 현재 레코드에 새 값을 SET하기 때문에, 마지막 커밋이 항상 승리한다(Last Commit Wins).
주의: Lost Update가 발생하는 건 애플리케이션이 스냅샷 기준으로 미리 계산한 절댓값을 SET할 때다.
SET balance = balance - 20처럼 DB가 UPDATE 시점에 직접 연산하는 경우, MySQL은 현재 커밋된 최신값(90)을 기준으로 계산하므로 Lost Update가 발생하지 않는다. 반면 앱에서 스냅샷(100)을 읽고 계산한 뒤SET balance = 80으로 절댓값을 박으면, DB는 그 값을 그대로 저장해버려 T2의 변경이 손실된다.
1. Locking Read
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE; -- X Lock 획득
-- T2는 여기서 대기
UPDATE accounts SET balance = balance - 20 WHERE id = 1;
COMMIT;
FOR UPDATE로 읽기 시점에 배타락을 걸면 다른 트랜잭션의 수정을 직렬화할 수 있다.
2. SERIALIZABLE
모든 읽기에 공유락을 걸고, 쓰기 충돌 시 롤백한다. 공유락과 배타락은 동시에 가질 수 없으므로 Lost Update가 차단된다.
SET SESSION TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
SELECT balance FROM accounts WHERE id = 1; -- S Lock 자동 획득
UPDATE accounts SET balance = balance - 20 WHERE id = 1;
COMMIT;
PostgreSQL의 REPEATABLE READ는 같은 MVCC 기반이지만, MySQL과 달리 쓰기 시점에 버전 충돌을 감지한다. UPDATE/DELETE 실행 시 내가 읽었던 버전이 여전히 최신인지 확인하고, 다른 트랜잭션이 이미 수정했다면 오류를 발생시키고 롤백한다.
-- T1, T2 동시에 시작 (둘 다 REPEATABLE READ)
BEGIN; -- T1
BEGIN; -- T2
SELECT balance FROM accounts WHERE id = 1; -- T1: 100
SELECT balance FROM accounts WHERE id = 1; -- T2: 100
UPDATE accounts SET balance = 90 WHERE id = 1; -- T2
COMMIT; -- T2: balance = 90
UPDATE accounts SET balance = 80 WHERE id = 1; -- T1
-- ERROR: could not serialize access due to concurrent update
-- T1: ROLLBACK 자동 발생
T1은 자신의 스냅샷(balance=100)과 현재 버전(balance=90)이 다름을 감지하고 오류를 낸다.
단, 이 보호는 충돌하는 두 트랜잭션 모두 REPEATABLE READ 이상이어야 동작한다. 한쪽이 READ COMMITTED라면 버전 충돌 감지가 이루어지지 않아 Lost Update가 발생할 수 있다.
SSI(Serializable Snapshot Isolation) 기반으로 동작한다. REPEATABLE READ의 버전 충돌 감지에 더해, write skew처럼 직렬성 위반 가능성이 있는 패턴도 감지하여 롤백한다. Lost Update는 물론 Write Skew까지 방지 가능하다.
SELECT ... FOR UPDATE로 명시적 잠금MSSQL은 기본적으로 Lock 기반 동시성 제어를 사용한다. MVCC 기반인 MySQL, PostgreSQL과 달리, 읽기 시점에 락을 잡고 유지하는 방식으로 동시성을 제어한다.
행을 읽으면 S Lock(공유락)을 걸고 트랜잭션이 끝날 때까지 유지한다. 다른 트랜잭션이 동일 행을 UPDATE하려면 X Lock(배타락)이 필요한데, S Lock과 X Lock은 공존할 수 없으므로 T2는 T1이 커밋하기 전까지 대기한다. 결과적으로 두 트랜잭션이 직렬화된다.
-- T1 시작 (REPEATABLE READ)
BEGIN TRAN; -- T1
SELECT balance FROM accounts WITH (HOLDLOCK) WHERE id = 1;
-- T1이 S Lock 획득, 트랜잭션 종료까지 유지
-- T2 시작
BEGIN TRAN; -- T2
UPDATE accounts SET balance = 90 WHERE id = 1;
-- X Lock 요청 → T1의 S Lock과 충돌 → 대기
-- T1 먼저 커밋
UPDATE accounts SET balance = 80 WHERE id = 1;
COMMIT; -- T1: balance = 80, S Lock 해제
-- T2 대기 해제, X Lock 획득 후 진행
-- T2의 UPDATE는 현재 커밋된 값(80) 기준으로 실행
COMMIT; -- T2
모든 트랜잭션이 REPEATABLE READ일 때 Lost Update가 발생하지 않는 이유는 S Lock이 트랜잭션을 직렬화하기 때문이다.
MSSQL에서도 아래 환경에서는 Lost Update가 발생할 수 있다.
READ_COMMITTED_SNAPSHOT(RCSI) 활성화 시
ALTER DATABASE mydb SET READ_COMMITTED_SNAPSHOT ON;
이 설정을 켜면 READ COMMITTED 트랜잭션이 MVCC 방식으로 동작하면서 S Lock을 오래 유지하지 않는다. REPEATABLE READ 트랜잭션과 혼재하면 S Lock 기반 보호가 깨져 Lost Update가 발생할 수 있다.
SNAPSHOT 격리 사용 시
ALTER DATABASE mydb SET ALLOW_SNAPSHOT_ISOLATION ON;
SET TRANSACTION ISOLATION LEVEL SNAPSHOT;
SNAPSHOT 트랜잭션은 S Lock 없이 버전 데이터를 읽는다. REPEATABLE READ 트랜잭션과 혼합되면 동시 갱신이 가능해진다. SNAPSHOT 트랜잭션 간 충돌은 에러 3960으로 롤백되지만, READ COMMITTED(RCSI)와 혼합 시에는 Lost Update가 발생할 수 있다.
혼합 환경 대응
SELECT balance FROM accounts WITH (UPDLOCK, HOLDLOCK) WHERE id = 1;
혼합 격리수준 환경에서는 충돌 가능 구간에 명시적 비관적 잠금을 사용하는 것이 안전하다.
REPEATABLE READ에 Key-Range Lock을 추가한다. Lost Update는 물론 Phantom Read, Write Skew까지 방지한다.
| DB / 격리수준 | Lost Update 발생 여부 | 방지 메커니즘 |
|---|---|---|
| MySQL RR | 발생 가능 | 충돌 미감지, FOR UPDATE 필요 |
| MySQL SERIALIZABLE | 불가능 | 모든 읽기에 S Lock |
| PostgreSQL RR | 불가능 (모두 RR 이상일 때) | 쓰기 시점 버전 충돌 감지 후 롤백 |
| PostgreSQL SERIALIZABLE | 불가능 | SSI 기반 직렬성 위반 감지 |
| MSSQL RR | 불가능 (모두 RR일 때) | S Lock 유지로 직렬화 |
| MSSQL SERIALIZABLE | 불가능 | S Lock + Key-Range Lock |
FOR UPDATE 또는 SERIALIZABLE 필요.FOR UPDATE, UPDLOCK, HOLDLOCK)으로 보완 필요.