diff --git a/build.gradle b/build.gradle index 56c6c4f..f662c3e 100644 --- a/build.gradle +++ b/build.gradle @@ -60,6 +60,9 @@ dependencies { // Discord Webhook implementation("club.minnced:discord-webhooks:0.8.4") + + // awaitility + testImplementation 'org.awaitility:awaitility:4.3.0' } // ------------------ diff --git a/src/main/java/com/pitchain/bmscrap/domain/BmScrap.java b/src/main/java/com/pitchain/bmscrap/domain/BmScrap.java index 1240ca5..3bdf12b 100644 --- a/src/main/java/com/pitchain/bmscrap/domain/BmScrap.java +++ b/src/main/java/com/pitchain/bmscrap/domain/BmScrap.java @@ -10,6 +10,7 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(uniqueConstraints = @UniqueConstraint(columnNames = {"member_id", "bm_id"})) public class BmScrap { @Id diff --git a/src/main/java/com/pitchain/common/config/AsyncConfig.java b/src/main/java/com/pitchain/common/config/AsyncConfig.java new file mode 100644 index 0000000..b23bb44 --- /dev/null +++ b/src/main/java/com/pitchain/common/config/AsyncConfig.java @@ -0,0 +1,73 @@ +package com.pitchain.common.config; + +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.TaskDecorator; +import org.springframework.scheduling.annotation.AsyncConfigurer; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.lang.reflect.Method; +import java.util.Map; +import java.util.concurrent.Executor; + +@Configuration +@EnableAsync +public class AsyncConfig implements AsyncConfigurer { + + @Bean + public Executor asyncTaskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + //TODO - corePoolSize, maxPoolSize, queueCapacity는 서비스에 맞게 테스트를 통해 설정 + executor.setCorePoolSize(Runtime.getRuntime().availableProcessors()); + executor.setMaxPoolSize(Runtime.getRuntime().availableProcessors()); +// executor.setQueueCapacity(); + executor.setTaskDecorator(new MdcTaskDecorator()); + executor.setThreadNamePrefix("async"); + executor.setWaitForTasksToCompleteOnShutdown(true); + executor.initialize(); + + return executor; + } + + @Override + public Executor getAsyncExecutor() { + return asyncTaskExecutor(); + } + + @Override + public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { + return new AsyncExceptionHandler(); + } + + @Slf4j + private static class AsyncExceptionHandler implements AsyncUncaughtExceptionHandler { + + @Override + public void handleUncaughtException(Throwable throwable, Method method, Object... params) { + log.error("비동기 실행 중 에러가 발생했습니다: ", throwable); + } + } + + @Slf4j + private static class MdcTaskDecorator implements TaskDecorator { + @Override + public Runnable decorate(Runnable runnable) { + Map contextMap = MDC.getCopyOfContextMap(); + return () -> { + try { + if (contextMap != null) { + MDC.setContextMap(contextMap); + } + runnable.run(); + } finally { + MDC.clear(); + } + }; + } + } + +} diff --git a/src/main/java/com/pitchain/common/config/RedisConfig.java b/src/main/java/com/pitchain/common/config/RedisConfig.java index 56c6c93..af7396d 100644 --- a/src/main/java/com/pitchain/common/config/RedisConfig.java +++ b/src/main/java/com/pitchain/common/config/RedisConfig.java @@ -4,13 +4,21 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.io.ClassPathResource; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.connection.RedisStandaloneConfiguration; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.data.redis.core.script.RedisScript; import org.springframework.data.redis.listener.ChannelTopic; import org.springframework.data.redis.listener.RedisMessageListenerContainer; import org.springframework.data.redis.listener.adapter.MessageListenerAdapter; import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.scripting.ScriptSource; +import org.springframework.scripting.support.ResourceScriptSource; + +import java.io.IOException; +import java.util.List; @Configuration @EnableRedisRepositories @@ -45,4 +53,12 @@ public MessageListenerAdapter messageListenerAdapter(RedisSubscriber subscriber) return new MessageListenerAdapter(subscriber, "onMessage"); } + @Bean + public RedisScript script() throws IOException { + ScriptSource scriptSource = new ResourceScriptSource(new ClassPathResource("META-INF/scripts/getanddeleteall.lua")); + String script = scriptSource.getScriptAsString(); + + return new DefaultRedisScript<>(script, List.class); + } + } diff --git a/src/main/java/com/pitchain/common/exception/GlobalExceptionHandler.java b/src/main/java/com/pitchain/common/exception/GlobalExceptionHandler.java index e682cd6..4563fbd 100644 --- a/src/main/java/com/pitchain/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/pitchain/common/exception/GlobalExceptionHandler.java @@ -6,7 +6,9 @@ import com.pitchain.common.apiPayload.ErrorStatus; import jakarta.validation.ConstraintViolationException; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.context.MessageSource; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.validation.ObjectError; @@ -14,6 +16,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +@Slf4j @RestControllerAdvice @RequiredArgsConstructor public class GlobalExceptionHandler { @@ -72,4 +75,17 @@ public ResponseEntity handleJWTVerificationException(JWTVerifica .status(HttpStatus.UNAUTHORIZED) .body(customResponse); } + + @ExceptionHandler(DataIntegrityViolationException.class) + public ResponseEntity handleDataIntegrityViolationException(DataIntegrityViolationException e) { + log.error("DataIntegrityViolationException = {}", e.getMessage()); + String errorMessage = "DataIntegrityViolationException(제약 조건 위반 오류)가 발생했습니다."; + + HttpStatus httpStatus = HttpStatus.BAD_REQUEST; + CustomResponse customResponse = CustomResponse.onFailure(httpStatus.name(), errorMessage); + + return ResponseEntity + .status(httpStatus) + .body(customResponse); + } } diff --git a/src/main/java/com/pitchain/common/redis/RedisHashRepository.java b/src/main/java/com/pitchain/common/redis/RedisHashRepository.java new file mode 100644 index 0000000..498fa91 --- /dev/null +++ b/src/main/java/com/pitchain/common/redis/RedisHashRepository.java @@ -0,0 +1,33 @@ +package com.pitchain.common.redis; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.HashOperations; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; +import org.springframework.stereotype.Component; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class RedisHashRepository { + + private final RedisTemplate redisTemplate; + private final RedisScript script; + + public void increment(String key, String hashKey, Long value) { + redisTemplate.opsForHash().increment(key, hashKey, value); + } + + public Map findAll(String key) { + HashOperations ops = redisTemplate.opsForHash(); + return ops.entries(key); + } + + public List getAndDeleteAll(String key) { + return redisTemplate.execute(script, Collections.singletonList(key)); + } + +} diff --git a/src/main/java/com/pitchain/sp/application/SpService.java b/src/main/java/com/pitchain/sp/application/SpService.java index 2d8b7cb..65b730a 100644 --- a/src/main/java/com/pitchain/sp/application/SpService.java +++ b/src/main/java/com/pitchain/sp/application/SpService.java @@ -18,6 +18,7 @@ public class SpService { private final SpCommandService spCommandService; private final SpQueryService spQueryService; + private final SpViewsService spViewsService; @Transactional public void createSp(MemberDetails memberDetails, Long bmId, SpCreateReq spCreateReq, MultipartFile thumbnailImg) { @@ -34,9 +35,10 @@ public InfinityScrollRes getSpDetailsFilteredCategory(MemberDetails return spQueryService.getSpDetailsFilteredCategory(memberDetails, mainCategoryInKorean, lastSpId, size); } - @Transactional(readOnly = true) public SpDetailRes getSpDetail(MemberDetails memberDetails, Long bmId, Long spId) { - return spQueryService.getSpDetail(memberDetails, bmId, spId); + SpDetailRes spDetailRes = spQueryService.getSpDetail(memberDetails, bmId, spId); + spViewsService.updateSpView(spId); + return spDetailRes; } @Transactional diff --git a/src/main/java/com/pitchain/sp/application/SpViewsService.java b/src/main/java/com/pitchain/sp/application/SpViewsService.java new file mode 100644 index 0000000..6726d27 --- /dev/null +++ b/src/main/java/com/pitchain/sp/application/SpViewsService.java @@ -0,0 +1,82 @@ +package com.pitchain.sp.application; + +import com.pitchain.common.redis.RedisHashRepository; +import com.pitchain.sp.infrastucture.SpRepositoryCustom; +import com.pitchain.sp.infrastucture.dto.SpViewsDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Service +@RequiredArgsConstructor +public class SpViewsService { + + private final SpRepositoryCustom spRepositoryCustom; + private final RedisHashRepository redisHashRepository; + + private static final String SP_VIEW_REDIS_KEY = "spView"; + + /** + * Redis에 Sp 조회수 증가 + * @param spId + */ + @Async + public void updateSpView(Long spId) { + redisHashRepository.increment(SP_VIEW_REDIS_KEY, String.valueOf(spId), 1L); + } + + /** + * Redis에서 DB로 Sp 조회수 업데이트 + */ + @Transactional + public void updateSpViews() { + List spViewsResult = redisHashRepository.getAndDeleteAll(SP_VIEW_REDIS_KEY); + List spViewsDtoList = parseResult(spViewsResult); + + for (SpViewsDto spViewsDto : spViewsDtoList) { + spRepositoryCustom.updateSpView(spViewsDto.spId(), spViewsDto.views()); + } + } + + /** + * 1분마다 Redis에서 DB로 Sp 조회수 업데이트하는 작업 수행 + */ + @Scheduled(cron = "0 */1 * * * *") + public void runUpdateSpViews() { + try { + updateSpViews(); + } catch (Exception e) { + log.error("Scheduling task [runUpdateSpViews] failed", e); + } + } + + /** + * Redis에서 가져온 Sp 조회수 String 리스트를 Dto 리스트로 파싱 + * @param List + * @return List + */ + private List parseResult(List spViewsStringList) { + if (spViewsStringList.size() % 2 != 0){ + log.error("spViewsStringList 개수가 올바르지 않습니다."); + throw new IllegalArgumentException("spViewsStringList 개수가 올바르지 않습니다."); + } + + List spViewsDtoList = new ArrayList<>(); + for (int i = 0; i < spViewsStringList.size(); i += 2) { + Long spId = Long.parseLong(spViewsStringList.get(i)); + Long views = Long.parseLong(spViewsStringList.get(i + 1)); + + SpViewsDto spViewsDto = new SpViewsDto(spId, views); + spViewsDtoList.add(spViewsDto); + } + + return spViewsDtoList; + } +} diff --git a/src/main/java/com/pitchain/sp/application/res/SpDetailRes.java b/src/main/java/com/pitchain/sp/application/res/SpDetailRes.java index 3060888..8478f3c 100644 --- a/src/main/java/com/pitchain/sp/application/res/SpDetailRes.java +++ b/src/main/java/com/pitchain/sp/application/res/SpDetailRes.java @@ -31,7 +31,7 @@ public record SpDetailRes( @S3Url String thumbnailImgURL, @NotNull - Integer views, + Long views, @NotBlank String name, @NotBlank diff --git a/src/main/java/com/pitchain/sp/domain/Sp.java b/src/main/java/com/pitchain/sp/domain/Sp.java index 4becc0d..09dbdb7 100644 --- a/src/main/java/com/pitchain/sp/domain/Sp.java +++ b/src/main/java/com/pitchain/sp/domain/Sp.java @@ -28,7 +28,7 @@ public class Sp extends BaseEntity { @Column(nullable = false) private String thumbnailImgKey; - private int views = 0; + private Long views; @Column(nullable = false) private String name; @@ -42,7 +42,7 @@ public static Sp of(Bm bm, String thumbnailImgKey, String name) { sp.thumbnailImgKey = thumbnailImgKey; sp.name = name; sp.spStatus = SpStatus.TRANSCODING; - sp.views = 0; + sp.views = 0L; return sp; } diff --git a/src/main/java/com/pitchain/sp/infrastucture/SpRepositoryCustom.java b/src/main/java/com/pitchain/sp/infrastucture/SpRepositoryCustom.java index 5324c4c..f795759 100644 --- a/src/main/java/com/pitchain/sp/infrastucture/SpRepositoryCustom.java +++ b/src/main/java/com/pitchain/sp/infrastucture/SpRepositoryCustom.java @@ -7,6 +7,7 @@ import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; import java.util.List; @@ -46,4 +47,13 @@ private static BooleanExpression eqMainCategory(MainCategory category) { private BooleanExpression ltSpId(Long lastSpId) { return lastSpId == null ? null : sp.id.lt(lastSpId); } + + @Transactional + public long updateSpView(Long spId, Long views) { + return queryFactory + .update(sp) + .set(sp.views, sp.views.add(views)) + .where(sp.id.eq(spId)) + .execute(); + } } diff --git a/src/main/java/com/pitchain/sp/infrastucture/dto/SpViewsDto.java b/src/main/java/com/pitchain/sp/infrastucture/dto/SpViewsDto.java new file mode 100644 index 0000000..12cd9a0 --- /dev/null +++ b/src/main/java/com/pitchain/sp/infrastucture/dto/SpViewsDto.java @@ -0,0 +1,4 @@ +package com.pitchain.sp.infrastucture.dto; + +public record SpViewsDto(Long spId, Long views) { +} diff --git a/src/main/java/com/pitchain/splike/domain/SpLike.java b/src/main/java/com/pitchain/splike/domain/SpLike.java index 5b4440f..9e2c634 100644 --- a/src/main/java/com/pitchain/splike/domain/SpLike.java +++ b/src/main/java/com/pitchain/splike/domain/SpLike.java @@ -10,6 +10,7 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(uniqueConstraints = @UniqueConstraint(columnNames = {"member_id", "sp_id"})) public class SpLike { @Id diff --git a/src/main/resources/META-INF/scripts/getanddeleteall.lua b/src/main/resources/META-INF/scripts/getanddeleteall.lua new file mode 100644 index 0000000..277933c --- /dev/null +++ b/src/main/resources/META-INF/scripts/getanddeleteall.lua @@ -0,0 +1,3 @@ +local data = redis.call('HGETALL', KEYS[1]) +redis.call('DEL', KEYS[1]) +return data \ No newline at end of file diff --git a/src/test/java/com/pitchain/service/SpViewsServiceTest.java b/src/test/java/com/pitchain/service/SpViewsServiceTest.java new file mode 100644 index 0000000..6fb0919 --- /dev/null +++ b/src/test/java/com/pitchain/service/SpViewsServiceTest.java @@ -0,0 +1,194 @@ +package com.pitchain.service; + +import com.pitchain.bm.domain.Bm; +import com.pitchain.common.constant.MemberRole; +import com.pitchain.common.redis.RedisHashRepository; +import com.pitchain.common.security.MemberDetails; +import com.pitchain.company.domain.Company; +import com.pitchain.member.domain.Member; +import com.pitchain.sp.application.SpService; +import com.pitchain.sp.application.SpViewsService; +import com.pitchain.sp.domain.Sp; +import com.pitchain.sp.infrastucture.SpRepository; +import com.pitchain.util.EntitySaver; +import org.assertj.core.api.Assertions; +import org.awaitility.Awaitility; +import org.awaitility.Durations; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@SpringBootTest +class SpViewsServiceTest { + + @Autowired + private EntitySaver entitySaver; + @Autowired + private RedisHashRepository redisHashRepository; + @Autowired + private SpRepository spRepository; + @Autowired + private SpService spService; + @SpyBean + private SpViewsService spViewsService; + + private static final String SP_VIEW_REDIS_KEY = "spView"; + + private Member individualMember; + private MemberDetails individualMemberDetails; + private Bm bm; + private Company company; + + @BeforeEach + void setUp() { + Member companyMember = entitySaver.saveCompanyMember(); + company = entitySaver.saveCompany(companyMember); + individualMember = entitySaver.saveIndividualMember(); + individualMemberDetails = new MemberDetails(individualMember.getId(), MemberRole.INDIVIDUAL); + bm = entitySaver.saveBm(company); + } + + @AfterEach + void tearDown() { + redisHashRepository.getAndDeleteAll(SP_VIEW_REDIS_KEY); + spRepository.deleteAll(); + } + + @Test + void SP_조회_동시성() throws InterruptedException { + //given + Sp sp = entitySaver.saveSp(bm); + + //when + int threadCount = 100; + ExecutorService executorService = Executors.newFixedThreadPool(32); + CountDownLatch latch = new CountDownLatch(threadCount); + + for (int i = 0; i < threadCount; i++) { + executorService.submit(() -> { + try { + spService.getSpDetail(individualMemberDetails, bm.getId(), sp.getId()); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + + Thread.sleep(5000); //비동기 처리 완료 대기 + + //then + Map spView = redisHashRepository.findAll(SP_VIEW_REDIS_KEY); + String views = spView.get(String.valueOf(sp.getId())); + assertEquals(100, Integer.parseInt(views)); + } + + @Test + @DisplayName("Sp 조회수 DB 업데이트 중 Sp 조회 동시성 테스트") + @Transactional(propagation = Propagation.NEVER) + void SP_조회수_업데이트_동시성() throws InterruptedException, ExecutionException { + //given + int spSize = 100; + List spIds = new ArrayList<>(); + for (int i = 0; i < spSize; i++) { + Sp sp = entitySaver.saveSp(bm); + spIds.add(sp.getId()); + } + + //when + //1) updateSpView 멀티스레드 실행 + //updateSpView: Redis에 Sp 조회수 증가 + int threadCount = 100; + ExecutorService executorService = Executors.newFixedThreadPool(32); + + //updateSpView에 대한 CountDownLatch 설정 + CountDownLatch latch = new CountDownLatch(spSize * threadCount); + + //updateSpView 스레드 실행 + for (Long spId : spIds) { + for (int i = 0; i < threadCount; i++) { + executorService.execute(() -> { + try { + spViewsService.updateSpView(spId); + } finally { + latch.countDown(); + } + }); + } + } + + //2) updateSpViews 싱글스레드 반복 실행 + //updateSpViews: Redis -> DB 조회수 업데이트 + + //updateSpViews 스레드 종료를 위한 플래그 + AtomicBoolean isRunning = new AtomicBoolean(true); + + //updateSpViews 스레드 실행 + ExecutorService updateExecutorService = Executors.newSingleThreadExecutor(); + Future updateFuture = updateExecutorService.submit(() -> { + try { + //조회수 DB에 업데이트 + while (isRunning.get()) { + spViewsService.updateSpViews(); + Thread.sleep(60000); + } + + //마지막으로 남은 데이터 처리 + spViewsService.updateSpViews(); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + + latch.await(); //updateSpView 모든 스레드 작업 끝날 때까지 대기 + executorService.shutdown(); + + isRunning.set(false); //updateSpViews 종료 유도 + + updateExecutorService.shutdown(); + updateFuture.get(); //updateSpViews 완전히 종료될 때까지 대기 + + //then + Map spViewList = redisHashRepository.findAll(SP_VIEW_REDIS_KEY); + List spList = spRepository.findAll(); + long totalCount = spList.stream().mapToLong(Sp::getViews).sum(); + + Assertions.assertThat(spViewList).isEmpty(); + Assertions.assertThat(totalCount).isEqualTo(spSize * threadCount); + } + + @Test + void SP_조회수_업데이트_스케줄링() { + //given + Sp sp = entitySaver.saveSp(bm); + Long spId = sp.getId(); + redisHashRepository.increment(SP_VIEW_REDIS_KEY, String.valueOf(spId), 1L); + + //then + Awaitility.await() + .atMost(Durations.ONE_MINUTE) + .untilAsserted(() -> { + verify(spViewsService, atLeast(1)).runUpdateSpViews(); + + Sp foundSp = spRepository.findById(spId).orElseThrow(); + Assertions.assertThat(foundSp.getViews()).isEqualTo(1L); + }); + } +} \ No newline at end of file