재고가 하나 남은 상품을 두 사람이 동시에 주문하면 어떻게 될까요? 두 요청이 모두 재고 1을 읽었다면, 재고를 줄이고 주문을 저장하는 코드를 트랜잭션으로 묶는 것만으로 충분할까요?
주문 처리에는 두 가지 문제가 있습니다. 재고 차감과 주문 저장은 함께 성공하거나 실패해야 합니다. 동시에, 다른 요청이 같은 재고를 읽고 바꾸는 과정도 조정해야 합니다. 작업을 하나로 묶는 것과 다른 작업의 간섭을 막는 것은 별개의 문제입니다.
먼저 CS 스터디 범위인 트랜잭션, 격리 수준, 락을 정리합니다. 그다음 이 개념을 애플리케이션에 적용할 때 생기는 질문으로 범위를 넓혀 봅니다. 트랜잭션이 실제로 끝나는 시점, 충돌한 요청을 다시 처리하는 방법, 동시성과 중복 처리의 차이가 이어지는 주제입니다.
구체적인 SQL 동작은 MySQL 8.0의 InnoDB를 기준으로 설명합니다. InnoDB는 MySQL에서 데이터 저장과 트랜잭션 처리를 담당하는 스토리지 엔진입니다. 앞부분의 수량과 실행 순서는 원리를 설명하기 위한 가정입니다.
1부. 트랜잭션, 격리 수준, 락의 기본 개념
트랜잭션은 어디까지 하나의 작업으로 묶을까요?
트랜잭션(transaction)은 데이터베이스 작업을 하나의 처리 단위로 묶는 방법입니다. 주문 한 건에 필요한 재고 차감과 주문 저장을 묶으면, 둘을 모두 반영하거나 모두 취소할 수 있습니다.
START TRANSACTION;
UPDATE inventories
SET stock = stock - 1
WHERE product_id = 1;
INSERT INTO orders (order_id, product_id, quantity)
VALUES (101, 1, 1);
COMMIT;
START TRANSACTION은 시작, COMMIT은 변경 확정을 뜻합니다. 처리 도중 주문 저장에 실패했다면 애플리케이션은 COMMIT 대신 ROLLBACK을 수행해 앞선 재고 차감도 취소해야 합니다. 모든 SQL 오류가 자동으로 트랜잭션 전체를 롤백하는 것은 아니므로, 실패 시 트랜잭션을 어떻게 끝낼지도 처리해야 합니다.
MySQL은 기본적으로 autocommit이 켜져 있어 SQL 한 문장마다 트랜잭션이 끝납니다. 여러 문장을 함께 처리하려면 위처럼 경계를 명시하거나 프레임워크의 트랜잭션 기능을 사용합니다. (참고: MySQL 커밋·롤백 문서)
이 코드는 작업을 묶는 예시입니다. 아직 재고가 충분한지 확인하지 않으므로, 이것만으로 올바른 주문 처리가 완성되지는 않습니다.
Commit과 Rollback은 무엇을 기준으로 나뉠까요?
트랜잭션은 시작한 뒤 SQL을 실행하다가, 변경을 확정하거나 취소하며 끝납니다. 이때 SQL 한 문장이 성공한 상태와 트랜잭션이 커밋된 상태를 구분해야 합니다.
트랜잭션 시작
→ 재고 UPDATE 성공
→ 주문 INSERT 시도
├─ 성공 → COMMIT → 두 변경 확정
└─ 실패 → ROLLBACK → 앞선 재고 변경도 취소
첫 번째 UPDATE가 성공했다는 것은 그 문장의 처리가 끝났다는 뜻입니다. 두 번째 작업까지 완료하기로 한 트랜잭션의 결과는 아직 결정되지 않았습니다. 애플리케이션도 커밋 단계에서 발생할 수 있는 실패까지 포함해 성공 여부를 판단해야 합니다.
트랜잭션의 네 가지 성질: ACID
| 성질 | 의미 | 재고 예시에서의 의미 |
|---|---|---|
| Atomicity | 트랜잭션의 변경을 모두 반영하거나 모두 취소합니다. | 주문 저장에 실패하면 재고 차감도 취소합니다. |
| Consistency | 트랜잭션 전후에 데이터가 정해진 규칙을 만족해야 합니다. | 재고는 0 이상이어야 하고, 주문은 존재하는 상품을 참조해야 합니다. |
| Isolation | 동시에 실행되는 트랜잭션 사이의 간섭을 제어합니다. | 다른 주문이 처리 중일 때 어떤 재고를 읽고 변경할지 정합니다. |
| Durability | 커밋된 변경은 장애가 발생해도 보존되어야 합니다. | 성공 처리한 주문이 DB 재시작 후에도 남아야 합니다. |
ACID의 Consistency는 데이터베이스가 업무 규칙을 알아서 만들어 준다는 뜻이 아닙니다. 외래 키나 CHECK 같은 제약조건, 애플리케이션의 검증과 갱신 로직으로 규칙을 표현해야 합니다. 트랜잭션으로 묶었더라도 재고를 음수로 만드는 코드는 여전히 잘못된 코드입니다.
Durability는 커밋을 마친 변경에 대한 성질입니다. InnoDB는 redo 로그 등을 이용해 복구를 지원하며, 로그 기록 설정과 저장 장치의 동작도 장애 시 보존 범위에 영향을 줍니다. (참고: MySQL ACID 문서)
Rollback과 장애 복구는 어떻게 가능할까요?
재고를 10에서 9로 바꾼 뒤 취소하려면, 변경 전 상태를 알아야 합니다. InnoDB의 undo log는 변경을 되돌리는 데 필요한 정보를 보관합니다. 이 정보는 뒤에서 설명할 MVCC가 이전 버전의 행을 읽는 데도 사용됩니다. (참고: MySQL Undo Logs)
redo log는 장애 복구를 위한 변경 기록입니다. 메모리에서 수정한 데이터 페이지가 파일에 모두 반영되기 전에 서버가 중단되더라도, 기록된 변경을 복구하는 데 사용합니다. 변경된 데이터 페이지를 디스크에 쓰기 전에 관련 로그를 먼저 디스크에 남기는 원칙을 Write-Ahead Logging(WAL)이라고 합니다. (참고: MySQL Redo Log, PostgreSQL 문서의 WAL 원리 설명)
| 구분 | 설명할 때 주목할 역할 |
|---|---|
| undo log | 변경을 취소하거나 이전 버전의 행을 구성하는 데 필요한 정보 |
| redo log | 장애 복구 때 데이터 페이지의 변경을 다시 적용하는 데 필요한 기록 |
두 로그를 각각 “실패한 SQL 목록”과 “성공한 SQL 목록”으로 이해하면 곤란합니다. 데이터 변경을 복구하는 내부 정보이며, SQL 문장을 그대로 저장한 실행 이력과는 다릅니다.
트랜잭션으로 묶었는데도 재고가 잘못될 수 있을까요?
재고를 읽고, 1 이상이면 애플리케이션에서 1을 뺀 값을 저장한다고 가정합니다. 초기 재고는 1개입니다. 아래는 별도의 버전 검사나 locking read 없이 일반 조회와 값 덮어쓰기를 수행하는 경우입니다.
| 순서 | 트랜잭션 A | 트랜잭션 B |
|---|---|---|
| 1 | 재고 1을 읽습니다. | |
| 2 | 재고 1을 읽습니다. | |
| 3 | 재고가 있으므로 주문을 진행합니다. | |
| 4 | 재고가 있으므로 주문을 진행합니다. | |
| 5 | 계산한 값 0을 저장하고 주문 A를 커밋합니다. | |
| 6 | 계산해 둔 값 0을 저장하고 주문 B를 커밋합니다. |
최종 재고는 0인데 주문은 두 건입니다. B가 A의 변경을 고려하지 않고 자신이 계산한 값으로 덮어쓴 것입니다. 이런 문제를 Lost Update라고 합니다.
각 트랜잭션은 재고 변경과 주문 저장을 모두 완료했습니다. Atomicity가 깨진 상황은 아닙니다. 읽은 뒤 변경하기까지 다른 트랜잭션이 끼어들 수 있다는 점이 문제입니다. 실제로 이 실행이 허용되는지는 격리 수준과 쿼리 방식에 따라 달라집니다.
여기서부터는 어떤 변경을 읽을 수 있는지, 읽은 값에 기반한 갱신을 어떻게 보호할지 살펴봐야 합니다.
격리 수준은 무엇을 정할까요?
격리 수준(Isolation Level)은 동시에 실행되는 트랜잭션 사이에서 어떤 간섭을 허용할지 정합니다. 먼저 다른 트랜잭션의 변경을 읽을 때 생기는 세 가지 현상을 살펴보겠습니다. 아래의 A와 B는 각각 별도의 트랜잭션입니다.
Dirty Read
- 재고는 10개입니다.
- A가 재고를 9로 변경했지만 아직 커밋하지 않았습니다.
- B가 9를 읽습니다.
- A가 롤백합니다. 실제 재고는 다시 10개입니다.
B는 최종적으로 반영되지 않은 변경을 근거로 판단했습니다. 이를 Dirty Read라고 합니다.
Non-repeatable Read
상품 1의 재고를 확인하는 동안 다른 주문이 들어오는 상황을 생각해 보겠습니다. 초기 재고는 10개입니다. A는 하나의 트랜잭션 안에서 다음 쿼리를 두 번 실행합니다.
SELECT stock
FROM inventories
WHERE product_id = 1;
아래 예시는 MySQL InnoDB의 READ COMMITTED에서 일반 SELECT를 사용하는 상황입니다. A는 두 번 읽는 사이에 커밋하거나 새 트랜잭션을 시작하지 않습니다.
| 순서 | 트랜잭션 A: 재고 확인 | 트랜잭션 B: 다른 주문의 재고 차감 |
|---|---|---|
| 1 | START TRANSACTION | |
| 2 | 첫 SELECT → stock = 10 | |
| 3 | START TRANSACTION | |
| 4 | 상품 1의 재고를 7개 차감 → stock = 3 | |
| 5 | COMMIT | |
| 6 | 같은 SELECT → stock = 3 | |
| 7 | COMMIT |
A가 읽은 것은 두 번 모두 같은 상품 1의 행입니다. 쿼리도 그대로인데 결과가 10에서 3으로 바뀌었습니다. 이를 Non-repeatable Read라고 합니다.
Dirty Read와의 차이는 B가 커밋한 뒤 A가 다시 읽었다는 점입니다. 커밋된 값만 읽어도 같은 트랜잭션 안에서 조회 결과는 달라질 수 있습니다.
예를 들어 A가 첫 조회로 “5개 주문 가능”이라고 판단하고, 두 번째 조회로 안내할 남은 수량을 계산한다면 두 판단의 기준이 달라집니다. 하나의 처리에서 같은 상태를 기준으로 판단해야 하는지 확인할 필요가 있습니다. 재고 5개를 실제로 확보하려면 차감을 따로 보호해야 하며, 그 방법은 뒤에서 살펴봅니다.
Phantom Read
이번에는 특정 상품 한 개가 아니라 재고가 있는 상품 전체를 조회합니다. 별도의 예시이며, 시작할 때 재고 테이블에는 다음 두 행만 있다고 가정합니다.
| product_id | stock |
|---|---|
| 1 | 10 |
| 2 | 3 |
A는 다음 쿼리를 같은 트랜잭션 안에서 두 번 실행합니다. 이 예시도 READ COMMITTED의 일반 SELECT를 기준으로 합니다.
SELECT product_id, stock
FROM inventories
WHERE stock > 0
ORDER BY product_id;
| 순서 | 트랜잭션 A: 판매 가능한 상품 조회 | 트랜잭션 B: 재고 행 추가 |
|---|---|---|
| 1 | START TRANSACTION | |
| 2 | 첫 SELECT → 상품 1과 2, 총 2행 | |
| 3 | START TRANSACTION | |
| 4 | 상품 3의 재고 행 추가: stock = 5 | |
| 5 | COMMIT | |
| 6 | 같은 SELECT → 상품 1, 2, 3, 총 3행 | |
| 7 | COMMIT |
상품 1과 2의 재고는 바뀌지 않았습니다. 그런데 처음에는 없던 상품 3이 같은 조건의 조회 결과에 들어왔습니다. 이렇게 조건을 만족하는 행 집합이 달라지는 현상을 Phantom Read라고 합니다.
예를 들어 한 번의 보고서 생성에서 첫 조회로 “판매 가능한 상품은 2종”이라고 계산하고, 다시 조회해 상세 목록을 만들면 목록에는 3종이 실릴 수 있습니다. 각각의 조회는 그 시점에 맞지만, 보고서 안의 개수와 목록은 같은 기준으로 만들어지지 않은 것입니다.
새 행의 INSERT가 대표적인 예이지만, 기존 행의 재고가 0에서 양수로 바뀌어 조건에 새로 들어오거나 행이 삭제되어 결과에서 빠지는 경우도 생각할 수 있습니다. 결과의 행 수가 우연히 같더라도 구성원이 바뀌면 같은 행 집합이 아닙니다.
| 현상 | 반복 조회에서 달라진 것 | 위 예시 |
|---|---|---|
| Non-repeatable Read | 같은 행의 값 | 상품 1의 재고가 10 → 3 |
| Phantom Read | 조건을 만족하는 행 집합 | 상품 1·2 → 상품 1·2·3 |
두 현상은 조회 결과에서 무엇이 달라졌는지에 주목하는 구분입니다. 위와 같은 실행이 실제로 보이는지는 격리 수준과 조회 방식에 따라 달라집니다. 특히 MySQL의 기본 격리 수준은 위 예시의 READ COMMITTED와 다르므로, 다음 표와 MVCC 설명을 함께 봐야 합니다. (참고: MySQL 격리 수준별 조회 동작)
네 가지 격리 수준
다음 표는 SQL 표준에서 각 수준이 요구하는 최소 보장을 정리한 것입니다. 허용된 현상도 DBMS 구현에 따라 더 강하게 차단될 수 있습니다.
| 격리 수준 | Dirty Read | Non-repeatable Read | Phantom Read |
|---|---|---|---|
| READ UNCOMMITTED | 허용 | 허용 | 허용 |
| READ COMMITTED | 방지 | 허용 | 허용 |
| REPEATABLE READ | 방지 | 방지 | 허용 |
| SERIALIZABLE | 방지 | 방지 | 방지 |
각 이름은 다음처럼 이해할 수 있습니다.
- READ UNCOMMITTED: 다른 트랜잭션이 아직 커밋하지 않은 변경을 읽을 수 있습니다. 이후 취소될 값을 근거로 판단할 수 있다는 부담이 있습니다.
- READ COMMITTED: 커밋된 변경만 읽습니다. 하지만 두 번 읽는 사이에 다른 트랜잭션이 커밋하면 결과가 바뀔 수 있습니다.
- REPEATABLE READ: 같은 행을 반복해서 읽을 때 다른 트랜잭션의 변경 때문에 값이 달라지는 현상을 막습니다. 범위 조회의 보장까지 이해하려면 DBMS 구현도 확인해야 합니다.
- SERIALIZABLE: 동시 실행 결과가 트랜잭션들을 하나씩 순서대로 실행한 결과와 같도록 보장합니다.
여기서 serial execution과 serializability를 구분할 수 있습니다. 전자는 A를 끝낸 뒤 B를 실행하는 방식입니다. 후자는 실제로 작업이 섞여 실행되더라도 결과가 A→B 또는 B→A 같은 어떤 순차 실행과 같아야 한다는 성질입니다. (참고: PostgreSQL의 SQL 표준 격리 수준 설명)
MySQL InnoDB의 기본값은 REPEATABLE READ입니다. 다만 표의 “Phantom Read 허용”을 보고 MySQL의 반복 조회에서 항상 새 행이 나타난다고 이해하면 안 됩니다. InnoDB는 snapshot과 범위 잠금을 사용하므로 일반 조회인지 locking read인지를 함께 봐야 합니다. (참고: MySQL 격리 수준 문서)
또한 이 표에는 앞에서 본 Lost Update가 없습니다. 세 가지 읽기 현상을 막는지만으로 모든 업무 규칙이 보호되는지 판단할 수는 없습니다. 재고 차감처럼 읽기와 쓰기가 연결된 작업은 갱신 방식까지 확인해야 합니다.
격리 수준은 높을수록 좋을까요?
강한 격리는 애플리케이션이 고려할 수 있는 실행 상황을 줄여 줍니다. 대신 충돌하는 작업을 기다리게 하거나 트랜잭션을 실패시켜 다시 실행하는 비용이 생길 수 있습니다. 그 비용의 크기는 구현 방식에 따라 다릅니다.
먼저 작업이 요구하는 보장을 정하는 편이 좋습니다. 한 트랜잭션에서 여러 집계 값을 읽어 비교한다면 조회 사이에 기준이 바뀌는 것이 문제가 될 수 있습니다. 반면 한 행의 수량만 조건부로 차감한다면, 해당 UPDATE가 업무 조건을 지키는지부터 살펴볼 수 있습니다.
중요한 것은 격리 수준의 이름과 실제 쿼리를 함께 보는 것입니다. 같은 격리 수준에서도 일반 SELECT와 locking read의 동작은 다를 수 있습니다.
데이터를 수정하는 동안 어떻게 이전 값을 읽을까요?
다른 트랜잭션이 데이터를 수정할 때마다 읽기를 멈추면, 조회가 많은 서비스에서는 대기가 늘어납니다. MVCC(Multi-Version Concurrency Control)는 데이터의 여러 버전을 이용해 읽기와 쓰기가 함께 진행될 수 있게 하는 방식입니다.
현재 재고가 9로 바뀌었더라도, 어떤 트랜잭션에는 이전에 커밋된 재고 10을 보여줄 수 있습니다. 특정 시점에 볼 수 있는 데이터 상태를 snapshot이라고 합니다. snapshot은 테이블 전체를 복사해 만드는 것이 아니라, InnoDB가 undo 로그의 이전 값과 트랜잭션 정보로 구성합니다. (참고: MySQL MVCC 문서)
READ COMMITTED와 REPEATABLE READ의 차이
InnoDB의 일반적인 SELECT는 두 수준에서 모두 snapshot을 읽습니다. 이를 consistent read라고 합니다. 두 수준은 snapshot을 정하는 시점이 다릅니다.
- READ COMMITTED: consistent read를 수행할 때마다 새 snapshot을 사용합니다.
- REPEATABLE READ: 트랜잭션의 첫 consistent read에서 정한 snapshot을 이후에도 사용합니다.
다음은 A가 직접 데이터를 변경하지 않고 같은 행을 두 번 읽는 경우입니다.
| 실행 순서 | A가 READ COMMITTED일 때 | A가 REPEATABLE READ일 때 |
|---|---|---|
| A가 첫 SELECT로 재고를 읽습니다. | 10 | 10 |
| B가 재고를 9로 바꾸고 커밋합니다. | ||
| A가 두 번째 SELECT로 재고를 읽습니다. | 9 | 10 |
REPEATABLE READ를 줄여 RR, READ COMMITTED를 줄여 RC라고 부르겠습니다. RR에서는 같은 snapshot을 읽으므로 다른 트랜잭션이 추가한 행도 반복 조회에 새로 나타나지 않습니다. 일반적인 트랜잭션에서 snapshot 기준은 시작 선언 시점이 아니라 첫 consistent read 시점입니다. 자신이 변경한 내용은 자기 트랜잭션에서 볼 수 있다는 점도 별도로 기억해야 합니다. (참고: MySQL Consistent Nonlocking Reads 문서)
A는 어느 버전을 읽을까요?
B가 재고를 10에서 9로 바꾸고 커밋하는 동안 A가 같은 행을 읽습니다. 격리 수준을 바꿔 보고, 각 버전 옆의 “보임 / 안 보임”이 언제 바뀌는지 보세요.
InnoDB의 MVCC를 단순화한 개념 모형입니다. snapshot 선과 “보임 / 안 보임” 표시는 “snapshot보다 먼저 커밋된 변경만 보인다”는 규칙을 그린 것으로, 실제 read view의 자료 구조와는 다릅니다. undo 로그의 정리, 트랜잭션 ID 비교, B가 잡은 락은 그리지 않았습니다.
Consistent Read와 Locking Read
snapshot으로 재고 1을 계속 볼 수 있어도, 다른 요청이 그 상품을 사는 것을 막지는 않습니다. 과거 상태를 일관되게 읽는 기능과 앞으로 수행할 갱신을 보호하는 기능은 역할이 다릅니다.
SELECT ... FOR UPDATE처럼 락을 획득하며 읽는 방식을 locking read라고 합니다. InnoDB의 UPDATE와 locking read는 과거 snapshot이 아니라 현재 데이터를 대상으로 잠금을 사용하므로, 다른 트랜잭션의 완료를 기다릴 수 있습니다. 따라서 RR 안에서 일반 조회로 본 값과 이후 UPDATE가 만나는 값은 다를 수 있습니다. (참고: MySQL 읽기와 갱신의 차이)
읽기 트랜잭션이 오래 열려 있으면
MVCC 덕분에 읽기가 쓰기를 매번 기다릴 필요는 없습니다. 하지만 오래된 snapshot을 읽는 트랜잭션이 남아 있으면, 그 트랜잭션에 필요한 이전 버전도 유지해야 합니다. 오래 열린 트랜잭션은 undo 정보의 정리를 지연시킬 수 있습니다.
SELECT만 실행하는 트랜잭션도 오래 열어 두면 비용이 생깁니다. 긴 보고서 조회나 대량 처리에서는 읽기의 기준 시점뿐 아니라 트랜잭션이 유지되는 시간도 확인해야 합니다. (참고: MySQL InnoDB Multi-Versioning)
락은 어떤 접근을 기다리게 할까요?
락(lock)은 어떤 자원에 대한 접근을 제한하는 장치입니다. 한 트랜잭션이 데이터를 변경하는 동안, 충돌하는 다른 접근을 기다리게 할 수 있습니다.
Shared Lock과 Exclusive Lock
행에 대한 대표적인 잠금 모드는 Shared Lock(S)과 Exclusive Lock(X)입니다. 서로 다른 트랜잭션이 같은 레코드에 요청하는 경우를 보면 다음과 같습니다.
| 먼저 획득한 락 | 다른 트랜잭션의 S 락 요청 | 다른 트랜잭션의 X 락 요청 |
|---|---|---|
| S 락 | 함께 획득 가능 | 대기 |
| X 락 | 대기 | 대기 |
SELECT ... FOR SHARE는 Shared Lock을, SELECT ... FOR UPDATE는 Exclusive Lock을 사용하는 locking read입니다. UPDATE 역시 변경할 레코드에 Exclusive Lock이 필요합니다.
이 표가 모든 읽기를 차단한다는 뜻은 아닙니다. RC·RR의 일반 SELECT는 보통 MVCC로 이전 버전을 읽으므로, 해당 레코드에 X 락이 있어도 consistent read가 가능할 수 있습니다. (참고: MySQL locking read 문서)
행을 잠갔는데 왜 INSERT도 기다릴까요?
InnoDB의 행 잠금은 실제로 인덱스 레코드를 대상으로 합니다. 검색 조건과 격리 수준에 따라 레코드 사이의 공간까지 보호할 수 있습니다.
| 종류 | 보호하는 대상 |
|---|---|
| Record Lock | 인덱스의 레코드 |
| Gap Lock | 인덱스 레코드 사이의 간격. 그 간격으로의 삽입을 제한합니다. |
| Next-key Lock | 레코드와 그 앞의 간격 |
예를 들어 인덱스에 상품 ID 10과 20이 있다면, 두 값 사이의 갭을 잠가 ID 15의 삽입을 기다리게 할 수 있습니다. 범위에 들어오는 새 행을 제어하기 위한 것입니다. RR에서 범위를 조회하는 locking read는 Next-key Lock을 사용할 수 있고, 존재하는 행을 유일 인덱스의 동등 조건으로 찾으면 레코드만 잠글 수 있습니다.
잠금 범위는 WHERE 조건의 모양보다 실제로 어떤 인덱스를 따라 어디까지 탐색하는지에 따라 정해집니다. (참고: MySQL InnoDB 잠금 문서)
락을 거는 단위와 방식은 다른 구분입니다
행 단위로 잠그면 서로 다른 행을 수정하는 작업은 함께 진행할 여지가 있습니다. 테이블 단위로 잠그면 넓은 범위를 한 번에 보호할 수 있지만, 관련 없는 행의 작업까지 영향을 받을 수 있습니다. 실제 대기 여부는 락의 모드와 호환성에 따라 결정됩니다.
또한 Shared와 Exclusive는 어떤 접근을 함께 허용하는가, Record와 Gap은 무엇을 보호하는가를 설명합니다. 뒤에서 나오는 Optimistic과 Pessimistic은 언제 충돌을 다루는가에 관한 전략입니다. 모두 락을 설명하는 용어지만 분류 기준이 서로 다릅니다.
읽은 재고를 안전하게 차감하려면
앞의 예시에서는 두 요청이 재고 1을 읽고 각자 계산한 값으로 덮어썼습니다. 이를 다룰 때는 먼저 잠그거나, 변경 여부를 검사하거나, 검증과 갱신을 한 문장에 넣는 방법을 고려할 수 있습니다.
재고 1개, 주문 두 건
트랜잭션 A와 B가 같은 상품을 동시에 주문합니다. 방식을 바꿔 다시 보내 보고, B가 어디에서 멈추는지 보세요.
설명을 위해 A가 항상 먼저 진행하도록 순서를 고정했습니다. 실제로는 어느 쪽이 먼저 락을 얻을지 정해져 있지 않습니다. version 검사에서 충돌한 B가 다시 시도하는 과정과 주문 INSERT의 세부, 오류 처리 코드는 그리지 않았습니다.
아래 SQL은 product_id가 유일하며 수량이 양수라는 전제의 설명용 예시입니다.
Pessimistic Locking: 먼저 잠그고 읽습니다
Pessimistic Locking은 충돌에 대비해 필요한 잠금을 먼저 획득하는 방식입니다.
START TRANSACTION;
SELECT stock
FROM inventories
WHERE product_id = 1
FOR UPDATE;
-- 애플리케이션이 조회 결과가 1 이상인지 확인합니다.
-- 부족하면 ROLLBACK하고 여기서 처리를 끝냅니다.
UPDATE inventories
SET stock = stock - 1
WHERE product_id = 1;
COMMIT;
A가 먼저 잠금을 얻으면 같은 행의 locking read를 시도한 B는 기다립니다. A가 재고를 0으로 만들고 커밋한 뒤, B는 0을 읽고 재고 부족으로 처리할 수 있습니다. 잠금은 재고 검증과 갱신을 포함하는 트랜잭션이 끝날 때까지 유지됩니다. (참고: MySQL FOR UPDATE 예시)
충돌 시 계속 실패하고 다시 시도하는 비용을 줄일 수 있지만, 대기 시간과 잠금 보유 시간이 생깁니다. 이 구간에서 느린 외부 API까지 호출하면 다른 요청도 그만큼 기다립니다. 필요한 DB 작업을 짧게 끝낼 수 있는지 함께 살펴봐야 합니다.
Optimistic Locking: 저장할 때 변경 여부를 확인합니다
Optimistic Locking은 미리 갱신용 잠금을 잡아 두는 대신, 저장할 때 충돌을 검사하는 방식입니다. 대표적으로 행에 version 열을 둡니다.
재고 1과 버전 7을 읽었다면, 다음과 같이 갱신합니다.
UPDATE inventories
SET stock = 0,
version = version + 1
WHERE product_id = 1
AND version = 7;
A가 먼저 성공하면 버전은 8이 됩니다. B도 버전 7로 갱신하려 하지만 조건에 맞는 행이 없어 변경 행 수는 0입니다. 애플리케이션은 이를 충돌로 판단하고, 같은 트랜잭션의 다른 변경도 롤백해야 합니다.
JPA는 엔티티의 @Version 필드로 이런 검사를 지원합니다. 충돌은 flush나 커밋 과정에서 드러날 수 있습니다. flush는 엔티티 변경을 SQL로 DB에 반영하는 과정이며, 트랜잭션의 최종 확정인 commit과는 다릅니다. (참고: Jakarta Persistence의 버전 검사)
충돌 후 다시 시도한다면 실패한 트랜잭션을 끝내고 새 트랜잭션에서 값을 다시 읽어야 합니다. 다시 읽은 값이 0이면 재고 부족으로 처리를 끝냅니다.
Optimistic Locking도 UPDATE를 실행할 때는 DB 잠금을 사용합니다. 이름의 차이는 DB 락의 존재 여부보다 읽기부터 갱신까지 미리 잠가 둘지, 버전으로 변경을 검증할지에 있습니다.
조건부 UPDATE: 조건 검사와 변경을 함께 수행합니다
재고가 충분한지 확인한 뒤 차감하는 작업을 SQL 한 문장으로 표현할 수는 없을까요? 단일 상품의 재고라면 다음과 같이 조건과 변경을 함께 적을 수 있습니다.
UPDATE inventories
SET stock = stock - 1
WHERE product_id = 1
AND stock >= 1;
이 문장은 재고가 충분할 때만 현재 값에서 1을 뺍니다. 조건 검사와 변경을 한 문장으로 수행하는 Atomicity를 활용한 방식입니다. 여기에 InnoDB가 같은 행에 대한 동시 갱신을 잠금으로 조정하는 동작이 함께 작용합니다.
재고가 1일 때 A와 B가 이 UPDATE를 함께 실행하면, 먼저 행의 락을 얻은 A가 재고를 0으로 바꿉니다. B는 같은 행을 바꾸려다 A의 커밋을 기다리고, 커밋 뒤에는 재고 0에서 조건이 맞지 않아 변경 행 수 0으로 끝납니다. 앞의 장면에서 “조건부 UPDATE”를 고르면 이 순서를 볼 수 있습니다.
A가 롤백한다면 B는 복구된 재고를 기준으로 차감할 수 있습니다. 핵심은 B가 애플리케이션에서 미리 계산해 둔 값을 덮어쓰지 않는다는 점입니다.
-- 이 문장도 원자적으로 실행되지만, 재고가 충분한지 검사하지 않습니다.
UPDATE inventories
SET stock = 0
WHERE product_id = 1;
위 문장은 두 요청 모두 실행에 성공할 수 있습니다. 따라서 “UPDATE 한 문장이므로 안전하다”는 설명만으로는 부족합니다. 업무 조건을 WHERE에 넣고, DB의 현재 값을 이용해 변경한다는 점까지 봐야 합니다.
애플리케이션은 변경 행 수가 1이면 성공, 0이면 차감 실패로 처리합니다. 0건은 상품 자체가 없는 경우도 포함하므로 오류를 구분해야 한다면 별도로 판단합니다. 주문 저장까지 필요하면 이 UPDATE와 주문 저장을 같은 트랜잭션에 묶고, 차감 성공 시에만 주문을 저장해야 합니다. (참고: MySQL UPDATE의 잠금 동작)
이 방법도 DB 내부의 잠금을 사용합니다. 줄어드는 것은 별도의 선행 조회와 애플리케이션에서 검증하는 단계입니다. 한 행의 조건부 UPDATE가 지킬 수 있는 것은 그 행 안의 규칙까지입니다.
어떤 방법을 선택할까요?
| 상황 | 고려할 방법 | 확인할 비용이나 한계 |
|---|---|---|
| 단일 행의 조건 검사와 증감으로 표현할 수 있습니다. | 조건부 UPDATE | 변경 행 수 처리, 다른 작업과의 트랜잭션 경계 |
| 읽은 상태에 기반해 여러 변경을 계산하고, 충돌이 드뭅니다. | Optimistic Locking | 충돌 시 롤백·재시도 비용 |
| 같은 데이터에 충돌이 잦고, 검증부터 변경까지 보호해야 합니다. | Pessimistic Locking | 잠금 대기, 트랜잭션 길이, deadlock |
이 표는 선택의 출발점입니다. 특정 방법이 항상 빠르다고 정할 수는 없습니다. 같은 상품에 요청이 얼마나 몰리는지, 한 트랜잭션이 얼마나 오래 걸리는지에 따라 결과가 달라집니다.
서로가 가진 락을 기다리면 어떻게 될까요?
한 주문에서 상품 두 개를 함께 차감한다고 가정합니다. A는 상품 1의 락을, B는 상품 2의 락을 먼저 얻은 뒤 각자 상대가 쥔 상품의 락을 기다립니다.
서로 기다리는 고리는 언제 생길까요?
A와 B가 상품 1과 2의 재고를 함께 차감합니다. 잠그는 순서를 바꿔 보고, 기다림이 한 바퀴 고리로 닫히는지 보세요.
어느 트랜잭션을 롤백할지는 InnoDB가 정합니다. 여기서는 B가 롤백된다고 가정했습니다. 롤백된 B의 재시도와 lock wait timeout은 그리지 않았습니다.
서로가 가진 자원을 기다리므로 어느 쪽도 진행할 수 없습니다. 이를 deadlock이라고 합니다. 단순한 락 대기는 상대가 완료하면 풀리지만, deadlock은 서로 기다리는 고리가 생깁니다.
InnoDB는 기본적으로 deadlock을 감지하면 한 트랜잭션을 롤백해 다른 쪽이 진행하도록 합니다. 애플리케이션은 이 실패를 처리하고 필요하면 트랜잭션 전체를 재시도해야 합니다.
발생 가능성을 낮추려면 자원을 같은 순서로 접근하고, 트랜잭션을 짧게 유지하며, 쿼리에 맞는 인덱스로 불필요한 잠금 범위를 줄입니다. 예를 들어 모든 요청이 상품 ID 오름차순으로 갱신하면 위의 역순 획득을 피할 수 있습니다. (참고: MySQL deadlock 처리 문서)
Deadlock과 Lock Wait Timeout은 같은가요?
락을 오래 기다렸다고 모두 deadlock인 것은 아닙니다. A가 긴 작업을 하느라 B가 기다리는 상황에서는 A가 끝나면 B도 진행할 수 있습니다. 반면 A와 B가 서로의 락을 기다리는 상황은 한쪽을 중단하는 등의 개입이 필요합니다.
Lock Wait Timeout은 정해진 대기 시간을 넘겼다는 뜻입니다. Deadlock detection은 서로 기다리는 관계를 발견하는 것입니다. 원인이 다르므로 timeout 시간을 늘리는 것만으로 deadlock을 해결할 수는 없습니다.
InnoDB는 deadlock을 감지하면 한 트랜잭션을 롤백합니다. Lock wait timeout은 기본 설정에서 실패한 문장만 롤백하므로, 애플리케이션이 남은 변경을 커밋할지 롤백할지 명시적으로 정해야 합니다. (참고: MySQL Deadlock Detection, MySQL InnoDB Error Handling)
기본 개념을 하나의 흐름으로 연결하면
| 확인할 질문 | 관련 개념 |
|---|---|
| 어디까지 함께 확정하거나 취소할까요? | 트랜잭션, Atomicity |
| 어떤 업무 규칙을 지켜야 할까요? | Consistency, 제약조건과 갱신 로직 |
| 다른 트랜잭션의 어떤 변경을 읽을까요? | Isolation Level, MVCC |
| 같은 데이터를 동시에 바꾸면 어떻게 조정할까요? | Lock, 버전 검사, 조건부 UPDATE |
| 서로 기다리거나 충돌해서 실패하면 어떻게 할까요? | Deadlock 처리, rollback과 재시도 |
이제 기본 동작을 애플리케이션 코드로 옮길 때 생기는 질문을 살펴보겠습니다. SQL에서 트랜잭션의 시작과 끝이 명확해도, 여러 메서드와 요청이 연결되면 경계를 잘못 이해하기 쉽습니다.
2부. 애플리케이션에 적용하면 무엇을 더 고려할 수 있을까요?
앞에서는 트랜잭션 A와 B가 직접 SQL을 실행한다고 가정했습니다. 실제 애플리케이션에서는 요청을 받은 메서드가 다른 서비스를 호출하고, ORM이 SQL 실행을 미루기도 합니다. 충돌한 요청은 다시 실행될 수 있고, 같은 작업이 다른 경로에서 들어오기도 합니다.
이때는 락의 종류를 고르는 데서 한 걸음 더 나아가야 합니다. 트랜잭션이 실제로 끝나는 시점, 실패 후 다시 실행할 범위, 같은 작업을 여러 번 받아도 되는지를 함께 정해야 합니다.
이 질문을 설명하면서 이커머스 학습 프로젝트인 Peekcart의 재고 처리 사례를 함께 살펴보겠습니다. 온라인 쇼핑몰에서 주문이 들어오면 상품 수량을 확보하고, 주문이 취소되거나 확보한 수량의 예약 시간이 지나면 다시 판매할 수 있도록 돌려놓는 기능입니다. 주문 하나에 여러 상품이 포함될 수 있고, 여러 주문이 같은 상품의 재고를 동시에 변경할 수 있습니다.
이 프로젝트에서는 주문에 필요한 재고 확보 요청을 Kafka 메시지로 전달합니다. 메시지를 받아 처리하는 코드를 consumer라고 하며, 재고를 담당하는 서비스가 Spring과 JPA를 통해 DB의 수량과 예약 상태를 변경합니다. 서비스 인스턴스가 여러 개라면 서로 다른 인스턴스의 consumer가 같은 상품을 동시에 처리할 수 있습니다. 여기에 취소와 예약 만료에 따른 재고 복구도 겹칠 수 있다는 상황을 두고, 앞의 개념을 적용해 보겠습니다.
메서드가 끝나는 시점과 트랜잭션이 끝나는 시점은 같을까요?
Spring에서는 @Transactional을 붙인 메서드가 트랜잭션 경계를 표현합니다. 하지만 어노테이션이 붙은 메서드를 호출할 때마다 새로운 DB 트랜잭션이 생기는 것은 아닙니다.
기존 트랜잭션이 있을 때 어떻게 동작할지를 정하는 설정이 transaction propagation입니다. 기본 방식인 REQUIRED는 이미 트랜잭션이 있으면 참여하고, 없으면 새로 시작합니다. 다음은 Spring의 트랜잭션 프록시를 통해 다른 빈의 메서드를 호출하는 상황입니다.
바깥 메서드: @Transactional
└─ 재고 메서드: @Transactional(REQUIRED)
이 구성에서는 두 메서드가 하나의 물리적인 DB 트랜잭션을 사용할 수 있습니다. 재고 메서드가 반환되더라도 바깥 메서드의 작업이 남았다면 커밋되지 않습니다. (참고: Spring Transaction Propagation)
이 차이는 외부에서 획득한 락을 언제 해제할지 정할 때 중요합니다. 이 재고 예약 기능의 초기 구현에는 Redis 락을 잡고 재고 메서드를 호출한 뒤, 반환되면 락을 푸는 코드가 있었습니다. 그런데 메시지를 받는 consumer가 이미 트랜잭션을 시작한 상태에서 재고 메서드를 호출하고 있었습니다. 실행 순서를 단순화하면 다음과 같습니다.
바깥 트랜잭션 시작
→ Redis 락 획득
→ 재고 메서드 호출: 기존 트랜잭션에 참여
→ 엔티티의 재고 필드 변경
→ 재고 메서드 반환
→ Redis 락 해제
→ 바깥 작업 완료
→ flush와 DB 커밋
재고 메서드의 반환을 커밋 완료로 해석하면, 락을 풀어도 된다고 판단하기 쉽습니다. 하지만 이 순서에서는 다음 요청이 Redis 락을 얻을 때 앞선 요청의 재고 변경이 아직 커밋되지 않았을 수 있습니다.
락이 풀린 순간, 재고는 커밋됐을까요?
같은 상품의 재고 요청 두 개가 Redis 락으로 차례를 정합니다. 락을 푸는 시점을 바꿔 보고, 요청 1이 바꾼 값 9가 DB에 내려앉기 전과 후 중 언제 요청 2가 읽는지 보세요.
글의 실행 순서를 단순화한 모형입니다. 막대의 길이는 실제 시간이 아닙니다. 요청 1의 UPDATE는 커밋 과정에서 실행된다고 가정했고, 여러 품목의 auto-flush와 lease time 만료는 그리지 않았습니다. “커밋 뒤 해제”는 글에서 검토한 대안이며, Peekcart가 현재 적용한 방식은 아닙니다.
flush를 먼저 호출하면 해결될까요?
JPA에서는 엔티티 필드를 바꾸는 시점, SQL을 실행하는 flush, 트랜잭션을 확정하는 commit을 구분해야 합니다. flush가 성공했어도 트랜잭션은 이후 실패해서 롤백될 수 있습니다.
앞의 재고 예약 경로도 재고 필드를 바꾸는 메서드 안에서 즉시 커밋하지 않았습니다. 단일 품목 재현에서는 Redis 락이 해제된 뒤 커밋 과정에서 UPDATE가 실행됐습니다. 여러 품목을 처리할 때는 뒤의 조회 앞에서 auto-flush가 일어날 수도 있으므로, “SQL은 언제나 메서드 맨 마지막에 실행된다”는 가정도 맞지 않습니다.
flush를 앞당기면 DB가 변경과 잠금을 처리하는 시점은 바뀔 수 있습니다. 그래도 바깥 트랜잭션의 완료 시점은 바뀌지 않습니다. 외부 락으로 갱신 전체를 보호하려는 설계라면, SQL 실행 여부뿐 아니라 커밋까지 포함한 경계를 봐야 합니다. (참고: Jakarta Persistence의 DB 동기화 규칙)
내부 트랜잭션을 따로 만들면 어떨까요?
REQUIRES_NEW는 바깥 트랜잭션과 독립된 물리 트랜잭션을 사용합니다. 재고 차감을 따로 커밋한 뒤 락을 풀 수 있지만, 그때는 실패의 의미도 달라집니다.
바깥 작업 시작
→ 별도 트랜잭션에서 재고 차감 후 커밋
→ 바깥 작업 실패 및 롤백
→ 먼저 커밋한 재고 차감은 남음
따라서 트랜잭션을 분리할지는 “락을 빨리 풀 수 있는가”뿐 아니라 어떤 변경을 함께 롤백해야 하는가로 판단해야 합니다. 별도 커밋이 필요하다면 남은 변경을 어떻게 처리할지도 설계해야 합니다. 또한 바깥 트랜잭션이 커넥션을 보유한 상태에서 안쪽이 다른 커넥션을 요구할 수 있으므로, 커넥션 풀의 여유도 확인해야 합니다. (참고: Spring REQUIRES_NEW 설명)
함께 롤백해야 하는 작업이라면 하나의 트랜잭션을 유지하면서, 필요한 외부 락을 그 바깥에서 관리하거나 DB가 트랜잭션에 맞춰 관리하는 락을 검토할 수 있습니다. 메서드를 나누는 것과 트랜잭션을 나누는 것은 다른 결정입니다.
서버가 여러 대라면 분산 락이 필요할까요?
프로세스 안의 메모리로 관리하는 락은 다른 프로세스와 자동으로 공유되지 않습니다. 서버 두 대가 각각 자기 락을 얻으면 둘 다 같은 재고를 변경하러 갈 수 있습니다.
그렇다고 서버가 여러 대라는 이유만으로 Redis 락을 추가해야 하는 것은 아닙니다. 같은 DB에 접근한다면 DB 행 락은 어느 서버에서 들어온 요청인지와 관계없이 갱신을 조정합니다. 앞서 본 조건부 UPDATE와 버전 검사도 여러 서버가 같은 DB를 사용할 때 적용할 수 있습니다.
Redis 같은 공유 저장소에서 관리하는 distributed lock은 여러 프로세스가 같은 키를 기준으로 작업 순서를 조정할 때 고려할 수 있습니다. 이 경우에는 그 키를 어떤 경로가 사용하고, 언제까지 락이 유지되는지가 추가 조건이 됩니다.
| 구분 | 함께 확인할 내용 |
|---|---|
| 프로세스 내부 락 | 보호 대상에 접근하는 실행 흐름이 모두 같은 프로세스 안에 있는가? |
| DB 락 | 어떤 행과 범위를 잠그며, 어떤 트랜잭션이 락을 보유하는가? |
| 외부 distributed lock | 모든 관련 경로가 같은 락 규약에 참여하며, 작업 완료까지 소유권이 유지되는가? |
앞서 살펴본 초기 구현에서는 Redis 락과 @Version을 함께 사용했습니다. Redis 락으로 같은 상품의 요청을 먼저 조정하고, DB의 버전 검사로 오래된 갱신을 거부하려는 구성이었습니다. 하지만 앞에서 본 커밋 경계가 맞지 않아, 이 경로에서는 Redis 락만으로 충돌을 예방할 수 없었습니다.
이 사례를 다른 시스템에 적용할 때의 질문은 “락을 몇 겹 둘까?”보다 “각 장치가 어떤 상황을 실제로 막고 있는가?”에 가깝습니다. 두 장치가 서로 다른 실패를 다루는지, 한쪽이 제대로 작동하지 않아 다른 쪽이 계속 실패를 받아내고 있는지 구분해야 합니다.
락의 유지 시간이 작업보다 짧으면
외부 락에는 보유자가 사라졌을 때 영원히 잠기지 않도록 lease time을 둘 수 있습니다. 정해진 시간이 지나면 락을 다시 얻을 수 있게 하는 방식입니다. 그런데 기존 작업이 멈추지 않고 계속 실행 중일 수도 있습니다.
A가 락 획득 → A의 DB 작업 지연
↓
lease time 만료
↓
B가 같은 락 획득 → A와 B가 작업을 계속 진행
이 초기 구현의 Redis 락은 lease time을 5초로 두었습니다. 재현 테스트에서는 그 시간이 지난 뒤 다른 스레드가 같은 키의 락을 얻을 수 있음을 확인했습니다. 원래 보유자가 뒤늦게 unlock을 호출해도 새 보유자의 락은 풀지 않았지만, 이것만으로 두 작업이 겹치는 상황을 막을 수는 없었습니다.
외부 락을 선택한다면 작업 시간과 lease time의 관계, 갱신 방식, 소유권을 잃은 작업의 처리를 검토해야 합니다. DB가 버전이나 상태 조건을 직접 검사하도록 하면, 외부 락의 유지 여부와 별개로 거부할 수 있는 갱신이 생깁니다. 다만 어떤 조건을 검사할지는 지켜야 할 업무 규칙에 맞춰 정해야 합니다.
충돌을 감지한 다음에는 어디서부터 다시 실행할까요?
Optimistic Locking이 충돌을 감지하면 오래된 값으로 덮어쓰는 것을 막을 수 있습니다. 하지만 그 요청은 아직 처리에 성공하지 못했습니다. 여기서 데이터를 잘못 바꾸지 않는 것과 요청이 끝내 처리되는 것을 나누어 볼 수 있습니다.
재시도는 실패한 UPDATE만 그대로 반복하는 방식으로는 충분하지 않습니다. 읽은 버전과 판단 근거가 이미 오래됐을 수 있기 때문입니다.
첫 시도
재고 1, version 7 조회
→ 다른 요청이 먼저 갱신
→ version 7 조건의 UPDATE 실패
→ 트랜잭션 롤백
재시도
새 트랜잭션 시작
→ 현재 재고와 version 다시 조회
→ 조건 재검사
→ 가능하면 변경, 재고가 없으면 종료
따라서 재시도 범위는 보통 읽기 → 검증 → 변경 → 커밋을 포함해야 합니다. 이미 실패한 트랜잭션 안에서 예외를 잡고 같은 엔티티를 다시 저장하는 것으로 해결하려 해서는 안 됩니다.
실패 종류도 구분해야 합니다. 버전 충돌은 새 상태를 읽고 다시 판단할 여지가 있지만, 재고가 이미 0이라는 결과는 같은 요청을 즉시 반복해도 해결되지 않을 수 있습니다. 잘못된 수량처럼 입력 자체가 문제인 경우도 재시도 대상과 다르게 다뤄야 합니다.
다시 시도하는 시각까지 같으면
동시에 실패한 요청들이 모두 1초 뒤에 다시 실행되면, 다음 시도에서도 겹칠 수 있습니다. 대기 간격을 두는 것을 backoff, 그 간격에 무작위 편차를 넣는 것을 jitter라고 합니다.
Peekcart의 현재 재고 처리에는 @Version을 사용하고, 해당 서비스의 메시지 재시도 간격에 jitter를 적용합니다. 기준 간격은 1초, 5초, 30초이고, 각 간격에 ±50%의 편차를 둡니다.
| 재시도 단계 | 기준 간격 | jitter를 적용한 대략적인 범위 |
|---|---|---|
| 1회 | 1초 | 0.5~1.5초 |
| 2회 | 5초 | 2.5~7.5초 |
| 3회 | 30초 | 15~45초 |
이 설정은 비동기 메시지 처리의 재시도입니다. 사용자가 응답을 기다리는 HTTP 요청은 허용할 수 있는 지연이 다르므로, 재시도 횟수와 간격도 따로 정해야 합니다.
재시도 횟수를 소진한 메시지는 DLQ(Dead Letter Queue)로 보냅니다. 자동 처리에 실패한 메시지를 따로 보관해 확인하고 다시 처리할 수 있게 하는 곳입니다. 횟수 제한 없이 반복하면 끝나지 않는 요청이 자원과 로그를 계속 소비할 수 있으므로, 자동 재시도가 끝난 뒤의 처리도 필요합니다.
jitter는 동시에 다시 실행될 가능성을 낮추지만, 충돌을 없애거나 성공을 보장하지는 않습니다. Peekcart의 기록에서도 고정 간격 재시도가 경합을 반복할 수 있다는 메커니즘과 실제 부하에서 발생한 모든 실패의 원인을 구분했습니다. jitter 적용 뒤의 처리량 변화를 보여 줄 측정값은 이 글에 포함하지 않았습니다.
동시성 제어가 중복 처리도 막아 줄까요?
버전 검사는 같은 상태를 읽은 요청이 서로 덮어쓰는 것을 막습니다. 하지만 같은 요청이 시간차를 두고 두 번 들어오는 문제까지 자동으로 해결하지는 않습니다.
예를 들어 주문 취소 후 재고를 1개 복구하는 요청이 두 번 들어왔다고 가정하겠습니다.
| 순서 | 처리 결과 |
|---|---|
| 첫 번째 복구 | stock 9 → 10, version 7 → 8 |
| 두 번째 복구 | 최신 version 8을 읽고 stock 10 → 11, version 8 → 9 |
두 번째 요청은 최신 버전으로 갱신했으므로 버전 충돌이 없습니다. 각각의 UPDATE는 정상이어도 같은 주문의 재고를 두 번 복구했다는 업무 오류가 생깁니다.
여기서 필요한 개념이 idempotency입니다. 같은 작업을 다시 처리하더라도 의도한 효과가 중복해서 발생하지 않도록 만드는 성질입니다. 버전 충돌을 막는 것과 작업의 중복 여부를 판단하는 것은 서로 다른 조건입니다.
같은 복구 요청이 두 번 오면
주문 101의 취소로 재고를 1개 돌려놓는 요청이 시간차를 두고 두 번 들어옵니다. 조건을 바꿔 다시 보내 보세요.
두 요청이 순차로 도착하는 경우를 그렸습니다. 글처럼 상태 변경과 재고 복구는 같은 트랜잭션에서 실행된다고 가정합니다. 동시에 도착하는 경우의 행 락 대기와 다른 주문의 차감은 그리지 않았습니다.
상태 변경에 성공한 요청만 다음 작업을 수행하게 한다면
앞서 소개한 재고 예약 기능에서는 주문 때문에 확보해 둔 수량을 예약 상태로 관리합니다. 취소 요청이나 예약 만료 처리가 재고를 돌려놓을 때, 다음과 같은 조건부 상태 변경을 먼저 수행합니다. 아래 SQL은 실제 상태 변경 쿼리를 핵심 조건만 남겨 표현한 것입니다.
UPDATE stock_reservations
SET status = 'RELEASED'
WHERE order_id = :orderId
AND status = 'RESERVED';
변경 행 수가 1인 요청만 재고를 복구합니다. 먼저 처리한 트랜잭션이 커밋하면, 뒤의 요청은 상태가 이미 RELEASED여서 조건을 만족하지 못합니다. 순차적으로 같은 요청이 다시 와도 같습니다.
이는 기대한 상태일 때만 다음 상태로 바꾸는 Compare-and-Set 방식으로 이해할 수 있습니다. CPU의 CAS 명령 대신, 데이터베이스의 조건부 UPDATE와 그 결과로 처리 권한을 정합니다.
하나의 트랜잭션
→ RESERVED인 예약을 RELEASED로 변경
→ 변경 행 수가 1이면 재고 복구
→ COMMIT
상태 변경과 재고 복구를 같은 트랜잭션에 묶는 점도 중요합니다. 상태만 커밋하고 재고 복구가 실패하면, 다음 요청은 이미 처리된 상태를 보고 복구를 건너뛸 수 있습니다. 같은 트랜잭션에서 재고 갱신이 실패하면 상태 변경도 롤백되어야 합니다.
이 복구 경로에서는 이 상태 조건이 같은 예약의 중복 복구를 막고, 재고의 @Version은 다른 주문의 차감·복구와 겹친 갱신을 검사합니다. 하나의 락으로 모든 문제를 설명하기보다, 막아야 할 중복과 충돌을 각각 조건으로 표현한 것입니다.
한 행을 보호하면 전체 업무 규칙도 지켜질까요?
지금까지의 재고 예시는 한 상품의 수량을 중심으로 설명했습니다. 실제 작업은 여러 상품을 함께 처리하거나, 서로 다른 행의 관계를 검사할 수 있습니다.
먼저 여러 변경을 함께 취소해야 하는 문제를 생각할 수 있습니다. 상품 A를 차감한 뒤 상품 B의 차감이 실패했다면 A도 취소해야 하는 경우입니다. 이때는 두 변경이 같은 트랜잭션에 속하는지 확인해야 합니다. 각각의 행을 안전하게 갱신했더라도 A만 따로 커밋했다면 전체 주문의 Atomicity는 확보되지 않습니다.
이 프로젝트의 재고 예약은 여러 상품을 먼저 확인한 뒤 차감하는 흐름을 가집니다. 선행 조회에서 수량이 충분해 보였다는 사실만으로 이후 차감을 보장할 수는 없습니다. 다른 요청이 중간에 변경할 수 있기 때문입니다. 현재 구조에서는 차감 중 발생한 실패가 전체 예약 트랜잭션을 롤백하도록 하고, 버전 충돌도 그 실패에 포함합니다.
다음으로 서로 다른 행 사이의 규칙을 생각할 수 있습니다. 예를 들어 창고 A와 B 중 적어도 한 곳에는 비상 재고를 남겨야 한다고 가정하겠습니다. 두 요청이 모두 “다른 창고에 재고가 있다”고 읽은 뒤 각자 자기 창고의 재고를 0으로 바꾸면, 갱신한 행은 서로 달라도 전체 규칙은 깨질 수 있습니다.
이런 형태의 문제를 Write Skew로 설명할 수 있습니다. 두 요청이 서로 다른 행을 수정했다면 각 행의 버전 검사가 모두 통과할 수도 있습니다. 여기서는 한 행의 재고 예시를 여러 행의 규칙으로 넓혀 가정한 상황입니다.
여기까지 확장되면 관련 행을 함께 잠그거나, 규칙을 대표하는 공통 행에서 경합하도록 모델링하거나, SERIALIZABLE을 검토할 수 있습니다. 각각 잠금 범위와 재시도 비용이 달라집니다. 검사하는 규칙의 범위와 충돌을 감지하는 범위가 일치하는지가 판단 기준입니다. (참고: PostgreSQL Repeatable Read와 Serializable 설명)
동시성 테스트는 어떤 실행 순서를 만들어야 할까요?
스레드를 많이 실행했다고 반드시 검증하려는 충돌이 발생하는 것은 아닙니다. 실행 시점이 어긋나 우연히 순차 처리되면, 잘못된 코드도 테스트를 통과할 수 있습니다.
앞의 Lost Update를 검사하려면 적어도 두 요청이 같은 값을 읽은 뒤 변경하도록 실행 순서를 맞춰야 합니다. 커밋 전 락 해제를 검사하려면 락 획득 횟수보다 unlock과 commit이 발생한 순서가 중요합니다.
앞서 본 재고 예약 기능의 기존 테스트는 Redis 락을 관리하는 객체를 바깥 트랜잭션 없이 직접 호출했습니다. 그 조건에서는 안쪽 재고 메서드가 자기 트랜잭션을 시작하고 커밋한 뒤 반환하므로, 락이 커밋 이후에 풀렸습니다. 실제 호출 경로에는 바깥 트랜잭션이 있었기 때문에 같은 결론을 그대로 적용할 수 없었습니다.
따라서 테스트에서도 실제 트랜잭션 경계를 구성할 필요가 있습니다. 같은 기능의 재현 테스트에서는 unlock이 afterCommit보다 먼저 발생하는지 순서로 확인했고, 여러 요청이 커밋 전에 만나는 지점을 만들어 충돌을 재현했습니다. 단순히 몇 초 기다리는 것보다, 실행 흐름이 특정 지점에 도착했는지를 맞추는 방식이 의도를 더 분명하게 보여줍니다.
| 검증할 내용 | 만들어야 할 조건 | 확인할 결과 |
|---|---|---|
| 버전 충돌을 감지하는가? | 여러 요청이 같은 버전을 읽고 갱신 | 성공·충돌 건수와 최종 재고 |
| 락이 필요한 구간을 보호하는가? | 실제 바깥 트랜잭션을 포함한 호출 | 락 해제와 커밋의 순서 |
| 중복 복구를 막는가? | 같은 예약의 복구를 동시·순차로 반복 | 상태 변경과 재고 복구 횟수 |
| 여러 품목이 함께 롤백되는가? | 일부 변경 뒤 실패를 발생시킴 | 부분 차감이 남지 않음 |
| 외부 락 만료를 다루는가? | 작업 중 lease time 만료 | 다른 요청 진입과 기존 요청의 처리 결과 |
테스트의 기대값도 선택한 전략에 맞아야 합니다. Optimistic Locking에서 충돌한 요청이 있다는 사실은 실패가 아니라, 전략이 의도대로 충돌을 감지했다는 신호입니다.
수량 1의 차감만 있고 복구나 다른 재고 변경이 없는 테스트라면 다음 관계를 확인할 수 있습니다.
최종 재고 = 초기 재고 - 실제 커밋된 차감 횟수
다양한 수량을 차감하면 성공한 수량의 합을 빼야 하고, 복구도 실행했다면 복구 수량을 더해야 합니다. 재고가 음수가 아닌지만 확인하는 것으로는 충분하지 않습니다. 재고는 0인데 주문 두 건이 성공했던 첫 예시도 음수 재고는 아니었기 때문입니다.
어떤 지표를 보고 방법을 바꿀 수 있을까요?
정합성 검사를 통과했다고 사용자 요청의 처리 비용까지 적절하다고 볼 수는 없습니다. 선택한 방식에 따라 기다리는 시간, 충돌 후 재시도 횟수, 끝내 처리되지 못한 작업 수가 달라집니다.
Peekcart의 GKE 부하 기록에서는 재고를 처리하는 서비스 인스턴스 세 개에 동일 상품의 주문을 집중시켰을 때, 분산 락 획득 실패는 0건이었지만 Optimistic Locking 예외와 DLQ 유입이 각각 7건 기록됐습니다. 음수 재고 행은 없었고, 품목별 재고 정합성 검사에서도 불일치가 없었습니다.
이 결과는 재고를 잘못 바꾸지 않았다는 확인과, 자동 처리가 끝나지 못한 작업이 남았다는 관찰이 함께 성립한다는 예입니다. 락 획득 실패가 없다는 지표 하나로 동시성 문제가 없다고 판단할 수는 없습니다.
다만 이 수치를 모든 요청에서 충돌이 정확히 일곱 번만 발생했다는 뜻으로 확대하거나, 특정 락 전략이 더 빠르다는 비교 결과로 사용하면 안 됩니다. 해당 측정의 분산 상품 대조군은 이전 실행의 잔여 작업이 섞여 비교에 사용할 수 없었습니다. jitter 도입 이후 개선 폭도 이 수치만으로 알 수 없습니다.
선택지를 비교하려면 동일한 데이터와 부하 조건에서 다음 항목을 함께 볼 수 있습니다.
- 정합성: 성공한 작업과 최종 재고가 맞는가?
- 완료율: 요청이 실제로 처리됐는가, 재시도 중이거나 실패 상태로 남았는가?
- 지연: 락 대기와 재시도를 포함해 완료까지 얼마나 걸리는가?
- 비용: DB 커넥션 점유, CPU 사용, 반복 쿼리 수가 어떻게 변하는가?
이 관점에서 Peekcart의 현재 선택은 재고 경로의 Redis 락을 제거하고 @Version과 제한된 재시도를 사용하는 것입니다. 커밋 경계를 바로잡아 외부 락을 유지하는 방법과 Pessimistic Locking도 검토했지만, 추가 잠금 관리와 DB 대기 비용을 함께 고려했습니다. 이 선택은 Peekcart의 조건에 맞춘 것입니다.
단일 행의 조건과 증감으로 충분하다면 조건부 UPDATE가 더 간단할 수 있습니다. 여러 변경을 계산한 뒤 저장하고 충돌이 드물다면 버전 검사를, 같은 데이터에 충돌이 집중된다면 locking read를 비교할 수 있습니다. Peekcart의 현재 재고 차감은 조건부 UPDATE로 전환한 구현이 아니므로, 이 선택지는 앞에서 설명한 대안으로 구분합니다.
특정 자원의 경합을 DB 밖에서 다룰 수도 있을까요?
지금까지는 여러 작업이 DB에 도착한 뒤 락이나 버전 검사로 충돌을 다뤘습니다. 같은 상품으로 요청이 계속 몰린다면, DB에 도착하기 전에 작업 순서를 정하거나 재고 확보 여부를 결정하는 방법까지 생각해 볼 수 있습니다. Message Broker나 Event Broker로 작업 유입을 조절하는 구조도 이 질문과 연결됩니다. 여기서는 Redis를 예로 두 가지 방향만 살펴보겠습니다.
앞서 살펴본 재고 예약도 Kafka 메시지를 받는 consumer가 처리하지만, 앞서 본 것처럼 여러 인스턴스에서 같은 상품을 처리하면 DB 충돌이 발생할 수 있었습니다. 브로커를 거치는 것에서 더 나아가, 어떤 자원의 작업을 함께 모으고 어디에서 재고 확보 여부를 결정할지 살펴볼 이유가 됩니다. 아래 Redis 방식은 이 문제에서 확장해 볼 수 있는 선택지입니다. Peekcart에 적용해 측정한 결과는 아닙니다.
Redis Streams로 같은 상품의 작업을 차례로 처리한다면
Redis Streams에 재고 변경 요청을 쌓고, 이를 받아 실행하는 consumer가 같은 상품의 작업을 하나씩 처리하도록 구성할 수 있습니다. 상품 A의 작업이 DB에 반영된 뒤 다음 A 작업을 시작하고, 상품 B의 작업은 별도로 처리하는 방식입니다.
상품 A 요청들 → Streams → A 작업을 하나씩 처리 → DB 반영
상품 B 요청들 → Streams → B 작업을 하나씩 처리 → DB 반영
여기서 Redis가 single-threaded라는 설명의 범위를 구분해야 합니다. Redis 인스턴스 안에서 일반적인 명령 실행은 직렬화되지만, 메시지를 꺼낸 consumer가 수행하는 DB 작업까지 Redis가 순차 실행해 주지는 않습니다. 네트워크 I/O나 백그라운드 작업까지 모두 하나의 스레드에서 처리한다는 뜻도 아닙니다. (참고: Redis의 명령 실행 모델)
Streams의 consumer group은 여러 consumer가 작업을 나눠 처리할 수 있습니다. 따라서 같은 상품의 요청 두 개를 서로 다른 consumer가 받아 동시에 DB에 반영하면 경합은 다시 생깁니다. 같은 상품의 작업을 한 처리 흐름으로 모으고, 앞 작업의 완료 후 다음 작업을 시작하도록 구성해야 합니다. 실패한 작업을 재처리할 때도 이 순서를 유지할지 정해야 합니다. (참고: Redis XREADGROUP)
이 방식은 재고 판단을 DB에 두면서 DB로 들어가는 동시 실행 수를 줄입니다. 락 대기나 버전 충돌에 쓰던 비용을 줄일 수 있지만, 요청은 큐에서 기다리게 됩니다. 취소나 재고 복구도 같은 상품을 변경한다면 이 처리 규칙에 함께 포함해야 합니다.
재고 확보 여부를 Redis에서 먼저 결정한다면
한 단계 더 나아가 Redis에 판매 가능한 수량을 두고, 재고 확인과 차감을 Redis 안에서 원자적으로 실행하는 방법도 있습니다. 예를 들어 재고가 1개인 상품에 요청 두 개가 들어오면, Redis에서 먼저 실행된 요청만 수량을 0으로 줄이고 재고를 확보합니다. 다음 요청은 남은 수량이 없다는 결과를 받습니다. 확보에 성공한 요청을 후속 DB 처리로 넘기는 구조입니다.
동시 요청 → Redis에서 수량 확인 + 차감 → 확보 성공 → DB 반영
→ 수량 부족 → 실패 응답
단순히 애플리케이션에서 GET으로 읽고 판단한 뒤 DECR을 호출하면, 두 명령 사이에 다른 요청이 끼어들 수 있습니다. DECR만 실행하면 수량이 음수가 될 수도 있습니다. 따라서 Lua script 등으로 “수량이 충분한가?”라는 조건과 차감을 하나의 원자적 실행으로 묶어야 합니다. 앞에서 조건부 UPDATE로 검증과 변경을 묶었던 원리를 Redis에 적용하는 것입니다. (참고: Redis Lua script의 atomic execution)
이 구조에서는 Redis가 재고 확보를 결정하는 역할을 맡습니다. 모든 요청이 같은 DB 행에서 경쟁하는 대신, Redis에서 결정된 결과를 DB에 반영하도록 경합 위치를 옮길 수 있습니다. 다만 성공한 요청들이 같은 DB 행을 동시에 갱신하면 DB 경합이 남을 수 있으므로, 후속 반영의 동시 실행 수도 함께 조절할 수 있습니다.
Redis에서 재고를 확보한 것과 DB 트랜잭션이 커밋된 것은 별개의 사건입니다. 둘 사이에 실패가 생겼을 때 확보한 수량을 어떻게 복구할지, 같은 요청을 다시 처리해도 중복 차감하지 않을지, Redis 장애 이후 수량을 어떤 기록으로 맞출지는 추가로 정해야 합니다. 이 글에서는 경합을 처리하는 위치를 바꾸면, 두 저장소 사이의 상태를 맞추는 문제도 함께 생긴다는 지점까지만 확장하겠습니다.
기본 개념에서 애플리케이션의 동작까지
트랜잭션은 함께 확정하고 취소할 작업의 범위를 정합니다. 격리 수준은 동시 실행 중 어떤 변경을 읽고 어떤 간섭을 허용할지 정합니다. 락과 버전 검사, 조건부 UPDATE는 그 안에서 충돌하는 접근과 갱신을 제어하는 수단입니다.
애플리케이션에 적용하면 질문이 조금 더 구체적이 됩니다.
| 기본 개념 | 확장해서 확인할 질문 |
|---|---|
| Atomicity | 메서드가 나뉘어 있어도 필요한 변경이 함께 롤백되는가? |
| Isolation과 MVCC | 선행 조회의 판단이 실제 변경 시점에도 유효한가? |
| Lock | 실제 커밋까지 필요한 범위를 보호하며, 모든 변경 경로가 같은 규약을 따르는가? |
| Optimistic Locking | 충돌 후 새 트랜잭션에서 다시 읽고 판단하는가? |
| 조건부 UPDATE | 수량뿐 아니라 처리 상태를 조건으로 중복 효과를 막을 수 있는가? |
| Deadlock과 재시도 | 실패를 어디까지 취소하고, 몇 번 다시 실행할 것인가? |
| 동시성 검증 | 실제 호출 경계와 충돌 순서를 만들고, 완료된 작업과 데이터를 함께 검사하는가? |
| 경합의 위치 | 같은 자원의 작업을 DB 밖에서 순차 처리하거나, 재고 확보 판단을 옮길 수 있는가? |
재고 차감이든 좌석 예약이든 포인트 사용이든, 지켜야 할 규칙과 그 규칙이 깨질 수 있는 실행 순서를 먼저 적으면 필요한 트랜잭션 경계와 동시성 제어 방법을 더 구체적으로 고를 수 있습니다.