관리자 동시 업데이트 시 데이터가 덮어쓰이는 문제 (feat. 낙관적 락)

gminnimk·2025년 9월 21일
0

문제 해결

목록 보기
15/18

JPA 동시성 문제, @Version으로 해결하기 (feat. 낙관적 락)


프로젝트 진행 중 복수의 관리자가 특정 상품을 대상으로 동시에 수정한다고 하였을 때 마지막에 수정한 내용이 덮어 씌어져 버리는 동시성 문제가 발생할 것으로 생각을 하였습니다. 이를 해결하고자 @Version 어노테이션과 필드를 활용 하였습니다.

쇼핑몰 관리자 페이지나 게시판처럼 여러 사용자가 동시에 데이터에 접근하고 수정할 수 있는 애플리케이션을 개발하다 보면 반드시 마주치는 문제가 있습니다. 바로 "데이터가 내가 모르는 사이에 덮어쓰여지는 문제" 입니다.

문제 상황: 수정한 데이터 적용 X

간단한 시나리오를 통해 살펴보겠습니다.

상황: 두 명의 쇼핑몰 관리자 'A'와 'B'가 동시에 '나이키 에어포스' 상품의 가격을 수정하려고 합니다. 현재 가격은 100,000원입니다.

  1. [10:00:01] 👨‍💻 관리자 A가 상품 수정 페이지에 접속해 가격 정보를 읽습니다. (현재 가격: 100,000원)
  2. [10:00:02] 👩‍💻 관리자 B도 같은 상품의 수정 페이지에 접속합니다. (현재 가격: 100,000원)
  3. [10:00:10] 👨‍💻 관리자 A가 가격을 120,000원으로 올리고 [저장] 버튼을 클릭합니다. DB의 가격은 120,000원으로 성공적으로 업데이트됩니다.
  4. [10:00:15] 👩‍💻 관리자 B는 A가 가격을 수정한 사실을 모릅니다. B는 본인이 보고 있던 100,000원을 기준으로 가격을 110,000원으로 할인하고 [저장] 버튼을 클릭합니다.
  5. 결과: DB의 가격은 최종적으로 110,000원으로 업데이트됩니다.

관리자 A가 수정했던 120,000원이라는 데이터는 흔적도 없이 사라졌습니다.

이처럼 먼저 완료된 트랜잭션의 결과를 나중에 시작된 트랜잭션이 덮어써서 데이터가 유실되는 현상을 '잃어버린 업데이트' 라고 부릅니다.



해결책: @Version 을 이용한 낙관적 잠금

이 문제를 해결하기 위해 @Version 어노테이션을 이용한 낙관적 락 을 사용하였습니다.

왜 "낙관적" 락일까요?
"에이, 설마 동시에 수정하는 일이 자주 있겠어?"라고 낙관적으로 가정하고, 일단은 아무런 제약 없이 수정을 허용합니다. 대신, 마지막에 데이터를 저장(UPDATE)하는 순간에만 내가 처음 읽었던 상태와 같은지를 검사하는 방식이기 때문입니다.


1. 엔티티에 @Version 필드 추가하기

엔티티에 @Version 어노테이션이 붙은 필드 하나만 추가하여 적용합니다.

// Product.java

@Entity
@Getter
// ...
public class Product extends BaseTimeEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Version
    private Long version;

    @Column(nullable = false, length = 100)
    private String name;

    // ...
}

이제 JPA(Hibernate)가 이 version 필드를 자동으로 관리해 줍니다.

  • 엔티티가 조회될 때, version 값도 함께 조회됩니다.
  • 엔티티가 수정될 때마다, version 값이 자동으로 1씩 증가합니다.

2. @Version의 동작 원리

@Version의 핵심은 UPDATE 쿼리가 실행될 때 드러납니다. @Version이 적용된 시나리오를 다시 한번 보겠습니다. (상품의 초기 version 값은 1이라고 가정)

  1. [10:00:01] 👨‍💻 관리자 A가 상품 정보를 읽습니다. (데이터: 가격 100,000원, version = 1)

  2. [10:00:02] 👩‍💻 관리자 B도 상품 정보를 읽습니다. (데이터: 가격 100,000원, version = 1)

  3. [10:00:10] 👨‍💻 관리자 A가 가격을 120,000원으로 수정하고 저장합니다. 이때 JPA가 생성하는 SQL은 다음과 같습니다.

    UPDATE products
    SET price = 120000, version = 2 -- version을 1 증가시킨다.
    WHERE id = 1 AND version = 1;   -- WHERE 절에 처음 읽었던 version을 조건으로 추가한다!
    • version = 1 조건이 일치하므로 업데이트는 성공하고, DB의 version2가 됩니다.
  4. [10:00:15] 👩‍💻 관리자 B가 가격을 110,000원으로 수정하고 저장합니다. JPA는 동일한 로직으로 SQL을 생성합니다.

    UPDATE products
    SET price = 110000, version = 2
    WHERE id = 1 AND version = 1;   -- 관리자 B도 처음 읽었던 version = 1을 조건으로 업데이트를 시도!
    • 하지만 DB의 현재 version2이므로, WHERE id = 1 AND version = 1 조건에 맞는 데이터가 없습니다. 업데이트는 실패하고 0개의 row가 변경됩니다.
  5. 예외 발생: JPA는 업데이트된 row의 개수가 0인 것을 확인하고, 데이터 충돌이 발생했음을 인지하여 OptimisticLockException 을 발생시킵니다.



예외 처리

OptimisticLockException이 발생했다는 것은 "누군가 먼저 수정했어요!"라는 명확한 신호입니다.

이 예외를 사용자에게 그대로 노출하는 대신, GlobalExceptionHandler를 통해 친절한 안내를 제공하도록 합니다.

// GlobalExceptionHandler.java

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(OptimisticLockException.class)
    public ResponseEntity<ErrorResponse> handleOptimisticLockException(OptimisticLockException e) {
        ErrorCode errorCode = ErrorCode.PRODUCT_UPDATE_CONFLICT; // "수정 충돌" 관련 에러 코드
        ErrorResponse response = new ErrorResponse(errorCode.getMessage());
        // HTTP 409 Conflict 상태와 함께 "다른 사용자가 먼저 수정했습니다." 같은 메시지를 반환
        return new ResponseEntity<>(response, errorCode.getStatus());
    }
}

이제 관리자 B는 서버 에러 페이지 대신 "데이터 수정 중 충돌이 발생했습니다. 페이지를 새로고침한 후 다시 시도해주세요." 와 같은 명확한 안내를 받게 되어 훨씬 나은 사용자 경험을 제공할 수 있습니다.



낙관적 락의 핵심은 "누가 먼저 읽었는가"가 아니라 "누가 먼저 수정(커밋)하는가" 입니다.

UPDATE 쿼리의 WHERE 절에 들어가는 version 값은 각 관리자가 "자신이 처음 데이터를 읽었던 시점"version 값이기 때문입니다.


관리자 B가 먼저 커밋하는 시나리오

초기 데이터 상태: 상품의 version 값은 1 입니다.

  1. [10:00:01] 👨‍💻 관리자 A, 상품 정보 조회

    • 관리자 A의 애플리케이션(영속성 컨텍스트)은 상품 정보를 version = 1인 상태로 기억합니다.
  2. [10:00:02] 👩‍💻 관리자 B, 상품 정보 조회

    • 관리자 B의 애플리케이션 역시 상품 정보를 version = 1인 상태로 기억합니다.
    • (이 시점까지는 둘 다 동일한 버전의 데이터를 보고 있습니다.)
  3. [10:00:10] 👩‍💻 관리자 B, 가격 수정 후 먼저 커밋!

    • 관리자 B가 가격을 110,000원으로 수정하고 저장합니다.

    • JPA는 관리자 B가 처음 읽었던 version = 1 을 조건으로 UPDATE SQL을 생성합니다.

      UPDATE products
      SET price = 110000, version = 2 -- version을 1 증가
      WHERE id = 1 AND version = 1;   -- 조건: 내가 읽었던 version이 1이 맞니?
    • 실행 결과: DB의 현재 version이 1이므로 조건이 일치합니다. 업데이트는 성공하고, DB의 version2로 변경됩니다.

  4. [10:00:15] 👨‍💻 관리자 A, 가격 수정 후 뒤늦게 커밋!

    • 이제 관리자 A가 가격을 120,000원으로 수정하고 저장하려고 합니다.

    • JPA는 관리자 A가 처음 읽었던 version = 1 을 조건으로 UPDATE SQL을 생성합니다.

      UPDATE products
      SET price = 120000, version = 2
      WHERE id = 1 AND version = 1;   -- 조건: 내가 읽었던 version도 1이었는데
    • 실행 결과: 이 SQL이 DB에 도착했을 때, DB의 version은 이미 관리자 B에 의해 2로 변경된 상태입니다. 따라서 WHERE id = 1 AND version = 1 조건에 맞는 데이터가 존재하지 않습니다.

    • 업데이트는 실패하고, 0개의 행이 변경됩니다.

  5. 예외 발생

    • JPA는 업데이트된 행의 개수가 0인 것을 보고 데이터 충돌을 감지합니다.
    • 즉시 OptimisticLockException 을 발생시켜 관리자 A의 트랜잭션을 롤백시킵니다.
    • 관리자 A에게는 "다른 사용자가 먼저 데이터를 수정했습니다. 페이지를 새로고침한 후 다시 시도해주세요." 와 같은 메시지를 보여주어야 합니다.

정리

  • 여러 사용자가 동시에 데이터를 수정할 때 발생하는 '잃어버린 업데이트' 문제는 데이터 정합성을 해치는 심각한 문제입니다.
  • JPA의 @Version 어노테이션은 낙관적 잠금을 매우 간단하게 구현하여 이 문제를 해결해 줍니다.
  • 업데이트 충돌 시 발생하는 OptimisticLockException 을 적절히 처리하여 사용자에게 명확한 피드백을 주는 것이 좋은 애플리케이션의 기본입니다.

0개의 댓글