Optimistic Lock

SexyWoong·2023년 12월 4일
0

spring

목록 보기
9/11

데이터베이스 관리에서 락(Lock)은 중요한 개념입니다. 특히, 동시에 여러 사용자나 서비스가 하나의 데이터에 접근하는 환경에서 데이터 무결성을 유지하는 것은 필수적인 과제입니다.
JPA(Java Persistence API)에서 제공하는 Optimistic Lock은 이러한 환경에서 데이터 무결성을 보장하는 효과적인 방법 중 하나입니다.
이번 글에서는 JPA의 Optimistic Lock에 대해 자세히 알아보겠습니다.

Optimistic Lock은 무엇인가?

Optimistic Lock, 즉 낙관적 락은 데이터베이스의 특정 레코드를 업데이트할 때 발생할 수 있는 충돌을 관리하는 기법입니다. 낙관적 락의 핵심 개념은 충돌이 드물다는 가정 하에 작동한다는 것입니다. 이는 락을 걸지 않고 데이터를 읽은 다음, 실제 업데이트 시에만 해당 데이터가 변경되지 않았는지 확인합니다.

여기서 잠깐! 왜 충돌이 드물다는 가정 하에 작동하는 것일까?

Optimistic Lock 방식에서는 데이터베이스의 레코드를 업데이트할 경우, 해당 레코드의 버전을 확인하고 다른 트랜잭션에 의해 해당 레코드가 변경되었다면(버전 정보가 다르다면) 업데이트 시도는 실패하고 OptimisticLockFailureException가 발생합니다.

충돌이 자주 발생하면 업데이트시 예외가 자주 발생하고 이로 인해 발생하는 rollback작업이 많아지기 때문입니다.

위 그림에서 알 수 있듯 데이터를 읽었을때의 version값과 수정시의 version값이 다르면 예외가 발생하고 rollback이 된다.

JPA에서 Optimistic Lock을 어떻게 사용하지?

엔티티 클래스에 '@Version' 어노테이션을 사용하여 버전 필드를 추가함으로써 Optimistic Lock을 사용할 수 있습니다. 이 버전 필드는 JPA가 자동으로 관리하며, 데이터베이스의 레코드가 업데이트 될 때마다 값을 증가시킵니다.

Book.java

@Entity
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@ToString
public class Book {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "book_name")
    private String name;

    @Version
    private Long version;

    public Book(String name) {
        this.name = name;
    }
}

위 처럼 Book 엔티티를 간단하게 구현하였고, 버전 필드는 Long타입으로 구현하였습니다.

BookService.java

@Service
@RequiredArgsConstructor
@Slf4j
@Transactional(readOnly = true)
public class BookService {

    private final BookRepository bookRepository;

    @Transactional
    public Long save(String name) {
        Book book = new Book(name);
        Book savedBook = bookRepository.save(book);

        log.info("book version = {}", savedBook.getVersion());

        return savedBook.getId();
    }

    @Transactional
    public Long updateBookName(Long id, String name) {
        Book book = bookRepository.findById(id)
            .orElseThrow(() -> new EntityNotFoundException("id에 해당하는 book이 없습니다."));

        log.info("book version = {}", book.getVersion());

        book.setName(name);
        return book.getVersion();
    }
}

Service클래스도 간단히 구현하였습니다.

이제 OptimisticLock이 잘 적용되는지 확인해보겠습니다.

Test

@Test
    public void testOptimisticLocking() throws InterruptedException {
        final int numberOfThread = 2;
        final ExecutorService es = Executors.newFixedThreadPool(10);
        final CountDownLatch latch = new CountDownLatch(numberOfThread);
        final AtomicInteger successCount = new AtomicInteger(0);
        final AtomicInteger failCount = new AtomicInteger(0);

        Book book = bookRepository.save(new Book("v1"));
        Long version = book.getVersion();
        log.info("처음 version = {}", version);

        for (int i = 0; i < numberOfThread; i++) {
            es.execute(() -> {
                try {
                    bookService.updateBookName(book.getId(), "v2");
                    successCount.incrementAndGet();
                    log.info("!!!!!!!!!!!!success!!!!!!!!!");
                } catch (OptimisticLockingFailureException e) {
                    log.info("!!!!!!!!!!!!!!fail!!!!!!!!!!!!");
                    log.info("Exception message = {}", e.getMessage());
                    failCount.incrementAndGet();
                } finally {
                    log.info("ThreadName = {}", Thread.currentThread().getName());
                    latch.countDown();
                    log.info("!!!!!!!!!finally!!!!!!!!!!!!");
                    Book afterUpdateBook = bookRepository.findById(book.getId()).orElseThrow();
                    log.info("afterUpdateBook.getVersion() = {}", afterUpdateBook.getVersion());
                }
            });
        }

        latch.await();

        log.info("성공횟수 = {}", successCount);
        log.info("실패횟수 = {}", failCount);
    }

log를 확인해보면

Hibernate: 
    update
        book 
    set
        book_name=?,
        version=? 
    where
        id=? 
        and version=?

Book 엔티티에 @Version필드를 만들어주었기 때문에 update시 version을 확인하는 쿼리가 날라가는것을 확인할 수 있습니다.

2023-12-05T08:45:12.586+09:00  INFO 1242 --- [pool-2-thread-2] hello.lock17.api.BookServiceTest         : !!!!!!!!!!!!success!!!!!!!!!
2023-12-05T08:45:12.587+09:00  INFO 1242 --- [pool-2-thread-2] hello.lock17.api.BookServiceTest         : ThreadName = pool-2-thread-2
2023-12-05T08:45:12.587+09:00  INFO 1242 --- [pool-2-thread-2] hello.lock17.api.BookServiceTest         : !!!!!!!!!finally!!!!!!!!!!!!

첫번째 Thread는 예외가 발생하지 않고 try문을 끝까지 잘 돌아 성공했음을 알 수 있습니다.

2023-12-05T08:45:12.591+09:00  INFO 1242 --- [pool-2-thread-1] hello.lock17.api.BookServiceTest         : !!!!!!!!!!!!!!fail!!!!!!!!!!!!
2023-12-05T08:45:12.592+09:00  INFO 1242 --- [pool-2-thread-1] hello.lock17.api.BookServiceTest         : Exception message = Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect) : [hello.lock17.model.Book#1]
2023-12-05T08:45:12.592+09:00  INFO 1242 --- [pool-2-thread-1] hello.lock17.api.BookServiceTest         : ThreadName = pool-2-thread-1
2023-12-05T08:45:12.592+09:00  INFO 1242 --- [pool-2-thread-1] hello.lock17.api.BookServiceTest         : !!!!!!!!!finally!!!!!!!!!!!!
2023-12-05T08:45:12.592+09:00  INFO 1242 --- [    Test worker] hello.lock17.api.BookServiceTest         : 성공횟수 = 1
2023-12-05T08:45:12.592+09:00  INFO 1242 --- [    Test worker] hello.lock17.api.BookServiceTest         : 실패횟수 = 1

두번째 Thread는 version의 값이 맞지 않아 실패했음을 알 수 있습니다.

2023-12-05T08:45:12.588+09:00  INFO 1242 --- [pool-2-thread-2] hello.lock17.api.BookServiceTest         : afterUpdateBook.getVersion() = 1
2023-12-05T08:45:12.593+09:00  INFO 1242 --- [pool-2-thread-1] hello.lock17.api.BookServiceTest         : afterUpdateBook.getVersion() = 1

최종적으로 두개의 Thread모두 version이 1이 되었음을 확인할 수 있습니다.

삽질1

@Slf4j
@SpringBootTest
@Transactional
class BookServiceTest {

처음 테스트 코드를 작성할때 클래스 레벨에 @Transactional을 아무 생각 없이 작성해주었습니다.
아무리 테스트 코드를 작성해보아도 update쿼리가 날라가지 않는것이었습니다.
도대체 무엇이 문제인지 알아보기 위해 여기저기 로그를 찍어보며 확인을 해보아도 알 수 없었습니다.

update쿼리가 날라가지 않는다는것은 dirty checking이 되지 않는다는 의미인것 같고...
이는 Transaction이 commit되지 않는구나! 라는 생각을 하게 되었습니다.

설마..? 하는 마음에 클래스 레벨의 @Transactional을 삭제해주니 update쿼리가 잘 날라가는것을 확인할 수 있었습니다.

따라서 Optimistic Lock을 테스트 하기 위해서는 테스트 메서드에는 @Transactional을 사용하지 않던가 하나의 작업이 끝나면 EntityManager.flush()를 해줘서 update쿼리를 날려줘야겠다! 라는 사실을 깨달았습니다.

삽질2

위 테스트코드를 실행하면 로그가 아래와 같이 찍힙니다.

Hibernate: 
    insert 
    into
        book
        (book_name, version, id) 
    values
        (?, ?, default)
2023-12-05T08:45:12.558+09:00  INFO 1242 --- [    Test worker] hello.lock17.api.BookServiceTest         : 처음 version = 0
Hibernate: 
    select
        b1_0.id,
        b1_0.book_name,
        b1_0.version 
    from
        book b1_0 
    where
        b1_0.id=?
Hibernate: 
    select
        b1_0.id,
        b1_0.book_name,
        b1_0.version 
    from
        book b1_0 
    where
        b1_0.id=?
Hibernate: 
    update
        book 
    set
        book_name=?,
        version=? 
    where
        id=? 
        and version=?
Hibernate: 
    update
        book 
    set
        book_name=?,
        version=? 
    where
        id=? 
        and version=?
2023-12-05T08:45:12.586+09:00  INFO 1242 --- [pool-2-thread-2] hello.lock17.api.BookServiceTest         : !!!!!!!!!!!!success!!!!!!!!!
2023-12-05T08:45:12.587+09:00  INFO 1242 --- [pool-2-thread-2] hello.lock17.api.BookServiceTest         : ThreadName = pool-2-thread-2
2023-12-05T08:45:12.587+09:00  INFO 1242 --- [pool-2-thread-2] hello.lock17.api.BookServiceTest         : !!!!!!!!!finally!!!!!!!!!!!!
Hibernate: 
    select
        b1_0.id,
        b1_0.book_name,
        b1_0.version 
    from
        book b1_0 
    where
        b1_0.id=?
2023-12-05T08:45:12.588+09:00  INFO 1242 --- [pool-2-thread-2] hello.lock17.api.BookServiceTest         : afterUpdateBook.getVersion() = 1
Hibernate: 
    select
        b1_0.id,
        b1_0.book_name,
        b1_0.version 
    from
        book b1_0 
    where
        b1_0.id=?
2023-12-05T08:45:12.591+09:00  INFO 1242 --- [pool-2-thread-1] hello.lock17.api.BookServiceTest         : !!!!!!!!!!!!!!fail!!!!!!!!!!!!
2023-12-05T08:45:12.592+09:00  INFO 1242 --- [pool-2-thread-1] hello.lock17.api.BookServiceTest         : Exception message = Row was updated or deleted by another transaction (or unsaved-value mapping was incorrect) : [hello.lock17.model.Book#1]
2023-12-05T08:45:12.592+09:00  INFO 1242 --- [pool-2-thread-1] hello.lock17.api.BookServiceTest         : ThreadName = pool-2-thread-1
2023-12-05T08:45:12.592+09:00  INFO 1242 --- [pool-2-thread-1] hello.lock17.api.BookServiceTest         : !!!!!!!!!finally!!!!!!!!!!!!
2023-12-05T08:45:12.592+09:00  INFO 1242 --- [    Test worker] hello.lock17.api.BookServiceTest         : 성공횟수 = 1
2023-12-05T08:45:12.592+09:00  INFO 1242 --- [    Test worker] hello.lock17.api.BookServiceTest         : 실패횟수 = 1
Hibernate: 
    select
        b1_0.id,
        b1_0.book_name,
        b1_0.version 
    from
        book b1_0 
    where
        b1_0.id=?
2023-12-05T08:45:12.593+09:00  INFO 1242 --- [pool-2-thread-1] hello.lock17.api.BookServiceTest         : afterUpdateBook.getVersion() = 1

select 쿼리가 5개 날라가는것을 볼 수 있습니다.
하지만 나는 아무리 로직을 보고 있어도 Thread 두개가 각각 updateBookName메서드 내에서 날리는 select 쿼리 두개, finally에서 조회하는 쿼리 두개, 총 네개가 날라가야한다고 생각했습니다.
몇시간째 찾아보고 고민해보았지만 이 부분에 대해서는 아직 정확한 이유를 잘 모르겠습니다...
나중에 언젠간 알게 되겠지...

작동 원리

트랜잭션이 데이터를 업데이트할 때, 해당 데이터의 버전이 읽은 시점과 동일한지 검사합니다. 만약 다른 트랜잭션이 해당 데이터를 이미 수정하여 버전이 달라졌다면, 'OptimisticLockFailureException'이 발생합니다. 이렇게 예외가 발생함으로써 데이터 무결성 위반을 방지할 수 있습니다.

Version 증가 확인

@Test
    @Transactional
    void checkVersion() {
        Book book = new Book("v1");
        Book savedBook = bookRepository.save(book);
        entityManager.flush();
        entityManager.clear();

        Long originalVersion = savedBook.getVersion();
        log.info("Original book version = {}", originalVersion);

        bookService.updateBookName(savedBook.getId(), "v2");
        entityManager.flush();
        entityManager.clear();

        Book updatedBook = bookRepository.findById(savedBook.getId()).orElseThrow();
        Long updatedVersion1 = updatedBook.getVersion();
        log.info("Updated book version (after first update) = {}", updatedVersion1);
        assertThat(updatedVersion1).isEqualTo(1);

        bookService.updateBookName(updatedBook.getId(), "v3");
        entityManager.flush();
        entityManager.clear();

        Book updatedBook2 = bookRepository.findById(updatedBook.getId()).orElseThrow();
        Long updatedVersion2 = updatedBook2.getVersion();
        log.info("Updated book version (after second update) = {}", updatedVersion2);
        assertThat(updatedVersion2).isEqualTo(2);
    }

테스트 코드를 통해서 엔티티를 update할 때 마다 version필드가 증가하는것 또한 확인할 수 있습니다.

예외 후 처리

예외가 발생하면 해당 트랜잭션의 로직은 rollback이 되고 이후에 메서드를 한번 더 실행해야 하는 등의 후처리를 해주어야 합니다.

다양한 해결방법들이 존재하겠지만 우이삭 프로젝트에서는 @Retryable을 활용하여 해결하였습니다.
@Retryable에 대해서 간략하게 설명하겠습니다.

@Retryable이란?

@Retryable은 스프링 애플리케이션에서 특정 메소드에 재시도 로직을 선언적으로 적용하는 방법입니다.
@Retryable 주석이 달린 메소드에서 지정된 유형의 예외를 던지면, 스프링은 어노테이션 매개변수에서 정의된 정책에 따라 자동으로 연산을 재시도합니다. 이 기능은 원격 서비스, 네트워크 통신 또는 후속 시도에서 성공할 수 있는 모든 작업에서 일시적인 실패를 처리하는 데 특히 유용합니다.

우이삭 코드 일부

@CacheEvict(value = {CacheConstants.ANSWERED_QUESTIONS, CacheConstants.QUESTION_DETAILS}, allEntries = true)
    @Retryable(retryFor = ObjectOptimisticLockingFailureException.class, maxAttempts = 3, backoff = @Backoff(delay = 1000))
    @Transactional
    public void updateQuestionAnswer(Long id, Long coupleId, Long loginUserId, int answer) {
        Question question = questionRepository.findById(id)
            .orElseThrow(() -> new NoSuchElementException(notFoundEntityMessage("question", id)));  // NOSONAR

        question.updateAnswer(answer, questionServiceSupporter.getBoyId(coupleId), loginUserId);
        Events.raise(new IncreaseTemperatureEvent(question.getCoupleId()));
    }

@Retryable의 매개변수들을 하나씩 설명해보겠습니다.

  • retryFor : 예외를 지정하면 지정된 예외가 발생했을 경우 재시도를 한다.
  • maxAttempts : 예외 발생 시 재시도의 최대 횟수이다. default값은 3이다.
  • backoff : 예외 발생 시 재시도 하기전 지연시간을 의미한다. 위 코드의 경우 1000ms는 예외 발생 시 1초 후 재시도 한다는 의미이다.

따라서 위 메서드는 ObjectOptimisticLockingFailureException 예외가 발생하면 1초의 시간을 두고 최대 3번 재시도를 한다.

profile
함께 있고 싶은 사람, 함께 일하고 싶은 개발자

0개의 댓글