[JPA] 동시성과 데이터 정합성(feat. 더티체킹)

Simple·2023년 4월 8일
0

JPA

목록 보기
6/6

문제 상황

게시물의 좋아요 기능을 JPA의 더티 체킹 기능(=변경 감지)으로 작동시키고 있었다. 그러다 문득 든 생각이 만약 동시에 여러 요청이 들어왔을때 좋아요 개수의 정합성이 맞을까? 라는 생각을 하고 테스트를 진행했다.

	@Test
    @DisplayName("게시물 좋아요 동시성 테스트")
    public void like_post_concurrent_threadCount() throws Exception {
        int threadCount = 10000;
        //given
        ExecutorService executorService = Executors.newFixedThreadPool(32);

        CountDownLatch latch = new CountDownLatch(threadCount);

        User user = mockUser();
        User githubUser = githubUser();
        AuthUser authUser = AuthUser.of(githubUser);
        Post post = Post.of("테스트 제목","테스트 본문",user);
        given(postRepository.findByIdAndStatus(any(),any()))
                .willReturn(Optional.of(post));
        //when
        for(int i=0; i<threadCount; i++){
            executorService.submit(()->{
                try{
                    postService.likePost(authUser,post.getId());
                }
                finally {
                    latch.countDown();
                }
            });
        }
        latch.await();

        //then
        assertThat(post.getLikeCnt()).isEqualTo(threadCount);
    }

위와 같이 10000번의 요청이 동시에 들어오게되면?

만약 100번의 요청이 동시에 들어오게 되면?

좋아요 개수가 맞지 않는 결과를 볼 수 있었다.

서버가 여러개일 때 그리고 동시에 API 요청이 들어올 때

더티체킹은

  1. JPQL이 실행되거나
  2. 트랜잭션이 커밋 될 때 발생한다

그러면 멀티쓰레딩 환경인 스프링에서 트랜잭션이 커밋되기전에 만약 조회가 된다면? 데이터 정합성이 안맞을 수 있다.

해결 방안

  1. 낙관적 락

    • 트랜잭션 충돌이 발생하지 않을 것이라고 낙관적으로 가정
    • version으로 구분 컬럼을 이용해서 충돌 예방(데이터베이스가 아닌 JPA가 제공하는 버전관리)
    • 버전이 안맞을 경우 수동으로 롤백처리로직 만들어줘야함
  2. 비관적 락

    • 트랜잭션 충돌이 발생한다고 가정하고 락을 우선 걸고보는 방식
    • 데이터베이스에서 제공하는 락 기능을 사용
    • 모든 경우에 락을 걸기 때문에 성능 저하 우려
  3. 쿼리 직접 실행

    • 더티 체킹이 아닌 서비스 레이어에서 레포지토리의 update 메서드를 호출한다.
    • update 쿼리가 데이터베이스 자체적으로 베타락을 걸기 때문에 해결 가능하다.
    • 다만 JPA 의 더티 체킹 기능을 사용하지 않기에, 객체지향적인 관점에서는 옳지 않은 방식일 수도 있다.
  4. Redisson

    • Pub-sub 기반으로 Lock 구현 제공
    • Pub-Sub 방식이란, 채널을 하나 만들고, 락을 점유중인 스레드가, 락을 해제했음을, 대기중인 스레드에게 알려주면 대기중인 스레드가 락 점유를 시도하는 방식
    • Lettuce와 다르게 별도의 Retry 방식을 작성하지 않아도 된다.

결과

쿼리 직접 실행과 Redisson을 이용해 본다.

쿼리 직접 실행 결과

@Modifying(clearAutomatically = true, flushAutomatically = true)
    @Query(value="update Post p set p.likeCnt = p.likeCnt + 1 where p.id = :postId")
    void increaseLikeCount(@Param("postId") Long postId);

100번의 요청 결과

10000번의 요청 결과

Redisson

Redisson 라이브러리 추가

implementation group: 'org.redisson', name: 'redisson-spring-boot-starter', version: '3.19.0'

파사드 패턴을 이용한 클래스 추가

@Component
@RequiredArgsConstructor
@Slf4j
public class RedissonLockPostFacade {

    private final RedissonClient redissonClient;
    private final PostService postService;

    public void likePost(AuthUser authUser, Long postId){
        RLock lock = redissonClient.getLock(postId.toString());

        try {
            // 획득시도 시간, 락 점유 시간
            boolean available = lock.tryLock(5, 1, TimeUnit.SECONDS);

            if (!available) {
                log.info("lock 획득 실패");
                return;
            }
            postService.likePost(authUser,postId);

        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }finally {
            lock.unlock();
        }
    }    

• Lock을 해제하기 전에 COMMIT을 해줘야 동시성 문제가 발생하지 않는다.

10000번 요청한 결과

    @Test
    @DisplayName("게시물 좋아요 / Redisson")
    public void like_post() throws Exception {
        int threadCount = 10000;
        ExecutorService executorService = Executors.newFixedThreadPool(32);
        CountDownLatch latch = new CountDownLatch(threadCount);
        //given
        User user = mockUser();
        userRepository.save(user);

        User githubUser = githubUser();
        userRepository.save(githubUser);

        AuthUser authUser = AuthUser.of(githubUser);
        Post post = createPost("테스트 제목","테스트 내용",user);
        postRepository.save(post);

        //when

        for(int i=0; i<threadCount; i++){
            executorService.submit(()->{
                try{
                    redissonLockPostFacade.likePost(authUser,1L);
                }
                finally {
                    latch.countDown();
                }
            });
        }
        latch.await();

        //then
        Post result = postRepository.findById(1L).get();
        assertThat(result.getLikeCnt()).isEqualTo(threadCount);
    }

결론

redis 자원을 이용해 Redisson을 이용할만큼 좋아요 개수가 서비스에 유의미한 영향을 주는 요소가 아니라 판단되어 쿼리 직접 실행을 채택한다.


참고

profile
몰입하는 개발자

0개의 댓글