JPA 동시성 이슈 해결: 낙관적 락 vs 비관적 락 완벽 비교 및 실무 가이드

JPA 동시성 이슈 해결: 낙관적 락 vs 비관적 락 완벽 비교 및 실무 가이드

1. 서론

백엔드 개발자가 로컬 환경에서 혼자 개발하고 테스트할 때는 절대 마주칠 수 없는, 하지만 운영 환경에 배포되자마자 서비스의 신뢰도를 바닥으로 떨어뜨리는 무서운 문제가 있습니다. 바로 ‘동시성 이슈(Concurrency Issue)‘입니다. 가장 대표적인 예가 ‘재고 관리 시스템‘입니다.

재고가 딱 1개 남은 인기 상품이 있다고 가정해 봅시다. 이 상품을 구매하기 위해 사용자 A와 사용자 B가 0.001초의 차이로 동시에 ‘구매’ 버튼을 눌렀습니다.

  1. 사용자 A의 요청이 서버에 도착하여 재고를 조회합니다. (현재 재고: 1)
  2. 아주 미세한 차이로 사용자 B의 요청도 도착하여 재고를 조회합니다. (현재 재고: 1)
  3. 사용자 A의 스레드가 재고를 1 차감하고 저장합니다. (재고: 0, 구매 성공)
  4. 사용자 B의 스레드도 (아까 조회한 1을 기준으로) 재고를 1 차감하고 저장합니다. (재고: 0, 구매 성공)

결과는 어떻게 될까요? 재고는 1개였는데, 2명에게 판매가 완료되는 ‘초과 판매(Overselling)’ 사태가 발생합니다. 심지어 로직이 꼬이면 재고가 마이너스가 되기도 합니다. 이는 데이터베이스의 데이터 정합성이 깨진 상태이며, 비즈니스적으로는 고객 컴레임과 금전적 손실을 초래하는 치명적인 버그입니다.

이러한 경쟁 상태(Race Condition)를 해결하기 위해 우리는 ‘락(Lock)‘이라는 기술을 사용해야 합니다. JPA는 이 문제를 해결하기 위해 크게 두 가지 접근 방식을 제공합니다. “충돌이 안 날 거야”라고 믿고 처리하는 낙관적 락(Optimistic Lock)과, “충돌이 무조건 날 거야”라고 가정하고 막아버리는 비관적 락(Pessimistic Lock)입니다. 오늘은 이 두 가지 락의 동작 원리와 장단점, 그리고 실무에서는 어떤 상황에 무엇을 선택해야 하는지 명쾌하게 정리해 드리겠습니다.


2. 본론

1. 낙관적 락 (Optimistic Lock) – 애플리케이션 레벨의 버전 관리

낙관적 락은 이름 그대로 “대부분의 트랜잭션은 충돌하지 않을 것이다”라고 낙관적으로 가정하는 방식입니다. 엄밀히 말하면 이는 데이터베이스의 락 기능(Locking)을 사용하는 것이 아니라, 애플리케이션 레벨에서 버전을 관리하여 충돌을 감지하는 논리적인 락입니다.

1. 동작 원리: @Version과 스냅샷

낙관적 락을 구현하기 위해 JPA는 엔티티 내부에 @Version 어노테이션이 붙은 필드(주로 Long 또는 Integer)를 사용합니다. 동작 메커니즘은 다음과 같습니다.

  1. 조회: 트랜잭션 A가 데이터를 읽을 때 버전 정보(예: version = 1)도 함께 읽어옵니다.
  2. 수정: 메모리에서 데이터를 수정합니다.
  3. 커밋(업데이트): 트랜잭션 A가 커밋하면서 UPDATE 쿼리를 날릴 때, WHERE 절에 버전 조건을 추가합니다.UPDATE Item SET stock = stock – 1, version = version + 1 WHERE id = 1 AND version = 1
  4. 검증: 만약 그사이에 트랜잭션 B가 데이터를 수정해서 버전을 2로 올려버렸다면? 트랜잭션 A가 날린 쿼리의 WHERE version = 1 조건이 맞지 않아 수정된 행(Row)의 개수가 0이 됩니다.
  5. 예외 발생: JPA는 “어? 내가 읽은 버전이랑 DB 버전이 다르네?”라고 판단하고 ObjectOptimisticLockingFailureException 예외를 터뜨리며 트랜잭션을 롤백시킵니다.

2. 장점과 단점

  • 장점: 데이터베이스에 물리적인 락(Lock)을 걸지 않으므로 성능이 매우 좋습니다. 트랜잭션 격리 수준을 낮추면서도 데이터 정합성을 유지할 수 있어 처리량(Throughput)이 높습니다.
  • 단점: 충돌이 발생했을 때 시스템이 예외를 던져버립니다. 즉, 개발자가 직접 예외를 잡아서 재시도(Retry)하는 로직을 구현해야 합니다. 충돌이 빈번하게 일어나는 환경에서는 재시도 처리 비용 때문에 오히려 성능이 떨어질 수 있습니다.

3. 실무 구현 코드

엔티티에 필드 하나만 추가하면 됩니다.

Java

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

    private int stock;

    @Version // 낙관적 락 적용
    private Long version;
}

2. 비관적 락 (Pessimistic Lock) – 데이터베이스 레벨의 강력한 잠금

비관적 락은 “데이터를 수정하는 동안 누군가 건드릴 수 있다”고 비관적으로 가정하고, 데이터를 읽어올 때부터 아예 물리적인 락(Lock)을 걸어서 다른 트랜잭션의 접근을 차단하는 방식입니다. 이는 데이터베이스가 제공하는 기능(주로 SELECT ... FOR UPDATE)을 사용합니다.

1. 동작 원리: 배타 락(Exclusive Lock)

JPA에서 비관적 락을 걸면, 내부적으로 SQL의 FOR UPDATE 구문이 실행됩니다.

  1. 조회 및 잠금: 트랜잭션 A가 findByWithPessimisticLock을 호출하면 DB는 해당 행(Row)에 락을 겁니다. (SELECT * FROM Product WHERE id = 1 FOR UPDATE)
  2. 대기: 뒤이어 들어온 트랜잭션 B가 동일한 데이터를 조회하려고 하면, 트랜잭션 A가 락을 풀 때(커밋 또는 롤백)까지 무한정 대기하거나 타임아웃 시간만큼 기다려야 합니다.
  3. 처리: 트랜잭션 A가 작업을 마치고 커밋하면 락이 해제되고, 대기하던 트랜잭션 B가 락을 획득하여 작업을 시작합니다.

2. JPA LockModeType 종류

  • PESSIMISTIC_WRITE (대표적): 일반적인 비관적 락입니다. 쓰기 락(Exclusive Lock)을 걸어 다른 트랜잭션이 읽지도, 쓰지도 못하게 합니다.
  • PESSIMISTIC_READ: 공유 락(Shared Lock)을 겁니다. 다른 트랜잭션이 읽을 수는 있지만 수정은 못 하게 합니다. 잘 사용되지 않습니다.
  • PESSIMISTIC_FORCE_INCREMENT: 비관적 락을 걸면서 동시에 @Version 정보도 강제로 증가시킵니다.

3. 장점과 단점

  • 장점: 충돌 발생을 원천 차단하므로 데이터 정합성이 100% 보장됩니다. 낙관적 락처럼 재시도 로직을 작성할 필요가 없습니다.
  • 단점: 락을 획득할 때까지 대기해야 하므로 성능 저하가 발생할 수 있습니다. 특히 서로 다른 자원을 점유하려다가 서로를 기다리는 데드락(Deadlock) 상황에 빠질 위험이 있어 쿼리 순서 관리에 주의해야 합니다.

4. 실무 구현 코드

리포지토리 메서드에 어노테이션만 붙이면 됩니다.

Java

public interface ProductRepository extends JpaRepository<Product, Long> {
    
    @Lock(LockModeType.PESSIMISTIC_WRITE) // 비관적 쓰기 락
    @Query("select p from Product p where p.id = :id")
    Optional<Product> findByIdWithLock(@Param("id") Long id);
}

3. 실무에서의 선택 기준, 언제 무엇을 써야 할까?

이것이 면접과 실무 아키텍처 설계의 핵심입니다. 두 방식은 상호 배타적인 것이 아니라 상황에 따라 적절한 도구를 꺼내 써야 합니다.

1. 충돌이 거의 없는 경우 (읽기 > 쓰기): 낙관적 락 권장

대부분의 웹 애플리케이션, 예를 들어 게시판 수정, 사용자 프로필 업데이트, 위키 문서 편집 등의 상황입니다.

이런 경우 동시에 같은 글을 수정할 확률은 매우 낮습니다. 굳이 DB 락을 걸어 성능을 떨어뜨릴 필요 없이 낙관적 락을 적용하는 것이 효율적입니다. “누군가 먼저 수정했습니다. 다시 시도해 주세요.”라는 메시지를 띄워주는 것만으로도 충분합니다.

2. 충돌이 빈번한 경우 (쓰기 집중): 비관적 락 권장

선착순 이벤트, 티켓 예매, 재고 차감, 포인트 사용과 같이 동시에 수많은 요청이 몰리는 비즈니스 핵심 로직입니다.

여기서 낙관적 락을 쓰면 1000명이 동시에 요청했을 때 1명만 성공하고 999명은 예외가 터져서 롤백됩니다. 이 999명이 재시도 요청을 보내면 서버는 더 큰 부하를 받게 됩니다. 차라리 줄을 세우는(Locking) 비관적 락이 전체적인 처리량 관점에서는 더 낫습니다.

3. 분산 환경과 Redis의 필요성

하지만 비관적 락은 DB 커넥션을 점유하는 시간이 길다는 치명적인 단점이 있습니다. 만약 로직이 복잡하거나 외부 API를 호출해야 한다면 DB 락을 잡고 있는 시간이 길어져 전체 시스템 장애로 이어질 수 있습니다.

이러한 한계를 극복하기 위해 대규모 트래픽 환경(MSA 등)에서는 DB 락 대신 Redis를 이용한 분산 락(Distributed Lock)을 주로 사용합니다. Redisson 라이브러리 등을 이용해 메모리 DB인 Redis에서 락을 관리하면 DB 부하를 줄이면서도 동시성 문제를 효과적으로 해결할 수 있습니다.


3. 결론

지금까지 JPA에서 동시성 문제를 해결하는 두 가지 무기인 낙관적 락과 비관적 락에 대해 상세히 알아보았습니다.

요약하자면, 낙관적 락은 @Version을 이용한 논리적인 락으로 성능이 중요하고 충돌 가능성이 낮은 곳에 적합하며, 예외 발생 시 재시도 로직(Facade 패턴 등)이 필수적입니다. 반면 비관적 락은 SELECT FOR UPDATE를 이용한 물리적인 락으로 데이터 정합성이 생명이고 충돌이 잦은 곳에 적합하지만, 데드락과 성능 저하에 유의해야 합니다.

개발자로서 가장 중요한 태도는 “무조건 비관적 락이 안전하니까 이걸 쓰자”라는 안일한 생각이 아니라, 내 서비스의 트래픽 특성과 비즈니스 중요도를 분석하여 최적의 전략을 선택하는 것입니다. 때로는 낙관적 락으로, 때로는 비관적 락으로, 그리고 규모가 커지면 Redis 분산 락으로 유연하게 대처할 수 있는 능력이 여러분을 고수준의 백엔드 엔지니어로 만들어줄 것입니다.


관련 포스팅 : Redis와 Redisson을 활용한 분산 락(Distributed Lock) 구현 가이드

댓글 남기기