<JPA> 동시성 제어, 낙관적 락 vs 비관적 락
갱신 분실 문제
다중 사용자 환경에서 동작하는 애플리케이션을 개발할 때, 데이터 무결성을 지키기 위한 동시성 제어는 선택이 아닌 필수입니다. 여러 트랜잭션이 동시에 같은 데이터에 접근하여 수정할 때, 예상치 못한 문제가 발생할 수 있으며, 그중 가장 대표적인 것이 '갱신 분실(Lost Update)' 문제입니다.
갱신 분실은 두 개 이상의 트랜잭션이 동일한 데이터를 조회한 후, 각자의 연산을 수행하고 순차적으로 데이터를 갱신할 때 발생하는 데이터 불일치 현상입니다. 이로 인해 먼저 완료된 트랜잭션의 수정 사항이 나중에 완료된 트랜잭션에 의해 덮어씌워져 유실됩니다.
간단한 예시로 재고 관리 시나리오를 살펴보겠습니다.
- 관리자 A와 관리자 B가 동시에 현재 재고가 10개인 상품의 정보를 조회합니다.
- 관리자 A는 상품 2개를 판매하고, 재고를 8로 수정하여 데이터베이스에 반영(commit)합니다.
- 그사이 관리자 B는 상품 5개를 판매하고, 자신이 처음에 조회했던 재고 10개를 기준으로 계산하여 재고를 5로 수정하고 반영합니다.
- 최종 재고는 3(10 - 2 - 5)이 되어야 하지만, 관리자 B의 작업이 관리자 A의 작업을 덮어썼기 때문에 최종 재고는 5가 됩니다. 결과적으로 관리자 A의 갱신 내용은 사라졌습니다.
이러한 환경에서는 READ_COMMITTED
나 REPEATABLE_READ
같은 표준 트랜잭션 격리 수준(Isolation Level)만으로는 여러 요청에 걸쳐 발생하는 갱신 분실 문제를 막을 수 없습니다. 데이터베이스 락은 단일 트랜잭션 내에서만 유효하기 때문입니다. 바로 이 지점에서 애플리케이션 레벨의 동시성 제어 메커니즘이 필요하며, JPA는 이 문제를 해결하기 위한 두 가지 핵심 전략, 즉 비관적 락과 낙관적 락을 제공합니다.

본 글에서는 JPA가 제공하는 두 가지 잠금 방식의 개념과 동작 원리, 실제 구현 방법을 다루고, 어떤 상황에서 어떤 전략을 선택해야 하는지에 대한 가이드를 제공하고자 합니다.
비관적 락 (Pessimistic Locking)
비관적 락은 이름에서 알 수 있듯이, 데이터 경합이 빈번하게 발생할 것이라고 '비관적으로' 가정하고 접근하는 전략입니다. 이 방식은 데이터베이스가 제공하는 잠금 기능을 사용하여, 특정 데이터에 대한 동시 수정을 원천적으로 차단합니다. 한 트랜잭션이 데이터에 접근해 잠금을 획득하면, 다른 트랜잭션은 해당 잠금이 해제될 때까지 대기해야 합니다.

비관적 락의 핵심은 충돌을 예방하여 데이터 정합성을 최우선으로 보장하는 것입니다. JPA에서는 @Lock
어노테이션과 LockModeType
열거형을 통해 비관적 락을 구현할 수 있습니다.
LockModeType으로 JPA 구현 예제
JPA는 여러 비관적 락 모드를 제공하지만, 가장 핵심적인 두 가지는 다음과 같습니다.
LockModeType.PESSIMISTIC_WRITE
: 데이터에 배타적 잠금(Exclusive Lock)을 설정합니다. 이 잠금이 설정된 데이터는 다른 트랜잭션에서 읽기, 수정, 삭제가 모두 불가능합니다. 대부분의 데이터베이스에서 이 락은SELECT... FOR UPDATE
SQL 구문을 통해 구현되며, 데이터베이스 레코드에 직접적인 잠금을 설정합니다.LockModeType.PESSIMISTIC_READ
: 데이터에 공유 잠금(Shared Lock)을 설정합니다. 다른 트랜잭션에서 해당 데이터를 읽는 것은 허용하지만, 수정이나 삭제는 방지합니다. 공유 잠금을 획득한 여러 트랜잭션이 동시에 데이터를 읽을 수는 있습니다. 이 락은 데이터베이스에 따라SELECT... FOR SHARE
또는LOCK IN SHARE MODE
같은 구문으로 변환되며, 지원 여부나 동작 방식이 다를 수 있습니다.
한 가지 유의할 점은 PESSIMISTIC_WRITE
의 동작 방식이 데이터베이스의 동시성 제어 모델에 따라 달라질 수 있다는 것입니다. JPA 명세는 읽기, 쓰기, 삭제를 모두 막는다고 정의하지만, MVCC(Multi-Version Concurrency Control)를 사용하는 PostgreSQL이나 Oracle 같은 데이터베이스에서는 PESSIMISTIC_WRITE
락이 걸려있어도 다른 트랜잭션이 잠금이 걸리기 전의 커밋된 버전 데이터를 읽는 것을 허용할 수 있습니다. 즉, SELECT... FOR UPDATE
구문은 다른 트랜잭션의 쓰기(UPDATE, DELETE, SELECT FOR UPDATE)는 차단하지만, 일반적인 읽기(SELECT)는 차단하지 않을 수 있습니다.
코드 예제
JpaRepository
에서 상품 재고를 수정하기 위해 데이터를 조회할 때 비관적 락을 적용하는 예제는 다음과 같습니다.
public interface ProductRepository extends JpaRepository<Product, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from Product p where p.id = :id")
Optional<Product> findByIdForUpdate(@Param("id") Long id);
}
// 서비스 계층에서의 사용
@Transactional
public void decreaseStock(Long id, int quantity) {
// 이 메서드 호출 시 SELECT... FOR UPDATE 쿼리가 실행되어 row에 락이 걸린다.
Product product = productRepository.findByIdForUpdate(id)
.orElseThrow(EntityNotFoundException::new);
// 트랜잭션이 커밋될 때 락이 해제된다.
product.decreaseStock(quantity);
}
위 코드에서 findByIdForUpdate
메서드가 호출되면, 트랜잭션이 끝날 때까지 해당 Product
엔티티의 데이터베이스 로우는 잠기게 됩니다. 다른 트랜잭션이 동일한 상품의 재고를 동시에 수정하려고 시도하면, 먼저 시작된 트랜잭션이 완료될 때까지 대기 상태에 빠집니다.
장점과 단점, 그리고 잠금 타임아웃
비관적 락의 가장 큰 장점은 충돌을 사전에 방지하여 데이터의 일관성을 강력하게 보장한다는 점입니다. 하지만 단점도 명확합니다. 락으로 인해 동시 처리 성능이 저하될 수 있으며, 여러 트랜잭션이 서로 다른 순서로 락을 획득하려 할 때 교착 상태(Deadlock)에 빠질 위험이 있습니다.
이러한 단점을 보완하기 위해 잠금 대기 시간(Lock Timeout)을 설정하는 것이 중요합니다. JPA에서는 jakarta.persistence.lock.timeout
쿼리 힌트를 사용하여 특정 시간 이상 락을 기다리지 않도록 설정할 수 있습니다. 이를 통해 특정 트랜잭션이 무한정 대기하여 전체 시스템의 성능을 저하시키는 상황을 방지할 수 있습니다.
public interface ProductRepository extends JpaRepository<Product, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")})
Optional<Product> findByIdForUpdate(@Param("id") Long id);
}
위 예제는 락을 획득하기 위해 최대 3000ms(3초)까지 대기하고, 그 시간 안에 락을 얻지 못하면 LockTimeoutException
을 발생시킵니다.
다음은 JPA가 제공하는 비관적 락 모드를 정리한 표입니다.
LockModeType
|
설명 | 데이터베이스 락 유형 | 대표적인 SQL |
---|---|---|---|
PESSIMISTIC_WRITE
|
데이터에 대한 독점적인 쓰기 락을 획득. 다른 트랜잭션의 읽기, 수정, 삭제를 모두 방지. | Exclusive Lock (배타적 잠금) |
SELECT... FOR UPDATE
|
PESSIMISTIC_READ
|
데이터에 대한 공유 락을 획득. 다른 트랜잭션의 읽기는 허용하지만, 수정 및 삭제는 방지. | Shared Lock (공유 잠금) |
SELECT... FOR SHARE
|
PESSIMISTIC_FORCE_INCREMENT
|
PESSIMISTIC_WRITE 와 동일하게 동작하며, 추가로 엔티티의 @Version 필드를 강제로 증가시킴.
|
Exclusive Lock + Version Increment |
SELECT... FOR UPDATE
|
낙관적 락 (Optimistic Locking)
낙관적 락은 데이터 충돌이 거의 발생하지 않을 것이라고 '낙관적으로' 가정하는 전략입니다. 이 방식은 데이터를 읽을 때 잠금을 설정하지 않습니다. 대신, 데이터를 수정하는 시점에 다른 트랜잭션에 의해 데이터가 변경되지 않았는지 확인합니다. 이는 "일단 진행하고, 문제가 생기면 감지해서 처리하자"는 접근법입니다.

JPA 구현: @Version
JPA에서 낙관적 락은 @Version
어노테이션을 통해 매우 간단하게 구현할 수 있습니다. 엔티티에 @Version
어노테이션이 붙은 필드를 추가하면, JPA가 해당 필드를 버전 관리용으로 사용합니다. 이 필드는 주로 숫자 타입(Long
, Integer
)이나 타임스탬프(Timestamp
) 타입을 사용하며, 일반적으로는 숫자 타입이 권장됩니다.
@Entity
public class Product {
@Id @GeneratedValue
private Long id;
private String name;
private int stock;
@Version
private Long version; // 또는 Integer, Timestamp
public void decreaseStock(int quantity) {
if (this.stock < quantity) {
throw new IllegalStateException("Not enough stock");
}
this.stock -= quantity;
}
// Getters and Setters
}
@Version
필드의 동작 원리는 원자적 연산을 위한 Compare-And-Swap(CAS) 메커니즘과 유사합니다.
- 조회: 트랜잭션이 데이터를 조회할 때, 데이터의 내용과 함께 버전 정보(예:
version = 1
)를 함께 읽어옵니다. - 수정: 트랜잭션이 데이터를 수정한 후 커밋을 시도하면, JPA는
UPDATE
쿼리를 생성합니다. 이때WHERE
절에 조회 시점의 버전 정보를 조건으로 추가합니다. - 버전 비교 및 갱신: 생성되는 SQL은 다음과 같은 형태가 됩니다.
UPDATE product SET stock = 8, version = 2 WHERE id = 123 AND version = 1;
. - 충돌 감지: 만약 다른 트랜잭션이 먼저 커밋하여 버전이 이미 2로 변경되었다면, 위
UPDATE
쿼리의WHERE
절은 만족하는 로우를 찾지 못해 0개의 로우가 수정되었다는 결과를 반환합니다. - 예외 발생: JPA(Hibernate)는 수정된 로우의 개수가 0인 것을 감지하고, 데이터 충돌이 발생했다고 판단하여
OptimisticLockException
(Spring Data JPA에서는ObjectOptimisticLockingFailureException
으로 래핑될 수 있음)을 발생시키며 트랜잭션을 롤백합니다.
예외 처리와 재시도 로직의 중요성
낙관적 락을 사용할 때 가장 중요한 점은 OptimisticLockException
이 발생했을 때의 처리 전략입니다. 예외가 발생했다는 것은 현재 트랜잭션이 오래된 데이터를 기반으로 작업을 수행했음을 의미하므로, 해당 작업은 롤백되어야 합니다. 애플리케이션은 이 예외를 잡아서 사용자에게 충돌 사실을 알리고 작업을 다시 시도하도록 유도하거나, 내부적으로 재시도(retry) 로직을 구현해야 합니다.
@Service
public class ProductService {
//...
@Transactional
public void decreaseStock(Long productId, int quantity) {
try {
Product product = productRepository.findById(productId).orElseThrow();
product.decreaseStock(quantity);
// save()를 명시적으로 호출하지 않아도, 영속성 컨텍스트의 변경 감지(dirty checking)에 의해
// 트랜잭션 커밋 시점에 UPDATE 쿼리가 실행되고 버전 체크가 일어납니다.
} catch (ObjectOptimisticLockingFailureException e) {
// 재시도 로직 또는 사용자에게 알림
log.warn("Product {}'s stock was already updated by another transaction.", productId);
throw new ConcurrencyConflictException("재고가 다른 사용자에 의해 수정되었습니다. 다시 시도해주세요.");
}
}
}
장점과 단점
낙관적 락의 가장 큰 장점은 데이터베이스에 직접적인 락을 걸지 않기 때문에 높은 동시 처리 성능을 제공한다는 것입니다. 읽기 작업이 대부분인 시스템에서 뛰어난 확장성을 보이며, 데이터베이스 교착 상태가 발생하지 않습니다.
또한, @Version
메커니즘은 데이터베이스 세션이나 커넥션에 의존하지 않는 상태 비저장(stateless) 방식의 동시성 제어입니다. 이는 사용자가 데이터를 조회한 후 수정하여 최종적으로 저장하기까지 여러 번의 HTTP 요청이 발생하는 웹 애플리케이션 환경이나 마이크로서비스 아키텍처와 같이 데이터베이스 락을 유지하기 어려운 환경에 매우 적합합니다. 버전 정보는 데이터와 함께 DTO 등을 통해 클라이언트까지 전달되었다가 다시 서버로 돌아와 검증될 수 있기 때문에, 여러 트랜잭션과 서비스 호출에 걸친 비즈니스 로직의 일관성을 보장할 수 있습니다.
반면, 단점은 충돌이 발생했을 때의 비용이 크다는 점입니다. 충돌이 감지되면 트랜잭션 전체가 롤백되고, 애플리케이션 레벨에서 재시도 로직을 처리해야 합니다. 만약 충돌이 빈번하게 발생한다면, 반복적인 롤백과 재시도로 인해 오히려 비관적 락보다 성능이 저하될 수 있습니다.
한 가지 주의할 점은 JPQL을 이용한 벌크 업데이트(@Modifying @Query
)는 @Version
을 통한 자동 버전 관리를 우회한다는 것입니다. 이러한 쿼리는 영속성 컨텍스트를 거치지 않고 데이터베이스에 직접 실행되므로, 개발자가 직접 쿼리 내에 버전 조건을 추가하고 버전을 수동으로 증가시켜야 낙관적 락의 일관성을 유지할 수 있습니다.
헷갈릴만한 개념 바로잡기
개념 1: "비관적 락은 항상 느리고, 낙관적 락은 항상 빠르다."
성능은 '빠르다/느리다'의 이분법적 문제가 아니라, **충돌 빈도(contention level)**에 따라 달라지는 상대적인 개념입니다.
- 충돌이 거의 없는 환경(Low Contention): 낙관적 락이 더 빠릅니다. 락을 획득하고 대기하는 오버헤드가 없기 때문입니다.
- 충돌이 빈번한 환경(High Contention): 비관적 락이 더 효율적일 수 있습니다. 낙관적 락에서 충돌이 자주 발생하면, 반복적인 트랜잭션 롤백, 예외 처리, 그리고 애플리케이션 레벨에서의 재시도 비용이 락 대기 비용보다 커질 수 있습니다.
결론적으로 성능은 시나리오에 따라 달라지므로, 애플리케이션의 데이터 접근 패턴을 분석하여 적절한 전략을 선택해야 합니다.
개념 2: "기본 @Transactional
설정만으로 갱신 분실을 막을 수 있다."
@Transactional
어노테이션은 트랜잭션의 범위를 지정하지만, 그 자체만으로 모든 동시성 문제를 해결해주지는 않습니다.
- 격리 수준의 한계: 대부분의 데이터베이스에서 기본 격리 수준인
READ_COMMITTED
는 갱신 분실을 막지 못합니다.REPEATABLE_READ
나SERIALIZABLE
같은 높은 격리 수준은 단일 데이터베이스 트랜잭션 내에서는 갱신 분실을 방지할 수 있지만, 서론에서 언급했듯이 사용자의 조회와 수정 작업이 별개의 트랜잭션으로 분리되는 웹 환경의 갱신 분실은 막을 수 없습니다. - 명시적 잠금의 필요성:
@Lock
이나@Version
과 같은 명시적 잠금은 기본 격리 수준과 무관하게, 개발자가 특정 비즈니스 로직에 대해 더 강력한 일관성을 보장하기 위해 사용하는 도구입니다.
개념 3: @Version
vs LockModeType.OPTIMISTIC
@Version
이 낙관적 락의 가장 일반적인 구현이지만, JPA는 LockModeType.OPTIMISTIC
라는 도구도 제공합니다. 이에 대한 설명은 아래와 같습니다.
- 역할 : 데이터를 조회하는 시점에 "이 트랜잭션이 끝날 때까지 이 데이터가 다른 곳에서 수정되지 않았음을 보장해줘"라고 요청하는 것입니다.
- 작동 방식
- 엔티티를 조회(SELECT)할 때 이 락 모드를 사용합니다.
- 해당 트랜잭션이 커밋되는 시점에, 조회했던 엔티티의 버전이 데이터베이스에서 변경되었는지 다시 확인합니다.
- 만약 버전이 변경되었다면, 비록 내 트랜잭션에서 데이터를 수정하지 않았더라도 OptimisticLockException 예외를 발생시킵니다.
- 핵심: 데이터를 수정하지 않고 읽기만 해도 트랜잭션 범위 내에서 데이터의 일관성을 보장받고 싶을 때 사용합니다. 이를 반복 불가능한 읽기(Non-Repeatable Read)를 방지하는 효과라고 합니다
@Version과 차이점 이해하기
긴 시간 동안 진행되는 트랜잭션이 있다고 가정해 봅시다.
- 트랜잭션 A 시작: 상품 ID가 1이고 버전이 0인 Product 엔티티를 조회만 합니다.
- 트랜잭션 B 개입: 트랜잭션 A가 끝나기 전에, 다른 사용자가 이 상품을 구매하여 Product 데이터가 수정되고 버전이 1로 증가하며 커밋됩니다.
- 트랜잭션 A 커밋:
– @Version만 있는 경우: 트랜잭션 A는 데이터를 수정하지 않았으므로 아무런 버전 체크 없이 정상적으로 커밋됩니다. 하지만 트랜잭션 A가 처음 읽었던 데이터는 이미 '오래된(stale)' 데이터가 되어버렸습니다.– LockModeType.OPTIMISTIC으로 조회한 경우: 트랜잭션 A가 커밋될 때, 처음 읽었던 버전(0)과 현재 DB의 버전(1)이 다른 것을 JPA가 감지하고 OptimisticLockException을 발생시킵니다. 즉, 조회했던 데이터의 일관성이 깨졌음을 알려줍니다.
개념 4: "비관적 락은 자바 객체를 잠근다."
이는 근본적인 오해입니다. 비관적 락은 JVM 힙 메모리에 있는 자바 객체를 잠그는 것이 아닙니다. 잠금의 주체는 데이터베이스 트랜잭션이며, 잠기는 대상은 데이터베이스 테이블의 로우(row) 입니다. JPA의 @Lock
어노테이션은 단지 특정 로우에 데이터베이스 수준의 락을 걸도록 요청하는 명령일 뿐입니다. 이 차이를 이해하는 것은 락의 범위와 생명주기를 정확히 파악하는 데 매우 중요합니다.
언제, 무엇을 선택할 것인가?
이 둘 사이에서 선택의 기준이 되는 핵심 질문은 "여러 사용자가 동일한 데이터를 동시에 수정하려고 시도할 가능성이 얼마나 높은가?"입니다.
비관적 락이 적합한 경우
충돌이 자주 발생할 것으로 예상되거나, 충돌 발생 시 비즈니스적으로 큰 손실이 발생하는 경우에 적합합니다. 데이터의 일관성이 동시성보다 훨씬 중요할 때 선택해야 합니다.
- 충돌이 빈번하고 예상될 때: 여러 사용자가 한정된 자원을 놓고 경쟁하는 상황.
- 충돌 비용이 매우 높을 때: 금전적 거래와 같이 한 번의 실수도 용납되지 않는 경우.
- 대표 사례:
– 재고 관리 시스템: 인기 상품의 재고 차감.– 좌석 예약 시스템: 항공권, 공연 티켓 등 고유한 좌석 예약.– 금융 시스템: 계좌 이체, 출금 등.
낙관적 락이 적합한 경우
충돌 발생 가능성이 낮고, 대부분의 작업이 읽기 위주일 때 적합합니다. 시스템의 확장성과 높은 처리량이 중요할 때 좋은 선택입니다.
- 충돌이 드물 때: 사용자들이 서로 다른 데이터를 수정하는 경우가 대부분인 상황.
- 읽기 작업이 대부분일 때: 쓰기 작업보다 읽기 작업의 비중이 압도적으로 높은 시스템.
- 여러 요청에 걸친 비즈니스 로직: 사용자가 데이터를 조회하고 수정하기까지 여러 단계의 요청과 응답이 오가는 웹 애플리케이션 환경.
- 대표 사례:
– 상품 정보(설명, 이름 등) 수정: 여러 관리자가 동시에 같은 상품의 재고가 아닌 설명을 수정할 확률은 낮습니다.– 사용자 정보 변경: 사용자는 보통 자신의 정보만 수정합니다.– 위키 또는 문서 협업 도구: 충돌이 발생하면 사용자에게 알리고 병합을 유도하는 방식.
다음 표는 두 잠금 전략의 핵심적인 특징을 비교하여 의사결정을 돕습니다.
특징 | 비관적 락 (Pessimistic Locking) | 낙관적 락 (Optimistic Locking) |
---|---|---|
핵심 철학 | 충돌은 발생할 것이므로, 미리 막는다 (Prevent) | 충돌은 드물 것이므로, 발생하면 감지한다 (Detect) |
잠금 시점 | 데이터 조회 시 (Read Time) | 데이터 수정 시 (Write/Commit Time) |
잠금 주체 | 데이터베이스 트랜잭션 (Database Transaction) | 애플리케이션 (Application Logic) |
구현 방식 |
DB 락 (SELECT FOR UPDATE )
|
버전 컬럼 (@Version )
|
성능 | 충돌이 잦을 때 유리, 평상시엔 락 대기로 인한 성능 저하 | 충돌이 드물 때 유리, 충돌 시 롤백 및 재시도 비용 발생 |
교착 상태 (Deadlock) | 발생 가능성 있음 | 발생하지 않음 |
적합한 환경 | 충돌이 빈번한 시스템 (High Contention) | 읽기 위주의 시스템 (Low Contention) |
대표 사례 | 재고 관리, 좌석 예약, 금융 거래 | 상품 정보 수정, 사용자 정보 변경 |
결론
JPA의 비관적 락과 낙관적 락은 동시성 문제를 해결하기 위한 강력한 도구이지만, 어느 하나가 절대적으로 우월한 '만능 해결책'은 없습니다. 비관적 락은 충돌을 방지하는 데 초점을 맞추고, 낙관적 락은 충돌을 감지하는 데 중점을 둡니다.
성숙한 애플리케이션은 종종 두 가지 전략을 함께 사용하는 하이브리드 접근법을 취합니다. 예를 들어, 상품 정보 조회나 장바구니 담기 같은 일반적인 작업에는 낙관적 락을 사용하고, 최종 결제 단계에서 실제 재고를 차감하는 결정적인 작업에만 비관적 락을 적용할 수 있습니다.
궁극적으로 최선의 선택은 애플리케이션의 데이터 접근 패턴과 비즈니스 요구사항에 대한 깊은 이해에서 비롯됩니다. 각 유스케이스를 분석하고, 데이터 충돌 가능성과 그로 인한 비용을 신중하게 평가하여 가장 적합한 도구를 선택하는 것이 중요합니다. 이처럼 신중하게 설계된 잠금 전략은 데이터 무결성을 보장하고, 안정적이며 확장 가능한 시스템을 구축하는 데 핵심적인 역할을 합니다.