From 6bf63e6ebd5d5c73c6d5cb4c77e071592f4e8b86 Mon Sep 17 00:00:00 2001 From: Yunwoo Jeong Date: Fri, 11 Jul 2025 13:42:27 +0900 Subject: [PATCH 01/11] =?UTF-8?q?refactor:=20BmScrap,=20SpLike=20Unique=20?= =?UTF-8?q?=EC=A0=9C=EC=95=BD=20=EC=A1=B0=EA=B1=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/pitchain/bmscrap/domain/BmScrap.java | 1 + .../common/exception/GlobalExceptionHandler.java | 16 ++++++++++++++++ .../java/com/pitchain/splike/domain/SpLike.java | 1 + 3 files changed, 18 insertions(+) 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/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/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 From ce8d8b94a150678638a196f817031c88fb224707 Mon Sep 17 00:00:00 2001 From: Yunwoo Jeong Date: Tue, 15 Jul 2025 12:11:08 +0900 Subject: [PATCH 02/11] =?UTF-8?q?feat:=20Redis=20Script=20=EB=B0=8F=20Redi?= =?UTF-8?q?sHashRepository=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pitchain/common/config/RedisConfig.java | 16 +++++++++ .../common/redis/RedisHashRepository.java | 33 +++++++++++++++++++ .../META-INF/scripts/getanddeleteall.lua | 3 ++ 3 files changed, 52 insertions(+) create mode 100644 src/main/java/com/pitchain/common/redis/RedisHashRepository.java create mode 100644 src/main/resources/META-INF/scripts/getanddeleteall.lua 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/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/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 From 76b60f0252d4b1209f04af043588017768c6ecd1 Mon Sep 17 00:00:00 2001 From: Yunwoo Jeong Date: Tue, 15 Jul 2025 12:12:59 +0900 Subject: [PATCH 03/11] =?UTF-8?q?feat:=20spViews=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=20Long=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/pitchain/sp/application/res/SpDetailRes.java | 2 +- src/main/java/com/pitchain/sp/domain/Sp.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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; } From f91da9c7ede2c28dfddd748d4d8726fb66a0cd37 Mon Sep 17 00:00:00 2001 From: Yunwoo Jeong Date: Tue, 15 Jul 2025 12:14:14 +0900 Subject: [PATCH 04/11] =?UTF-8?q?feat:=20Sp=20=EC=A1=B0=ED=9A=8C=EC=88=98?= =?UTF-8?q?=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 3 + .../pitchain/sp/application/SpService.java | 3 +- .../sp/application/SpViewsService.java | 61 ++++++ .../sp/infrastucture/SpRepositoryCustom.java | 10 + .../sp/infrastucture/dto/SpViewsDto.java | 4 + .../pitchain/service/SpViewsServiceTest.java | 192 ++++++++++++++++++ 6 files changed, 272 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/pitchain/sp/application/SpViewsService.java create mode 100644 src/main/java/com/pitchain/sp/infrastucture/dto/SpViewsDto.java create mode 100644 src/test/java/com/pitchain/service/SpViewsServiceTest.java diff --git a/build.gradle b/build.gradle index 3cf2ef0..7f92fe9 100644 --- a/build.gradle +++ b/build.gradle @@ -54,6 +54,9 @@ dependencies { annotationProcessor "jakarta.annotation:jakarta.annotation-api" annotationProcessor "jakarta.persistence:jakarta.persistence-api" testImplementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' + + // awaitility + testImplementation 'org.awaitility:awaitility:4.3.0' } // ------------------ diff --git a/src/main/java/com/pitchain/sp/application/SpService.java b/src/main/java/com/pitchain/sp/application/SpService.java index 2d8b7cb..9fbcb38 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,8 +35,8 @@ public InfinityScrollRes getSpDetailsFilteredCategory(MemberDetails return spQueryService.getSpDetailsFilteredCategory(memberDetails, mainCategoryInKorean, lastSpId, size); } - @Transactional(readOnly = true) public SpDetailRes getSpDetail(MemberDetails memberDetails, Long bmId, Long spId) { + spViewsService.updateSpView(spId); return spQueryService.getSpDetail(memberDetails, bmId, spId); } 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..1710eb3 --- /dev/null +++ b/src/main/java/com/pitchain/sp/application/SpViewsService.java @@ -0,0 +1,61 @@ +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.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"; + + public void updateSpView(Long spId) { + redisHashRepository.increment(SP_VIEW_REDIS_KEY, String.valueOf(spId), 1L); + } + + @Scheduled(cron = "0 */1 * * * *") + public void runUpdateSpViews() { + updateSpViews(); + } + + @Transactional + public void updateSpViews() { + List list = redisHashRepository.getAndDeleteAll(SP_VIEW_REDIS_KEY); + List spViewsDtoList = parseResult(list); + + for (SpViewsDto spViewsDto : spViewsDtoList) { + spRepositoryCustom.updateSpView(spViewsDto.spId(), spViewsDto.views()); + } + } + + private List parseResult(List list) { + if (list.size() % 2 != 0){ + log.error("조회수 개수가 올바르지 않습니다."); + throw new IllegalArgumentException("list 개수가 올바르지 않습니다."); + } + + List spViewsDtoList = new ArrayList<>(); + for (int i = 0; i < list.size(); i += 2) { + Long spId = Long.parseLong(list.get(i)); + Long views = Long.parseLong(list.get(i + 1)); + + SpViewsDto spViewsDto = new SpViewsDto(spId, views); + spViewsDtoList.add(spViewsDto); + } + + return spViewsDtoList; + } +} 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/test/java/com/pitchain/service/SpViewsServiceTest.java b/src/test/java/com/pitchain/service/SpViewsServiceTest.java new file mode 100644 index 0000000..086de46 --- /dev/null +++ b/src/test/java/com/pitchain/service/SpViewsServiceTest.java @@ -0,0 +1,192 @@ +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.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.*; + +@SpringBootTest +@ActiveProfiles("local") +class SpViewsServiceTest { + + @Autowired + private EntitySaver entitySaver; + @Autowired + private RedisHashRepository redisHashRepository; + @Autowired + private SpViewsService spViewsService; + @Autowired + private SpRepository spRepository; + @Autowired + private SpService spService; + + 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(); + + //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); + + Awaitility.await() + .atMost(Durations.ONE_MINUTE) + .untilAsserted(() -> { + //when + spViewsService.runUpdateSpViews(); + + //then + Sp foundSp = spRepository.findById(spId).orElseThrow(); + Assertions.assertThat(foundSp.getViews()).isEqualTo(1L); + }); + } +} \ No newline at end of file From 53925725e8da10b047042d1d1c8d97a2cfcff844 Mon Sep 17 00:00:00 2001 From: Yunwoo Jeong Date: Tue, 15 Jul 2025 12:30:10 +0900 Subject: [PATCH 05/11] =?UTF-8?q?refactor:=20spViewsService=20=EB=B3=80?= =?UTF-8?q?=EC=88=98=EB=AA=85=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sp/application/SpViewsService.java | 43 +++++++++++++------ 1 file changed, 29 insertions(+), 14 deletions(-) diff --git a/src/main/java/com/pitchain/sp/application/SpViewsService.java b/src/main/java/com/pitchain/sp/application/SpViewsService.java index 1710eb3..631f2aa 100644 --- a/src/main/java/com/pitchain/sp/application/SpViewsService.java +++ b/src/main/java/com/pitchain/sp/application/SpViewsService.java @@ -22,35 +22,50 @@ public class SpViewsService { private static final String SP_VIEW_REDIS_KEY = "spView"; + /** + * Redis에 Sp 조회수 증가 + * @param spId + */ public void updateSpView(Long spId) { redisHashRepository.increment(SP_VIEW_REDIS_KEY, String.valueOf(spId), 1L); } - @Scheduled(cron = "0 */1 * * * *") - public void runUpdateSpViews() { - updateSpViews(); - } - + /** + * Redis에서 DB로 Sp 조회수 업데이트 + */ @Transactional public void updateSpViews() { - List list = redisHashRepository.getAndDeleteAll(SP_VIEW_REDIS_KEY); - List spViewsDtoList = parseResult(list); + List spViewsResult = redisHashRepository.getAndDeleteAll(SP_VIEW_REDIS_KEY); + List spViewsDtoList = parseResult(spViewsResult); for (SpViewsDto spViewsDto : spViewsDtoList) { spRepositoryCustom.updateSpView(spViewsDto.spId(), spViewsDto.views()); } } - private List parseResult(List list) { - if (list.size() % 2 != 0){ - log.error("조회수 개수가 올바르지 않습니다."); - throw new IllegalArgumentException("list 개수가 올바르지 않습니다."); + /** + * 1분마다 Redis에서 DB로 Sp 조회수 업데이트하는 작업 수행 + */ + @Scheduled(cron = "0 */1 * * * *") + public void runUpdateSpViews() { + updateSpViews(); + } + + /** + * Redis에서 가져온 Sp 조회수 String 리스트를 Dto 리스트로 파싱 + * @param List + * @return List + */ + private List parseResult(List spViewsResult) { + if (spViewsResult.size() % 2 != 0){ + log.error("spViewsResult 개수가 올바르지 않습니다."); + throw new IllegalArgumentException("spViewsResult 개수가 올바르지 않습니다."); } List spViewsDtoList = new ArrayList<>(); - for (int i = 0; i < list.size(); i += 2) { - Long spId = Long.parseLong(list.get(i)); - Long views = Long.parseLong(list.get(i + 1)); + for (int i = 0; i < spViewsResult.size(); i += 2) { + Long spId = Long.parseLong(spViewsResult.get(i)); + Long views = Long.parseLong(spViewsResult.get(i + 1)); SpViewsDto spViewsDto = new SpViewsDto(spId, views); spViewsDtoList.add(spViewsDto); From a4760b8a8b2a8d09bf0e8a8c24451aaedce14f2b Mon Sep 17 00:00:00 2001 From: Yunwoo Jeong Date: Tue, 15 Jul 2025 12:36:58 +0900 Subject: [PATCH 06/11] =?UTF-8?q?refactor:=20spViewsService=20=EB=B3=80?= =?UTF-8?q?=EC=88=98=EB=AA=85=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/pitchain/sp/application/SpViewsService.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/pitchain/sp/application/SpViewsService.java b/src/main/java/com/pitchain/sp/application/SpViewsService.java index 631f2aa..53407aa 100644 --- a/src/main/java/com/pitchain/sp/application/SpViewsService.java +++ b/src/main/java/com/pitchain/sp/application/SpViewsService.java @@ -56,16 +56,16 @@ public void runUpdateSpViews() { * @param List * @return List */ - private List parseResult(List spViewsResult) { - if (spViewsResult.size() % 2 != 0){ + private List parseResult(List spViewsStringList) { + if (spViewsStringList.size() % 2 != 0){ log.error("spViewsResult 개수가 올바르지 않습니다."); throw new IllegalArgumentException("spViewsResult 개수가 올바르지 않습니다."); } List spViewsDtoList = new ArrayList<>(); - for (int i = 0; i < spViewsResult.size(); i += 2) { - Long spId = Long.parseLong(spViewsResult.get(i)); - Long views = Long.parseLong(spViewsResult.get(i + 1)); + 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); From 3c914329181645eaddae626860861f612c913785 Mon Sep 17 00:00:00 2001 From: Yunwoo Jeong Date: Tue, 15 Jul 2025 12:38:51 +0900 Subject: [PATCH 07/11] =?UTF-8?q?refactor:=20spViewsService=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/pitchain/sp/application/SpViewsService.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/pitchain/sp/application/SpViewsService.java b/src/main/java/com/pitchain/sp/application/SpViewsService.java index 53407aa..6901515 100644 --- a/src/main/java/com/pitchain/sp/application/SpViewsService.java +++ b/src/main/java/com/pitchain/sp/application/SpViewsService.java @@ -58,8 +58,8 @@ public void runUpdateSpViews() { */ private List parseResult(List spViewsStringList) { if (spViewsStringList.size() % 2 != 0){ - log.error("spViewsResult 개수가 올바르지 않습니다."); - throw new IllegalArgumentException("spViewsResult 개수가 올바르지 않습니다."); + log.error("spViewsStringList 개수가 올바르지 않습니다."); + throw new IllegalArgumentException("spViewsStringList 개수가 올바르지 않습니다."); } List spViewsDtoList = new ArrayList<>(); From ba634f58d090ec4daa48d6b620c968d7c20756f3 Mon Sep 17 00:00:00 2001 From: Yunwoo Jeong Date: Tue, 15 Jul 2025 13:17:08 +0900 Subject: [PATCH 08/11] =?UTF-8?q?refactor:=20=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20SpyBean=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/pitchain/sp/application/SpViewsService.java | 6 +++++- .../com/pitchain/service/SpViewsServiceTest.java | 12 +++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/pitchain/sp/application/SpViewsService.java b/src/main/java/com/pitchain/sp/application/SpViewsService.java index 6901515..8eb1f33 100644 --- a/src/main/java/com/pitchain/sp/application/SpViewsService.java +++ b/src/main/java/com/pitchain/sp/application/SpViewsService.java @@ -48,7 +48,11 @@ public void updateSpViews() { */ @Scheduled(cron = "0 */1 * * * *") public void runUpdateSpViews() { - updateSpViews(); + try { + updateSpViews(); + } catch (Exception e) { + log.error("Scheduling task [runUpdateSpViews] failed", e); + } } /** diff --git a/src/test/java/com/pitchain/service/SpViewsServiceTest.java b/src/test/java/com/pitchain/service/SpViewsServiceTest.java index 086de46..ec5248d 100644 --- a/src/test/java/com/pitchain/service/SpViewsServiceTest.java +++ b/src/test/java/com/pitchain/service/SpViewsServiceTest.java @@ -20,6 +20,7 @@ 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; @@ -31,6 +32,8 @@ import java.util.concurrent.atomic.AtomicBoolean; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.atLeast; +import static org.mockito.Mockito.verify; @SpringBootTest @ActiveProfiles("local") @@ -41,11 +44,11 @@ class SpViewsServiceTest { @Autowired private RedisHashRepository redisHashRepository; @Autowired - private SpViewsService spViewsService; - @Autowired private SpRepository spRepository; @Autowired private SpService spService; + @SpyBean + private SpViewsService spViewsService; private static final String SP_VIEW_REDIS_KEY = "spView"; @@ -178,13 +181,12 @@ void tearDown() { Long spId = sp.getId(); redisHashRepository.increment(SP_VIEW_REDIS_KEY, String.valueOf(spId), 1L); + //then Awaitility.await() .atMost(Durations.ONE_MINUTE) .untilAsserted(() -> { - //when - spViewsService.runUpdateSpViews(); + verify(spViewsService, atLeast(1)).runUpdateSpViews(); - //then Sp foundSp = spRepository.findById(spId).orElseThrow(); Assertions.assertThat(foundSp.getViews()).isEqualTo(1L); }); From 9bff600c53d229c5cb9c16816e802c34a3a26629 Mon Sep 17 00:00:00 2001 From: Yunwoo Jeong Date: Tue, 15 Jul 2025 13:46:33 +0900 Subject: [PATCH 09/11] =?UTF-8?q?fix:=20SpViewsServiceTest=20@ActiveProfil?= =?UTF-8?q?es("local")=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/java/com/pitchain/service/SpViewsServiceTest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/test/java/com/pitchain/service/SpViewsServiceTest.java b/src/test/java/com/pitchain/service/SpViewsServiceTest.java index ec5248d..8a52d79 100644 --- a/src/test/java/com/pitchain/service/SpViewsServiceTest.java +++ b/src/test/java/com/pitchain/service/SpViewsServiceTest.java @@ -36,7 +36,6 @@ import static org.mockito.Mockito.verify; @SpringBootTest -@ActiveProfiles("local") class SpViewsServiceTest { @Autowired From be58d45a68f55604a01f3f0aa39d440ef126d5d6 Mon Sep 17 00:00:00 2001 From: Yunwoo Jeong Date: Wed, 23 Jul 2025 12:20:54 +0900 Subject: [PATCH 10/11] =?UTF-8?q?feat:=20=EB=B9=84=EB=8F=99=EA=B8=B0=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pitchain/common/config/AsyncConfig.java | 73 +++++++++++++++++++ .../pitchain/sp/application/SpService.java | 3 +- .../sp/application/SpViewsService.java | 2 + 3 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/pitchain/common/config/AsyncConfig.java 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/sp/application/SpService.java b/src/main/java/com/pitchain/sp/application/SpService.java index 9fbcb38..65b730a 100644 --- a/src/main/java/com/pitchain/sp/application/SpService.java +++ b/src/main/java/com/pitchain/sp/application/SpService.java @@ -36,8 +36,9 @@ public InfinityScrollRes getSpDetailsFilteredCategory(MemberDetails } public SpDetailRes getSpDetail(MemberDetails memberDetails, Long bmId, Long spId) { + SpDetailRes spDetailRes = spQueryService.getSpDetail(memberDetails, bmId, spId); spViewsService.updateSpView(spId); - return spQueryService.getSpDetail(memberDetails, bmId, 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 index 8eb1f33..6726d27 100644 --- a/src/main/java/com/pitchain/sp/application/SpViewsService.java +++ b/src/main/java/com/pitchain/sp/application/SpViewsService.java @@ -5,6 +5,7 @@ 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; @@ -26,6 +27,7 @@ public class SpViewsService { * Redis에 Sp 조회수 증가 * @param spId */ + @Async public void updateSpView(Long spId) { redisHashRepository.increment(SP_VIEW_REDIS_KEY, String.valueOf(spId), 1L); } From 6e273977defa936a87b052ab0e52452a159e691e Mon Sep 17 00:00:00 2001 From: Yunwoo Jeong Date: Wed, 23 Jul 2025 14:10:40 +0900 Subject: [PATCH 11/11] =?UTF-8?q?fix:=20Sp=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EB=8F=99=EC=8B=9C=EC=84=B1=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EB=B9=84=EB=8F=99=EA=B8=B0=20=EC=B2=98=EB=A6=AC=20=EB=8C=80?= =?UTF-8?q?=EA=B8=B0=20=EC=8B=9C=EA=B0=84=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/java/com/pitchain/service/SpViewsServiceTest.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/pitchain/service/SpViewsServiceTest.java b/src/test/java/com/pitchain/service/SpViewsServiceTest.java index 8a52d79..6fb0919 100644 --- a/src/test/java/com/pitchain/service/SpViewsServiceTest.java +++ b/src/test/java/com/pitchain/service/SpViewsServiceTest.java @@ -32,8 +32,7 @@ import java.util.concurrent.atomic.AtomicBoolean; import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.atLeast; -import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.*; @SpringBootTest class SpViewsServiceTest { @@ -92,6 +91,8 @@ void tearDown() { } latch.await(); + Thread.sleep(5000); //비동기 처리 완료 대기 + //then Map spView = redisHashRepository.findAll(SP_VIEW_REDIS_KEY); String views = spView.get(String.valueOf(sp.getId()));