@Override
@Transactional
public PostRepDto getPostById(Long postId, Member member) {
Post post = postRepository.findById(postId).orElseThrow(PostNotFoundException::new);
if(reportRepository.existsByPostAndReporter(post,member)){
throw new ReportedPostAccessDeniedException();
}
if(blockRepository.existsByMemberAndBlockedMember(member, post.getWriter())){
throw new BlockedMemberPostAccessDeniedException();
}
// 조회수 증가
post.increaseView();
log.info("게시글 조회 성공, postId = {}", postId);
return post.toPostRepDto(scrapRepository.existsByMemberIdAndItemIdAndItemType(member.getId(),post.getId(), POST));
}
// Post.java
public void increaseView() {
this.view++;
}
게시글 조회 시 게시글의 조회수를 증가시키는 로직을 구현함.
toBuilder()를 이용하여 조회 수를 증가시킨 후 다시 저장하는 방식.
하지만 여러 명이 동시에 조회 시 조회 수가 제대로 증가하지 못하는 문제 발생.
PostViewConcurrencyTest.java
하나의 게시글을 생성하고, 여러 명의 사용자(멀티 쓰레드 1000개)가 게시글 조회 API 요청 시 조회수가 1000이 되는지 확인하는 테스트 구현.
원래 MySQL 데이터베이스를 사용하지만 테스트 환경과의 분리를 위해 h2 데이터베이스 사용
@Test
@DisplayName("게시글 조회 수 증가 동시성 문제 테스트")
void successPostViewConcurrencyTest() throws InterruptedException {
// given
Member writer = memberRepository.save(Member.builder()
.username("member1")
.role(Role.USER)
.build());
Post post = postRepository.save(Post.builder()
.title("post1")
.writer(writer)
.view(0L)
.contents(List.of(Content.builder().content(CONTENT).type(ContentType.TEXT).build()))
.build());
int threadCount = 1000;
// when
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.execute(() -> {
try {
postReadService.getPostById(post.getId(), writer);
} finally {
latch.countDown();
}
});
}
latch.await();
Post updatedPost = postRepository.findById(post.getId()).orElseThrow();
assertThat(updatedPost.getView()).isEqualTo(threadCount);
System.out.println("게시글 조회수" + " " + updatedPost.getView());
}
한번 조회 시(threadCount : 1)
여러 번 조회 시(threadCount : 1000)
위와 같이 멀티 쓰레드로 테스트 시 1000번 조회가 되면 조회수는 1000이 되어야하는데 111만 증가된 모습 확인
공유 자원의 동시 접근
여러 스레드가 동시에 하나의 변수를 수정하게 되면서 레이스 컨디션이 발생함.
➔ 서로의 연산이 덮어쓰기 때문에 예상한 만큼 증가하지 않는 것
레이스 컨디션이란?
동시에 실행되는 여러 작업이 동일한 자원에 접근하여, 실행 순서나 타이밍에 따라 결과가 달라지는 상황
1. synchronized 키워드 사용?
public synchronized void increaseView() {
this.view++;
}
단일 JVM 내에서 한 번에 하나의 쓰레드만 해당 메서드에 접근하도록 동기화하는 방법
동시에 여러 요청이 들어와도 한 번에 하나만 처리하므로 Race Condition 방지 가능
장점
구현이 간단하다, 별도의 라이브러리나 DB 등이 필요하지 않음
단점
여러 서버 환경에서 무효함
가장 간편한 방법이지만 성능 저하 등 프로젝트의 확장성을 해치는 단점이 존재함.
2. 락을 거는 방식
1. 비관적 락 (Pessimistic Lock)
@Transactional
public void increaseViewCount(Long id) {
Post post = PostRepository.findByIdWithLock(id)
.orElseThrow(() -> new NotFoundException());
post.increaseView();
// 트랜잭션 종료 시 update 자동 수행
}
// Repository
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Post p WHERE p.id = :id")
Optional<Post> findByIdWithLock(@Param("id") Long id);
단점
단점
3. 직접 쿼리 수정 방식
@Modifying + @Query 를 이용하여 한번의 쿼리로 바로 수정하는 방식.
단점
Redis를 이용한 방법
Redis를 이용하여 동시성 문제를 해결하는 방법은 크게 2가지가 존재
Redis 원자적 연산
단일 명령어 실행이 절대 끊기지 않고 완전히 실행되는 것을 보장
build.gradle
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
RedisConfig.java
@Configuration
public class RedisConfig {
@Bean
public RedisConnectionFactory redisConnectionFactory() {
return new LettuceConnectionFactory("localhost", 6379);
}
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory());
template.setKeySerializer(new org.springframework.data.redis.serializer.StringRedisSerializer());
template.setValueSerializer(new org.springframework.data.redis.serializer.StringRedisSerializer());
return template;
}
}
RedisUtil.java
@Component
@RequiredArgsConstructor
public class RedisUtil {
private final RedisTemplate<String, String> redisTemplate;
// 키에 해당하는 조회수 1 증가
public void increaseView(String key){
redisTemplate.opsForValue().increment(key, 1L);
}
// 키에 해당하는 조회수 반환
public Long getViewCount(String key){
String value = redisTemplate.opsForValue().get(key);
return value == null ? null : Long.parseLong(value);
}
// 저장된 모든 키, 값 쌍을 map으로 반환
public Map<String, Long> getAllViewCount(String prefix) {
Set<String> keys = redisTemplate.keys(prefix + "*");
if (keys == null || keys.isEmpty()) {
return Collections.emptyMap();
}
List<Object> values = redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
for (String key : keys) {
connection.stringCommands().get(redisTemplate.getStringSerializer().serialize(key));
}
return null;
});
Map<String, Long> result = new HashMap<>();
int index = 0;
for (String key : keys) {
String value = (String) values.get(index++);
result.put(key, value != null ? Long.parseLong(value) : null);
}
return result;
}
public void setView(String key, Long viewCount) {
redisTemplate.opsForValue().set(key, String.valueOf(viewCount));
}
}
이때 실제 운영 환경에서는 많은 조회수 키값이 저장되기 때문에 좀 더 효율적으로 조회하고자 executePipeLined를 사용
List<Object> values = redisTemplate.executePipelined((RedisCallback<Object>) connection -> {
for (String key : keys) {
connection.stringCommands().get(redisTemplate.getStringSerializer().serialize(key));
}
return null;
});
PostReadServiceImpl.java
public void increaseViewCount(Long postId, Post post) {
String key = VIEW_COUNT_PREFIX + postId;
// Redis의 INCR → 원자적 증가 연산
if(redisUtil.getViewCount(key) == null){
redisUtil.setView(key, post.getView());
}
redisUtil.increaseView(key);
}
조회수 증가 함수
/** 게시글, 리뷰 상세 조회 */
@Override
@Transactional
public PostRepDto getPostById(Long postId, Member member) {
Post post = postRepository.findById(postId).orElseThrow(PostNotFoundException::new);
if(reportRepository.existsByPostAndReporter(post,member)){
throw new ReportedPostAccessDeniedException();
}
if(blockRepository.existsByMemberAndBlockedMember(member, post.getWriter())){
throw new BlockedMemberPostAccessDeniedException();
}
// 조회수 증가
increaseViewCount(post.getId(), post);
String key = VIEW_COUNT_PREFIX + postId;
Long viewCount = redisUtil.getViewCount(key);
if(viewCount == null){
viewCount = post.getView() + 1;
}
log.info("게시글 조회 성공, postId = {}", postId);
return post.toPostRepDto(scrapRepository.existsByMemberIdAndItemIdAndItemType(member.getId(),post.getId(), POST), viewCount);
}
게시글 상세 조회 메서드 수정
@Override
@Scheduled(fixedRate = 300000) // 5분
public void allViewCountFlush(){
log.info("게시글 조회수 flush 작업 수행");
// Redis에 저장된 조회수 값 모두 가져오기
Map<String, Long> views = redisUtil.getAllViewCount(VIEW_COUNT_PREFIX);
// 키값 해당하는 게시글 데이터 모두 조회
List<Long> postIds = views.keySet().stream()
.map(key -> key.substring(VIEW_COUNT_PREFIX.length()))
.map(Long::valueOf).toList();
List<Post> postList = postRepository.findAllById(postIds);
List<Post> updatedPostList = new ArrayList<>();
for (Post post : postList) {
Long view = views.get(VIEW_COUNT_PREFIX + post.getId());
if (view != null) {
updatedPostList.add(post.toBuilder().view(view).build());
}
}
// 한번에 처리
postRepository.saveAll(updatedPostList);
}
Redis에 존재하는 조회수 데이터를 일정 주기로 DB에 반영하는 작업
PostViewConcurrencyTest.java
@Test
@DisplayName("게시글 조회 수 증가 동시성 문제 테스트")
void successPostViewConcurrencyTest() throws InterruptedException {
// given
Member writer = memberRepository.save(Member.builder()
.username("member1")
.role(Role.USER)
.build());
Post post = postRepository.save(Post.builder()
.title("post1")
.writer(writer)
.view(0L)
.contents(List.of(Content.builder().content(CONTENT).type(ContentType.TEXT).build()))
.build());
int threadCount = 1000;
// when
ExecutorService executorService = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
executorService.execute(() -> {
try {
postReadService.getPostById(post.getId(), writer);
} finally {
latch.countDown();
}
});
}
latch.await();
postReadService.allViewCountFlush();
Post updatedPost = postRepository.findById(post.getId()).orElseThrow();
assertThat(updatedPost.getView()).isEqualTo(threadCount);
System.out.println("게시글 조회수" + " " + updatedPost.getView());
}
기존 테스트 메서드에 DB반영 메서드 호출 로직 추가
이전과 달리 조회수가 1000회로 잘 나오는 모습
5분 후
update문이 실행 후 DB에 반영되는 모습을 확인 가능