Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,9 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'org.springframework.kafka:spring-kafka'
implementation 'com.github.ben-manes.caffeine:caffeine'
runtimeOnly 'software.amazon.msk:aws-msk-iam-auth:2.3.5'
runtimeOnly 'org.postgresql:postgresql'
compileOnly 'org.projectlombok:lombok'
Expand Down
2 changes: 2 additions & 0 deletions src/main/java/site/holliverse/HolliverseApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;

@EnableCaching
@SpringBootApplication(
scanBasePackages = "site.holliverse"
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package site.holliverse.admin.application.usecase;

import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import site.holliverse.admin.query.dao.AdminRegionalMetricDao;
import site.holliverse.admin.query.dao.RegionalMetricRawData;
import site.holliverse.admin.web.dto.analytics.AdminRegionalMetricRequestDto;
import site.holliverse.shared.config.cache.CacheConfig;
import site.holliverse.shared.alert.AlertOwner;
import site.holliverse.shared.logging.SystemLogEvent;

Expand All @@ -24,6 +26,7 @@ public class RetrieveRegionalMetricUseCase {
@Transactional(readOnly = true)
@SystemLogEvent("admin.regional.metrics")
@AlertOwner("bm")
@Cacheable(cacheNames = CacheConfig.REGIONAL_METRICS_CACHE, key = "#requestDto.yyyymm()",sync = true)
public List<RegionalMetricRawData> execute(AdminRegionalMetricRequestDto requestDto) {
return adminRegionalMetricDao.findRegionalAverages(requestDto.yyyymm());
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package site.holliverse.admin.application.usecase;

import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import site.holliverse.admin.query.dao.AdminRegionalTopPlanDao;
import site.holliverse.admin.query.dao.RegionalSubscriberCountRawData;
import site.holliverse.admin.query.dao.RegionalTopPlanRawData;
import site.holliverse.shared.config.cache.CacheConfig;
import site.holliverse.shared.alert.AlertOwner;
import site.holliverse.shared.logging.SystemLogEvent;

Expand Down Expand Up @@ -43,6 +45,7 @@ public class RetrieveRegionalTopPlanUseCase {
@Transactional(readOnly = true)
@SystemLogEvent("admin.regional.topN")
@AlertOwner("bm")
@Cacheable(cacheNames = CacheConfig.REGIONAL_TOP_PLANS_CACHE, sync = true)
public List<RegionalTopPlanSummary> execute() {

// ์ง€์—ญ๋ณ„
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
* ์ง€์—ญ๋ณ„ Top ์š”๊ธˆ์ œ/๊ฐ€์ž…์ž ์ˆ˜ ์ง‘๊ณ„๋ฅผ ๋‹ด๋‹นํ•˜๋Š” DAO.
* - ์ „์ง€์—ญ ๋‹จ์œ„๋กœ ํ•œ ๋ฒˆ์— ์ง‘๊ณ„ํ•œ๋‹ค.
* - ๊ธฐ์ค€ ๋ฐ์ดํ„ฐ๋Š” ํ™œ์„ฑ ๊ตฌ๋…๋งŒ ์‚ฌ์šฉํ•œ๋‹ค.
*
* @author nonstop
* @version 1.0.0
* @since 2026-02-23
Expand All @@ -34,60 +35,82 @@ public class AdminRegionalTopPlanDao {

/**
* ์ „์ง€์—ญ์˜ Top N ์š”๊ธˆ์ œ๋ช…์„ ์กฐํšŒํ•œ๋‹ค.
*
* ์ •๋ ฌ ๊ธฐ์ค€(์ง€์—ญ ๋‚ด๋ถ€):
* 1) ์š”๊ธˆ์ œ ๊ฐ€์ž…์ž ์ˆ˜ ๋‚ด๋ฆผ์ฐจ์ˆœ
* 2) ์š”๊ธˆ์ œ๋ช… ์˜ค๋ฆ„์ฐจ์ˆœ (์ด์œ  : ์š”๊ธˆ์ œ๋ช… ์˜ค๋ฆ„์ฐจ์ˆœ์„ ์•ˆํ•˜๋ฉด ๊ฐ™์€ ๊ตฌ๋…์ž์ผ์‹œ ๋งค๋ฒˆ ์š”๊ธˆ์ œ๋ช…์ด ๋‹ฌ๋ผ์งˆ ์ˆ˜ ์žˆ์Œ)
*
* ๋ฐ˜ํ™˜:
* ์ง€์—ญ๊ณผ top3 ์š”๊ธˆ์ œ
*/
public List<RegionalTopPlanRawData> findTopPlansByAllProvinces(int limit) {
Field<String> province = ADDRESS.PROVINCE.as("province");
Field<Long> memberId = MEMBER.MEMBER_ID.as("memberId");
Field<String> planName = PRODUCT.NAME.as("planName");
// ์ง€์—ญ๋ณ„ ์ˆœ์œ„๋ฅผ ๋งŒ๋“ค๊ธฐ ์œ„ํ•œ ์œˆ๋„์šฐ ํ•จ์ˆ˜(row_number)
Field<Integer> rankNo = DSL.rowNumber().over(
DSL.partitionBy(ADDRESS.PROVINCE)
.orderBy(DSL.countDistinct(MEMBER.MEMBER_ID).desc(), PRODUCT.NAME.asc())
).as("rankNo");

// 1๋‹จ๊ณ„: (์ง€์—ญ, ์š”๊ธˆ์ œ)๋ณ„ ๊ฐ€์ž…์ž ์ˆ˜๋ฅผ ์ง‘๊ณ„ํ•˜๊ณ  ์ง€์—ญ ๋‚ด๋ถ€ ์ˆœ์œ„๋ฅผ ๊ณ„์‚ฐ
Table<?> ranked = dsl
.select(province, planName, rankNo)
//1. ๋จผ์ € ์ง€์—ญ + ํšŒ์› + ์š”๊ธˆ์ œ ๊ธฐ์ค€์œผ๋กœ ์ค‘๋ณต์„ ์ œ๊ฑฐ
// ์›๋ž˜ countDistinct(member_id)๋กœ ํ•˜๋˜ ์ผ์„ ์•ž๋‹จ์˜ distinct๋กœ ์˜ฎ๊ธด ๋‹จ๊ณ„์˜€์Œ.
Table<?> base = dsl
.selectDistinct(province, memberId, planName)
.from(SUBSCRIPTION)
.join(MEMBER).on(MEMBER.MEMBER_ID.eq(SUBSCRIPTION.MEMBER_ID))
.join(ADDRESS).on(ADDRESS.ADDRESS_ID.eq(MEMBER.ADDRESS_ID))
.join(PRODUCT).on(PRODUCT.PRODUCT_ID.eq(SUBSCRIPTION.PRODUCT_ID))
.where(SUBSCRIPTION.STATUS.isTrue())
.and(PRODUCT.PRODUCT_TYPE.eq(ProductTypeEnum.MOBILE_PLAN))
.groupBy(ADDRESS.PROVINCE, PRODUCT.NAME)
.asTable("ranked");
.asTable("base");
Field<String> baseProvince = base.field("province", String.class);
Field<String> basePlanName = base.field("planName", String.class);

Field<String> rankedProvince = ranked.field(province);
Field<String> rankedPlanName = ranked.field(planName);
Field<Integer> rankedRankNo = ranked.field(rankNo);
//2. ์ค‘๋ณต ์ œ๊ฑฐ๋œ ๊ฒฐ๊ณผ์—์„œ ์ง€์—ญ + ์š”๊ธˆ์ œ๋ณ„ ๊ฐ€์ž…์ž ์ˆ˜๋ฅผ ์นด์šดํŠธ
// ๊ฐœ์„ ์•ˆ : ์—ฌ๊ธฐ์„œ๋Š” ์ด๋ฏธ ํšŒ์› ์ค‘๋ณต์ด ์ œ๊ฑฐ ๋์œผ๋ฏ€๋กœ countDistinct๊ฐ€ ์•„๋‹ˆ๋ผ count(*)๋ฉด ๋จ
Field<Integer> subscriberCount = DSL.count().as("subscriberCount");
Comment thread
bon0512 marked this conversation as resolved.
Table<?> planCounts = dsl
.select(
baseProvince,
basePlanName,
subscriberCount
)
.from(base)
.groupBy(baseProvince, basePlanName)
.asTable("planCounts");

// 2๋‹จ๊ณ„: ์ง€์—ญ๋ณ„ ์ˆœ์œ„๊ฐ€ limit ์ดํ•˜์ธ ๋ฐ์ดํ„ฐ๋งŒ ์ถ”์ถœ
return dsl
Field<String> countedProvince = planCounts.field("province", String.class);
Field<String> countedPlanName = planCounts.field("planName", String.class);
Field<Integer> countedSubscriberCount = planCounts.field("subscriberCount", Integer.class);

// 3. ์ง€์—ญ๋ณ„๋กœ ๊ฐ€์ž…์ž ์ˆ˜ ๋‚ด๋ฆผ์ฐจ์ˆœ, ์š”๊ธˆ์ œ๋ช… ์˜ค๋ฆ„์ฐจ์ˆœ ๊ธฐ์ค€ rank๋ฅผ ๋งŒ๋“ฌ
Field<Integer> rankNo = DSL.rowNumber().over(
DSL.partitionBy(countedProvince)
.orderBy(countedSubscriberCount.desc(), countedPlanName.asc())
).as("rankNo");

Table<?> ranked = dsl
.select(
rankedProvince,
rankedPlanName
countedProvince,
countedPlanName,
rankNo
)
.from(planCounts)
.asTable("ranked");

Field<String> rankedProvince = ranked.field("province", String.class);
Field<String> rankedPlanName = ranked.field("planName", String.class);
Field<Integer> rankedRankNo = ranked.field("rankNo", Integer.class);

// 4. ์ง€์—ญ๋ณ„ ์ƒ์œ„ N๊ฐœ๋งŒ ์ถ”์ถœํ•˜๊ณ  ๊ธฐ์กด DTO ํ˜•ํƒœ๋กœ ๋ฐ˜ํ™˜
return dsl
.select(rankedProvince, rankedPlanName)
.from(ranked)
.where(rankedRankNo.le(limit))
.orderBy(
rankedProvince.asc(),
rankedRankNo.asc()
)
.orderBy(rankedProvince.asc(), rankedRankNo.asc())
.fetch(r -> new RegionalTopPlanRawData(
r.get(rankedProvince),
r.get(rankedPlanName)
));

}

/**
* ์ „์ง€์—ญ ์ด ๊ฐ€์ž…์ž ์ˆ˜๋ฅผ ์กฐํšŒํ•œ๋‹ค.
*
* ๊ทœ์น™:
* - distinct(member_id) ๊ธฐ์ค€
* - ํ™œ์„ฑ ๊ตฌ๋…(status=true)๋งŒ ํฌํ•จ
Expand Down
65 changes: 65 additions & 0 deletions src/main/java/site/holliverse/shared/config/cache/CacheConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package site.holliverse.shared.config.cache;

import com.github.benmanes.caffeine.cache.Caffeine;
import org.springframework.beans.factory.annotation.Value;

import org.springframework.cache.CacheManager;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.cache.support.CompositeCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;



/**
* ==========================
* ์บ์‰ฌ ์ปจํ”ผ๊ทธ ํŒŒ์ผ
*
* @author nonstop
* @version 1.0.0
* @since 2026-04-04
* ==========================
*/

@Configuration
public class CacheConfig {
Comment thread
bon0512 marked this conversation as resolved.

public static final String REGIONAL_METRICS_CACHE = "regionalMetrics";
public static final String REGIONAL_TOP_PLANS_CACHE = "regionalTopPlans";

/**
* ์Šคํ”„๋ง์ด ์‚ฌ์šฉํ•  ์บ์‹œ๋งค๋‹ˆ์ € ๋นˆ ๋“ฑ๋ก
* ๊ฐ ์บ์‹œ์˜ ๋งŒ๋ฃŒ์‹œ๊ฐ„, ์ตœ๋Œ€ ํฌ๊ธฐ๋ฅผ application.yaml์—์„œ ์ฃผ์ž…๋ฐ›๋Š”๋‹ค.
* ๊ฐ’์ด ์—†์œผ๋ฉด ๊ธฐ๋ณธ ๊ฐ’์œผ๋กœ ์„ค์ •
*/
@Bean
public CacheManager cacheManager(
@Value("${app.cache.regional-metrics.spec:maximumSize=50,expireAfterWrite=5m}") String regionalMetricsSpec,
@Value("${app.cache.regional-top-plans.spec:maximumSize=10,expireAfterWrite=5m}") String regionalTopPlansSpec
) {

/**
* ๊ด€๋ฆฌ์ž ํ†ต๊ณ„ ์ชฝ ์ „์šฉ ์บ์‹œ ๋งค๋‹ˆ์ € ์ƒ์„ฑ
* caffeine ์ •์ฑ…์œผ๋กœ ์ ์šฉ
* ๋ณ„๋„ TTL/์‚ฌ์ด์ฆˆ ์ •์ฑ…์œผ๋กœ ๊ฐ€์งˆ ์ˆ˜ ์ž‡์Œ.
*/

var regionalMetricsCache = new CaffeineCacheManager(REGIONAL_METRICS_CACHE);
regionalMetricsCache.setCaffeine(Caffeine.from(regionalMetricsSpec));

var regionalTopPlansCache = new CaffeineCacheManager(REGIONAL_TOP_PLANS_CACHE);
regionalTopPlansCache.setCaffeine(Caffeine.from(regionalTopPlansSpec));

/**
* ์Šคํ”„๋ง์—์„œ๋Š” ํ•˜๋‚˜์˜ ์บ์‹œ๋งค๋‹ˆ์ €๋งŒ ๋“ฑ๋กํ•ด์•ผ๋˜๊ธฐ๋•Œ๋ฌธ์— ์—ฌ๋Ÿฌ ์บ์‹œ๋งค๋‹ˆ์ €๋ฅผ ๋ฌถ์–ด์„œ ๋ฐ˜ํ™˜.
* ์บ์‹œ ์ด๋ฆ„์— ๋”ฐ๋ผ ์ ์ ˆํ•œ ๋งค๋‹ˆ์ €๋ฅผ ์ฐพ์•„์ฃผ๋Š” ์—ญํ• 
*/
return new CompositeCacheManager(
regionalMetricsCache,
regionalTopPlansCache
);
}



}
5 changes: 5 additions & 0 deletions src/main/resources/application.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,11 @@ app:
version: ${APP_VERSION:latest} #ํ˜„์žฌ release version ๋ช…์‹œ
logging:
member-hash-salt: ${APP_LOGGING_MEMBER_HASH_SALT:change-me-local}
cache:
regional-metrics:
spec: ${APP_CACHE_REGIONAL_METRICS_SPEC:maximumSize=50,expireAfterWrite=5m}
regional-top-plans:
spec: ${APP_CACHE_REGIONAL_TOP_PLANS_SPEC:maximumSize=10,expireAfterWrite=5m}

#Discord
alerts:
Expand Down
Loading