티스토리 뷰
`@Transactional`로 묶었다고 동시성이 해결되는 건 아니다. 그건 원자성(A) 얘기지 격리성(I) 얘기가 아니다. 재고 5개짜리 상품에 10명이 동시에 주문을 넣는 상황을 따라가며 비관적 락, 낙관적 락, 그리고 "사실 읽을 필요도 없는" 원자적 UPDATE까지 저울질한 기록.
시작은 단순한 착각이었다
주문 로직을 짜면서 처음엔 이렇게 생각했다.
"재고 차감이랑 주문 생성을 @Transacional 로 묶었으니, 동시에 주문이 들어와도 알아서 잘 처리하겠지
트랜잭션이 ACID 를 보장하니깐 동시성도 트랜잭션이 잘 막아줄 것이라고 막연히 믿었다.
혼자 주문할 땐 이 믿음이 안깨진다.
읽고 -> 확인하고 -> 줄이고 -> 커밋 읽기와 쓰기가 딱 붙어 있어서 문제가 생길 틈이 안보인다. 검증할 상황 자체가 안오니깐.
그런데 "10명이 동시에"를 그려보려고 손님을 한명 더 붙여봤다.
손님 A 가 재고를 읽고 아직 줄이기 전인 그 찰나에, 손님 B 도 같은 재고를 읽는다. 둘 다 "재고 있음"을 확인하고 통과한다.
그 순간 알았다. @Transactional 이 지켜주는 건 내 트랜잭션 안이지, 남이 끼어드는 것이 아니었다. 혼자일 땐 붙어 있는 것처럼 보이던 읽기와 쓰기 사이의 틈이, 두 명을 겹쳐 놓으니 그제야 드러났다.
이 글은 그 착각이 깨지고, 대안을 하나씩 저울질해간 과정을 따라간 기록이다.
트랜잭션이 보장하는 것과 보장하지 않는 것
먼저 주문 하나를 쪼개봤다. 한 줄처럼 보이는 주문도 사실 여러 작업이다.
BEGIN;
SELECT stock FROM product WHERE id = 1; -- (1) 재고 읽기
-- 애플리케이션에서 if (stock > 0) 체크 -- (2) 확인
UPDATE product SET stock = stock - 1 ...; -- (3) 차감
INSERT INTO orders ...; -- (4) 주문 생성
COMMIT;
트랜잭션은 이 1~4번을 하나로 묶어, 중간에 실패하면 통째로 되돌린다.
3번까지 했는데 4번에서 서버가 죽어도, 재고만 깍이고 주문은 없는 유령 상태가 안생긴다.
여기 까지는 트랜잭션이 확실하게 해준다.
ACID 를 다시 보면 이렇다.
- A (원자성): 다 되거나 다 안되거나
- C (일관성): 트랜잭션 전후로 DB 규칙이 안깨짐
- I (격리성): 동시에 도는 트랜잭션들이 서로 간섭하지 않은 것처럼 보이게 함
- D (지속성): 커밋된 건 서버가 죽어도 살아남음
내가 기대한 "동시 주문이 알아서 처리되는 것"은 원자성이 아니라 격리성의 영역이였다.
격리성은 트랜잭션을 그냥 묶는다고 강하게 보장되는 게 아니다. 이게 첫번째 깨달음이었다.
실제로 뭐가 터지는지 그려봤다
재고 5개, 동시 주문 10명.
각자 위의 1~4번을 자기 트랜잭션으로 돌리다고 하고, 두명만 떼어내 타임라인을 그려봤다.

문제는 t2와 t4이다. A와 B 둘다 "남이 차감하기 전의 재고 5"를 읽었다.
둘다 5-1=4 를 계산했고, B 의 UPDATE 가 A 의 결과를 덮어썼다.
A 가 깎은 1개가 증발한 것이다.
10명으로 늘리면, 운 나쁘면 10명 전무 stock = 5 를 읽고 전부 통과해서 재고 5개짜리를 10명에게 팔아버린다.
Lost Update (갱신 손실) — "읽고 → 계산하고 → 쓰는(read-modify-write)" 패턴에서, 읽은 값이 쓰는 시점엔 이미 낡아버려 한쪽의
갱신이 통째로 사라지는 현상.
여기서 처음 착각이 완전히 깨졌다. 위 두 트랜잭션은 각각 멀쩡히 @Transactional로 묶여 있었다. 원자성도 지속성도 완벽했다. 그런데도 깨진 건 격리성이 약해서 서로의 중간 작업을 못 막았기 때문이다. 트랜잭션은 죄가 없다. 동시성은 별개의 문제다.
격리 수준을 올리면 되지 않을까 (두 번째 착각)
"격리 수준이라는 게 있다던데, 그걸 높이면 막히는 거 아니야?"
격리 수준은 트랜잭션들이 서로 얼마나 격리되는지 정한 표이다.
| 격리수준 | 막아주는 것 | 샐 수 있는 이상현상 |
| READ UNCOMMITTED | (거의 없음) | Dirty Read까지 다 샘 |
| READ COMMITTED | Dirty Read | Non-repeatable Read, Phantom |
| REPEATABLE READ | + Non-repeatable Read | Phantom (표준상) |
| SERIALIZABLE | 전부 | 전부 |
표만 보면 "격리 수준을 더 높은 단계(REPEATABLE READ나 SERIALIZABLE)로 올리면 lost update도 막히겠네"가 합리적인 추론이다. 그런데 막상 들여다보니 아니었다. 그 이유를 매장 상황으로 바꿔보면 분명해진다.
스투시 매장에 재고 현황판이 하나 걸려 있고, 직원 A와 B가 각자 손님을 받는다고 하자.
주문을 처리하는 직원이 하는 일은 두 가지다. 현황판을 본다(재고 확인), 그리고 새 숫자를 적는다(재고 차감).
거의 동시에 손님을 받으면 이렇게 흘러간다.

여기서 핵심은, A도 B도 잘못 본 게 아니라는 점이다. 둘 다 봤을 때 진짜로 5였다. 읽기는 완벽하게 정확했다.
문제는 적기(쓰기)가 서로 덮어쓴 데 있다. B가 적을 때 A가 이미 적어둔 4를 그냥 지워버린 것이다.
그런데 격리 수준이라는 다이얼이 조절하는 건 오직 "본다(읽기)"뿐이다.
표에 있는 이상현상 세 개의 이름을 보면 전부 Dirty Read, Non-repeatable Read, Phantom Read — 죄다 "읽기"다.
즉 격리 수준은 "현황판을 볼 때 뭐가 보이게 할 거냐"를 정하는 규칙이지, "적기끼리 부딪히는 것"과는 다른 영역이다. 문제는 쓰기에 있는데 다이얼은 읽기만 만지니, 아무리 돌려도 안 풀린다.
그럼 "REPEATABLE READ면 내가 본 값을 지켜준다던데?"라는 의문이 남는다.
실제로 MySQL InnoDB의 기본값이 REPEATABLE READ인데도 위 상황은 그대로 일어난다.
함정은 이 "지켜준다"의 뜻에 있다.
REPEATABLE READ가 약속하는 건 "A 눈에 보이는 화면을 끝까지 5로 유지해준다"이지, "현황판의 진짜 내용을 지켜준다"가 아니다.
B가 현황판을 진짜로 4로 바꿔놔도, A 눈에는 친절하게 계속 5를 보여준다. A를 "5라고 적힌 옛날 화면"에 가둬두는 셈이다.
그래서 오히려 A는 "내 화면은 5니까 5겠지" 하고 4를 적어버린다. 읽기 일관성을 지켜주는 것과 쓰기 충돌을 막아주는 것은 전혀 다른 얘기다.
SERIALIZABLE까지 올리면 막히긴 한다.
이 수준에선 직원이 현황판을 보는 순간부터 아예 판을 잡아버려서, 다른 직원이 동시에 보지 못하게 하기 때문이다.
하지만 이건 매장의 모든 조회를 다 줄 세우는 것과 같다. 동시성이 바닥으로 떨어지고 데드락도 잦아진다. 주문 한 줄 막자고 시스템 전체를 직렬화하는 건 과하다.
그래서 두 번째 결론에 도달했다.
격리 수준을 올리는 건 lost update의 정답이 아니다. 격리 수준은 "읽기의 일관성"을 다루는 다이얼이고, 내 문제는 "특정 쓰기가 덮어써지는 것"이다. 연장이 애초에 안 맞았다. 내가 풀어야 할 건 "이 한 행을 갱신하는 동안 끼어들지 못하게 하는 것"이고, 그건 그 행을 콕 집어 제어하는 락 전략의 영역이다.
락 전략 1 — 비관적 락: 일단 잠그고 본다
비관적 락의 전제는 이렇다. "충돌은 자주 난다고 비관적으로 가정한다. 그러니 건드리기 전에 미리 잠가 남이 못 들어오게 한다."
읽을 때부터 락을 거는 방식이다. 평범한 SELECT가 아니라 SELECT ... FOR UPDATE를 쓴다.
BEGIN;
SELECT stock FROM product WHERE id = 1 FOR UPDATE; -- 이 행에 배타 락
-- if (stock > 0)
UPDATE product SET stock = stock - 1 WHERE id = 1;
INSERT INTO orders ...;
COMMIT; -- 여기서 락 해제
FOR UPDATE가 붙으면 그 행에 배타 락이 걸리고, 락이 걸린 동안 다른 트랜잭션이 같은 행을 FOR UPDATE로 읽으려 하면 앞 트랜잭션이 COMMIT할 때까지 멈춰서 기다린다. 타임라인이 이렇게 바뀐다.

핵심은 t4다. B는 A가 끝날 때까지 아예 못 읽는다.
그래서 B가 읽는 시점엔 이미 A가 깎은 최신값(4)을 보고, lost update가 원천 차단된다. 줄을 세운 것이다.
다만 대가가 있다. 한 행에 주문이 몰리면 모두 한 줄로 서서 처리량이 떨어진다.
여러 행을 서로 다른 순서로 잠그면 데드락이 생길 수 있어 "락은 항상 같은 순서로 잡는다"는 규칙이 따라온다.
그리고 락 보유 시간은 곧 트랜잭션 길이라서, FOR UPDATE를 잡은 채 느린 작업(외부 API 호출 등)을 하면 줄 선 사람 전부가 그만큼 기다린다. 트랜잭션은 짧게 가져가야 한다.
락 전략 2 — 낙관적 락: 일단 진행하고, 충돌나면 그때 처리
낙관적 락의 전제는 정반대다.
"충돌은 드물다고 낙관적으로 가정한다. 미리 잠그지 말고 진행하되, 마지막에 '내가 읽은 뒤 누가 건드렸나'만 확인한다."
여기서 짚고 넘어갈 게 있다. 낙관적 락은 사실 DB의 락이 아니다.
진짜 락을 거는 게 아니라 version 컬럼으로 충돌을 감지하는 애플리케이션 레벨 기법이다.
이름에 '락'이 붙어 헷갈리기 쉬운 부분이다.
BEGIN;
SELECT stock, version FROM product WHERE id = 1; -- 락 없이 읽음. stock=5, version=1
-- if (stock > 0)
UPDATE product
SET stock = stock - 1, version = version + 1
WHERE id = 1 AND version = 1; -- 내가 읽었던 version 조건
COMMIT;
핵심은 WHERE ... AND version = 1과, 이 UPDATE가 실제로 몇 행을 바꿨는지(affected rows) 확인하는 데 있다.
1이면 그 사이 아무도 안 건드린 것이다. => 성공.
0이면 누군가 먼저 version을 올린 것이다. 내가 읽은 값은 이미 낡았다. => 충돌, 실패.

비관적 락과 결정적으로 다른 점은 B를 막지 않았다는 것이다.
B는 자유롭게 진행했고, 마지막 UPDATE 순간에 "내가 읽은 게 낡았다"를 스스로 발견했다.
그래서 B는 이제 다시 읽고 다시 시도하거나, 사용자에게 "다시 시도해달라"고 알려야 한다.
이 충돌 후 재시도 로직이 낙관적 락의 핵심이자 부담이다.
// 충돌 시 최대 3번까지 재시도
async function placeOrder(productId: number) {
for (let attempt = 0; attempt < 3; attempt++) {
const { stock, version } = await readProduct(productId);
if (stock <= 0) throw new Error("품절");
const affected = await db.query(
`UPDATE product SET stock = stock - 1, version = version + 1
WHERE id = ? AND version = ?`,
[productId, version]
);
if (affected.rowCount === 1) return; // 성공
await sleep(randomBackoff()); // 충돌 → 잠깐 쉬고 재시도
}
throw new Error("주문 실패, 잠시 후 다시 시도해주세요");
}
락을 안 잡으니 대기가 없고, 충돌이 드문 상황에선 비관적 락보다 처리량이 높다. 데드락 걱정도 없다.
하지만 충돌이 잦으면 이야기가 달라진다.
다들 같은 version을 읽고 한 명만 성공, 나머지는 전부 실패 후 재시도, 재시도해도 또 충돌... 이런 재시도 폭풍이 일면 오히려 비관적 락보다 느려질 수 있다.
둘 중 뭘 고를까
| 비관적 락 | 낙관적 락 | |
| 가정 | 충돌이 자주 난다 | 충돌이 드물다 |
| 방식 | 미리 잠굼 (for update) | 나중에 감지 (version) |
| 충돌시 | 애초에 막아서 대기 | 실패 후 재시도 |
| 진짜 DB 락인가 | o (배타 락) | x (앱 레벨 기법) |
| 유리한 상황 | 총돌 잦음 / 짧은 트랜잭션 / 꼭 성공해야 함 | 충돌 드묾 / 읽기 위주 / 대기 비용큼 |
| 주의점 | 대기, 데드락, 처리량 저하 | 재시도 폭풍, 재시도 로직 부담 |
판단 기준을 두 개로 좁히면 이렇다.
- 이 상품에 동시 주문이 진짜 몰리는가? 몰리면 비관적 락(낙관적으로 가면 재시도 지옥). 아니면 낙관적 락.
- 충돌했을 때 "다시 시도하세요"가 자연스러운가? 게시글 동시 수정 같은 건 자연스럽다(낙관적에 적합). 결제나 재고처럼 조용히 정확하게 처리돼야 하는 건 대기를 감수하더라도 비관적이 안전한 경우가 많다.
그런데 한정판이라면

여기서 한 가지 의문이 들었다. "스투시 한정판처럼 수천 명이 5개 재고에 몰리면, 비관적 락은 이들을 한 줄로 세워버리는데 — 뒤쪽 사람은 끝없이 기다리는 거 아닌가?"
맞다. 그리고 사용자 경험 관점에서 최악은 "실패"가 아니라 "언제 끝날지 모르는 대기"다.
이런 폭발적 트래픽에서는 DB 락에 도달하기 전에 경쟁 자체를 줄이는 접근(인메모리 저장소에서 재고를 먼저 차감해 걸러내거나, 요청을 큐에 넣어 순서대로 처리하는 식)을 쓴다.
핵심은 "될 사람은 빨리 되게, 안 될 사람은 빨리 알게"다. 다만 이는 트랜잭션·락의 범위를 넘는 주제라 여기서는 방향만 짚고 넘어간다.
마지막 의문 — 재고 차감은 꼭 읽어야 할까
비관적/낙관적을 비교하다 보니 더 근본적인 의문이 들었다.
"그런데 재고 차감은 애초에 읽을 필요가 있나?"
Lost update의 원인은 "읽고 → 계산하고 → 쓰는" 패턴이라고 했다.
읽기와 쓰기 사이에 틈이 생기고, 그 틈에 남이 끼어드는 게 문제였다.
그렇다면 그 틈 자체를 없애면 된다.
재고 차감은 읽지 않고 DB가 원자적으로 계산하게 시킬 수 있다.
UPDATE product
SET stock = stock - 1
WHERE id = 1 AND stock >= 1; -- 재고가 1 이상일 때만 차감
UPDATE 한 방으로 끝난다.
WHERE stock >= 1 덕분에 재고가 0이면 0행이 바뀌고(품절), UPDATE는 그 행에 자동으로 락을 잡고 원자적으로 실행되니 lost update가 구조적으로 불가능하다.
읽고-쓰기 사이의 틈이 없으니 끼어들 자리도 없다. affected rows가 0이면 품절로 처리하면 된다.
단순 카운터형 재고에는 이게 가장 깔끔하다.
다만 차감 전에 복잡한 비즈니스 검증이 필요하거나, 재고 외 다른 필드도 함께 조건 검사해야 한다면 락 전략으로 돌아가야 한다.
그래서 마지막 판단은 이 질문으로 귀결된다. 내 상황은 단순 카운터인가, 복잡한 검증이 필요한가.
정리하며
이번 공부에서 가장 크게 바뀐 생각은 "트랜잭션으로 묶으면 동시성이 해결된다"는 믿음이 깨진 것이다.
트랜잭션은 한 작업의 원자성·지속성을 보장하지만, 여러 작업이 동시에 부딪힐 때의 조율(격리성)은 격리 수준과 락이라는 별도의 도구로 풀어야 했다.
그리고 락을 비교하다가 "애초에 읽지 않으면 충돌도 없다"는 데까지 와보니, 결국 정답은 하나로 고정되어 있지 않았다.
충돌 빈도, 트랜잭션 길이, 검증 복잡도, 트래픽 규모에 따라 비관적 락이 맞을 때도, 낙관적 락이 맞을 때도, 락 없이 원자적 UPDATE 한 줄로 끝낼 때도 있다.
이 글을 쓰는 지금도 "내 케이스엔 뭐가 맞나"는 결국 상황을 보고 판단할 문제라고 생각한다.
'Coding > Dev Study' 카테고리의 다른 글
| [LOOp:pak vol.4] 이 규칙은 도메인일까, 유스케이스일까 — Service / Facade 의 진짜 분기 기준 (0) | 2026.05.29 |
|---|---|
| [LOOp:pak vol.4] 좋아요, 그 단순해 보이는 기능에서 결정해야 했던 것들 (0) | 2026.05.22 |
| [LOOp:pak vol.4] 회원 도메인을 TDD로 만들며 — 1주차 회고 (0) | 2026.05.15 |
| [항해 복귀 스터디] 2주차 설계 + 아키텍처 패턴 (0) | 2026.02.02 |
| [항해 복귀 스터디] 1주차 TDD (0) | 2026.02.02 |
- Total
- Today
- Yesterday
- 코테 준비
- 백엔드 개발자 기술 면접 준비
- 취준
- 코딩테스트
- 취업준비
- 프로그래머스 카카오
- 프로그래머스 자바
- 개발자 면접 준비
- 백엔드 개발자
- 알고리즘공부
- 코딩테스트 준비
- 백엔드 개발자 취업 준비
- 코딩테스트 공부
- java
- 알고리즘 공부
- 코딩테스트공부
- 개발자 취준
- 자바
- 프로그래머스
- 백준
- 주니어 개발자 취업 준비
- 코테준비
- 개발자 취업 준비
- 제로베이스 백준 장학금
- 제로베이스 백엔드 스쿨
- 코테공부
- 알고리즘
- 기술 면접 준비
- 자바공부
- 취업 준비
| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 |
