리타의 저장소

Lock과 트랜잭션 동시성제어 #3 본문

Dev/Backend

Lock과 트랜잭션 동시성제어 #3

ريتا 2026. 4. 3. 21:13

 

JPA 레이어에서의 동시성 제어 방식 1 : 낙관적 락

JPA의 대표적인 동시성 제어는 @Version 기반 optimistic locking이다.

단순하게, 대부분의 경우 데이터 충돌이 일어나지 않을 거라 낙관적으로 가정하고 동시성을 제어하는 방식. 충돌이 드물 거라 전제 하에, 굳이 무거운 락을 걸지 않고 처리량과 성능을 높이는 것에 초점을 둔다고 생각하면 된다.

애플리케이션 레벨에서의 버전정보를 통해 충돌 여부를 감지하는데, DB가 아닌 JPA가 제공하는 락이다.

낙관적 락은 DB row를 오래 붙잡지 않고, 마지막 반영 순간에 “중간에 누가 먼저 바꿨는지”를 version으로 검증한다. 즉, JPA 낙관적 락은 “미리 막는 게 아니라 나중에 충돌을 감지” 한다.

 

EX) 동작 방식 흐름
  • 트랜잭션 A, B가 같은 row 조회
  • 둘 다 version=5 읽음
  • A가 수정 후 flush/commit → version 6
  • B가 수정 후 flush/commit 시도
  • where id=? and version=5 조건이 안 맞아서 갱신 row 수 0
  • OptimisticLockException 발생

우선 낙관적 락을 사용하려면, @Version 어노테이션을 붙인 필드를 추가해야 한다. 이 필드는 int, Integer, long, Long, short, Short, Timestamp 등의 타입으로 정의할 수 있고, 엔티티가 수정될 때마다 이 값이 자동으로 증가한다.

@Entity
public class Board {

  @Id
  @GeneratedValue
  private Long id;

  private String title;

  @Version // @Version 어노테이션 적용
  private Integer version; // Long, Integer, Short, Timestamp 타입 가능
  ...
}

 

JPA는 엔티티를 수정하고 트랜잭션을 커밋하는 시점에 영속성 컨텍스트를 flush하면서 UPDATE 쿼리를 실행한다. 이 UPDATE 쿼리의 WHERE 절에는 엔티티를 조회했던 시점의 버전 정보가 포함된다.

 

UPDATE BOARD SET title =?, version =? WHERE id =? AND version =?

 

만약 데이터 조회 이후 다른 트랜잭션에 의해 엔티티가 수정되어 버전이 증가했다면, WHERE 절의 버전 조건이 일치하지 않아 UPDATE 쿼리가 성공하지 못하고 JPA는 ObjectOptimisticLockingFailureException예외를 발생시킨다.

 

상세

  • 조회시, 버전 정보도 함께 메모리에 저장된다.
  Board board = entityManager.find(Board.class, 1L);  // version = 5
  • JPA는 versoin = 5인 상태를 persistence context에 저장한다
  • update 시점에, where 조건으로 버전을 비교한다
board.setTitle("수정된 제목");
transaction.commit(); // 또는 flush()
UPDATE board
SET title = '수정된 제목', version = 6
WHERE id = 1 AND version = 5;
  • 즉, 조회했을 당시 버전(5)를 기억하고 있다가, update 쿼리의 where 절에서 비교
  • 다른 트랜잭션이 (트랜잭션 A) 가 같은 엔티티를 먼저 수정했다면?
int updateCount = preparedStatement.executeUpdate(); // 예: 1 또는 0
  • 영향 받는 행이 0개가 되고, jpa는 내부적으로 JDBC를 사용하고 JDBC에서는 executeUpdate()를 호출하면 수정된 행 갯수를 반환한다. 그럼 jpa는 업데이트된 행의 수가 0인지 확인 가능하다. 이를 근거로 ObjectOptimisticLockingFailureException 예외를 던지게 됨.

낙관적 락의 Retry 로직 (예외 처리)

낙관적 락은 충돌이 발생하면 ObjectOptimisticLockingFailureException을 던지고 끝난다. 즉, 트랜잭션이 롤백되어 버립니다. 따라서 실무에서는 이 예외를 캐치하여 일정 횟수만큼 재시도(Retry)하는 로직을 애플리케이션 계층(예: @Retryable 활용 혹은 AOP)에 직접 구현해 주어야 완벽한 동시성 처리가 된다.

 

JPA 레이어에서의 동시성 제어 방식 2 : 비관적 락

간단하게 이야기하면, 충돌이 발생할 것이라 비관적으로 가정하고 데이터에 접근할 때부터 해당 데이터를 잠가두는 방식이다.

보통 Oracle에서는 SELECT ... FOR UPDATE 계열 SQL로 이어진다고 보면 된다. 정확한 SQL문의 형태는 provider 구현체에 따라 다를 수 있으나, 개념적으로는 DB row lock을 선점하는 방식이다.

  • SELECT FOR UPDATE?
select * from board where id = 1 for update;
  • id=1인 게시글을 조회하면서 해당 행을 잠금.FOR UPDATE 는 데이터를 잠그면서 읽는 SQL로, 수정 충돌을 방지한다.
  • 해당 쿼리를 실행한 트랜잭션이 락을 해제 (커밋/롤백)하기 전까지, 다른 트랜잭션에서는 해당 행을 수정하거나 삭제할 수 없다.
  • FOR UPDATE 는 데이터를 잠그면서 읽는 SQL로, 수정 충돌을 방지한다.
em.find(Package.class, id, LockModeType.PESSIMISTIC_WRITE);

 

JPA에서 비관적 락은 EntityManager 의 find()나 lock() 메서드, 또는 Query의 setLockMode() 메서드를 통해 LockModeType을 지정하여 사용할 수 있다.

Spring Data JPA라면 @Lock 어노테이션으로 간편하게 적용 가능하다.

// EntityManager를 통한 비관적 락 획득 (예: PESSIMISTIC_WRITE)
Package package = entityManager.find(Package.class, packageId, LockModeType.PESSIMISTIC_WRITE);

// Spring Data JPA에서 @Lock 사용
public interface PackageRepository extends JpaRepository<Package, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select p from Package p where p.id = :id")
    Optional<Package> findByIdForUpdate(@Param("id") Long id);
}

락 획득 → 트랜잭션 A가 특정 엔티티를 조회하면서 락을 함께 요청
@Lock(LockModeType.PESSIMISTIC_WRITE)을 통해 조회 시점에 락을 건다.

→ PESSIMISTIC_WRITE는 Exclusive Lock, 다른 스레드의 읽기/쓰기 모두 차단한다.
→ 상황에 따라 공유락인 PESSIMISTIC_READ를 사용하는 경우도 있음.

이게 SQL로 나간다면 아래같은 식이다.

SELECT * FROM Package WHERE id = ? FOR UPDATE;

DB 락 : 데이터베이스는 해당 엔티티에 락을 걸고, 트랜잭션 A에 엔티티르 반환한다.

이 때부터, 다른 트랜잭션은 해당 엔티티에 접근하면 락이 해제될때까지 대기하거나, 오류를 반환하게 된다.

비관적 락의 DeadLock 위험

비관적 락은 확실하지만, 여러 트랜잭션이 서로 다른 순서로 데이터를 잠그기 시작하면 교착 상태(Deadlock)에 빠질 위험이 크다.

따라서 비관적 락을 사용할 때는 무한정 대기하는 것을 막기 위해 락 획득 대기 시간(Timeout)을 설정하는 힌트(javax.persistence.lock.timeout)를 함께 사용하는 것이 안전한하다.