동시성 제어를 위한 DB 락에 대해 알아보자

2026. 7. 11. 15:55·Java/JPA

이번에 컨텐츠를 사고팔 수 있는 커머스 서비스를 개발하다보니 동시에 일어난 결제를 처리하는 방법으로 락에 대해서 공부하게 되었다. 내용을 복습할 겸 블로그에 정리해보고자 한다.

 

1. 락이란?

웹 서비스는 여러 사용자의 요청을 동시에 처리한다. 조회 API나 데이터 정합성이 덜 중요한 데이터라면 큰 문제가 없을 수 있지만, 잔액 차감, 재고 감소처럼 정합성에 민감한 데이터에서는 동시 요청이 치명적일 수 있다.

 

예를 들어 사용자 A의 크레딧 잔액이 10,000원이고, 동시에 두 개의 구매 요청이 들어왔다고 가정해보자.

첫 번째 요청은 7,000원을 차감하려고 하고, 두 번째 요청도 7,000원을 차감하려고 한다. 둘 다 거의 동시에 잔액을 조회하면 두 요청 모두 “현재 잔액은 10,000원이므로 구매 가능하다”고 판단할 수 있다. 이후 각각 7,000원을 차감하면 실제로는 14,000원이 사용된 셈인데, 시스템에서는 둘 다 성공 처리될 수 있다. 이런 문제가 대표적인 동시성 문제다.

 

동시성 문제는 주로 다음과 같은 상황에서 발생한다.

  • 같은 행을 동시에 읽고 수정하는 경우
  • 읽은 값을 기준으로 계산한 뒤 다시 저장하는 경우
  • 중복 요청이 거의 동시에 들어오는 경우
  • 하나의 비즈니스 로직에서 여러 행을 함께 수정하는 경우
  • 트랜잭션 격리 수준과 애플리케이션의 기대 동작이 맞지 않는 경우

이 문제를 막기 위해 사용하는 대표적인 방법이 락(Lock) 이다.

락은 다른 트랜잭션들에게 이미 사용중이라고 표시하는 장치다. 락을 걸면 같은 데이터를 동시에 수정하려는 요청을 하나씩 처리할 수 있다.

 

1-1. 낙관적 락과 비관적 락

락은 크게 낙관적 락(Optimistic Lock) 과 비관적 락(Pessimistic Lock) 으로 나눌 수 있다.

낙관적 락

낙관적 락은 이름 그대로 충돌이 자주 발생하지 않을 것이라고 보고 처리하는 방식이다.

보통 엔티티에 version 컬럼을 두고, 데이터를 수정할 때 내가 읽었던 version과 현재 DB의 version이 같은지 확인한다. 만약 다른 트랜잭션이 먼저 수정해서 version이 바뀌었다면 업데이트에 실패시키고 예외를 발생시킨다.

JPA에서 낙관적 락은 엔티티에 @Version 필드를 추가하는 것만으로 적용된다.

@Entity
public class User {

    @Id @GeneratedValue
    private Long id;

    private int creditBalance;

    @Version
    private Long version;

    public void decreaseCredit(int amount) {
        if (creditBalance < amount) throw new IllegalArgumentException();
        creditBalance -= amount;
    }
}

 

핵심은 @Version이다. JPA는 update 시점에 version을 조건으로 포함한다.

update users
set credit_balance = ?, version = ?
where id = ? and version = ?;

 

두 트랜잭션이 동시에 version = 1을 읽었다고 가정하면, 먼저 커밋한 쪽만 성공하고 나머지는 update row 수가 0이 되어 예외가 발생한다. (OptimisticLockException)

즉, 조회 시점에는 락을 걸지 않고, 수정 시점에 충돌을 감지한다.

@Transactional
public void purchase(Long userId, int price) {
    User user = userRepository.findById(userId).orElseThrow();
    user.decreaseCredit(price);
}

 

충돌이 발생할 수 있으므로 필요하면 외부에서 재시도를 추가해준다.

public void purchaseWithRetry(Long userId, int price) {
    for (int i = 0; i < 3; i++) {
        try {
            purchase(userId, price);
            return;
        } catch (ObjectOptimisticLockingFailureException e) {
            if (i == 2) throw e;
        }
    }
}

 

비관적 락

비관적 락은 충돌이 발생할 가능성이 높다고 보고, 데이터를 읽는 시점부터 실제 DB 락을 거는 방식이다.

장점은 동시 수정 상황에서 데이터 정합성을 강하게 보장할 수 있다는 점이다. 단점은 락을 잡는 동안 다른 트랜잭션이 기다려야 하므로 처리량이 줄어들 수 있고, 여러 데이터를 서로 다른 순서로 잠그면 교착 상태가 발생할 수 있다는 점이다.

JPA에서는 Repository 메서드에 @Lock을 사용해 비관적 락을 적용한다.

public interface UserRepository extends JpaRepository<User, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select u from User u where u.id = :id")
    Optional<User> findByIdForUpdate(@Param("id") Long id);
}

 

PESSIMISTIC_WRITE는 조회 시점에 쓰기 락을 걸며, DB에서는 다음과 같이 실행된다.

select * from users where id = 1 for update;

 

해당 row는 트랜잭션 종료까지 잠기며, 다른 트랜잭션은 대기한다.

서비스에서는 락이 걸린 상태로 안전하게 수정한다.

@Transactional
public void purchase(Long userId, int price) {
    User user = userRepository.findByIdForUpdate(userId)
            .orElseThrow();

    user.decreaseCredit(price);
}

 

동시에 요청이 들어오면 한 트랜잭션이 먼저 처리되고, 나머지는 대기 후 최신 데이터를 기준으로 실행된다. 따라서 중복 차감 문제가 발생하지 않는다.

 

정리해보자면, 낙관적 락은 충돌이 자주 발생하지 않을 것을 염두하고 적용시키는 락이고, 비관적 락은 충돌이 자주 발생할 것이라고 예상하고 적용시키는 락이다. 만약 낙관적 락에서 충돌이 발생하게 된다면 데이터 수정이 실패하게 되고, 재시도 로직을 통해 다시 데이터 조회, 수정을 하게 된다. 이 과정에서 조회, 쿼리, 커넥션 점유 시간이 낭비되고 충돌이 다시 발생하면 이를 무한 반복할 수도 있다. 이런 이유 때문에 낙관적 락과 비관적 락을 구분해서 사용한다.

 

1-2. 트랜잭션 격리 수준

락을 이해하려면 트랜잭션 격리 수준도 함께 봐야 한다.

트랜잭션 격리 수준은 동시에 실행되는 트랜잭션들이 서로의 변경 내용을 어느 정도까지 볼 수 있는지 정하는 기준이다. 격리 수준이 높을수록 정합성은 강해지지만 동시 처리 성능은 떨어질 수 있고, 격리 수준이 낮을수록 동시성은 좋아지지만 개발자가 직접 정합성을 더 신경 써야 한다.

대표적인 격리 수준은 다음과 같다.

  • READ UNCOMMITTED: 커밋되지 않은 변경도 읽을 수 있다.
  • READ COMMITTED: 커밋된 데이터만 읽을 수 있다.
  • REPEATABLE READ: 같은 트랜잭션 안에서 같은 데이터를 다시 읽으면 같은 결과를 보장하려고 한다.
  • SERIALIZABLE: 가장 강한 격리 수준으로, 트랜잭션을 직렬 실행한 것처럼 보장한다.

MySQL InnoDB의 기본 격리 수준은 일반적으로 REPEATABLE READ다. 이 방식은 같은 트랜잭션 안에서 처음 읽은 시점의 스냅샷을 유지한다. 이 특성은 일반 조회에서는 장점이지만, 동시 요청 처리 후 “다시 조회해서 최신 커밋 결과를 확인해야 하는 로직”에서는 예상과 다른 결과를 만들 수 있다. 이 문제가 내가 개발하던 커머스 서비스에서 발생했다.

 

2. 발생한 문제

2-1. 이전 데이터 반영 문제

문제 개요

위 문제는 크레딧 충전 기능에서 발생했다.

크레딧 충전은 결제 성공 이력을 저장하고, 사용자의 크레딧 잔액을 증가시키며, 크레딧 거래 이력을 남기는 흐름으로 동작한다.

문제는 같은 사용자에 대한 충전 요청이 거의 동시에 들어왔을 때 발생했다.

문제 파악

테스트에서 사용자의 현재 크레딧 잔액이 0이고, 10,000 크레딧 충전 요청이 동시에 두 번 들어왔다고 설정했다.

두 요청은 각각 사용자 정보를 조회하고, 조회한 사용자 객체의 잔액을 기준으로 크레딧을 증가시키려고 했다.

이때 트랜잭션 A와 트랜잭션 B가 거의 동시에 실행되면서 둘 다 같은 시점의 사용자 잔액을 조회했다. A는 잔액 0을 기준으로 10,000을 더해 저장했고, B도 자신이 먼저 조회해둔 잔액 0을 기준으로 10,000을 더해 저장했다. 그 결과 두 번의 충전 요청이 모두 처리되었음에도 최종 잔액은 20,000이 아니라 10,000으로 남는 문제가 발생했다.

 

즉, 나중에 커밋된 트랜잭션이 앞선 트랜잭션의 변경 결과를 반영하지 못하고, 자신이 처음 조회했던 스냅샷 기준으로 값을 저장하면서 앞선 변경 내용을 덮어쓴 것이다. 이 문제는 같은 데이터를 동시에 읽고 수정하는 과정에서 발생한 동시성 문제였다.

해결방법

이를 해결하기 위해 READ COMMITTED를 도입했다. READ COMMITTED는 스냅샷을 저장하여 재사용하지 않고 커밋한 데이터를 매번 조회한다. 동시에 JPA를 사용하고 있다면 fetch join을 조심해야 한다. fetch join은 영속성 컨텍스트에 필요한 엔티티를 미리 불러오기 때문에 READ COMMITTED와 비슷하게 락을 적용했음에도 이전 데이터가 그대로 반영될 수 있다.

@Transactional(isolation = Isolation.READ_COMMITTED)
public PaymentResponse charge(...) {
    ...
}

 

2-2. 데드락 문제

문제 개요

콘텐츠 구매는 구매자의 크레딧을 차감하고, 창작자의 크레딧을 증가시키는 과정으로 이루어진다. 이 과정에서 하나의 트랜잭션에서 구매자와 창작자 두 사용자의 잔액을 함께 수정하는 동시성 테스트 중 두 개 이상의 트랜잭션이 서로가 점유한 락이 해제되기를 기다리면서 작업을 진행하지 못하는 데드락 문제가 발생했다.

문제 파악

구매 로직에서는 구매자와 창작자의 User 엔티티를 모두 수정하기 위해 비관적 락을 사용했다. 하지만 트랜잭션마다 락을 획득하는 순서가 달라질 수 있었다.

예를 들어

  • 트랜잭션 A : User1 → User2 순서로 락 획득
  • 트랜잭션 B : User2 → User1 순서로 락 획득

과 같이 서로 반대 순서로 락을 획득하면 각각 상대방이 가진 락을 기다리게 되어 데드락이 발생할 수 있다.

해결 방법

이를 해결하기 위해 락을 획득하는 순서를 사용자 ID 기준으로 통일했다.

구매자와 창작자 중 ID가 작은 사용자를 먼저 조회하여 락을 획득하고, 이후 ID가 큰 사용자의 락을 획득하도록 변경했다. 모든 트랜잭션이 동일한 순서로 락을 획득하도록 하여 순환 대기(Circular Wait)가 발생하지 않도록 했다. 이러한 방법을 락 순서 고정(Lock Ordering)이라 한다.

Long firstLockId = Math.min(buyerId, creatorId);
Long secondLockId = Math.max(buyerId, creatorId);

User firstLocked = lockUser(firstLockId);
User secondLocked = lockUser(secondLockId);

User buyer = buyerId.equals(firstLockId)
        ? firstLocked
        : secondLocked;

User creator = creatorId.equals(firstLockId)
        ? firstLocked
        : secondLocked;

 

락 획득 순서를 통일한 이후 구매 요청이 동시에 발생해도 모든 트랜잭션이 동일한 순서로 User 행을 잠그기 때문에 데드락이 발생하지 않았다. 구매자의 크레딧 차감, 창작자의 크레딧 증가가 하나의 트랜잭션에서 정상적으로 처리되었으며, 데드락 없이 데이터 정합성을 유지할 수 있었다.

 

3. 다른 방법

검색해보니 실무에서는 원자적 UPDATE를 많이 사용한다고 한다.

원자적 UPDATE란, 하나의 UPDATE SQL 쿼리를 통해 조회와 수정 모두를 처리하는 방법이다.

 

사실 JPA에서 save()를 한다면 실제로는

SELECT * FROM users WHERE id = 1;

UPDATE users
SET credit = 10000
WHERE id = 1;

 

이렇게 쿼리가 나간다.

즉, 조회, 연산, 수정이 분리되어 있으므로 성능이 떨어지고 동시성 제어를 하기 위해선 락을 설정해줘야 한다.

 

하지만 원자적 UPDATE를 한다면 

UPDATE users
SET credit = credit + 10000
WHERE id = 1;

 

이런식으로 하나의 쿼리로 해결 가능하기 때문에 성능이 좋을 뿐만 아니라, UPDATE 자체가 row Lock을 걸어버리기 때문에 동시성 문제도 방지할 수 있다.

 

그러나 원자적 UPDATE의 단점도 있다.

UPDATE 한 문장으로 처리할 수 없는 경우 사용할 수 없고, JPA를 사용하는 경우 Repository에 UPDATE 쿼리를 직접 작성해야한다. 이런식으로 변경감지를 통한 UPDATE 쿼리가 아닌 직접 작성한 UPDATE 쿼리를 Bulk UPDATE라 한다.

public interface UserRepository extends JpaRepository<User, Long> {

    @Modifying
    @Query("""
        UPDATE User u
        SET u.credit = u.credit + :amount
        WHERE u.id = :id
    """)
    int charge(Long id, int amount);
}

 

Bulk UPDATE의 경우 영속성 컨텍스트를 거치지 않고 데이터베이스에 직접 UPDATE 쿼리를 실행한다.

일반적인 JPA 수정은 엔티티를 조회한 뒤 영속성 컨텍스트에서 값을 변경하고, 변경 감지를 통해 UPDATE 쿼리를 실행한다. 반면 Bulk UPDATE는 엔티티를 조회하지 않고 JPQL UPDATE 문을 바로 실행한다.

 

Bulk UPDATE 방식은 조회 없이 데이터를 수정할 수 있고, 여러 행도 하나의 쿼리로 변경할 수 있다는 장점이 있다. 그러나 데이터베이스만 변경하고 영속성 컨텍스트에 저장된 엔티티의 상태는 변경하지 않는다.

 

예를 들어 다음과 같이 사용자를 먼저 조회한 뒤 Bulk UPDATE를 실행했다고 가정해보자.

User user = userRepository.findById(userId)
        .orElseThrow();

userRepository.charge(userId, 10_000);

System.out.println(user.getCredit());

 

사용자의 기존 크레딧이 0이었다면 데이터베이스에는 10,000이 저장되지만, 이미 영속성 컨텍스트에 저장된 user 객체의 크레딧은 여전히 0이다.

 

이 상태에서 해당 엔티티를 기준으로 거래 이력을 생성하면 변경 전 잔액이 기록될 수 있다. 또한 이후 엔티티의 다른 필드가 변경되어 변경 감지가 실행되면, 영속성 컨텍스트에 남아 있던 이전 크레딧 값이 데이터베이스에 다시 반영될 가능성도 있다.

 

이 문제를 방지하려면 Bulk UPDATE 실행 전 변경 내용을 데이터베이스에 반영하고, 실행 후 영속성 컨텍스트를 초기화해야 한다.

@Modifying(
    flushAutomatically = true,
    clearAutomatically = true
)
@Query("""
    UPDATE User u
    SET u.credit = u.credit + :amount
    WHERE u.id = :id
""")
int charge(Long id, int amount);

 

flushAutomatically는 영속성 컨텍스트에 남아 있는 변경 내용을 Bulk UPDATE 실행 전에 데이터베이스에 반영한다.

clearAutomatically는 Bulk UPDATE 실행 후 영속성 컨텍스트를 초기화하여, 이후 조회 시 데이터베이스의 값을 다시 가져오도록 한다.

 

이런 설정 없이 아래와 같이 직접 처리할 수도 있다.

entityManager.flush();
userRepository.charge(userId, amount);
entityManager.clear();

User updatedUser = userRepository.findById(userId)
        .orElseThrow();

 

따라서 Bulk UPDATE를 JPA와 함께 사용할 때는 영속성 컨텍스트를 고려해야 한다. 

 

4. 느낀점

이번 프로젝트에서 락을 적용하면서 개념에 대해서 확립할 수 있었다. 락에는 충돌을 예상하고 거는 비관적 락과 충돌이 없다고 예상하고 거는 낙관적 락이 있으며, 단순히 락만 적용한다고 동시성 문제가 해결되는 것이 아닌 JPA의 영속성 컨텍스트의 구조, 트랜잭션 격리 수준을 모두 고려해야만 정확히 문제를 해결할 수 있음을 배울 수 있었다.

또한, 간단한 증감 쿼리는 원자적 UPDATE를 사용하는 것이 좋다는 것을 알 수 있었다. 성능이 좋아지며, 락을 설정할 필요가 없기 때문이다. 하지만, 이 역시 JPA와 함께 사용한다면 JPA가 어떤식으로 SQL 쿼리를 작성하고 날리는지를 알아야 문제 없이 사용할 수 있었다.

 

이번 기회에 JPA가 실행되는 구조를 다시한번 복습할 수 있었고, 락이라는 개념을 확실하게 잡고 갈 수 있었다.

아직 결제 관련 프로젝트를 경험하지 않은 분들에게는 꼭 해보라고 강추하고 싶다. 많은 것을 얻을 수 있었던 사이드 프로젝트였다.

'Java > JPA' 카테고리의 다른 글

Fetch Join(2) - xToOne과 xToMany의 차이  (0) 2024.08.27
OSIV(OpenSessionInView)  (2) 2024.08.27
ddl-auto: create 적용 안되는 문제  (0) 2024.02.04
'Java/JPA' 카테고리의 다른 글
  • Fetch Join(2) - xToOne과 xToMany의 차이
  • OSIV(OpenSessionInView)
  • ddl-auto: create 적용 안되는 문제
뽀루피
뽀루피
  • 뽀루피
    뽀루피의 개발스토리
    뽀루피
  • 전체
    오늘
    어제
    • 분류 전체보기 (83) N
      • 프로젝트 (3)
      • Java (32) N
        • JPA (4) N
        • Spring (8)
      • FrontEnd (4)
        • JavaScript (1)
        • Flutter (3)
      • DB (4)
        • SQL (4)
      • 알고리즘 (10)
      • Git (3)
      • SSAFY (0)
      • Figma (1)
      • Notion (0)
      • Jira (0)
      • 기획 (0)
      • 네트워크 (10)
      • 우아한테크코스 (2)
      • Infra (5)
        • AWS (0)
        • Docker (3)
      • AI (1)
      • 자격증 (3)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    자바
    스프링
    알고리즘
    n8n
    cloudflare
    Stomp
    Jenkins
    트랜잭션
    JPA
    웹소켓
    fetch join
    java
    Docker
    네트워크
    크래프트맨 멘탈리티
    네트워크 기초
    SQL
    백준
    redis streams
    모던 자바 인 액션
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
뽀루피
동시성 제어를 위한 DB 락에 대해 알아보자
상단으로

티스토리툴바