본문 바로가기
TWIL

JPA 더티체킹 사용시 주의할점 : REPEATABLE READ 격리 수준에서 발생한 동시성문제 해결하기

by swims 2025. 3. 22.
JPA는 자바 개발자들에게 매우 강력한 ORM 도구다. 특히 더티 체킹이라는 기능은 개발자가 명시적으로 업데이트 쿼리를 작성하지 않아도 엔티티의 변경사항을 감지하여 자동으로 DB에 반영해주는 아주 편리한 기능이다. (하지만 이런 편리함 뒤에는 생각지 못한 위험이 숨겨져 있을수도 있다…….)

 

 

실제 배송 시스템에서 발생한 동시성 문제를 통해 JPA의 더티체킹과 데이터베이스의 트랜잭션 격리 수준이 어떻게 작동하는지와 이로 인해 발생할 수 있는 문제와 해결에 대해 알아보도록 하자.

 


⚠️ 문제상황

 

문제가 발생한 배송 프로세스는 다음과 같다

  1. 사용자가 상품을 주문하면 시스템은 배송 요청을 생성하고 외부 배송사에 주문 정보를 전달합니다.
  2. 외부 배송사로부터 배송 접수 완료 콜백이 오면, 시스템은 해당 요청의 배송 접수 상태를 갱신합니다.
  3. 이후 배송사가 실제로 물품을 수령하면, 배송 출발 콜백이 오고 시스템은 배송 상태를 갱신합니다.
  4. 최종적으로 배송이 시작된 요청은 다음과 같은 상태를 갖습니다.
    배송 접수 상태 배송 상태
    CONFIRMED STARTED

 

하지만 어느 날 데이터를 확인해보니, 이미 물류 출발까지 완료된 요청임에도 불구하고 아래와 같은 상태로 남아 있는 경우가 있었다.

배송 접수 상태 배송 상태
CONFIRMED PENDING ❌

 

 

🔎 문제 분석

 

트랜잭션 로그를 분석한 결과, 아래와 같은 시나리오가 재현되었다.

  • A, B 트랜잭션이 동시에 동일한 데이터를 조회했다(1,2번)
  • B 트랜잭션이 먼저 커밋되면서 배송 출발 상태를 변경했다.(3번)
  • A 트랜잭션이 뒤에 커밋되면서 배송 접수 처리 상태를 변경했다.(4번)

배송 상태를 먼저 업데이트한 트랜잭션 B가 커밋된 뒤, 배송 접수 처리 상태를 업데이트한 트랜잭션 A가 그 이전 상태인 PENDING 값으로 덮어써버린 것이다.

 

A, B 트랜잭션은 각각 다른 필드를 변경했는데, 왜 배송 상태 데이터는 다시 원래 상태로 롤백되어 버렸을까?

 

문제는 JPA의 Dirty Checking 방식에 있었다.

 

JPA의 더티체킹은 엔티티의 변경사항을 자동으로 감지하여 데이터베이스에 반영하는 기능이다. 작동방식은 다음과 같은데

  1. 트랜잭션이 시작되면 JPA는 영속성 컨텍스트에 엔티티의 원본 스냅샷을 저장한다.
  2. 트랜잭션이 커밋되기 전, JPA의 엔티티의 현재 상태와 원본 스냅샷을 비교한다.
  3. 데이터에 변경이 있으면 UPDATE 쿼리를 생성하여 데이터 베이스에 반영한다.

 

기본적으로 JPA는 변경된 필드만 업데이트하는 것이 아니라, 엔티티의 모든 필드를 업데이트하는 UPDATE 쿼리를 생성한다.

 

결국, 배송완료 처리를 했던 트랜잭션 B의 상태가 배송 신청 완료 트랜잭션 A의 Dirty Checking으로 인해 원복되어 버린것이다.

 

A 트랜잭션의 업데이트 쿼리를 재현하면 다음과 같다.

UPDATE shipment SET confirmation_status = 'CONFIRMED', delivery_status = 'PENDING' WHERE id = 1;

 

DB 격리 수준의 관점에서 보면,

 

이 문제는 REPEATABLE READ 격리 수준에서 발생할 수 있는 문제 중에 Lost Update 의 한 종류로, 두 트랜잭션이 서로 다른 필드를 변경하려고 했지만, JPA 더티 체킹으로 인해 나중에 커밋된 트랜잭션 A가 먼저 커밋된 트랜잭션 B의 변경사항을 덮어쓴 것이다.

 

 

❗참고

READ_COMMITTED 격리 수준에서는 다음과 같은 동시성 문제가 발생할 수 있습니다

1. Non-repeatable Read: 트랜잭션 내에서 같은 쿼리를 두 번 실행했을 때 다른 결과가 나올 수 있습니다.
2. Phantom Read: 트랜잭션 내에서 같은 쿼리를 두 번 실행했을 때 새로운 행이 나타나거나 사라질 수 있습니다.
3. Lost Update: 두 트랜잭션이 동시에 같은 데이터를 수정할 때, 한 트랜잭션의 변경사항이 다른 트랜잭션에 의해 덮어쓰여질 수 있습니다.

 

 

🕵️ 해결 방법

 

이 문제는 각 트랜잭션에서 실제로 변경하려고 한 필드의 데이터만 업데이트하도록 해주면 해결할 수 있다.

엔티티에 JPA의 @DynamicUpdate를 적용하면, 변경된 필드만 동적으로 업데이트되도록 설정할 수 있어서, 동시성 문제가 발생하더라도 데이터의 정합성을 유지할 수 있다.

@Entity
@DynamicUpdate
public class Shipment {
    @Id
    private Long id;

    @Enumerated(EnumType.STRING)
    private ConfirmationStatus confirmationStatus;

    @Enumerated(EnumType.STRING)
    private DeliveryStatus deliveryStatus;
}

 

이제 confirmationStatus만 변경되었을 경우에는 JPA가 다음과 같은 쿼리만 실행하게 된다.

 

UPDATE shipment SET confirmation_status = 'CONFIRMED' WHERE id = 1;