프로젝트 진행 중 복수의 관리자가 특정 상품을 대상으로 동시에 수정한다고 하였을 때 마지막에 수정한 내용이 덮어 씌어져 버리는 동시성 문제가 발생할 것으로 생각을 하였습니다. 이를 해결하고자 @Version
어노테이션과 필드를 활용 하였습니다.
쇼핑몰 관리자 페이지나 게시판처럼 여러 사용자가 동시에 데이터에 접근하고 수정할 수 있는 애플리케이션을 개발하다 보면 반드시 마주치는 문제가 있습니다. 바로 "데이터가 내가 모르는 사이에 덮어쓰여지는 문제" 입니다.
간단한 시나리오를 통해 살펴보겠습니다.
상황: 두 명의 쇼핑몰 관리자 'A'와 'B'가 동시에 '나이키 에어포스' 상품의 가격을 수정하려고 합니다. 현재 가격은 100,000원입니다.
관리자 A가 수정했던 120,000원이라는 데이터는 흔적도 없이 사라졌습니다.
이처럼 먼저 완료된 트랜잭션의 결과를 나중에 시작된 트랜잭션이 덮어써서 데이터가 유실되는 현상을 '잃어버린 업데이트' 라고 부릅니다.
이 문제를 해결하기 위해 @Version
어노테이션을 이용한 낙관적 락 을 사용하였습니다.
왜 "낙관적" 락일까요?
"에이, 설마 동시에 수정하는 일이 자주 있겠어?"라고 낙관적으로 가정하고, 일단은 아무런 제약 없이 수정을 허용합니다. 대신, 마지막에 데이터를 저장(UPDATE)하는 순간에만 내가 처음 읽었던 상태와 같은지를 검사하는 방식이기 때문입니다.
@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씩 증가합니다.@Version
의 핵심은 UPDATE 쿼리가 실행될 때 드러납니다. @Version
이 적용된 시나리오를 다시 한번 보겠습니다. (상품의 초기 version
값은 1
이라고 가정)
[10:00:01] 👨💻 관리자 A가 상품 정보를 읽습니다. (데이터: 가격 100,000원, version = 1)
[10:00:02] 👩💻 관리자 B도 상품 정보를 읽습니다. (데이터: 가격 100,000원, version = 1)
[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의 version
은 2
가 됩니다.[10:00:15] 👩💻 관리자 B가 가격을 110,000원으로 수정하고 저장합니다. JPA는 동일한 로직으로 SQL을 생성합니다.
UPDATE products
SET price = 110000, version = 2
WHERE id = 1 AND version = 1; -- 관리자 B도 처음 읽었던 version = 1을 조건으로 업데이트를 시도!
version
은 2
이므로, WHERE id = 1 AND version = 1
조건에 맞는 데이터가 없습니다. 업데이트는 실패하고 0개의 row가 변경됩니다.예외 발생: 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
값이기 때문입니다.
초기 데이터 상태: 상품의 version
값은 1 입니다.
[10:00:01] 👨💻 관리자 A, 상품 정보 조회
version = 1
인 상태로 기억합니다.[10:00:02] 👩💻 관리자 B, 상품 정보 조회
version = 1
인 상태로 기억합니다.[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의 version
은 2로 변경됩니다.
[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개의 행이 변경됩니다.
예외 발생
OptimisticLockException
을 발생시켜 관리자 A의 트랜잭션을 롤백시킵니다.@Version
어노테이션은 낙관적 잠금을 매우 간단하게 구현하여 이 문제를 해결해 줍니다.OptimisticLockException
을 적절히 처리하여 사용자에게 명확한 피드백을 주는 것이 좋은 애플리케이션의 기본입니다.