445. 갱신 손실 (Lost Update)
⚠️ 이 문서는 다수의 사용자가 동시에 데이터베이스에 접속하여 똑같은 데이터를 수정하려고 할 때, 아무런 자물쇠(Lock) 장치를 하지 않으면 **내가 힘들게 수정한 데이터가 1초 뒤에 남이 수정한 데이터에 덮어씌워져 흔적도 없이 날아가 버리는 최악의 동시성 버그인 '갱신 손실'**을 다룹니다.
핵심 인사이트 (3줄 요약)
- 본질: 두 개의 트랜잭션이 같은 데이터를 동시에 읽고 각자 수정한 뒤 저장할 때, 나중에 저장한 사람의 값만 남고 먼저 저장한 사람의 업데이트 내역은 완벽하게 사라지는(Lost) 현상이다.
- 가치: 이 문제를 방치하면 쇼핑몰의 재고가 마이너스가 되거나, 두 명이 동시에 입금했는데 잔액이 한 명분만 늘어나는 치명적인 금융 사고가 터진다.
- 해결책: 트랜잭션의 **고립성(Isolation - 443번 문서)**을 지키기 위해, 데이터를 읽을 때부터 남들이 못 건드리게 자물쇠를 거는 **비관적 락(Pessimistic Lock)**이나, 저장할 때 버전(Version)을 검사하는 **낙관적 락(Optimistic Lock)**을 사용해야 한다.
Ⅰ. 개요: 우주로 증발한 1만 원 (Context & Necessity)
은행 계좌에 10만 원이 있다.
- 나(T1): 계좌에 1만 원을 입금하려고 한다.
- 엄마(T2): 동시에 내 계좌에 5만 원을 입금하려고 한다.
정상적이라면 10만 + 1만 + 5만 = 16만 원이 되어야 한다. 하지만 갱신 손실이 발생하면 이런 일이 벌어진다.
- **나(T1)**가 통장을 열어보니 10만 원이 있다. (읽기)
- 0.01초 뒤, **엄마(T2)**도 통장을 열어보니 10만 원이 있다. (읽기)
- **나(T1)**가 10만 원 + 1만 원 = 11만 원을 기록하고 통장을 덮는다. (저장 1)
- 0.01초 뒤, **엄마(T2)**는 아까 자기가 본 10만 원에 5만 원을 더해서 15만 원을 기록하고 통장을 덮는다. (저장 2)
최종 결과: 통장 잔액은 15만 원이다. 내가 입금한 1만 원은 우주로 날아갔다(Lost Update). 이 끔찍한 현상은 코드가 잘못된 게 아니라, '동시성 제어(Concurrency Control)'를 하지 않아서 생긴 데이터베이스의 구조적 결함이다.
📢 섹션 요약 비유: 갱신 손실은 **'하나의 엑셀 파일을 두 명이 동시에 열어서 편집하는 것'**과 같습니다. 내가 A 셀을 고치고 저장했는데, 내 친구가 1초 뒤에 B 셀을 고치고 덮어쓰기 저장을 해버리면? 내가 고쳤던 A 셀은 다시 옛날 값으로 돌아가 버립니다. 내 노력이 허공으로 증발한 것이죠.
Ⅱ. 갱신 손실이 일어나는 조건
갱신 손실은 정확히 다음 3가지 박자가 맞을 때 터진다.
- 두 개 이상의 트랜잭션이 동시에(Concurrent) 실행된다.
- 두 트랜잭션이 같은 데이터를 타겟으로 삼는다.
- 두 트랜잭션 모두 읽기(Read) $\rightarrow$ 수정 $\rightarrow$ 쓰기(Write) 패턴을 수행한다.
Ⅲ. 실무 해결책: 락(Lock)의 세계 ★
이 현상을 막는 유일한 방법은 엑셀 파일처럼 "내가 편집 중일 땐 너는 읽기 전용으로만 열어!"라고 통제하는 것이다.
1. 비관적 락 (Pessimistic Lock)
- 철학: "남들이 무조건 내 데이터를 덮어쓸 거야!"라고 비관적으로 생각한다.
- 방식: 내가 통장을 읽는 순간(SELECT)부터 통장에 아주 튼튼한 **자물쇠(Exclusive Lock)**를 채워버린다. 내가 입금을 끝내고
COMMIT을 칠 때까지 엄마는 내 통장을 아예 열어볼 수도 없고 무한 대기해야 한다. - 장점: 100% 안전하다.
- 단점: 엄마(T2)가 너무 오래 기다려야 하므로 전체 시스템 속도가 느려진다.
2. 낙관적 락 (Optimistic Lock)
- 철학: "설마 0.1초 사이에 남이 덮어쓰겠어?"라고 낙관적으로 생각한다. 자물쇠를 걸지 않는다.
- 방식: 대신 데이터 옆에 **'버전(Version)'**이라는 숨겨진 숫자를 둔다.
- 내가 10만 원(버전 1)을 읽어갔다. 엄마도 10만 원(버전 1)을 읽어갔다.
- 내가 11만 원으로 고치면서 저장할 때 버전을 2로 올린다. (
UPDATE ... WHERE 버전 = 1) -> 성공! 이제 버전은 2다. - 엄마가 15만 원으로 고치려고 저장할 때 쿼리를 날린다. (
UPDATE ... WHERE 버전 = 1) -> 실패! "어? 버전이 2로 바뀌었네? 누군가 먼저 덮어썼구나!"
- 결과: 엄마의 트랜잭션은 튕겨 나가고(Exception 발생), 앱에서 에러를 잡아서 "다시 시도해 주세요"라고 처리한다.
┌──────────────────────────────────────────────────────────────┐
│ 갱신 손실 (Lost Update) 타임라인 시각화 │
├──────────────────────────────────────────────────────────────┤
│ │
│ 시간 │ 트랜잭션 A (나) │ 트랜잭션 B (엄마) │
│ ────┼─────────────────────────┼─────────────────────────│
│ T1 │ 잔액 10만 원 읽기 (Read) │ │
│ T2 │ │ 잔액 10만 원 읽기 (Read) │
│ T3 │ 10만+1만=11만 저장 (Write) │ │
│ T4 │ │ 10만+5만=15만 저장 (Write) │
│ │
│ ★ 최종 결과: 15만 원. (A의 11만 원 저장 내역이 B에 의해 무참히 짓밟힘) │
└──────────────────────────────────────────────────────────────┘
Ⅳ. 결론
"Lock을 모르는 개발자는 시한폭탄을 만드는 것과 같다."
혼자서 테스트할 때(로컬 환경)는 갱신 손실 버그가 절대 발견되지 않는다. 하지만 1만 명의 유저가 모여드는 티켓 예매 사이트를 오픈하는 순간, 100장짜리 티켓이 1,000장 팔려나가는 마법을 보게 될 것이다. 현대 RDBMS는 4단계 격리 수준(Isolation Level - 443번 문서) 중 Read Committed 이상만 설정해도 기본적인 방어막을 쳐주지만, Read -> Modify -> Write 로직에서는 무조건 개발자가 명시적으로 @Version(낙관적 락)이나 FOR UPDATE(비관적 락)를 걸어주어야만 갱신 손실의 악몽을 피할 수 있다.
📌 관련 개념 맵
- 관련 특성: 고립성(Isolation - 443번 문서)
- 해결 기술: Locking (S-Lock, X-Lock - 449번 문서), MVCC
- 프레임워크 연동: JPA의
@Version, MyBatis의FOR UPDATE - 유사 이상 현상: Dirty Read, Non-Repeatable Read, Phantom Read
👶 어린이를 위한 3줄 비유 설명
- 갱신 손실은 학교 칠판에 당번을 적는 것과 같아요.
- 내가 칠판의 빈칸을 보고 내 자리로 돌아가서 '철수'라고 적힌 이름표를 만들어와서 붙이려고 했죠.
- 근데 내가 자리로 간 사이에, 영희도 빈칸을 보고 '영희' 이름표를 만들어 와서 붙였어요. 내가 그 위에 '철수'를 덧붙여버리면 영희 이름은 영원히 사라져 버리겠죠?