Skip to content

Conversation

@kilian-develop
Copy link
Collaborator

@kilian-develop kilian-develop commented Jan 1, 2026

๐Ÿ“Œ Summary

๊ตฌํ˜„ ๊ธฐ๋Šฅ ๋ชฉ๋ก

  • ์ผ๊ฐ„ ์ƒํ’ˆ ๋ฉ”ํŠธ๋ฆญ์„ ํ™œ์šฉํ•œ ์ฃผ๊ฐ„ ๋ฉ”ํŠธ๋ฆญ ์ง‘๊ณ„
  • ์ผ๊ฐ„ ์ƒํ’ˆ ๋ฉ”ํŠธ๋ฆญ์„ ํ™œ์šฉํ•œ ์›”๊ฐ„ ๋ฉ”ํŠธ๋ฆญ ์ง‘๊ณ„
  • ์ง‘๊ณ„ ๋ฐ์ดํ„ฐ๋ฅผ ๊ธฐ๋ฐ˜ํ•œ ์ฃผ๊ฐ„, ์›”๊ฐ„ ๋žญํ‚น ์กฐํšŒ

๋ฉ”ํŠธ๋ฆญ ์ง‘๊ณ„

๋ฉ”ํŠธ๋ฆญ์€ Spring Batch๋ฅผ ํ†ตํ•ด ๊ตฌํ˜„ํ–ˆ์Šต๋‹ˆ๋‹ค.
๊ธฐ์กด์— ์žˆ๋˜ api, streamer์•ˆ์— ๊ฐ™์ด ๊ตฌํ˜„ํ•˜๋ฉด ์‹ค์‹œ๊ฐ„ ๊ณ ๊ฐ ๊ฒฝํ—˜์„ ๋‹ด๋‹นํ•˜๋Š” ๋‹ค๋ฅธ ์„œ๋น„์Šค์— ๋ฐฐํฌ ์‹œ, ํ˜น์€ ๋ฌธ์ œ ๋ฐœ์ƒ ์‹œ ์˜ํ–ฅ์„ ์ค„ ์ˆ˜ ์žˆ์„ ๊ฑฐ๋ผ๊ณ  ํŒ๋‹จํ•˜์—ฌ Spring Batch๋ฅผ ๋ณ„๋„ ์–ดํ”Œ๋ฆฌ์ผ€์ด์…˜(commerce-batch)์œผ๋กœ ๊ตฌ์„ฑํ–ˆ์Šต๋‹ˆ๋‹ค.

์ง‘๊ณ„ Job์€ ๋‘ Step์œผ๋กœ ๊ตฌ๋ถ„๋ฉ๋‹ˆ๋‹ค.
Step1. ์ง‘๊ณ„ํ•  ์ „์ฒด ์ƒํ’ˆ ID๋ฅผ ์—ฌ๋Ÿฌ Grid๋กœ ๊ตฌ๋ถ„ํ•ฉ๋‹ˆ๋‹ค.
Step2. ์ง‘๊ณ„ ๋ฐ์ดํ„ฐ๋ฅผ ์ฝ๊ณ , ์ฃผ๊ฐ„ ์ง‘๊ณ„, ์›”๊ฐ„ ์ง‘๊ณ„ ๋ฐ์ดํ„ฐ๋ฅผ ์ƒ์„ฑํ•˜์—ฌ ์ €์žฅํ•ฉ๋‹ˆ๋‹ค.

์œ„์™€ ๊ฐ™์ด ๋‘ Step์œผ๋กœ ๊ตฌ๋ถ„์ง€์€ ์ด์œ ๋Š” ๋‹ค์Œ๊ณผ ๊ฐ™์Šต๋‹ˆ๋‹ค.
์ผ์ผ ์ง‘๊ณ„ ๋ฐ์ดํ„ฐ๊ฐ€ ์ˆ˜์‹ญ๋งŒ๊ฑด์ด ๋„˜์–ด๊ฐ€๋Š” ๊ฒฝ์šฐ, Scaling์„ ํ†ตํ•ด Step์„ ๋‚˜๋ˆ„์–ด ๋…๋ฆฝ์ ์œผ๋กœ ์ฒ˜๋ฆฌํ•  ํ•„์š”๊ฐ€ ์žˆ์–ด๋ณด์˜€์Šต๋‹ˆ๋‹ค.
์ด Scailing ๋ฐฉ๋ฒ• ์ค‘ partitioner๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๊ตฌํ˜„ํ–ˆ์Šต๋‹ˆ๋‹ค. ์ฐธ๊ณ ๋ฌธ์„œ

๋‘ Step์€ ์•„๋ž˜์™€ ๊ฐ™์ด ์„ค์ •ํ–ˆ์Šต๋‹ˆ๋‹ค.

Step1. ํŒŒํ‹ฐ์…”๋‹

    @Bean
    public Step partitionDailyMetricStep(
            JobRepository jobRepository,
            Step collectDailyMetricStep,
            WeeklyProductMetricBatchPartitioner partitioner,
            @Value("${batch.weekly-product-metric.partition.grid-size:4}") int gridSize
    ) {
        return new StepBuilder("partitionDailyMetricStep", jobRepository)
                .partitioner("collectDailyMetricStep", partitioner)
                .step(collectDailyMetricStep)
                .taskExecutor(asyncTaskExecutor())
                .gridSize(gridSize)
                .build();
    }

๊ฐ ์„ค์ •์€ ์•„๋ž˜์™€ ๊ฐ™์Šต๋‹ˆ๋‹ค.

  1. partitioner : ํ•˜์œ„ Step์„ ๋ถ„๋ฆฌํ•  ํŒŒํ‹ฐ์…”๋„ˆ๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค.
  2. step : ํ•˜์œ„ Step์„ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค.
  3. taskExecutor : ํ•˜์œ„ Step๋“ค์„ ์‹คํ–‰ ์‹œํ‚ฌ Executor๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค.
  4. gridSize : ํ•˜์œ„ ์Šคํ…์„ ๋‚˜๋ˆŒ gridSize๋ฅผ ์ •์˜ํ•ฉ๋‹ˆ๋‹ค. (์ด ๊ฐ’์ด 4์ธ ๊ฒฝ์šฐ, 4๊ฐœ์˜ Step์œผ๋กœ ๋™์ž‘)

Step2. ์ฃผ๊ฐ„ ์ง‘๊ณ„

    @Bean
    public Step collectDailyMetricStep(
            JobRepository jobRepository,
            PlatformTransactionManager transactionManager,
            ItemStreamReader<ProductMetricAggregation> synchronizedWeeklyProductMetricReader,
            WeeklyProductMetricBatchWriter weeklyProductMetricBatchWriter,
            @Value("${batch.weekly-product-metric.chunk:50}") int chunk
    ) {
        return new StepBuilder("collectDailyMetricStep", jobRepository)
                .<ProductMetricAggregation, ProductMetricAggregation>chunk(chunk, transactionManager)
                .reader(synchronizedWeeklyProductMetricReader)
                .writer(weeklyProductMetricBatchWriter)
                .taskExecutor(asyncTaskExecutor())
                .faultTolerant()
                .retry(DataAccessException.class)
                .build();
    }
  1. ๋‹จ๊ฑด์”ฉ ์‹คํ–‰๋˜๋Š” processor๊ฐ€ ์ง‘๊ณ„์— ํฐ ์˜๋ฏธ๊ฐ€ ์—†๋‹ค๊ณ  ํŒ๋‹จํ•˜์—ฌ reader์™€ writer๋กœ๋งŒ ๊ตฌํ˜„ํ–ˆ์Šต๋‹ˆ๋‹ค.
  2. retry๋Š” ์ผ์‹œ์ ์ธ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์ ‘๊ทผ ๋ฌธ์ œ์—๋งŒ ์žฌ์‹œ๋„ ํ•  ์ˆ˜ ์žˆ๋„๋ก ํ–ˆ์Šต๋‹ˆ๋‹ค.
  3. Reader์˜ ๊ฒฝ์šฐ ๋ฉ€ํ‹ฐ์“ฐ๋ ˆ๋“œ๋ฅผ ํ†ตํ•œ taskExecutor๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ์—ฌ๋Ÿฌ ์“ฐ๋ ˆ๋“œ๊ฐ€ Reader์— ๋™์‹œ ์ ‘๊ทผํ•˜์—ฌ ์ƒ๊ธฐ๋Š” ๋ฌธ์ œ๋ฅผ ๋ฐฉ์ง€ํ•˜๊ธฐ์œ„ํ•ด SynchronizedItemStreamReader๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๊ตฌํ˜„ํ–ˆ์Šต๋‹ˆ๋‹ค.
    @Bean
    @StepScope
    public ItemStreamReader<ProductMetricAggregation> synchronizedWeeklyProductMetricReader(
            DailyProductMetricRepository dailyProductMetricRepository
    ) {
        WeeklyProductMetricBatchReader reader = new WeeklyProductMetricBatchReader(dailyProductMetricRepository);
        return new SynchronizedItemStreamReaderBuilder<ProductMetricAggregation>()
                .delegate(reader)
                .build();
    }

TaskExecutor๋Š” ์ˆ˜์‹ญ๋งŒ๊ฑด์˜ ์ƒํ’ˆ์„ ๋™๊ธฐ๋กœ Step์„ ๋™๊ธฐ์ ์œผ๋กœ ์‹คํ–‰ํ•˜๋ฉด ์„ฑ๋Šฅ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•  ๊ฒƒ์ด๋ผ๊ณ  ์ƒ๊ฐํ•˜์—ฌ,
Virtual Thread ๋ฉ€ํ‹ฐ์“ฐ๋ ˆ๋“œ ๊ธฐ๋ฐ˜์œผ๋กœ ๊ตฌํ˜„ํ–ˆ์Šต๋‹ˆ๋‹ค.

    @Bean
    public TaskExecutor asyncTaskExecutor() {
        SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor("weekly-product-metric-batch-");
        executor.setVirtualThreads(true);
        executor.setConcurrencyLimit(20);
        return executor;
    }

Partitioner ๊ตฌํ˜„์€ ์•„๋ž˜์™€ ๊ฐ™์Šต๋‹ˆ๋‹ค.

public class WeeklyProductMetricBatchPartitioner implements Partitioner {

    private final DailyProductMetricRepository dailyProductMetricRepository;

    @Value("#{jobParameters['startDate']}")
    private String startDateParam;

    @Value("#{jobParameters['endDate']}")
    private String endDateParam;

    @Override
    public Map<String, ExecutionContext> partition(int gridSize) {
        LocalDate startDate = LocalDate.parse(startDateParam);
        LocalDate endDate = LocalDate.parse(endDateParam);
        Long totalCount = dailyProductMetricRepository.countDistinctProductIdsBy(startDate, endDate);

        if (totalCount == 0) {
            return Collections.emptyMap();
        }

        long targetSize = (totalCount / gridSize) + 1;
        Map<String, ExecutionContext> partitions = new HashMap<>();

        for (int i = 0; i < gridSize; i++) {
            ExecutionContext context = new ExecutionContext();

            context.putLong("partitionOffset", i * targetSize);
            context.putLong("partitionLimit", targetSize);
            context.putString("startDate", startDateParam);
            context.putString("endDate", endDateParam);

            partitions.put("partition" + i, context);
        }

        return partitions;
    }
}
  1. ์ง‘๊ณ„ํ•  ์ผ์ผ ๋ฉ”ํŠธ๋ฆญ์˜ ์‹œ์ž‘์ผ, ์ข…๋ฃŒ์ผ์„ ๊ธฐ์ค€์œผ๋กœ ์ฒ˜๋ฆฌํ•  ์ƒํ’ˆ์˜ ์ˆ˜๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.
  2. ์ „์ฒด ์ฒ˜๋ฆฌํ•ด์•ผ ํ•  ์ƒํ’ˆ์„ gridSize์— ๋งž์ถฐ offset ๊ณผ limit์„ ๊ณ„์‚ฐํ•˜์—ฌ ExecutionContext์— putํ•ฉ๋‹ˆ๋‹ค.

๋งŒ์•ฝ ์ด 2000๊ฐœ๋ผ๋ฉด 500๊ฐœ์”ฉ ๋‚˜๋‰˜์–ด ๋™์ž‘ํ•ฉ๋‹ˆ๋‹ค.

ItemReader๋Š” ์•„๋ž˜์™€ ๊ฐ™์ด ๊ตฌํ˜„ํ–ˆ์Šต๋‹ˆ๋‹ค.

@RequiredArgsConstructor
public class WeeklyProductMetricBatchReader implements ItemStreamReader<ProductMetricAggregation> {

    private final DailyProductMetricRepository dailyProductMetricRepository;
    private Iterator<ProductMetricAggregation> iterator;

    @Override
    public void open(@NonNull ExecutionContext executionContext) {
        LocalDate startDate = LocalDate.parse(executionContext.getString("startDate"));
        LocalDate endDate = LocalDate.parse(executionContext.getString("endDate"));
        long partitionOffset = executionContext.getLong("partitionOffset");
        long partitionLimit = executionContext.getLong("partitionLimit");

        this.iterator = dailyProductMetricRepository.findAggregatedBy(startDate, endDate, partitionOffset, partitionLimit)
                .iterator();
    }

    @Nullable
    @Override
    public ProductMetricAggregation read() {
        if (Objects.isNull(iterator) || !iterator.hasNext()) {
            return null;
        }

        return iterator.next();
    }
}
  1. open : Reader๊ฐ€ ์‹œ์ž‘๋  ๋•Œ, ํŒŒํ‹ฐ์…”๋„ˆ๋กœ๋ถ€ํ„ฐ ExecutionContext์„ ๋ฐ›์•„์„œ ์ฒ˜๋ฆฌํ•  ๋ฐ์ดํ„ฐ๋ฅผ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค.
  2. read : ์ „์ฒด ์กฐํšŒํ•œ ๋ฐ์ดํ„ฐ๋ฅผ ์ฝ์Šต๋‹ˆ๋‹ค.

ItemWriter๋Š” ์•„๋ž˜์™€ ๊ฐ™์ด ๊ตฌํ˜„ํ•ฉ๋‹ˆ๋‹ค.

@StepScope
@Component
@RequiredArgsConstructor
public class WeeklyProductMetricBatchWriter implements ItemStreamWriter<ProductMetricAggregation> {

    private final WeeklyProductMetricRepository repository;
    private YearMonthWeek yearMonthWeek;

    @Override
    public void open(@NonNull ExecutionContext executionContext) {
        LocalDate startDate = LocalDate.parse(executionContext.getString("startDate"));
        this.yearMonthWeek = YearMonthWeek.from(startDate);
    }

    @Override
    public void write(@NonNull Chunk<? extends ProductMetricAggregation> chunk) {
        List<WeeklyProductMetric> weeklyMetrics = chunk.getItems().stream()
                .map(aggregation -> aggregation.to(yearMonthWeek))
                .toList();

        repository.bulkUpsert(weeklyMetrics);
    }
}
  1. open : ์‹œ์ž‘์ผ๋กœ๋ถ€ํ„ฐ ๋ช‡๋…„, ๋ช‡์›”, ๋ช‡์ฃผ์ฐจ์ธ์ง€๋ฅผ ๊ณ„์‚ฐํ•ฉ๋‹ˆ๋‹ค.
  2. 'write' : jdbc์˜ batchUpdate๋ฅผ ํ†ตํ•ด ๋งŽ์€ ์–‘์˜ ๋ฐ์ดํ„ฐ๋ฅผ ๋น ๋ฅด๊ฒŒ ์ €์žฅ ํ˜น์€ ์—…๋ฐ์ดํŠธ ํ•  ์ˆ˜ ์žˆ๋„๋กํ•ฉ๋‹ˆ๋‹ค.

์ƒํ’ˆ ๋žญํ‚น ์กฐํšŒ

์ƒํ’ˆ ๋žญํ‚น์€ type์„ ๋ฐ›์•„ ์ผ์ผ, ์ฃผ๊ฐ„, ์›”๊ฐ„์„ ์กฐํšŒํ•  ์ˆ˜ ์žˆ๋„๋ก ํ–ˆ์Šต๋‹ˆ๋‹ค.
์ผ๊ฐ„ ๋žญํ‚น์€ ๊ธฐ์กด๊ณผ ๋™์ดํ•˜๊ฒŒ ์‹ค์‹œ๊ฐ„์œผ๋กœ redis๋ฅผ ํ†ตํ•ด ๋ฐ›์•„์˜ค๋ฉฐ ์ฃผ๊ฐ„, ์›”๊ฐ„์€ metric์„ ํ†ตํ•ด ๊ณ„์‚ฐํ•ฉ๋‹ˆ๋‹ค.

์ด ๋ถ€๋ถ„์€ ์ „๋žตํŒจํ„ด์„ ํ†ตํ•ด type๋ณ„๋กœ ๋‹ค๋ฅธ ์ „๋žต์„ ์‚ฌ์šฉํ•˜๋„๋ก ํ–ˆ์Šต๋‹ˆ๋‹ค.

์ „๋žต ์ธํ„ฐํŽ˜์ด์Šค

public interface GetProductRankingsStrategy {

    boolean supports(String type);

    ProductRankings getRankings(LocalDate date, Integer pageNo, Integer pageSize);
}

์ผ์ผ ๋žญํ‚น ์กฐํšŒ

@Component
@RequiredArgsConstructor
public class GetDailyProductRankingsStrategy implements GetProductRankingsStrategy {

    private final ProductRankingCacheRepository productRankingCacheRepository;

    @Override
    public boolean supports(String type) {
        return type.equals("DAILY");
    }

    @Override
    public ProductRankings getRankings(LocalDate date, Integer pageNo, Integer pageSize) {
        return productRankingCacheRepository.getRankings(date, pageNo, pageSize);
    }
}

์ฃผ๊ฐ„ ๋žญํ‚น ์กฐํšŒ

@Component
@RequiredArgsConstructor
public class GetWeeklyProductRankingsStrategy implements GetProductRankingsStrategy {

    private final WeeklyProductMetricRepository weeklyProductMetricRepository;

    @Value("${product.ranking.score.weight.pay}")
    private Double payWeight;

    @Value("${product.ranking.score.weight.view}")
    private Double viewWeight;

    @Value("${product.ranking.score.weight.like}")
    private Double likeWeight;

    @Override
    public boolean supports(String type) {
        return type.equals("WEEKLY");
    }

    @Override
    public ProductRankings getRankings(LocalDate date, Integer pageNo, Integer pageSize) {
        return weeklyProductMetricRepository.findRankingsBy(date, pageNo, pageSize, payWeight, viewWeight, likeWeight);
    }
}

์›”๊ฐ„ ๋žญํ‚น ์กฐํšŒ

@Component
@RequiredArgsConstructor
public class GetMonthlyProductRankingsStrategy implements GetProductRankingsStrategy {

    private final MonthlyProductMetricRepository monthlyProductMetricRepository;

    @Value("${product.ranking.score.weight.pay}")
    private Double payWeight;

    @Value("${product.ranking.score.weight.view}")
    private Double viewWeight;

    @Value("${product.ranking.score.weight.like}")
    private Double likeWeight;

    @Override
    public boolean supports(String type) {
        return type.equals("MONTHLY");
    }

    @Override
    public ProductRankings getRankings(LocalDate date, Integer pageNo, Integer pageSize) {
        return monthlyProductMetricRepository.findRankingsBy(date, pageNo, pageSize, payWeight, viewWeight, likeWeight);
    }
}

์ „๋žต ์„ ํƒ์ž

@Component
@RequiredArgsConstructor
public class GetProductRankingsStrategySelector {

    private final List<GetProductRankingsStrategy> strategies;

    public GetProductRankingsStrategy select(String type) {
        return strategies.stream()
                .filter(strategy -> strategy.supports(type))
                .findFirst()
                .orElseThrow(() -> new IllegalArgumentException("์ง€์›ํ•˜์ง€ ์•Š๋Š” ํƒ€์ž…์˜ ๋žญํ‚น์ž…๋‹ˆ๋‹ค."));
    }
}

๐Ÿ’ฌ Review Points

  1. processor๋ฅผ ๋‹จ๊ฑด์”ฉ ์ฃผ๊ฐ„ ๋ฉ”ํŠธ๋ฆญ์„ ๋งŒ๋“œ๋Š” ๊ฒƒ๋ณด๋‹ค writer์—์„œ ์ผ๊ด„๋กœ ๋งŒ๋“œ๋Š” ๊ฒƒ์ด ๋” ํšจ์œจ์ ์ด๋ผ ํŒ๋‹จํ•˜์—ฌ ๊ตฌํ˜„ํ•˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. ์ด ๋ถ€๋ถ„์— ๋Œ€ํ•ด์„œ ์–ด๋–ป๊ฒŒ ์ƒ๊ฐํ•˜์‹œ๋‚˜์š”?
  2. Timezone ์„ค์ • ๋•Œ๋ฌธ์— ํ˜น์‹œ Spring Scheduler๊ฐ€ ์ ์ ˆํ•œ ์‹œ๊ฐ„์— ๋™์ž‘ํ•˜์ง€ ์•Š์œผ๋ฉด ์–ด์ฉŒ์ง€? ๋ผ๋Š” ์ƒ๊ฐ์ด ๋“ค์—ˆ์Šต๋‹ˆ๋‹ค. ์ด ๋ถ€๋ถ„์„ ํ…Œ์ŠคํŠธ ํ•ด๋ณผ ์ˆ˜ ์žˆ๋Š” ๋ฐฉ๋ฒ•์ด ์žˆ์„๊นŒ์š”?

โœ… Checklist

๐Ÿงฑ Spring Batch

  • Spring Batch Job ์„ ์ž‘์„ฑํ•˜๊ณ , ํŒŒ๋ผ๋ฏธํ„ฐ ๊ธฐ๋ฐ˜์œผ๋กœ ๋™์ž‘์‹œํ‚ฌ ์ˆ˜ ์žˆ๋‹ค.
  • Chunk Oriented Processing (Reader/Processor/Writer or Tasklet) ๊ธฐ๋ฐ˜์˜ ๋ฐฐ์น˜ ์ฒ˜๋ฆฌ๋ฅผ ๊ตฌํ˜„ํ–ˆ๋‹ค.
  • ์ง‘๊ณ„ ๊ฒฐ๊ณผ๋ฅผ ์ €์žฅํ•  Materialized View ์˜ ๊ตฌ์กฐ๋ฅผ ์„ค๊ณ„ํ•˜๊ณ  ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ์ ์žฌํ–ˆ๋‹ค.

๐Ÿงฉ Ranking API

  • API ๊ฐ€ ์ผ๊ฐ„, ์ฃผ๊ฐ„, ์›”๊ฐ„ ๋žญํ‚น์„ ์ œ๊ณตํ•˜๋ฉฐ ์กฐํšŒํ•ด์•ผ ํ•˜๋Š” ํ˜•ํƒœ์— ๋”ฐ๋ผ ์ ์ ˆํ•œ ๋ฐ์ดํ„ฐ๋ฅผ ๊ธฐ๋ฐ˜์œผ๋กœ ๋žญํ‚น์„ ์ œ๊ณตํ•œ๋‹ค.

๐Ÿ“Ž References

Summary by CodeRabbit

๋ฆด๋ฆฌ์Šค ๋…ธํŠธ

  • ์ƒˆ ๊ธฐ๋Šฅ

    • ์ œํ’ˆ ์ˆœ์œ„ API์— ๋‹ค์–‘ํ•œ ์ˆœ์œ„ ์œ ํ˜•(์ผ์ผ/์ฃผ๊ฐ„/์›”๊ฐ„) ์ง€์› ์ถ”๊ฐ€
    • Kafka๋ฅผ ํ†ตํ•œ ์ œํ’ˆ ์ข‹์•„์š” ๋ฉ”ํŠธ๋ฆญ ์‹ค์‹œ๊ฐ„ ์ˆ˜์ง‘ ๊ตฌํ˜„
    • ์ผ์ผ ๋ฉ”ํŠธ๋ฆญ์˜ ์ฃผ๊ฐ„ ๋ฐ ์›”๊ฐ„ ์ž๋™ ์ง‘๊ณ„ ๋ฐฐ์น˜ ์ฒ˜๋ฆฌ ์ถ”๊ฐ€
  • ๋ณ€๊ฒฝ ์‚ฌํ•ญ

    • ์ œํ’ˆ ์ˆœ์œ„ ์กฐํšŒ ์‹œ ์ˆœ์œ„ ์œ ํ˜• ๋งค๊ฐœ๋ณ€์ˆ˜ ํ•„์ˆ˜ ์ถ”๊ฐ€ (๊ธฐ๋ณธ๊ฐ’: DAILY)

โœ๏ธ Tip: You can customize this high-level summary in your review settings.

@kilian-develop kilian-develop self-assigned this Jan 1, 2026
@coderabbitai
Copy link

coderabbitai bot commented Jan 1, 2026

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Walkthrough

์ƒํ’ˆ ๋ฉ”ํŠธ๋ฆญ ์ถ”์ ์„ ์ผ์ผ/์ฃผ๊ฐ„/์›”๊ฐ„ ๋‹จ์œ„๋กœ ๋ถ„๋ฆฌํ•˜๊ณ , ๋ฐฐ์น˜ ์ฒ˜๋ฆฌ๋กœ ์ฃผ๊ฐ„/์›”๊ฐ„ ์ง‘๊ณ„ ๋ฐ์ดํ„ฐ๋ฅผ ์ƒ์„ฑํ•˜๋ฉฐ, ๋žญํ‚น ์กฐํšŒ API์— ํƒ€์ž… ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ์ถ”๊ฐ€ํ•˜์—ฌ ์ „๋žต ๊ธฐ๋ฐ˜ ๋žญํ‚น ์„ ํƒ์„ ์ง€์›ํ•ฉ๋‹ˆ๋‹ค.

Changes

Cohort / File(s) Summary
API ๋ณ€๊ฒฝ
apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductV1Api*.java
getProductRankings ๋ฉ”์„œ๋“œ ์‹œ๊ทธ๋‹ˆ์ฒ˜์— String type ํŒŒ๋ผ๋ฏธํ„ฐ ์ถ”๊ฐ€ (๊ธฐ๋ณธ๊ฐ’: "DAILY"). GetProductRankingQuery์— type์„ ์ „๋‹ฌํ•˜๋„๋ก ์—…๋ฐ์ดํŠธ
๋ฐฐ์น˜ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์„ค์ •
apps/commerce-batch/build.gradle.kts, apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java, apps/commerce-batch/src/main/resources/application*.yml
์ƒˆ๋กœ์šด commerce-batch ๋ชจ๋“ˆ ์ถ”๊ฐ€. Spring Boot ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์ง„์ž…์  ๋ฐ Gradle ์˜์กด์„ฑ ์ •์˜. ๋ฐฐ์น˜ ์„ค์ • ํŒŒ์ผ ์ถ”๊ฐ€ (์ฃผ๊ฐ„/์›”๊ฐ„ ํŒŒํ‹ฐ์…˜ ๊ทธ๋ฆฌ๋“œ ํฌ๊ธฐ, ์ฒญํฌ ํฌ๊ธฐ ๋“ฑ)
์ฃผ๊ฐ„ ๋ฐฐ์น˜ ์ฒ˜๋ฆฌ
apps/commerce-batch/src/main/java/com/loopers/application/batch/product/Weekly*.java, apps/commerce-batch/src/main/java/com/loopers/application/batch/product/config/WeeklyProductMetricBatchConfig.java
WeeklyProductMetricScheduler, Partitioner, Reader, Writer ๋ฐ ๋ฐฐ์น˜ ์„ค์ • ํด๋ž˜์Šค. ์ฃผ๊ฐ„ ๋‹จ์œ„๋กœ ์ผ์ผ ๋ฉ”ํŠธ๋ฆญ์„ ์ง‘๊ณ„ํ•˜๊ณ  ์Šค์ผ€์ค„ ์‹คํ–‰ (๋งค์ฃผ ์›”์š”์ผ ์˜ค์ „ 2์‹œ)
์›”๊ฐ„ ๋ฐฐ์น˜ ์ฒ˜๋ฆฌ
apps/commerce-batch/src/main/java/com/loopers/application/batch/product/Monthly*.java, apps/commerce-batch/src/main/java/com/loopers/application/batch/product/config/MonthlyProductMetricBatchConfig.java
MonthlyProductMetricScheduler, Partitioner, Reader, Writer ๋ฐ ๋ฐฐ์น˜ ์„ค์ • ํด๋ž˜์Šค. ์›”๊ฐ„ ๋‹จ์œ„๋กœ ์ผ์ผ ๋ฉ”ํŠธ๋ฆญ์„ ์ง‘๊ณ„ํ•˜๊ณ  ์Šค์ผ€์ค„ ์‹คํ–‰ (๋งค์›” 1์ผ ์˜ค์ „ 2์‹œ)
๋ฐฐ์น˜ ํ…Œ์ŠคํŠธ
apps/commerce-batch/src/test/java/com/loopers/application/batch/*.java
IntegrationTest ๊ธฐ๋ณธ ํด๋ž˜์Šค, WeeklyProductMetricSchedulerTest, MonthlyProductMetricSchedulerTest. ๋ฐฐ์น˜ ์ง‘๊ณ„ ๊ฒ€์ฆ ํ…Œ์ŠคํŠธ ์ผ€์ด์Šค
Kafka ์ŠคํŠธ๋ฆฌ๋จธ
apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/IncreaseProductLikeMetricKafkaConsumer.java, apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/dto/IncreaseProductLikeMetricEvent.java
์ƒํ’ˆ ์ข‹์•„์š” ๋ฉ”ํŠธ๋ฆญ ์ฆ๊ฐ€ Kafka ์ปจ์Šˆ๋จธ ๋ฐ ์ด๋ฒคํŠธ DTO ์ถ”๊ฐ€
๋„๋ฉ”์ธ ๋ชจ๋ธ ๋ฆฌํŒฉํ† ๋ง
core/domain/src/main/java/com/loopers/core/domain/product/DailyProductMetric.java, core/domain/src/main/java/com/loopers/core/domain/product/WeeklyProductMetric.java, core/domain/src/main/java/com/loopers/core/domain/product/MonthlyProductMetric.java, core/domain/src/main/java/com/loopers/core/domain/product/ProductMetric.java (์‚ญ์ œ)
ProductMetric์„ DailyProductMetric์œผ๋กœ ๋ถ„๋ฆฌํ•˜๊ณ , WeeklyProductMetric, MonthlyProductMetric ์ถ”๊ฐ€. ์ข‹์•„์š” ์นด์šดํŠธ(likeCount) ํ•„๋“œ ์ถ”๊ฐ€
๋„๋ฉ”์ธ VO ์ถ”๊ฐ€
core/domain/src/main/java/com/loopers/core/domain/common/vo/YearMonthWeek.java, core/domain/src/main/java/com/loopers/core/domain/product/vo/YearMonth.java, core/domain/src/main/java/com/loopers/core/domain/product/vo/*.java (ID ํด๋ž˜์Šค๋“ค)
YearMonthWeek, YearMonth VO ์ถ”๊ฐ€. DailyProductMetricId, WeeklyProductMetricId, MonthlyProductMetricId ์ถ”๊ฐ€. ProductMetricAggregation VO ์ถ”๊ฐ€. ๊ธฐ์กด ProductMetricId ์‚ญ์ œ
๋„๋ฉ”์ธ ๋ฆฌํฌ์ง€ํ† ๋ฆฌ ์ธํ„ฐํŽ˜์ด์Šค
core/domain/src/main/java/com/loopers/core/domain/product/repository/DailyProductMetricRepository.java, core/domain/src/main/java/com/loopers/core/domain/product/repository/WeeklyProductMetricRepository.java, core/domain/src/main/java/com/loopers/core/domain/product/repository/MonthlyProductMetricRepository.java, core/domain/src/main/java/com/loopers/core/domain/product/repository/ProductMetricRepository.java (์‚ญ์ œ)
์ผ์ผ/์ฃผ๊ฐ„/์›”๊ฐ„ ๋ฉ”ํŠธ๋ฆญ ๋ฆฌํฌ์ง€ํ† ๋ฆฌ ์ธํ„ฐํŽ˜์ด์Šค ์ถ”๊ฐ€. ๊ธฐ์กด ProductMetricRepository ์‚ญ์ œ
JPA ์—”ํ‹ฐํ‹ฐ
core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/DailyProductMetricEntity.java, core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/WeeklyProductMetricEntity.java, core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/MonthlyProductMetricEntity.java
ProductMetricEntity๋ฅผ DailyProductMetricEntity๋กœ ๋ฆฌ๋„ค์ž„. WeeklyProductMetricEntity, MonthlyProductMetricEntity ์ถ”๊ฐ€. ํ…Œ์ด๋ธ”๋ช… ๋ณ€๊ฒฝ (product_metrics โ†’ daily_product_metrics, weekly_product_metrics, monthly_product_metrics)
JPA ๋ฆฌํฌ์ง€ํ† ๋ฆฌ ๋ฐ ๊ตฌํ˜„
core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/Daily*.java, core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/Weekly*.java, core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/Monthly*.java, core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/*.java
JPA ๋ฆฌํฌ์ง€ํ† ๋ฆฌ, Querydsl ๋ฆฌํฌ์ง€ํ† ๋ฆฌ, ๋ฒŒํฌ ๋ฆฌํฌ์ง€ํ† ๋ฆฌ ์ถ”๊ฐ€. ์ผ์ผ/์ฃผ๊ฐ„/์›”๊ฐ„ ๋ฉ”ํŠธ๋ฆญ ์กฐํšŒ ๋ฐ ๋ฒŒํฌ ์—…์„œํŠธ ๊ตฌํ˜„
Querydsl ํ”„๋กœ์ ์…˜
core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/dto/WeeklyProductRankingProjection.java
์ฃผ๊ฐ„ ์ƒํ’ˆ ๋žญํ‚น ํ”„๋กœ์ ์…˜ DTO ์ถ”๊ฐ€
์„œ๋น„์Šค ๋ ˆ์ด์–ด
core/service/src/main/java/com/loopers/core/service/product/IncreaseProductMetricViewCountService.java, core/service/src/main/java/com/loopers/core/service/product/IncreaseProductTotalSalesService.java, core/service/src/main/java/com/loopers/core/service/product/IncreaseProductLikeMetricService.java (์‹ ๊ทœ)
ProductMetric โ†’ DailyProductMetric์œผ๋กœ ๋ณ€๊ฒฝ. IncreaseProductLikeMetricService ์ถ”๊ฐ€
๋žญํ‚น ์ „๋žต ํŒจํ„ด
core/service/src/main/java/com/loopers/core/service/product/component/GetProductRankingsStrategy.java, core/service/src/main/java/com/loopers/core/service/product/component/GetDailyProductRankingsStrategy.java, core/service/src/main/java/com/loopers/core/service/product/component/GetWeeklyProductRankingsStrategy.java, core/service/src/main/java/com/loopers/core/service/product/component/GetMonthlyProductRankingsStrategy.java, core/service/src/main/java/com/loopers/core/service/product/component/GetProductRankingsStrategySelector.java
Strategy ํŒจํ„ด์„ ์‚ฌ์šฉํ•œ ๋žญํ‚น ์ „๋žต ์„ ํƒ ๊ตฌํ˜„. DAILY/WEEKLY/MONTHLY ์ „๋žต ์ง€์›
์ฟผ๋ฆฌ ๋ฐ ์„œ๋น„์Šค
core/service/src/main/java/com/loopers/core/service/product/query/GetProductRankingQuery.java, core/service/src/main/java/com/loopers/core/service/product/GetProductRankingService.java, core/service/src/main/java/com/loopers/core/service/product/command/IncreaseProductLikeMetricCommand.java
GetProductRankingQuery์— type ํ•„๋“œ ์ถ”๊ฐ€. GetProductRankingService๊ฐ€ ์ „๋žต ์„ ํƒ์ž ์‚ฌ์šฉ. IncreaseProductLikeMetricCommand ์ถ”๊ฐ€
๊ธฐํƒ€ ๋ณ€๊ฒฝ
core/domain/src/main/java/com/loopers/core/domain/event/type/EventType.java, core/infra/database/mysql/mysql-config/src/main/resources/jpa.yml, core/domain/src/testFixtures/java/com/loopers/core/domain/product/DailyProductMetricFixture.java, settings.gradle.kts
์ด๋ฒคํŠธ ํƒ€์ž…์— INCREASE_PRODUCT_METRIC_LIKE_COUNT ์ถ”๊ฐ€. JPA timezone ์„ค์ • ์ œ๊ฑฐ. DailyProductMetricFixture ์ถ”๊ฐ€. Gradle settings์— commerce-batch ๋ชจ๋“ˆ ํฌํ•จ

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant API as ProductV1Api
    participant Service as GetProductRankingService
    participant Selector as GetProductRankingsStrategySelector
    participant Strategy as GetProductRankingsStrategy
    participant Repository as Metric Repository
    participant Cache as Cache Repository
    
    User->>API: getProductRankings(date, "WEEKLY")
    API->>Service: getRanking(GetProductRankingQuery)
    Service->>Selector: select("WEEKLY")
    Selector->>Strategy: [filter by support]
    Strategy-->>Selector: GetWeeklyProductRankingsStrategy
    Selector-->>Service: WeeklyStrategy
    Service->>Strategy: getRankings(date, page, size)
    Strategy->>Repository: findRankingsBy(date, weights, pageable)
    Repository->>Repository: Query aggregated metrics
    Repository-->>Strategy: ProductRankings
    Strategy-->>Service: ProductRankings
    Service-->>API: ApiResponse
    API-->>User: Rankings JSON
Loading
sequenceDiagram
    actor Scheduler
    participant MonthlyScheduler as MonthlyProductMetricScheduler
    participant JobLauncher
    participant Job as monthlyProductMetricJob
    participant Partitioner as MonthlyProductMetricBatchPartitioner
    participant Reader as MonthlyProductMetricBatchReader
    participant Writer as MonthlyProductMetricBatchWriter
    participant DailyRepo as DailyProductMetricRepository
    participant MonthlyRepo as MonthlyProductMetricRepository
    
    rect rgb(220, 240, 255)
    Note over MonthlyScheduler: ๋งค์›” 1์ผ ์˜ค์ „ 2์‹œ
    Scheduler->>MonthlyScheduler: ์Šค์ผ€์ค„ ์‹คํ–‰
    end
    
    MonthlyScheduler->>MonthlyScheduler: ์ง€๋‚œ๋‹ฌ startDate, endDate ๊ณ„์‚ฐ
    MonthlyScheduler->>JobLauncher: JobParameters ์ „๋‹ฌ (startDate, endDate)
    JobLauncher->>Job: ์›”๊ฐ„ ๋ฐฐ์น˜ ์ž‘์—… ์‹œ์ž‘
    
    rect rgb(240, 255, 240)
    Note over Job: ํŒŒํ‹ฐ์…”๋‹ ๋‹จ๊ณ„
    Job->>Partitioner: partition(gridSize)
    Partitioner->>DailyRepo: countDistinctProductIdsBy(date range)
    DailyRepo-->>Partitioner: ์ด ์ƒํ’ˆ ์ˆ˜
    Partitioner-->>Job: ํŒŒํ‹ฐ์…˜ ๋งต (partition0...partitionN)
    end
    
    par Partition Processing
        rect rgb(255, 240, 220)
        Note over Reader: ๊ฐ ํŒŒํ‹ฐ์…˜ ๋ณ‘๋ ฌ ์ฒ˜๋ฆฌ
        Job->>Reader: open(ExecutionContext)
        Reader->>DailyRepo: findAggregatedBy(date, offset, limit)
        DailyRepo-->>Reader: ProductMetricAggregation ๋ฆฌ์ŠคํŠธ
        end
        
        loop ๊ฐ ์ง‘๊ณ„ ํ•ญ๋ชฉ
            Job->>Reader: read()
            Reader-->>Job: ProductMetricAggregation
            Job->>Writer: write(chunk)
            Writer->>Writer: to(YearMonth) ๋ณ€ํ™˜
            Writer->>MonthlyRepo: bulkUpsert(MonthlyProductMetric)
        end
    end
    
    Job-->>JobLauncher: ์ž‘์—… ์™„๋ฃŒ
Loading

Estimated code review effort

๐ŸŽฏ 4 (Complex) | โฑ๏ธ ~60 minutes

Possibly related PRs

  • ๋ฐฐ์น˜ ์ฒ˜๋ฆฌ๋ฅผ ํ†ตํ•œ ์ฃผ๊ฐ„/์›”๊ฐ„ ๋ฉ”ํŠธ๋ฆญ ์ง‘๊ณ„ ๊ธฐ๋Šฅ ์ถ”๊ฐ€๋กœ, ๊ธฐ์กด์˜ ์ผ์ผ ๋ฉ”ํŠธ๋ฆญ ์ˆ˜์ง‘ ๋กœ์ง๊ณผ ํ†ตํ•ฉ๋˜๋Š” ๊ตฌ์กฐ์ž…๋‹ˆ๋‹ค.
  • ์ƒํ’ˆ ๋žญํ‚น ์กฐํšŒ API์— type ํŒŒ๋ผ๋ฏธํ„ฐ ์ถ”๊ฐ€๋กœ ์ „๋žต ๊ธฐ๋ฐ˜ ์„ ํƒ์„ ์ง€์›ํ•˜๋ฉฐ, ๊ธฐ์กด ProductMetric ์—”ํ‹ฐํ‹ฐ ๋ฆฌํŒฉํ† ๋ง์˜ ์˜ํ–ฅ์„ ๋ฐ›์Šต๋‹ˆ๋‹ค.
  • Kafka ์ปจ์Šˆ๋จธ ์ถ”๊ฐ€๋กœ ์ƒํ’ˆ ์ข‹์•„์š” ๋ฉ”ํŠธ๋ฆญ ์ˆ˜์ง‘์ด ๊ฐ€๋Šฅํ•ด์ง€๋ฉฐ, ๋ฐฐ์น˜ ์ฒ˜๋ฆฌ์™€ ํ•จ๊ป˜ ๋™์ž‘ํ•˜๋Š” ์™„์ „ํ•œ ๋ฉ”ํŠธ๋ฆญ ํŒŒ์ดํ”„๋ผ์ธ์„ ํ˜•์„ฑํ•ฉ๋‹ˆ๋‹ค.

Suggested labels

enhancement, batch-processing, refactoring, database-schema


๐Ÿฐ ๋ฉ”ํŠธ๋ฆญ์„ ๋‚˜๋ˆ„์–ด ๋ชจ์•„์„œ (daily, weekly, monthly!)
๋ฐฐ์น˜๋กœ ์ง‘๊ณ„ํ•˜๊ณ  ์ „๋žต์„ ์„ ํƒํ•˜๋‹ˆ,
์‚ฌ์šฉ์ž ์ทจํ–ฅ์„ ๋” ์ž˜ ์•Œ๊ฒ ๋„ค์š”!
์ด์ œ ๋žญํ‚น๋„ ๋ง˜๊ป ๊ณจ๋ผ ๋ณผ ์ˆ˜ ์žˆ๊ณ ,
์ˆจ ๊ณ ๋ฅด๋ฉฐ ์ฃผ๊ฐ„, ์›”๊ฐ„ ์—…๋ฐ์ดํŠธ ์™„๋ฃŒ! ๐ŸŽ‰

Pre-merge checks and finishing touches

โŒ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage โš ๏ธ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
Title check โ“ Inconclusive ์ œ๋ชฉ์ด ๋ณ€๊ฒฝ ์‚ฌํ•ญ๊ณผ ๊ด€๋ จ์ด ์žˆ์ง€๋งŒ, ์‹ค์ œ ๊ตฌํ˜„๋œ ๊ธฐ๋Šฅ(์ œํ’ˆ ๋ฉ”ํŠธ๋ฆญ ๋ฐฐ์น˜ ์‹œ์Šคํ…œ ์ถ”๊ฐ€, ์ฃผ๊ฐ„/์›”๊ฐ„ ๋žญํ‚น ์กฐํšŒ)์„ ๋ช…ํ™•ํžˆ ๋ฐ˜์˜ํ•˜์ง€ ๋ชปํ•ฉ๋‹ˆ๋‹ค. ์ œ๋ชฉ์„ '์ œํ’ˆ ๋ฉ”ํŠธ๋ฆญ ๋ฐฐ์น˜ ์‹œ์Šคํ…œ ๋ฐ ์ฃผ๊ฐ„/์›”๊ฐ„ ๋žญํ‚น ์กฐํšŒ ๊ธฐ๋Šฅ ์ถ”๊ฐ€' ๋˜๋Š” ์œ ์‚ฌํ•œ ๊ตฌ์ฒด์ ์ธ ์„ค๋ช…์œผ๋กœ ๋ณ€๊ฒฝํ•˜๊ธธ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.
โœ… Passed checks (1 passed)
Check name Status Explanation
Description check โœ… Passed PR ์„ค๋ช…์ด ํ…œํ”Œ๋ฆฟ์˜ ๋ชจ๋“  ํ•„์ˆ˜ ์„น์…˜์„ ํฌํ•จํ•˜๊ณ  ์žˆ์œผ๋ฉฐ, ๊ตฌํ˜„๋œ ๊ธฐ๋Šฅ, ์„ค๊ณ„ ํฌ์ธํŠธ, ์ฒดํฌ๋ฆฌ์ŠคํŠธ, ์ฐธ๊ณ  ์ž๋ฃŒ๊ฐ€ ๊ตฌ์ฒด์ ์œผ๋กœ ์ž‘์„ฑ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

โค๏ธ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 18

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

Caution

Some comments are outside the diff and canโ€™t be posted inline due to platform limitations.

โš ๏ธ Outside diff range comments (1)
core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/entity/DailyProductMetricEntity.java (1)

62-72: to() ๋ฉ”์„œ๋“œ์—์„œ id๊ฐ€ null์ผ ๊ฒฝ์šฐ NPE ๋ฐœ์ƒ ๊ฐ€๋Šฅ์„ฑ.

from() ๋ฉ”์„œ๋“œ์—์„œ๋Š” Optional.ofNullable()๋กœ null ID๋ฅผ ์ฒ˜๋ฆฌํ•˜์ง€๋งŒ, to() ๋ฉ”์„œ๋“œ์˜ this.id.toString() (Line 64)์€ ์ €์žฅ๋˜์ง€ ์•Š์€ ์—”ํ‹ฐํ‹ฐ์˜ ๊ฒฝ์šฐ NullPointerException์„ ๋ฐœ์ƒ์‹œํ‚ฌ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ œ์•ˆ๋œ ์ˆ˜์ • ์‚ฌํ•ญ
     public DailyProductMetric to() {
         return DailyProductMetric.mappedBy(
-                new DailyProductMetricId(this.id.toString()),
+                new DailyProductMetricId(Optional.ofNullable(this.id).map(Object::toString).orElse(null)),
                 new ProductId(this.productId.toString()),
                 new ProductLikeCount(this.likeCount),
                 new ProductTotalSalesCount(this.totalSalesCount),
                 new ProductDetailViewCount(this.viewCount),
                 new CreatedAt(this.createdAt),
                 new UpdatedAt(this.updatedAt)
         );
     }
๐ŸŸก Minor comments (3)
core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/MonthlyProductMetricBulkRepository.java-9-9 (1)

9-9: ์ธํ„ฐํŽ˜์ด์Šค ์ˆ˜์ค€์—์„œ null/empty ์ฒ˜๋ฆฌ ๊ณ„์•ฝ์„ ๋ช…์‹œ์ ์œผ๋กœ ๋ฌธ์„œํ™”ํ•˜์„ธ์š”

๊ตฌํ˜„์ฒด์—์„œ null ๋˜๋Š” ๋นˆ ๋ฆฌ์ŠคํŠธ์— ๋Œ€ํ•ด ์ ์ ˆํžˆ ์ฒ˜๋ฆฌํ•˜๊ณ  ์žˆ์œผ๋‚˜(๋ผ์ธ 22-24), ์ธํ„ฐํŽ˜์ด์Šค ๋ฉ”์„œ๋“œ์— @NonNull ์–ด๋…ธํ…Œ์ด์…˜์ด๋‚˜ JavaDoc์ด ์—†์–ด ํ˜ธ์ถœ์ž๊ฐ€ ์ด ๋™์ž‘์„ ์ธ์‹ํ•˜๊ธฐ ์–ด๋ ต์Šต๋‹ˆ๋‹ค. ๋ฉ”์„œ๋“œ ํŒŒ๋ผ๋ฏธํ„ฐ์— @NonNull์„ ์ถ”๊ฐ€ํ•˜๊ฑฐ๋‚˜ JavaDoc์œผ๋กœ null/empty ๋ฆฌ์ŠคํŠธ ์‹œ ๋™์ž‘์„ ๋ช…์‹œํ•˜๋Š” ๊ฒƒ์„ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.

core/service/src/main/java/com/loopers/core/service/product/IncreaseProductTotalSalesService.java-40-43 (1)

40-43: ๋ฃจํ”„ ์™ธ๋ถ€์—์„œ ํƒ€์ž„์Šคํƒฌํ”„๋ฅผ ์ถ”์ถœํ•˜์„ธ์š”.

forEach ๋ฃจํ”„ ๋‚ด์—์„œ ๋งค iteration๋งˆ๋‹ค new CreatedAt(LocalDateTime.now())๋ฅผ ํ˜ธ์ถœํ•˜๋ฉด, ๋™์ผํ•œ ์ฃผ๋ฌธ์˜ ์—ฌ๋Ÿฌ ์ƒํ’ˆ๋“ค์ด ์„œ๋กœ ๋‹ค๋ฅธ ์ผ์ž์˜ DailyProductMetric์— ์ง‘๊ณ„๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํƒ€์ž„์Šคํƒฌํ”„๋ฅผ ๋ฃจํ”„ ์ „์— ํ•œ ๋ฒˆ ์ƒ์„ฑํ•˜๊ณ  ์žฌ์‚ฌ์šฉํ•˜์„ธ์š”:

LocalDateTime now = LocalDateTime.now();
orderItemRepository.findAllByOrderId(order.getId()).forEach(orderItem -> {
    DailyProductMetric metric = dailyProductMetricRepository.findByWithLock(orderItem.getProductId(), new CreatedAt(now))
            .orElse(DailyProductMetric.init(orderItem.getProductId()));
    
    dailyProductMetricRepository.save(metric.increaseSalesCount(orderItem.getQuantity()));
});

๋˜๋Š” ์ด๋ฏธ ์กด์žฌํ•˜๋Š” CreatedAt.now() ํ—ฌํผ ๋ฉ”์„œ๋“œ๋ฅผ ์‚ฌ์šฉํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

core/domain/src/main/java/com/loopers/core/domain/product/DailyProductMetric.java-62-66 (1)

62-66: increaseLikeCount()์—์„œ updatedAt ๊ฐฑ์‹  ๋ˆ„๋ฝ

increaseViewCount()์™€ increaseSalesCount()๋Š” updatedAt(UpdatedAt.now())๋ฅผ ํ˜ธ์ถœํ•˜์—ฌ ์ˆ˜์ • ์‹œ๊ฐ„์„ ๊ฐฑ์‹ ํ•˜์ง€๋งŒ, increaseLikeCount()๋Š” ์ด๋ฅผ ์ˆ˜ํ–‰ํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ์ผ๊ด€์„ฑ์„ ์œ„ํ•ด ๋™์ผํ•˜๊ฒŒ ๊ฐฑ์‹ ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ œ์•ˆ๋œ ์ˆ˜์ •
     public DailyProductMetric increaseLikeCount() {
         return this.toBuilder()
                 .likeCount(this.likeCount.increase())
+                .updatedAt(UpdatedAt.now())
                 .build();
     }
๐Ÿงน Nitpick comments (32)
settings.gradle.kts (1)

43-43: ๋ชจ๋“ˆ ์„ ์–ธ์„ ๊ธฐ์กด include ๋ธ”๋ก์— ํ†ตํ•ฉํ•˜์„ธ์š”.

์ƒˆ๋กœ์šด commerce-batch ๋ชจ๋“ˆ์ด ๋ณ„๋„์˜ include() ๋ฌธ์œผ๋กœ ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ์ผ๊ด€์„ฑ๊ณผ ์œ ์ง€๋ณด์ˆ˜์„ฑ์„ ์œ„ํ•ด 3-20๋ฒˆ์งธ ์ค„์˜ ๊ธฐ์กด include() ๋ธ”๋ก์— ํ•จ๊ป˜ ์„ ์–ธํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž ๊ถŒ์žฅํ•˜๋Š” ์ˆ˜์ •์•ˆ
 include(
     ":apps:commerce-api",
     ":apps:commerce-streamer",
+    ":apps:commerce-batch",
     ":supports:jackson",
     ":supports:logging",
     ":supports:monitoring",
     ":core:domain",
     ":core:service",
     ":core:infra:database:mysql:mysql-config",
     ":core:infra:database:mysql",
     ":core:infra:database:redis:redis-config",
     ":core:infra:database:redis",
     ":core:infra:http-client:http-client-config",
     ":core:infra:http-client",
     ":core:infra:event",
     ":core:infra:event:kafka-config",
     ":core:common"
 )
 
 // configurations
 pluginManagement {
     val springBootVersion: String by settings
     val springDependencyManagementVersion: String by settings
 
     repositories {
         maven { url = uri("https://repo.spring.io/milestone") }
         maven { url = uri("https://repo.spring.io/snapshot") }
         gradlePluginPortal()
     }
 
     resolutionStrategy {
         eachPlugin {
             when (requested.id.id) {
                 "org.springframework.boot" -> useVersion(springBootVersion)
                 "io.spring.dependency-management" -> useVersion(springDependencyManagementVersion)
             }
         }
     }
 }
-
-include("apps:commerce-batch")
apps/commerce-batch/src/main/resources/application.yml (2)

64-86: ํ™˜๊ฒฝ๋ณ„ ์„ค์ • ์ถ”๊ฐ€๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.

ํ˜„์žฌ ํ”„๋กœํ•„ ์„น์…˜๋“ค์ด ๋Œ€๋ถ€๋ถ„ ๋น„์–ด์žˆ์Šต๋‹ˆ๋‹ค. ๊ฐ ํ™˜๊ฒฝ(dev, qa, prd)์— ๋งž๋Š” ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค URL, ์™ธ๋ถ€ ์„œ๋น„์Šค ์—”๋“œํฌ์ธํŠธ, ๋ฐฐ์น˜ ํŒŒํ‹ฐ์…˜ ํฌ๊ธฐ ๋“ฑ ํ™˜๊ฒฝ๋ณ„ ์„ค์ •์„ ์ถ”๊ฐ€ํ•˜๋Š” ๊ฒƒ์„ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.

์˜ˆ๋ฅผ ๋“ค์–ด:

  • ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์—ฐ๊ฒฐ ์ •๋ณด
  • ์™ธ๋ถ€ API ์—”๋“œํฌ์ธํŠธ (์œ„์—์„œ ์–ธ๊ธ‰ํ•œ PG ์‹œ๋ฎฌ๋ ˆ์ดํ„ฐ, ์ฝœ๋ฐฑ URL)
  • ๋ฐฐ์น˜ ์ฒญํฌ ํฌ๊ธฐ ๋ฐ ํŒŒํ‹ฐ์…˜ ๊ทธ๋ฆฌ๋“œ ํฌ๊ธฐ (ํ™˜๊ฒฝ ๋ฆฌ์†Œ์Šค์— ๋”ฐ๋ผ ์กฐ์ •)
  • ๋กœ๊น… ๋ ˆ๋ฒจ ์กฐ์ •

88-90: SpringDoc ์„ค์ • ์œ„์น˜๋ฅผ ๋ช…ํ™•ํžˆ ํ•˜์„ธ์š”.

springdoc ์„ค์ •์ด ํ”„๋กœ๋•์…˜ ํ”„๋กœํ•„ ์„น์…˜ ๋’ค์— ์œ„์น˜ํ•˜์—ฌ ํ˜ผ๋ž€์„ ์ค„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํ˜„์žฌ ๋“ค์—ฌ์“ฐ๊ธฐ ๊ธฐ์ค€์œผ๋กœ๋Š” ์ „์—ญ ์„ค์ •์ด์ง€๋งŒ, ์œ„์น˜์ƒ prd ํ”„๋กœํ•„๊ณผ ์—ฐ๊ด€๋œ ๊ฒƒ์ฒ˜๋Ÿผ ๋ณด์ž…๋‹ˆ๋‹ค.

API ๋ฌธ์„œ๋ฅผ ๋ชจ๋“  ํ™˜๊ฒฝ์—์„œ ๋น„ํ™œ์„ฑํ™”ํ•˜๋ ค๋ฉด ์ „์—ญ ์„ค์ • ์˜์—ญ(line 13-35 ๋ถ€๊ทผ)์œผ๋กœ ์ด๋™ํ•˜๊ฑฐ๋‚˜, ํŠน์ • ํ”„๋กœํ•„์—์„œ๋งŒ ๋น„ํ™œ์„ฑํ™”ํ•˜๋ ค๋ฉด ํ•ด๋‹น ํ”„๋กœํ•„ ์„น์…˜ ๋‚ด๋ถ€๋กœ ์ด๋™ํ•˜์„ธ์š”.

๐Ÿ”Ž ์ „์—ญ ์„ค์ •์œผ๋กœ ์ด๋™ ์ œ์•ˆ
   batch:
     jdbc:
       initialize-schema: always
     job:
       enabled: false
+
+springdoc:
+  api-docs:
+    enabled: false
+
 http-client:
   pg-simulator:

๊ทธ๋ฆฌ๊ณ  ํ˜„์žฌ ์œ„์น˜์—์„œ ์ œ๊ฑฐ:

 spring:
   config:
     activate:
       on-profile: prd
-
-springdoc:
-  api-docs:
-    enabled: false
core/domain/src/testFixtures/java/com/loopers/core/domain/product/DailyProductMetricFixture.java (1)

14-66: ์ฝ”๋“œ ์ค‘๋ณต ์ œ๊ฑฐ๋ฅผ ํ†ตํ•œ ์œ ์ง€๋ณด์ˆ˜์„ฑ ๊ฐœ์„  ๊ถŒ์žฅ

๋„ค ๊ฐœ์˜ ํŒฉํ† ๋ฆฌ ๋ฉ”์„œ๋“œ๊ฐ€ ์œ ์‚ฌํ•œ Instancio ์„ค์ •์„ ๋ฐ˜๋ณตํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ๊ณตํ†ต ๋กœ์ง์„ private ํ—ฌํผ ๋ฉ”์„œ๋“œ๋กœ ์ถ”์ถœํ•˜๋ฉด ์œ ์ง€๋ณด์ˆ˜์„ฑ์ด ํ–ฅ์ƒ๋˜๊ณ  ํ–ฅํ›„ ํ•„๋“œ ๋ณ€๊ฒฝ ์‹œ ์ˆ˜์ • ๋ฒ”์œ„๋ฅผ ์ค„์ผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž ๋ฆฌํŒฉํ† ๋ง ์ œ์•ˆ
 public class DailyProductMetricFixture {
+
+    private static DailyProductMetric buildBase(
+            String productId,
+            long likeCount,
+            long viewCount,
+            long totalSalesCount,
+            LocalDateTime createdAt
+    ) {
+        return Instancio.of(DailyProductMetric.class)
+                .set(field(DailyProductMetric::getId), DailyProductMetricId.empty())
+                .set(field(DailyProductMetric::getProductId), new ProductId(productId))
+                .set(field(DailyProductMetric::getLikeCount), new ProductLikeCount(likeCount))
+                .set(field(DailyProductMetric::getTotalSalesCount), new ProductTotalSalesCount(totalSalesCount))
+                .set(field(DailyProductMetric::getViewCount), new ProductDetailViewCount(viewCount))
+                .set(field(DailyProductMetric::getCreatedAt), new CreatedAt(createdAt))
+                .set(field(DailyProductMetric::getUpdatedAt), new UpdatedAt(createdAt))
+                .create();
+    }

     public static DailyProductMetric create() {
-        return Instancio.of(DailyProductMetric.class)
-                .set(field(DailyProductMetric::getId), DailyProductMetricId.empty())
-                .set(field(DailyProductMetric::getProductId), new ProductId("1"))
-                .set(field(DailyProductMetric::getLikeCount), new ProductLikeCount(0L))
-                .set(field(DailyProductMetric::getTotalSalesCount), new ProductTotalSalesCount(0L))
-                .set(field(DailyProductMetric::getViewCount), new ProductDetailViewCount(0L))
-                .set(field(DailyProductMetric::getCreatedAt), new CreatedAt(LocalDateTime.now()))
-                .set(field(DailyProductMetric::getUpdatedAt), new UpdatedAt(LocalDateTime.now()))
-                .create();
+        return buildBase("1", 0L, 0L, 0L, LocalDateTime.now());
     }

     public static DailyProductMetric createWith(String productId) {
-        return Instancio.of(DailyProductMetric.class)
-                .set(field(DailyProductMetric::getId), DailyProductMetricId.empty())
-                .set(field(DailyProductMetric::getProductId), new ProductId(productId))
-                .set(field(DailyProductMetric::getLikeCount), new ProductLikeCount(0L))
-                .set(field(DailyProductMetric::getTotalSalesCount), new ProductTotalSalesCount(0L))
-                .set(field(DailyProductMetric::getViewCount), new ProductDetailViewCount(0L))
-                .set(field(DailyProductMetric::getCreatedAt), new CreatedAt(LocalDateTime.now()))
-                .set(field(DailyProductMetric::getUpdatedAt), new UpdatedAt(LocalDateTime.now()))
-                .create();
+        return buildBase(productId, 0L, 0L, 0L, LocalDateTime.now());
     }

     public static DailyProductMetric createWith(String productId, LocalDateTime createdAt) {
-        return Instancio.of(DailyProductMetric.class)
-                .set(field(DailyProductMetric::getId), DailyProductMetricId.empty())
-                .set(field(DailyProductMetric::getProductId), new ProductId(productId))
-                .set(field(DailyProductMetric::getLikeCount), new ProductLikeCount(0L))
-                .set(field(DailyProductMetric::getTotalSalesCount), new ProductTotalSalesCount(0L))
-                .set(field(DailyProductMetric::getViewCount), new ProductDetailViewCount(0L))
-                .set(field(DailyProductMetric::getCreatedAt), new CreatedAt(createdAt))
-                .set(field(DailyProductMetric::getUpdatedAt), new UpdatedAt(createdAt))
-                .create();
+        return buildBase(productId, 0L, 0L, 0L, createdAt);
     }

     public static DailyProductMetric createWith(
             String productId,
             long likeCount,
             long viewCount,
             long totalSalesCount,
             LocalDateTime createdAt
     ) {
-        return Instancio.of(DailyProductMetric.class)
-                .set(field(DailyProductMetric::getId), DailyProductMetricId.empty())
-                .set(field(DailyProductMetric::getProductId), new ProductId(productId))
-                .set(field(DailyProductMetric::getLikeCount), new ProductLikeCount(likeCount))
-                .set(field(DailyProductMetric::getTotalSalesCount), new ProductTotalSalesCount(totalSalesCount))
-                .set(field(DailyProductMetric::getViewCount), new ProductDetailViewCount(viewCount))
-                .set(field(DailyProductMetric::getCreatedAt), new CreatedAt(createdAt))
-                .set(field(DailyProductMetric::getUpdatedAt), new UpdatedAt(createdAt))
-                .create();
+        return buildBase(productId, likeCount, viewCount, totalSalesCount, createdAt);
     }
 }
core/service/src/main/java/com/loopers/core/service/product/command/IncreaseProductLikeMetricCommand.java (1)

3-7: eventId์™€ productId์— ๋Œ€ํ•œ null ๊ฒ€์ฆ ๊ณ ๋ ค

๋ ˆ์ฝ”๋“œ๊ฐ€ null ๊ฐ’์„ ํ—ˆ์šฉํ•˜๋ฉด ์„œ๋น„์Šค ๋ ˆ์ด์–ด์—์„œ NullPointerException์ด ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. eventId๋Š” ๋ฉฑ๋“ฑ์„ฑ ๋ณด์žฅ์— ์‚ฌ์šฉ๋˜๊ณ  productId๋Š” ๋ฉ”ํŠธ๋ฆญ ์‹๋ณ„์— ํ•„์ˆ˜์ ์ด๋ฏ€๋กœ, compact ์ƒ์„ฑ์ž์—์„œ null ๊ฒ€์ฆ์„ ์ถ”๊ฐ€ํ•˜๋Š” ๊ฒƒ์ด ์•ˆ์ „ํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ”Ž null ๊ฒ€์ฆ ์ถ”๊ฐ€ ์ œ์•ˆ
 public record IncreaseProductLikeMetricCommand(
         String eventId,
         String productId
 ) {
+    public IncreaseProductLikeMetricCommand {
+        if (eventId == null || eventId.isBlank()) {
+            throw new IllegalArgumentException("eventId must not be null or blank");
+        }
+        if (productId == null || productId.isBlank()) {
+            throw new IllegalArgumentException("productId must not be null or blank");
+        }
+    }
 }
apps/commerce-batch/src/test/java/com/loopers/application/batch/IntegrationTest.java (1)

10-12: ๋ฒ ์ด์Šค ํด๋ž˜์Šค๋กœ ์‚ฌ์šฉ๋  ๊ฒฝ์šฐ abstract ์„ ์–ธ ๊ณ ๋ ค

IntegrationTest๊ฐ€ ๋‹ค๋ฅธ ํ†ตํ•ฉ ํ…Œ์ŠคํŠธ ํด๋ž˜์Šค๋“ค(์˜ˆ: MonthlyProductMetricSchedulerTest, WeeklyProductMetricSchedulerTest)์˜ ๋ฒ ์ด์Šค ํด๋ž˜์Šค๋กœ ์‚ฌ์šฉ๋  ๊ฒฝ์šฐ, abstract๋กœ ์„ ์–ธํ•˜์—ฌ ์ง์ ‘ ์ธ์Šคํ„ด์Šคํ™”๋˜๋Š” ๊ฒƒ์„ ๋ฐฉ์ง€ํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž abstract ํด๋ž˜์Šค๋กœ ๋ณ€๊ฒฝ ์ œ์•ˆ
 @SpringBootTest
 @ExtendWith(MySqlTestContainersExtension.class)
-public class IntegrationTest {
+public abstract class IntegrationTest {
apps/commerce-streamer/src/main/java/com/loopers/applications/streamer/consumer/product/dto/IncreaseProductLikeMetricEvent.java (1)

5-8: ์ปค๋งจ๋“œ์™€ ๋™์ผํ•œ null ๊ฒ€์ฆ ๊ณ ๋ ค

IncreaseProductLikeMetricCommand์— ๊ฒ€์ฆ์„ ์ถ”๊ฐ€ํ•˜๋Š” ๊ฒฝ์šฐ, ์ด๋ฒคํŠธ ๋ ˆ์ฝ”๋“œ์—์„œ๋„ ์ผ๊ด€์„ฑ์„ ์œ„ํ•ด ๋™์ผํ•œ ๊ฒ€์ฆ์„ ์ˆ˜ํ–‰ํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค. Kafka์—์„œ ์—ญ์ง๋ ฌํ™”๋œ ๋ฐ์ดํ„ฐ์˜ ๋ฌด๊ฒฐ์„ฑ์„ ๋ณด์žฅํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/WeeklyProductMetricBulkRepository.java (1)

7-10: ๋ฐ˜ํ™˜ ํƒ€์ž… ๋ฐ ๋ฌธ์„œํ™” ๊ฐœ์„  ๊ณ ๋ ค

ํ˜„์žฌ void ๋ฐ˜ํ™˜ ํƒ€์ž…์€ upsert ์ž‘์—…์˜ ๊ฒฐ๊ณผ(์˜ˆ: ์˜ํ–ฅ๋ฐ›์€ ํ–‰ ์ˆ˜, ์„ฑ๊ณต/์‹คํŒจ ์—ฌ๋ถ€)๋ฅผ ํ™•์ธํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. ๋‹ค์Œ ๊ฐœ์„  ์‚ฌํ•ญ์„ ๊ณ ๋ คํ•ด๋ณด์„ธ์š”:

  1. ๋ฉ”์„œ๋“œ JavaDoc ์ถ”๊ฐ€ (๋นˆ ๋ฆฌ์ŠคํŠธ ์ฒ˜๋ฆฌ, ํŠธ๋žœ์žญ์…˜ ๋™์ž‘, ์˜ˆ์™ธ ๋ฐœ์ƒ ์กฐ๊ฑด ๋“ฑ)
  2. ํ•„์š”์‹œ ์˜ํ–ฅ๋ฐ›์€ ํ–‰ ์ˆ˜๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋„๋ก ๋ฐ˜ํ™˜ ํƒ€์ž… ๋ณ€๊ฒฝ (์˜ˆ: int ๋˜๋Š” BulkUpsertResult)
๐Ÿ”Ž ๋ฌธ์„œํ™” ์˜ˆ์‹œ
 public interface WeeklyProductMetricBulkRepository {
 
+    /**
+     * ์ฃผ๊ฐ„ ์ƒํ’ˆ ๋ฉ”ํŠธ๋ฆญ ๋ชฉ๋ก์„ ์ผ๊ด„ ์‚ฝ์ž… ๋˜๋Š” ์—…๋ฐ์ดํŠธํ•ฉ๋‹ˆ๋‹ค.
+     * 
+     * @param weeklyProductMetrics upsertํ•  ์ฃผ๊ฐ„ ๋ฉ”ํŠธ๋ฆญ ์—”ํ‹ฐํ‹ฐ ๋ชฉ๋ก (๋นˆ ๋ฆฌ์ŠคํŠธ์˜ ๊ฒฝ์šฐ ์•„๋ฌด ์ž‘์—…๋„ ์ˆ˜ํ–‰ํ•˜์ง€ ์•Š์Œ)
+     * @throws org.springframework.dao.DataAccessException ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ์˜ค๋ฅ˜ ๋ฐœ์ƒ ์‹œ
+     */
     void bulkUpsert(List<WeeklyProductMetricEntity> weeklyProductMetrics);
 }
core/service/src/main/java/com/loopers/core/service/product/query/GetProductRankingQuery.java (1)

7-7: type ํŒŒ๋ผ๋ฏธํ„ฐ์˜ ์œ ํšจ์„ฑ ๊ฒ€์ฆ ๋˜๋Š” ๋ฌธ์„œํ™”๋ฅผ ๊ณ ๋ คํ•˜์„ธ์š”.

type ํŒŒ๋ผ๋ฏธํ„ฐ๋Š” ์ „๋žต ์„ ํƒ์— ์‚ฌ์šฉ๋˜์ง€๋งŒ ํ˜„์žฌ ๊ฒ€์ฆ์ด๋‚˜ ์ œ์•ฝ์ด ์—†์Šต๋‹ˆ๋‹ค. ๋‹ค์Œ ์ค‘ ํ•˜๋‚˜๋ฅผ ๊ณ ๋ คํ•ด๋ณด์„ธ์š”:

  1. ์œ ํšจํ•œ ํƒ€์ž… ๊ฐ’๋“ค์„ enum์œผ๋กœ ์ •์˜ (ํƒ€์ž… ์•ˆ์ •์„ฑ)
  2. ์ƒ์„ฑ์ž์—์„œ ์œ ํšจ์„ฑ ๊ฒ€์ฆ ๋กœ์ง ์ถ”๊ฐ€
  3. ์ตœ์†Œํ•œ Javadoc์œผ๋กœ ์œ ํšจํ•œ ๊ฐ’๋“ค์„ ๋ช…์‹œ (์˜ˆ: "daily", "weekly", "monthly")

ํ˜„์žฌ๋Š” ์ž˜๋ชป๋œ type ๊ฐ’์ด ์ „๋‹ฌ๋˜์–ด๋„ ์ฟผ๋ฆฌ ์ƒ์„ฑ ์‹œ์ ์— ๊ฐ์ง€๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž Enum ๊ธฐ๋ฐ˜ ๋ฆฌํŒฉํ† ๋ง ์ œ์•ˆ
 package com.loopers.core.service.product.query;
 
 import java.time.LocalDate;
 
+public enum RankingType {
+    DAILY, WEEKLY, MONTHLY
+}
+
 public record GetProductRankingQuery(
         LocalDate date,
-        String type,
+        RankingType type,
         int pageNo,
         int pageSize
 ) {
 }
core/service/src/main/java/com/loopers/core/service/product/component/GetProductRankingsStrategy.java (1)

7-12: LGTM: ์ „๋žต ํŒจํ„ด์ด ๊น”๋”ํ•˜๊ฒŒ ๊ตฌํ˜„๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

์ธํ„ฐํŽ˜์ด์Šค ์„ค๊ณ„๊ฐ€ ๋ช…ํ™•ํ•˜๊ณ  ๋‹จ์ผ ์ฑ…์ž„ ์›์น™์„ ์ž˜ ๋”ฐ๋ฅด๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. supports()์™€ getRankings()์˜ ๋ถ„๋ฆฌ๊ฐ€ ์ „๋žต ์„ ํƒ๊ณผ ์‹คํ–‰์„ ๋ช…ํ™•ํžˆ ๊ตฌ๋ถ„ํ•ฉ๋‹ˆ๋‹ค.

์„ ํƒ์ ์œผ๋กœ, ๋‹ค์Œ์„ ๋ฌธ์„œํ™”ํ•˜๋ฉด ๋” ์ข‹์Šต๋‹ˆ๋‹ค:

  • supports() ๋ฉ”์„œ๋“œ๊ฐ€ ์–ด๋–ค type ๊ฐ’๋“ค์„ ์ง€์›ํ•ด์•ผ ํ•˜๋Š”์ง€
  • getRankings()์˜ ๋ฐ˜ํ™˜๊ฐ’์ธ ProductRankings์˜ ๊ตฌ์กฐ
  • ์—ฌ๋Ÿฌ ์ „๋žต์ด ๋™์ผํ•œ type์„ ์ง€์›ํ•  ๊ฒฝ์šฐ์˜ ์šฐ์„ ์ˆœ์œ„
apps/commerce-batch/src/test/resources/application-test.yml (1)

53-61: ๋ฐฐ์น˜ ์„ค์ •์ด ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค.

chunk ํฌ๊ธฐ 100๊ณผ partition grid-size 4๋Š” ํ…Œ์ŠคํŠธ ํ™˜๊ฒฝ์— ์ ํ•ฉํ•œ ๊ฐ’์ž…๋‹ˆ๋‹ค.

์„ ํƒ์ ์œผ๋กœ, weekly์™€ monthly์˜ ์„ค์ •์ด ๋™์ผํ•˜๋ฏ€๋กœ ๊ณตํ†ต ์„ค์ •์œผ๋กœ ์ถ”์ถœํ•˜์—ฌ ์ค‘๋ณต์„ ์ œ๊ฑฐํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

apps/commerce-batch/src/main/java/com/loopers/application/batch/product/MonthlyProductMetricScheduler.java (2)

27-29: ํƒ€์ž„์กด ์ฒ˜๋ฆฌ๋ฅผ ๋ช…์‹œ์ ์œผ๋กœ ์ง€์ •ํ•˜๋Š” ๊ฒƒ์„ ๊ณ ๋ คํ•˜์„ธ์š”.

YearMonth.now()๋Š” ์‹œ์Šคํ…œ ๊ธฐ๋ณธ ํƒ€์ž„์กด์„ ์‚ฌ์šฉํ•˜๋ฏ€๋กœ, ์„œ๋ฒ„ ํ™˜๊ฒฝ์— ๋”ฐ๋ผ ๋‹ค๋ฅธ ๊ฒฐ๊ณผ๋ฅผ ์ดˆ๋ž˜ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋ฐฐ์น˜ ์ž‘์—…์˜ ์ผ๊ด€์„ฑ์„ ์œ„ํ•ด ๋ช…์‹œ์ ์ธ ํƒ€์ž„์กด(์˜ˆ: UTC)์„ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž ๋ช…์‹œ์  ํƒ€์ž„์กด ์‚ฌ์šฉ ์ œ์•ˆ
+import java.time.ZoneId;
+
     @Scheduled(cron = "0 0 2 1 * ?")
     public void run() {
-        YearMonth lastMonth = YearMonth.now().minusMonths(1);
+        YearMonth lastMonth = YearMonth.now(ZoneId.of("Asia/Seoul")).minusMonths(1);
         LocalDate startDate = lastMonth.atDay(1);
         LocalDate endDate = lastMonth.atEndOfMonth();

๋˜๋Š” UTC๋ฅผ ์‚ฌ์šฉํ•˜๋ ค๋ฉด:

+import java.time.ZoneOffset;
+
-        YearMonth lastMonth = YearMonth.now().minusMonths(1);
+        YearMonth lastMonth = YearMonth.now(ZoneOffset.UTC).minusMonths(1);

25-25: ์Šค์ผ€์ค„ ์‹คํ–‰ ํƒ€์ž„์กด์„ ๋ช…์‹œํ•˜๋Š” ๊ฒƒ์„ ๊ณ ๋ คํ•˜์„ธ์š”.

cron ํ‘œํ˜„์‹์— ํƒ€์ž„์กด์ด ์ง€์ •๋˜์ง€ ์•Š์•„ ์„œ๋ฒ„์˜ ๊ธฐ๋ณธ ํƒ€์ž„์กด์„ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. ์ผ๊ด€๋œ ์‹คํ–‰์„ ๋ณด์žฅํ•˜๊ธฐ ์œ„ํ•ด zone ์†์„ฑ์„ ์ถ”๊ฐ€ํ•˜๋Š” ๊ฒƒ์„ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ”Ž ํƒ€์ž„์กด ์ง€์ • ์ œ์•ˆ
-    @Scheduled(cron = "0 0 2 1 * ?")
+    @Scheduled(cron = "0 0 2 1 * ?", zone = "Asia/Seoul")
     public void run() {
core/service/src/main/java/com/loopers/core/service/product/component/GetProductRankingsStrategySelector.java (1)

14-19: null ์•ˆ์ „์„ฑ๊ณผ ์˜ˆ์™ธ ์ฒ˜๋ฆฌ๋ฅผ ๊ฐœ์„ ํ•˜์„ธ์š”.

ํ˜„์žฌ ๊ตฌํ˜„์— ๋‹ค์Œ ๊ฐœ์„ ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค:

  1. Null ํŒŒ๋ผ๋ฏธํ„ฐ ์ฒ˜๋ฆฌ: type์ด null์ธ ๊ฒฝ์šฐ supports() ํ˜ธ์ถœ ์‹œ NPE๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.
  2. ์˜ˆ์™ธ ํƒ€์ž…: ํด๋ผ์ด์–ธํŠธ ์˜ค๋ฅ˜์— ๋Œ€ํ•ด IllegalArgumentException ๋Œ€์‹  ๋„๋ฉ”์ธ๋ณ„ ์˜ˆ์™ธ๋ฅผ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.
  3. ๊ด€์ฐฐ์„ฑ: ์ „๋žต ์„ ํƒ ์‹คํŒจ ์‹œ ๋กœ๊น…์ด ์—†์–ด ๋””๋ฒ„๊น…์ด ์–ด๋ ต์Šต๋‹ˆ๋‹ค.
๐Ÿ”Ž ๊ฐœ์„  ์‚ฌํ•ญ ์ œ์•ˆ
+import lombok.extern.slf4j.Slf4j;
+import java.util.Objects;
+
+@Slf4j
 @Component
 @RequiredArgsConstructor
 public class GetProductRankingsStrategySelector {
 
     private final List<GetProductRankingsStrategy> strategies;
 
     public GetProductRankingsStrategy select(String type) {
+        Objects.requireNonNull(type, "๋žญํ‚น ํƒ€์ž…์€ null์ผ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.");
+        
         return strategies.stream()
                 .filter(strategy -> strategy.supports(type))
                 .findFirst()
-                .orElseThrow(() -> new IllegalArgumentException("์ง€์›ํ•˜์ง€ ์•Š๋Š” ํƒ€์ž…์˜ ๋žญํ‚น์ž…๋‹ˆ๋‹ค."));
+                .orElseThrow(() -> {
+                    log.error("Unsupported ranking type requested: {}", type);
+                    return new IllegalArgumentException("์ง€์›ํ•˜์ง€ ์•Š๋Š” ํƒ€์ž…์˜ ๋žญํ‚น์ž…๋‹ˆ๋‹ค: " + type);
+                });
     }
 }

๋” ๋‚˜์€ ๋ฐฉ๋ฒ•์œผ๋กœ๋Š”, ํ”„๋กœ์ ํŠธ์— ๋„๋ฉ”์ธ ์˜ˆ์™ธ ํด๋ž˜์Šค(์˜ˆ: UnsupportedRankingTypeException)๋ฅผ ์ •์˜ํ•˜์—ฌ ์‚ฌ์šฉํ•˜๋Š” ๊ฒƒ์„ ๊ณ ๋ คํ•˜์„ธ์š”.

apps/commerce-api/src/main/java/com/loopers/application/api/product/ProductV1ApiSpec.java (1)

44-44: type ํŒŒ๋ผ๋ฏธํ„ฐ์— ๋Œ€ํ•œ Swagger ๋ฌธ์„œ๋ฅผ ์ถ”๊ฐ€ํ•˜๊ณ , API ์ˆ˜์ค€์—์„œ์˜ ์œ ํšจ์„ฑ ๊ฒ€์ฆ์„ ๊ณ ๋ คํ•˜์„ธ์š”.

type ํŒŒ๋ผ๋ฏธํ„ฐ๋Š” ํ—ˆ์šฉ๋˜๋Š” ๊ฐ’("DAILY", "WEEKLY", "MONTHLY")์— ๋Œ€ํ•œ Swagger ๋ฌธ์„œ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. ๋˜ํ•œ ์ปจํŠธ๋กค๋Ÿฌ ๋ ˆ๋ฒจ์—์„œ ์œ ํšจ์„ฑ ๊ฒ€์ฆ ์–ด๋…ธํ…Œ์ด์…˜์ด ์—†์–ด, API ์‚ฌ์šฉ์ž๊ฐ€ ์œ ํšจํ•œ ๊ฐ’์„ ์‚ฌ์ „์— ํŒŒ์•…ํ•˜๊ธฐ ์–ด๋ ต์Šต๋‹ˆ๋‹ค. ์„œ๋น„์Šค ๋ ˆ์ด์–ด์—์„œ ๋Ÿฐํƒ€์ž„ ๊ฒ€์ฆ์ด ์กด์žฌํ•˜์ง€๋งŒ, API ๋ฌธ์„œ ๊ฐœ์„ ๊ณผ ์ž…๋ ฅ๊ฐ’ ๊ฒ€์ฆ ๊ฐ•ํ™”๋Š” API ์‚ฌ์šฉ์„ฑ์„ ํฌ๊ฒŒ ๊ฐœ์„ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž ๊ฐœ์„  ๋ฐฉ์•ˆ
     @Operation(
             summary = "์ƒํ’ˆ ๋žญํ‚น ์กฐํšŒ",
-            description = "์ƒํ’ˆ ๋žญํ‚น์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค."
+            description = "์ƒํ’ˆ ๋žญํ‚น์„ ์กฐํšŒํ•ฉ๋‹ˆ๋‹ค. type์€ 'DAILY', 'WEEKLY', 'MONTHLY' ์ค‘ ํ•˜๋‚˜๋ฅผ ์ง€์ •ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."
     )
     ApiResponse<GetProductRankingsResponse> getProductRankings(LocalDate date, String type, int pageNo, int pageSize);

์‹ค์ œ ๊ตฌํ˜„์ฒด(ProductV1Api)์—์„œ @RequestParam์— @Pattern ์–ด๋…ธํ…Œ์ด์…˜์„ ์ถ”๊ฐ€ํ•˜๊ฑฐ๋‚˜ enum์„ ์‚ฌ์šฉํ•˜์—ฌ ์ปดํŒŒ์ผ ํƒ€์ž„์— ๊ฒ€์ฆํ•˜๋Š” ๊ฒƒ์„ ๊ณ ๋ คํ•˜์„ธ์š”.

core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/MonthlyProductMetricBulkRepositoryImpl.java (1)

21-52: ๋ฒŒํฌ ์—…์„œํŠธ ๊ตฌํ˜„์ด ์˜ฌ๋ฐ”๋ฆ…๋‹ˆ๋‹ค.

ON DUPLICATE KEY UPDATE๋ฅผ ์‚ฌ์šฉํ•œ ๋ฒŒํฌ ์—…์„œํŠธ ๋กœ์ง์ด ์ •ํ™•ํ•˜๋ฉฐ, createdAt์€ ๋ณด์กดํ•˜๊ณ  ๋ฉ”ํŠธ๋ฆญ ํ•„๋“œ๋“ค๊ณผ updatedAt๋งŒ ๊ฐฑ์‹ ํ•˜๋Š” ๊ฒƒ์ด ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค.

๋‹ค๋งŒ WeeklyProductMetricBulkRepositoryImpl๊ณผ ๊ฑฐ์˜ ๋™์ผํ•œ ๊ตฌ์กฐ๋กœ ์ฝ”๋“œ ์ค‘๋ณต์ด ๋ฐœ์ƒํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์Œ๊ณผ ๊ฐ™์ด ๊ณตํ†ต ์ถ”์ƒ ํด๋ž˜์Šค๋‚˜ ํ…œํ”Œ๋ฆฟ ๋ฉ”์„œ๋“œ ํŒจํ„ด์„ ๊ณ ๋ คํ•ด๋ณด์„ธ์š”:

๐Ÿ”Ž ์ œ์•ˆ: ์ถ”์ƒ ํด๋ž˜์Šค๋ฅผ ํ†ตํ•œ ์ค‘๋ณต ์ œ๊ฑฐ

๊ณตํ†ต ๋กœ์ง์„ ์ถ”์ƒํ™”ํ•˜๋Š” ๋ฒ ์ด์Šค ํด๋ž˜์Šค๋ฅผ ๋งŒ๋“ค ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค:

public abstract class AbstractProductMetricBulkRepository<T> {
    protected final NamedParameterJdbcTemplate jdbcTemplate;
    
    protected AbstractProductMetricBulkRepository(NamedParameterJdbcTemplate jdbcTemplate) {
        this.jdbcTemplate = jdbcTemplate;
    }
    
    protected void executeBulkUpsert(List<T> metrics, String sql, 
                                      Function<T, SqlParameterSource> mapper) {
        if (Objects.isNull(metrics) || metrics.isEmpty()) {
            return;
        }
        
        SqlParameterSource[] batch = metrics.stream()
                .map(mapper)
                .toArray(SqlParameterSource[]::new);
        
        jdbcTemplate.batchUpdate(sql, batch);
    }
    
    protected abstract String getUpsertSql();
    protected abstract SqlParameterSource toParameterSource(T metric);
}

๊ทธ๋Ÿฐ ๋‹ค์Œ ๊ฐ ๊ตฌํ˜„์ฒด์—์„œ SQL๊ณผ ๋งคํ•‘ ๋กœ์ง๋งŒ ์ œ๊ณตํ•˜๋ฉด ๋ฉ๋‹ˆ๋‹ค.

core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/WeeklyProductMetricBulkRepositoryImpl.java (1)

21-53: ๋ฒŒํฌ ์—…์„œํŠธ ๋กœ์ง์ด ์˜ฌ๋ฐ”๋ฅด๊ฒŒ ๊ตฌํ˜„๋˜์—ˆ์Šต๋‹ˆ๋‹ค.

ON DUPLICATE KEY UPDATE๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๋ฉ”ํŠธ๋ฆญ์„ ํšจ์œจ์ ์œผ๋กœ ์—…์„œํŠธํ•˜๊ณ  ์žˆ์œผ๋ฉฐ, createdAt ๋ณด์กด๊ณผ ๋ฉ”ํŠธ๋ฆญ ํ•„๋“œ ๊ฐฑ์‹  ๋กœ์ง์ด ์ ์ ˆํ•ฉ๋‹ˆ๋‹ค.

MonthlyProductMetricBulkRepositoryImpl๊ณผ ๋™์ผํ•œ ๊ตฌ์กฐ์˜ ์ฝ”๋“œ ์ค‘๋ณต์ด ๋ฐœ์ƒํ•˜๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค. ์›”๊ฐ„ ๋ฉ”ํŠธ๋ฆญ ๋ฆฌ๋ทฐ ์ฝ”๋ฉ˜ํŠธ์—์„œ ์ œ์•ˆํ•œ ๊ฒƒ์ฒ˜๋Ÿผ, ๊ณตํ†ต ์ถ”์ƒ ํด๋ž˜์Šค๋ฅผ ํ†ตํ•ด ์ค‘๋ณต์„ ์ œ๊ฑฐํ•˜๋Š” ๊ฒƒ์„ ๊ณ ๋ คํ•˜์„ธ์š”.

apps/commerce-batch/src/main/java/com/loopers/application/batch/product/WeeklyProductMetricBatchReader.java (1)

22-30: ExecutionContext ํŒŒ๋ผ๋ฏธํ„ฐ ๊ฒ€์ฆ์„ ๊ณ ๋ คํ•˜์„ธ์š”.

startDate, endDate, partitionOffset, partitionLimit ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ExecutionContext์—์„œ ์ฝ์„ ๋•Œ null ์ฒดํฌ๋‚˜ ์˜ˆ์™ธ ์ฒ˜๋ฆฌ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. ๋ฐฐ์น˜ ์„ค์ • ์˜ค๋ฅ˜ ์‹œ ๋ช…ํ™•ํ•˜์ง€ ์•Š์€ ์˜ˆ์™ธ๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํ•„์ˆ˜ ํŒŒ๋ผ๋ฏธํ„ฐ ๊ฒ€์ฆ์„ ์ถ”๊ฐ€ํ•˜๋ฉด ๋ฐฐ์น˜ ์‹คํŒจ ์‹œ ์›์ธ ํŒŒ์•…์ด ๋” ์‰ฌ์›Œ์ง‘๋‹ˆ๋‹ค.

๐Ÿ”Ž ๋ฐฉ์–ด์  ๊ฒ€์ฆ ์ถ”๊ฐ€ ์ œ์•ˆ
 @Override
 public void open(@NonNull ExecutionContext executionContext) {
+    Objects.requireNonNull(executionContext.getString("startDate"), "startDate is required");
+    Objects.requireNonNull(executionContext.getString("endDate"), "endDate is required");
     LocalDate startDate = LocalDate.parse(executionContext.getString("startDate"));
     LocalDate endDate = LocalDate.parse(executionContext.getString("endDate"));
     long partitionOffset = executionContext.getLong("partitionOffset");
     long partitionLimit = executionContext.getLong("partitionLimit");
+    
+    if (partitionLimit <= 0) {
+        throw new IllegalArgumentException("partitionLimit must be positive");
+    }

     this.iterator = dailyProductMetricRepository.findAggregatedBy(startDate, endDate, partitionOffset, partitionLimit)
             .iterator();
 }
apps/commerce-batch/src/test/java/com/loopers/application/batch/product/WeeklyProductMetricSchedulerTest.java (1)

142-142: ๋ถˆํ•„์š”ํ•œ ์ฃผ์„ ์ฝ”๋“œ ์ •๋ฆฌ ํ•„์š”

์ฃผ์„ ์ฒ˜๋ฆฌ๋œ ์ฝ”๋“œ๊ฐ€ ๋‚จ์•„์žˆ์Šต๋‹ˆ๋‹ค. ์ •๋ฆฌํ•ด ์ฃผ์„ธ์š”.

๐Ÿ”Ž ์ˆ˜์ • ์ œ์•ˆ
-//            // ์ƒํ’ˆ 1: 7์ผ ๋™์•ˆ ์ข‹์•„์š” 10์”ฉ (์ด 70)
+            // ์ƒํ’ˆ 1: 7์ผ ๋™์•ˆ ์ข‹์•„์š” 10์”ฉ (์ด 70)
core/domain/src/main/java/com/loopers/core/domain/product/MonthlyProductMetric.java (1)

8-45: WeeklyProductMetric๊ณผ์˜ ์ผ๊ด€์„ฑ ๊ฐœ์„  ์ œ์•ˆ

WeeklyProductMetric์€ @AllArgsConstructor(access = AccessLevel.PRIVATE)๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์ƒ์„ฑ์ž๋ฅผ ์ž๋™ ์ƒ์„ฑํ•˜๋Š” ๋ฐ˜๋ฉด, ์ด ํด๋ž˜์Šค๋Š” ์ˆ˜๋™์œผ๋กœ private ์ƒ์„ฑ์ž๋ฅผ ์ •์˜ํ–ˆ์Šต๋‹ˆ๋‹ค. ๊ธฐ๋Šฅ์ƒ ๋™์ผํ•˜์ง€๋งŒ, ์ฝ”๋“œ๋ฒ ์ด์Šค ์ผ๊ด€์„ฑ์„ ์œ„ํ•ด Lombok ์–ด๋…ธํ…Œ์ด์…˜ ์‚ฌ์šฉ์„ ๊ณ ๋ คํ•ด ๋ณด์„ธ์š”.

๐Ÿ”Ž ์ˆ˜์ • ์ œ์•ˆ
 import com.loopers.core.domain.common.vo.CreatedAt;
 import com.loopers.core.domain.common.vo.UpdatedAt;
 import com.loopers.core.domain.product.vo.*;
+import lombok.AccessLevel;
+import lombok.AllArgsConstructor;
 import lombok.Getter;
 
 @Getter
+@AllArgsConstructor(access = AccessLevel.PRIVATE)
 public class MonthlyProductMetric {
 
     private final MonthlyProductMetricId id;
-
     private final ProductId productId;
-
     private final ProductLikeCount likeCount;
-
     private final ProductDetailViewCount viewCount;
-
     private final ProductTotalSalesCount totalSalesCount;
-
     private final YearMonth yearMonth;
-
     private final CreatedAt createdAt;
-
     private final UpdatedAt updatedAt;
 
-    private MonthlyProductMetric(
-            MonthlyProductMetricId id,
-            ProductId productId,
-            ProductLikeCount likeCount,
-            ProductDetailViewCount viewCount,
-            ProductTotalSalesCount totalSalesCount,
-            YearMonth yearMonth,
-            CreatedAt createdAt,
-            UpdatedAt updatedAt
-    ) {
-        this.id = id;
-        this.productId = productId;
-        this.likeCount = likeCount;
-        this.viewCount = viewCount;
-        this.totalSalesCount = totalSalesCount;
-        this.yearMonth = yearMonth;
-        this.createdAt = createdAt;
-        this.updatedAt = updatedAt;
-    }
-
     public static MonthlyProductMetric create(
apps/commerce-batch/src/main/java/com/loopers/application/batch/product/MonthlyProductMetricBatchReader.java (1)

31-38: @Nullable ์–ด๋…ธํ…Œ์ด์…˜ ๋ˆ„๋ฝ

WeeklyProductMetricBatchReader์˜ read() ๋ฉ”์„œ๋“œ์—๋Š” @Nullable ์–ด๋…ธํ…Œ์ด์…˜์ด ์žˆ์ง€๋งŒ, ์ด ํด๋ž˜์Šค์—๋Š” ๋ˆ„๋ฝ๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค. ์ผ๊ด€์„ฑ์„ ์œ„ํ•ด ์ถ”๊ฐ€ํ•˜๋Š” ๊ฒƒ์ด ์ข‹์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ œ์•ˆ๋œ ์ˆ˜์ •
+import org.springframework.lang.Nullable;
+
 @RequiredArgsConstructor
 public class MonthlyProductMetricBatchReader implements ItemStreamReader<ProductMetricAggregation> {
     // ...
 
+    @Nullable
     @Override
     public ProductMetricAggregation read() {
         if (Objects.isNull(iterator) || !iterator.hasNext()) {
             return null;
         }
 
         return iterator.next();
     }
 }
core/service/src/main/java/com/loopers/core/service/product/component/GetMonthlyProductRankingsStrategy.java (1)

26-29: supports() ๋ฉ”์„œ๋“œ์˜ NullPointerException ๊ฐ€๋Šฅ์„ฑ

type ํŒŒ๋ผ๋ฏธํ„ฐ๊ฐ€ null๋กœ ์ „๋‹ฌ๋  ๊ฒฝ์šฐ type.equals("MONTHLY")์—์„œ NPE๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. GetWeeklyProductRankingsStrategy๋„ ๋™์ผํ•œ ํŒจํ„ด์„ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ์ง€๋งŒ, ๋ฐฉ์–ด์  ์ฝ”๋”ฉ์„ ๊ณ ๋ คํ•ด๋ณผ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ œ์•ˆ๋œ ์ˆ˜์ •
     @Override
     public boolean supports(String type) {
-        return type.equals("MONTHLY");
+        return "MONTHLY".equals(type);
     }
core/service/src/main/java/com/loopers/core/service/product/component/GetWeeklyProductRankingsStrategy.java (1)

26-29: type์ด null์ธ ๊ฒฝ์šฐ NPE ๋ฐœ์ƒ ๊ฐ€๋Šฅ

supports() ๋ฉ”์„œ๋“œ์—์„œ type.equals("WEEKLY") ํ˜ธ์ถœ ์‹œ type์ด null์ด๋ฉด NullPointerException์ด ๋ฐœ์ƒํ•ฉ๋‹ˆ๋‹ค. ๋‹ค๋ฅธ ์ „๋žต๋“ค๋„ ๋™์ผํ•œ ํŒจํ„ด์„ ์‚ฌ์šฉํ•˜๊ณ  ์žˆ์œผ๋‚˜, ๋ฐฉ์–ด์  ์ฝ”๋”ฉ์„ ์œ„ํ•ด ์ˆ˜์ •์„ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ œ์•ˆ๋œ ์ˆ˜์ •
     @Override
     public boolean supports(String type) {
-        return type.equals("WEEKLY");
+        return "WEEKLY".equals(type);
     }
apps/commerce-batch/src/main/java/com/loopers/application/batch/product/MonthlyProductMetricBatchPartitioner.java (1)

42-51: ๋ถˆํ•„์š”ํ•œ ๋นˆ ํŒŒํ‹ฐ์…˜ ์ƒ์„ฑ ๊ฐ€๋Šฅ์„ฑ

totalCount๊ฐ€ gridSize๋ณด๋‹ค ์ž‘์€ ๊ฒฝ์šฐ์—๋„ ๋ชจ๋“  gridSize๊ฐœ์˜ ํŒŒํ‹ฐ์…˜์„ ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. ์ด๋กœ ์ธํ•ด partitionOffset์ด totalCount๋ฅผ ์ดˆ๊ณผํ•˜๋Š” ๋นˆ ํŒŒํ‹ฐ์…˜์ด ์ƒ์„ฑ๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ œ์•ˆ๋œ ์ˆ˜์ •
+        int actualGridSize = (int) Math.min(gridSize, totalCount);
+        long targetSize = (totalCount / actualGridSize) + 1;
         Map<String, ExecutionContext> partitions = new HashMap<>();

-        for (int i = 0; i < gridSize; i++) {
+        for (int i = 0; i < actualGridSize; i++) {
             ExecutionContext context = new ExecutionContext();

             context.putLong("partitionOffset", i * targetSize);
             context.putLong("partitionLimit", targetSize);
             context.putString("startDate", startDateParam);
             context.putString("endDate", endDateParam);

             partitions.put("partition" + i, context);
         }
core/domain/src/main/java/com/loopers/core/domain/common/vo/YearMonthWeek.java (1)

48-50: getWeekEndDateTime()์—์„œ ๋งˆ์ง€๋ง‰ ๋‚˜๋…ธ์ดˆ ๋ˆ„๋ฝ

atTime(23, 59, 59)๋Š” ๋งˆ์ง€๋ง‰ 1์ดˆ(999,999,999 ๋‚˜๋…ธ์ดˆ)๋ฅผ ํฌํ•จํ•˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ๋ฒ”์œ„ ์ฟผ๋ฆฌ์— ์‚ฌ์šฉ ์‹œ ๋ฏธ์„ธํ•œ ๋ฐ์ดํ„ฐ ๋ˆ„๋ฝ์ด ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ œ์•ˆ๋œ ์ˆ˜์ •
     public LocalDateTime getWeekEndDateTime() {
-        return this.getWeekEndDate().atTime(23, 59, 59);
+        return this.getWeekEndDate().atTime(23, 59, 59, 999_999_999);
     }

๋˜๋Š” LocalTime.MAX๋ฅผ ์‚ฌ์šฉ:

return this.getWeekEndDate().atTime(LocalTime.MAX);
core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/MonthlyProductMetricRepositoryImpl.java (1)

25-30: Null ๋ฆฌ์ŠคํŠธ ์ž…๋ ฅ ์‹œ NPE ๊ฐ€๋Šฅ์„ฑ

bulkUpsert์—์„œ monthlyProductMetrics๊ฐ€ null์ธ ๊ฒฝ์šฐ stream() ํ˜ธ์ถœ ์‹œ NPE๊ฐ€ ๋ฐœ์ƒํ•ฉ๋‹ˆ๋‹ค. MonthlyProductMetricBulkRepositoryImpl์—์„œ null ์ฒดํฌ๋ฅผ ์ˆ˜ํ–‰ํ•˜์ง€๋งŒ, entity ๋ณ€ํ™˜ ์ „์— ๋ฐœ์ƒํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ œ์•ˆ๋œ ์ˆ˜์ •
     @Override
     public void bulkUpsert(List<MonthlyProductMetric> monthlyProductMetrics) {
+        if (monthlyProductMetrics == null || monthlyProductMetrics.isEmpty()) {
+            return;
+        }
         bulkRepository.bulkUpsert(monthlyProductMetrics.stream()
                 .map(MonthlyProductMetricEntity::from)
                 .toList());
     }
apps/commerce-batch/src/main/java/com/loopers/application/batch/product/config/WeeklyProductMetricBatchConfig.java (3)

38-49: Step ์ด๋ฆ„์ด ์‹ค์ œ ๊ธฐ๋Šฅ๊ณผ ๋ถˆ์ผ์น˜ํ•ฉ๋‹ˆ๋‹ค.

partitionDailyMetricStep๊ณผ collectDailyMetricStep์€ ์ฃผ๊ฐ„(Weekly) ๋ฉ”ํŠธ๋ฆญ ์ง‘๊ณ„ ์ž‘์—…์„ ์ˆ˜ํ–‰ํ•˜์ง€๋งŒ, ์ด๋ฆ„์— "Daily"๊ฐ€ ํฌํ•จ๋˜์–ด ์žˆ์–ด ํ˜ผ๋ž€์„ ์ค„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. partitionWeeklyMetricStep, aggregateWeeklyMetricStep ๋“ฑ์œผ๋กœ ๋ณ€๊ฒฝ์„ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.


80-86: TaskExecutor Bean ์ด๋ฆ„ ์ถฉ๋Œ ๊ฐ€๋Šฅ์„ฑ

asyncTaskExecutor()๋ผ๋Š” ์ผ๋ฐ˜์ ์ธ ์ด๋ฆ„์€ ๋‹ค๋ฅธ ๋ฐฐ์น˜ ์„ค์ •(์˜ˆ: MonthlyProductMetricBatchConfig์˜ monthlyAsyncTaskExecutor)๊ณผ ์ถฉ๋Œํ•˜๊ฑฐ๋‚˜ ์˜๋„์น˜ ์•Š์€ ์ฃผ์ž…์ด ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ˆ˜์ • ์ œ์•ˆ
 @Bean
-public TaskExecutor asyncTaskExecutor() {
+public TaskExecutor weeklyAsyncTaskExecutor() {
     SimpleAsyncTaskExecutor executor = new SimpleAsyncTaskExecutor("weekly-product-metric-batch-");
     executor.setVirtualThreads(true);
     executor.setConcurrencyLimit(20);
     return executor;
 }

๊ทธ๋ฆฌ๊ณ  collectDailyMetricStep์—์„œ๋„ ํ•ด๋‹น Bean์„ ๋ช…์‹œ์ ์œผ๋กœ ์ฐธ์กฐ:

-.taskExecutor(asyncTaskExecutor())
+.taskExecutor(weeklyAsyncTaskExecutor())

75-77: ์žฌ์‹œ๋„ ํšŸ์ˆ˜(retryLimit) ๋ช…์‹œ ๊ถŒ์žฅ

retry(DataAccessException.class)๋งŒ ์„ค์ •๋˜์–ด ์žˆ๊ณ  retryLimit๊ฐ€ ๋ช…์‹œ๋˜์ง€ ์•Š์•˜์Šต๋‹ˆ๋‹ค. ๊ธฐ๋ณธ๊ฐ’์— ์˜์กดํ•˜๊ธฐ๋ณด๋‹ค ๋ช…์‹œ์ ์œผ๋กœ ์žฌ์‹œ๋„ ํšŸ์ˆ˜๋ฅผ ์„ค์ •ํ•˜๋Š” ๊ฒƒ์ด ์šด์˜ ์‹œ ์˜ˆ์ธก ๊ฐ€๋Šฅํ•œ ๋™์ž‘์„ ๋ณด์žฅํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ˆ˜์ • ์ œ์•ˆ
 .faultTolerant()
 .retry(DataAccessException.class)
+.retryLimit(3)
 .build();
core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/DailyProductMetricJpaRepository.java (2)

30-46: JPQL์—์„œ LIMIT/OFFSET ์ง์ ‘ ์‚ฌ์šฉ์€ Hibernate ์ „์šฉ ๋ฌธ๋ฒ•์ž…๋‹ˆ๋‹ค.

ํ‘œ์ค€ JPQL์€ LIMIT/OFFSET์„ ์ง€์›ํ•˜์ง€ ์•Š์œผ๋ฉฐ, ์ด๋Š” Hibernate(HQL) ํ™•์žฅ ๊ธฐ๋Šฅ์ž…๋‹ˆ๋‹ค. Hibernate ์™ธ JPA ๊ตฌํ˜„์ฒด๋กœ ์ „ํ™˜ ์‹œ ํ˜ธํ™˜์„ฑ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

Spring Data JPA์˜ Pageable ํŒŒ๋ผ๋ฏธํ„ฐ๋ฅผ ํ™œ์šฉํ•˜๋ฉด ๊ตฌํ˜„์ฒด์— ๋…๋ฆฝ์ ์ธ ํŽ˜์ด์ง€๋„ค์ด์…˜์ด ๊ฐ€๋Šฅํ•ฉ๋‹ˆ๋‹ค.


22-28: CAST(createdAt AS date) ์‚ฌ์šฉ ์‹œ ์ธ๋ฑ์Šค ํ™œ์šฉ ํ™•์ธ ํ•„์š”

cast(pme.createdAt as date) ํ•จ์ˆ˜ ํ˜ธ์ถœ์€ createdAt ์ปฌ๋Ÿผ์˜ ์ธ๋ฑ์Šค๋ฅผ ํ™œ์šฉํ•˜์ง€ ๋ชปํ•˜๊ฒŒ ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค(Index Scan โ†’ Full Table Scan). ๋ฐ์ดํ„ฐ ๊ทœ๋ชจ๊ฐ€ ํด ๊ฒฝ์šฐ ์ฟผ๋ฆฌ ์„ฑ๋Šฅ์— ์˜ํ–ฅ์„ ์ค„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๋Œ€์•ˆ์œผ๋กœ ๋‚ ์งœ ๋ฒ”์œ„ ์กฐ๊ฑด์„ ์‚ฌ์šฉํ•˜๊ฑฐ๋‚˜, ๋ณ„๋„์˜ date ์ปฌ๋Ÿผ์„ ์ถ”๊ฐ€ํ•˜์—ฌ ์ธ๋ฑ์‹ฑํ•˜๋Š” ๋ฐฉ๋ฒ•์„ ๊ณ ๋ คํ•ด ๋ณด์„ธ์š”.

-- ๋Œ€์•ˆ: ๋ฒ”์œ„ ์กฐ๊ฑด ์‚ฌ์šฉ
WHERE pme.createdAt >= :startDate AND pme.createdAt < :endDate + 1 day

Also applies to: 36-37

apps/commerce-batch/src/main/java/com/loopers/application/batch/product/config/MonthlyProductMetricBatchConfig.java (1)

75-77: ์žฌ์‹œ๋„ ํšŸ์ˆ˜ ์ œํ•œ ๋ช…์‹œ๋ฅผ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค.

.retry(DataAccessException.class)๋งŒ ์„ค์ •ํ•˜๋ฉด Spring Batch ๊ธฐ๋ณธ๊ฐ’(1ํšŒ)์ด ์ ์šฉ๋ฉ๋‹ˆ๋‹ค. ๋ช…์‹œ์ ์œผ๋กœ .retryLimit()์„ ์ง€์ •ํ•˜๋ฉด ์˜๋„๊ฐ€ ๋” ๋ช…ํ™•ํ•ด์ง€๊ณ  ์œ ์ง€๋ณด์ˆ˜๊ฐ€ ์šฉ์ดํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ œ์•ˆ๋œ ์ˆ˜์ • ์‚ฌํ•ญ
                 .taskExecutor(monthlyAsyncTaskExecutor())
                 .faultTolerant()
                 .retry(DataAccessException.class)
+                .retryLimit(3)
                 .build();

Comment on lines +1 to +26
dependencies {
implementation(project(":core:infra:database:mysql:mysql-config"))
implementation(project(":supports:jackson"))
implementation(project(":supports:logging"))
implementation(project(":supports:monitoring"))

//service
implementation(project(":core:service"))

//domain
implementation(project(":core:domain"))

// web
implementation("org.springframework.boot:spring-boot-starter-web")

//batch
implementation("org.springframework.boot:spring-boot-starter-batch")

implementation("org.springframework:spring-tx")
implementation("org.springframework.boot:spring-boot-starter-actuator")

// test-fixtures
testImplementation(project(":core:infra:database:mysql:mysql-config"))
testImplementation(testFixtures(project(":core:domain")))
testImplementation(testFixtures(project(":core:infra:database:mysql")))
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

โš ๏ธ Potential issue | ๐ŸŸ  Major

๐Ÿงฉ Analysis chain

๐Ÿ Script executed:

# Check for web component usage in commerce-batch module
fd -e java -e kt --path-separator / | grep -E "apps/commerce-batch" | head -20

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 1574


๐Ÿ Script executed:

# Search for web annotations in commerce-batch source files
rg -i '(@(RestController|Controller|RequestMapping|GetMapping|PostMapping|Service|Component)|WebController|RestEndpoint)' apps/commerce-batch --type java --type kotlin -n

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 828


๐Ÿ Script executed:

# Check the actual directory structure of commerce-batch
find apps/commerce-batch -type f -name "*.kt" -o -name "*.java" -o -name "*.kts" | grep -v ".gradle" | head -30

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 1574


๐Ÿ Script executed:

# Check CommerceBatchApplication main class
cat -n apps/commerce-batch/src/main/java/com/loopers/CommerceBatchApplication.java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 599


๐Ÿ Script executed:

# Check one of the scheduler files to see if web exposure is used
cat -n apps/commerce-batch/src/main/java/com/loopers/application/batch/product/MonthlyProductMetricScheduler.java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 2033


๐Ÿ Script executed:

# Search for any Actuator endpoint configuration or web exposure in the batch module
rg -i '(actuator|endpoint|web|servlet)' apps/commerce-batch --type java --type kotlin -A 2 -B 2

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 788


spring-boot-starter-web ์ œ๊ฑฐ ๋ฐ ์ค‘๋ณต๋œ mysql-config ์˜์กด์„ฑ ์ •๋ฆฌ

  1. spring-boot-starter-web ์ œ๊ฑฐ (Line 14): ๋ฐฐ์น˜ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์—์„œ ์›น ์Šคํƒ€ํ„ฐ๊ฐ€ ์‚ฌ์šฉ๋˜์ง€ ์•Š์Šต๋‹ˆ๋‹ค. ๋ชจ๋“ˆ ์ „์ฒด์—์„œ HTTP ์—”๋“œํฌ์ธํŠธ๋‚˜ ์›น ์ปจํŠธ๋กค๋Ÿฌ๊ฐ€ ์—†์œผ๋ฉฐ, ๋ชจ๋“  ๋ฐฐ์น˜ ์ž‘์—…์€ @Scheduled ์–ด๋…ธํ…Œ์ด์…˜์„ ํ†ตํ•ด ์Šค์ผ€์ค„๋ง์œผ๋กœ ์‹คํ–‰๋ฉ๋‹ˆ๋‹ค. ์ด ์˜์กด์„ฑ์„ ์ œ๊ฑฐํ•˜์„ธ์š”.

  2. ์ค‘๋ณต๋œ mysql-config ์ œ๊ฑฐ (Line 23): mysql-config๋Š” ์ด๋ฏธ implementation ์Šค์ฝ”ํ”„(Line 2)์— ์„ ์–ธ๋˜์–ด ์žˆ์œผ๋ฏ€๋กœ, testImplementation์—์„œ์˜ ์„ ์–ธ์€ ์ œ๊ฑฐํ•˜์„ธ์š”. Gradle์—์„œ ํ…Œ์ŠคํŠธ ์˜์กด์„ฑ์€ ์ž๋™์œผ๋กœ ๊ตฌํ˜„ ์˜์กด์„ฑ์„ ์ƒ์†๋ฐ›์Šต๋‹ˆ๋‹ค.

๐Ÿค– Prompt for AI Agents
In apps/commerce-batch/build.gradle.kts around lines 1 to 26, remove the
unnecessary "org.springframework.boot:spring-boot-starter-web" dependency (line
~14) because this is a batch-only module with no web controllers, and delete the
duplicate testImplementation(project(":core:infra:database:mysql:mysql-config"))
(line ~23) since mysql-config is already declared as an implementation
dependency and test scope inherits implementation dependencies; update the file
to keep only the batch- and required support dependencies.

Comment on lines +25 to +26
@Scheduled(cron = "0 0 2 1 * ?")
public void run() throws JobInstanceAlreadyCompleteException, JobExecutionAlreadyRunningException, JobParametersInvalidException, JobRestartException {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

โš ๏ธ Potential issue | ๐ŸŸ  Major

๋ฐฐ์น˜ ์ž‘์—… ์‹คํ–‰ ์‹คํŒจ ์‹œ ๊ด€์ฐฐ์„ฑ๊ณผ ์˜ค๋ฅ˜ ์ฒ˜๋ฆฌ๋ฅผ ๊ฐœ์„ ํ•˜์„ธ์š”.

์Šค์ผ€์ค„๋Ÿฌ ๋ฉ”์„œ๋“œ๊ฐ€ 4๊ฐœ์˜ ์ฒดํฌ ์˜ˆ์™ธ๋ฅผ ๋˜์ง€์ง€๋งŒ ๋กœ๊น…์ด๋‚˜ ์˜ˆ์™ธ ์ฒ˜๋ฆฌ๊ฐ€ ์—†์–ด, ๋ฐฐ์น˜ ์ž‘์—… ์‹คํŒจ๊ฐ€ ์กฐ์šฉํžˆ ๋ฌด์‹œ๋  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ์šด์˜ ํ™˜๊ฒฝ์—์„œ ๋ฌธ์ œ ์ง„๋‹จ์„ ์–ด๋ ต๊ฒŒ ๋งŒ๋“ญ๋‹ˆ๋‹ค.

๐Ÿ”Ž ๋กœ๊น…๊ณผ ์˜ˆ์™ธ ์ฒ˜๋ฆฌ ์ถ”๊ฐ€ ์ œ์•ˆ
     @Scheduled(cron = "0 0 2 1 * ?")
-    public void run() throws JobInstanceAlreadyCompleteException, JobExecutionAlreadyRunningException, JobParametersInvalidException, JobRestartException {
+    public void run() {
+        try {
+            log.info("Starting monthly product metric aggregation batch job");
             YearMonth lastMonth = YearMonth.now().minusMonths(1);
             LocalDate startDate = lastMonth.atDay(1);
             LocalDate endDate = lastMonth.atEndOfMonth();
     
             JobParameters jobParameters = new JobParametersBuilder()
                     .addString("startDate", startDate.toString())
                     .addString("endDate", endDate.toString())
                     .addLong("timestamp", System.currentTimeMillis())
                     .toJobParameters();
     
             jobLauncher.run(monthlyProductMetricJob, jobParameters);
+            log.info("Monthly product metric aggregation batch job completed successfully");
+        } catch (JobExecutionAlreadyRunningException e) {
+            log.warn("Monthly product metric batch job is already running", e);
+        } catch (JobInstanceAlreadyCompleteException | JobRestartException | JobParametersInvalidException e) {
+            log.error("Failed to execute monthly product metric batch job", e);
+        }
     }

Lombok์˜ @Slf4j ์–ด๋…ธํ…Œ์ด์…˜์„ ํด๋ž˜์Šค์— ์ถ”๊ฐ€ํ•˜์„ธ์š”.

Committable suggestion skipped: line range outside the PR's diff.

๐Ÿค– Prompt for AI Agents
In
apps/commerce-batch/src/main/java/com/loopers/application/batch/product/MonthlyProductMetricScheduler.java
around lines 25-26, the scheduled run() method currently declares four checked
exceptions but has no logging or handling; add Lombok's @Slf4j to the class,
wrap the job execution in a try/catch that catches those specific exceptions (or
Exception) inside the method instead of letting them propagate silently, call
log.error(...) with a clear contextual message and the exception instance for
each failure, and optionally emit a metric or rethrow a runtime exception if you
need the scheduler to mark the job as failed.

Comment on lines +17 to +28
@Component
@RequiredArgsConstructor
public class WeeklyProductMetricBatchWriter implements ItemStreamWriter<ProductMetricAggregation> {

private final WeeklyProductMetricRepository repository;
private YearMonthWeek yearMonthWeek;

@Override
public void open(@NonNull ExecutionContext executionContext) {
LocalDate startDate = LocalDate.parse(executionContext.getString("startDate"));
this.yearMonthWeek = YearMonthWeek.from(startDate);
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

โš ๏ธ Potential issue | ๐Ÿ”ด Critical

์‹ฑ๊ธ€ํ†ค Bean์—์„œ ๊ฐ€๋ณ€ ์ƒํƒœ๋กœ ์ธํ•œ ๋™์‹œ์„ฑ ๋ฌธ์ œ

@Component๋Š” ์‹ฑ๊ธ€ํ†ค ์Šค์ฝ”ํ”„์ด์ง€๋งŒ, yearMonthWeek ํ•„๋“œ๋Š” ๊ฐ€๋ณ€ ์ƒํƒœ์ž…๋‹ˆ๋‹ค. ํŒŒํ‹ฐ์…˜์ด ๋ณ‘๋ ฌ๋กœ ์‹คํ–‰๋  ๊ฒฝ์šฐ, ์—ฌ๋Ÿฌ ์Šค๋ ˆ๋“œ๊ฐ€ ๋™์ผํ•œ ์ธ์Šคํ„ด์Šค๋ฅผ ๊ณต์œ ํ•˜๋ฉด์„œ open() ๋ฉ”์„œ๋“œ์—์„œ yearMonthWeek๋ฅผ ๋ฎ์–ด์“ธ ์ˆ˜ ์žˆ์–ด ๋ฐ์ดํ„ฐ ์ •ํ•ฉ์„ฑ ๋ฌธ์ œ๊ฐ€ ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

@StepScope๋ฅผ ์ถ”๊ฐ€ํ•˜์—ฌ ๊ฐ ํŒŒํ‹ฐ์…˜์ด ๋…๋ฆฝ์ ์ธ Writer ์ธ์Šคํ„ด์Šค๋ฅผ ๊ฐ–๋„๋ก ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ”Ž ์ œ์•ˆ๋œ ์ˆ˜์ •
 import org.springframework.batch.item.Chunk;
 import org.springframework.batch.item.ExecutionContext;
 import org.springframework.batch.item.ItemStreamWriter;
+import org.springframework.batch.core.configuration.annotation.StepScope;
 import org.springframework.stereotype.Component;

 @Component
+@StepScope
 @RequiredArgsConstructor
 public class WeeklyProductMetricBatchWriter implements ItemStreamWriter<ProductMetricAggregation> {
๐Ÿค– Prompt for AI Agents
In
apps/commerce-batch/src/main/java/com/loopers/application/batch/product/WeeklyProductMetricBatchWriter.java
around lines 17 to 28, the class is a singleton component but holds mutable
state in the yearMonthWeek field which can be overwritten concurrently by
parallel partitions; make the writer step-scoped so each partition gets its own
instance: annotate the class with Spring Batch's @StepScope (and add the
necessary import) so dependency injection still works but a new writer instance
is created per step execution, keep yearMonthWeek as an instance field
(non-static) and leave open() logic unchanged; verify no other shared mutable
fields exist.

- monitoring.yml
batch:
jdbc:
initialize-schema: always
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

โš ๏ธ Potential issue | ๐Ÿ”ด Critical

ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ์—์„œ ๋ฐ์ดํ„ฐ ์†์‹ค ์œ„ํ—˜์ด ์žˆ์Šต๋‹ˆ๋‹ค.

initialize-schema: always ์„ค์ •์€ ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์ด ์‹œ์ž‘๋  ๋•Œ๋งˆ๋‹ค ์Šคํ‚ค๋งˆ๋ฅผ ์žฌ์ƒ์„ฑํ•ฉ๋‹ˆ๋‹ค. ์ด๋Š” ๊ฐœ๋ฐœ ํ™˜๊ฒฝ์—์„œ๋Š” ์œ ์šฉํ•˜์ง€๋งŒ, ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ์—์„œ๋Š” ๋ฐ์ดํ„ฐ ์†์‹ค์„ ์ดˆ๋ž˜ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ํ”„๋กœํ•„๋ณ„๋กœ ๋‹ค๋ฅด๊ฒŒ ์„ค์ •ํ•ด์•ผ ํ•ฉ๋‹ˆ๋‹ค.

๐Ÿ”Ž ํ”„๋กœํ•„๋ณ„ ์„ค์ • ์ œ์•ˆ

๊ธ€๋กœ๋ฒŒ ์„ค์ •์„ ์ œ๊ฑฐํ•˜๊ณ  ๊ฐ ํ”„๋กœํ•„ ์„น์…˜์— ๋‹ค์Œ๊ณผ ๊ฐ™์ด ์ถ”๊ฐ€ํ•˜์„ธ์š”:

-  batch:
-    jdbc:
-      initialize-schema: always
-    job:
-      enabled: false
+  batch:
+    job:
+      enabled: false

๊ทธ๋ฆฌ๊ณ  local/test ํ”„๋กœํ•„ ์„น์…˜์—๋งŒ ์ถ”๊ฐ€:

 spring:
   config:
     activate:
       on-profile: local, test
+  batch:
+    jdbc:
+      initialize-schema: always

ํ”„๋กœ๋•์…˜ ํ™˜๊ฒฝ(dev, qa, prd)์—์„œ๋Š” Flyway๋‚˜ Liquibase ๊ฐ™์€ ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ๋„๊ตฌ๋ฅผ ์‚ฌ์šฉํ•˜๊ฑฐ๋‚˜ never๋กœ ์„ค์ •ํ•˜์„ธ์š”.

Committable suggestion skipped: line range outside the PR's diff.

๐Ÿค– Prompt for AI Agents
In apps/commerce-batch/src/main/resources/application.yml around line 33, the
global setting "initialize-schema: always" causes schema recreation on every
startup (risking production data loss); remove this global key and instead add
profile-specific settings: set initialize-schema: always (or create-drop) only
under local/test profiles, and set initialize-schema: never for production
profiles (dev/qa/prd) while relying on Flyway/Liquibase for migrations; update
the YAML to place these keys under appropriate spring.profiles sections so
production never recreates the schema.

Comment on lines +11 to +17
Page<MonthlyProductRankingProjection> findMonthlyProductRanking(
LocalDate date,
Double payWeight,
Double viewWeight,
Double likeWeight,
Pageable pageable
);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

โš ๏ธ Potential issue | ๐ŸŸ  Major

๐Ÿงฉ Analysis chain

๐Ÿ Script executed:

#!/bin/bash
# Find the implementation file
find . -type f -name "*MonthlyProductMetricQuerydslRepositoryImpl*" -o -name "*MonthlyProductMetric*" | head -20

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 1964


๐Ÿ Script executed:

#!/bin/bash
# Search for the implementation class definition
rg "class.*MonthlyProductMetricQuerydslRepository" --type java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 329


๐Ÿ Script executed:

#!/bin/bash
# Search for findMonthlyProductRanking implementation
rg "findMonthlyProductRanking" --type java -A 30

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 9995


๐Ÿ Script executed:

#!/bin/bash
# Read the complete implementation file
cat -n core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/MonthlyProductMetricQuerydslRepositoryImpl.java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 3286


๐Ÿ Script executed:

#!/bin/bash
# Check the caller class for parameter validation
cat -n core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/impl/MonthlyProductMetricRepositoryImpl.java | head -80

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 2708


๐Ÿ Script executed:

#!/bin/bash
# Search for any @Nullable or @NonNull annotations on the interface
rg "@Nullable|@NonNull|Objects.requireNonNull" core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/MonthlyProductMetricQuerydslRepository.java -B 2 -A 2

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


๐Ÿ Script executed:

#!/bin/bash
# Check the interface definition for annotations
cat -n core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/MonthlyProductMetricQuerydslRepository.java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 770


๐Ÿ Script executed:

#!/bin/bash
# Search for any validation or use of these weight parameters in domain layer
rg "payWeight|viewWeight|likeWeight" --type java -B 2 -A 2

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 18074


๐Ÿ Script executed:

#!/bin/bash
# Check if there's validation at service or controller level
find . -type f -name "*.java" | xargs grep -l "findRankingsBy" | head -10

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 786


๐Ÿ Script executed:

#!/bin/bash
# Check the strategy component to see weight handling
cat -n core/service/src/main/java/com/loopers/core/service/product/component/GetMonthlyProductRankingsStrategy.java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 1493


date ํŒŒ๋ผ๋ฏธํ„ฐ์˜ null ์ฒดํฌ์™€ ๊ฐ€์ค‘์น˜ ๊ฐ’ ๊ฒ€์ฆ ํ•„์š”

๊ตฌํ˜„์ฒด(MonthlyProductMetricQuerydslRepositoryImpl)์—์„œ date ํŒŒ๋ผ๋ฏธํ„ฐ์— ๋Œ€ํ•œ null ์ฒดํฌ๊ฐ€ ์—†์–ด date.getYear() ํ˜ธ์ถœ ์‹œ NullPointerException์ด ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋˜ํ•œ payWeight, viewWeight, likeWeight ํŒŒ๋ผ๋ฏธํ„ฐ๋„ null์ผ ๊ฒฝ์šฐ .multiply() ์—ฐ์‚ฐ์—์„œ NPE๊ฐ€ ๋ฐœ์ƒํ•˜๋ฉฐ, ์Œ์ˆ˜ ๋˜๋Š” ์œ ํšจํ•˜์ง€ ์•Š์€ ๊ฐ€์ค‘์น˜ ๊ฐ’์— ๋Œ€ํ•œ ๊ฒ€์ฆ์ด ์—†์–ด ๋ถ€์ •ํ™•ํ•œ ์ˆœ์œ„ ๊ณ„์‚ฐ์ด ๋ฐœ์ƒํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.

๊ถŒ์žฅ ์ˆ˜์ • ์‚ฌํ•ญ

๋ฉ”์„œ๋“œ ์‹œ์ž‘ ๋ถ€๋ถ„์—์„œ ๋‹ค์Œ ๊ฒ€์ฆ ๋กœ์ง ์ถ”๊ฐ€:

  • Objects.requireNonNull(date, "date must not be null")
  • Objects.requireNonNull(payWeight, "payWeight must not be null")
  • ๊ฐ€์ค‘์น˜ ๊ฐ’์ด ์Œ์ˆ˜ ๋˜๋Š” 0 ์ดํ•˜์ธ ๊ฒฝ์šฐ์— ๋Œ€ํ•œ ๊ฒ€์ฆ ๋กœ์ง
๐Ÿค– Prompt for AI Agents
In
core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/MonthlyProductMetricQuerydslRepository.java
around lines 11-17, add input validation at the start of the implementation:
require non-null for date, payWeight, viewWeight, likeWeight (e.g.
Objects.requireNonNull(..., "X must not be null")), and validate that numeric
weights are > 0 (else throw IllegalArgumentException with a clear message);
these checks prevent date.getYear() and weight.multiply(...)
NullPointerExceptions and guard against zero/negative weights affecting ranking.

Comment on lines +42 to +56
List<MonthlyProductRankingProjection> content = queryFactory
.select(new QMonthlyProductRankingProjection(
metric.productId,
Expressions.numberTemplate(Long.class,
"ROW_NUMBER() OVER (ORDER BY {0} DESC)",
scoreCalculation),
scoreCalculation.as("score")
))
.from(metric)
.where(metric.year.eq(year)
.and(metric.month.eq(month)))
.orderBy(scoreCalculation.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

โš ๏ธ Potential issue | ๐ŸŸ  Major

ROW_NUMBER()์™€ ํŽ˜์ด์ง€๋„ค์ด์…˜ ์กฐํ•ฉ ์‹œ ๋žญํ‚น ๊ฐ’์ด ์ž˜๋ชป๋ฉ๋‹ˆ๋‹ค.

ROW_NUMBER() OVER (ORDER BY score DESC)๋Š” ์ฟผ๋ฆฌ ๊ฒฐ๊ณผ ์ „์ฒด๊ฐ€ ์•„๋‹Œ ํŽ˜์ด์ง€๋„ค์ด์…˜ ์ ์šฉ ํ›„์˜ ๊ฒฐ๊ณผ ์ง‘ํ•ฉ์—์„œ ๊ณ„์‚ฐ๋ฉ๋‹ˆ๋‹ค. ์˜ˆ๋ฅผ ๋“ค์–ด, pageNo=1, pageSize=10์ผ ๋•Œ ์‹ค์ œ ์ˆœ์œ„ 1120์œ„์ธ ๋ฐ์ดํ„ฐ๊ฐ€ ROW_NUMBER 110์œผ๋กœ ๋ฐ˜ํ™˜๋ฉ๋‹ˆ๋‹ค.

์ˆ˜์ • ๋ฐฉ์•ˆ:

  1. ์„œ๋ธŒ์ฟผ๋ฆฌ์—์„œ ๋จผ์ € ROW_NUMBER๋ฅผ ๊ณ„์‚ฐํ•œ ํ›„ ์™ธ๋ถ€ ์ฟผ๋ฆฌ์—์„œ ํŽ˜์ด์ง€๋„ค์ด์…˜ ์ ์šฉ
  2. ๋˜๋Š” ROW_NUMBER ๋Œ€์‹  offset + index + 1๋กœ ํด๋ผ์ด์–ธํŠธ์—์„œ ๋žญํ‚น ๊ณ„์‚ฐ
๐Ÿ”Ž ์ˆ˜์ • ์ œ์•ˆ: ํด๋ผ์ด์–ธํŠธ ์ธก ๋žญํ‚น ๊ณ„์‚ฐ

ROW_NUMBER ์ปฌ๋Ÿผ์„ ์ œ๊ฑฐํ•˜๊ณ , ์„œ๋น„์Šค ๋ ˆ์ด์–ด์—์„œ offset + index + 1๋กœ ๋žญํ‚น์„ ๊ณ„์‚ฐํ•˜๋Š” ๋ฐฉ์‹์„ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค:

// Service layer์—์„œ ์ฒ˜๋ฆฌ
long offset = pageable.getOffset();
for (int i = 0; i < content.size(); i++) {
    content.get(i).setRanking(offset + i + 1);
}
๐Ÿค– Prompt for AI Agents
In
core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/MonthlyProductMetricQuerydslRepositoryImpl.java
around lines 42 to 56, the current use of ROW_NUMBER() in the same query that
applies offset/limit produces incorrect rankings for paginated results; fix by
either 1) moving the ROW_NUMBER() calculation into a subquery (compute
ROW_NUMBER() OVER (ORDER BY score DESC) in an inner query and then apply
WHERE/ORDER/OFFSET/LIMIT in an outer query selecting the precomputed ranking),
or 2) remove the ROW_NUMBER projection from this repository and compute ranking
in the service layer using offset + index + 1 after fetch; choose one approach
and implement it consistently (if you pick client-side, strip ROW_NUMBER from
the select and set ranking in service using pageable.getOffset() + i + 1).

Comment on lines +11 to +17
Page<WeeklyProductRankingProjection> findWeeklyProductRanking(
LocalDate date,
Double payWeight,
Double viewWeight,
Double likeWeight,
Pageable pageable
);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

โš ๏ธ Potential issue | ๐ŸŸ  Major

๐Ÿงฉ Analysis chain

๐Ÿ Script executed:

#!/bin/bash
# Find the implementation class
find . -name "*WeeklyProductMetricQuerydslRepository*" -type f

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 353


๐Ÿ Script executed:

#!/bin/bash
# Search for the implementation class
rg "class WeeklyProductMetricQuerydslRepositoryImpl" -A 30

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 5599


๐Ÿ Script executed:

#!/bin/bash
# Also search for the interface file to confirm its location
rg "interface WeeklyProductMetricQuerydslRepository" -A 20

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 1661


๐Ÿ Script executed:

#!/bin/bash
# Get complete implementation file
wc -l ./core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/WeeklyProductMetricQuerydslRepositoryImpl.java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 225


๐Ÿ Script executed:

#!/bin/bash
# Read the complete implementation
cat -n ./core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/WeeklyProductMetricQuerydslRepositoryImpl.java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 3626


๐Ÿ Script executed:

#!/bin/bash
# Check for @NonNull or @NotNull annotations in both files
rg "(@NonNull|@NotNull|@Nullable)" ./core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/WeeklyProductMetric*

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 70


๐Ÿ Script executed:

#!/bin/bash
# Search for callers of findWeeklyProductRanking method
rg "findWeeklyProductRanking" --type java -B 2 -A 5

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 3995


๐Ÿ Script executed:

#!/bin/bash
# Check if there are any validation annotations or constraints in the imports
head -20 ./core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/WeeklyProductMetricQuerydslRepository.java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 640


๊ฐ€์ค‘์น˜ ํŒŒ๋ผ๋ฏธํ„ฐ์— null ์•ˆ์ „์„ฑ์„ ์ถ”๊ฐ€ํ•˜์„ธ์š”.

๊ตฌํ˜„์ฒด์˜ 42-44์ค„์—์„œ payWeight, viewWeight, likeWeight ํŒŒ๋ผ๋ฏธํ„ฐ๊ฐ€ null ์ฒดํฌ ์—†์ด .multiply() ์—ฐ์‚ฐ์— ์ง์ ‘ ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค. null ๊ฐ’์ด ์ „๋‹ฌ๋˜๋ฉด NullPointerException์ด ๋ฐœ์ƒํ•ฉ๋‹ˆ๋‹ค. @NonNull ์–ด๋…ธํ…Œ์ด์…˜์„ ํŒŒ๋ผ๋ฏธํ„ฐ์— ์ถ”๊ฐ€ํ•˜๊ฑฐ๋‚˜ primitive double ํƒ€์ž…์œผ๋กœ ๋ณ€๊ฒฝํ•˜์„ธ์š”.

๐Ÿค– Prompt for AI Agents
In
core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/WeeklyProductMetricQuerydslRepository.java
around lines 11 to 17 (implementation uses lines ~42-44), the weight parameters
payWeight, viewWeight, likeWeight are nullable and are used with .multiply()
without null checks which can cause NPEs; fix by making these parameters
null-safeโ€”either change their types from Double to primitive double or add
@NonNull to each parameter in the method signature, and ensure the
implementation assumes non-null (or add explicit null-to-default fallback before
multiply if you prefer to keep boxed types).

Comment on lines +46 to +61
List<WeeklyProductRankingProjection> content = queryFactory
.select(new QWeeklyProductRankingProjection(
metric.productId,
Expressions.numberTemplate(Long.class,
"ROW_NUMBER() OVER (ORDER BY {0} DESC)",
scoreCalculation),
scoreCalculation.as("score")
))
.from(metric)
.where(metric.year.eq(year)
.and(metric.month.eq(month))
.and(metric.weekOfYear.eq(weekOfYear)))
.orderBy(scoreCalculation.desc())
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

โš ๏ธ Potential issue | ๐ŸŸ  Major

ROW_NUMBER()์™€ ํŽ˜์ด์ง€๋„ค์ด์…˜ ์กฐํ•ฉ ๋ฌธ์ œ (MonthlyProductMetricQuerydslRepositoryImpl๊ณผ ๋™์ผ)

์œ„ Monthly ๊ตฌํ˜„๊ณผ ๋™์ผํ•˜๊ฒŒ, ROW_NUMBER๊ฐ€ ํŽ˜์ด์ง€๋„ค์ด์…˜ ์ ์šฉ ํ›„ ๊ฒฐ๊ณผ์— ๋Œ€ํ•ด ๊ณ„์‚ฐ๋˜์–ด ์ž˜๋ชป๋œ ๋žญํ‚น ๊ฐ’์ด ๋ฐ˜ํ™˜๋ฉ๋‹ˆ๋‹ค.

๐Ÿค– Prompt for AI Agents
In
core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/WeeklyProductMetricQuerydslRepositoryImpl.java
around lines 46-61, the ROW_NUMBER() window is being applied after offset/limit
which yields wrong ranks; instead compute ROW_NUMBER() in an inner/subquery
without pagination and then apply pagination in an outer query. Change the
Querydsl logic to build a subquery that selects productId, ROW_NUMBER() OVER
(ORDER BY score DESC) as rank and score (no offset/limit), then create an outer
query that selects from that subquery, orders by score (or rank) and applies
pageable.getOffset()/getPageSize() so pagination does not affect rank
calculation.

Comment on lines +55 to +57
.where(metric.year.eq(year)
.and(metric.month.eq(month))
.and(metric.weekOfYear.eq(weekOfYear)))
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

โš ๏ธ Potential issue | ๐Ÿ”ด Critical

๐Ÿงฉ Analysis chain

๐Ÿ Script executed:

fd "YearMonthWeek.java" --exec cat {} \;

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 1657


๐Ÿ Script executed:

fd "WeeklyProductMetricBatchWriter.java" -x head -100 {}

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 1517


๐Ÿ Script executed:

rg "class WeeklyProductMetric" --type java -A 10

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 18443


๐Ÿ Script executed:

cat > /tmp/test_week.java << 'EOF'
import java.time.LocalDate;
import java.time.temporal.WeekFields;
import java.util.Locale;

public class test_week {
    public static void main(String[] args) {
        // Test cross-month week scenario
        // Week that spans Dec 31 2024 to Jan 6 2025
        LocalDate dec31 = LocalDate.of(2024, 12, 31);
        LocalDate jan1 = LocalDate.of(2025, 1, 1);
        LocalDate jan6 = LocalDate.of(2025, 1, 6);
        
        WeekFields wf = WeekFields.of(Locale.getDefault());
        
        System.out.println("Dec 31, 2024: year=" + dec31.getYear() + ", month=" + dec31.getMonthValue() + ", weekOfYear=" + dec31.get(wf.weekOfYear()));
        System.out.println("Jan 1, 2025: year=" + jan1.getYear() + ", month=" + jan1.getMonthValue() + ", weekOfYear=" + jan1.get(wf.weekOfYear()));
        System.out.println("Jan 6, 2025: year=" + jan6.getYear() + ", month=" + jan6.getMonthValue() + ", weekOfYear=" + jan6.get(wf.weekOfYear()));
    }
}
EOF
javac /tmp/test_week.java 2>/dev/null && java -cp /tmp test_week

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 72


๐Ÿ Script executed:

# Check if there are any query methods that might use date ranges instead of the three-field filter
rg "findWeeklyProductRanking|findByYearMonthWeek" --type java -A 5

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 3129


๐Ÿ Script executed:

# Look at the complete query method to understand the context
cat core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/WeeklyProductMetricQuerydslRepositoryImpl.java | head -80

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 3122


๐Ÿ Script executed:

# Verify the exact locale used in YearMonthWeek vs the query
rg "Locale\.(KOREA|getDefault)" --type java -B 2 -A 2

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 2720


๐Ÿ Script executed:

# Check if there are any tests that cover cross-month week scenarios
fd "test" -t f --name "*Weekly*" | head -5 | xargs grep -l "Dec\|Jan\|month" 2>/dev/null || echo "No specific cross-month tests found"

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 391


๐Ÿ Script executed:

# Verify the impact by checking if Locale.KOREA behavior is different from Locale.getDefault()
# and understand what the default locale typically is
rg "server.servlet.locale|application.properties|locale" core/infra/database/mysql/src/main/resources/ 2>/dev/null || echo "No locale config found in infra module"

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 126


๐Ÿ Script executed:

# Check the batch config to see if there's a default locale set
fd "application*.properties" -type f | xargs grep -i "locale" 2>/dev/null || echo "No locale property found"

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 300


๐Ÿ Script executed:

# Look for any TODO or comments about the locale inconsistency
rg "TODO|FIXME|locale|Locale\.(KOREA|getDefault)" core/domain/src/main/java/com/loopers/core/domain/common/vo/YearMonthWeek.java core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/WeeklyProductMetricQuerydslRepositoryImpl.java

Repository: Loopers-dev-lab/loopers-spring-java-template

Length of output: 722


Locale.KOREA์™€ Locale.getDefault() ๋ถˆ์ผ์น˜๋กœ ์ธํ•œ ์ฟผ๋ฆฌ ๋ถˆ์ผ์น˜ ๋ฌธ์ œ

YearMonthWeek.java๋Š” ๋ฐ์ดํ„ฐ ์ €์žฅ ์‹œ Locale.getDefault()๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ weekOfYear๋ฅผ ๊ณ„์‚ฐํ•˜์ง€๋งŒ, WeeklyProductMetricQuerydslRepositoryImpl.java์˜ ์ฟผ๋ฆฌ๋Š” Locale.KOREA๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ weekOfYear๋ฅผ ๊ณ„์‚ฐํ•ฉ๋‹ˆ๋‹ค.

์„œ๋ฒ„์˜ ๊ธฐ๋ณธ ๋กœ์ผ€์ผ์ด ํ•œ๊ตญ์ด ์•„๋‹Œ ๊ฒฝ์šฐ, ์ €์žฅ๋œ ๋ฐ์ดํ„ฐ์™€ ์ฟผ๋ฆฌ์˜ weekOfYear ๊ฐ’์ด ์ผ์น˜ํ•˜์ง€ ์•Š์•„ ์ฟผ๋ฆฌ ๊ฒฐ๊ณผ๊ฐ€ ๋ฐ˜ํ™˜๋˜์ง€ ์•Š์„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. ๋ฐ์ดํ„ฐ ์ €์žฅ๊ณผ ์กฐํšŒ ๋กœ์ง์—์„œ ๋™์ผํ•œ ๋กœ์ผ€์ผ(Locale.KOREA)์„ ์‚ฌ์šฉํ•˜์—ฌ ์ผ๊ด€์„ฑ์„ ๋ณด์žฅํ•˜์„ธ์š”.

ํ˜„์žฌ ์ฝ”๋“œ
// ์ €์žฅ ์‹œ - YearMonthWeek.java
WeekFields weekFields = WeekFields.of(Locale.getDefault());

// ์ฟผ๋ฆฌ ์‹œ - WeeklyProductMetricQuerydslRepositoryImpl.java  
WeekFields weekFields = WeekFields.of(Locale.KOREA);
๐Ÿค– Prompt for AI Agents
In
core/infra/database/mysql/src/main/java/com/loopers/core/infra/database/mysql/product/WeeklyProductMetricQuerydslRepositoryImpl.java
around lines 55-57, the query uses WeekFields.of(Locale.KOREA) while
YearMonthWeek.java (storage) uses WeekFields.of(Locale.getDefault()), causing
weekOfYear mismatches; make the locale usage consistent by changing the storage
logic (YearMonthWeek.java) to use WeekFields.of(Locale.KOREA) instead of
Locale.getDefault() so both save and query use Locale.KOREA, and run/update
tests to verify week-based lookups still return expected results.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant