트랜잭션 격리 수준 완벽 가이드: Dirty Read부터 락킹 전략까지
이커머스 이벤트 당일, 한정 수량 100개짜리 상품이 어느새 103개나 팔렸습니다. 로그를 뒤져보면 코드는 분명 "재고가 0보다 클 때만 차감"하도록 짜여 있는데 말이죠. 이런 일이 벌어지는 이유는 단 하나, 동시성(Concurrency) 입니다. 오늘은 옆자리에서 코드 리뷰해 주듯, 트랜잭션 격리 수준을 처음부터 끝까지 같이 뜯어보겠습니다.
데이터가 꼬이는 순간: 왜 트랜잭션 관리가 중요한가
단일 사용자만 접속하는 시스템이라면 트랜잭션을 깊게 고민할 필요가 없습니다. 문제는 항상 "동시에" 발생합니다. 다음 시나리오를 봅시다.
잔액 10,000원인 계좌에서 A와 B가 거의 동시에 7,000원씩 출금을 시도합니다.
[T1] 잔액 조회 → 10,000원 (출금 가능 판단)
[T2] 잔액 조회 → 10,000원 (출금 가능 판단)
[T1] 10,000 - 7,000 = 3,000 저장
[T2] 10,000 - 7,000 = 3,000 저장 ← 덮어쓰기!결과적으로 14,000원이 빠져나갔는데 잔액은 3,000원입니다. 4,000원이 증발한 거죠. 이것이 바로 Lost Update(갱신 손실) 입니다. 이런 비즈니스 로직과 동시성의 충돌을 막는 도구가 바로 트랜잭션과 격리 수준입니다.
트랜잭션의 기본기: ACID 다시 보기
격리 수준을 이야기하기 전에 트랜잭션이 보장하는 4가지 성질부터 정리합시다.
| 속성 | 의미 | 한 줄 요약 |
|---|---|---|
| Atomicity (원자성) | 전부 성공하거나 전부 실패 | "중간은 없다" |
| Consistency (일관성) | 트랜잭션 전후 데이터 무결성 유지 | "규칙은 깨지지 않는다" |
| Isolation (고립성) | 동시 실행 트랜잭션 간 간섭 차단 | "남의 작업은 안 보인다" |
| Durability (영속성) | 커밋된 결과는 영구 보존 | "한 번 저장하면 끝" |
오늘의 주제인 격리 수준은 바로 세 번째 Isolation을 어느 정도까지 보장할지를 조절하는 다이얼입니다. 고립성을 강하게 걸수록 데이터는 안전하지만 성능(동시 처리량)은 떨어집니다. 이 트레이드오프를 이해하는 것이 핵심입니다.
격리 수준 4단계 완벽 해부
SQL 표준은 네 가지 격리 수준을 정의합니다. 낮은 단계부터 보겠습니다. 각 단계에서 막아주는 이상 현상(Anomaly)이 다릅니다.
- Dirty Read: 다른 트랜잭션이 아직 커밋하지 않은 데이터를 읽는 것
- Non-Repeatable Read: 같은 행을 두 번 읽었는데 값이 달라지는 것
- Phantom Read: 같은 조건으로 조회했는데 행의 개수가 달라지는 것
격리 수준별 동작 비교 테이블
| 격리 수준 | Dirty Read | Non-Repeatable Read | Phantom Read |
|---|---|---|---|
| READ UNCOMMITTED | 발생 | 발생 | 발생 |
| READ COMMITTED | 방지 | 발생 | 발생 |
| REPEATABLE READ | 방지 | 방지 | 발생* |
| SERIALIZABLE | 방지 | 방지 | 방지 |
*MySQL InnoDB는 REPEATABLE READ에서도 갭 락(Gap Lock)으로 Phantom Read를 대부분 막습니다. DB마다 동작이 다르니 반드시 본인 DB 문서를 확인하세요.
격리 수준을 설정하는 SQL은 다음과 같습니다.
-- 세션 단위로 격리 수준 설정
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
-- 다음 트랜잭션 1회에만 적용
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
SELECT balance FROM accounts WHERE id = 1;
-- ... 비즈니스 로직 ...
COMMIT;실무 팁을 하나 드리면, 대부분의 OLTP 서비스는 PostgreSQL과 Oracle의 기본값인 READ COMMITTED로 충분합니다. MySQL의 기본값은 REPEATABLE READ인데, 이 차이를 모르고 MySQL → PostgreSQL 마이그레이션을 했다가 미묘한 데이터 불일치로 고생하는 팀을 여럿 봤습니다. DB를 바꿀 때는 격리 수준 기본값부터 확인하세요.
코드로 트랜잭션 안전성 확보하기: 락킹 전략
격리 수준만으로 Lost Update를 완벽히 막기는 어렵습니다. 그래서 실무에서는 락킹 전략을 함께 씁니다. 재고 차감 로직을 두 방식으로 비교해 봅시다.
비관적 락 (Pessimistic Locking)
"충돌이 자주 일어난다"고 가정하고, 읽는 순간 아예 행을 잠급니다.
function decreaseStockPessimistic(productId, qty):
BEGIN TRANSACTION
# SELECT ... FOR UPDATE 로 행에 락을 건다
stock = SELECT quantity FROM products
WHERE id = productId FOR UPDATE # 다른 트랜잭션은 여기서 대기
if stock < qty:
ROLLBACK
throw OutOfStockError
UPDATE products SET quantity = stock - qty WHERE id = productId
COMMITFOR UPDATE가 핵심입니다. T1이 락을 잡으면 T2는 T1이 커밋할 때까지 기다리므로 Race Condition이 원천 차단됩니다. 단, 대기가 길어지면 처리량이 떨어지고 데드락 위험이 있습니다.
낙관적 락 (Optimistic Locking)
"충돌은 드물다"고 가정하고, 락 대신 버전 컬럼으로 검증합니다.
function decreaseStockOptimistic(productId, qty):
# version 컬럼을 함께 조회
(stock, version) = SELECT quantity, version
FROM products WHERE id = productId
if stock < qty:
throw OutOfStockError
# WHERE 절에 version을 넣어 그 사이 변경 여부 확인
affected = UPDATE products
SET quantity = quantity - qty, version = version + 1
WHERE id = productId AND version = version
if affected == 0: # 다른 트랜잭션이 먼저 바꿨다!
retry or throw ConflictError업데이트된 행이 0이면 그사이 누군가 데이터를 바꿨다는 뜻이니 재시도하거나 예외를 던집니다. JPA에서는 @Version 어노테이션 한 줄로 이 메커니즘을 자동 적용할 수 있습니다.
어느 것을 써야 할까?
| 구분 | 비관적 락 | 낙관적 락 |
|---|---|---|
| 가정 | 충돌 잦음 | 충돌 드묾 |
| 방식 | DB 락(FOR UPDATE) | 버전 비교 + 재시도 |
| 장점 | 확실한 정합성 | 높은 동시성, 데드락 없음 |
| 단점 | 대기·데드락 위험 | 재시도 로직 필요 |
| 적합한 곳 | 인기 상품 재고, 좌석 예약 | 게시글 수정, 일반 엔티티 |
MSA 환경에서는 한계가 있다
지금까지의 이야기는 모두 단일 데이터베이스 안에서의 트랜잭션입니다. 마이크로서비스 환경에서 주문 서비스와 재고 서비스, 결제 서비스가 각자 DB를 가진다면 BEGIN ... COMMIT으로 묶을 수 없습니다. 이때 등장하는 것이 Saga 패턴입니다.
Saga는 각 서비스의 로컬 트랜잭션을 순차로 실행하고, 중간에 실패하면 보상 트랜잭션(Compensating Transaction) 으로 이전 작업을 되돌립니다. 예를 들어 결제가 실패하면 이미 차감한 재고를 다시 복원하는 식이죠. 분산 환경에서는 ACID의 강한 일관성 대신 최종적 일관성(Eventual Consistency) 을 받아들이는 발상의 전환이 필요합니다.
결론: 트랜잭션 설계 체크리스트
마지막으로 실무에 바로 쓸 체크리스트로 정리합니다.
- 우리 DB의 기본 격리 수준을 정확히 알고 있는가? (MySQL=REPEATABLE READ, PostgreSQL/Oracle=READ COMMITTED)
- Lost Update가 치명적인 로직(재고, 잔액, 좌석)에 락 전략을 적용했는가?
- 충돌 빈도를 기준으로 낙관적 vs 비관적 락을 선택했는가?
- 트랜잭션 범위를 최소화해 락 보유 시간을 줄였는가?
- 여러 락을 잡는다면 항상 같은 순서로 잡아 데드락을 예방했는가?
- 서비스 경계를 넘는 작업이라면 Saga 패턴을 검토했는가?
격리 수준은 단순한 옵션 값이 아니라, 정합성과 성능 사이에서 내리는 설계 결정입니다. 무조건 SERIALIZABLE로 올리기보다, 비즈니스가 허용하는 만큼만 격리하고 부족한 부분을 락으로 보완하는 것이 정석입니다.
자주 묻는 질문 (FAQ)
Q. 격리 수준을 무조건 SERIALIZABLE로 두면 안전하지 않나요? A. 안전하지만 비쌉니다. 모든 트랜잭션이 사실상 직렬로 실행되면서 동시 처리량이 급감하고 데드락이 늘어납니다. 대부분의 서비스는 READ COMMITTED 또는 REPEATABLE READ에 락 전략을 더하는 편이 현실적입니다.
Q. 낙관적 락과 비관적 락을 어떻게 선택하나요? A. 충돌 빈도가 기준입니다. 인기 상품 재고나 좌석 예약처럼 같은 행에 동시 요청이 몰리면 비관적 락(FOR UPDATE)이 낫고, 게시글 수정처럼 충돌이 드물면 재시도 비용이 적은 낙관적 락(@Version)이 효율적입니다.
Q. MySQL과 PostgreSQL의 기본 격리 수준이 다른데 마이그레이션 시 주의점은? A. MySQL InnoDB는 REPEATABLE READ, PostgreSQL은 READ COMMITTED가 기본입니다. 같은 코드라도 반복 조회 결과가 달라질 수 있으니, 마이그레이션 시 격리 수준에 의존하는 로직을 점검하고 필요하면 명시적으로 설정하세요.
이 글은 AI 에이전트가 1차 초안을 작성한 뒤, 사람 편집자가 사실관계·출처·톤과 맥락을 검토하여 발행했습니다. 오류나 부정확한 내용이 확인되면 24시간 이내에 정정합니다.
댓글
불러오는 중...