Theme:

여러 사용자가 같은 데이터를 동시에 수정하려 할 때, 충돌을 막는 가장 좋은 방법은 무엇일까요?

두 가지 동시성 제어 전략

동시에 같은 데이터를 수정하려는 상황을 처리하는 방법은 크게 두 가지입니다.

  • 비관적 락 (Pessimistic Lock): "충돌이 일어날 것이다"라고 가정하고, 데이터에 접근할 때 미리 락을 겁니다
  • 낙관적 락 (Optimistic Lock): "충돌이 드물 것이다"라고 가정하고, 수정할 때 충돌을 감지합니다

어떤 것이 더 좋다고 단정할 수 없습니다. 상황에 따라 적합한 전략이 다릅니다.

비관적 락 — SELECT FOR UPDATE

기본 동작

SQL
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

SQL
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR SHARE;
-- 공유 락(S Lock) 획득
-- 다른 트랜잭션의 FOR SHARE는 허용, FOR UPDATE/쓰기는 차단

FOR SHARE는 **공유 락(S Lock)**을 겁니다. 여러 트랜잭션이 동시에 공유 락을 획득할 수 있지만, 쓰기는 차단됩니다.

구분FOR UPDATEFOR SHARE
락 종류X LockS Lock
다른 FOR SHARE차단허용
다른 FOR UPDATE차단차단
일반 SELECT허용(MVCC)허용(MVCC)

NOWAIT과 SKIP LOCKED (MySQL 8.0+)

락을 기다리지 않고 즉시 결과를 얻을 수 있는 옵션입니다.

SQL
-- 락을 획득할 수 없으면 즉시 에러 반환
SELECT * FROM orders WHERE status = 'pending'
FOR UPDATE NOWAIT;

-- 이미 잠긴 행은 건너뛰고 나머지만 반환
SELECT * FROM orders WHERE status = 'pending'
FOR UPDATE SKIP LOCKED;

SKIP LOCKED는 큐 형태의 작업 분배에 유용합니다. 여러 워커가 동시에 미처리 건을 가져갈 때 충돌 없이 분배할 수 있습니다.

SQL
-- 워커 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건을 가져옴

비관적 락의 주의점

  1. 반드시 인덱스를 사용해야 합니다: 인덱스가 없으면 풀 테이블 스캔이 발생하고, 의도하지 않은 행까지 잠길 수 있습니다
  2. 트랜잭션을 짧게 유지해야 합니다: 락 보유 시간이 길수록 다른 트랜잭션의 대기 시간이 증가합니다
  3. 데드락 가능성: 여러 행을 순서 없이 잠그면 데드락이 발생할 수 있습니다

낙관적 락 — 버전 관리

기본 원리

낙관적 락은 데이터베이스 락을 사용하지 않습니다. 대신 version 컬럼을 사용하여 수정 시점에 충돌을 감지합니다.

SQL
-- 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이면 다른 트랜잭션이 먼저 수정한 것 (충돌!)

테이블 설계

SQL
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이 더 안전합니다.

충돌 감지와 재시도

JAVA
// 자바 의사 코드
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

JAVA
@Entity
public class Account {
    @Id
    private Long id;
    private int balance;

    @Version  // JPA가 자동으로 버전 관리
    private int version;
}

@Version을 붙이면 JPA가 자동으로 다음을 처리합니다.

  • UPDATE 시 WHERE version = ? 조건 추가
  • UPDATE 성공 시 version 자동 증가
  • 불일치 시 OptimisticLockException 발생
JAVA
// JPA가 생성하는 SQL
UPDATE accounts
SET balance = ?, version = 4
WHERE id = ? AND version = 3;

비관적 락 vs 낙관적 락 비교

구분비관적 락낙관적 락
락 방식DB 레벨 실제 락애플리케이션 레벨 버전 체크
충돌 처리대기 (블로킹)실패 후 재시도
동시성낮음 (락으로 직렬화)높음 (락 없음)
데드락 위험있음없음
적합한 상황충돌이 빈번한 경우충돌이 드문 경우
구현 복잡도SQL만으로 가능재시도 로직 필요

선택 기준

비관적 락을 선택하는 경우:

  • 같은 데이터에 대한 동시 수정이 빈번합니다
  • 충돌 시 재시도 비용이 높습니다 (외부 시스템 연동 등)
  • 반드시 한 번에 성공해야 하는 작업입니다

낙관적 락을 선택하는 경우:

  • 읽기가 대부분이고 수정은 드문 경우입니다
  • 충돌 시 재시도가 가능한 작업입니다
  • 높은 동시성이 필요합니다

실무 패턴 — 재고 차감 예제

비관적 락 방식

SQL
BEGIN;
-- 재고 행을 잠금
SELECT stock FROM products WHERE id = 100 FOR UPDATE;
-- stock = 5

-- 재고 확인 후 차감
UPDATE products SET stock = stock - 1 WHERE id = 100;
COMMIT;

장점은 확실하게 동시 수정을 방지한다는 것이고, 단점은 인기 상품에 요청이 몰리면 대기가 길어진다는 것입니다.

낙관적 락 방식

SQL
-- 조회
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이면 다른 요청이 먼저 처리됨 → 재시도

장점은 동시성이 높다는 것이고, 단점은 인기 상품에 요청이 몰리면 재시도가 폭증한다는 것입니다.

실무 절충안

SQL
-- CAS(Compare-And-Swap) 패턴
UPDATE products
SET stock = stock - 1
WHERE id = 100 AND stock >= 1;
-- version 없이도 stock 자체를 조건으로 사용

이 방식은 version 컬럼 없이도 동작하며, 단순한 재고 차감에는 충분합니다.

JPA에서 비관적 락 사용

JAVA
// 비관적 쓰기 락
@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의 NOWAITSKIP LOCKED는 비관적 락의 대기 문제를 완화하는 좋은 옵션입니다
댓글 로딩 중...