재고 동시성을 공부하며 이해한 SELECT와 FOR UPDATE

3줄 요약
1. MySQL RR의 일반 SELECT는 기존 Read View를 유지하고, 필요하면 Undo로 이전 버전을 읽는다.
2. RR 격리 수준에서 FOR UPDATE는 현재 상태를 읽으며 잠그지만 Read View를 바꾸지 않아, 같은 트랜잭션에서도 5 → 4 → 5가 보일 수 있다.
3. 재고 차감은 잠금 조회 → 검사 → 차감을 한 트랜잭션으로 묶거나 조건부 UPDATE로 처리해야 하며, 조건부 UPDATE도 잠금을 사용한다.
여러 주문이 동시에 들어와도 재고가 정확하게 차감되도록 만들어야 합니다.
제가 먼저 떠올린 흐름은 단순했습니다. 재고를 조회하고, 주문 수량만큼 남아 있는지 확인한 뒤, 그 수량을 빼서 저장하는 것입니다.
그동안 업무에서는 이런 동시성 문제를 깊게 고민해 볼 일이 많지 않았습니다. 처음에는 이 흐름을 트랜잭션으로 묶고 적절한 락을 걸면 되겠다고 생각했습니다.
그런데 조회할 때부터 잠근다는 SELECT ... FOR UPDATE를 살펴보다가 의문이 생겼습니다. 일반 SELECT에 잠금만 더하는 것이라면, 같은 트랜잭션 안에서는 읽는 값도 같지 않을까요?
재고 차감 로직에 적용하기 전에 MySQL에서 작게 실험해 봤습니다. 다른 주문이 중간에 재고를 바꾸는 상황을 두 콘솔로 만들어 봤더니, 잠금 유무뿐 아니라 조회 결과 자체도 달랐습니다.
저는 REPEATABLE READ를 “같은 트랜잭션에서 반복해서 조회하면 같은 값이 보이는 것”으로 이해하고 있었습니다. 이 결과를 보고 제가 놓친 부분이 무엇인지 찾아보기로 했습니다.
재고 차감에서 출발한 이 의문을 따라, 일반 SELECT와 FOR UPDATE의 읽기 방식이 어떻게 다른지 살펴봤습니다.
그 과정에서 확인한 일관 읽기와 Read View, Undo의 역할을 함께 정리했습니다.
실험 환경
MySQL 8.0 · InnoDB · REPEATABLE READ
이후 REPEATABLE READ는 RR로 줄여 부릅니다.
재고가 음수가 아니어도 주문 결과는 틀릴 수 있다
조회 → 검사 → 차감 흐름을 생각하면서, 두 주문이 같은 상품을 동시에 구매하는 상황을 먼저 떠올렸습니다. 재고가 5개인 상품을 각각 1개씩 주문했는데, 두 요청 모두 5를 읽으면 어떻게 될까요?
| 순서 | 요청 A | 요청 B |
|---|---|---|
| 1 | 재고 5 조회 | 재고 5 조회 |
| 2 | 5 − 1 = 4 계산 | 5 − 1 = 4 계산 |
| 3 | 재고를 4로 저장하고 커밋 | |
| 4 | 재고를 4로 저장하고 커밋 |
두 주문은 성공했지만 최종 재고는 4입니다. 두 개를 판매했으니 남은 재고는 3이어야 합니다.
한 요청의 차감이 다른 요청의 저장에 덮인 갱신 유실(Lost Update)입니다.
재고가 음수인지만 검사했다면 이 문제를 놓쳤을 겁니다. 성공한 주문 수량과 남은 재고를 함께 확인해야 합니다.
초기 재고 = 성공한 요청의 구매 수량 합 + 남은 재고
기대: 5 = 2 + 3
갱신 유실: 5 ≠ 2 + 4
이 상황에서 애플리케이션이 계산한 값을 그대로 저장한다면, 두 요청 모두 아래와 같은 UPDATE를 실행하게 됩니다.
UPDATE product SET stock = 4 WHERE id = 1;
-- 두 요청이 각각 이미 계산한 4를 저장하는 상황
UPDATE도 잠금을 잡습니다. 하지만 두 명령을 차례로 실행해도 둘 다 “4로 설정하라”는 내용이므로 최종 값은 4입니다.
변경할 때의 잠금이 그 전에 끝난 조회와 계산까지 보호해 주지는 않습니다.
그래서 조회할 때부터 잠그는 FOR UPDATE를 알아봤습니다. 재고 차감에 사용하려면 어떤 값을 받아서 검사하게 되는지부터 확인하고 싶었습니다.
같은 트랜잭션에서 5와 4가 함께 보였다
주문 전체를 실행하는 대신, 실습용 테이블에 상품 한 행만 두고 조회 방식을 확인했습니다. 콘솔 A에서 재고를 읽고 트랜잭션을 유지한 채, 콘솔 B가 다른 주문처럼 재고를 5에서 4로 바꾸고 커밋하도록 했습니다.
| 순서 | 트랜잭션 A · 조회 | 트랜잭션 B · 변경 |
|---|---|---|
| 1 | 시작 → 일반 SELECT: 5 | |
| 2 | 트랜잭션 유지 | 재고를 4로 변경 → COMMIT |
| 3 | B의 커밋 이후 일반 SELECT: 5 | |
| 4 | SELECT ... FOR UPDATE: 4 | |
| 5 | 일반 SELECT: 5 <<< 눈 여겨볼 부분 | |
| 6 | COMMIT |
눈여겨볼 부분은 마지막 조회입니다. 방금 FOR UPDATE로 4를 확인했는데, 일반 SELECT는 다시 5를 반환합니다.
처음에는 잠금을 잡으면 조회 기준도 최신으로 바뀔 거라고 생각했습니다. 하지만 결과는 그렇지 않았습니다. 어떤 기준으로 값을 읽는지 알아볼 필요가 생겼습니다.
직접 따라 하려면 서로 다른 콘솔 두 개를 준비합니다.
먼저 한쪽 콘솔에서 실습용 데이터베이스와 테이블을 만듭니다.
CREATE DATABASE IF NOT EXISTS stock_read_lab;
USE stock_read_lab;
CREATE TABLE product (
id BIGINT PRIMARY KEY,
stock INT NOT NULL
) ENGINE = InnoDB;
START TRANSACTION;
INSERT INTO product (id, stock) VALUES (1, 5);
COMMIT;
두 콘솔 모두 아래 설정을 실행합니다. CONNECTION_ID()가 서로 다른지 확인하면 독립된 연결인지 알 수 있습니다.
USE stock_read_lab;
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT CONNECTION_ID(), @@session.transaction_isolation;
① 콘솔 A: 재고를 처음 조회하고, 아직 커밋하지 않습니다.
START TRANSACTION;
SELECT stock FROM product WHERE id = 1;
-- 5
② 콘솔 B: 재고를 변경하고 커밋합니다.
START TRANSACTION;
UPDATE product SET stock = 4 WHERE id = 1;
COMMIT;
③ 콘솔 A: 같은 트랜잭션에서 다음 쿼리를 순서대로 실행합니다.
SELECT stock FROM product WHERE id = 1;
-- 5
SELECT stock FROM product WHERE id = 1 FOR UPDATE;
-- 4
SELECT stock FROM product WHERE id = 1;
-- 5
COMMIT;
다시 실험할 때는 재고를 5로 초기화합니다. 두 콘솔의 트랜잭션을 COMMIT 또는 ROLLBACK으로 종료한 뒤, 한쪽 콘솔에서 UPDATE product SET stock = 5 WHERE id = 1;을 실행하고 커밋합니다. 테이블을 다시 만들 필요 없이 ①부터 진행하면 됩니다.
RR 설명에서 ‘일관 읽기’라는 말을 다시 봤다
실험 결과를 보고 RR의 의미부터 다시 확인해 보기로 했습니다. 같은 트랜잭션이라면 같은 값을 읽는다고 생각했는데, FOR UPDATE에서는 왜 다른 값이 나올까요?
MySQL 공식 문서의 Consistent Nonlocking Reads 항목을 읽어 보니, 반복해서 같은 기준으로 읽는다는 설명에는 일관 읽기(consistent read)라는 말이 붙어 있었습니다. 이 말이 어떤 조회를 가리키는지부터 살펴봤습니다.

문서에서 눈에 들어온 부분은 “첫 일관 읽기”였습니다. RR에서는 같은 트랜잭션의 일관 읽기가 첫 일관 읽기로 정한 스냅샷을 사용한다고 설명합니다.
여기서 말하는 일관 읽기에 제가 실행한 일반 SELECT stock FROM product WHERE id = 1도 해당합니다.
일관 읽기는 특정 시점을 기준으로 보이는 데이터 버전을 읽는 방식입니다. 다른 트랜잭션이 그 기준 전에 커밋한 변경은 보이지만, 당시 진행 중이었거나 이후에 발생한 변경은 보이지 않습니다.
RR에서는 첫 일관 읽기에서 정한 기준을 이후의 일관 읽기에서도 사용합니다.
이 기준을 판단하는 정보를 "Read View"라고 부릅니다. 자신의 트랜잭션에서 직접 수행한 변경은 이와 별도로 볼 수 있습니다.
그 읽기 기준은 트랜잭션이 시작할 때 만들어질까요?
문서에서 “첫 일관 읽기”라는 표현을 보고, START TRANSACTION을 실행한 시점과 첫 SELECT를 실행한 시점도 구분해야 하는지 궁금해졌습니다.
이번에는 A에서 START TRANSACTION만 실행하고 조회는 미뤘습니다.
B가 재고를 4로 변경하고 커밋한 뒤 A에서 처음 일반 SELECT를 실행했더니, 첫 결과부터 4가 나왔습니다.
일반적인 START TRANSACTION에서는 첫 일관 읽기 때 Read View가 만들어지기 때문입니다.
이 순서에서는 B의 커밋 이후에 읽기 기준이 정해집니다. A의 이후 일반 SELECT도 그 기준을 유지합니다.
참고 - 시작할 때 읽기 기준을 정하는 옵션도 있습니다.
RR에서는 START TRANSACTION WITH CONSISTENT SNAPSHOT;으로 첫 SELECT를 기다리지 않고 트랜잭션 시작 시점에 읽기 기준을 만들 수 있습니다. 앞의 실험에서는 이 옵션 없이 START TRANSACTION을 사용했으므로,
첫 조회를 언제 했는지가 중요했습니다. MySQL의 START TRANSACTION 문서에서 옵션 설명을 확인할 수 있습니다.
이제 일반 SELECT가 5를 계속 보여주는 이유는 이해할 수 있었습니다.
그렇다면 FOR UPDATE는 같은 기준을 사용하지 않는 걸까요? 이번에는 잠금 읽기 설명을 찾아봤습니다.
FOR UPDATE는 현재 상태를 읽으며 잠근다
일반 SELECT의 읽기 기준을 확인한 뒤, 공식 문서의 Locking Reads 항목도 찾아봤습니다. 잠금을 잡는다는 설명뿐 아니라, 어떤 데이터를 읽는지도 함께 설명돼 있었습니다.

기존 Read View에 맞춰 읽는 일반 SELECT와 달리, 현재 상태를 읽으면서 변경을 위한 잠금을 잡는 방식입니다.
흔히 현재 읽기(current read)라고 부릅니다.
앞선 실험에서는 B가 이미 커밋했으므로, A는 재고 4를 읽고 해당 레코드의 배타적 잠금을 확보합니다.
이 잠금이 유지되는 동안 다른 트랜잭션은 같은 레코드를 변경하려면 기다려야 합니다.
“현재 읽기”가 다른 트랜잭션의 미커밋 값을 곧바로 읽는다는 뜻은 아닙니다. 같은 레코드에 충돌하는 잠금이 있다면 기본적인 FOR UPDATE는 그 트랜잭션이 끝날 때까지 기다립니다.
또한 FOR UPDATE가 기존 Read View를 갱신하는 것은 아닙니다. A가 직접 데이터를 변경하지 않았기 때문에, 일반 SELECT로 돌아가면 기존 기준에 따라 다시 5가 보입니다.
만약 A가 직접 재고를 3으로 변경했다면 이후 일반 SELECT에서도 자신의 변경인 3이 보입니다.
자신의 변경이 보인다는 설명은 MySQL 일관 읽기 문서에서 확인할 수 있습니다.
일반 SELECT와 FOR UPDATE가 서로 다른 기준으로 읽는다는 설명으로, 5와 4가 함께 보였던 이유는 이해할 수 있었습니다. 그런데 읽기 기준을 유지하는 것만으로 이전 값이 다시 나타나는 걸까요? 현재 레코드는 이미 4로 바뀌어 있습니다.
이미 4로 바뀐 레코드에서 5를 읽는 방법
처음에는 스냅샷이라는 표현 때문에 A가 조회한 재고 5를 어딘가에 복사해 놓고 계속 읽는 모습을 떠올렸습니다. 하지만 앞에서 확인한 Read View는 어떤 변경을 보여줄 수 있는지 판단하는 정보였습니다.
그렇다면 A에게 필요한 이전 값인 5는 어디에 남아 있을까요? 읽기 기준과 실제 이전 값을 만드는 과정은 따로 살펴볼 필요가 있었습니다.
이전 값이 어디에 남아 있는지 알아보려고 InnoDB 다중 버전 관리 문서를 읽어 봤습니다. 문서에서는 변경 전 정보를 롤백뿐 아니라, 일관 읽기에 필요한 이전 버전을 구성할 때도 사용한다고 설명합니다. 이 정보가 남는 곳이 Undo 로그였습니다.

제가 따로 생각하던 두 역할이 여기서 연결됐습니다. Read View로 어떤 버전을 보여줄지 판단하고, 필요한 이전 값은 Undo 정보로 복원하는 흐름이었습니다.
그런데 이 설명만으로는 한 가지가 더 궁금했습니다. InnoDB는 현재 레코드를 보고 누가 변경했는지 어떻게 알까요? 또 그 레코드에 필요한 Undo 기록은 어떻게 찾을까요?
저는 레코드에 id와 stock 같은 데이터만 들어 있다고 생각했습니다.
공식 문서의 레코드 내부 정보를 살펴보니, 변경한 트랜잭션과 Undo 기록을 찾기 위한 필드도 함께 관리하고 있었습니다.
아래 그림은 그 연결을 이해하려고 정리한 개념도입니다.

그림 위쪽은 행 데이터가 저장되는 클러스터드 인덱스의 리프 레코드를 단순화한 모습입니다. 이번 예제에서는 다음 두 필드의 역할을 보면 됩니다.
- DB_TRX_ID
레코드를 마지막으로 삽입하거나 변경한 트랜잭션의 식별자입니다. InnoDB가 Read View의 기준과 대조해 이 버전을 보여줄 수 있는지 판단할 때 사용합니다. - DB_ROLL_PTR
Undo 기록을 가리키는 포인터입니다. 이전 버전을 복원하는 데 필요한 정보를 찾을 때 사용합니다.
이 두 필드를 알고 나니 A가 다시 5를 읽는 과정도 따라갈 수 있었습니다.
트랜잭션 A에서 InnoDB는 현재 레코드의 DB_TRX_ID를 확인하고, B가 변경한 4를 A의 Read View에서는 보여줄 수 없다고 판단합니다. 그러면 DB_ROLL_PTR를 따라 Undo 기록을 찾고, 레코드의 메모리 복사본에 변경 전 정보를 적용해 5였던 버전을 복원합니다.
여기서 “복원”이라는 말을 보고 실제 재고도 5로 돌아가는 건 아닌지 헷갈렸습니다. 하지만 이 과정은 B의 변경을 롤백하는 작업이 아닙니다. 현재 레코드의 4는 유지하고, A의 조회에 필요한 이전 버전만 구성합니다.
그렇다면 변경이 여러 번 있었다면 어떨까요? A의 첫 조회 이후 다른 트랜잭션들이 재고를 5에서 4로, 다시 3으로 변경하고 각각 커밋했다고 해보겠습니다. 바로 이전 값인 4도 A의 읽기 기준 이후에 만들어진 버전이므로, 그것만 복원해서는 충분하지 않습니다. InnoDB는 복원한 버전도 확인하고, A가 볼 수 있는 5까지 거슬러 올라갑니다.
이 과정을 정리하고 나니, 실제 재고는 이미 4인데 A에게는 여전히 5가 보였던 이유를 이해할 수 있었습니다. 조회 결과를 따로 저장해 둔 것이 아니라, Read View로 볼 수 있는 버전을 판단하고, 필요한 이전 버전을 Undo로 구성하는 방식이었습니다. 현재 버전이 그 기준에 맞는다면 이전 버전을 복원할 필요는 없습니다.
이처럼 데이터의 여러 버전을 활용해 동시성을 제어하는 방식을 MVCC(Multi-Version Concurrency Control)라고 부릅니다.
다시 재고 차감 흐름을 보면
현재 재고가 4인데도 A에게 5가 보이는 이유는 이해했습니다. 이제 처음 생각했던 재고 차감 흐름을 다시 봤습니다.
일반 SELECT로 읽은 5를 기준으로 주문 가능 여부를 검사하고 차감해도 괜찮을까요?
앞선 실험에서 5는 A의 읽기 기준에 맞는 이전 버전이었습니다. 따라서 일반 SELECT로 읽은 수량이 차감할 시점에도 그대로 유지된다고 생각해서는 안 됐습니다.
처음 떠올린 조회 → 검사 → 차감 흐름에 FOR UPDATE를 사용한다면, 잠금을 확보한 조회에서 반환된 수량으로 검사하고 차감해야 합니다. 앞서 일반 SELECT로 읽은 5를 계속 사용하면, 잠금을 얻었더라도 오래된 값으로 계산하게 됩니다.
이번에는 조회 방식만 비교했던 앞의 실험과 달리, 재고 5에서 두 주문이 각각 1개씩 차감하는 상황을 생각해 봤습니다.
두 주문 모두 FOR UPDATE로 조회하면 다음과 같은 흐름이 됩니다.
| 주문 A | 주문 B |
|---|---|
| FOR UPDATE → 재고 5, 잠금 획득 | FOR UPDATE 시도 → 대기 |
| 재고 확인 후 1개 차감: 5 → 4 | 대기 |
| COMMIT → 잠금 해제 | 잠금 획득 → 재고 4 확인 |
| 종료 | 재고 확인 후 1개 차감: 4 → 3, COMMIT |
조회 → 검사 → 차감은 같은 트랜잭션 안에서 이어져야 합니다. 조회 직후 커밋하면 차감 전에 잠금이 풀립니다.
재고가 충분한지 검사하고 차감하는 작업을 조건부 UPDATE 한 문장으로 처리할 수도 있습니다.
이 경우에는 앞서 일반 SELECT로 읽은 값을 가지고 계산하는 대신, DB가 변경 대상의 현재 재고를 기준으로 조건을 확인하고 차감합니다.
이 방식에서도 UPDATE는 잠금을 사용합니다.
다시 실험 결과로 돌아가 보면
A에게는 계속 5가 보였지만, 실제 재고는 이미 4였습니다. 처음에는 이 두 결과가 모순처럼 느껴졌습니다. 하지만 A가 읽고 있던 것은 현재 재고가 아니라, 자신의 읽기 기준에 맞는 이전 버전이었습니다.
FOR UPDATE도 일반 SELECT에 잠금만 덧붙이는 쿼리로 생각했는데, 읽는 버전부터 달랐습니다. 같은 행을 조회하더라도 어떤 방식으로 읽느냐에 따라 다른 값이 나올 수 있다는 점이, 처음의 5 → 4 → 5를 이해하는 데 필요했습니다.
긴글 읽어주셔서 감사합니다.