Skip to content

Commit d11bd42

Browse files
authored
[25.01.16 / TASK-271] Hotfix - SVG Badge API 통계값 오류 해결 및 캐싱 적용 (#50)
* fix: 배지 API 데이터 정합성 오류 수정 배지 API 데이터 정합성 오류 수정 - 다른 사용자 게시글이 섞여 조회되는 문제 해결 - 누적 조회수 중복 계산 오류 수정 * feat: 배지 API 캐싱 및 쿼리 최적화 배지 API 캐싱 및 쿼리 최적화 - Cache-Aside 패턴 적용 (TTL 10분) - IN 서브쿼리 → INNER JOIN 최적화 * refactor: 사용하지 않는 import 제거 * style: 프레티어 적용 * refactor: TTL 관련 주석 추가 * refactor: badge 쿼리 빌더 함수 분리 및 COALESCE 표현 통일 * fix: 배지 API 레디스 풀백 패턴 적용
1 parent 288e304 commit d11bd42

2 files changed

Lines changed: 110 additions & 80 deletions

File tree

src/repositories/totalStats.repository.ts

Lines changed: 86 additions & 79 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Pool } from 'pg';
22
import logger from '@/configs/logger.config';
33
import { DBError } from '@/exception';
44
import { TotalStatsType } from '@/types';
5-
import { getCurrentKSTDateString, getKSTDateStringWithOffset } from '@/utils/date.util';
5+
import { getKSTDateStringWithOffset } from '@/utils/date.util';
66

77
interface RawStatsResult {
88
date: string;
@@ -110,46 +110,9 @@ export class TotalStatsRepository {
110110
async getUserBadgeStats(username: string, dateRange: number = 30) {
111111
try {
112112
const pastDateKST = getKSTDateStringWithOffset(-dateRange * 24 * 60);
113-
const nowDateKST =
114-
new Date().getUTCHours() === 15 ? getKSTDateStringWithOffset(-24 * 60) : getCurrentKSTDateString();
113+
const query = buildBadgeStatsQuery();
115114

116-
const query = `
117-
WITH
118-
today_stats AS (
119-
SELECT DISTINCT ON (post_id)
120-
post_id,
121-
daily_view_count AS today_view,
122-
daily_like_count AS today_like
123-
FROM posts_postdailystatistics
124-
WHERE date = $2
125-
ORDER BY post_id, date DESC
126-
),
127-
start_stats AS (
128-
SELECT DISTINCT ON (post_id)
129-
post_id,
130-
daily_view_count AS start_view,
131-
daily_like_count AS start_like
132-
FROM posts_postdailystatistics
133-
WHERE date = $3
134-
ORDER BY post_id, date DESC
135-
)
136-
SELECT
137-
u.username,
138-
COALESCE(SUM(ts.today_view), 0) AS total_views,
139-
COALESCE(SUM(ts.today_like), 0) AS total_likes,
140-
COUNT(DISTINCT CASE WHEN p.is_active = true THEN p.id END) AS total_posts,
141-
SUM(COALESCE(ts.today_view, 0) - COALESCE(ss.start_view, 0)) AS view_diff,
142-
SUM(COALESCE(ts.today_like, 0) - COALESCE(ss.start_like, 0)) AS like_diff,
143-
COUNT(DISTINCT CASE WHEN p.released_at >= $3 AND p.is_active = true THEN p.id END) AS post_diff
144-
FROM users_user u
145-
LEFT JOIN posts_post p ON p.user_id = u.id
146-
LEFT JOIN today_stats ts ON ts.post_id = p.id
147-
LEFT JOIN start_stats ss ON ss.post_id = p.id
148-
WHERE u.username = $1
149-
GROUP BY u.username
150-
`;
151-
152-
const result = await this.pool.query(query, [username, nowDateKST, pastDateKST]);
115+
const result = await this.pool.query(query, [username, pastDateKST]);
153116
return result.rows[0] || null;
154117
} catch (error) {
155118
logger.error('TotalStatsRepository getUserBadgeStats error:', error);
@@ -160,46 +123,9 @@ export class TotalStatsRepository {
160123
async getUserRecentPosts(username: string, dateRange: number = 30, limit: number = 4) {
161124
try {
162125
const pastDateKST = getKSTDateStringWithOffset(-dateRange * 24 * 60);
163-
const nowDateKST =
164-
new Date().getUTCHours() === 15 ? getKSTDateStringWithOffset(-24 * 60) : getCurrentKSTDateString();
126+
const query = buildRecentPostsQuery();
165127

166-
const query = `
167-
WITH
168-
today_stats AS (
169-
SELECT DISTINCT ON (post_id)
170-
post_id,
171-
daily_view_count AS today_view,
172-
daily_like_count AS today_like
173-
FROM posts_postdailystatistics
174-
WHERE date = $3
175-
ORDER BY post_id, date DESC
176-
),
177-
start_stats AS (
178-
SELECT DISTINCT ON (post_id)
179-
post_id,
180-
daily_view_count AS start_view,
181-
daily_like_count AS start_like
182-
FROM posts_postdailystatistics
183-
WHERE date = $4
184-
ORDER BY post_id, date DESC
185-
)
186-
SELECT
187-
p.title,
188-
p.released_at,
189-
COALESCE(ts.today_view, 0) AS today_view,
190-
COALESCE(ts.today_like, 0) AS today_like,
191-
(COALESCE(ts.today_view, 0) - COALESCE(ss.start_view, 0)) AS view_diff
192-
FROM posts_post p
193-
JOIN users_user u ON u.id = p.user_id
194-
LEFT JOIN today_stats ts ON ts.post_id = p.id
195-
LEFT JOIN start_stats ss ON ss.post_id = p.id
196-
WHERE u.username = $1
197-
AND p.is_active = true
198-
ORDER BY p.released_at DESC
199-
LIMIT $2
200-
`;
201-
202-
const result = await this.pool.query(query, [username, limit, nowDateKST, pastDateKST]);
128+
const result = await this.pool.query(query, [username, limit, pastDateKST]);
203129
return result.rows;
204130
} catch (error) {
205131
logger.error('TotalStatsRepository getUserRecentPosts error:', error);
@@ -231,3 +157,84 @@ export class TotalStatsRepository {
231157
}
232158
}
233159
}
160+
161+
function buildUserPostsCTE(includeTitle: boolean = false, includeLimit: boolean = false): string {
162+
const titleColumn = includeTitle ? ', p.title' : '';
163+
const limitClause = includeLimit ? 'ORDER BY p.released_at DESC\n LIMIT $2' : '';
164+
165+
return `
166+
user_posts AS (
167+
SELECT p.id${titleColumn}, p.released_at
168+
FROM posts_post p
169+
INNER JOIN users_user u ON u.id = p.user_id
170+
WHERE u.username = $1 AND p.is_active = true
171+
${limitClause}
172+
)`;
173+
}
174+
175+
function buildLatestStatsCTE(): string {
176+
return `
177+
latest_stats AS (
178+
SELECT DISTINCT ON (pds.post_id)
179+
pds.post_id,
180+
pds.daily_view_count AS total_view,
181+
pds.daily_like_count AS total_like
182+
FROM posts_postdailystatistics pds
183+
INNER JOIN user_posts up ON up.id = pds.post_id
184+
ORDER BY pds.post_id, pds.date DESC
185+
)`;
186+
}
187+
188+
function buildStartStatsCTE(includeLike: boolean = true, paramIndex: number = 2): string {
189+
const likeColumn = includeLike ? ',\n pds.daily_like_count AS start_like' : '';
190+
191+
return `
192+
start_stats AS (
193+
SELECT DISTINCT ON (pds.post_id)
194+
pds.post_id,
195+
pds.daily_view_count AS start_view${likeColumn}
196+
FROM posts_postdailystatistics pds
197+
INNER JOIN user_posts up ON up.id = pds.post_id
198+
WHERE pds.date <= $${paramIndex}
199+
ORDER BY pds.post_id, pds.date DESC
200+
)`;
201+
}
202+
203+
function buildBadgeStatsQuery(): string {
204+
return `
205+
WITH
206+
${buildUserPostsCTE(false, false)},
207+
${buildLatestStatsCTE()},
208+
${buildStartStatsCTE(true, 2)}
209+
SELECT
210+
$1 AS username,
211+
COALESCE(SUM(ls.total_view), 0) AS total_views,
212+
COALESCE(SUM(ls.total_like), 0) AS total_likes,
213+
COUNT(up.id) AS total_posts,
214+
COALESCE(SUM(ls.total_view - COALESCE(ss.start_view, 0)), 0) AS view_diff,
215+
COALESCE(SUM(ls.total_like - COALESCE(ss.start_like, 0)), 0) AS like_diff,
216+
COUNT(CASE WHEN up.released_at >= $2 THEN 1 END) AS post_diff
217+
FROM user_posts up
218+
LEFT JOIN latest_stats ls ON ls.post_id = up.id
219+
LEFT JOIN start_stats ss ON ss.post_id = up.id
220+
`;
221+
}
222+
223+
function buildRecentPostsQuery(): string {
224+
return `
225+
WITH
226+
${buildUserPostsCTE(true, true)},
227+
${buildLatestStatsCTE()},
228+
${buildStartStatsCTE(false, 3)}
229+
SELECT
230+
up.title,
231+
up.released_at,
232+
COALESCE(ls.total_view, 0) AS today_view,
233+
COALESCE(ls.total_like, 0) AS today_like,
234+
ls.total_view - COALESCE(ss.start_view, 0) AS view_diff
235+
FROM user_posts up
236+
LEFT JOIN latest_stats ls ON ls.post_id = up.id
237+
LEFT JOIN start_stats ss ON ss.post_id = up.id
238+
ORDER BY up.released_at DESC
239+
`;
240+
}

src/services/totalStats.service.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ const safeNumber = (value: string | number | null | undefined, defaultValue: num
1212

1313
const BADGE_DATE_RANGE = 30;
1414

15+
export const BADGE_CACHE_TTL = 60 * 10; // 뱃지 캐시 TTL 10분
16+
1517
export class TotalStatsService {
1618
private readonly STATS_REFRESH_INTERVAL = 15 * 60 * 1000; // 15분 (밀리초)
1719
private readonly MAIN_QUEUE_KEY = 'stats-refresh';
@@ -47,6 +49,19 @@ export class TotalStatsService {
4749
}
4850

4951
async getBadgeData(username: string, type: 'default' | 'simple' = 'default'): Promise<BadgeData> {
52+
const cacheKey = `badge:${username}:${type}`;
53+
54+
try {
55+
const cached = await cache.get<BadgeData>(cacheKey);
56+
if (cached) {
57+
logger.info(`[Cache HIT] ${cacheKey}`);
58+
return cached;
59+
}
60+
logger.info(`[Cache MISS] ${cacheKey}`);
61+
} catch (cacheError) {
62+
logger.warn(`[Cache Error] Failed to get cache for ${cacheKey}:`, cacheError);
63+
}
64+
5065
try {
5166
const userStats = await this.totalStatsRepo.getUserBadgeStats(username, BADGE_DATE_RANGE);
5267

@@ -57,7 +72,7 @@ export class TotalStatsService {
5772
const recentPosts =
5873
type === 'default' ? await this.totalStatsRepo.getUserRecentPosts(username, BADGE_DATE_RANGE, 4) : [];
5974

60-
return {
75+
const result: BadgeData = {
6176
user: {
6277
username: userStats.username,
6378
totalViews: safeNumber(userStats.total_views),
@@ -75,6 +90,14 @@ export class TotalStatsService {
7590
viewDiff: safeNumber(post.view_diff),
7691
})),
7792
};
93+
94+
try {
95+
await cache.set(cacheKey, result, BADGE_CACHE_TTL);
96+
} catch (cacheSetError) {
97+
logger.warn(`[Cache Error] Failed to set cache for ${cacheKey}:`, cacheSetError);
98+
}
99+
100+
return result;
78101
} catch (error) {
79102
logger.error('TotalStatsService getBadgeData error: ', error);
80103
throw error;

0 commit comments

Comments
 (0)