부하 테스트를 하게 된 배경
초기에 설계할 때, `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
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` 을 방지하는 것이 안전하다는 것을 알게 되었다.
'💠프로젝트 및 경험 > 프로젝트' 카테고리의 다른 글
[모플 프로젝트] 지연 로딩(Lazy Loading) 문제와 해결 경험 정리 (2) | 2025.07.25 |
---|---|
[메모장 프로젝트] Nignx 무중단 배포 (AWS 5) (1) | 2025.07.18 |
[메모장 프로젝트] 도메인 연결하기 (AWS 4) (0) | 2025.07.18 |
[메모장 프로젝트] GitHub Actions 로 자동 배포 (AWS 3) (0) | 2025.07.18 |
[메모장 프로젝트] RDS PostgreSQL 생성, EC2 에 Redis 설치 (AWS 2) (0) | 2025.07.18 |