문제 상황
게시판의 좋아요 기능 구현을 하고 있었습니다. 게시판 좋아요 기능의 로직은 다음과 같습니다.
@Transactional
public LikeResponseDto toggleLike(UUID postId, Long userId) {
Post post = postService.getPostById(postId);
Like like = likeRepository.findFirstByPostAndUserId(post, userId)
.orElse(new Like(post, userId));
// 좋아요 상태 토글
like.toggleLikeStatus();
likeRepository.save(like);
postService.updatePostLikeCount(postId, like.getLikeStatus());
return new LikeResponseDto(like.getLikeId(),postId, userId, like.getLikeStatus());
}
//Like.java
public void toggleLikeStatus() {
this.likeStatus = !this.likeStatus;
}
@Transactional
public Post updatePostLikeCount(UUID postId, boolean likeStatus) {
Post post = getPostById(postId);
if (likeStatus) {
post.incrementLikes();
} else {
post.decrementLikes();
}
return postRepository.save(post);
}
// Post.java
public void incrementLikes() {
this.likes++;
}
public void decrementLikes() {
if (this.likes > 0) {
this.likes--;
}
}
사용자가 좋아요 요청을 하면 toggleLike() 메서드가 호출되어 좋아요 상태를 바꾸고, 이후 updatePostLikeCount() 메서드로 게시물의 좋아요 수를 업데이트합니다.
동시성 문제를 확인하기 위해 JMeter 대신 테스트 코드를 작성했습니다. 여러 사용자들이 동시에 좋아요를 요청하는 상황을 시뮬레이션하기 위한 테스트 코드입니다.
테스트 코드
@SpringBootTest
@Transactional
@Slf4j
public class PostServiceTest {
@Autowired
private PostService postService;
@Autowired
private PostRepository postRepository;
private UUID postId;
@BeforeEach
void setUp() {
postId = UUID.fromString("c4e50cbb-2b04-4536-b0a8-08d1832f7b57");
}
@Test
@Transactional
void multiThreadsLikesTest() throws InterruptedException {
int numberOfThreads = 100;
ExecutorService executorService = Executors.newFixedThreadPool(numberOfThreads);
CountDownLatch latch = new CountDownLatch(numberOfThreads);
// 여러 사용자가 동시에 좋아요를 누름
for (int i = 0; i < numberOfThreads; i++) {
Long userId = (long) i; // 각 사용자마다 고유한 ID 할당
executorService.execute(() -> {
try {
postService.updatePostLikeCount(postId, true);
} finally {
latch.countDown();
}
});
}
// 모든 스레드가 완료될 때까지 대기
latch.await();
// 게시물 좋아요 수 검증
Post post = postService.getPostById(postId);
assertThat(post.getLikes()).isEqualTo(numberOfThreads);
// 결과 확인을 위한 print문
System.out.println("테스트 성공: 게시물 좋아요 수가 " + post.getLikes() + "로 예상한 수치와 일치합니다.");
}
}
- 동시성 시뮬레이션
- 100개의 스레드를 생성하여 100명의 사용자가 동시에 좋아요를 누르는 상황을 시뮬레이션합니다.
- ExecutorService와 CountDownLatch를 사용하여 모든 스레드가 거의 동시에 실행되고 완료되는 것을 보장합니다.
- 각 사용자의 독립성
- 각 스레드에 고유한 userId를 할당하여 서로 다른 사용자의 행동을 모방합니다.
- 동시성 제어 검증
- 모든 스레드가 완료된 후, 최종적인 좋아요 수를 확인합니다.
- 이상적으로는 최종 좋아요 수가 스레드의 수(100)와 일치해야 합니다.
- 결과 검증
- assertThat을 사용하여 예상 결과와 실제 결과를 비교합니다.
이 테스트는 다음과 같은 동시성 관련 문제들을 발견하는 데 도움이 됩니다.
Race Conditon
- 여러 스레드가 동시에 같은 데이터를 수정하려고 할 때 발생할 수 있는 문제
Lost Update
- 한 스레드의 업데이트가 다른 스레드의 업데이트로 인해 덮어써지는 문제
즉, 100개의 스레드를 생성하고, 각기 다른 사용자 아이디를 반복문을 통해서 부여하고, 동시에 같은 게시물 (c4e50...)에 좋아요를 클릭하도록 updatePostLikeCount()메서드에 대한 테스트 코드를 작성했습니다.
테스트 결과는 위 사진과 같이, 이상적으로 100의 좋아요 수가 나와야하지만, 좋아요수가 14로 많은 데이터 손실이 발생했습니다.
문제 원인
이는 동시성 문제로, 여러 트랜잭션이 동시에 같은 데이터를 읽고 수정하는 과정에서 발생하는 경쟁 조건(Race Condition) 때문입니다.
Read ➡ Update ➡ Save 패턴에서 여러 트랜잭션이 동시에 같은 값을 읽고 이를 수정하려다 보니 결과적으로 일부 좋아요 연산이 손실되었습니다.
문제 해결 : 원자적 업데이트
게시판 조회수 동시성 문제를 해결했던것처럼 게시판 좋아요에도 똑같이 원자적 업데이트를 통해 동시성 제어를 했습니다.
@Modifying
@Query(value = "UPDATE p_posts SET likes = likes + CASE WHEN :likeStatus THEN 1 ELSE -1 END " +
"WHERE post_id = :postId AND (likes > 0 OR :likeStatus = true)",
nativeQuery = true)
int updateLikeCount(@Param("postId") UUID postId, @Param("likeStatus") boolean likeStatus);
이 방식으로 데이터베이스에서 likes 컬럼을 원자적으로 업데이트하여 동시성 문제를 방지했습니다.
테스트 결과도 위와 같이 100명의 스레드가 거의 동시에 같은 게시물에 좋아요 요청을 해도 정상적으로 100개의 좋아요가 올라간 것을 확인할 수 있습니다.
결론
원자적 업데이트를 통해 여러 스레드가 동시에 데이터를 처리할 때 발생하는 경쟁 조건 문제를 해결할 수 있었습니다.
이 방식은 데이터베이스에서 직접 연산을 수행하여 동시성을 제어하는 효과적인 방법으로, 특히 Read-Modify-Write 패턴에서 발생하는 동시성 문제를 해결하는 데 적합합니다.
'Trouble Shooting' 카테고리의 다른 글
[트러블 슈팅] BeanCreationException 빈 충돌 해결을 위한 @ConditionalOnProperty 활용 (0) | 2024.10.11 |
---|---|
[트러블 슈팅] 사용자 직접 결제 취소 시 서버 리다이렉트 문제 해결기 (0) | 2024.10.10 |
[트러블 슈팅] 토스페이먼츠 결제 트러블 슈팅 (0) | 2024.10.09 |
[트러블 슈팅] @ModelAttribute의 자동 변환 에러 해결 (0) | 2024.10.07 |
[트러블 슈팅] 게시판 조회수 동시성 문제 트러블 슈팅 (0) | 2024.10.01 |