동시성 문제의 핵심은 두 요청이 같은 상태를 읽은 뒤 서로의 판단을 모른 채 저장하는 시간 간격입니다. 트랜잭션이라는 경계를 붙였다는 사실만으로 그 간격이 사라지지는 않으므로, DB가 계산을 원자적으로 수행하는지, 읽은 버전을 검사하는지, 잠금으로 판단 구간을 보호하는지를 구분해 봅니다.
중복 판매와 갱신 유실의 재고 불일치
재고가 10개인 상품을 두 요청이 하나씩 구매한다고 해 보겠습니다. 각 요청은 재고를 읽고, 1을 빼고, 계산한 값을 저장합니다. 혼자 실행하면 아무 문제가 없어 보입니다. 그런데 두 요청이 같은 10을 읽으면 둘 다 9를 저장합니다. 판매 기록은 두 건인데 재고는 9개가 됩니다.
먼저 저장한 변경을 뒤의 요청이 오래된 계산 결과로 덮어쓰는 현상을 갱신 유실(lost update)이라고 합니다. 이 노트는 같은 행에 대한 갱신 유실을 다룹니다. 여러 행에 걸친 당직 인원 같은 집합 규칙, 분산 트랜잭션, 모든 격리 수준의 비교까지 한 번에 설명하지는 않습니다.
두 트랜잭션의 겹친 실행 순서
아래는 오래된 값을 그대로 저장하는 쓰기를 허용하는 설정의 실행 예입니다. DB 엔진과 격리 수준에 따라 충돌 오류가 나거나 실행이 직렬화될 수도 있으므로, 모든 DB에서 반드시 같은 결과가 나온다는 뜻은 아닙니다.
| 순서 | 요청 A | 요청 B | 저장된 재고 |
|---|---|---|---|
| 1 | 트랜잭션 시작, 10 읽기 | 10 | |
| 2 | 트랜잭션 시작, 10 읽기 | 10 | |
| 3 | 애플리케이션에서 9 계산 | 9 계산 | 10 |
| 4 | remaining = 9 저장·커밋 |
9 | |
| 5 | remaining = 9 저장·커밋 |
9 |
A와 B가 각각 트랜잭션을 사용했다는 사실만으로는 충분하지 않습니다. 트랜잭션의 변경을 모두 반영하거나 모두 취소하는 원자성과, 동시에 진행되는 트랜잭션 사이에 어떤 읽기·충돌을 허용하는 격리는 다른 문제이기 때문입니다.
그림을 글로 읽기
- DB 재고 10: 두 요청이 같은 값을 읽음
- 요청 A: 10 - 1 = 9
- 요청 B: 10 - 1 = 9
- DB 재고 9: 9 저장 뒤 다시 9 저장 · 판매 두 건과 불일치
- DB 재고 10 → 요청 A: 읽기
- DB 재고 10 → 요청 B: 읽기
- 요청 A → DB 재고 9: 먼저 저장
- 요청 B → DB 재고 9: 나중 저장
그림의 틈을 줄이는 방법은 세 가지로 나누어 생각할 수 있습니다. 계산 자체를 DB로 옮기거나, 읽은 뒤 누군가 바꾸었는지 검사하거나, 읽는 동안 다른 갱신을 기다리게 하는 것입니다.
조건부 UPDATE의 원자적 재고 차감
재고 차감처럼 현재 값에 대한 간단한 연산은 읽기와 계산을 애플리케이션으로 가져올 필요가 없습니다.
UPDATE inventory
SET remaining = remaining - 1
WHERE id = :id
AND remaining >= 1;
:id는 설명용 바인딩 표기입니다. 실제 코드에서는 DB 드라이버의 파라미터 API를 사용합니다. 핵심은 SET remaining = 9처럼 과거 값을 덮지 않고, DB가 갱신을 처리하는 시점의 값에서 차감한다는 점입니다. 같은 행에 대한 갱신은 엔진의 쓰기 동기화 규칙을 거칩니다. 경합 시 대기·조건 재평가·충돌 오류 중 어떤 일이 생기는지는 엔진과 격리 수준에 맞게 처리해야 합니다.
성공 여부는 SQL을 호출했다는 사실이 아니라 영향받은 행 수로 확인합니다. 1행이면 차감이 반영됐고, 0행이면 대상이 없거나 조건을 충족하지 못했습니다. 구체적인 거절 사유를 사용자에게 구분해 보여 줄지는 별도 설계입니다. 판매 행도 기록한다면 차감과 판매 기록을 같은 트랜잭션에 넣어 둘 중 하나만 남지 않게 해야 합니다.
읽은 버전과 저장 시점의 버전 비교
문서 편집처럼 사용자가 읽은 내용에 기초해 전체 새 값을 만드는 경우는 단순 차감과 다릅니다. 다른 편집을 무시하고 덮어써도 되는지 판단해야 합니다. 행에 version을 두고, 읽었던 버전과 여전히 같은 경우에만 저장할 수 있습니다.
UPDATE documents
SET body = :new_body,
version = version + 1
WHERE id = :id
AND version = :read_version;
A와 B가 모두 버전 7을 읽었다고 합시다. A가 먼저 저장하면 버전은 8이 됩니다. B의 조건 version = 7은 더 이상 맞지 않아 0행을 갱신합니다. 충돌은 사라진 것이 아니라 조용한 덮어쓰기에서 관찰 가능한 실패로 바뀐 것입니다.
B가 버전 숫자만 8로 바꿔 같은 오래된 본문을 재전송하면 보호를 우회합니다. 최신 내용을 다시 읽고 변경을 병합하거나, 사용자에게 다른 편집이 있음을 알려 선택하게 해야 합니다. 계산을 다시 할 수 있고 충돌이 드물 때 잘 맞는 이 방식을 낙관적 동시성 제어라고 합니다.
Version과 객체 변경 전제
updated_at을 조건으로 사용할 수도 있지만 시각 정밀도·동일 시각의 여러 갱신·서버 시계가 의미를 흔들 수 있습니다. 증가 version은 각 변경을 구별하기 쉽지만 모든 배치·관리 SQL도 증가 규칙을 지켜야 합니다. 삭제 뒤 같은 ID와 초기 version을 재사용하면 오래된 요청이 새 객체를 수정하는 ABA 같은 문제가 생길 수 있어 ID·세대 수명을 함께 관리합니다.
0행 갱신은 부재·권한 불가·버전 충돌을 포함할 수 있습니다. 필요한 범위에서 안전하게 재조회해 결과를 구분하되 비공개 자원 존재를 과하게 노출하지 않습니다. 서로 다른 필드를 수정했다는 사실만으로 병합이 안전하지도 않습니다. 주문 취소와 결제 확정은 필드가 달라도 같은 상태 불변식에 속할 수 있습니다. HTTP에는 적절한 ETag·If-Match와 실패 응답으로 연결할 수 있습니다.
읽기·판단·갱신을 묶는 행 잠금
여러 값을 읽고 분기해야 하며 충돌 후 다시 계산하는 비용이 크다면, 잠금 읽기가 더 자연스러울 수 있습니다. 다음은 FOR UPDATE를 지원하는 DB의 원리를 보여 주는 슈도코드입니다.
begin transaction
try:
row = SELECT ... WHERE id = :id FOR UPDATE
if row does not exist: rollback and return not_found
if row.remaining < quantity: rollback and return sold_out
UPDATE inventory SET remaining = row.remaining - quantity
INSERT sale ...
commit
catch database_error:
rollback if transaction is still active
classify conflict, timeout, or other failure
잠금은 조회와 변경이 같은 트랜잭션과 연결에서 실행될 때 의미가 있습니다. 조회 직후 자동 커밋하면 보호하려던 구간이 사라집니다. 잠근 채 결제 API나 사용자의 입력을 기다리면 다른 요청도 오래 기다리므로, 트랜잭션 안에는 꼭 필요한 DB 작업만 두는 편이 좋습니다.
없는 행이나 범위 전체까지 어떤 잠금으로 보호하는지는 엔진·인덱스·격리 수준에 따라 다릅니다. 여러 행을 잠근다면 가능한 한 같은 순서로 접근하고, 그래도 발생할 수 있는 교착 상태를 처리해야 합니다.
갱신 유형별 동시성 제어 선택 기준
| 상황 | 먼저 고려할 방법 | 반드시 확인할 결과 |
|---|---|---|
| 현재 재고에서 일정 수량 차감 | 조건부 UPDATE |
영향 행 수, 판매 기록과 같은 커밋 |
| 읽은 문서를 오래 편집한 뒤 저장 | 버전 조건 갱신 | 충돌 뒤 최신 내용의 병합 또는 재편집 |
| 짧은 읽기·판단·여러 쓰기를 보호 | 잠금 읽기 | 같은 트랜잭션, 잠금 대기, 교착 처리 |
세 방법 모두 같은 구매 요청을 두 번 실행하는 문제와는 구별해야 합니다. 응답이 유실되어 같은 구매를 다시 보내면, 정확한 차감 SQL도 두 번 성공할 수 있습니다. 논리적으로 같은 요청인지 판별하는 멱등성 키와 거래 기록이 따로 필요합니다.
반복 충돌의 재시도 예산과 소유 모델
같은 hot key(여러 요청이 한 행에 몰리는 키)에서 한 요청이 10번씩 계산을 버리면, 성공한 한 건보다 재시도와 재조회가 더 많은 DB 부하를 만들 수 있습니다. 그래서 충돌률과 성공 한 건당 시도 수뿐 아니라 전체 deadline(요청이 끝나야 하는 시각), 최장 대기, DB 읽기량, 사용자 거절도 함께 보면서 재시도 예산을 정합니다.
재시도할 때는 새 거래에서 최신 상태를 다시 읽어 판단하고 같은 논리 요청 ID를 유지하되, 횟수·시간 상한과 backoff(실패 뒤 기다리는 간격), jitter(동시에 재시도하지 않도록 간격에 더하는 작은 변동)를 둡니다. DB 시도가 롤백되어도 외부 결제나 메일은 취소되지 않을 수 있으므로, 실패한 시도마다 그 효과를 다시 실행하지 않습니다.
지속적인 충돌이면 짧은 잠금 거래·키별 직렬 실행·재고 예약·조건부 갱신으로 구조를 바꾸는 편이 나을 수 있습니다. 로컬 큐 하나는 다른 프로세스 writer를 막지 못하므로 최종 권위는 저장 경계에서 유지합니다. 직렬화로 바꾸면 반복 계산은 줄지만 대기·기아·큐 상한 비용이 생깁니다. 충돌을 0으로 만드는 대신 정상 요청의 지연·처리량·공정성이 나아졌는지 판단합니다.
두 연결을 장벽으로 동기화해 같은 재고를 읽게 한 뒤, 조건부 UPDATE의 영향 행 수와 판매 기록을 같은 커밋으로 확인하십시오. 초기 재고 1이면 성공한 차감과 판매는 최대 한 건이어야 하며, 재시도는 새 트랜잭션에서 최신 상태를 읽어야 합니다. 결과가 두 건이면 SQL 문장보다 멱등성 키와 외부 효과 재실행 여부를 먼저 진단합니다.
두 연결을 이용한 갱신 유실·충돌 실험
처음에는 테스트 DB에 재고 10을 넣고 A와 B의 읽기 직후를 일시 정지시킵니다. 둘 다 10을 읽은 것을 확인한 뒤 A 저장, B 저장 순으로 진행합니다. 오래된 값 저장을 허용하는 설정에서는 9가 남는지, 충돌을 감지하는 설정에서는 어느 요청이 실패하는지 기록합니다.
그다음 조건부 차감으로 바꾸어 두 요청을 동시에 실행합니다. 둘 다 커밋에 성공했다면 재고는 8이고 판매는 두 건이어야 합니다. 초기 재고를 1로 바꾸면 성공한 차감·판매는 최대 한 건이어야 합니다. 충돌 오류가 나면 트랜잭션 전체를 새로 시작할지, 거절할지 정한 정책도 함께 시험합니다.
문서 버전 예에서는 같은 버전 7을 기반으로 두 번 저장하고, 한 요청만 성공하며 최종 버전이 8인지 확인합니다. 숫자만 보지 말고 어떤 본문이 남았는지도 대조하세요. 이 예제들은 수행 지침이며, 특정 DB에서 실행한 결과를 보고하는 것은 아닙니다. 실험할 때 엔진 버전·격리 수준·autocommit·SQL과 실제 오류를 함께 남겨야 결과를 재현할 수 있습니다.