@Version 사용 시 오버플로우가 발생하면 오버플로우가 발생한 채로 갱신한다.오버플로우가 발생해도 대소 비교가 아닌 수 비교이기에 큰 문제가 발생하지 않는다.
64비트 정수형을 사용하면 우리가 살아있는 한 오버플로우가 발생할 일이 없다고 봐도 무방하다.
Java Persistence API에서 제공하는 애너테이션 @Version은 동시성 제어를 위한 비선점 잠금 또는 낙관적 잠금*을 위해 int, Integer, short, Short, long, Long, java.sql.Timestamp 등의 타입을 가진 프로퍼티나 필드**에 적용할 수 있는 애너테이션이다.
* 데이터베이스 잠금을 수행하지 않고 DB 반영 시점에 변경 가능 여부를 확인하는 방식으로, 잠금을 수행하지 않아 반영 속도가 빠르나 재시도 로직이 있다면 데이터 변경이 잦을 때 성능이 하락할 수 있다.
** 엔티티 클래스 내에 존재하는 프로퍼티 또는 필드 하나에만 적용할 수 있다.
이 애너테이션이 적용된 엔티티 클래스는 엔티티 인스턴스가 변경되는 시점에 아래와 같은 형태의 비선점 잠금 쿼리가 실행되도록 한다.
UPDATE product
SET stock = stock - ?,
version = version + 1
WHERE id = ?
AND version = ?
덕분에 애플리케이션 로직에 버전을 증가시키고 비교하는 로직을 추가하지 않아도 된다. 물론 재시도 로직은 추가한다. 그러나 Spring을 사용하고 있다면 애너테이션 @Retryable을 애플리케이션 메서드에 적용해 직접 재시도 로직을 작성하지 않을 수 있다.
대부분의 서비스에서는 같은 시각에 갱신될 수 있다는 우려와 의미 해석의 차이 등을 이유로 Timestamp가 아닌 정수형을 버전 프로퍼티나 필드의 타입으로 채택한다.
여기서 한 가지 생각이 떠오를 수 있다. 정수형은 저장 가능한 숫자 범위가 존재한다는 점이다. 가령 short는 2바이트의 저장 공간을 가지므로 -32_768부터 32_767까지의 정수만 저장할 수 있다. 물론 Timestamp도 저장 가능한 범위*가 있다. 그러나 대개 사용하지 않으므로 무시하겠다.
* MySQL은 1970.01.01. 00:00:01.000000부터 2038.01.19. 03:14:07.499999만 저장 가능하며 그 외의 범위는 저장할 수 없는데 이를 Y2K38 문제라 한다.
그렇다면 애너테이션 @Version에 의해 버전 프로퍼티나 필드가 증가할 때 오버플로우가 발생할 수 있다. 이를 각종 커뮤니티를 찾아보고 직접 실험해보며 어떤 결과가 나오는지 확인하고자 한다. (이 글은 JPA 구현체로 Hibernate를 채택하며, 다른 구현체에서는 다르게 구현했을 수 있다.)
같은 궁금증을 가졌던
사람이 있었을까?


Google에 JPA @Version "overflow"라는 검색어와, JPA 구현체로 Hibernate가 거의 사용되므로 Hibernate optimistic lock "overflow"라는 검색어로 검색했다. 우리가 찾고자 하던 글들이 보인다.
한 글에서는 모두 1초 동안 갱신이 발생하면 32비트 자료형에서는 2(32-1)초*로 약 68년이 걸린다고 말하고 있으며 다른 글에서는 오류가 발생하지 않고 제대로 동작한다고 말한다. 수가 일치하는지만 검사하지 수의 대소 비교를 하는 것이 아니기 때문이다.
* 최상위 비트(MSB, Most Significant Bit)는 부호를 나타내며 0이면 양수, 1이면 음수를 뜻하므로 실질적으로 숫자는 나머지 31비트에 저장된다.
오버플로우가 발생하려면
얼마나 걸릴까?
앞서 살펴본 것처럼 @Version이 적용된 엔티티는 데이터가 갱신될 때마다 버전이 증가한다. 그렇다면 오버플로우가 발생하려면 얼마나 많은 갱신이 이루어져야 할까?
비선점 잠금 또는 낙관적 잠금은 같은 튜플에 대해 최종적으로 성공한 갱신 질의문에 대해서만 버전을 갱신한다. 따라서 이러한 질의문이 동시에 충돌하는 어느 시점에서는 성공한 트랜잭션 수만큼 증가하고 나머지 요청은 실패 또는 재시도 로직에 의해 새로운 버전으로 다시 요청을 보내게 된다.
데이터베이스가 10ms*에 갱신 질의문을 처리할 수 있다고 할 때, 초당 5000회의 갱신 질의문이 전달된다고 하자.
* 로컬에서 한 개의 테이블과 한 개의 행이 존재하는 데이터베이스에서 갱신 질의문을 실행했을 때 약 15ms가 소요되었으며 이는 환경에 따라 다를 수 있다.
32비트 자료형에서는 2(32-1) / 5000 = 429,496.73초 = 4.97일로 약 4일이 걸린다. 64비트 자료형에서는 2(64-1) / 5000 = 1,844,674,407,370,955.16초 = 58,494,241.74년으로 약 5,845만 년이 걸린다.
오버플로우 발생 전
무슨 일을 할까?
우리는 이제 64비트 정수형에서 우리가 살아있는 동안에는 오버플로우가 발생하기 쉽지 않음을 알았다. 그럼 처음에 제기했던 오버플로우 발생 전에는 무슨 일을 하는지 알아보겠다.
엔티티 클래스에서 애너테이션 @Version을 적용할 수 있는 세 가지 정수 원시 자료형에 대해 비교할 것이다. 물론 모두 동일한 결과를 보일 것이다.
사용하려는 엔티티 클래스 및 애플리케이션 로직은 다음과 같이 좋아요 기능을 예로 들었으며, 초기 상태는 MAX_VALUE - 1로 설정하겠다.
@RestController
@RequestMapping(path = "/posts", version = "20260117")
@RequiredArgsConstructor
public class PostController {
private final PostService postService;
@GetMapping("/{postId}")
public ResponseEntity<PostResponse> getPost(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable Long postId) {
return ResponseEntity.ok(
postService.getPost(user.getId(), postId));
}
@PostMapping("/{postId}/likes")
public ResponseEntity<PostLikeResponse> likePost(
@AuthenticationPrincipal AuthenticatedUser user,
@PathVariable Long postId) {
return ResponseEntity.ok(
postService.likePost(user.getId(), postId));
}
}
@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepository;
private final PostLikeRepository postLikeRepository;
@Transactional
public PostLikeResponse likePost(Long memberId, Long postId) {
if (!postRepository.existsById(postId)) {
throw new BusinessException(
BusinessExceptionCode.POST_NOT_FOUND);
}
int inserted = postLikeRepository.insertIfAbsent(
postId, memberId);
if (inserted == 1) {
postRepository.incrementLikeCount(postId);
}
Post post = postRepository.findById(postId)
.orElseThrow();
post.increaseLikeCount();
long likeCount = postRepository.findLikeCountById(postId)
.orElseThrow();
return new PostLikeResponse(postId, likeCount);
}
}
@Entity
@Table(name = "post")
@EntityListeners(AuditingEntityListener.class)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Post {
@Id
@Tsid
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(
name = "user_id",
nullable = false,
updatable = false
)
private User author;
@Column(nullable = false, length = 255)
private String title;
@Column(nullable = false, columnDefinition = "MEDIUMTEXT")
private String content;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 20)
private PostStatus status;
@Column(nullable = false)
private long likeCount = 0L;
@Version
@Column(nullable = false)
private Long version;
@CreatedDate
@Column(nullable = false, updatable = false)
private Instant createdAt;
@LastModifiedDate
@Column(nullable = false)
private Instant updatedAt;
@Column
private Instant deletedAt;
@Builder
private Post(User user, String title, String content) {
validateContent(title, content);
this.author = Objects.requireNonNull(user);
this.title = title;
this.content = content;
this.status = PostStatus.PUBLISHED;
}
public boolean isAuthor(Long userId) {
return userId != null
&& user.getId().equals(userId);
}
public void edit(String title, String content) {
validateNotDeleted();
validateContent(title, content);
this.title = title;
this.content = content;
}
public void delete() {
if (status == PostStatus.DELETED) {
return;
}
this.status = PostStatus.DELETED;
this.deletedAt = Instant.now();
}
public void increaseLikeCount() {
++this.likeCount;
}
private void validateNotDeleted() {
if (status == PostStatus.DELETED) {
throw new IllegalStateException(
"요청하신 글을 찾을 수 없습니다.");
}
}
private static void validateContent(String title, String content) {
if (title == null || title.isBlank()) {
throw new IllegalArgumentException(
"제목은 비어 있을 수 없습니다.");
}
if (title.codePointCount(0, title.length()) > 40) {
throw new IllegalArgumentException(
"제목은 40자를 초과할 수 없습니다.");
}
if (content == null || content.isBlank()) {
throw new IllegalArgumentException(
"본문은 비어 있을 수 없습니다.");
}
}
}

Short



초기 상태로 Short의 최댓값인 32,767보다 1 작은 32,766으로 설정하였다. 좋아요를 누를 때마다 likeCount가 1씩 증가했다. 첫 번째 증가에서는 최댓값인 32,767에, 두 번째 증가에서는 오버플로우가 발생해 최솟값인 -32,768이, 그 다음에는 1 증가한 -32,767이 되었다.
Integer



초기 상태로 Integer의 최댓값인 2,147,483,647보다 1 작은 2,147,483,646으로 설정하였다. 이전에 살펴본 Short와 마찬가지로 최댓값에 도달하였다가 오버플로우가 발생해 최솟값이 되었으며 버전은 오류없이 증가함을 살펴볼 수 있다.
Long



초기 상태로 Long의 최댓값인 9,223,372,036,854,775,807보다 1 작은 9,223,372,036,854,775,806으로 설정하였다. 이전에 살펴본 Short 및 Integer와 마찬가지로 최댓값에 도달하였다가 오버플로우가 발생해 최솟값이 되었으며 버전은 오류없이 증가함을 살펴볼 수 있다.
그렇다면 이러한 결과가 나타나는 이유는 무엇일까? Hibernate에서는 어떻게 관리하고 있는지 구현 코드를 살펴보겠다.
Hibernate는
어떻게 구현했을까?
@Override
public Object generate(SharedSessionContractImplementor session, Object owner, Object current, EventType eventType) {
return eventType == INSERT
? seed( versionMapping, session )
: increment( current, versionMapping, session );
}
Hibernate의 VersionGeneration 클래스는 버전을 관리한다. 메서드 generate()는 엔티티가 새로 저장되는 경우 메서드 seed()를 호출해 버전의 초깃값을 생성하고, 갱신되는 경우에는 increment()를 호출해 다음 버전을 계산한다.
@Override
public Short seed(Long length, Integer precision, Integer scale, WrapperOptions options) {
return ZERO;
}
@Override
public Short next(Short current, Long length, Integer precision, Integer scale, WrapperOptions options) {
return (short) ( current + 1 );
}
@Override
public Integer seed(Long length, Integer precision, Integer scale, WrapperOptions options) {
return ZERO;
}
@Override
public Integer next(Integer current, Long length, Integer precision, Integer scale, WrapperOptions options) {
return current + 1;
}
@Override
public Long seed(Long length, Integer precision, Integer scale, WrapperOptions options) {
return ZERO;
}
@Override
public Long next(Long current, Long length, Integer precision, Integer scale, WrapperOptions options) {
return current + 1L;
}
메서드 increment()에서는 각 자료형에 대한 메서드 next()를 호출해 다음 버전을 계산하는데 이들은 모두 기존 버전에 1을 더하고 있으며 그 외의 로직은 없음을 알 수 있다.
마지막으로 얻은 결론
애너테이션 @Version에 의해 버전 프로퍼티나 필드가 증가할 때 오버플로우가 발생할 가능성과 Hibernate에서 오버플로우 관련 로직은 없는지 정리해 보았다.
앞서 살펴본 바와 같이 실무에서 오버플로우가 32비트 정수형에서는 발생할 가능성이 있지만 64비트 정수형에서는 거의 불가능함을 확인하였다.
또 오버플로우가 발생하면 JPA 구현체인 Hibernate에서는 오버플로우 발생 전 별다른 처리를 하지 않으며 발생한다 하더라도 큰 문제가 발견되지 않음을 알 수 있다.
참고 자료
- Javier | JPA @Version overflow | StackOverflow | 2014.05.14.
- genkiro | Hibernate Optimistic Locking Version overflow | StackOverflow | 2011.08.21.
- Hibernate | org.hibernate.generator.internal.VersionGeneration | GitHub
- Hibernate | org.hibernate.type.descriptor.java.ShortJavaType | GitHub
- Hibernate | org.hibernate.type.descriptor.java.IntegerJavaType | GitHub
- Hibernate | org.hibernate.type.descriptor.java.LongJavaType | GitHub