[모플 프로젝트] 좋아요 기능 동시성 문제 : 오류 95% > 12% > 0% !!

728x90

 

부하 테스트를 하게 된 배경

초기에 설계할 때, `Comment` 엔티티 안에 `likeCount` 를 직접 +1, -1 하도록 했었다. 
엔티티 값을 직접 변경했기 때문에 동시 요청 시 정합성이 깨질 것으로 예상되어서 DB 트랜잭션 기반으로 정합성을 유지하도록 수정하였다.

 

`@Query` + `@Modifying(clearAutomatically = true)` 를 사용하고, 엔티티를 재조회 하여 `LaszInitializationException` 문제도 해결하였었다.

(LazyInitializationException 관련 트러블 슈팅 블로그 글 보러가기)

 

렇게 처리했어도 좋아요 기능은 동시에 여러 사용자가 같은 데이터에 접근하는 것이기 때문에 부하 테스트를 통해 잘 설계됐는지 확인해 보고 싶었다.

 

따라서 `Jmeter` 를 설치하여 확인하기로 하였다.

 

쓰레드 그룹

우선 쓰레드 그룹 설정을 해주었다.
- `쓰레드들의 수` : 100
(동시 요청하는 유저 수)

- `Ramp-Up Period` : 1s

- `루프 카운트` : 50 (총 5000회 요청)

 

HTTP 요청

테스트를 하기 위해 로컬에서 Spring Boot 서버를 띄울 것이기 때문에, `localhost` 와 서버를 띄울 포트 번호를 명시해 준다.

 

그리고 테스트할 api url 을 적어야 한다.

 

이때 테스트 DB 에 30 명의 유저를 생성해 놓고, 테스트할 때 1 ~ 30 중에서 랜덤한 값을 가지고 요청할 수 있도록 했다.

컨트롤러에서 미리 쿼리 파라미터로 userId 를 받도록 임의로 수정하였다.

(실제 운영 때는 절대 이렇게 하면 안 된다!! 테스트할 때만 수정해 놓았다.)

 

Listener

테스트 결과 정보 및 진행 상태 정보를 표시해 주는 것이다.

나는 `결과들의 트리 보기`, `요약 보고서`, `총합 그래프` 를 추가했다.

 

1차 : 트랜잭션만으로 처리

Optional<CommentLike> findByUserIdAndCommentId(Long userId, Long commentId);

@Transactional
public boolean toggleLike(Long userId, PlanComment comment) {
  Optional<CommentLike> existingLike = likeRepository.findByUserIdAndCommentId(userId, comment.getId());

  if (existingLike.isPresent() && comment.canDecreaseLikeCount()) {
    commentRepository.decreaseLikeCount(comment.getId());
    likeRepository.delete(existingLike.get());
    return false;
  }

  commentRepository.increaseLikeCount(comment.getId());
  CommentLike like = CommentLike.builder()
        .userId(userId)
        .commentId(comment.getId())
        .build();
  likeRepository.save(like);

  return true;
}

`findByUserIdAndCommentId` 를 통해 `CommentLike` 엔티티를 가져와 존재하면 삭제하고, 없으면 추가하는 로직이었다.

 

오류 95%

실행하고 보니 오류율 95% 나 나왔다,,,, 이대로 배포했으면 큰일 날 뻔했다,,, 😂

 

서버 로그를 보면 

org.hibernate.NonUniqueResultException: Query did not return a unique result: 2 results were returned

 

JPA 쿼리가 단일 결과를 기대했는데 여러 개가 반환되면서 예외가 발생했다고 적혀있다.
(음...? 왜 여러 개가 반환되지)
 
자료를 찾아보니 
`likeRepository.findByUserIdAndCommentId(userId, comment.getId())` 부분에서 
여러 Thread 가 거의 동시에 호출하면
두 쓰레드 모두 존재하지 않는 것으로 판단하여 동시에 insert 가 일어나게 되어
DB 에 같은 commentId + userId 레코드가 중복되고,
결과를 가져올 때 `NonUniqueResultException` 이 발생하는 것이었다. 🥲

 

2차 : Pessimistic Lock 사용

우선 DB 단에서 `comment_like` 엔티티에 `userId` `commentId` 조합에 `Unique Constraint` 설정하여 중복을 방지하는 것이다.

ALTER TABLE comment_like ADD CONSTRAINT uq_comment_user UNIQUE (comment_id, user_id);

 

그리고 Lock 을 통해 insert 전 존재 여부를 확인하도록 하였다.

@Lock(LockModeType.PESSIMISTIC_WRITE) 
Optional<CommentLike> findByUserIdAndCommentId(Long userId, Long commentId);

`PESSIMISTIC_WRITE Lock` 을 걸어 동시에 접근하는 쓰레드는 첫 쓰레드가 끝날 때까지 대기하도록 하였다.

 

@Transactional
public boolean toggleLike(Long userId, PlanComment comment) {
  Optional<CommentLike> existingLike = likeRepository.findByUserIdAndCommentId(userId, comment.getId());

  if (existingLike.isPresent() && comment.canDecreaseLikeCount()) {
    commentRepository.decreaseLikeCount(comment.getId());
    likeRepository.delete(existingLike.get());
    return false;
  }

  commentRepository.increaseLikeCount(comment.getId());
  CommentLike like = CommentLike.builder()
        .userId(userId)
        .commentId(comment.getId())
        .build();

  try {
    likeRepository.save(like);
  } catch (DataIntegrityViolationException e) {
    return true;
  }

  return true;
}

서비스 단에서 `Unique Constraint` 위반 시 이미 다른 쓰레드가 insert 완료한 것이기 때문에 그냥 무시하는 것으로 처리했다.

 

오류 12%

흠 사실 다 해결될 줄 알았지만 12% 나 나왔다.

 

서버 로그를 보면

중복된 키 값이 "uq_comment_user" 고유 제약 조건을 위반함
(comment_id, user_id)=(1, 21)

동시에 같은 commentId 와 userId 로 insert 를 시도했다는 것이다.

(왜 lock 까지 걸었는데 동시 insert 가 발생하는 것이지....)

 

여기저기 찾아보니 `PESSIMISTIC Lock` 을 적용했어도 

`likeRepository.save(like)` 시 다른 쓰레드가 동시에 insert 시도하게 되면 `race condition` 이 발생 가능하고,

lock 범위를 벗어나 DB constraint 위반이 가능하다는 것이다.. 😶‍🌫️

 

3차 : DB 단에서 insert 무시

서비스 단에서 Lock 을 걸어도 실제 insert 당시 중복 insert 가 발생할 수도 있으니 DB 단에서 insert 할 때 처리하도록 하였다.

기존 `Pessimistic Lock` 은 지워주었다.

 

@Modifying
    @Query(value =
            "insert into comment_like (comment_id, user_id) " +
            "       values (:commentId, :userId) " +
            "       on conflict do nothing"
            , nativeQuery = true)
    void insertIfNotExists(Long commentId, Long userId);

insert 할 때 `on conflict do nothing` 을 사용하여 이미 존재할 때 무시하도록 설정하여 `race condition` 을 방지했다.

 

하지만 Jpa save 는 트랜잭션과 엔티티 상태 관리 때문에 동시 insert 를 막는 데에는 한계가 있기 때문에

`native query` 를 사용하여 수동 insert 사용해야 한다.

 

이때 사용하는 DB 인 postgreSQL 은 `id` 생성 방식이 기본적으로 `SEQUENCE` 이기 때문에

수동 insert 하게 되면 `id` 가 `null` 로 오류가 발생한다. 

 

따라서 어쩔 수 없이 `comment_like` 엔티티만 예외적으로 `IDENTITY` 로 바꿔주었다.

 

@Transactional
public boolean toggleLike(Long userId, PlanComment comment) {
  Optional<CommentLike> existingLike = likeRepository.findByUserIdAndCommentId(userId, comment.getId());

  if (existingLike.isPresent() && comment.canDecreaseLikeCount()) {
    commentRepository.decreaseLikeCount(comment.getId());
    likeRepository.delete(existingLike.get());
    return false;
  }

  commentRepository.increaseLikeCount(comment.getId());
  likeRepository.insertIfNotExists(comment.getId(), userId);

  return true;
}

 

오류 0% !!!!

오류율이 0...? 0이다 !!! 와 0까지 나올 줄은 몰랐다 ㅎㅎㅎㅎ 🤩

 

결과를 보면

평균 `685ms` 최소 `41ms` / 최대 `1319ms` 처리량 `812 req/sec` 결과를 얻게 됐다 !

 

동시에 Insert 가 발생할 수 있는 상황은 애플리케이션 레벨 lock 만으로는 완전히 방지하기 어렵고, DB 단에서 `on conflict do nothing` 같은 처리로 `race condition` 을 방지하는 것이 안전하다는 것을 알게 되었다.

 

 

 

 

 

 

 

 

728x90