본문 바로가기

IT

JPA에서 동시성 제어 방법(낙관적 락,비관적 락)

728x90
반응형
728x170

동시에 같은 데이터를 수정하는 순간, 서비스는 별도의 처리를 안해두었다면 “마지막에 저장한 사람이 이기는” 게임이 됩니다. 주문 수량, 재고, 쿠폰 발급, 포인트 차감 같은 중요한 곳에서 이러한 로직을 방치하면 곧바로 장애 티켓이 날아옵니다.


JPA는 이런 동시성 충돌을 다루기 위해 낙관적 락(Optimistic Lock)과 비관적 락(Pessimistic Lock)을 제공합니다.

 

이 글에서는 두 락의 원리, JPA/Spring Data JPA에서의 구현 예시, 그리고 언제 무엇을 선택하는 게 적절한지 실전 기준으로 정리해 보도록 하겠습니다.

 

우선 낙관적 락과 비관적 락의 장단점을 도표로 간단하게 정리하고 시작하겠습니다. 

낙관적 락vs 비관적 락 비교

 

락이 없으면 생기는 대표적인 문제 : 잃어버린 업데이트

가장 흔한 사고는 잃어버린 업데이트(Lost Update)입니다.

  • 트랜잭션 A가 재고 10을 읽음
  • 트랜잭션 B도 재고 10을 읽음
  • A가 1개 판매해서 9로 저장
  • B가 1개 판매해서 9로 저장

결과는 8이 되어야 하는데 9가 됩니다. 둘 중 한 명이 한 일을 다른 한 명이 덮어써버린 셈이죠.

이걸 막는 방법이 크게 두 가지입니다.

  1. 저장 시점에 충돌을 감지해서 실패 처리(낙관적 락)
  2. 아예 다른 사람이 못 만지게 잠가두고 순서대로 처리(비관적 락)

 

낙관적 락(Optimistic Locking)

낙관적 락은 “대부분은 안 부딪힐 거야”라는 가정으로 출발합니다. DB에 락을 걸고 기다리기보다, 엔티티에 버전(version) 값을 두고 업데이트 시점에 버전이 같은지 확인합니다.

@Version 어노테이션 하나로 간단하게 시작

import javax.persistence.*;

@Entity
public class Product {

    @Id
    private Long id;

    private long stock;

    @Version
    private Long version;

    // getter/setter 생략
}

 

동작 흐름은 이렇습니다.

  1. A와 B가 같은 Product를 읽는다(둘 다 version=0)
  2. A가 stock을 수정하고 커밋한다(버전이 1로 증가)
  3. B가 stock을 수정하고 커밋하려고 하면, “내가 읽은 버전(0)과 DB 버전(1)이 다르네?”가 되어 예외가 발생하고 롤백된다

즉, 충돌이 발생하면 늦게 저장하려던 쪽이 실패합니다. 데이터 무결성은 지키되, 충돌이 드문 환경에서는 락 대기 없이 빠르게 처리할 수 있습니다.

 

실전 예시: 장바구니 수량 변경(충돌 가능성 낮은 편)

  • 대부분 사용자는 자기 장바구니만 수정함
  • 같은 상품을 동시에 여러 기기에서 수정하는 경우는 있지만 빈도는 낮음
  • 충돌 시 “다른 곳에서 변경이 발생했습니다. 새로고침 후 다시 시도해 주세요” 같은 UX로 해결 가능

낙관적 락 사용 시 꼭 넣어야 하는 것: 재시도 정책

낙관적 락은 충돌을 “막는” 게 아니라 “감지해서 실패시키는” 방식입니다. 그래서 서비스는 실패를 어떻게 처리할지 정해야 합니다.

  • 자동 재시도(짧은 백오프 + 최대 횟수 제한)
  • 사용자에게 충돌 안내 후 재시도 유도
  • 일부 필드 병합(가능한 도메인에 한해)

예를 들어 재고 차감처럼 실패가 곧바로 사용자 불만으로 이어지는 곳은, 무작정 재시도만 걸면 오히려 트래픽 폭증으로 이어질 수 있어 설계가 필요합니다.

 

비관적 락(Pessimistic Locking)

비관적 락은 “충돌이 날 가능성이 높다”는 전제입니다. DB 레벨에서 로우(행)를 잠가서, 다른 트랜잭션이 같은 데이터를 동시에 만지지 못하게 합니다. 흔히 SQL로는 SELECT ... FOR UPDATE 계열로 표현됩니다.

Spring Data JPA에서 가장 흔한 방식: @Lock(PESSIMISTIC_WRITE)

import org.springframework.data.jpa.repository.*;
import org.springframework.data.repository.query.Param;

import javax.persistence.LockModeType;
import javax.persistence.QueryHint;
import java.util.Optional;

public interface ProductRepository extends JpaRepository<Product, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints({
        @QueryHint(name = "javax.persistence.lock.timeout", value = "10000") // 10초 대기(지원 범위는 DB/구현체에 따라 다름)
    })
    @Query("select p from Product p where p.id = :id")
    Optional<Product> findForUpdate(@Param("id") Long id);
}

 

서비스에서 보통 이렇게 씁니다.

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class StockService {

    private final ProductRepository productRepository;

    public StockService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    @Transactional
    public void decreaseStock(Long productId, long qty) {
        Product p = productRepository.findForUpdate(productId).orElseThrow();

        long next = p.getStock() - qty;
        if (next < 0) {
            throw new IllegalStateException("재고 부족");
        }
        p.setStock(next);
        // 커밋 시 락 해제
    }
}

 

여기서 핵심은, 트랜잭션이 끝날 때까지 해당 로우는 잠겨 있다는 점입니다. 그래서 같은 상품 재고를 동시에 차감하려는 요청이 몰리면, 자연스럽게 “줄 서서” 처리됩니다.

PESSIMISTIC_READ / PESSIMISTIC_WRITE / FORCE_INCREMENT 개념

  • PESSIMISTIC_READ: 공유락 성격. 보통 수정은 막고 읽기는 허용하려는 목적이지만, 실제 동작은 DB/격리수준에 따라 차이가 날 수 있습니다.
  • PESSIMISTIC_WRITE: 배타락 성격. 같은 로우에 대해 다른 트랜잭션의 수정/삭제는 물론, 경우에 따라 읽기까지 대기하게 만들 수 있습니다.
  • PESSIMISTIC_FORCE_INCREMENT: 배타락 + 버전 강제 증가. “락도 걸고, 버전 기반 충돌 감지도 강하게 걸겠다” 같은 엄격 모드에 가깝습니다.

 

언제 낙관적 락 vs 비관적 락 선택 방법

아래 표는 실무에서 가장 많이 쓰는 판단 기준입니다.

판단 요소 낙관적 락이 유리한 경우 비관적 락이 유리한 경우
충돌 빈도 낮다 높다
트랜잭션 길이 짧거나, 실패 시 재시도 부담이 낮다 짧게 강제할 수 있다(락 오래 잡지 않기)
성능/확장성 읽기 비중 높고 수평 확장 중요 정확성이 최우선, 처리량보다 무결성
실패 처리 충돌 예외를 처리/재시도 설계가 가능 실패보다는 대기를 택하는 게 낫다
대표 도메인 게시글/프로필 수정, 관리 UI 편집 재고 차감, 쿠폰 선착순, 좌석 예약, 포인트 차감

 

한 줄로 정리하면 이렇습니다.

  • 충돌이 드문 데이터는 낙관적 락이 더 가볍고 빠릅니다.
  • 충돌이 잦고 “틀리면 안 되는” 데이터는 비관적 락이 더 단단합니다.

 

실전 시나리오로 보는 추천

1) 선착순 쿠폰 발급

  • 동시에 같은 쿠폰을 잡으려는 요청이 몰림
  • 실패가 많아도 사용자 불만이 커짐
  • 추천: 비관적 락(PESSIMISTIC_WRITE) 또는 DB가 지원하면 잠긴 로우를 건너뛰는 방식(SKIP LOCKED 계열) 검토

2) 어드민에서 상품 정보 편집(충돌 빈도 낮음)

  • 보통 한 명이 편집
  • 동시에 편집하더라도 “내 변경이 덮어쓰이면 안 된다” 정도가 핵심
  • 추천: 낙관적 락(@Version) + 충돌 안내/새로고침 UX

3) 재고 차감/좌석 예약(정확성이 절대)

  • 단 1건의 오류도 금전 문제로 직결
  • 추천: 비관적 락(PESSIMISTIC_WRITE) + 짧은 트랜잭션 + 타임아웃 설정

 

비관적 락을 쓸 때 더 중요한 것들

비관적 락은 강력하지만, 잘못 쓰면 시스템이 “단체로 멈춰서 줄 서는” 상황이 됩니다. 다음 체크리스트는 거의 필수입니다.

1) 트랜잭션을 짧게 유지하기

락을 잡은 상태로 외부 API 호출, 파일 업로드, 긴 연산을 하면 대기열이 길어지고 장애로 이어집니다.
락을 잡는 구간은 가능한 한 DB 수정에만 집중해야 합니다.

2) 락 타임아웃 정책

무한 대기는 최악입니다. 락 대기 시간이 길어질 수 있는 업무는 타임아웃을 두고 실패 처리 전략을 준비해야 합니다.

3) 교착 상태(Deadlock) 예방

여러 로우를 락 잡을 때는 “항상 같은 순서”로 접근하는 습관이 중요합니다.
예를 들어 주문 아이템을 id 오름차순으로 정렬해서 락을 잡는 식으로 규칙을 강제하면 교착 확률이 크게 줄어듭니다.

 

낙관적 락을 쓸 때 더 중요한 것들

낙관적 락은 예외가 설계의 일부입니다.

1) 충돌 예외를 비즈니스적으로 해석하기

  • “동시에 수정이 발생했습니다”는 개발자에겐 친절하지만 사용자에겐 불친절합니다.
  • 상황에 따라 메시지와 UI를 분리해야 합니다(예: 새로고침 유도, 변경 사항 비교 표시 등).

2) 재시도는 무조건 선이 아니다

재시도를 너무 공격적으로 걸면 같은 충돌이 반복되면서 트래픽이 폭증할 수 있습니다.

  • 짧은 랜덤 백오프
  • 최대 재시도 횟수 제한
  • 특정 도메인은 사용자 재시도로 전환
    같은 장치를 둬야 안정적입니다.

결론: 정답은 하나가 아니라, 충돌 확률과 도메인 위험도에 따라서 알맞게 선택하는것이다!

  • 낙관적 락은 가볍고 확장성에 유리하지만, 충돌을 “실패”로 처리해야 합니다.
  • 비관적 락은 무결성을 강하게 보장하지만, 대기/교착/처리량 저하를 동반합니다.
  • 재고/결제/쿠폰처럼 금전과 직결되는 곳은 비관적 락을 기본으로 두고, 트랜잭션을 짧게 만드는 설계가 핵심입니다.
  • 관리 화면 편집처럼 충돌이 드문 곳은 낙관적 락이 비용 대비 효과가 좋습니다.

현실에서는 둘 중 하나만 고집하기보다, 도메인별로 섞어서 적재적소에 사용하시면 됩니다.

728x90
반응형
그리드형