Skip to content

Commit 20bcd49

Browse files
authored
[25.04.22 / TASK-100] Feature - Leaderboard 도메인 추가 및 조회 API 구현 (#27)
* feature: Leaderboard 도메인 파일 세팅 및 타입 추가 * feature: Leaderboard 조회 API 구현 * modify: Leaderboard 조회시 type 분기는 post를 먼저 하도록 통일 * test: Leaderboard 조회 API 서비스, 리포지토리 계층 단위 테스트 추가 * modify: Leaderboard Repo 쿼리시 오는 RawResult 타입 정의 any로 두었을 때 lint 검증 및 실행은 잘 되었으나, 테스트 코드 작성시 에러 나서 내부에 정의 * test: Leaderboard 조회 API 서비스 계층 단위 테스트 케이스 추가 * modify: 코드래빗 리뷰 반영 유저 리더보드 조회시 GROUP BY절에 user.email 추가 타입 안정성 강화: DTO에 유효성 검증 추가 및 관련 코드 수정에 따른 테스트 케이스 삭제 * modify: 관련 타입 index.ts에 넣고 import 부분 수정 * modify: 사용자, 게시물 별로 Leaderboard 조회 API 분리 라우터, 타입, 함수 모두 분리 repo 쿼리 주석 삭제 및 파라미터에 따른 날짜 변환을 make_interval로 통일 불필요해진 테스트 코드 삭제 * modify: 분리중 자동완성에서 생긴 불필요한 쿼리 삭제 * modify: 리뷰 기반 테스트 코드 수정 * modify: API 분리하며 불필요해진 swagger 주석 삭제 * modify: bigint PK를 string으로 받도록 타입 수정 * modify: sortCol 변환을 삼항연산자에서 객체 형식으로 수정
1 parent 62a2b6b commit 20bcd49

File tree

10 files changed

+1032
-0
lines changed

10 files changed

+1032
-0
lines changed
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import logger from '@/configs/logger.config';
2+
import { NextFunction, RequestHandler, Request, Response } from 'express';
3+
import { LeaderboardService } from '@/services/leaderboard.service';
4+
import {
5+
GetUserLeaderboardQuery,
6+
GetPostLeaderboardQuery,
7+
UserLeaderboardResponseDto,
8+
PostLeaderboardResponseDto,
9+
} from '@/types/index';
10+
11+
export class LeaderboardController {
12+
constructor(private leaderboardService: LeaderboardService) {}
13+
14+
getUserLeaderboard: RequestHandler = async (
15+
req: Request<object, object, object, GetUserLeaderboardQuery>,
16+
res: Response<UserLeaderboardResponseDto>,
17+
next: NextFunction,
18+
) => {
19+
try {
20+
const { sort, dateRange, limit } = req.query;
21+
22+
const users = await this.leaderboardService.getUserLeaderboard(sort, dateRange, limit);
23+
const response = new UserLeaderboardResponseDto(true, '사용자 리더보드 조회에 성공하였습니다.', users, null);
24+
25+
res.status(200).json(response);
26+
} catch (error) {
27+
logger.error('사용자 리더보드 조회 실패:', error);
28+
next(error);
29+
}
30+
};
31+
32+
getPostLeaderboard: RequestHandler = async (
33+
req: Request<object, object, object, GetPostLeaderboardQuery>,
34+
res: Response<PostLeaderboardResponseDto>,
35+
next: NextFunction,
36+
) => {
37+
try {
38+
const { sort, dateRange, limit } = req.query;
39+
40+
const posts = await this.leaderboardService.getPostLeaderboard(sort, dateRange, limit);
41+
const response = new PostLeaderboardResponseDto(true, '게시물 리더보드 조회에 성공하였습니다.', posts, null);
42+
43+
res.status(200).json(response);
44+
} catch (error) {
45+
logger.error('게시물 리더보드 조회 실패:', error);
46+
next(error);
47+
}
48+
};
49+
}
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
import { Pool, QueryResult } from 'pg';
2+
import { DBError } from '@/exception';
3+
import { LeaderboardRepository } from '@/repositories/leaderboard.repository';
4+
5+
jest.mock('pg');
6+
7+
// pg의 QueryResult 타입을 만족하는 mock 객체를 생성하기 위한 헬퍼 함수 생성
8+
function createMockQueryResult<T extends Record<string, unknown>>(rows: T[]): QueryResult<T> {
9+
return {
10+
rows,
11+
rowCount: rows.length,
12+
command: '',
13+
oid: 0,
14+
fields: [],
15+
} satisfies QueryResult<T>;
16+
}
17+
18+
const mockPool: {
19+
query: jest.Mock<Promise<QueryResult<Record<string, unknown>>>, unknown[]>;
20+
} = {
21+
query: jest.fn(),
22+
};
23+
24+
describe('LeaderboardRepository', () => {
25+
let repo: LeaderboardRepository;
26+
27+
beforeEach(() => {
28+
repo = new LeaderboardRepository(mockPool as unknown as Pool);
29+
});
30+
31+
describe('getUserLeaderboard', () => {
32+
it('사용자 통계 배열로 이루어진 리더보드를 반환해야 한다', async () => {
33+
const mockResult = [
34+
{
35+
id: '1',
36+
37+
total_views: 100,
38+
total_likes: 50,
39+
total_posts: 1,
40+
view_diff: 20,
41+
like_diff: 10,
42+
post_diff: 1,
43+
},
44+
{
45+
id: '2',
46+
47+
total_views: 200,
48+
total_likes: 100,
49+
total_posts: 2,
50+
view_diff: 10,
51+
like_diff: 5,
52+
post_diff: 1,
53+
},
54+
];
55+
mockPool.query.mockResolvedValue(createMockQueryResult(mockResult));
56+
57+
const result = await repo.getUserLeaderboard('viewCount', 30, 10);
58+
59+
expect(mockPool.query).toHaveBeenCalledWith(expect.stringContaining('FROM users_user u'), expect.anything());
60+
expect(result).toEqual(mockResult);
61+
});
62+
63+
it('sort가 viewCount인 경우 view_diff 필드를 기준으로 내림차순 정렬해야 한다', async () => {
64+
await repo.getUserLeaderboard('viewCount', 30, 10);
65+
66+
expect(mockPool.query).toHaveBeenCalledWith(
67+
expect.stringContaining('ORDER BY view_diff DESC'),
68+
expect.anything(),
69+
);
70+
});
71+
72+
it('sort가 likeCount인 경우 like_diff 필드를 기준으로 내림차순 정렬해야 한다', async () => {
73+
await repo.getUserLeaderboard('likeCount', 30, 10);
74+
75+
expect(mockPool.query).toHaveBeenCalledWith(
76+
expect.stringContaining('ORDER BY like_diff DESC'),
77+
expect.anything(),
78+
);
79+
});
80+
81+
it('sort가 postCount인 경우 post_diff 필드를 기준으로 내림차순 정렬해야 한다', async () => {
82+
await repo.getUserLeaderboard('postCount', 30, 10);
83+
84+
expect(mockPool.query).toHaveBeenCalledWith(
85+
expect.stringContaining('ORDER BY post_diff DESC'),
86+
expect.anything(),
87+
);
88+
});
89+
90+
it('limit 파라미터가 쿼리에 올바르게 적용되어야 한다', async () => {
91+
const mockLimit = 5;
92+
93+
await repo.getUserLeaderboard('viewCount', 30, mockLimit);
94+
95+
expect(mockPool.query).toHaveBeenCalledWith(
96+
expect.stringContaining('LIMIT $2'),
97+
expect.arrayContaining([30, mockLimit]),
98+
);
99+
});
100+
101+
it('dateRange 파라미터가 쿼리에 올바르게 적용되어야 한다', async () => {
102+
const mockDateRange = 30;
103+
104+
await repo.getUserLeaderboard('viewCount', mockDateRange, 10);
105+
106+
expect(mockPool.query).toHaveBeenCalledWith(
107+
expect.stringContaining('make_interval(days := $1::int)'),
108+
expect.arrayContaining([mockDateRange, expect.anything()]),
109+
);
110+
});
111+
112+
it('에러 발생 시 DBError를 던져야 한다', async () => {
113+
mockPool.query.mockRejectedValue(new Error('DB connection failed'));
114+
await expect(repo.getUserLeaderboard('viewCount', 30, 10)).rejects.toThrow(DBError);
115+
});
116+
});
117+
118+
describe('getPostLeaderboard', () => {
119+
it('게시물 통계 배열로 이루어진 리더보드를 반환해야 한다', async () => {
120+
const mockResult = [
121+
{
122+
id: '2',
123+
title: 'test2',
124+
slug: 'test2',
125+
total_views: 200,
126+
total_likes: 100,
127+
view_diff: 20,
128+
like_diff: 10,
129+
released_at: '2025-01-02',
130+
},
131+
{
132+
id: '1',
133+
title: 'test',
134+
slug: 'test',
135+
total_views: 100,
136+
total_likes: 50,
137+
view_diff: 10,
138+
like_diff: 5,
139+
released_at: '2025-01-01',
140+
},
141+
];
142+
143+
mockPool.query.mockResolvedValue(createMockQueryResult(mockResult));
144+
145+
const result = await repo.getPostLeaderboard('viewCount', 30, 10);
146+
147+
expect(result).toEqual(mockResult);
148+
expect(mockPool.query).toHaveBeenCalledWith(expect.stringContaining('FROM posts_post p'), expect.anything());
149+
});
150+
151+
it('sort가 viewCount인 경우 view_diff 필드를 기준으로 내림차순 정렬해야 한다', async () => {
152+
await repo.getPostLeaderboard('viewCount', 30, 10);
153+
154+
expect(mockPool.query).toHaveBeenCalledWith(
155+
expect.stringContaining('ORDER BY view_diff DESC'),
156+
expect.anything(),
157+
);
158+
});
159+
160+
it('sort가 likeCount인 경우 like_diff 필드를 기준으로 내림차순 정렬해야 한다', async () => {
161+
await repo.getPostLeaderboard('likeCount', 30, 10);
162+
163+
expect(mockPool.query).toHaveBeenCalledWith(
164+
expect.stringContaining('ORDER BY like_diff DESC'),
165+
expect.anything(),
166+
);
167+
});
168+
169+
it('limit 파라미터가 쿼리에 올바르게 적용되어야 한다', async () => {
170+
const mockLimit = 5;
171+
172+
await repo.getPostLeaderboard('viewCount', 30, mockLimit);
173+
174+
expect(mockPool.query).toHaveBeenCalledWith(
175+
expect.stringContaining('LIMIT $2'),
176+
expect.arrayContaining([30, mockLimit]),
177+
);
178+
});
179+
180+
it('dateRange 파라미터가 쿼리에 올바르게 적용되어야 한다', async () => {
181+
const mockDateRange = 30;
182+
183+
await repo.getPostLeaderboard('viewCount', mockDateRange, 10);
184+
185+
expect(mockPool.query).toHaveBeenCalledWith(
186+
expect.stringContaining('make_interval(days := $1::int)'),
187+
expect.arrayContaining([mockDateRange, expect.anything()]),
188+
);
189+
});
190+
191+
it('에러 발생 시 DBError를 던져야 한다', async () => {
192+
mockPool.query.mockRejectedValue(new Error('DB connection failed'));
193+
await expect(repo.getPostLeaderboard('viewCount', 30, 10)).rejects.toThrow(DBError);
194+
});
195+
});
196+
});
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import logger from '@/configs/logger.config';
2+
import { Pool } from 'pg';
3+
import { DBError } from '@/exception';
4+
import { UserLeaderboardSortType, PostLeaderboardSortType } from '@/types/index';
5+
6+
export class LeaderboardRepository {
7+
constructor(private pool: Pool) {}
8+
9+
async getUserLeaderboard(sort: UserLeaderboardSortType, dateRange: number, limit: number) {
10+
try {
11+
const cteQuery = this.buildLeaderboardCteQuery();
12+
13+
const query = `
14+
${cteQuery}
15+
SELECT
16+
u.id AS id,
17+
u.email AS email,
18+
COALESCE(SUM(ts.today_view), 0) AS total_views,
19+
COALESCE(SUM(ts.today_like), 0) AS total_likes,
20+
COUNT(DISTINCT CASE WHEN p.is_active = true THEN p.id END) AS total_posts,
21+
SUM(COALESCE(ts.today_view, 0) - COALESCE(ss.start_view, COALESCE(ts.today_view, 0))) AS view_diff,
22+
SUM(COALESCE(ts.today_like, 0) - COALESCE(ss.start_like, COALESCE(ts.today_like, 0))) AS like_diff,
23+
COUNT(DISTINCT CASE WHEN p.released_at >= CURRENT_DATE - make_interval(days := $1::int) AND p.is_active = true THEN p.id END) AS post_diff
24+
FROM users_user u
25+
LEFT JOIN posts_post p ON p.user_id = u.id
26+
LEFT JOIN today_stats ts ON ts.post_id = p.id
27+
LEFT JOIN start_stats ss ON ss.post_id = p.id
28+
WHERE u.email IS NOT NULL
29+
GROUP BY u.id, u.email
30+
ORDER BY ${this.SORT_COL_MAPPING[sort]} DESC
31+
LIMIT $2;
32+
`;
33+
const result = await this.pool.query(query, [dateRange, limit]);
34+
35+
return result.rows;
36+
} catch (error) {
37+
logger.error(`Leaderboard Repo getUserLeaderboard error:`, error);
38+
throw new DBError(`사용자 리더보드 조회 중 문제가 발생했습니다.`);
39+
}
40+
}
41+
42+
async getPostLeaderboard(sort: PostLeaderboardSortType, dateRange: number, limit: number) {
43+
try {
44+
const cteQuery = this.buildLeaderboardCteQuery();
45+
46+
const query = `
47+
${cteQuery}
48+
SELECT
49+
p.id AS id,
50+
p.title,
51+
p.slug,
52+
p.released_at,
53+
COALESCE(ts.today_view, 0) AS total_views,
54+
COALESCE(ts.today_like, 0) AS total_likes,
55+
COALESCE(ts.today_view, 0) - COALESCE(ss.start_view, COALESCE(ts.today_view, 0)) AS view_diff,
56+
COALESCE(ts.today_like, 0) - COALESCE(ss.start_like, COALESCE(ts.today_like, 0)) AS like_diff
57+
FROM posts_post p
58+
LEFT JOIN today_stats ts ON ts.post_id = p.id
59+
LEFT JOIN start_stats ss ON ss.post_id = p.id
60+
WHERE p.is_active = true
61+
ORDER BY ${this.SORT_COL_MAPPING[sort]} DESC
62+
LIMIT $2;
63+
`;
64+
const result = await this.pool.query(query, [dateRange, limit]);
65+
66+
return result.rows;
67+
} catch (error) {
68+
logger.error(`Leaderboard Repo getPostLeaderboard error:`, error);
69+
throw new DBError(`게시물 리더보드 조회 중 문제가 발생했습니다.`);
70+
}
71+
}
72+
73+
// 오늘 날짜와 기준 날짜의 통계를 가져오는 CTE(임시 결과 집합) 쿼리 빌드
74+
private buildLeaderboardCteQuery() {
75+
return `
76+
WITH
77+
today_stats AS (
78+
SELECT DISTINCT ON (post_id)
79+
post_id,
80+
daily_view_count AS today_view,
81+
daily_like_count AS today_like
82+
FROM posts_postdailystatistics
83+
WHERE (date AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC')::date <= (NOW() AT TIME ZONE 'UTC')::date
84+
ORDER BY post_id, date DESC
85+
),
86+
start_stats AS (
87+
SELECT DISTINCT ON (post_id)
88+
post_id,
89+
daily_view_count AS start_view,
90+
daily_like_count AS start_like
91+
FROM posts_postdailystatistics
92+
WHERE (date AT TIME ZONE 'Asia/Seoul' AT TIME ZONE 'UTC')::date >= ((NOW() AT TIME ZONE 'UTC')::date - make_interval(days := $1::int))
93+
ORDER BY post_id, date ASC
94+
)
95+
`;
96+
}
97+
98+
private readonly SORT_COL_MAPPING = {
99+
viewCount: 'view_diff',
100+
likeCount: 'like_diff',
101+
postCount: 'post_diff',
102+
} as const;
103+
}

src/routes/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import express, { Router } from 'express';
22
import UserRouter from './user.router';
33
import PostRouter from './post.router';
44
import NotiRouter from './noti.router';
5+
import LeaderboardRouter from './leaderboard.router';
56

67
const router: Router = express.Router();
78

@@ -12,4 +13,5 @@ router.use('/ping', (req, res) => {
1213
router.use('/', UserRouter);
1314
router.use('/', PostRouter);
1415
router.use('/', NotiRouter);
16+
router.use('/', LeaderboardRouter);
1517
export default router;

0 commit comments

Comments
 (0)