낙관적 락 테스트하기

2021-10-17

낙관적 락

낙관적 락은 이름 그대로 트랜잭션 대부분은 충돌이 발생하지 않는다고 낙관적으로 가정하는 방법이다.

이것은 데이터베이스가 제공하는 락 기능을 사용하는 것이 아니라 JPA가 제공하는 버전 관리 기능을 사용한다.

쉽게 이야기해서 애플리케이션이 제공하는 락이다.

낙관적 락은 트랜잭션을 커밋하기 전까지는 트랜잭션의 충돌을 알 수 없다는 특징이 있다.

예제

  • 어떤 게시글이 있다.

  • 유저 A와 B가 그 게시글을 동시에 수정화면 UI 진입한다.
  • 유저 A가 글을 수정하고 저장버튼을 누른다.
  • 유저 B도 글을 수정하고 저장버튼을 누른다.
  • 유저 B가 수정한 글은 반영이 안된다.

Article.class

@Entity
@NoArgsConstructor
@Getter
@ToString
public class Article {

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

    @Version
    private Long version;

    private String content;

    public Article(String content) {
        this.content = content;
    }

    public void updateContent(String content) {
        this.content = content;
    }
}

게시글을 나타내는 Article 엔티티는 다음의 필드가 있다.

  • 게시글의 id
  • 게시글의 버전
  • 게시글의 내용

여기서 사용자 A와 B가 게시글의 내용을 동시에 수정한다고 가정해보자.

어떻게 테스트 해 볼수 있을까?

매번 로직을 완성하고 동일한 Request를 연속으로 보내봐야 할까?

연속으로 보낸 Request가 낙관적 락에 안걸릴 수 있기 때문에 테스트가 어려울 수 있다.

그럼 테스트코드를 작성해보자.

JPA @Version 어노테이션

@Version 어노테이션은 해당 데이터가 업데이트가 됐을 경우

@Version 어노테이션이 붙은 컬럼의 값을 +1해서 저장한다.

데이터를 업데이트 할 때 다른 스레드에서 먼저 업데이트 되버리면 업데이트가 실패를 시켜야 한다.

업데이트가 실패되면 OptimisticLockException 을 던진다.

서비스 레이어 로직

테스트 코드를 조금 줄이고자 ArticleSaveService 클래스의 도움을 받았다.

이 클래스는 게시글을 저장하는 책임을 갖고 있다.

@Service
@RequiredArgsConstructor
@Slf4j
public class ArticleSaveService {

    private final ArticleRepository articleRepository;

    @Transactional
    public Article saveArticle(String content) {
        log.debug("saveArticle called : {} ",content);
        Article article = new Article(content);
        return articleRepository.save(article);
    }
}

테스트코드 작성

테스트 코드를 어떻게 작성하면 될까?

  • 테스트할 데이터를 넣는다.
  • 두 개의 스레드가 동시에 넣었던 데이터를 수정한다.
  • version 필드가 1만 수정됐는지 확인한다.

그럼 이 흐름대로 테스트코드를 작성해보자

    @Test
    @Transactional
    void 낙관적락_테스트() throws InterruptedException {
        //given
        final int numberOfThreads = 3;
        CountDownLatch latch = new CountDownLatch(numberOfThreads);
        ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads);
        String content = "가나다라";
        String changeContent = "마바사";

        //데이터 삽입
        Article article = articleSaveService.saveArticle(content);

        //when
        for (int i = 0; i < numberOfThreads; i++) {
            executorService.execute(() -> {
                        Article updateArticle = articleRepository.findById(article.getId()).orElseThrow();
                        updateArticle.updateContent(changeContent + UUID.randomUUID());
                        latch.countDown();
                    }
            );
        }
        latch.await();

        Thread.sleep(500);
        Article result = articleRepository.findById(article.getId()).orElseThrow();

        //then
        assertEquals(1,result.getVersion());
    }
    
}

given

  • 동시에 업데이트 할 스레드의 개수를 3개로 정한다.
  • CountDownLatch도 스레드의 개수만큼 생성자에 인자를 줘서 생성한다.
  • 백그라운드에서 돌릴 스레드를 관리하는 ExecutorService 객체도 만든다.
  • 테스트 할 데이터의 게시판 내용을 “가나다라” 로 설정한다.
  • 게시판의 내용을 “마바사”로 변경한다.
  • articleSaveService.saveArticle() 함수를 이용해 테스트할 데이터를 데이터베이스에 넣는다.

when

  • 설정한 스레드 개수만큼 for문을 돌린다.
  • 백그라운드 스레드를 실행한다.
    • 변경할 아티클을 데이터베이스에서 가져온다.
    • 아티클의 내용을 “마바사” + UUID.randomUUID(); 로 변경한다.
    • CountDownLatch.countDown()을 호출해 작업이 끝난다는걸 알린다.

CountDownLatch를 사용한 이유는 모든 스레드가 작업을 완료할 때 까지,

메인스레드를 대기시키기 위해서다.

모든 백그라운드 스레드가 작업을 완료할 때 까지 메인스레드는 대기하게 된다.

  • latch.await()은 CountDownLatch.countDown() 가 스레드 수 만큼 호출될 때 까지 메인스레드를 대기 시킨다.
  • Thread.sleep(500); 으로 메인스레드를 0.5초 정도 대기시켜, 백그라운드 스레드가 완전히 마무리 되도록 잠시 대기한다.
  • articleRepository.findById() 로 데이터베이스에서 변경된 데이터를 가져온다.

then

  • 가져온 게시판의 version이 1인지 확인한다.

이 테스트코드가 잘 작동될 것 같지만. 아쉽게도 오류가 난다.

이미지내용

디버그를 위해 로깅레벨을 debug로 낮춰서 하나씩 살펴보자.

logging:
  level:
    root: debug

로그 결과

Opened new EntityManager [SessionImpl(1779524436<open>)] for JPA transaction
Opened new EntityManager [SessionImpl(898665953<open>)] for JPA transaction
Opened new EntityManager [SessionImpl(1577705187<open>)] for JPA transaction
Opened new EntityManager [SessionImpl(1644342874<open>)] for JPA transaction
Closing JPA EntityManager [SessionImpl(1577705187<open>)] after transaction
Closing JPA EntityManager [SessionImpl(898665953<open>)] after transaction
Closing JPA EntityManager [SessionImpl(1644342874<open>)] after transaction
Exception in thread "pool-1-thread-1" java.util.NoSuchElementException: No value present
	at java.base/java.util.Optional.orElseThrow(Optional.java:375)
	at dev.donghyeon.jpaoptimism.ArticleOptimismTest.lambda$낙관적락_테스트$0(ArticleOptimismTest.java:80)
	at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1130)
	at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:630)
	at java.base/java.lang.Thread.run(Thread.java:832)
Exception in thread "pool-1-thread-2" java.util.NoSuchElementException: No value present
	at java.base/java.util.Optional.orElseThrow(Optional.java:375)
	at dev.donghyeon.jpaoptimism.ArticleOptimismTest.lambda$낙관적락_테스트$0(ArticleOptimismTest.java:80)
	at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1130)
	at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:630)
	at java.base/java.lang.Thread.run(Thread.java:832)
Exception in thread "pool-1-thread-3" java.util.NoSuchElementException: No value present
	at java.base/java.util.Optional.orElseThrow(Optional.java:375)
	at dev.donghyeon.jpaoptimism.ArticleOptimismTest.lambda$낙관적락_테스트$0(ArticleOptimismTest.java:80)
	at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1130)
	at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:630)
	at java.base/java.lang.Thread.run(Thread.java:832)
Closing JPA EntityManager [SessionImpl(1779524436<open>)] after transaction

로그를 보니 NoSuchElementException 예외가 났다.

articleRepository.findById(); 코드가 실행 될 때 데이터를 찾지 못해서 예외가 던져졌다.

분명 우리는 테스트를 시작하기 전에 테스트할 데이터를 저장했는데 왜 그 데이터를 찾지 못할까?

그 이유는 JPA의 EntityManager에 있다.

EntityManager를 살펴보자.

EntityManager는 트랜잭션마다 생긴다.

EntityManager마다 영속성 컨텍스트가 생긴다.

  • 총 4개의 Entitymanager가 생겼다.
    • SessionImpl(1779524436<open>)
    • SessionImpl(898665953<open> )
    • SessionImpl(1577705187<open>)
    • SessionImpl(1644342874<open>)
  • 4개의 Entity가 Close 되는 순서를 살펴보자.
    • SessionImpl(1577705187<open>)
    • SessionImpl(898665953<open> )
    • SessionImpl(1644342874<open>)
    • SessionImpl(1779524436<open>)

맨 처음 데이터를 저장하는 코드인 articleSaveService.saveArticle(content); 의 EntityManager가 제일 늦게 닫혔다.

그 말은 저장한 데이터가 아직 데이터베이스에 반영이 안됐다는 뜻이다.

  • articleSaveService.saveArticle(content); 를 실행하면 영속성 컨텍스트에만 데이터를 영속화 시킨다.
  • 이때 데이터베이스에는 실제로 반영되지 않는다.
  • 모든 로직이 끝나고 낙관적락_테스트() 메서드가 끝날 때 트랜잭션이 끝날 때, 데이터베이스에 반영된다.

그러므로 다른 스레드에서는 데이터베이스에서 이 데이터를 가져 올 수 없기 때문에 오류가 난다.

이미지내용

해결방법은 articleSaveService.saveArticle(content); 시 데이터베이스에 반영하면 된다.

  • articleSaveService.saveArticle(content); 를 다른 트랜잭션에서 실행시킨다.
  • articleSaveService.saveArticle(content); 시 repository.save() 말고, repository.saveflush() 메서드를 사용한다.

전자 방법을 살펴보자.

트랜잭션 분리하기

데이터베이스에 데이터를 반영하는 방법은 독립적인 트랜잭션에서 실행하는 것이다.

해당 트랜잭션이 끝나면 데이터베이스에 반영한다.

articleSaveService.saveArticle(); 코드를 살펴보자.

ArticleSaveService.class

    @Transactional
    public Article saveArticle(String content) {
        log.debug("saveArticle called : {} ",content);
        Article article = new Article(content);
        return articleRepository.save(article);
    }

메서드 레벨에 @Transactional 이 사용되었다.

@Transactional 디폴트 Propagation은 PROPAGATION_REQUIRED 이다.

이미 진행중인 트랜잭션이 존재하면, 그 트랜잭션에 참여하고, 그게 아니라면 자신만의 새로운 트랜잭션을 만든다.

하지만 테스트를 위해 ArticleSaveService 클래스에 변경이 일어나는건 정말 좋지 않다.

그 대신 테스트시 Propagation을 바꿀수 있게 도와주는 TransactionTemplate 을 사용해보자.

테스트 코드 수정

    private TransactionTemplate transaction;

    @BeforeEach
    void setUp() {
        transaction = new TransactionTemplate(tm);
        transaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
    }

새로운 트랜잭션에서 동작하기 위해 PROPAGATION_REQUIRES_NEW 으로 세팅해준다.

그 후 게시판을 저장하는 코드를 아래와 같이 변경한다.

        //데이터 삽입
        Article article = transaction.execute((status -> articleSaveService.saveArticle(content)));

테스트를 실행해보자.

expected: <1> but was: <0>
Expected :1
Actual   :0

테스트가 또 실패한다.

version이 1일거라고 예상했지만 결과는 0으로 나왔다.

다시 코드를 살펴보자.

        //when
        for (int i = 0; i < numberOfThreads; i++) {
            executorService.execute(() -> {
                        Article updateArticle = articleRepository.findById(article.getId()).orElseThrow();
                        updateArticle.updateContent(changeContent + UUID.randomUUID());
                        latch.countDown();
                    }
            );
        }

그 원인은 백그라운드 스레드에서 update 로직이 있는데,

여기서 트랜잭션을 명시안해줬기 때문이다.

하지만 위에서 로그에서는 EntityManager가 open되고 close 된 로그가 있었다.

그 이유는 JpaRepository 구현체에 있다.

JpaRepository는에 트랜잭션이 존재하지 않으면, 이 구현체를 트랜잭션을 시작으로 Readonly로 트랜잭션을 시작한다.

그렇기 때문에, 영속성 컨텍스트에서 더티체킹이 되지 않아 update 문이 날라가지 않았다.

그래서 version이 업데이트가 되지 않았다.

@Repository
@Transactional(readOnly = true)
public class SimpleJpaRepository<T, ID> implements JpaRepositoryImplementation<T, ID> {
  ...
}

위와 비슷하게 트랜잭션을 시작해주는 코드를 넣자.

        //when
        for (int i = 0; i < numberOfThreads; i++) {
            executorService.execute(() ->
                    transaction.execute((status -> {
                        Article updateArticle = articleRepository.findById(article.getId()).orElseThrow();
                        updateArticle.updateContent(changeContent + UUID.randomUUID());
                        latch.countDown();
                        return null;
                    }))
            );
        }

테스트를 실행하면 잘 통과한다.

이미지내용

하지만 여기서 문제가 있다.

이 테스트를 100번정도 돌리면 한 두개정도는 실패한다.

양치기 테스트가 되버린다.

그 이유는 간혹 스레드1이 먼저 작업을 시작하고, 먼저 끝내버린다.

그 다음 스레드2도 먼저 작업을 시작하고 먼저 끝내버리기 때문에, 낙관적 락에 안걸릴 수 있기 때문이다.

그렇기 때문에 일관된 상태를 만들어 줘야 한다.

이미지내용

CyclicBarrier

CyclicBarrier를 사용하면 다른 스레드를 기다려 주기 때문에 일관된 상태를 만들 수 있다.

    @Test
    @Transactional
    void 낙관적락_테스트() throws InterruptedException {
        //given

        final int numberOfThreads = 3;
        CountDownLatch latch = new CountDownLatch(numberOfThreads);
        ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads);
        CyclicBarrier cyclicBarrier = new CyclicBarrier(numberOfThreads);
        String content = "가나다라";
        String changeContent = "마바사";

        //데이터 삽입
        Article article = transaction.execute((status -> articleSaveService.saveArticle(content)));


        //when
        for (int i = 0; i < numberOfThreads; i++) {
            executorService.execute(() ->
                    transaction.execute((status -> {
                        try {
                            Article updateArticle = articleRepository.findById(article.getId()).orElseThrow();
                            cyclicBarrier.await(); //추가
                            updateArticle.updateContent(changeContent + UUID.randomUUID());
                        } catch (InterruptedException | BrokenBarrierException e) {
                            e.printStackTrace();
                        } finally {
                            latch.countDown();
                        }
                        return null;
                    }))
            );
        }
        latch.await(2, TimeUnit.SECONDS);

        Thread.sleep(500);
        Article result = articleRepository.findById(article.getId()).orElseThrow();

        //then
        assertEquals(1, result.getVersion());
    }

스레드 10개 이상으로 테스트한다면

스프링부트 2.0부터 데이터베이스 커넥션 풀이 히카라 풀로 변경이 되었다.

히카라 풀이 기본 커넥션풀이 10개이기 때문에, 데드락 상태가 된다.

그래서 백그라운드 스레드가 10개 이상이면,

10개 모두 커넥션풀을 잡고 있기 때문에 11번째 스레드가 커넥션풀을 기다리고 있는 상태가 된다.

커넥션 풀 개수를 스레드 개수 이상만큼으로 변경해줘야 한다.

낙관적 락 테스트에는 3~4개 정도 스레드만 테스트해도 문제 없긴 하다.

마치며

마지막 코드를 보면 Thread.sleep(500) 코드가 영 찜찜하다.

백그라운드 스레드 로직이 전부 끝날 때 마다 latch.countDown() 을 실행하게 만들어 놨는데,

디버깅 찍어보니 latch.countDown() 을 호출 한 후 트랜잭션 AOP가 데이터베이스에 commit 하는 로그가 있었다.

그래서 임시로 Thread.sleep(500) 을 코드를 넣긴 했지만, 어떻게 해결해야 하는지 좀 더 알아봐야 할 것 같다.