Trouble Shooting

멀티 스레드 환경에서 @Transactional을 사용할 때 주의할 점

olsohee 2024. 5. 20. 18:02

이 글에서 소개하는 내용은 동시성 환경을 테스트하기 위한 테스트 코드를 작성하던 중에 만난 문제이다. 

문제

우선 다음과 같이 동시성 환경을 테스트하기 위한 테스트 코드를 작성했다.

  1. 게시글인 post 엔티티를 save() 한 후
  2. 10개의 스레드에서 해당 엔티티를 조회해와서 좋아요를 등록한다.
@Transactional
@SpringBootTest
class PostServiceTest {

    @Autowired private PostService postService;
    @Autowired private PostRepository postRepository;

    @Test
    @DisplayName("좋아요 등록 성공-한 사람이 동시에 여러번 좋아요를 눌러도 중복 저장되지 않아야 한다.")
    void likePostSuccess() throws InterruptedException {
        // 1. post 엔티티 저장
        Post post = Post.create("user@naver.com", "title", "content", Category.FREE);
        postRepository.save(post);
        
        int numberOfThreads = 10;
        CountDownLatch countDownLatch = new CountDownLatch(numberOfThreads);
        ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads);

        for (int i = 0; i < numberOfThreads; i++) {
            executorService.execute(() -> {
                // 2. 각 스레드가 post 엔티티 조회 후 좋아요 등록
                postService.likePost(post.getId(), "likeUser@naver.com");
                countDownLatch.countDown();
            });
        }
        countDownLatch.await();

        Post savedPost = postRepository.findById(post.getId()).get();
        Assertions.assertThat(savedPost.getLikeCount()).isEqualTo(1);
    }
}

 

그런데 postService.likePost() 메소드에서 문제가 발생했다. 해당 메소드는 다음과 같이 postId에 해당하는 post 엔티티를 조회해오는데 이때 해당 엔티티를 찾을 수 없다는 오류가 발생했다.

@Slf4j
@Service
@Transactional
@RequiredArgsConstructor
public class PostService {

    private final PostRepository postRepository;


    public void likePost(long postId, String email) {
        Post post = postRepository.findById(postId)
                .orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND));
        ...
    }
}

 

다음 코드에서 result1의 값은 true인데 result2의 값은 false였다. 

@Transactional
@SpringBootTest
class PostServiceTest {

    @Autowired private PostService postService;
    @Autowired private PostRepository postRepository;

    @Test
    @DisplayName("좋아요 등록 성공-한 사람이 동시에 여러번 좋아요를 눌러도 중복 저장되지 않아야 한다.")
    void likePostSuccess() throws InterruptedException {
        // post 엔티티 저장
        Post post = Post.create("user@naver.com", "title", "content", Category.FREE);
        postRepository.save(post);

        int numberOfThreads = 10;
        CountDownLatch countDownLatch = new CountDownLatch(numberOfThreads);
        ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads);

        boolean result1 = postRepository.findById(post.getId()).isPresent();
        System.out.println("result1 = " + result1);

        for (int i = 0; i < numberOfThreads; i++) {
            executorService.execute(() -> {
                // 각 스레드가 post 엔티티 조회 후 좋아요 등록
                boolean result2 = postRepository.findById(post.getId()).isPresent();
                System.out.println("result2 = " + result2);
                postService.likePost(post.getId(), "likeUser@naver.com");
                countDownLatch.countDown();
            });
        }
        countDownLatch.await();

        Post savedPost = postRepository.findById(post.getId()).get();
        Assertions.assertThat(savedPost.getLikeCount()).isEqualTo(1);
    }
}

 

이유는 멀티 스레드 환경에서 각 스레드는 각각의 트랜잭션을 실행하며, 각각의 영속성 컨텍스트를 갖기 때문이다.

  • 즉, save() 메소드를 호출했을 때 post 엔티티는 아직 실제 DB에 저장되지는 않았으며, 영속성 컨텍스트의 1차 캐시에 저장되어 있는 상태이다.
  • 그리고 이어서 findById() 메소드를 호출하면 영속성 컨텍스트에서 id에 해당하는 엔티티를 찾고, 1차 캐시에 있으므로 반환한다. 그래서 result1 변수의 값이 true가 된다.
  • 그리고 이어서 10개의 독립적인 스레드가 실행된다. 이때 각각의 스레드는 각각의 트랜잭션을 갖고 각각의 영속성 컨텍스트를 갖는다. 따라서 findById() 메소드로 엔티티를 조회하면 영속성 컨텍스트에도, DB에도 해당 엔티티가 없으므로 result2의 값이 false가 되는 것이다.

결론적으로 post 엔티티를 저장하는 로직은 별도의 트랜잭션에서 실행되어야 하며, 트랜잭션이 종료되어 커밋된 후에(=DB에 insert된 후에) 멀티 스레드 환경에서 동시성 테스트가 이뤄져야 한다.

해결 과정 1: 같은 클래스 내에 @Transactional을 붙인 메소드 정의

그러면 트랜잭션을 어떻게 분리할까? 

 

다음과 같이 post 엔티티를 저장하는 로직을 initPost() 라는 메소드로 빼고, 메소드에 @Transactional을 붙여주었다. 그리고 해당 로직은 별도의 트랜잭션에서 실행되어야 하기 때문에 propagation = Propagation.REQUIRES_NEW 옵션을 추가했다. 

@Transactional
@SpringBootTest
class PostServiceTest {

    @Autowired private PostService postService;
    @Autowired private PostRepository postRepository;

    @Test
    @DisplayName("좋아요 등록 성공-한 사람이 동시에 여러번 좋아요를 눌러도 중복 저장되지 않아야 한다.")
    void likePostSuccess() throws InterruptedException {
        // post 엔티티 저장을 위한 메소드 호출
        long savedPostId = initPost();

        int numberOfThreads = 10;
        CountDownLatch countDownLatch = new CountDownLatch(numberOfThreads);
        ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads);

        for (int i = 0; i < numberOfThreads; i++) {
            executorService.execute(() -> {
                postService.likePost(savedPostId, "likeUser@naver.com");
                countDownLatch.countDown();
            });
        }
        countDownLatch.await();

        Post savedPost = postRepository.findById(savedPostId).get();
        Assertions.assertThat(savedPost.getLikeCount()).isEqualTo(1);
    }

    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public long initPost() {
        Post post = Post.create("user@naver.com", "title", "content", Category.FREE);
        postRepository.save(post);
        return post.getId();
    }
}

 

따라서 initPost() 메소드가 호출될 때 별도의 트랜잭션이 실행되고 메소드가 종료되면 커밋되며 DB에 데이터가 정상적으로 insert 될 것을 기대했다.

 

그런데 다음 로그와 같이 initPost() 메소드가 호출될 때 별도의 트랜잭션에서 실행되지 않고 기존 트랜잭션에 참여하고 있었다.

 

이유는 트랜잭션 AOP가 적용된 원본 객체에서 내부 호출을 했기 때문이다. 즉 프록시를 거치지 않고 호출했기 때문이다.

  • 클래스나 메소드에 @Transactional이 하나라도 있으면 스프링의 트랜잭션 AOP가 적용된다.
  • 따라서 원본 객체를 상속받은 프록시 객체가 스프링 빈으로 등록된다.
  • 따라서 우리가 원본 객체의 특정 메소드를 호출하면 프록시 객체가 먼저 해당 요청을 받아서 트랜잭션을 처리하고 실제 객체의 메소드를 호출한다.
  • 그런데 내부 호출을 할 경우, 프록시 객체를 거치지 않기 때문에 트랜잭션이 적용되지 않는다. (참고: https://olsohee.tistory.com/83)

따라서 프록시 객체를 거치지 않고 테스트 메소드에서 initPost() 메소드를 직접 호출했기 때문에 @Transactional(propagation = Propagation.REQUIRES_NEW)이 동작하지 않은 것이다. 

해결 과정 2: 내부 호출을 피하기 위해 클래스 분리

따라서 다음과 같이 initPost() 메소드를 별도의 클래스로 분리해주었다. 

@Transactional
@SpringBootTest
class PostServiceTest {

    @Autowired private PostService postService;
    @Autowired private PostRepository postRepository;
    @Autowired private InitService initService;

    @TestConfiguration
    static class TestConfig {

        @Autowired PostRepository postRepository;

        @Bean
        InitService initService() {
            return new InitService(postRepository);
        }
    }

    @RequiredArgsConstructor
    static class InitService {

        private final PostRepository postRepository;

        @Transactional(propagation = Propagation.REQUIRES_NEW)
        public long initPost() {
            Post post = Post.create("user@naver.com", "title", "content", Category.FREE);
            postRepository.save(post);
            return post.getId();
        }
    }

    @Test
    @DisplayName("좋아요 등록 성공-한 사람이 동시에 여러번 좋아요를 눌러도 중복 저장되지 않아야 한다.")
    void likePostSuccess() throws InterruptedException {
        // post 엔티티 저장을 위한 메소드 호출
        long savedPostId = initService.initPost();

        int numberOfThreads = 10;
        CountDownLatch countDownLatch = new CountDownLatch(numberOfThreads);
        ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads);

        for (int i = 0; i < numberOfThreads; i++) {
            executorService.execute(() -> {
                postService.likePost(savedPostId, "likeUser@naver.com");
                countDownLatch.countDown();
            });
        }
        countDownLatch.await();

        Post savedPost = postRepository.findById(savedPostId).get();
        Assertions.assertThat(savedPost.getLikeCount()).isEqualTo(1);
    }
}

 

이렇게 하면 initService.initPost()가 호출될 때 새로운 트랜잭션이 실행되고 해당 트랜잭션이 종료될 때 커밋된다. 따라서 이후에 테스트 메소드에서 findById()를 통해 엔티티를 조회하면 정상적으로 조회된다.

참고 1: 물리 트랜잭션과 논리 트랜잭션을 구분하자

참고로 InitService.initPost() 메소드에 Rollback false를 설정해주지 않아도 커밋된다. 사실 이때의 커밋은 실제 DB에 커밋되는 것은 아니다. 테스트 메소드이 외부 트랜잭션, InitService.initPost()이 내부 트랜잭션인데, 실제 DB에 커밋을 하는 것은 외부 트랜잭션이며 내부 트랜잭션이 하는 커밋은 실제 DB에 반영되지 않는다. (참고: https://olsohee.tistory.com/85)

참고 2: 스프링 빈으로 등록된 객체가 트랜잭션 AOP가 적용된 객체이다

참고로 만약 다음과 같이 InitService를 스프링 빈으로 등록된 객체를 주입받아 사용하지 않고, new 키워드를 통해 새로 생성해서 사용하면 새로운 트랜잭션이 생성되지 않고 기존 트랜잭션에 참여하게 된다. 앞서 설명한 내용을 잘 이해했다면 이 또한 이해할 수 있을 것이다. new 키워드를 통해 생성한 InitService 객체는 트랜잭션을 처리해주는 AOP가 아니라 실제 객체이기 때문에 @Transactional을 붙여도 트랜잭션에 대한 처리가 이뤄지지 않기 때문이다.

@Transactional
@SpringBootTest
class PostServiceTest {

    @Autowired private PostService postService;
    @Autowired private PostRepository postRepository;
//    @Autowired private InitService initService;

//    @TestConfiguration
//    static class TestConfig {
//
//        @Autowired PostRepository postRepository;
//
//        @Bean
//        InitService initService() {
//            return new InitService(postRepository);
//        }
//    }

    @RequiredArgsConstructor
    static class InitService {

        private final PostRepository postRepository;

        @Transactional(propagation = Propagation.REQUIRES_NEW)
        public long initPost() {
            Post post = Post.create("user@naver.com", "title", "content", Category.FREE);
            postRepository.save(post);
            return post.getId();
        }
    }

    @Test
    @DisplayName("좋아요 등록 성공-한 사람이 동시에 여러번 좋아요를 눌러도 중복 저장되지 않아야 한다.")
    void likePostSuccess() throws InterruptedException {
        // post 엔티티 저장을 위한 메소드 호출
        InitService initService = new InitService(postRepository);
        long savedPostId = initService.initPost();

        int numberOfThreads = 10;
        CountDownLatch countDownLatch = new CountDownLatch(numberOfThreads);
        ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads);

        for (int i = 0; i < numberOfThreads; i++) {
            executorService.execute(() -> {
                postService.likePost(savedPostId, "likeUser@naver.com");
                countDownLatch.countDown();
            });
        }
        countDownLatch.await();

        Post savedPost = postRepository.findById(savedPostId).get();
        Assertions.assertThat(savedPost.getLikeCount()).isEqualTo(1);
    }
}

 

이렇게 멀티 스레드 환경에서 @Transactional을 사용할 때 마주한 문제와 해결 과정을 정리해봤다. 사실 각 스레드마다 트랜잭션과 영속성 컨텍스트가 독립적으로 존재한다는 점, 그리고 @Transactional을 통해 프록시 객체가 생성되며 이 프록시 객체를 통해 트랜잭션이 시작되고 종료된다는 점을 알고 있다면 수월하게 해결 가능한 문제였다. 하지만 공부했던 내용들을 실제 개발 과정에서 마주하니 낯설어서 좀 헤맸다.. 역시 공부만 한다고 되는게 아니라 직접 개발하고 문제를 겪어봐야 더 와닿는 거 같다.