DATABASE

데이터베이스 동시성 — 비관적 락과 낙관적 락

algml0703 2026. 6. 26. 14:49
반응형

데이터베이스 동시성 — 비관적 락과 낙관적 락

여러 사용자나 프로세스가 동시에 같은 데이터를 수정하려고 할 때 데이터가 어긋나는 동시성 문제가 발생한다. 이를 막기 위한 대표적인 방어 전략이 비관적 락(Pessimistic Lock)낙관적 락(Optimistic Lock)이다.

문제 — 레이스 컨디션과 로스트 업데이트

통제되지 않은 레이스 컨디션(경쟁 상태)이라는 환경 때문에, 로스트 업데이트(갱신 손실)라는 데이터 유실 사고가 발생한다. 즉 레이스 컨디션이 원인이고, 로스트 업데이트는 그 결과 중 하나이다.

① 레이스 컨디션 (Race Condition, 경쟁 상태)

두 개 이상의 스레드나 프로세스가 공유 자원(메모리, 데이터베이스 등)에 동시에 접근하여 값을 변경하려고 할 때 발생하는 문제다. 운영체제의 스케줄링에 따라 A 스레드와 B 스레드의 실행 순서가 매번 뒤섞이기 때문에, 타이밍에 따라 최종 결과값이 매번 달라질 수 있는 상태를 뜻한다.

ex) 두 사람이 같은 계정으로 로그인해, 한 명은 비밀번호를 1234로, 다른 한 명은 5678로 바꾸는 버튼을 거의 동시에 눌렀을 때, 최종 비밀번호가 무엇이 될지는 시스템의 미세한 타이밍(경쟁)에 따라 결정된다.

② 로스트 업데이트 (Lost Update, 갱신 손실)

레이스 컨디션으로 인해 발생하는 결과 중 하나이다. 두 트랜잭션(또는 스레드)이 거의 동시에 같은 데이터를 읽고 수정한 뒤 저장할 때, 나중에 저장된 값이 먼저 저장된 값을 덮어쓰면서 먼저 수행된 업데이트가 날아가는(Lost) 현상이다.

잔액이 100인 계좌에 두 트랜잭션이 동시에 진입했다고 가정하면 다음과 같이 흘러간다.

시점 트랜잭션 A (+50) 트랜잭션 B (+30) DB 잔액
t1 100 읽음   100
t2   100 읽음 100
t3 100+50=150 저장   150
t4   100+30=130 저장 130

정상이라면 잔액은 180이어야 한다. 그런데 B가 A의 결과를 보지 못한 채 옛날 값(100)을 기준으로 계산해 저장하면서, A의 +50이 통째로 사라졌다. 이것이 로스트 업데이트다.

해결 ① 비관적 락 (Pessimistic Lock)

"충돌이 무조건 발생할 것이다"라고 비관적으로 가정하고 접근하는 방식이다.

  • 동작 원리: 데이터를 읽는 시점부터 데이터베이스 엔진 자체의 기능(Row-level Lock 등)을 사용해 아예 잠가버린다. 다른 트랜잭션은 락이 풀릴 때까지 대기해야 한다.
  • 장점: 데이터의 무결성을 확실하게 보장한다. 충돌이 잦은 환경에서는 롤백과 재시도를 반복하는 낙관적 락보다 오히려 효율적일 수 있다.
  • 단점: 다른 요청이 대기해야 해서 시스템 전체의 동시 처리량이 크게 저하될 수 있고, 서로의 락이 풀리기를 기다리는 데드락(Deadlock, 교착 상태)에 빠질 위험이 있다.
  • 사용처: 은행 계좌 이체, 선착순 재고 차감 등 데이터 정합성이 성능보다 압도적으로 중요한 곳.

데이터를 읽을 때 수정 권한까지 함께 획득한다.

-- 조회 시점에 행에 락을 건다 → 다른 트랜잭션은 대기
SELECT * FROM account WHERE id = 1 FOR UPDATE;

JPA에서는 @Lock 어노테이션으로 이 쿼리를 만들술 있다.

public interface AccountRepository extends JpaRepository<Account, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)   // SELECT ... FOR UPDATE
    @Query("SELECT a FROM Account a WHERE a.id = :id")
    Optional<Account> findByIdForUpdate(@Param("id") Long id);
}

해결 ② 낙관적 락 (Optimistic Lock)

"충돌이 거의 발생하지 않을 것이다"라고 낙관적으로 가정하고 접근하는 방식이다.

  • 동작 원리: DB의 실제 락 기능을 쓰지 않는다. 대신 테이블에 version이나 updated_at 같은 컬럼을 두어 애플리케이션 단에서 충돌을 감지한다. 데이터를 수정할 때 내가 처음 읽은 버전과 현재 DB의 버전이 일치하는지 확인한다.
  • 장점: DB 락을 잡지 않아 대기 시간이 없어 성능이 우수하고, 데드락이 발생하지 않는다.
  • 단점: 충돌이 발생했을 때 예외를 잡아 데이터를 다시 읽고 수정을 시도하는 재시도(Retry) 로직을 개발자가 직접 구현해야 한다.
  • 사용처: 게시글 수정, 사용자 프로필 업데이트 등 같은 데이터를 동시에 수정할 확률이 낮고 읽기 요청이 많은 곳.

수정 시 버전 조건을 함께 걸어, 그사이 값이 바뀌었으면 업데이트가 0건이 되도록 만든다.

-- 내가 읽은 version(1)이 그대로일 때만 수정된다
UPDATE account
   SET balance = 5000, version = 2
 WHERE id = 1 AND version = 1;
-- 영향받은 행이 0건이면 → 그사이 누군가 먼저 수정함 (충돌)

JPA에서는 @Version 필드 하나만 두면 이 동작이 자동으로 적용된다.

@Entity
public class Account {

    @Id @GeneratedValue
    private Long id;

    private int balance;

    @Version            // 충돌 감지용 버전 컬럼
    private Long version;
}

// 충돌 시 OptimisticLockException → 재시도
public void transferWithRetry(Long id, int amount) {
    int maxRetry = 3;
    for (int i = 0; i < maxRetry; i++) {
        try {
            accountService.transfer(id, amount);
            return;
        } catch (OptimisticLockException e) {
            // 버전 충돌 → 다시 읽고 재시도
        }
    }
    throw new RuntimeException("처리 실패");
}

한눈에 비교

항목 비관적 락 낙관적 락
가정 충돌이 잦다 충돌이 드물다
구현 DB 락 (FOR UPDATE) version 컬럼 비교
대기/성능 대기 발생, 처리량 저하 대기 없음, 빠름
데드락 가능 없음
재시도 로직 불필요 필요
적합한 상황 계좌 이체, 선착순 차감 게시글·프로필 수정
반응형