개발/기타

DB Lock

Ski_ 2025. 5. 4. 23:57

최근 간단한 예약 서비스 과제를 진행하면서, JPA의 Lock을 직접 다뤄야 하는 상황이 생겼다.  
@Transactional만으로는 동시성 문제가 해결되지 않았고 비관적 락(Pessimistic Lock) 을 적용해 해결했다.

이후 해당 내용에 대해 좀 더 자세히 알아보고자 글을 작성하게 되었다.

 

대표적으로 사용하는 DB중 하나인 MySQL 기준으로 작성할 예정이며 아래와 같은 순서로 작성하겠다.

 

1. DB Lock의 기본 개념

2. Read Lock vs Write Lock

3. 비관적 락 vs 낙관적 락

4. 교착 상태

5. ORM과 Lock (JPA)


1. DB Lock

Lock은 왜 사용해야 할까? 예를 들어 캐치테이블 같은 예약 앱을 생각해보자.

여러 명의 유저가 동시에 예약을 시도했을 때, 누가 예약을 성공해야 할까? 모두 성공하면 중복 예약이 되고, 일부만 성공해도 일관성이 깨질 수 있다.

 

이처럼 동시성 문제를 방지하기 위해 Lock이 필요하다.

DB Lock은 트랜잭션 간 경쟁 조건(Race Condition)이나 Dirty Read를 방지하고, 데이터의 무결성과 일관성을 보장하기 위해 사용하는 기법이다.

 

- 경쟁 조건(Race Condition)?

둘 이상의 트랜잭션 또는 스레드가 동시에 동일한 자원(데이터)에 접근하여 결과가 실행 순서에 따라 달라지는 현상

 

- Dirty Read?

커밋되지 않은 트랜잭션의 데이터를 다른 트랜잭션이 읽는 현상 -> 잘못된 데이터 노출 위험

 

- 데이터 무결성(Integrity)

데이터의 형식, 제약 조건, 참조 관계 등이 정해진 규칙을 어기지 않고 유지되는 상태

 

- 데이터 일관성

트랜잭션 전후에 데이터가 논리적으로 정합된 상태를 유지하는 것


2. Read Lock vs Write Lock 

DB에서의 락은 읽기와 쓰기 작업을 구분하여 관리된다.

- Read Lock (공유 락): 여러 트랜잭션이 동시에 데이터를 읽을 수 있음. 서로 충돌 없음.

- Write Lock (배타 락): 데이터를 수정하는 트랜잭션이 단독으로 접근해야 하며, 다른 트랜잭션은 대기한다.

 

1) 트랜잭션 격리 수준(Transaction Isolation Level)

트랜잭션의 일관성과 동시성을 보장하기 위해 격리 수준이 필요하다. 각 격리 수준은 다음과 같은 특성과 잠금 전략을 가진다.

 

- READ UNCOMMITTED: 다른 트랜잭션의 커밋되지 않은 데이터를 읽을 수 있음 (Dirty Read 허용)

- READ COMMITTED: 커밋된 데이터만 읽음 (Non-repeatable Read 허용)

- REPEATABLE READ: 동일 데이터를 반복 조회 시 값이 변하지 않음 (Phantom Read 허용)

- SERIALIZABLE: 가장 엄격한 격리 수준, 트랜잭션을 직렬화하여 처리 (모든 읽기에 락 적용)

 

2) 읽기 현상(Read Phenomena)

트랜잭션 동시성에서 발생할 수 있는 대표적인 문제 상황들은 아래와 같다.

 

- Dirty Read : 커밋되지 않은 데이터를 읽음

- Non-repeatable Read : 같은 데이터를 두 번 읽었을 때 값이 다름

- Phantom Read : 같은 조건의 SELECT에서 처음엔 없던 데이터가 나중엔 존재

 

3) 락의 세부 범위(Lock Granularity)

락은 적용되는 범위에 따라 성능과 충돌 가능성에 영향을 준다.

 

- row-level Lock: 가장 정밀한 단위. 충돌은 적지만 비용 높음.

- page-level Lock: 다수의 레코드를 포함하는 페이지 단위로 락

- table-level Lock: 전체 테이블을 락으로 묶음. 성능은 좋지만 병렬성 낮음

 

MySQL InnoDB는 기본적으로 row-level lock을 사용.

 

3) 2단계 락 규약(2PL - two-phase locking protocol)

2단계 락 규약은 트랜잭션에서 다음 두 단계를 반드시 따를 때 일관성과 직렬성을 보장한다.

 

1. 확장 단계(Growing Phase): 락을 획득할 수는 있지만 해제할 수는 없음

2. 축소 단계(Shrinking Phase): 락을 해제할 수는 있지만 새로운 락을 획득할 수는 없음

 

이 규칙을 지키면 트랜잭션 간 충돌 시 데이터 불일치나 교착 상태를 피할 수 있다.


3. 비관적 락 vs 낙관적 락

1) 비관적 락(Pessimistic Lock)

- 다른 트랜잭션이 동시에 데이터를 변경할 수 있다고 비관적으로 가정

- 데이터에 접근할 때 즉시 락을 획득

- 다른 트랜잭션이 접근 못 하도록 선제적으로 제어

 

장점: 확실한 충돌 방지
단점: 락을 지속적으로 유지하기 때문에 락 보유 시간이 길어질수록 성능 저하 발생

 

2) 낙관적 락 (Optimistic Locking)

- 대부분 충돌이 없을 것이라 낙관적으로 가정

- 트랜잭션이 끝나는 시점에서 버전 정보를 비교해 충돌 여부 판단

장점: 충돌이 없으면 성능 우수, 락이 없기 때문에 다른 트랜잭션과 병렬 실행 가능
단점: 충돌이 발생할 경우 예외를 처리해야 함


4. 교착 상태(DeadLock)

교착 상태란 두 개 이상의 트랜잭션이 서로 상대방의 락을 기다리면서 영원히 대기 상태에 빠지는 현상이다.

 

교착 상태를 방지하려면 다음과 같은 전략을 고려할 수 있다.

- 트랜잭션 순서화: 락을 획득하는 순서를 일정하게 만들어 교착 상태를 방지

- 락 타임아웃 설정: 일정 시간 동안 락을 얻지 못하면 트랜잭션을 롤백하도록 설정

- 최소화된 락 범위 사용: 필요한 최소 범위만 락을 걸어 다른 트랜잭션의 대기 시간을 줄임

MySQL InnoDB는 Deadlock 발생 시 하나의 트랜잭션을 강제로 롤백하여 해결한다고 한다.


5. JPA의 Lock

0) JPA의 Lock의 종류

JPA에서 사용하는 LockType의 종류와 설명은 아래와 같다.

public enum LockModeType {
    READ,               // 낙관적 락 (Optimistic Lock)
    WRITE,              // 낙관적 락 (OPTIMISTIC_FORCE_INCREMENT) 
    OPTIMISTIC,         // 낙관적 락
    OPTIMISTIC_FORCE_INCREMENT, // 낙관적 락 + 버전 증가
    PESSIMISTIC_READ,   // 비관적 읽기 락
    PESSIMISTIC_WRITE,  // 비관적 쓰기 락
    PESSIMISTIC_FORCE_INCREMENT, // 비관적 락 + 버전 증가
    NONE                // 락 적용 안함
}

 

1) JPA 비관적 락(pessimistic lock)

@Lock(LockModeType. PESSIMISTIC_WRITE)을 사용하면 비관적 락을 적용할 수 있다.

@Lock(LockModeType.PESSIMISTIC_WRITE)
public Entity findEntityBuId(Long id);

 

2) JPA 낙관적 락(optimistic lock)

@Lock(LockModeType. OPTIMISTIC)과 엔티티에 @Version 어노테이션을 사용하여 버전 관리를 통해 충돌 여부를 확인한다.

@Entity
public class Entity {
    @Id
    private Long id;

    @Version
    private Long version;
}

 

3) JPA의 락 예외 처리

락의 종류에 따라 다음과 같은 예외가 발생할 수 있다.

예외 클래스 발생 상황
PessimisticLockException 비관적 락 획득 실패 시 (다른 트랜잭션이 선점 중)
OptimisticLockException 낙관적 락 사용 시 버전 충돌이 발생했을 때
CannotAcquireLockException DB에서 Deadlock 또는 락 대기시간 초과 (timeout) 발생 시
LockTimeoutException (JPA 2.2+) 특정 시간 안에 락을 획득하지 못한 경우

비관적 락 예외 처리

비관적 락은 락을 선점하려다 실패하거나, DB 락 타임아웃 설정에 걸리면 아래와 같은 예외가 발생할 수 있다.

@Lock(LockModeType.PESSIMISTIC_WRITE)
public Item findWithPessimisticLock(Long id) {
    try {
        return entityManager.find(Item.class, id, LockModeType.PESSIMISTIC_WRITE);
    } catch (PessimisticLockException | LockTimeoutException e) {
        // 다른 트랜잭션이 선점 중이거나 타임아웃이 발생한 경우
        throw new BusinessException("잠시 후 다시 시도해주세요. 현재 다른 사용자가 작업 중입니다.");
    }
}

 

낙관적 락 예외 처리

낙관적 락은 병행 트랜잭션 간 충돌 시점에 발생하며, 주로 merge() 또는 flush() 시점에 예외가 발생한다.

@Transactional
public void updateProduct(ProductRequest dto) {
    Product product = productRepository.findById(dto.getId())
        .orElseThrow(() -> new NotFoundException("존재하지 않는 상품입니다."));

    product.updateName(dto.getName()); // 변경

    try {
        // flush 또는 트랜잭션 종료 시점에서 충돌 감지
    } catch (OptimisticLockException e) {
        throw new ConflictException("다른 사용자가 먼저 수정했습니다. 다시 시도해주세요.");
    }
}

+ 재시도 전략 (Retryable)

낙관적 락 충돌 시 @Retryable을 사용하는 전략도 고려할 수 있다.

@Retryable(
    value = { OptimisticLockException.class },
    maxAttempts = 3,
    backoff = @Backoff(delay = 200)
)
@Transactional
public void updateProductWithRetry() {
    // 업데이트 로직
}

Lock에 대해 작성해봤고 ORM에서 Lock을 사용하는 방법과 예외 처리 방법에 대해 작성해봤다.

실무에서 성능과 동시성 요구사항에 따라 어떤 락 전략을 선택해야 할 지 고민하는데 도움이 되리라 생각한다.

 

 

출처

https://dev.mysql.com/doc/refman/8.0/en/innodb-locking-transaction-model.html

https://www.postgresql.org/docs/current/explicit-locking.html

반응형