From 1d2e5440b299602ac74f78b2c264efe25a12adc5 Mon Sep 17 00:00:00 2001 From: ooheunda Date: Sat, 26 Jul 2025 18:47:11 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feature:=20leaderboard=20service=EC=97=90?= =?UTF-8?q?=20=EC=BA=90=EC=8B=9C=20=EB=A0=88=EC=9D=B4=EC=96=B4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__test__/leaderboard.service.test.ts | 284 +++++++++++------- src/services/leaderboard.service.ts | 27 +- 2 files changed, 205 insertions(+), 106 deletions(-) diff --git a/src/services/__test__/leaderboard.service.test.ts b/src/services/__test__/leaderboard.service.test.ts index 5428c2a..915466b 100644 --- a/src/services/__test__/leaderboard.service.test.ts +++ b/src/services/__test__/leaderboard.service.test.ts @@ -1,23 +1,34 @@ import { Pool } from 'pg'; import { DBError } from '@/exception'; import { LeaderboardRepository } from '@/repositories/leaderboard.repository'; -import { LeaderboardService } from '@/services/leaderboard.service'; +import { LEADERBOARD_CACHE_TTL, LeaderboardService } from '@/services/leaderboard.service'; +import { ICache } from '@/modules/cache/cache.type'; jest.mock('@/repositories/leaderboard.repository'); +jest.mock('@/configs/cache.config', () => ({ + cache: { + get: jest.fn(), + set: jest.fn(), + }, +})); describe('LeaderboardService', () => { let service: LeaderboardService; - let repo: jest.Mocked; + let mockRepo: jest.Mocked; let mockPool: jest.Mocked; + let mockCache: jest.Mocked; beforeEach(() => { const mockPoolObj = {}; mockPool = mockPoolObj as jest.Mocked; const repoInstance = new LeaderboardRepository(mockPool); - repo = repoInstance as jest.Mocked; + mockRepo = repoInstance as jest.Mocked; - service = new LeaderboardService(repo); + const { cache } = jest.requireMock('@/configs/cache.config'); + mockCache = cache as jest.Mocked; + + service = new LeaderboardService(mockRepo); }); afterEach(() => { @@ -25,83 +36,92 @@ describe('LeaderboardService', () => { }); describe('getUserLeaderboard', () => { - it('응답 형식에 맞게 변환된 사용자 리더보드 데이터를 반환해야 한다', async () => { - const mockRawResult = [ + const mockRawResult = [ + { + id: '1', + email: 'test@test.com', + username: 'test', + total_views: '100', + total_likes: '50', + total_posts: '1', + view_diff: '20', + like_diff: '10', + post_diff: '1', + }, + { + id: '2', + email: 'test2@test.com', + username: 'test2', + total_views: '200', + total_likes: '100', + total_posts: '2', + view_diff: '10', + like_diff: '5', + post_diff: '1', + }, + ]; + + const mockResult = { + users: [ { id: '1', email: 'test@test.com', username: 'test', - total_views: '100', - total_likes: '50', - total_posts: '1', - view_diff: '20', - like_diff: '10', - post_diff: '1', + totalViews: 100, + totalLikes: 50, + totalPosts: 1, + viewDiff: 20, + likeDiff: 10, + postDiff: 1, }, { id: '2', email: 'test2@test.com', username: 'test2', - total_views: '200', - total_likes: '100', - total_posts: '2', - view_diff: '10', - like_diff: '5', - post_diff: '1', + totalViews: 200, + totalLikes: 100, + totalPosts: 2, + viewDiff: 10, + likeDiff: 5, + postDiff: 1, }, - ]; - - const mockResult = { - users: [ - { - id: '1', - email: 'test@test.com', - username: 'test', - totalViews: 100, - totalLikes: 50, - totalPosts: 1, - viewDiff: 20, - likeDiff: 10, - postDiff: 1, - }, - { - id: '2', - email: 'test2@test.com', - username: 'test2', - totalViews: 200, - totalLikes: 100, - totalPosts: 2, - viewDiff: 10, - likeDiff: 5, - postDiff: 1, - }, - ], - }; - - repo.getUserLeaderboard.mockResolvedValue(mockRawResult); + ], + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('응답 형식에 맞게 변환된 사용자 리더보드 데이터를 반환해야 한다', async () => { + mockCache.get.mockResolvedValue(null); + mockRepo.getUserLeaderboard.mockResolvedValue(mockRawResult); + const result = await service.getUserLeaderboard('viewCount', 30, 10); expect(result.users).toEqual(mockResult.users); }); it('쿼리 파라미터가 올바르게 적용되어야 한다', async () => { - repo.getUserLeaderboard.mockResolvedValue([]); + mockCache.get.mockResolvedValue(null); + mockRepo.getUserLeaderboard.mockResolvedValue([]); await service.getUserLeaderboard('postCount', 30, 10); - expect(repo.getUserLeaderboard).toHaveBeenCalledWith('postCount', 30, 10); + expect(mockRepo.getUserLeaderboard).toHaveBeenCalledWith('postCount', 30, 10); }); it('쿼리 파라미터가 입력되지 않은 경우 기본값으로 처리되어야 한다', async () => { - repo.getUserLeaderboard.mockResolvedValue([]); + mockCache.get.mockResolvedValue(null); + mockRepo.getUserLeaderboard.mockResolvedValue([]); await service.getUserLeaderboard(); - expect(repo.getUserLeaderboard).toHaveBeenCalledWith('viewCount', 30, 10); + expect(mockRepo.getUserLeaderboard).toHaveBeenCalledWith('viewCount', 30, 10); }); it('데이터가 없는 경우 빈 배열을 반환해야 한다', async () => { - repo.getUserLeaderboard.mockResolvedValue([]); + mockCache.get.mockResolvedValue(null); + mockRepo.getUserLeaderboard.mockResolvedValue([]); const result = await service.getUserLeaderboard(); @@ -111,91 +131,124 @@ describe('LeaderboardService', () => { it('쿼리 오류 발생 시 예외를 그대로 전파한다', async () => { const errorMessage = '사용자 리더보드 조회 중 문제가 발생했습니다.'; const dbError = new DBError(errorMessage); - repo.getUserLeaderboard.mockRejectedValue(dbError); + + mockCache.get.mockResolvedValue(null); + mockRepo.getUserLeaderboard.mockRejectedValue(dbError); await expect(service.getUserLeaderboard()).rejects.toThrow(errorMessage); - expect(repo.getUserLeaderboard).toHaveBeenCalledTimes(1); + expect(mockRepo.getUserLeaderboard).toHaveBeenCalledTimes(1); + }); + + it('캐시 히트 시 Repository를 호출하지 않고 캐시된 데이터를 반환해야 한다', async () => { + mockCache.get.mockResolvedValue(mockResult); + + const result = await service.getUserLeaderboard('viewCount', 30, 10); + + expect(mockCache.get).toHaveBeenCalledWith('leaderboard:user:viewCount:30:10'); + expect(mockRepo.getUserLeaderboard).not.toHaveBeenCalled(); + expect(result).toEqual(mockResult); + }); + + it('캐시 미스 시 Repository를 호출하고 결과를 캐싱해야 한다', async () => { + mockCache.get.mockResolvedValue(null); + mockRepo.getUserLeaderboard.mockResolvedValue(mockRawResult); + + const result = await service.getUserLeaderboard('postCount', 30, 10); + + expect(mockRepo.getUserLeaderboard).toHaveBeenCalledWith('postCount', 30, 10); + expect(mockCache.set).toHaveBeenCalledWith('leaderboard:user:postCount:30:10', mockResult, LEADERBOARD_CACHE_TTL); + expect(result).toEqual(mockResult); }); }); describe('getPostLeaderboard', () => { - it('응답 형식에 맞게 변환된 게시물 리더보드 데이터를 반환해야 한다', async () => { - const mockRawResult = [ + const mockRawResult = [ + { + id: '1', + title: 'test', + slug: 'test-slug', + username: 'test', + total_views: '100', + total_likes: '50', + view_diff: '20', + like_diff: '10', + released_at: '2025-01-01', + }, + { + id: '2', + title: 'test2', + slug: 'test2-slug', + username: 'test2', + total_views: '200', + total_likes: '100', + view_diff: '10', + like_diff: '5', + released_at: '2025-01-02', + }, + ]; + + const mockResult = { + posts: [ { id: '1', title: 'test', slug: 'test-slug', username: 'test', - total_views: '100', - total_likes: '50', - view_diff: '20', - like_diff: '10', - released_at: '2025-01-01', + totalViews: 100, + totalLikes: 50, + viewDiff: 20, + likeDiff: 10, + releasedAt: '2025-01-01', }, { id: '2', title: 'test2', slug: 'test2-slug', username: 'test2', - total_views: '200', - total_likes: '100', - view_diff: '10', - like_diff: '5', - released_at: '2025-01-02', + totalViews: 200, + totalLikes: 100, + viewDiff: 10, + likeDiff: 5, + releasedAt: '2025-01-02', }, - ]; - - const mockResult = { - posts: [ - { - id: '1', - title: 'test', - slug: 'test-slug', - username: 'test', - totalViews: 100, - totalLikes: 50, - viewDiff: 20, - likeDiff: 10, - releasedAt: '2025-01-01', - }, - { - id: '2', - title: 'test2', - slug: 'test2-slug', - username: 'test2', - totalViews: 200, - totalLikes: 100, - viewDiff: 10, - likeDiff: 5, - releasedAt: '2025-01-02', - }, - ], - }; - - repo.getPostLeaderboard.mockResolvedValue(mockRawResult); + ], + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('응답 형식에 맞게 변환된 게시물 리더보드 데이터를 반환해야 한다', async () => { + mockCache.get.mockResolvedValue(null); + mockRepo.getPostLeaderboard.mockResolvedValue(mockRawResult); + const result = await service.getPostLeaderboard('viewCount', 30, 10); expect(result.posts).toEqual(mockResult.posts); }); it('쿼리 파라미터가 올바르게 적용되어야 한다', async () => { - repo.getPostLeaderboard.mockResolvedValue([]); + mockCache.get.mockResolvedValue(null); + mockRepo.getPostLeaderboard.mockResolvedValue([]); await service.getPostLeaderboard('likeCount', 30, 10); - expect(repo.getPostLeaderboard).toHaveBeenCalledWith('likeCount', 30, 10); + expect(mockRepo.getPostLeaderboard).toHaveBeenCalledWith('likeCount', 30, 10); }); it('쿼리 파라미터가 입력되지 않은 경우 기본값으로 처리되어야 한다', async () => { - repo.getPostLeaderboard.mockResolvedValue([]); + mockCache.get.mockResolvedValue(null); + mockRepo.getPostLeaderboard.mockResolvedValue([]); await service.getPostLeaderboard(); - expect(repo.getPostLeaderboard).toHaveBeenCalledWith('viewCount', 30, 10); + expect(mockRepo.getPostLeaderboard).toHaveBeenCalledWith('viewCount', 30, 10); }); it('데이터가 없는 경우 빈 배열을 반환해야 한다', async () => { - repo.getPostLeaderboard.mockResolvedValue([]); + mockCache.get.mockResolvedValue(null); + mockRepo.getPostLeaderboard.mockResolvedValue([]); + const result = await service.getPostLeaderboard(); expect(result).toEqual({ posts: [] }); @@ -204,10 +257,33 @@ describe('LeaderboardService', () => { it('쿼리 오류 발생 시 예외를 그대로 전파한다', async () => { const errorMessage = '게시물 리더보드 조회 중 문제가 발생했습니다.'; const dbError = new DBError(errorMessage); - repo.getPostLeaderboard.mockRejectedValue(dbError); + + mockCache.get.mockResolvedValue(null); + mockRepo.getPostLeaderboard.mockRejectedValue(dbError); await expect(service.getPostLeaderboard()).rejects.toThrow(errorMessage); - expect(repo.getPostLeaderboard).toHaveBeenCalledTimes(1); + expect(mockRepo.getPostLeaderboard).toHaveBeenCalledTimes(1); + }); + + it('캐시 히트 시 Repository를 호출하지 않고 캐시된 데이터를 반환해야 한다', async () => { + mockCache.get.mockResolvedValue(mockResult); + + const result = await service.getPostLeaderboard('viewCount', 30, 10); + + expect(mockCache.get).toHaveBeenCalledWith('leaderboard:post:viewCount:30:10'); + expect(mockRepo.getPostLeaderboard).not.toHaveBeenCalled(); + expect(result).toEqual(mockResult); + }); + + it('캐시 미스 시 Repository를 호출하고 결과를 캐싱해야 한다', async () => { + mockCache.get.mockResolvedValue(null); + mockRepo.getPostLeaderboard.mockResolvedValue(mockRawResult); + + const result = await service.getPostLeaderboard('likeCount', 30, 10); + + expect(mockRepo.getPostLeaderboard).toHaveBeenCalledWith('likeCount', 30, 10); + expect(mockCache.set).toHaveBeenCalledWith('leaderboard:post:likeCount:30:10', mockResult, LEADERBOARD_CACHE_TTL); + expect(result).toEqual(mockResult); }); }); }); diff --git a/src/services/leaderboard.service.ts b/src/services/leaderboard.service.ts index 475b9fc..93ab40b 100644 --- a/src/services/leaderboard.service.ts +++ b/src/services/leaderboard.service.ts @@ -1,3 +1,4 @@ +import { cache } from '@/configs/cache.config'; import logger from '@/configs/logger.config'; import { LeaderboardRepository } from '@/repositories/leaderboard.repository'; import { @@ -7,6 +8,8 @@ import { PostLeaderboardData, } from '@/types/index'; +export const LEADERBOARD_CACHE_TTL = 60 * 30; // 30분 + export class LeaderboardService { constructor(private leaderboardRepo: LeaderboardRepository) {} @@ -16,8 +19,18 @@ export class LeaderboardService { limit: number = 10, ): Promise { try { + const cacheKey = `leaderboard:user:${sort}:${dateRange}:${limit}`; + const cachedResult = await cache.get(cacheKey); + + if (cachedResult) { + return cachedResult as UserLeaderboardData; + } + const rawResult = await this.leaderboardRepo.getUserLeaderboard(sort, dateRange, limit); - return this.mapRawUserResult(rawResult); + const result = this.mapRawUserResult(rawResult); + cache.set(cacheKey, result, LEADERBOARD_CACHE_TTL); + + return result; } catch (error) { logger.error('LeaderboardService getUserLeaderboard error : ', error); throw error; @@ -30,8 +43,18 @@ export class LeaderboardService { limit: number = 10, ): Promise { try { + const cacheKey = `leaderboard:post:${sort}:${dateRange}:${limit}`; + const cachedResult = await cache.get(cacheKey); + + if (cachedResult) { + return cachedResult as PostLeaderboardData; + } + const rawResult = await this.leaderboardRepo.getPostLeaderboard(sort, dateRange, limit); - return this.mapRawPostResult(rawResult); + const result = this.mapRawPostResult(rawResult); + cache.set(cacheKey, result, LEADERBOARD_CACHE_TTL); + + return result; } catch (error) { logger.error('LeaderboardService getPostLeaderboard error : ', error); throw error; From 3a96087a7f820d5a0f009df673f6c1e25ca35ac3 Mon Sep 17 00:00:00 2001 From: ooheunda Date: Sat, 26 Jul 2025 19:06:23 +0900 Subject: [PATCH 2/2] =?UTF-8?q?modify:=20=EC=A4=91=EB=B3=B5=EB=90=9C=20moc?= =?UTF-8?q?k=20clearing=20=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/__test__/leaderboard.service.test.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/services/__test__/leaderboard.service.test.ts b/src/services/__test__/leaderboard.service.test.ts index 915466b..0a1defd 100644 --- a/src/services/__test__/leaderboard.service.test.ts +++ b/src/services/__test__/leaderboard.service.test.ts @@ -88,10 +88,6 @@ describe('LeaderboardService', () => { ], }; - beforeEach(() => { - jest.clearAllMocks(); - }); - it('응답 형식에 맞게 변환된 사용자 리더보드 데이터를 반환해야 한다', async () => { mockCache.get.mockResolvedValue(null); mockRepo.getUserLeaderboard.mockResolvedValue(mockRawResult); @@ -214,10 +210,6 @@ describe('LeaderboardService', () => { ], }; - beforeEach(() => { - jest.clearAllMocks(); - }); - it('응답 형식에 맞게 변환된 게시물 리더보드 데이터를 반환해야 한다', async () => { mockCache.get.mockResolvedValue(null); mockRepo.getPostLeaderboard.mockResolvedValue(mockRawResult);