문제 상황
@Entity
@Table(name = "p_posts")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Post extends BaseEntity {
public void incrementViews(){
this.views++;
}
}
@Transactional
public PostResponseDto getPost(UUID postId) {
Post post = postRepository.findByPostId(postId)
.orElseThrow(() -> new GlowGlowException(GlowGlowError.POST_NO_EXIST));
post.incrementViews();
return PostResponseDto.of(post);
}
게시판 조회에 대한 동시성 문제를 확인하기 위해서 Jmeter로 쓰레드의 수 : 500, Ramp-up : 5초 , Loop Count : 1 로 설정을 하고, 테스트를 진행했습니다.
그러면 500개의 스레드가 거의 동시에 게시물 조회에 요청을 보내는데, 위와 같이 데이터베이스를 확인해보면 예상했던 조회수가 500이 아닌, 448이 나왔습니다. (테스트 마다 계속 달라짐)
이는 게시물 조회에 대한 동시성 문제가 발생했다는 것을 의미합니다. 동시성 문제가 발생하는 큰 이유 2가지는 다음과 같습니다.
1. 읽기와 쓰기의 분리
- findByPostId로 엔티티를 조회한 후, 메모리 상의 객체에서 incrementViews() : 쓰기를 호출합니다.
- 이는 데이터베이스의 실제 값과 일시적으로 불일치를 일으킬 수 있습니다.
2. 경쟁 조건(Race Condition)
- 여러 트랜잭션이 동시에 같은 게시물을 조회하고 조회수를 증가시키려 할 때, 일부 증가 연산이 손실될 수 있습니다.
즉 "읽고-수정하고-쓰기(Read-Modify-Write)" 패턴을 사용합니다.
- 데이터베이스에서 현재 값을 읽어옵니다. ➡️ 읽기
- 애플리케이션 메모리에서 값을 수정합니다. ➡️ 수정
- 수정된 값을 다시 데이터베이스에 씁니다. ➡️ 쓰기
정리하자면, 문제의 이유는 트랜잭션 안에서 Read ➡️ Update ➡️ Save의 순서로 흘러갔고, 여러 트랜잭션이 동시에 Read하여 같은 결과를 Save 했기 때문입니다. 이 과정에서 여러 트랜잭션이 동시에 같은 데이터를 읽고 수정하려 할 때 경쟁 조건이 발생할 수 있습니다.
문제 해결 방법 : 원자적 업데이트
동시성 문제를 해결하는 방법은 여러가지 있습니다. 락을 사용하는 방법, 레디스를 사용하는 방법, 원자적 업데이트 등 많은 방법이 있습니다.
이 중에서 저는 동시성 문제를 해결하기 위해서 원자적 업데이트 방식을 채택했습니다.
@Transactional
public PostResponseDto getPost(UUID postId) {
Post post = postRepository.findByPostId(postId)
.orElseThrow(() -> new GlowGlowException(GlowGlowError.POST_NO_EXIST));
postRepository.incrementViews(postId);
int updatedViews = postRepository.getViews(postId);
post.setViews(updatedViews);
return PostResponseDto.of(post);
}
@Modifying
@Query(value = "UPDATE p_posts SET views = views + 1 WHERE post_id = :postId", nativeQuery = true)
void incrementViews(@Param("postId") UUID postId);
@Query(value = "SELECT views FROM p_posts WHERE post_id = :postId", nativeQuery = true)
int getViews(@Param("postId") UUID postId);
PostgreSQL에서는 UPDATE 문을 명시적으로 실행하면 내부에서 자동으로 락을 걸어 동시성을 제어합니다.
이를 활용하여 @Modifying과 @Query 어노테이션을 사용하여 데이터베이스 수준에서 원자적으로 조회수 증가(UPDATE) 문을 실행해 동시성 문제를 해결했습니다.
동시성 문제를 해결한 이유를 정리하자면 다음과 같습니다.
1. 원자적 업데이트
- incrementViews 메서드는 데이터베이스 수준에서 원자적으로 조회수를 증가시킵니다. 이는 "READ-MODIFY-WRITE" 문제를 방지합니다.
2. 데이터베이스 일관성
- 업데이트 후 getViews를 통해 최신 값을 조회함으로써, 데이터베이스의 실제 값과 애플리케이션의 값이 일치하게 됩니다.
3. 격리된 연산
- 데이터베이스에서 직접 연산을 수행하므로, 여러 트랜잭션이 동시에 실행되어도 각 연산이 격리되어 처리됩니다.
4. 락 활용
- 데이터베이스 시스템의 내부 락 메커니즘을 활용하여 동시 업데이트를 관리합니다.
5. 일관된 읽기
- getViews를 통해 항상 최신의 조회수 값을 읽어오므로, 다른 트랜잭션에 의한 변경사항도 정확히 반영됩니다.
결론적으로, @Modifying 어노테이션과, @Query 어노테이션을 사용하여 데이터베이스 수준에서 동시성을 관리하고, 애플리케이션 레벨에서의 경쟁 조건을 제거함으로써 동시성 문제를 효과적으로 해결합니다.
PostgreSQL에서 내부 락 매커니즘이 걸리는 이유❓
- 초기 상태
- 영속성 컨텍스트: views=100
- 데이터베이스: views=100
- 원자적 업데이트 후
- 영속성 컨텍스트: views=100 (아직 변경 전)
- 데이터베이스: views=101 (UPDATE로 증가됨)
- 이 상태에서는 영속성 컨텍스트와 DB의 데이터가 불일치
- getViews() 호출 후
- 영속성 컨텍스트: views=101 (최신값으로 동기화됨)
- 데이터베이스: views=101
- setViews()로 영속성 컨텍스트의 엔티티를 DB와 동일한 상태로 만듦
'Trouble Shooting' 카테고리의 다른 글
[트러블 슈팅] BeanCreationException 빈 충돌 해결을 위한 @ConditionalOnProperty 활용 (0) | 2024.10.11 |
---|---|
[트러블 슈팅] 사용자 직접 결제 취소 시 서버 리다이렉트 문제 해결기 (0) | 2024.10.10 |
[트러블 슈팅] 토스페이먼츠 결제 트러블 슈팅 (0) | 2024.10.09 |
[트러블 슈팅] @ModelAttribute의 자동 변환 에러 해결 (0) | 2024.10.07 |
[트러블 슈팅] 게시판 좋아요 동시성 문제 해결 (2) | 2024.10.02 |