정리정리

게시글 좋아요 기능 개선기 (동시성, 데드락) 본문

개발 기록

게시글 좋아요 기능 개선기 (동시성, 데드락)

wlsh 2023. 6. 13. 00:57

사이드 프로젝트로 간단한 sns를 만들면서, 게시글에 좋아요를 누르는 기능을 만드는 과정에서 생긴 여러 문제점과 개선하는 과정을 기록해보려고 합니다.

기본적인 좋아요 기능 구현

우선 게시글(Post)과 좋아요(Like)가 일대다 연관관계에 있는 상태로 구현을 했습니다.

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Post extends BaseTimeEntity {

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

    private int likeCount;
    
    public void increaseLikeCount() {
    	this.likeCount++;
    }
    
    //...
}
@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Like extends BaseTimeEntity {

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

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "post_id")
    private Post post;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;

	//...
}

그리고 다음처럼 LikeService를 구현했습니다.

@Service
@Transactional
@RequiredArgsConstructor
public class LikeService {

    private final MemberRepository memberRepository;
    private final PostRepository postRepository;
    private final LikeRepository likeRepository;

    public void like(Long memberId, Long postId) {
        Post post = getPost(postId);
        Member member = getMember(memberId);

        validateAlreadyLikedPost(memberId, postId);

    	likeRepository.save(new Like(post, member));
        post.increaseLikeCount();
    }

    private void validateAlreadyLikedPost(Long memberId, Long postId) {
        if (likeRepository.existsByMemberIdAndPostId(memberId, postId)) {
            throw new AlreadyLikedPostException(postId, memberId);
        }
    }
    
    //...
}

로직은 단순히 memberId와 postId를 통해 Like가 존재하는지 확인한 후, 없으면 Like를 저장하고 Post의 likeCount를 늘리는 방식으로 흘러갑니다.

테스트 코드를 작성한 후 확인을 해보면 문제없이 성공한 것을 볼 수 있었습니다.

문제점 - 동시성

저는 처음 sns를 설계할 때 여러 유저들이 사용할 것을 가정했고, 여러 시나리오 중 많은 사람들이 동시에 좋아요를 누르면 어떻게 될까 라는 의문을 가졌습니다.

그래서 100명이 거의 동시에 클릭을 했을 때를 가정한 테스트 코드를 작성했습니다.

@Test
@DisplayName("100명이 동시에 좋아요를 누를 경우 100개의 좋아요와 likeCount가 100개 올라야 함")
void likeTest_100_differentMembers() throws Exception {
    //given
    int threadCount = 100;
    Member member = memberRepository.save(getBasicMember());
    Post post = postRepository.save(getBasicPost(member));
    List<Member> members = new ArrayList<>();
    for (int i = 0; i < threadCount; i++) {
        members.add(memberRepository.save(getBasicMember()));
    }

    ExecutorService executorService = Executors.newFixedThreadPool(32);
    CountDownLatch latch = new CountDownLatch(threadCount);

    //when
    for (int i = 0; i < threadCount; i++) {
        int finalI = i;
        executorService.submit(() -> {
            try {
                likeService.like(members.get(finalI).getId(), post.getId());
            } finally {
                latch.countDown();
            }
        });
    }
    latch.await();

    //then
    List<Like> likes = likeRepository.findAll();
    Post findPost = postRepository.findById(post.getId()).get();
    assertThat(likes).hasSize(threadCount);
    assertThat(findPost.getLikeCount()).isEqualTo(threadCount);
}

 

테스트의 흐름은 다음과 같습니다.

  • 우선 100명이 동시에 요청을 하기 위해 100명의 유저를 만듭니다.
  • 병렬 처리를 위한 ExecuterService와 모든 스레드들이 끝나는 것을 기다리기 위한 CountDownLatch를 만듭니다.
  • 100명이 동시에 하나의 게시글에 좋아요를 누릅니다.

만약 올바르게 구현이 되었다면 실제 db에 저장된 좋아요의 수와 게시글의 좋아요 수가 100개가 되어야 합니다.

그렇다면 테스트 결과는 어떨까요?

무려 85개의 좋아요 수가 증발한 것을 볼 수 있습니다.

이렇게 데이터 부정합이 발생하는 이유는 좋아요를 누를 때 Post를 select 한 후 그때 읽은 likeCount를 이용해 값을 update를 하기 때문에, 동시에 여러 트랜잭션이 값을 읽게 되었을 때 가장 마지막 commit의 결과가 최종적으로 반영되기 때문입니다.

이를 그림으로 표현하면 다음과 같은 상황이 됩니다.

update 쿼리에서 likeCount가 0 + 1로 된 이유는 JPA의 select를 통한 데이터를 기준으로 더티 체킹이 진행됐고, 그에 따른 쿼리가 만들어졌기 때문입니다.

update 쿼리를 통한 구현

첫 번째 구현의 문제점을 해결하는 방법으로는 update 쿼리를 직접 실행하는 방법이 있습니다.

객체 지향과 조금 멀어지는 방식이지만 가장 쉽게 해결할 수 있어 해당 방법으로 코드를 수정해보겠습니다.

public interface PostRepository extends JpaRepository<Post, Long> {

    @Modifying(clearAutomatically = true)
    @Query("update Post p set p.likeCount = p.likeCount + 1 where p.id = :postId")
    void increaseLikeCount(@Param("postId") Long postId);
}
public void like(Long memberId, Long postId) {
    Post post = getPost(postId);
    Member member = getMember(memberId);

    validateAlreadyLikedPost(memberId, postId);

    likeRepository.save(new Like(post, member));
    postRepository.increaseLikeCount(postId);
}

이런 식으로 직접 likeCount = likeCount + 1 이라는 쿼리를 작성하면, 영속성 컨텍스트에서 관리하는 데이터가 아닌 db에서 관리하는 데이터를 기준으로 수정이 이뤄지기 때문에 올바른 데이터 변경을 기대할 수 있게 됩니다.

해당 코드로 다시 한번 테스트를 해보겠습니다.

이번에는 기대한 대로 likeCount가 100으로 잘 저장이 된 모습을 볼 수 있었습니다.

문제점 - 데드락

사실 여기서 해결이 된 줄 알고 해당 코드로 변경 후 성능 테스트를 진행하는데 테스트가 도중에 멈춰버리는 일이 발생하였습니다.

로그를 살펴보니 MySQLTransactionRollbackException 이 발생했고, 원인은 데드락이 생겼다는 이유였습니다.

어디에서 데드락이 걸렸는지 확인해 보기 위해 SHOW ENGINE INNODB STATUS 명령어를 통해 확인을 해본 결과, 게시글에서 데드락이 걸렸다는 내용을 볼 수 있었습니다.

처음에 이 상황을 봤을 때는 게시글에 베타 락(X Lock)이 걸리는 것은 이해를 할 수 있었지만 처음에 공유 락(S Lock)이 걸리는 이유에 대해서는 알 수가 없었습니다.

왜 이런 일이 발생하나 찾아보니 MySQL의 InnoDB에 대한 이해도가 낮아서 생긴 일이었습니다.

MySQL은 insert, delete, update 쿼리를 실행하면 foreign key에 대해서 올바른 데이터가 존재하는지 확인하는 과정을 거칩니다. 그 과정에서 InnoDB는 foreign key로 참조된 레코드에 대해 공유 락을 걸게 됩니다.

In an SQL statement that inserts, deletes, or updates many rows, foreign key constraints (like unique constraints) are checked row-by-row. When performing foreign key checks, InnoDB sets shared row-level locks on child or parent records that it must examine. 공식문서

이전 테스트가 성공한 이유는 h2 데이터베이스 환경으로 돌아갔기 때문이었던 것이죠.

테스트 디비를 MySQL로 변경을 하고 예외가 발생하는 것을 확인하기 위한 새로운 테스트 코드를 추가했습니다.

@Test
@DisplayName("데드락 예외가 발생해야 함")
void deadlockTest() throws Exception {
    Member member = memberRepository.save(getBasicMember());
    Post post = postRepository.save(getBasicPost(member));
    ExecutorService executorService = Executors.newFixedThreadPool(2);

    while (true) {
        Member member1 = memberRepository.save(getBasicMember());
        Member member2 = memberRepository.save(getBasicMember());
        Future<?> submit1 = executorService.submit(() -> likeService.like(member1.getId(), post.getId()));
        Future<?> submit2 = executorService.submit(() -> likeService.like(member2.getId(), post.getId()));
        try {
            submit1.get();
            submit2.get();
        } catch (Exception e) {
            executorService.shutdown();
            throw e;
        }
    }
}

h2 디비로 해당 테스트를 돌리면 무한 루프에 빠지지만, MySQL로 돌리면 금세 데드락이 걸리면서 테스트가 종료되는 모습을 볼 수 있었습니다.

공식 문서에 나온 대로 likes가 insert 될 때 foreign key가 걸린 레코드에 대해 공유 락이 걸리는지도 확인을 해보았습니다.

문서에서 설명한 것처럼, 실제로 likes 테이블에 insert를 하면 foreign key 제약사항이 걸려있는 post 레코드에 대해 공유 락이 걸려있었습니다.

이를 기반으로 조금만 생각을 하면 왜 데드락이 걸렸는지 쉽게 알 수 있습니다.

그림처럼 서로 다른 트랜잭션이 동시에 likes 테이블에 같은 post를 foreign key로 하는 데이터를 insert 할 경우, 해당 post에 두 개의 공유 락이 걸리게 됩니다.

그리고 post를 수정하는 트랜잭션에서는 다른 트랜잭션에서 공유 락을 걸었기 때문에 wait을 하게 되고, 추후에 다른 트랜잭션에서 수정을 하는 쿼리를 작성하면 데드락이 발생하게 됩니다.

데드락이 발생했음을 감지한 트랜잭션은 롤백되며, 덕분에 wait을 하던 트랜잭션에서는 수정을 할 수 있게 됩니다.

실제로 해당 시나리오를 확인해 보겠습니다.

select for update를 이용한 구현

그렇다면 애초부터 post를 조회할 때 베타 락(X Lock)을 걸면 문제를 쉽게 해결할 수 있지 않을까요?

JPA에서 지원하는 락 중 하나인 @Lock(LockModeType=PESSIMISTIC_WRITE)를 이용하여 조회를 하면 select for update 쿼리가 작성되어 해당 레코드에 베타 락을 걸 수 있습니다.

또한 조회할 때 베타 락을 건다면 맨 처음에 문제가 되었던 post.increaseLikeCount 메서드도 다시 사용을 할 수 있게 됩니다.

public interface PostRepository extends JpaRepository<Post, Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select p from Post p where p.id = :postId")
    Optional<Post> findByIdWithPessimisticLock(@Param("postId") Long postId);
}
public void like(Long memberId, Long postId) {
    Post post = getPost(postId);
    Member member = getMember(memberId);

    validateAlreadyLikedPost(memberId, postId);

    likeRepository.save(new Like(post, member));
    post.increaseLikeCount();

}

private Post getPost(Long postId) {
    return postRepository.findByIdWithPessimisticLock(postId)
            .orElseThrow(() -> new PostNotFoundException(postId));
}

그리고 테스트를 해보겠습니다.

역시나 기대했던 대로 테스트를 통과한 것과, post를 조회할 때 for update가 마지막에 붙은 쿼리가 작성되는 것을 확인할 수 있었습니다.

문제점 - 성능

테스트 소요 시간을 보면 뭔가 문제가 있다는 걸 알 수 있습니다. 테스트를 한 번 하는데 무려 1.2초나 걸린 것이죠.

비록 데드락이 걸리는 구현이었지만, 두 번째 구현의 소요 시간인 704ms와 비교를 해보면 시간이 거의 두 배나 증가한 셈입니다.

해당 구현을 기반으로 ngrinder를 이용해 성능 테스트를 진행을 해봤습니다.

vuser를 30명으로 두고 테스트를 했을 때 TPS가 53, MTT가 357ms 정도가 나왔고, 로컬 환경에서 테스트를 돌렸기 때문에 높은 퍼포먼스를 기대할 수 없는 것을 감안해도 아주 낮은 성능을 보이는 것을 짐작할 수 있었습니다.

쿼리 순서 변경

사실 조금 더 근본적인 문제를 생각해 보면 likes를 insert 할 때 post 레코드에 공유 락이 걸리는 것입니다.

다시 말하면, 다른 트랜잭션에서 공유 락을 걸어둔 post 레코드에 대해 update를 하기 때문에 데드락이 생기는 것이죠.

그렇다면 처음부터 post를 먼저 update를 하면 문제를 해결할 수 있다는 생각이 들었습니다.

그렇게 되면 잠시 update를 할 post에 베타 락이 걸리겠지만, 수정에 성공하면 바로 락이 풀리기 때문에 다른 트랜잭션에서 데드락이 걸릴 가능성이 사라지게 된다고 판단했습니다.

public void like(Long memberId, Long postId) {
    Post post = getPost(postId);
    Member member = getMember(memberId);

    validateAlreadyLikedPost(memberId, postId);

    postRepository.increaseLikeCount(postId);
    likeRepository.save(new Like(post, member));
}

private Post getPost(Long postId) {
    return postRepository.findById(postId)
            .orElseThrow(() -> new PostNotFoundException(postId));
}

기존 테스트들이 무리 없이 통과하는 것을 보였고, 문제없다고 생각해 해당 코드를 기반으로 성능 테스트도 돌려보았습니다.

  Pessimistic Write Insert 쿼리 -> Save 쿼리
TPS 56.2 70.3
Peak TPS 68.0 87.5
MTT(ms) 356.88 260.87

단순히 코드 두 줄의 순서를 바꾼 것만으로도 성능이 눈에 띄게 올라갔습니다.

문제점 - 가독성

사실 이건 큰 문제가 아닐 수도 있지만 그래도 문제라면 문제라고 할 수 있을 것 같습니다.

기본적으로 코드의 흐름을 따라가다 보면 likes 생성 -> likeCount 증가라는 흐름이 자연스럽게 느껴지기 때문이죠.

또 누군가가 비슷한 생각을 하여 두 코드의 순서를 바꾼다고 할지라도 당장 눈에 띄는 문제가 발생하지 않기 때문에 성능 저하의 이유를 찾기 힘들어질 수도 있습니다.

그렇다고 주석을 쓰자니 코드 스멜이 될 확률이 매우 높아지죠.

increaseLikeCount 트랜잭션 분리

제가 마지막으로 생각한 방법은 이벤트 처리였습니다.

우선 '좋아요의 수'라는 데이터 정합성이 아주 중요하냐에 대해 생각을 해봐야 합니다.

물론 맨 처음 코드처럼 100명이 좋아요를 눌렀을 때 85%가 사라지는 것은 문제가 되지만, 그정도의 수준이 아니라면 '좋아요 수'의 정합성은 지금의 서비스에서 크게 중요하지 않다고 판단을 했습니다.

약간의 오차가 있어도 된다는 말은 트랜잭션에서 분리를 해도 된다는 뜻이고, 그래서 이벤트를 이용해 기능을 분리하는 방식으로 구현을 변경했습니다.

@Getter
@RequiredArgsConstructor
public class PostLikeCountIncreasedEvent {
    private final Long postId;
}
@Service
@Transactional
@RequiredArgsConstructor
public class LikeService {

    //...
    private final ApplicationEventPublisher eventPublisher;

    public void like(Long memberId, Long postId) {
        Post post = getPost(postId);
        Member member = getMember(memberId);

        validateAlreadyLikedPost(memberId, postId);

        likeRepository.save(new Like(post, member));
        eventPublisher.publishEvent(new PostLikeCountIncreasedEvent(postId));
    }
    
    //...
}
@Component
@RequiredArgsConstructor
public class PostEventHandler {

    private final PostRepository postRepository;

    @Async
    @Transactional
    @TransactionalEventListener
    public void increaseLikeCount(PostLikeCountIncreasedEvent event) {
        Long postId = event.getPostId();

        postRepository.increaseLikeCount(postId);
    }
}

이렇게 구현을 하면 코드의 흐름도 지킬 수 있을뿐더러 likeCount를 업데이트하는 부분을 비동기로 처리하기 때문에 유저의 응답 시간에 영향을 미치지 않게 됩니다.

해당 구현으로도 성능 테스트를 해봤습니다.

  Insert 쿼리 -> Save 쿼리 트랜잭션 분리 및 이벤트 처리
TPS 70.3 62.1
Peak TPS 87.5 76.0
MTT(ms) 260.87 231.01

아무래도 트랜잭션을 분리했기 때문인지 TPS는 전보다 떨어졌지만 응답 속도인 MTT는 조금 향상한 모습을 볼 수 있었습니다.

지금 보니 테스트들에서 에러가 꽤나 많이 나왔지만 이는 ngrinder의 동시성 문제로 validateAlreadyLikedPost에서 예외가 발생했기 때문이었습니다.

정확히 원인이 무엇인지는 알아내지 못했지만 HTTPRequest가 static으로밖에 구현을 못해서 동일한 요청을 여러 번 보내는 상황이 만들어졌던 것 같습니다.

문제점 - 테스트

안 그래도 동시성 테스트 때문에 비동기 처리를 하는데 각각의 스레드에서 또 비동기로 새로운 이벤트를 호출하고 있기 때문에 테스트 코드가 점점 엉망이 되게 됩니다.

@Test
@DisplayName("100명이 동시에 좋아요를 누를 경우 100개의 좋아요와 likeCount가 100개 올라야 함")
void likeTest_100_differentMembers() throws Exception {
    //given
    int threadCount = 100;
    Member member = memberRepository.save(getBasicMember());
    Post post = postRepository.save(getBasicPost(member));
    List<Member> members = new ArrayList<>();
    for (int i = 0; i < threadCount; i++) {
        members.add(memberRepository.save(getBasicMember()));
    }

    ExecutorService executorService = Executors.newFixedThreadPool(32);
    CountDownLatch latch = new CountDownLatch(threadCount);

    //when
    for (int i = 0; i < threadCount; i++) {
        int finalI = i;
        executorService.submit(() -> {
            try {
                likeService.like(members.get(finalI).getId(), post.getId());
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            } finally {
                latch.countDown();
            }
        });
    }
    latch.await();

    //then
    List<Like> likes = likeRepository.findAll();
    Post findPost = postRepository.findById(post.getId()).get();
    assertThat(likes).hasSize(threadCount);
    assertThat(findPost.getLikeCount()).isEqualTo(threadCount);
}

이전 테스트 코드에서 likeService.like 후에 해당 스레드에 1초를 sleep 하는 코드를 추가하였습니다.

그렇지 않으면 해당 스레드가 likes를 생성하고 바로 종료되면서 likeCount를 증가시키는 이벤트가 끝나기 전에 latch의 수가 0이 되어 테스트가 종료되기 때문입니다.

그리고 이건 테스트 db를 MySQL로 바꾸면서 발생하는 문제점인데, 데이터를 저장하거나 수정하는 쿼리가 테스트 스레드에서 이뤄지지 않기 때문에 데이터가 롤백되지 않게 됩니다.

또한 저 같은 경우는 여기에서는 작성하지 않았지만 따로 테스트가 끝나면 h2 디비를 초기화하는 클래스를 구현을 해뒀는데, 거기에서 h2 디비만의 SQL 문법을 사용했기 때문에 따로 MySQL을 사용하는 테스트 코드만 따로 모아 롤백하는 번거로운 작업을 추가해야 했습니다.

정리

지금까지 게시글 좋아요 기능을 구현하면서 겪은 문제들과 해결하거나 수정했던 제 경험들을 기록해 봤습니다.

개인적으로 느끼기에는 여전히 성능이 기대만큼 잘 나오지는 않은 것 같아서 캐시를 적용시켜 성능을 향상시켜 보려고 합니다.

이 부분은 추후에 따로 포스팅을 해보도록 하겠습니다.

그리고 아직 취업 준비 중인 학생의 입장에서 작성한 글이라 잘못된 부분이 있을 수도 있기 때문에, 혹시 이보다 더 좋은 방법들이 있거나 잘못된 점이 있다면 공유해 주시면 감사하겠습니다.

참고

https://kapentaz.github.io/jpa/mysql/JPA-@OneToMany%EC%99%80-foreign-key-%EA%B7%B8%EB%A6%AC%EA%B3%A0-deadlock/#

https://dev.mysql.com/doc/mysql-reslimits-excerpt/5.7/en/ansi-diff-foreign-keys.html

https://codechacha.com/ko/java-countdownlatch/

https://thalals.tistory.com/370

Comments