Skip to content

Commit e29d2ba

Browse files
authored
[25.08.25 / TASK-247] Fix - Leaderboard 쿼리 결과값 버그 수정 (#44)
* fix: 리더보드 비정상적인 집계값 수정 * refactor: 중복된 날짜 계산 개선 * test: 통합 테스트에 버그 픽스한 부분 추가 및 skip 삭제 * fix: 전체 데이터 수집 전(00시~01시)의 요청에 대한 방어로직 추가 * fix: linting * refactor: 비정상 데이터 필터링 구문 개선
1 parent 90432cc commit e29d2ba

File tree

5 files changed

+84
-28
lines changed

5 files changed

+84
-28
lines changed

src/controllers/user.controller.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import { fetchVelogApi } from '@/modules/velog/velog.api';
88

99
type Token10 = string & { __lengthBrand: 10 };
1010

11-
// eslint-disable-next-line @typescript-eslint/naming-convention
1211
const THREE_WEEKS_IN_MS = 21 * 24 * 60 * 60 * 1000;
1312

1413
export class UserController {

src/repositories/__test__/integration/leaderboard.repo.integration.test.ts

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
/* eslint-disable jest/no-disabled-tests */
21
/**
32
* 주의: 이 통합 테스트는 현재 시간에 의존적입니다.
43
* getCurrentKSTDateString과 getKSTDateStringWithOffset 함수는 실제 시간을 기준으로
@@ -10,22 +9,21 @@ import dotenv from 'dotenv';
109
import pg, { Pool } from 'pg';
1110
import { LeaderboardRepository } from '@/repositories/leaderboard.repository';
1211
import { PostLeaderboardSortType, UserLeaderboardSortType } from '@/types';
12+
import { getKSTDateStringWithOffset } from '@/utils/date.util';
1313

1414
dotenv.config();
15-
16-
jest.setTimeout(60000); // 각 케이스당 60초 타임아웃 설정
15+
jest.setTimeout(30000); // 각 케이스당 30초 타임아웃 설정
1716

1817
/**
1918
* LeaderboardRepository 통합 테스트
2019
*
2120
* 이 테스트 파일은 실제 데이터베이스와 연결하여 LeaderboardRepository의 모든 메서드를
2221
* 실제 환경과 동일한 조건에서 테스트합니다.
2322
*/
24-
describe.skip('LeaderboardRepository 통합 테스트', () => {
23+
describe('LeaderboardRepository 통합 테스트', () => {
2524
let testPool: Pool;
2625
let repo: LeaderboardRepository;
2726

28-
// eslint-disable-next-line @typescript-eslint/naming-convention
2927
const DEFAULT_PARAMS = {
3028
USER_SORT: 'viewCount' as UserLeaderboardSortType,
3129
POST_SORT: 'viewCount' as PostLeaderboardSortType,
@@ -45,7 +43,7 @@ describe.skip('LeaderboardRepository 통합 테스트', () => {
4543
idleTimeoutMillis: 30000, // 연결 유휴 시간 (30초)
4644
connectionTimeoutMillis: 5000, // 연결 시간 초과 (5초)
4745
allowExitOnIdle: false, // 유휴 상태에서 종료 허용
48-
statement_timeout: 60000, // 쿼리 타임아웃 증가 (60초)
46+
statement_timeout: 30000, // 쿼리 타임아웃 증가 (30초)
4947
};
5048

5149
// localhost 가 아니면 ssl 필수
@@ -80,10 +78,17 @@ describe.skip('LeaderboardRepository 통합 테스트', () => {
8078

8179
afterAll(async () => {
8280
try {
83-
jest.clearAllMocks();
84-
85-
// 풀 완전 종료
86-
await testPool.end();
81+
// 모든 쿼리 완료 대기
82+
await new Promise(resolve => setTimeout(resolve, 1000));
83+
84+
// 풀 완전 종료
85+
if (testPool) {
86+
// 강제 종료: 모든 활성 쿼리와 연결 중지
87+
await testPool.end();
88+
}
89+
90+
// 추가 정리 시간
91+
await new Promise(resolve => setTimeout(resolve, 1000));
8792

8893
logger.info('LeaderboardRepository 통합 테스트 DB 연결 종료');
8994
} catch (error) {
@@ -225,6 +230,16 @@ describe.skip('LeaderboardRepository 통합 테스트', () => {
225230
expect(user.username).not.toBeNull();
226231
});
227232
});
233+
234+
it('데이터 수집이 비정상적인 유저는 리더보드에 포함되지 않아야 한다', async () => {
235+
const result = await repo.getUserLeaderboard(DEFAULT_PARAMS.USER_SORT, DEFAULT_PARAMS.DATE_RANGE, 30);
236+
237+
if (!isEnoughData(result, 1, '사용자 리더보드 비정상 유저 필터링')) return;
238+
239+
result.forEach((user) => {
240+
expect(Number(user.total_views)).not.toBe(Number(user.view_diff));
241+
});
242+
});
228243
});
229244

230245
describe('getPostLeaderboard', () => {
@@ -342,6 +357,20 @@ describe.skip('LeaderboardRepository 통합 테스트', () => {
342357
expect(areDifferent).toBe(true);
343358
}
344359
});
360+
361+
it('데이터 수집이 비정상적인 게시물은 리더보드에 포함되지 않아야 한다', async () => {
362+
const result = await repo.getPostLeaderboard(DEFAULT_PARAMS.POST_SORT, DEFAULT_PARAMS.DATE_RANGE, 30);
363+
const pastDateKST = getKSTDateStringWithOffset(-DEFAULT_PARAMS.DATE_RANGE * 24 * 60);
364+
365+
if (!isEnoughData(result, 1, '게시물 리더보드 비정상 게시물 필터링')) return;
366+
367+
result.forEach((post) => {
368+
if (post.released_at < pastDateKST) {
369+
// eslint-disable-next-line jest/no-conditional-expect
370+
expect(Number(post.total_views)).not.toBe(Number(post.view_diff));
371+
}
372+
});
373+
});
345374
});
346375
});
347376

src/repositories/__test__/integration/post.repo.integration.test.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ describe('PostRepository 통합 테스트', () => {
1919
let repo: PostRepository;
2020

2121
// 테스트에 사용할 기본 데이터 ID
22-
// eslint-disable-next-line @typescript-eslint/naming-convention
2322
const TEST_DATA = {
2423
USER_ID: 1,
2524
POST_ID: 2445,

src/repositories/__test__/leaderboard.repo.test.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -79,11 +79,20 @@ describe('LeaderboardRepository', () => {
7979
await repo.getUserLeaderboard('viewCount', mockDateRange, 10);
8080

8181
expect(mockPool.query).toHaveBeenCalledWith(
82-
expect.stringContaining('WHERE date >='), // pastDateKST를 사용하는 부분 확인
82+
expect.stringContaining('WHERE date ='), // pastDateKST를 사용하는 부분 확인
8383
[expect.any(Number)], // limit
8484
);
8585
});
8686

87+
it('데이터 수집이 비정상적인 유저는 리더보드에 포함되지 않아야 한다', async () => {
88+
await repo.getUserLeaderboard('viewCount', 30, 10);
89+
90+
expect(mockPool.query).toHaveBeenCalledWith(
91+
expect.stringContaining('HAVING SUM(COALESCE(ss.start_view, 0)) != 0'),
92+
expect.anything(),
93+
);
94+
});
95+
8796
it('에러 발생 시 DBError를 던져야 한다', async () => {
8897
mockPool.query.mockRejectedValue(new Error('DB connection failed'));
8998
await expect(repo.getUserLeaderboard('viewCount', 30, 10)).rejects.toThrow(DBError);
@@ -156,11 +165,20 @@ describe('LeaderboardRepository', () => {
156165
await repo.getPostLeaderboard('viewCount', mockDateRange, 10);
157166

158167
expect(mockPool.query).toHaveBeenCalledWith(
159-
expect.stringContaining('WHERE date >='), // pastDateKST를 사용하는 부분 확인
168+
expect.stringContaining('WHERE date ='), // pastDateKST를 사용하는 부분 확인
160169
[expect.any(Number)], // limit
161170
);
162171
});
163172

173+
it('데이터 수집이 비정상적인 게시물은 리더보드에 포함되지 않아야 한다', async () => {
174+
await repo.getPostLeaderboard('viewCount', 30, 10);
175+
176+
expect(mockPool.query).toHaveBeenCalledWith(
177+
expect.stringContaining('ss.post_id IS NOT NULL'),
178+
expect.anything()
179+
);
180+
});
181+
164182
it('에러 발생 시 DBError를 던져야 한다', async () => {
165183
mockPool.query.mockRejectedValue(new Error('DB connection failed'));
166184
await expect(repo.getPostLeaderboard('viewCount', 30, 10)).rejects.toThrow(DBError);

src/repositories/leaderboard.repository.ts

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export class LeaderboardRepository {
1010
async getUserLeaderboard(sort: UserLeaderboardSortType, dateRange: number, limit: number) {
1111
try {
1212
const pastDateKST = getKSTDateStringWithOffset(-dateRange * 24 * 60);
13-
const cteQuery = this.buildLeaderboardCteQuery(dateRange);
13+
const cteQuery = this.buildLeaderboardCteQuery(dateRange, pastDateKST);
1414

1515
const query = `
1616
${cteQuery}
@@ -21,15 +21,16 @@ export class LeaderboardRepository {
2121
COALESCE(SUM(ts.today_view), 0) AS total_views,
2222
COALESCE(SUM(ts.today_like), 0) AS total_likes,
2323
COUNT(DISTINCT CASE WHEN p.is_active = true THEN p.id END) AS total_posts,
24-
SUM(COALESCE(ts.today_view, 0) - COALESCE(ss.start_view, COALESCE(ts.today_view, 0))) AS view_diff,
25-
SUM(COALESCE(ts.today_like, 0) - COALESCE(ss.start_like, COALESCE(ts.today_like, 0))) AS like_diff,
24+
SUM(COALESCE(ts.today_view, 0) - COALESCE(ss.start_view, 0)) AS view_diff,
25+
SUM(COALESCE(ts.today_like, 0) - COALESCE(ss.start_like, 0)) AS like_diff,
2626
COUNT(DISTINCT CASE WHEN p.released_at >= '${pastDateKST}' AND p.is_active = true THEN p.id END) AS post_diff
2727
FROM users_user u
2828
LEFT JOIN posts_post p ON p.user_id = u.id
2929
LEFT JOIN today_stats ts ON ts.post_id = p.id
3030
LEFT JOIN start_stats ss ON ss.post_id = p.id
3131
WHERE u.username IS NOT NULL
3232
GROUP BY u.id, u.email, u.username
33+
HAVING SUM(COALESCE(ss.start_view, 0)) != 0
3334
ORDER BY ${this.SORT_COL_MAPPING[sort]} DESC, u.id
3435
LIMIT $1;
3536
`;
@@ -44,7 +45,8 @@ export class LeaderboardRepository {
4445

4546
async getPostLeaderboard(sort: PostLeaderboardSortType, dateRange: number, limit: number) {
4647
try {
47-
const cteQuery = this.buildLeaderboardCteQuery(dateRange);
48+
const pastDateKST = getKSTDateStringWithOffset(-dateRange * 24 * 60);
49+
const cteQuery = this.buildLeaderboardCteQuery(dateRange, pastDateKST);
4850

4951
const query = `
5052
${cteQuery}
@@ -56,13 +58,18 @@ export class LeaderboardRepository {
5658
u.username AS username,
5759
COALESCE(ts.today_view, 0) AS total_views,
5860
COALESCE(ts.today_like, 0) AS total_likes,
59-
COALESCE(ts.today_view, 0) - COALESCE(ss.start_view, COALESCE(ts.today_view, 0)) AS view_diff,
60-
COALESCE(ts.today_like, 0) - COALESCE(ss.start_like, COALESCE(ts.today_like, 0)) AS like_diff
61+
COALESCE(ts.today_view, 0) - COALESCE(ss.start_view, 0) AS view_diff,
62+
COALESCE(ts.today_like, 0) - COALESCE(ss.start_like, 0) AS like_diff
6163
FROM posts_post p
6264
LEFT JOIN users_user u ON u.id = p.user_id
6365
LEFT JOIN today_stats ts ON ts.post_id = p.id
6466
LEFT JOIN start_stats ss ON ss.post_id = p.id
6567
WHERE p.is_active = true
68+
AND (
69+
p.released_at >= '${pastDateKST}'
70+
OR
71+
ss.post_id IS NOT NULL
72+
)
6673
ORDER BY ${this.SORT_COL_MAPPING[sort]} DESC, p.id
6774
LIMIT $1;
6875
`;
@@ -76,10 +83,16 @@ export class LeaderboardRepository {
7683
}
7784

7885
// 오늘 날짜와 기준 날짜의 통계를 가져오는 CTE(임시 결과 집합) 쿼리 빌드
79-
private buildLeaderboardCteQuery(dateRange: number) {
80-
const nowDateKST = getCurrentKSTDateString();
81-
// 과거 날짜 계산 (dateRange일 전)
82-
const pastDateKST = getKSTDateStringWithOffset(-dateRange * 24 * 60);
86+
private buildLeaderboardCteQuery(dateRange: number, pastDateKST?: string) {
87+
// KST 기준 00시~01시 (UTC 15:00~16:00) 사이라면 전날 데이터를 사용
88+
const nowDateKST =
89+
new Date().getUTCHours() === 15
90+
? getKSTDateStringWithOffset(-24 * 60) // 전날 데이터
91+
: getCurrentKSTDateString();
92+
93+
if (!pastDateKST) {
94+
pastDateKST = getKSTDateStringWithOffset(-dateRange * 24 * 60);
95+
}
8396

8497
return `
8598
WITH
@@ -89,17 +102,15 @@ export class LeaderboardRepository {
89102
daily_view_count AS today_view,
90103
daily_like_count AS today_like
91104
FROM posts_postdailystatistics
92-
WHERE date <= '${nowDateKST}'
93-
ORDER BY post_id, date DESC
105+
WHERE date = '${nowDateKST}'
94106
),
95107
start_stats AS (
96108
SELECT DISTINCT ON (post_id)
97109
post_id,
98110
daily_view_count AS start_view,
99111
daily_like_count AS start_like
100112
FROM posts_postdailystatistics
101-
WHERE date >= '${pastDateKST}'
102-
ORDER BY post_id, date ASC
113+
WHERE date = '${pastDateKST}'
103114
)
104115
`;
105116
}

0 commit comments

Comments
 (0)