[Spring Boot+JPA] Pessimistic Lock VS Optimistic Lock

사람·2025년 7월 20일
2

Backend

목록 보기
8/11

1. 발단

이전 글에도 썼듯이 요즘 Apache JMeter를 통해서 성능 테스트를 해보고 있다.
그런데 성능 테스트를 위해서 반복적으로 HTTP 요청을 보내다 보니 특정 API에서 간헐적으로 오류가 발생했다.
요청을 보낸 시점에 어떤 유저가 피드에 추가한 감정 표현이 이미 존재한다면 해당 감정 표현을 삭제하고, 추가되어 있는 감정 표현이 존재하지 않는 상태라면 새롭게 추가하기 위한 API였다.

{
    "timestamp":"2025-07-12T18:56:43.610878954",
    "status":500,
    "error":"org.springframework.orm.ObjectOptimisticLockingFailureException: Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect) : [com.score.backend.domain.exercise.emotion.Emotion#1723]",
    "message":"Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect) : [com.score.backend.domain.exercise.emotion.Emotion#1723]"
}

이 오류에 대해 좀 찾아보니 동시성 문제라는 것이 그 원인이었다. 여러 트랜잭션에서 동시에 어떤 리소스에 접근해서 수정이나 삭제를 하려고 할 때 데이터의 불일치가 발생하는 것이다. 학교 운영체제 수업에서 데드락 이런 거 배울 땐 남일인 줄 알았는데 아니었네....

그러니까 이렇게 되었을 확률이 높다.
1. 트랜잭션 A와 B가 모두 감정 표현 데이터를 읽음.
2. A가 먼저 해당 감정 표현 데이터를 삭제.
3. B는 여전히 감정 표현 데이터가 존재한다고 생각하고 삭제 시도 -> 예외 발생.

이번 기회에 동시성 문제와 동시성 제어 기법에 대해 자세히 알아보기로 했다.

2. 동시성(Concurrency) 문제란?

동시성 문제는 여러 개의 프로세스나 스레드가 동시에 같은 자원에 접근하거나 수정하려고 할 때 발생하는 문제이다. 여러 스레드가 동시에 데이터를 읽고 쓰는 과정에서 충돌이 발생할 수 있기 때문이다.
하나의 세션이 어떤 데이터를 수정 중일 때, 다른 세션이 아직 수정되기 전의 데이터를 조회하게 된다면 데이터의 정합성이 깨지게 될 것이다.

가장 많이 드는 예시가 쇼핑몰 재고 관리 시스템이다. A라는 사람이 재고가 1인 물건을 구매하고 있다. 따라서 이제 해당 상품에 대한 주문 요청은 수행되어서는 안 될 것이다. 그런데 시스템 상에서 재고가 0으로 수정되기 전에 B라는 다른 사람도 그 물건을 구매하려고 시도한다면, B의 요청을 처리하는 스레드는 재고가 남아 있다고 인식해 주문을 승인해 버릴 수 있다. 이러면 재고가 마이너스가 되어 버리고 시스템에 문제가 생길 것이다.

따라서 공유 자원에 멀티 스레드가 접근 가능한 환경이라면 이 동시성 문제를 염두에 두고 충돌 문제가 발생하지 않도록 처리해주는 것이 필요하다. 이를 위한 기법들을 동시성 제어 혹은 동시성 처리 (Concurrency Control) 기법이라고 한다.

3. Lock, Locking

락(Lock)은 데이터베이스에서의 동시성 제어를 위한 기법 중 하나이다.
말 그대로 어떤 공유 자원에 접근하는 트랜잭션이 있다면, 그동안 다른 트랜잭션은 해당 자원에 접근할 수 없도록 잠그는 것이라고 생각하면 된다.

일단 OptimisticLockingFailure라고 하니, Pessimistic LockOptimistic Lock이 무엇인지 알아보았다.

3.1. Pessimistic Lock (비관적 잠금)

Pessimistic Locking은 데이터에 대한 수정이나 삭제가 이뤄질 때 동시성 문제로 인한 충돌이 발생할 것이라는 비관적인 가정을 하고 락을 거는 방식이다. 데이터를 갱신하기 전에 우선적으로 DB에 락을 걸어서 다른 트랜잭션의 접근을 아예 차단하는 방식으로 충돌을 예방하는 것이다.

3.1.1. Lock Modes

JPA에서는 LockModeType라는 enum 클래스로 Pessimistic Lock에 대해 3가지 모드를 정의하고 있다. PESSIMISTIC_READ, PESSIMISTIC_WRITE, PESSIMISTIC_FORCE_INCREMENT가 그것이다.

  • PESSIMISTIC_READ: DB에 Shared Lock을 거는 것이다. 다른 트랜잭션에서 수정이나 삭제를 불가하게 하는 락이다. 즉 읽기는 허용한다.

  • PESSIMISTIC_WRITE: DB에 Exclusive Lock을 거는 것이다. 다른 트랜잭션에서 데이터에 대한 수정, 삭제뿐만 아니라 읽기도 불가하게 하는 락이다.

  • PESSIMISTIC_FORCE_INCREMENT: 버전화된 엔티티에 적용되는 락이다. PESSIMISTIC_WRITE와 비슷하게 Exclusive Lock을 거는데, 버전 열까지 업데이트를 한다.

3.1.2. JPA에서 Pessimisitic Lock 사용하기

3.1.2.1. JpaRepository 인터페이스를 사용하는 경우

@Repository
public interface EmotionRepository extends JpaRepository<Emotion,Long>, EmotionRepositoryCustom {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select e from Emotion e where e.feed.id = :feedId")
    Optional<List<Emotion>> findAllEmotionsByFeedId(@Param("feedId") Long feedId);
}

위와 같이 @Lock 어노테이션을 통해 사용할 수 있다.

3.1.2.2. QueryDSL을 사용하는 경우

@RequiredArgsConstructor
public class EmotionRepositoryImpl implements EmotionRepositoryCustom {
    private final JPAQueryFactory queryFactory;
    QEmotion e = new QEmotion("e");

    @Override
    public List<Emotion> findAllEmotionType(Long feedId, EmotionType emotionType) {
        return queryFactory
                .selectFrom(e)
                .where(e.feed.id.eq(feedId).and(e.emotionType.eq(emotionType)))
                .orderBy(e.createdAt.desc())
                .setLockMode(LockModeType.PESSIMISTIC_WRITE)
                .fetch();
    }
}

위와 같이 setLockMode() 메소드를 통해 사용할 수 있다.

3.1.3. 발생할 수 있는 예외

JPA에서 Pessimistic Lock 사용 시 다음과 같은 예외가 발생할 수 있다.

  • PessimisticLockException: 락을 얻어야 걸 수 있는데, 여러 이유로 락을 얻지도 못하고 튕겨나가면 이 예외가 터진다.
  • LockTimeoutException: 어떤 데이터에 접근하려고 시도했는데 락이 걸려 있다면, 해당 트랜잭션은 락이 풀릴 때까지 대기하다가 락이 풀리면 작업을 계속 수행한다. 그런데 설정된 시간까지 락이 풀리지 않으면 이 예외가 발생한다. 대기 시간 설정은 아래와 같이 할 수 있다.
@Repository
public interface EmotionRepository extends JpaRepository<Emotion,Long>, EmotionRepositoryCustom {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value = "10000")})
    @Query("select e from Emotion e where e.feed.id = :feedId")
    Optional<List<Emotion>> findAllEmotionsByFeedId(@Param("feedId") Long feedId);
}

3.2. Optimistic Lock (낙관적 잠금)

Optimistic Locking은 데이터에 대한 수정이나 삭제가 이뤄질 때 동시성 문제로 인한 충돌은 없을 것이라는 낙관적인 가정을 하는 방식이다. 그래서 DB에 미리 락을 걸지 않는 대신, 충돌이 실제로 발생하면 그때 사후 처리를 해줘야 한다.
이름은 락이지만 사실은 락을 걸지 않는... 아이러니한 친구다. 하지만 그 아이러니함이 사실 Optimisitic Lock의 본질이라고 할 수 있다. 사실 Optimistic Lock은 버전 불일치 탐지기에 더 가깝다. DB에서 충돌을 바로 감지하는 대신 애플리케이션 레벨에서 엔티티의 버전을 관리하다가, 버전의 불일치가 생겼다면 그때 예외를 터뜨려주는 방식이기 때문.

다시 말하면 Pessimistic Lock의 경우 데이터에 대한 업데이트가 이뤄지는 즉시 충돌을 감지할 수 있지만, Optimisitc Lock은 트랜잭션을 commit 하는 시점이 되어야 충돌을 감지할 수 있다. 이전 트랜잭션에서 데이터와 엔티티 버전 업데이트가 한 번 일어난 상태여야만 다음 트랜잭션에서 '버전의 불일치'라는 것이 발생할 수 있기 때문이다.

3.2.1. Lock Modes

JPA에서는 LockModeType라는 enum 클래스로 Optimistic Lock에 대해 2가지 모드를 정의하고 있다. OPTIMISTIC, OPTIMISTIC_FORCE_INCREMENT가 그것이다.

  • OPTIMISTIC: 트랜잭션이 commit될 때 버전 필드를 확인해 충돌 여부를 판단한다.

  • OPTIMISTIC_FORCE_INCREMENT: OPTIMISTIC과 유사하지만, 버전 정보까지 강제로 업데이트한다.

3.2.2. JPA에서 Pessimisitic Lock 사용하기

JPA에서는 @Version 어노테이션을 통해 쉽게 Optimistic Lock을 사용할 수 있다.
엔티티 내부에 @Version 이 붙은 필드를 추가해주면 된다.

@Entity
@Getter
public class Emotion extends BaseEntity {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "emotion_id")
    private Long id;

    @Version
    private Long version;
    
    }
}

이때 중요한 점은

  • 하나의 엔티티 클래스는 하나의 @Version 필드만을 가져야 한다.
  • @Version 필드의 타입은 int , Integer , long , Long , short , Short , java.sql.Timestamp 중 하나여야 한다.
    (이 필드의 값 혹은 시간이 처음 조회될 때와 commit될 때 서로 다르다면 충돌이 발생한 것으로 판단한다.)

3.2.3. OptimisticLockException

충돌이 발생하면 OptimisticLockException이라는 것이 발생하고, 트랜잭션이 롤백된다.
이 예외가 발생하면

for (int i = 0; i < MAX_RETRIES; i++) {
    try {
        // update 시도
        break;
    } catch (OptimisticLockException e) {
        // 재시도
    }
}

이런 식으로 직접 재시도 로직을 구현해야 한다.

3.3. Pessimistic Lock VS Optimistic Lock

여기까지 읽어보면 'Optimistic Lock'이라는 건 대체 왜 쓰는 거지...? 라는 생각이 들 수도 있다.
왜냐하면 Optimistic Lock은 락을 걸지 않기 때문에 충돌 발생했을 때 발생하는 예외를 막을 수는 없기 때문이다.
게다가 예외 발생했을 때 추가적인 사후 처리도 직접 구현을 해줘야 한다.
Optimistic Lock의 역할은 그냥 '충돌 났으니 알아서 처리해라~'라고 알려주는 것에 지나지 않는다.
이럴바엔 DB 레벨에서 알아서 락을 걸어주는 Pessimisitic Lock이 더 낫지 않나?

싶겠지만...
충돌이 거의 안 나는 환경인데도 트랜잭션마다 매번 락을 걸어가며 동기화를 하는 것은 비용이 너무 크다.
가뭄에 콩 나듯 가끔 가다 충돌이 나는 환경이라면(잘 안 바뀌는 데이터라든가) 매번 충돌 대비를 하는 것보다 가끔 실패했을 때 그냥 다시 시도하는 게 더 빠르고 저렴한 방식이 된다.

그러니 어떤 데이터에 대해 어떤 락을 사용할 것인지 잘 판단하는 것이 중요하겠다.


이번에 동시성 문제에 대해 알아보면서 느낀 건,
사용자가 접근해서 수정, 삭제할 수 있는 데이터라면 그게 뭐든 동시성 문제의 위험을 안고 있다는 점이다.
동시성은 예외적인 상황이 아니라, 설계의 기본값이다.

profile
알고리즘 블로그 아닙니다.

2개의 댓글

comment-user-thumbnail
2025년 8월 1일

운영체제에서 듣던게 여기 쓰이는군요!
유익한 글 감사합니다🥰🥰

1개의 답글