데이터베이스 READ COMMITED 격리 수준에서
동시에 같은 데이터를 조회하고 수정하는 경우 어떻게 되나요?
이번글은 데이터베이스 READ COMMITED 격리 수준에서 발생하는 갱신 손실(Lost Update) 문제와, 이를 해결하는 방법중 하나인 JPA에서 제공하는 낙관적 락에 대해 알아보겠습니다.
READ COMMITED 격리 수준에서 동시에 같은 데이터를 조회하고 수정하는 경우 어떻게 될까요?
예를 들어, 두 트랜잭션 A와 B가 있다고 가정해 보겠습니다.
두 트랜잭션이 동시에 동일한 데이터를 조회한 후, 먼저 A가 데이터를 수정하고 커밋합니다. 이후에 B가 기존에 조회한 데이터를 기반으로 수정하고 커밋하면, A의 수정 내용은 B의 수정 내용으로 덮여씌워집니다.
이처럼 A의 변경 사항이 사라지는 문제를 갱실 손실이라고 합니다.
READ COMMITED 격리 수준은 뭔가요?
데이터베이스 트랜잭션의 격리 수준(Isolation Level) 중 하나로,
트랜잭션이 커밋된 데이터만 읽을 수 있도록 보장하는 격리 수준입니다.
즉, 아직 커밋되지 않은 트랜잭션의 변경 사항은 다른 트랜잭션에서 볼 수 없음을 의미합니다.
READ COMMITED 격리 수준에서 동시에 같은 데이터를 조회하는게 왜 가능한가요?
읽기 작업(SELECT)은 잠금을 걸지 않기 때문에, 읽는 동안 다른 트랜잭션이 데이터를 수정하는 것을 막지는 않습니다.
즉, 읽기 작업끼리는 서로 방해하지 않으므로 동시에 여러 트랜잭션이 같은 데이터를 조회할 수 있습니다.
READ COMMITED 격리 수준은 아래와 같이 수정 작업시에만 잠금을 걸어주게 됩니다.
읽기 작업 → 잠금 없음 → 동시 조회 가능
수정 작업 → 잠금 걸림 → 동시에 수정 불가
즉, UPDATE가 실제로 실행되는 동안만 해당 데이터를 다른 트랜잭션이 수정할 수 없고,
이전이나 이후에는 SELECT는 자유롭게 실행할 수 있습니다.
그럼 갱신 분실 문제는 어떻게 해결할 수 있나요?
갱신 분실 문제를 해결하는 방법은 크게 3가지가 있습니다.
1. 마지막 커밋만 인정하기
- 가장 마지막에 수정 후 커밋한 사용자의 내용만 반영하는 방식입니다.
2. 최초 커밋만 인정하기
- 가장 먼저 수정 후 커밋한 사용자의 내용만 반영하는 방식입니다.
3. 충돌하는 갱신 내용 병합하기
- 두 개 이상의 수정 내용을 병합하여 반영하는 방식입니다.
이번 글에서는 "최초 커밋만 인정하기" 방법을 활용하여 갱신 손실 문제를 해결하는 방법을 다뤄보겠습니다.
낙관적 락(Optimistic Lock)
낙관적 락은 이름과 같이 트랜잭션 대부분은 충돌이 발생하지 않는다고 낙관적으로 가정하는 방법이다.
데이터베이스가 제공하는 락 기능을 사용하는 것이 아닌, JPA가 제공하는 버전 관리 기능을 사용합니다.
쉽게 말해서 어플리케이션이 제공하는 락입니다.
어떻게 낙관적 락이 최초 커밋만 인정하기 방법을 활용할 수 있나요?
버전 관리 기능을 사용해서 최초 커밋만 인정하기 방법을 구현할 수 있습니다.
버전 관리 기능의 동작 원리가 뭔가요?
버전 관리를 그림으로 표현하면 다음과 같습니다.
1. 트랜잭션1과 트랜잭션2가 동일한 시점에 동일한 엔티티를 조회합니다.
2. 트랜잭션2가 먼저 조회한 엔티티를 수정한 후 커밋을 합니다. (이때, 버전이 1에서 2로 변경되게 됩니다.)
3. 트랜잭션 1이 조회한 엔티티(버전 1)를 수정 후 커밋을 합니다.
4. 트랜잭션 1이 조회한 엔티티(버전 1)과 DB에 저장된 엔티티(버전2)의 버전이 다르므로, OptimisticLockException 예외가 발생하여 트랜잭션이 롤백됩니다.
@Version
JPA가 제공하는 낙관적락을 사용하기 위해선 @Version 어노테이션을 사용해서 버전 관리 기능을 추가해야 합니다.
엔티티에 버전 관리용 필드르 하나 추가하고 @Version을 붙이면 됩니다. @Version 어노테이션을 적용한 엔티티를 수정할 때 마다 버전이 하나씩 자동으로 증가하게 됩니다.
정리하면, 다음과 같습니다.
- 엔티티에 버전 관리용 필드를 추가하고, 해당 필드에 @Version 어노테이션을 적용합니다.
- 수정이 발생할 때마다 버전 값이 자동으로 증가합니다.
- 수정 시 조회한 버전과 현재 버전이 다르면 예외가 발생하여, 갱신 손실(Lost Update)을 방지할 수 있습니다.
@Version 어노테이션으로 사용할 수 있는 타입은 다음과 같습니다.
- Integer, Long, Short
- java.sql.Timestamp
- java.util.Date
낙관적 락 사용하기
낙관적 락이 적용된 코드는 다음과 같습니다.
package com.example.spring_study_test.entity;
import jakarta.persistence.*;
@Entity
@Table(name = "user")
public class UserEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@Version
private int version;
protected UserEntity() {
}
public UserEntity(String name) {
this.name = name;
}
public Long getId() {
return this.id;
}
public String getName() {
return this.name;
}
public void updateName(String updatedName) {
this.name = updatedName;
}
}
int형 변수인 version을 따로 선언해주었습니다.
낙관적 락으로 정말, 갱실 분실 문제를 "최초 커밋만 인정하기" 방법으로 해결할 수 있을까요?
낙관적 락 테스트
테스트 하는 상황은 다음과 같습니다.
1. name 필드에 낙관적 락을 적용시키지 않은 상태에서 두 트랜잭션으로 동일한 데이터 수정하기
2. name 필드에 낙관적 락을 적용시킨 상태에서 두 트랜잭션으로 동일한 데이터 수정하기
먼저, 낙관적락을 적용시키지 않은 상태를 테스트 하겠습니다. 코드는 다음과 같습니다.
UserEntity
package com.example.spring_study_test.entity;
import jakarta.persistence.*;
@Entity
@Table(name = "user")
public class UserEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
protected UserEntity() {
}
public UserEntity(String name) {
this.name = name;
}
public Long getId() {
return this.id;
}
public String getName() {
return this.name;
}
public void updateName(String updatedName) {
this.name = updatedName;
}
}
엔티티에서 @Version을 제거해 줍니다.
UserRepository
package com.example.spring_study_test.service;
import com.example.spring_study_test.entity.UserEntity;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@Component
public class UserRepository {
@PersistenceContext
private EntityManager em;
@Transactional
public void updateUserName(Long userId, String name) {
UserEntity user = em.find(UserEntity.class, userId);
user.updateName(name);
em.flush();
}
@Transactional
public void save(UserEntity userEntity) {
em.persist(userEntity);
em.flush();
}
@Transactional(readOnly = true)
public UserEntity findById(Long id) {
UserEntity user = em.find(UserEntity.class, id);
return user;
}
}
레포지토리에 유저 이름을 업데이트, 저장, 찾기 메서드를 구현했습니다.
LostUpdateTest
import com.example.spring_study_test.entity.UserEntity;
import com.example.spring_study_test.service.UserRepository;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class LostUpdateTest {
@Autowired
private UserRepository userRepository;
@Test
void givenOneUser_whenUpdatedNameNotUseOptimisticLock_thenLostUpdateProblem() throws InterruptedException {
UserEntity user = new UserEntity("정기");
userRepository.save(user);
Runnable updateNameTask1 = () -> {
userRepository.updateUserName(user.getId(),"수훈");
};
Runnable updateNameTask2 = () -> {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
userRepository.updateUserName(user.getId(),"기정");
};
Thread thread1 = new Thread(updateNameTask1);
Thread thread2 = new Thread(updateNameTask2);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
UserEntity savedUser = userRepository.findById(user.getId());
System.out.println("수정된 이름: " + savedUser.getName());
em.close();
}
}
결과가 어떻게 나올까요?
갱신 분실이 일어나 마지막에 실행된 스레드에서 업데이트한 이름으로만 변경이 됩니다.
스레드 순서를 지정해주기 위해, Thread.sleep(500)메서드로 사용해 "기정"으로 변경하는 스레드가 마지막으로 실행되도록 합니다.
테스트 결과는 다음과 같습니다.
Hibernate Log
update 쿼리가 2번 실행이 됩니다.
DB 결과
최종적으로 "정기"으로 저장했던 이름이 "기정"으로 변경되게 되었습니다.
"수훈"으로 업데이트 했던 스레드는 갱신 분실이 발생했습니다.
이제, 낙관적 락을 통해 갱실 분실 문제를 해결해보겠습니다.
UserEntity (@Version)
package com.example.spring_study_test.entity;
import jakarta.persistence.*;
@Entity
@Table(name = "user")
public class UserEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@Version
private int version;
protected UserEntity() {
}
public UserEntity(String name) {
this.name = name;
}
public Long getId() {
return this.id;
}
public String getName() {
return this.name;
}
public void updateName(String updatedName) {
this.name = updatedName;
}
}
name 필드에 @Version 어노테이션을 추가해 줍니다.
LostUpdateTest(@Version)
@SpringBootTest
class LostUpdateTest {
@Autowired
private UserRepository userRepository;
@Test
void givenOneUser_whenUpdatedNameNotUseOptimisticLock_thenLostUpdateProblem() throws InterruptedException {
UserEntity user = new UserEntity("정기");
userRepository.save(user);
Runnable updateNameTask1 = () -> {
userRepository.updateUserName(user.getId(),"수훈");
};
Runnable updateNameTask2 = () -> {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
userRepository.updateUserName(user.getId(),"기정");
};
Thread thread1 = new Thread(updateNameTask1);
Thread thread2 = new Thread(updateNameTask2);
thread1.start();
thread2.start();
thread1.join();
thread2.join();
UserEntity savedUser = userRepository.findById(user.getId());
System.out.println("수정된 이름: " + savedUser.getName());
em.close();
}
}
업데이트하는 두 스레드 실행의 차이를 최소화 하기 위해 Thread.sleep() 내부의 값을 10으로 변경했습니다.
테스트 결과는 다음과 같습니다.
Hibernate Log(@Version)
update 쿼리가 두번 수행이 되는가 싶더니,
바로 OptimisticLockException이 발생하게 됩니다.
최종적으로 저장 된 결과는 다음과 같습니다.
결과를 정리하면, 10ms정도 늦게 "기정"으로 수정되는 로직은 낙관적 락(@Version)으로 인해 반영되지 않고, 최초 커밋된 "수훈"으로 수정되는 로직만 반영이 되었습니다.
아래와 같은 과정이 발생하게 됩니다.
정리
갱신 분실 발생시 "최초 커밋된 내용만 반영"을 하기 위해서 JPA에서 제공하는 낙관락을 사용하여 문제를 해결할 수 있습니다.
'JPA' 카테고리의 다른 글
JPA 비관적 락 (0) | 2025.02.07 |
---|---|
JPA와 읽기 전용 쿼리 (0) | 2025.02.05 |
find 메서드와 1차 캐시의 관계 (0) | 2025.02.04 |
IDENTITY 식별자 생성 전략과 persist()의 관계 (0) | 2025.02.03 |
동시성 문제 해결방법(Update Query편) (0) | 2025.01.29 |