Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.loopers.application.ranking;

import com.loopers.domain.ranking.MvProductRankMonthly;
import com.loopers.domain.ranking.MvProductRankWeekly;
import com.loopers.domain.ranking.ProductRankMvRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@RequiredArgsConstructor
public class MvRankingAppService {

private final ProductRankMvRepository productRankMvRepository;

@Transactional(readOnly = true)
public List<RankingEntry> getWeeklyRankings(String yearWeek, int page, int size) {
return productRankMvRepository.findWeeklyRankings(yearWeek, page, size).stream()
.map(mv -> new RankingEntry(mv.getRanking(), mv.getProductId(), mv.getScore()))
.toList();
}

@Transactional(readOnly = true)
public List<RankingEntry> getMonthlyRankings(String yearMonth, int page, int size) {
return productRankMvRepository.findMonthlyRankings(yearMonth, page, size).stream()
.map(mv -> new RankingEntry(mv.getRanking(), mv.getProductId(), mv.getScore()))
.toList();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,30 +13,34 @@
public class RankingFacade {

private final RankingAppService rankingAppService;
private final MvRankingAppService mvRankingAppService;
private final ProductAppService productAppService;

public List<RankingInfo> getTopRankings(String date, int page, int size) {
List<RankingEntry> entries = rankingAppService.getTopRankings(date, page, size);
return enrichWithProductInfo(entries);
}

if (entries.isEmpty()) {
return List.of();
}

List<Long> productIds = entries.stream()
.map(RankingEntry::productId)
.toList();

Map<Long, Product> productMap = productAppService.getByIds(productIds);
public List<RankingInfo> getWeeklyTopRankings(String yearWeek, int page, int size) {
List<RankingEntry> entries = mvRankingAppService.getWeeklyRankings(yearWeek, page, size);
return enrichWithProductInfo(entries);
}

return entries.stream()
.filter(entry -> productMap.containsKey(entry.productId()))
.map(entry -> RankingInfo.of(entry, productMap.get(entry.productId())))
.toList();
public List<RankingInfo> getMonthlyTopRankings(String yearMonth, int page, int size) {
List<RankingEntry> entries = mvRankingAppService.getMonthlyRankings(yearMonth, page, size);
return enrichWithProductInfo(entries);
}

public List<RankingInfo> getHourlyTopRankings(String hour, int page, int size) {
List<RankingEntry> entries = rankingAppService.getHourlyTopRankings(hour, page, size);
return enrichWithProductInfo(entries);
}

public Long getProductRank(String date, Long productId) {
return rankingAppService.getProductRank(date, productId);
}

private List<RankingInfo> enrichWithProductInfo(List<RankingEntry> entries) {
if (entries.isEmpty()) {
return List.of();
}
Expand All @@ -52,8 +56,4 @@ public List<RankingInfo> getHourlyTopRankings(String hour, int page, int size) {
.map(entry -> RankingInfo.of(entry, productMap.get(entry.productId())))
.toList();
}

public Long getProductRank(String date, Long productId) {
return rankingAppService.getProductRank(date, productId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.loopers.infrastructure.ranking;

import com.loopers.domain.ranking.MvProductRankMonthly;
import com.loopers.domain.ranking.MvProductRankMonthlyId;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface MvProductRankMonthlyJpaRepository extends JpaRepository<MvProductRankMonthly, MvProductRankMonthlyId> {

List<MvProductRankMonthly> findByYearMonthOrderByRankingAsc(String yearMonth, Pageable pageable);

List<MvProductRankMonthly> findByYearMonthOrderByRankingAsc(String yearMonth);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.loopers.infrastructure.ranking;

import com.loopers.domain.ranking.MvProductRankWeekly;
import com.loopers.domain.ranking.MvProductRankWeeklyId;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface MvProductRankWeeklyJpaRepository extends JpaRepository<MvProductRankWeekly, MvProductRankWeeklyId> {

List<MvProductRankWeekly> findByYearWeekOrderByRankingAsc(String yearWeek, Pageable pageable);

List<MvProductRankWeekly> findByYearWeekOrderByRankingAsc(String yearWeek);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.loopers.infrastructure.ranking;

import com.loopers.domain.ranking.MvProductRankMonthly;
import com.loopers.domain.ranking.MvProductRankWeekly;
import com.loopers.domain.ranking.ProductRankMvRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Repository;

import java.util.List;

@Repository
@RequiredArgsConstructor
public class ProductRankMvRepositoryImpl implements ProductRankMvRepository {

private final MvProductRankWeeklyJpaRepository weeklyJpaRepository;
private final MvProductRankMonthlyJpaRepository monthlyJpaRepository;

@Override
public List<MvProductRankWeekly> findWeeklyRankings(String yearWeek, int page, int size) {
return weeklyJpaRepository.findByYearWeekOrderByRankingAsc(yearWeek, PageRequest.of(page, size));
}

@Override
public List<MvProductRankMonthly> findMonthlyRankings(String yearMonth, int page, int size) {
return monthlyJpaRepository.findByYearMonthOrderByRankingAsc(yearMonth, PageRequest.of(page, size));
}

@Override
public void saveAllWeekly(List<MvProductRankWeekly> rankings) {
weeklyJpaRepository.saveAll(rankings);
}

@Override
public void saveAllMonthly(List<MvProductRankMonthly> rankings) {
monthlyJpaRepository.saveAll(rankings);
}

@Override
public void deleteWeeklyByYearWeek(String yearWeek) {
List<MvProductRankWeekly> existing = weeklyJpaRepository.findByYearWeekOrderByRankingAsc(yearWeek);
weeklyJpaRepository.deleteAll(existing);
}

@Override
public void deleteMonthlyByYearMonth(String yearMonth) {
List<MvProductRankMonthly> existing = monthlyJpaRepository.findByYearMonthOrderByRankingAsc(yearMonth);
monthlyJpaRepository.deleteAll(existing);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.IsoFields;
import java.util.List;

@RestController
Expand All @@ -28,15 +29,30 @@ public class RankingController {

@GetMapping("/api/v1/rankings")
public ApiResponse<RankingDto.RankingListResponse> getRankings(
@RequestParam(defaultValue = "daily") String period,
@RequestParam(required = false) String date,
@RequestParam(defaultValue = "" + DEFAULT_SIZE) int size,
@RequestParam(defaultValue = "1") int page
) {
String rankingDate = validateDate(date);
int validatedSize = validatePageSize(size);
int zeroBasedPage = validatePage(page) - 1;

List<RankingInfo> rankings = rankingFacade.getTopRankings(rankingDate, zeroBasedPage, validatedSize);
List<RankingInfo> rankings = switch (period) {
case "daily" -> {
String rankingDate = validateDate(date);
yield rankingFacade.getTopRankings(rankingDate, zeroBasedPage, validatedSize);
}
case "weekly" -> {
String yearWeek = toYearWeek(date);
yield rankingFacade.getWeeklyTopRankings(yearWeek, zeroBasedPage, validatedSize);
}
case "monthly" -> {
String yearMonth = toYearMonth(date);
yield rankingFacade.getMonthlyTopRankings(yearMonth, zeroBasedPage, validatedSize);
}
default -> throw new CoreException(ErrorType.BAD_REQUEST, "period는 daily, weekly, monthly 중 하나여야 합니다.");
};

List<RankingDto.RankingResponse> responses = rankings.stream()
.map(RankingDto.RankingResponse::from)
.toList();
Expand All @@ -62,6 +78,26 @@ public ApiResponse<RankingDto.RankingListResponse> getHourlyRankings(
return ApiResponse.success(new RankingDto.RankingListResponse(responses, page, validatedSize));
}

private String toYearWeek(String date) {
LocalDate targetDate = date != null ? parseDate(date) : LocalDate.now();
int year = targetDate.get(IsoFields.WEEK_BASED_YEAR);
int week = targetDate.get(IsoFields.WEEK_OF_WEEK_BASED_YEAR);
return String.format("%d-W%02d", year, week);
}

private String toYearMonth(String date) {
LocalDate targetDate = date != null ? parseDate(date) : LocalDate.now();
return String.format("%d-%02d", targetDate.getYear(), targetDate.getMonthValue());
}

private LocalDate parseDate(String date) {
try {
return LocalDate.parse(date, DATE_FORMAT);
} catch (Exception e) {
throw new CoreException(ErrorType.BAD_REQUEST, "date는 yyyyMMdd 형식이어야 합니다.");
}
}

private int validatePage(int page) {
if (page < 1) {
throw new CoreException(ErrorType.BAD_REQUEST, "page는 1 이상이어야 합니다.");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -278,8 +278,27 @@ void concurrentLike() throws InterruptedException {
assertThat(successCount.get()).isEqualTo(10);
assertThat(failCount.get()).isZero();

Product updatedProduct = productRepository.findById(productId).orElseThrow();
assertThat(updatedProduct.getLikeCount()).isEqualTo(successCount.get());
// LikeCountEventListener가 @Async + AFTER_COMMIT으로 비동기 처리되므로 polling 대기
int expectedLikeCount = successCount.get();
long timeoutMillis = 10_000;
long pollIntervalMillis = 100;
long deadline = System.currentTimeMillis() + timeoutMillis;

Product updatedProduct;
while (true) {
updatedProduct = productRepository.findById(productId).orElseThrow();
if (updatedProduct.getLikeCount() == expectedLikeCount) {
break;
}
if (System.currentTimeMillis() >= deadline) {
assertThat(updatedProduct.getLikeCount())
.as("likeCount가 %d초 내에 %d에 도달하지 못함 (현재: %d)",
timeoutMillis / 1000, expectedLikeCount, updatedProduct.getLikeCount())
.isEqualTo(expectedLikeCount);
}
Thread.sleep(pollIntervalMillis);
}
assertThat(updatedProduct.getLikeCount()).isEqualTo(expectedLikeCount);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,13 @@
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.test.util.ReflectionTestUtils;

import java.util.List;

import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
Expand All @@ -26,13 +29,21 @@ class QueueSchedulerTest {
@Mock
private TokenService tokenService;

@Mock
private RedissonClient redissonClient;

@Mock
private RLock lock;

@InjectMocks
private QueueScheduler queueScheduler;

@BeforeEach
void setUp() {
ReflectionTestUtils.setField(queueScheduler, "batchSize", 14);
ReflectionTestUtils.setField(queueScheduler, "fixedRate", 1L); // jitter = 0ms 고정
given(redissonClient.getLock(anyString())).willReturn(lock);
given(lock.isHeldByCurrentThread()).willReturn(true);
}

@DisplayName("큐가 비어있으면 아무것도 실행하지 않는다.")
Expand Down
Loading