비관적 락 vs 낙관적 락 — SELECT FOR UPDATE와 버전 관리
여러 사용자가 같은 데이터를 동시에 수정하려 할 때, 충돌을 막는 가장 좋은 방법은 무엇일까요?
두 가지 동시성 제어 전략
동시에 같은 데이터를 수정하려는 상황을 처리하는 방법은 크게 두 가지입니다.
- 비관적 락 (Pessimistic Lock): "충돌이 일어날 것이다"라고 가정하고, 데이터에 접근할 때 미리 락을 겁니다
- 낙관적 락 (Optimistic Lock): "충돌이 드물 것이다"라고 가정하고, 수정할 때 충돌을 감지합니다
어떤 것이 더 좋다고 단정할 수 없습니다. 상황에 따라 적합한 전략이 다릅니다.
비관적 락 — SELECT FOR UPDATE
기본 동작
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
-- id = 1에 배타적 락(X Lock) 획득
-- 다른 트랜잭션은 이 행을 수정하거나 FOR UPDATE로 읽을 수 없음
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;
-- 커밋 시 락 해제
FOR UPDATE는 선택한 행에 **배타적 락(X Lock)**을 겁니다. 다른 트랜잭션이 같은 행에 대해 FOR UPDATE, FOR SHARE, UPDATE, DELETE를 실행하면 대기합니다.
SELECT ... FOR SHARE
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR SHARE;
-- 공유 락(S Lock) 획득
-- 다른 트랜잭션의 FOR SHARE는 허용, FOR UPDATE/쓰기는 차단
FOR SHARE는 **공유 락(S Lock)**을 겁니다. 여러 트랜잭션이 동시에 공유 락을 획득할 수 있지만, 쓰기는 차단됩니다.
| 구분 | FOR UPDATE | FOR SHARE |
|---|---|---|
| 락 종류 | X Lock | S Lock |
| 다른 FOR SHARE | 차단 | 허용 |
| 다른 FOR UPDATE | 차단 | 차단 |
| 일반 SELECT | 허용(MVCC) | 허용(MVCC) |
NOWAIT과 SKIP LOCKED (MySQL 8.0+)
락을 기다리지 않고 즉시 결과를 얻을 수 있는 옵션입니다.
-- 락을 획득할 수 없으면 즉시 에러 반환
SELECT * FROM orders WHERE status = 'pending'
FOR UPDATE NOWAIT;
-- 이미 잠긴 행은 건너뛰고 나머지만 반환
SELECT * FROM orders WHERE status = 'pending'
FOR UPDATE SKIP LOCKED;
SKIP LOCKED는 큐 형태의 작업 분배에 유용합니다. 여러 워커가 동시에 미처리 건을 가져갈 때 충돌 없이 분배할 수 있습니다.
-- 워커 A
BEGIN;
SELECT * FROM tasks WHERE status = 'waiting'
ORDER BY created_at LIMIT 5
FOR UPDATE SKIP LOCKED;
-- 잠기지 않은 5건을 가져옴
-- 워커 B (동시에 실행)
SELECT * FROM tasks WHERE status = 'waiting'
ORDER BY created_at LIMIT 5
FOR UPDATE SKIP LOCKED;
-- A가 잠근 행은 건너뛰고, 다음 5건을 가져옴
비관적 락의 주의점
- 반드시 인덱스를 사용해야 합니다: 인덱스가 없으면 풀 테이블 스캔이 발생하고, 의도하지 않은 행까지 잠길 수 있습니다
- 트랜잭션을 짧게 유지해야 합니다: 락 보유 시간이 길수록 다른 트랜잭션의 대기 시간이 증가합니다
- 데드락 가능성: 여러 행을 순서 없이 잠그면 데드락이 발생할 수 있습니다
낙관적 락 — 버전 관리
기본 원리
낙관적 락은 데이터베이스 락을 사용하지 않습니다. 대신 version 컬럼을 사용하여 수정 시점에 충돌을 감지합니다.
-- 1. 데이터 조회 (version도 함께 조회)
SELECT id, balance, version FROM accounts WHERE id = 1;
-- 결과: id=1, balance=1000, version=3
-- 2. 수정 시 version 조건 추가
UPDATE accounts
SET balance = 900, version = version + 1
WHERE id = 1 AND version = 3;
-- affected_rows = 1이면 성공
-- affected_rows = 0이면 다른 트랜잭션이 먼저 수정한 것 (충돌!)
테이블 설계
CREATE TABLE accounts (
id BIGINT PRIMARY KEY,
balance INT NOT NULL,
version INT NOT NULL DEFAULT 0, -- 버전 컬럼
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
version 대신 updated_at 타임스탬프를 사용할 수도 있지만, 밀리초 단위 충돌 가능성이 있으므로 정수형 version이 더 안전합니다.
충돌 감지와 재시도
// 자바 의사 코드
public void transfer(Long fromId, Long toId, int amount) {
int maxRetries = 3;
for (int retry = 0; retry < maxRetries; retry++) {
try {
Account from = accountDao.findById(fromId);
Account to = accountDao.findById(toId);
from.setBalance(from.getBalance() - amount);
to.setBalance(to.getBalance() + amount);
// version 조건이 맞지 않으면 0행 업데이트 → 예외 발생
int updated = accountDao.updateWithVersion(from);
if (updated == 0) {
throw new OptimisticLockException("충돌 발생");
}
accountDao.updateWithVersion(to);
return; // 성공
} catch (OptimisticLockException e) {
if (retry == maxRetries - 1) throw e;
// 잠시 대기 후 재시도
}
}
}
JPA에서의 낙관적 락 — @Version
@Entity
public class Account {
@Id
private Long id;
private int balance;
@Version // JPA가 자동으로 버전 관리
private int version;
}
@Version을 붙이면 JPA가 자동으로 다음을 처리합니다.
- UPDATE 시
WHERE version = ?조건 추가 - UPDATE 성공 시 version 자동 증가
- 불일치 시
OptimisticLockException발생
// JPA가 생성하는 SQL
UPDATE accounts
SET balance = ?, version = 4
WHERE id = ? AND version = 3;
비관적 락 vs 낙관적 락 비교
| 구분 | 비관적 락 | 낙관적 락 |
|---|---|---|
| 락 방식 | DB 레벨 실제 락 | 애플리케이션 레벨 버전 체크 |
| 충돌 처리 | 대기 (블로킹) | 실패 후 재시도 |
| 동시성 | 낮음 (락으로 직렬화) | 높음 (락 없음) |
| 데드락 위험 | 있음 | 없음 |
| 적합한 상황 | 충돌이 빈번한 경우 | 충돌이 드문 경우 |
| 구현 복잡도 | SQL만으로 가능 | 재시도 로직 필요 |
선택 기준
비관적 락을 선택하는 경우:
- 같은 데이터에 대한 동시 수정이 빈번합니다
- 충돌 시 재시도 비용이 높습니다 (외부 시스템 연동 등)
- 반드시 한 번에 성공해야 하는 작업입니다
낙관적 락을 선택하는 경우:
- 읽기가 대부분이고 수정은 드문 경우입니다
- 충돌 시 재시도가 가능한 작업입니다
- 높은 동시성이 필요합니다
실무 패턴 — 재고 차감 예제
비관적 락 방식
BEGIN;
-- 재고 행을 잠금
SELECT stock FROM products WHERE id = 100 FOR UPDATE;
-- stock = 5
-- 재고 확인 후 차감
UPDATE products SET stock = stock - 1 WHERE id = 100;
COMMIT;
장점은 확실하게 동시 수정을 방지한다는 것이고, 단점은 인기 상품에 요청이 몰리면 대기가 길어진다는 것입니다.
낙관적 락 방식
-- 조회
SELECT stock, version FROM products WHERE id = 100;
-- stock = 5, version = 10
-- 차감 시도
UPDATE products
SET stock = stock - 1, version = version + 1
WHERE id = 100 AND version = 10;
-- affected_rows 확인
-- 0이면 다른 요청이 먼저 처리됨 → 재시도
장점은 동시성이 높다는 것이고, 단점은 인기 상품에 요청이 몰리면 재시도가 폭증한다는 것입니다.
실무 절충안
-- CAS(Compare-And-Swap) 패턴
UPDATE products
SET stock = stock - 1
WHERE id = 100 AND stock >= 1;
-- version 없이도 stock 자체를 조건으로 사용
이 방식은 version 컬럼 없이도 동작하며, 단순한 재고 차감에는 충분합니다.
JPA에서 비관적 락 사용
// 비관적 쓰기 락
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT a FROM Account a WHERE a.id = :id")
Account findByIdForUpdate(@Param("id") Long id);
// 비관적 읽기 락
@Lock(LockModeType.PESSIMISTIC_READ)
@Query("SELECT a FROM Account a WHERE a.id = :id")
Account findByIdForShare(@Param("id") Long id);
// 타임아웃 설정
@QueryHints(@QueryHint(name = "javax.persistence.lock.timeout", value = "3000"))
@Lock(LockModeType.PESSIMISTIC_WRITE)
Account findByIdForUpdate(@Param("id") Long id);
정리
- 비관적 락은
SELECT ... FOR UPDATE로 행에 실제 락을 걸어 동시 수정을 차단합니다 - 낙관적 락은 version 컬럼으로 수정 시점에 충돌을 감지하고 재시도합니다
- 충돌이 빈번하면 비관적 락, 드물면 낙관적 락이 효율적입니다
- MySQL 8.0의
NOWAIT과SKIP LOCKED는 비관적 락의 대기 문제를 완화하는 좋은 옵션입니다