From 713f7f27aac55470a16bbaae7ed8c9d3f5fc1f8c Mon Sep 17 00:00:00 2001 From: chanwoo7 Date: Mon, 31 Aug 2026 03:28:10 +0900 Subject: [PATCH] =?UTF-8?q?feat(search):=20=EA=B2=80=EC=83=89=EC=96=B4=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D(recordSearch)=EA=B3=BC=20=EC=9D=B8=EA=B8=B0?= =?UTF-8?q?=20=EA=B2=80=EC=83=89=EC=96=B4=20=EC=8B=9C=EA=B0=84=EB=B3=84=20?= =?UTF-8?q?=EC=8A=A4=EB=83=85=EC=83=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 검색 진입 화면(figma search 05) '인기 검색어 TOP10 + 순위 변동 + HH:00 기준' 대응. spec 문서가 없어 시안 + 문답으로 정책을 확정했다. - recordSearch(keyword) mutation(OptionalJwt): 정규화(trim·공백 축약, 빈값/200자 초과 400) 후 SearchEvent 기록. 로그인 시 SearchHistory upsert(soft-delete 복원·last_used_at 갱신)와 한 트랜잭션. 검색 실행 시 1회 호출 계약(탭 전환·페이지네이션 제외). - popularSearchKeywords(limit=10, 최대 20): 최신 스냅샷 + 직전 스냅샷 비교 UP/DOWN/SAME/NEW. 스냅샷 없으면 빈 배열 + rankedAt null. - SearchKeywordRankSnapshot 모델(마이그레이션): 매시 정각 직전 24h SearchEvent 집계 TOP20 (count desc → keyword asc), uk(ranked_at, rank)로 멱등. 불변 로그라 deleted_at 없음. - @nestjs/schedule 도입(6.x — 12.x는 ESM 전용이라 jest CJS 변환 불가): 정각 크론 + 부팅 시 현재 정각 스냅샷 1회 생성. 실패는 로그만 남긴다. - common/utils/search-keyword: 정규화·단어 분리 순수 함수(후속 상품/매장 검색이 공유). - 시드: 시드 유저 소유 SearchEvent + 직전/현재 정각 스냅샷 2개(변동 4종 노출). resetSeedScope에 SearchEvent 정리 추가. 자체 판단(시안 외): 직전 스냅샷은 '가장 최근의 이전 스냅샷'(다운타임 허용), 동률은 keyword asc, 스냅샷 저장 20건/노출 10건. 테스트 21건: 정규화 유닛, recordSearch 로그인/비로그인/복원/400, 스냅샷 윈도우·TOP20· 멱등·빈 이벤트·soft-delete 제외, trend 4종·직전 스냅샷 탐색·limit, uk 충돌 흡수, 스케줄러 실패 삼킴, resolver 통합. --- package.json | 1 + .../migration.sql | 13 ++ prisma/schema.prisma | 21 ++ prisma/seed.ts | 4 + prisma/seed/idempotent.ts | 5 + prisma/seed/search-events.ts | 83 +++++++ src/app.module.ts | 5 + src/common/utils/search-keyword.spec.ts | 49 ++++ src/common/utils/search-keyword.ts | 37 +++ .../search/constants/search-error-messages.ts | 4 + .../search/constants/search.constants.ts | 16 ++ .../inputs/popular-search-keywords.input.ts | 11 + .../repositories/search.repository.spec.ts | 61 +++++ .../search/repositories/search.repository.ts | 140 ++++++++++++ .../search-entry-mutation.resolver.ts | 28 +++ .../search-entry-query.resolver.spec.ts | 85 +++++++ .../resolvers/search-entry-query.resolver.ts | 20 ++ src/features/search/search-entry.graphql | 37 +++ src/features/search/search.module.ts | 24 ++ .../services/search-entry.service.spec.ts | 123 ++++++++++ .../search/services/search-entry.service.ts | 38 ++++ .../search-keyword-rank.scheduler.spec.ts | 51 +++++ .../services/search-keyword-rank.scheduler.ts | 43 ++++ .../search-keyword-rank.service.spec.ts | 210 ++++++++++++++++++ .../services/search-keyword-rank.service.ts | 91 ++++++++ .../search/types/search-entry-output.type.ts | 18 ++ src/test/factories/index.ts | 2 + src/test/factories/search-event.factory.ts | 27 +++ .../search-keyword-rank-snapshot.factory.ts | 16 ++ yarn.lock | 37 +++ 30 files changed, 1300 insertions(+) create mode 100644 prisma/migrations/20260830181501_add_search_keyword_rank_snapshot/migration.sql create mode 100644 prisma/seed/search-events.ts create mode 100644 src/common/utils/search-keyword.spec.ts create mode 100644 src/common/utils/search-keyword.ts create mode 100644 src/features/search/constants/search-error-messages.ts create mode 100644 src/features/search/constants/search.constants.ts create mode 100644 src/features/search/dto/inputs/popular-search-keywords.input.ts create mode 100644 src/features/search/repositories/search.repository.spec.ts create mode 100644 src/features/search/repositories/search.repository.ts create mode 100644 src/features/search/resolvers/search-entry-mutation.resolver.ts create mode 100644 src/features/search/resolvers/search-entry-query.resolver.spec.ts create mode 100644 src/features/search/resolvers/search-entry-query.resolver.ts create mode 100644 src/features/search/search-entry.graphql create mode 100644 src/features/search/search.module.ts create mode 100644 src/features/search/services/search-entry.service.spec.ts create mode 100644 src/features/search/services/search-entry.service.ts create mode 100644 src/features/search/services/search-keyword-rank.scheduler.spec.ts create mode 100644 src/features/search/services/search-keyword-rank.scheduler.ts create mode 100644 src/features/search/services/search-keyword-rank.service.spec.ts create mode 100644 src/features/search/services/search-keyword-rank.service.ts create mode 100644 src/features/search/types/search-entry-output.type.ts create mode 100644 src/test/factories/search-event.factory.ts create mode 100644 src/test/factories/search-keyword-rank-snapshot.factory.ts diff --git a/package.json b/package.json index 6020387..9a07201 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@nestjs/jwt": "^11.0.2", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "11.2.1", + "@nestjs/schedule": "^6.1.3", "@nestjs/serve-static": "^5.0.5", "@nestjs/swagger": "^11.4.7", "@prisma/client": "^6.19.3", diff --git a/prisma/migrations/20260830181501_add_search_keyword_rank_snapshot/migration.sql b/prisma/migrations/20260830181501_add_search_keyword_rank_snapshot/migration.sql new file mode 100644 index 0000000..bf06aed --- /dev/null +++ b/prisma/migrations/20260830181501_add_search_keyword_rank_snapshot/migration.sql @@ -0,0 +1,13 @@ +-- CreateTable +CREATE TABLE `search_keyword_rank_snapshot` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `ranked_at` DATETIME(3) NOT NULL, + `rank` SMALLINT UNSIGNED NOT NULL, + `keyword` VARCHAR(200) NOT NULL, + `search_count` INTEGER UNSIGNED NOT NULL, + `created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + + INDEX `idx_search_keyword_rank_snapshot_ranked_at`(`ranked_at`), + UNIQUE INDEX `uk_search_keyword_rank_snapshot`(`ranked_at`, `rank`), + PRIMARY KEY (`id`) +) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 767dad0..7277f74 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1246,6 +1246,27 @@ model SearchEvent { @@map("search_event") } +/** + * 인기 검색어 시간별 스냅샷(검색 진입 화면 TOP10 + 순위 변동). + * 매시 정각 크론이 직전 24h SearchEvent를 keyword별 집계해 TOP20을 저장한다. + * 불변 로그 성격이라 soft-delete(deleted_at)를 두지 않는다(보관 무제한 — 사용자 확정). + */ +model SearchKeywordRankSnapshot { + id BigInt @id @default(autoincrement()) @db.UnsignedBigInt + + // 정각(시 단위 절삭) 기준 시각. 같은 ranked_at은 1회만 생성(멱등) + ranked_at DateTime @db.DateTime(3) + rank Int @db.UnsignedSmallInt + keyword String @db.VarChar(200) + search_count Int @db.UnsignedInt + + created_at DateTime @default(now()) @db.DateTime(3) + + @@unique([ranked_at, rank], map: "uk_search_keyword_rank_snapshot") + @@index([ranked_at], map: "idx_search_keyword_rank_snapshot_ranked_at") + @@map("search_keyword_rank_snapshot") +} + /** * ========================= * 12) Banner diff --git a/prisma/seed.ts b/prisma/seed.ts index fdc08b9..4a8b89b 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -19,6 +19,7 @@ import { seedOrders } from './seed/orders'; import { seedRecentViews } from './seed/recent-views'; import { seedRegions } from './seed/regions'; import { seedReviews } from './seed/reviews'; +import { seedSearchEvents } from './seed/search-events'; import { seedSearchHistory } from './seed/search-history'; import { seedStores } from './seed/stores'; import { seedUsers } from './seed/users'; @@ -67,6 +68,9 @@ async function main(): Promise { log('검색 히스토리 시드 중...'); await seedSearchHistory(prisma, { users }); + log('검색 이벤트 + 인기 검색어 스냅샷 시드 중...'); + await seedSearchEvents(prisma, { users }); + log('완료. 발급된 테스트 계정:'); for (const u of users) { const status = diff --git a/prisma/seed/idempotent.ts b/prisma/seed/idempotent.ts index ee052cb..167534d 100644 --- a/prisma/seed/idempotent.ts +++ b/prisma/seed/idempotent.ts @@ -116,6 +116,11 @@ export async function resetSeedScope(prisma: PrismaClient): Promise { where: { account_id: { in: userIds } }, }); + // 검색 집계 이벤트(시드 유저 소유분만) — 스냅샷은 파생 캐시라 seedSearchEvents가 전량 재생성 + await prisma.searchEvent.deleteMany({ + where: { account_id: { in: userIds } }, + }); + // 카트 const userCarts = await prisma.cart.findMany({ where: { account_id: { in: userIds } }, diff --git a/prisma/seed/search-events.ts b/prisma/seed/search-events.ts new file mode 100644 index 0000000..4f32049 --- /dev/null +++ b/prisma/seed/search-events.ts @@ -0,0 +1,83 @@ +/** + * 검색 집계 이벤트 + 인기 검색어 스냅샷 시드(검색 진입 화면 검증용). + * + * - 이벤트는 시드 유저 소유(account_id)로만 만들어 resetSeedScope가 유저 기준으로 정리한다. + * - 스냅샷은 이벤트에서 파생되는 캐시라 시드마다 전량 재생성한다(직전 정각 + 현재 정각 2개, + * 순위 변동 UP/DOWN/SAME/NEW가 모두 보이도록 구성). + */ +import type { PrismaClient } from '@prisma/client'; + +import type { SeededUser } from './users'; + +const HOUR_MS = 60 * 60 * 1000; + +const PREVIOUS_RANKING = [ + '과일 케이크', + '생일 케이크', + '크리스마스', + '3d', + '강아지 케이크', + '생화 케이크', + '떡 케이크', + '연인', + '미니 케이크', + '기념일 케이크', + '레터링 케이크', +] as const; + +/** 현재 정각 기준 순위(검색 횟수 desc). 직전 대비 UP/DOWN/SAME/NEW 혼합. */ +const CURRENT_RANKING: { keyword: string; count: number }[] = [ + { keyword: '생일 케이크', count: 12 }, // 2 → 1 UP + { keyword: '과일 케이크', count: 11 }, // 1 → 2 DOWN + { keyword: '크리스마스', count: 9 }, // SAME + { keyword: '3d', count: 8 }, // SAME + { keyword: '강아지 케이크', count: 7 }, // SAME + { keyword: '생화 케이크', count: 6 }, // SAME + { keyword: '떡 케이크', count: 5 }, // SAME + { keyword: '연인', count: 4 }, // SAME + { keyword: '미니 케이크', count: 3 }, // SAME + { keyword: '도넛', count: 2 }, // NEW + { keyword: '기념일 케이크', count: 1 }, // 10 → 11 DOWN +]; + +export async function seedSearchEvents( + prisma: PrismaClient, + ctx: { users: SeededUser[] }, +): Promise { + const user1 = ctx.users[0]; + if (!user1) throw new Error('seedUsers must run before seedSearchEvents'); + + const now = Date.now(); + const rankedAt = new Date(Math.floor(now / HOUR_MS) * HOUR_MS); + const previousAt = new Date(rankedAt.getTime() - HOUR_MS); + + // 현재 정각 스냅샷 윈도우([rankedAt-24h, rankedAt)) 안에 count만큼 이벤트를 심는다 + await prisma.searchEvent.createMany({ + data: CURRENT_RANKING.flatMap(({ keyword, count }) => + Array.from({ length: count }, (_, i) => ({ + account_id: user1.id, + keyword, + context: 'GLOBAL' as const, + created_at: new Date(rankedAt.getTime() - (i + 1) * 10 * 60 * 1000), + })), + ), + }); + + await prisma.searchKeywordRankSnapshot.deleteMany(); + await prisma.searchKeywordRankSnapshot.createMany({ + data: [ + ...PREVIOUS_RANKING.map((keyword, i) => ({ + ranked_at: previousAt, + rank: i + 1, + keyword, + search_count: PREVIOUS_RANKING.length - i, + })), + ...CURRENT_RANKING.map(({ keyword, count }, i) => ({ + ranked_at: rankedAt, + rank: i + 1, + keyword, + search_count: count, + })), + ], + }); +} diff --git a/src/app.module.ts b/src/app.module.ts index 8be329e..e800113 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -11,6 +11,7 @@ import { } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { GraphQLModule } from '@nestjs/graphql'; +import { ScheduleModule } from '@nestjs/schedule'; import { ServeStaticModule } from '@nestjs/serve-static'; import type { Request, Response } from 'express'; @@ -23,6 +24,7 @@ import s3Config from '@/config/s3.config'; import { AuthModule } from '@/features/auth/auth.module'; import { PickupModule } from '@/features/pickup'; import { RegionModule } from '@/features/region'; +import { SearchModule } from '@/features/search/search.module'; import { SellerModule } from '@/features/seller/seller.module'; import { StoreModule } from '@/features/store'; import { SystemModule } from '@/features/system/system.module'; @@ -56,6 +58,8 @@ import { PrismaModule } from '@/prisma'; AuthGlobalModule, GraphqlGlobalModule, StorageModule, + // 인기 검색어 스냅샷 크론(SearchModule) 활성화 + ScheduleModule.forRoot(), GraphQLModule.forRootAsync({ driver: ApolloDriver, inject: [ConfigService], @@ -90,6 +94,7 @@ import { PrismaModule } from '@/prisma'; AuthModule, PickupModule, RegionModule, + SearchModule, StoreModule, UserModule, SellerModule, diff --git a/src/common/utils/search-keyword.spec.ts b/src/common/utils/search-keyword.spec.ts new file mode 100644 index 0000000..67429f5 --- /dev/null +++ b/src/common/utils/search-keyword.spec.ts @@ -0,0 +1,49 @@ +import { + normalizeSearchKeyword, + SEARCH_KEYWORD_MAX_LENGTH, + splitSearchWords, +} from '@/common/utils/search-keyword'; + +describe('search-keyword utils', () => { + describe('normalizeSearchKeyword', () => { + it('앞뒤 공백을 제거하고 연속 공백을 하나로 축약한다', () => { + expect(normalizeSearchKeyword(' 딸기 케이크\t\n')).toEqual({ + ok: true, + keyword: '딸기 케이크', + }); + }); + + it('1글자 검색어를 허용한다', () => { + expect(normalizeSearchKeyword('a')).toEqual({ ok: true, keyword: 'a' }); + }); + + it('공백만 있으면 EMPTY로 거절한다', () => { + expect(normalizeSearchKeyword(' ')).toEqual({ + ok: false, + reason: 'EMPTY', + }); + }); + + it('정규화 후 200자를 넘으면 TOO_LONG으로 거절한다', () => { + const raw = 'a'.repeat(SEARCH_KEYWORD_MAX_LENGTH + 1); + expect(normalizeSearchKeyword(raw)).toEqual({ + ok: false, + reason: 'TOO_LONG', + }); + expect( + normalizeSearchKeyword(` ${'a'.repeat(SEARCH_KEYWORD_MAX_LENGTH)} `) + .ok, + ).toBe(true); + }); + }); + + describe('splitSearchWords', () => { + it('공백 기준으로 나누고 중복 단어를 제거한다', () => { + expect(splitSearchWords('딸기 케이크 딸기')).toEqual(['딸기', '케이크']); + }); + + it('단어 하나면 그대로 반환한다', () => { + expect(splitSearchWords('레터링')).toEqual(['레터링']); + }); + }); +}); diff --git a/src/common/utils/search-keyword.ts b/src/common/utils/search-keyword.ts new file mode 100644 index 0000000..c531d1a --- /dev/null +++ b/src/common/utils/search-keyword.ts @@ -0,0 +1,37 @@ +/** + * 검색어 정규화·단어 분리(DI-free 순수 함수). + * + * 검색 기록(SearchHistory/SearchEvent)·인기 검색어 집계·상품/매장 검색이 동일 규칙을 + * 공유해야 "같은 검색어"가 한 키로 모인다 — 정책 확정(검색 화면 문답): + * trim → 연속 공백 1개로 축약. 최소 길이 제한 없음(1글자 허용), 최대 200자 + * (SearchHistory/SearchEvent.keyword 컬럼 길이). 대소문자는 MySQL collation(ci)에 맡긴다. + */ + +/** 정규화된 검색어 최대 길이(keyword 컬럼 VarChar(200)). */ +export const SEARCH_KEYWORD_MAX_LENGTH = 200; + +export type SearchKeywordInvalidReason = 'EMPTY' | 'TOO_LONG'; + +export type NormalizeSearchKeywordResult = + | { ok: true; keyword: string } + | { ok: false; reason: SearchKeywordInvalidReason }; + +/** 앞뒤 공백 제거 + 연속 공백(탭·개행 포함) 1개로 축약. 빈 문자열/길이 초과는 실패로 알린다. */ +export function normalizeSearchKeyword( + raw: string, +): NormalizeSearchKeywordResult { + const keyword = raw.trim().replace(/\s+/g, ' '); + if (keyword.length === 0) return { ok: false, reason: 'EMPTY' }; + if (keyword.length > SEARCH_KEYWORD_MAX_LENGTH) { + return { ok: false, reason: 'TOO_LONG' }; + } + return { ok: true, keyword }; +} + +/** + * 정규화된 검색어를 공백 기준 단어로 나눈다(중복 단어 제거). + * 상품/매장 검색은 각 단어를 AND로 결합한다 — '딸기 케이크'가 '딸기 생크림 케이크'에 매칭. + */ +export function splitSearchWords(normalizedKeyword: string): string[] { + return [...new Set(normalizedKeyword.split(' ').filter((w) => w !== ''))]; +} diff --git a/src/features/search/constants/search-error-messages.ts b/src/features/search/constants/search-error-messages.ts new file mode 100644 index 0000000..1a3fbdd --- /dev/null +++ b/src/features/search/constants/search-error-messages.ts @@ -0,0 +1,4 @@ +export const SEARCH_ERROR_MESSAGES = { + KEYWORD_EMPTY: '검색어를 입력해 주세요.', + KEYWORD_TOO_LONG: '검색어는 200자 이하여야 합니다.', +} as const; diff --git a/src/features/search/constants/search.constants.ts b/src/features/search/constants/search.constants.ts new file mode 100644 index 0000000..8c3165e --- /dev/null +++ b/src/features/search/constants/search.constants.ts @@ -0,0 +1,16 @@ +/** 인기 검색어 기본 노출 개수(figma 검색 진입 시안 TOP10). */ +export const DEFAULT_POPULAR_KEYWORDS_LIMIT = 10; + +/** + * 스냅샷당 저장 순위 수. 노출은 10건이지만 직전 11~20위에서 올라온 키워드를 + * NEW가 아니라 UP으로 판정하기 위해 20건을 저장한다(사용자 확정). + */ +export const KEYWORD_RANK_SNAPSHOT_SIZE = 20; + +/** popularSearchKeywords limit 상한(= 스냅샷 저장 크기). */ +export const MAX_POPULAR_KEYWORDS_LIMIT = KEYWORD_RANK_SNAPSHOT_SIZE; + +/** 스냅샷 집계 윈도우(시간). 직전 24시간 SearchEvent를 keyword별로 센다. */ +export const KEYWORD_RANK_WINDOW_HOURS = 24; + +export const HOUR_MS = 60 * 60 * 1000; diff --git a/src/features/search/dto/inputs/popular-search-keywords.input.ts b/src/features/search/dto/inputs/popular-search-keywords.input.ts new file mode 100644 index 0000000..aa37e32 --- /dev/null +++ b/src/features/search/dto/inputs/popular-search-keywords.input.ts @@ -0,0 +1,11 @@ +import { IsInt, IsOptional, Max, Min } from 'class-validator'; + +import { MAX_POPULAR_KEYWORDS_LIMIT } from '@/features/search/constants/search.constants'; + +export class PopularSearchKeywordsInput { + @IsOptional() + @IsInt() + @Min(1) + @Max(MAX_POPULAR_KEYWORDS_LIMIT) + limit?: number; +} diff --git a/src/features/search/repositories/search.repository.spec.ts b/src/features/search/repositories/search.repository.spec.ts new file mode 100644 index 0000000..197d500 --- /dev/null +++ b/src/features/search/repositories/search.repository.spec.ts @@ -0,0 +1,61 @@ +import type { PrismaClient } from '@prisma/client'; + +import { SearchRepository } from '@/features/search/repositories/search.repository'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +/** repository에서만 도달 가능한 계약(uk 충돌 흡수·빈 입력) 검증. */ +describe('SearchRepository (real DB)', () => { + let repo: SearchRepository; + let prisma: PrismaClient; + const rankedAt = new Date('2026-08-31T13:00:00.000Z'); + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [SearchRepository], + }); + repo = module.get(SearchRepository); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + describe('createSnapshot', () => { + it('빈 집계는 저장하지 않고 false', async () => { + expect(await repo.createSnapshot({ rankedAt, rows: [] })).toBe(false); + expect(await prisma.searchKeywordRankSnapshot.count()).toBe(0); + }); + + it('같은 ranked_at 경합(uk 충돌)은 false로 흡수하고 기존 스냅샷을 보존한다', async () => { + const rows = [{ keyword: '생일', count: 3 }]; + expect(await repo.createSnapshot({ rankedAt, rows })).toBe(true); + + expect( + await repo.createSnapshot({ + rankedAt, + rows: [{ keyword: '덮어쓰기', count: 9 }], + }), + ).toBe(false); + + const saved = await repo.listSnapshotRows(rankedAt); + expect(saved).toEqual([{ rank: 1, keyword: '생일', search_count: 3 }]); + }); + + it('uk 충돌이 아닌 DB 오류는 그대로 던진다', async () => { + await expect( + repo.createSnapshot({ + rankedAt, + rows: [{ keyword: 'x'.repeat(201), count: 1 }], + }), + ).rejects.toThrow(); + }); + }); +}); diff --git a/src/features/search/repositories/search.repository.ts b/src/features/search/repositories/search.repository.ts new file mode 100644 index 0000000..e57e440 --- /dev/null +++ b/src/features/search/repositories/search.repository.ts @@ -0,0 +1,140 @@ +import { Injectable } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; + +import { KEYWORD_RANK_SNAPSHOT_SIZE } from '@/features/search/constants/search.constants'; +import { PrismaService } from '@/prisma/prisma.service'; + +export interface KeywordCountRow { + keyword: string; + count: number; +} + +export interface KeywordRankSnapshotRow { + rank: number; + keyword: string; + search_count: number; +} + +@Injectable() +export class SearchRepository { + constructor(private readonly prisma: PrismaService) {} + + /** + * 검색 실행 기록. 로그인 사용자는 최근 검색어(SearchHistory)도 함께 갱신한다 — + * uk(account_id, keyword) upsert라 soft-delete된 항목은 복원되고 last_used_at만 앞당겨진다. + * 두 쓰기는 한 트랜잭션으로 묶어 집계 이벤트만 남고 최근 검색어가 빠지는 상태를 막는다. + */ + async recordSearch(args: { + accountId: bigint | null; + keyword: string; + now: Date; + }): Promise { + const event = this.prisma.searchEvent.create({ + data: { + account_id: args.accountId, + keyword: args.keyword, + context: 'GLOBAL', + created_at: args.now, + }, + }); + if (args.accountId === null) { + await event; + return; + } + await this.prisma.$transaction([ + event, + this.prisma.searchHistory.upsert({ + where: { + account_id_keyword: { + account_id: args.accountId, + keyword: args.keyword, + }, + }, + create: { + account_id: args.accountId, + keyword: args.keyword, + last_used_at: args.now, + }, + update: { last_used_at: args.now, deleted_at: null }, + }), + ]); + } + + /** + * [since, until) 윈도우의 keyword별 검색 횟수 상위 N. 동률은 keyword asc로 고정해 + * 스냅샷 순위가 결정적으로 나오게 한다(직전 스냅샷 비교가 흔들리지 않도록). + */ + async countKeywordsInWindow(args: { + since: Date; + until: Date; + limit: number; + }): Promise { + const rows = await this.prisma.searchEvent.groupBy({ + by: ['keyword'], + where: { created_at: { gte: args.since, lt: args.until } }, + _count: { _all: true }, + orderBy: [{ _count: { keyword: 'desc' } }, { keyword: 'asc' }], + take: args.limit, + }); + return rows.map((r) => ({ keyword: r.keyword, count: r._count._all })); + } + + async snapshotExists(rankedAt: Date): Promise { + const count = await this.prisma.searchKeywordRankSnapshot.count({ + where: { ranked_at: rankedAt }, + }); + return count > 0; + } + + /** + * 스냅샷 저장. 같은 ranked_at이 이미 있으면(크론·부트스트랩 경합) uk 충돌을 + * "이미 생성됨"으로 흡수해 false를 반환한다. + */ + async createSnapshot(args: { + rankedAt: Date; + rows: KeywordCountRow[]; + }): Promise { + if (args.rows.length === 0) return false; + try { + await this.prisma.searchKeywordRankSnapshot.createMany({ + data: args.rows.slice(0, KEYWORD_RANK_SNAPSHOT_SIZE).map((row, i) => ({ + ranked_at: args.rankedAt, + rank: i + 1, + keyword: row.keyword, + search_count: row.count, + })), + }); + return true; + } catch (err) { + if ( + err instanceof Prisma.PrismaClientKnownRequestError && + err.code === 'P2002' + ) { + return false; + } + throw err; + } + } + + /** 가장 최근 스냅샷 시각. `before` 지정 시 그보다 이전 것 중 최근(직전 스냅샷 탐색용). */ + async findLatestSnapshotAt(before?: Date): Promise { + const row = await this.prisma.searchKeywordRankSnapshot.findFirst({ + where: before ? { ranked_at: { lt: before } } : undefined, + orderBy: { ranked_at: 'desc' }, + select: { ranked_at: true }, + }); + return row?.ranked_at ?? null; + } + + async listSnapshotRows( + rankedAt: Date, + limit?: number, + ): Promise { + return this.prisma.searchKeywordRankSnapshot.findMany({ + where: { ranked_at: rankedAt }, + orderBy: { rank: 'asc' }, + take: limit, + select: { rank: true, keyword: true, search_count: true }, + }); + } +} diff --git a/src/features/search/resolvers/search-entry-mutation.resolver.ts b/src/features/search/resolvers/search-entry-mutation.resolver.ts new file mode 100644 index 0000000..2bb076f --- /dev/null +++ b/src/features/search/resolvers/search-entry-mutation.resolver.ts @@ -0,0 +1,28 @@ +import { UseGuards } from '@nestjs/common'; +import { Args, Mutation, Resolver } from '@nestjs/graphql'; + +import { SearchEntryService } from '@/features/search/services/search-entry.service'; +import { + CurrentUser, + OptionalJwtAuthGuard, + parseAccountId, + type JwtUser, +} from '@/global/auth'; + +/** + * 검색 실행 기록 resolver. 비로그인도 호출 가능(집계 이벤트만), 로그인 시 최근 검색어까지 갱신. + */ +@Resolver('Mutation') +export class SearchEntryMutationResolver { + constructor(private readonly service: SearchEntryService) {} + + @Mutation('recordSearch') + @UseGuards(OptionalJwtAuthGuard) + recordSearch( + @Args('keyword') keyword: string, + @CurrentUser() user: JwtUser | undefined, + ): Promise { + const accountId = user ? parseAccountId(user) : undefined; + return this.service.recordSearch(keyword, accountId); + } +} diff --git a/src/features/search/resolvers/search-entry-query.resolver.spec.ts b/src/features/search/resolvers/search-entry-query.resolver.spec.ts new file mode 100644 index 0000000..f5b5076 --- /dev/null +++ b/src/features/search/resolvers/search-entry-query.resolver.spec.ts @@ -0,0 +1,85 @@ +import type { PrismaClient } from '@prisma/client'; + +import { ClockService } from '@/common/providers/clock.service'; +import { SearchRepository } from '@/features/search/repositories/search.repository'; +import { SearchEntryMutationResolver } from '@/features/search/resolvers/search-entry-mutation.resolver'; +import { SearchEntryQueryResolver } from '@/features/search/resolvers/search-entry-query.resolver'; +import { SearchEntryService } from '@/features/search/services/search-entry.service'; +import { SearchKeywordRankService } from '@/features/search/services/search-keyword-rank.service'; +import type { JwtUser } from '@/global/auth'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { createAccount, createKeywordRankSnapshot } from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +/** + * Resolver ↔ Service ↔ Repository ↔ DB 통합 경로 검증. + * 분기/집계 세부 검증은 service.spec.ts에서 담당. + */ +describe('SearchEntry Resolvers (real DB)', () => { + let queryResolver: SearchEntryQueryResolver; + let mutationResolver: SearchEntryMutationResolver; + let prisma: PrismaClient; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [ + SearchEntryQueryResolver, + SearchEntryMutationResolver, + SearchEntryService, + SearchKeywordRankService, + SearchRepository, + ClockService, + ], + }); + queryResolver = module.get(SearchEntryQueryResolver); + mutationResolver = module.get(SearchEntryMutationResolver); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + it('popularSearchKeywords: 최신 스냅샷을 반환한다', async () => { + const rankedAt = new Date('2026-08-31T13:00:00.000Z'); + await createKeywordRankSnapshot(prisma, { + ranked_at: rankedAt, + keywords: [{ keyword: '생일 케이크', count: 7 }], + }); + + const result = await queryResolver.popularSearchKeywords({ limit: 10 }); + + expect(result.rankedAt).toEqual(rankedAt); + expect(result.items).toEqual([ + { rank: 1, keyword: '생일 케이크', trend: 'NEW', searchCount: 7 }, + ]); + }); + + it('recordSearch: 로그인 사용자는 최근 검색어까지 기록한다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const user = { accountId: account.id.toString() } as JwtUser; + + const result = await mutationResolver.recordSearch(' 레터링 ', user); + + expect(result).toBe(true); + expect(await prisma.searchEvent.count()).toBe(1); + expect( + await prisma.searchHistory.count({ + where: { account_id: account.id, keyword: '레터링' }, + }), + ).toBe(1); + }); + + it('recordSearch: 비로그인은 집계 이벤트만 남긴다', async () => { + await mutationResolver.recordSearch('도넛', undefined); + + expect(await prisma.searchEvent.count()).toBe(1); + expect(await prisma.searchHistory.count()).toBe(0); + }); +}); diff --git a/src/features/search/resolvers/search-entry-query.resolver.ts b/src/features/search/resolvers/search-entry-query.resolver.ts new file mode 100644 index 0000000..5e4f1bc --- /dev/null +++ b/src/features/search/resolvers/search-entry-query.resolver.ts @@ -0,0 +1,20 @@ +import { Args, Query, Resolver } from '@nestjs/graphql'; + +import { PopularSearchKeywordsInput } from '@/features/search/dto/inputs/popular-search-keywords.input'; +import { SearchKeywordRankService } from '@/features/search/services/search-keyword-rank.service'; +import type { PopularSearchKeywordsResult } from '@/features/search/types/search-entry-output.type'; + +/** + * 검색 진입 화면 조회 resolver. 개인화 필드가 없는 public query(인증 불필요). + */ +@Resolver('Query') +export class SearchEntryQueryResolver { + constructor(private readonly rankService: SearchKeywordRankService) {} + + @Query('popularSearchKeywords') + popularSearchKeywords( + @Args('input', { nullable: true }) input?: PopularSearchKeywordsInput, + ): Promise { + return this.rankService.popularSearchKeywords(input); + } +} diff --git a/src/features/search/search-entry.graphql b/src/features/search/search-entry.graphql new file mode 100644 index 0000000..5209bd4 --- /dev/null +++ b/src/features/search/search-entry.graphql @@ -0,0 +1,37 @@ +extend type Query { + """검색 진입 화면 '인기 검색어' TOP10(매시 정각 스냅샷 기준, 순위 변동 포함). 스냅샷이 없으면 빈 목록 + rankedAt null. 비로그인 접근 가능.""" + popularSearchKeywords(input: PopularSearchKeywordsInput): PopularSearchKeywordsResult! +} + +extend type Mutation { + """검색 실행 기록. 로그인 시 최근 검색어(SearchHistory) 갱신 + 인기 검색어 집계 이벤트 기록, 비로그인 시 집계 이벤트만. 검색 실행 시 1회 호출(탭 전환·페이지네이션에는 호출하지 않는다).""" + recordSearch(keyword: String!): Boolean! +} + +input PopularSearchKeywordsInput { + """노출 개수(최대 20 — 스냅샷 저장 상한).""" + limit: Int = 10 +} + +"""직전 스냅샷 대비 순위 변동.""" +enum SearchKeywordTrend { + UP + DOWN + SAME + NEW +} + +type PopularSearchKeywordsResult { + items: [PopularSearchKeyword!]! + """스냅샷 기준 시각(정각). 화면의 'YY.MM.DD HH:mm 기준' 표기. 스냅샷이 없으면 null.""" + rankedAt: DateTime +} + +type PopularSearchKeyword { + """순위(1부터).""" + rank: Int! + keyword: String! + trend: SearchKeywordTrend! + """집계 윈도우(직전 24시간) 내 검색 횟수.""" + searchCount: Int! +} diff --git a/src/features/search/search.module.ts b/src/features/search/search.module.ts new file mode 100644 index 0000000..09d7a29 --- /dev/null +++ b/src/features/search/search.module.ts @@ -0,0 +1,24 @@ +import { Module } from '@nestjs/common'; + +import { SearchRepository } from '@/features/search/repositories/search.repository'; +import { SearchEntryMutationResolver } from '@/features/search/resolvers/search-entry-mutation.resolver'; +import { SearchEntryQueryResolver } from '@/features/search/resolvers/search-entry-query.resolver'; +import { SearchEntryService } from '@/features/search/services/search-entry.service'; +import { SearchKeywordRankScheduler } from '@/features/search/services/search-keyword-rank.scheduler'; +import { SearchKeywordRankService } from '@/features/search/services/search-keyword-rank.service'; + +/** + * 검색 도메인 모듈(검색 진입 화면·검색어 기록·인기 검색어 스냅샷). + * 크론(@nestjs/schedule)은 AppModule의 ScheduleModule.forRoot()가 활성화한다. + */ +@Module({ + providers: [ + SearchRepository, + SearchEntryService, + SearchKeywordRankService, + SearchKeywordRankScheduler, + SearchEntryQueryResolver, + SearchEntryMutationResolver, + ], +}) +export class SearchModule {} diff --git a/src/features/search/services/search-entry.service.spec.ts b/src/features/search/services/search-entry.service.spec.ts new file mode 100644 index 0000000..bf3e1f1 --- /dev/null +++ b/src/features/search/services/search-entry.service.spec.ts @@ -0,0 +1,123 @@ +import { BadRequestException } from '@nestjs/common'; +import type { PrismaClient } from '@prisma/client'; + +import { ClockService } from '@/common/providers/clock.service'; +import { SearchRepository } from '@/features/search/repositories/search.repository'; +import { SearchEntryService } from '@/features/search/services/search-entry.service'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { createAccount, createSearchHistory } from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +describe('SearchEntryService (real DB)', () => { + let service: SearchEntryService; + let prisma: PrismaClient; + let clock: ClockService; + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [SearchEntryService, SearchRepository, ClockService], + }); + service = module.get(SearchEntryService); + clock = module.get(ClockService); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + jest.restoreAllMocks(); + }); + + async function searchEvents() { + return prisma.searchEvent.findMany({ orderBy: { id: 'asc' } }); + } + + async function activeHistories(accountId: bigint) { + return prisma.searchHistory.findMany({ + where: { account_id: accountId }, + orderBy: { id: 'asc' }, + }); + } + + describe('recordSearch', () => { + it('비로그인은 정규화된 검색어로 SearchEvent만 남긴다', async () => { + const result = await service.recordSearch(' 딸기 케이크 '); + + expect(result).toBe(true); + const events = await searchEvents(); + expect(events).toHaveLength(1); + expect(events[0].keyword).toBe('딸기 케이크'); + expect(events[0].account_id).toBeNull(); + expect(events[0].context).toBe('GLOBAL'); + expect(await prisma.searchHistory.count()).toBe(0); + }); + + it('로그인은 SearchEvent + SearchHistory를 함께 기록한다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + const now = new Date('2026-08-31T03:00:00.000Z'); + jest.spyOn(clock, 'now').mockReturnValue(now); + + await service.recordSearch('레터링', account.id); + + const events = await searchEvents(); + expect(events).toHaveLength(1); + expect(events[0].account_id).toBe(account.id); + const histories = await activeHistories(account.id); + expect(histories).toHaveLength(1); + expect(histories[0].keyword).toBe('레터링'); + expect(histories[0].last_used_at).toEqual(now); + }); + + it('같은 검색어 재검색은 SearchHistory를 1건으로 유지하고 last_used_at만 갱신한다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + await createSearchHistory(prisma, { + account_id: account.id, + keyword: '레터링', + last_used_at: new Date('2026-01-01T00:00:00.000Z'), + }); + const now = new Date('2026-08-31T03:00:00.000Z'); + jest.spyOn(clock, 'now').mockReturnValue(now); + + await service.recordSearch('레터링', account.id); + + const histories = await activeHistories(account.id); + expect(histories).toHaveLength(1); + expect(histories[0].last_used_at).toEqual(now); + // 집계 이벤트는 검색 횟수만큼 쌓인다 + expect(await searchEvents()).toHaveLength(1); + }); + + it('soft-delete된 최근 검색어는 복원한다', async () => { + const account = await createAccount(prisma, { account_type: 'USER' }); + await createSearchHistory(prisma, { + account_id: account.id, + keyword: '도넛', + deleted_at: new Date('2026-01-02T00:00:00.000Z'), + }); + + await service.recordSearch('도넛', account.id); + + const histories = await activeHistories(account.id); + expect(histories).toHaveLength(1); + expect(histories[0].deleted_at).toBeNull(); + }); + + it('공백만 있는 검색어는 400', async () => { + await expect(service.recordSearch(' ')).rejects.toThrow( + BadRequestException, + ); + expect(await searchEvents()).toHaveLength(0); + }); + + it('200자를 넘는 검색어는 400', async () => { + await expect(service.recordSearch('a'.repeat(201))).rejects.toThrow( + BadRequestException, + ); + }); + }); +}); diff --git a/src/features/search/services/search-entry.service.ts b/src/features/search/services/search-entry.service.ts new file mode 100644 index 0000000..44f9c5f --- /dev/null +++ b/src/features/search/services/search-entry.service.ts @@ -0,0 +1,38 @@ +import { BadRequestException, Injectable } from '@nestjs/common'; + +import { ClockService } from '@/common/providers/clock.service'; +import { normalizeSearchKeyword } from '@/common/utils/search-keyword'; +import { SEARCH_ERROR_MESSAGES } from '@/features/search/constants/search-error-messages'; +import { SearchRepository } from '@/features/search/repositories/search.repository'; + +@Injectable() +export class SearchEntryService { + constructor( + private readonly repo: SearchRepository, + private readonly clock: ClockService, + ) {} + + /** + * 검색 실행 기록. 정규화(trim·공백 축약)된 검색어를 저장해 최근 검색어·인기 검색어가 + * 같은 키로 모이게 한다. 비로그인(accountId undefined)은 집계 이벤트만 남긴다. + */ + async recordSearch(rawKeyword: string, accountId?: bigint): Promise { + const keyword = this.requireKeyword(rawKeyword); + await this.repo.recordSearch({ + accountId: accountId ?? null, + keyword, + now: this.clock.now(), + }); + return true; + } + + private requireKeyword(raw: string): string { + const result = normalizeSearchKeyword(raw); + if (result.ok) return result.keyword; + throw new BadRequestException( + result.reason === 'EMPTY' + ? SEARCH_ERROR_MESSAGES.KEYWORD_EMPTY + : SEARCH_ERROR_MESSAGES.KEYWORD_TOO_LONG, + ); + } +} diff --git a/src/features/search/services/search-keyword-rank.scheduler.spec.ts b/src/features/search/services/search-keyword-rank.scheduler.spec.ts new file mode 100644 index 0000000..bc2098c --- /dev/null +++ b/src/features/search/services/search-keyword-rank.scheduler.spec.ts @@ -0,0 +1,51 @@ +import { Logger } from '@nestjs/common'; + +import { ClockService } from '@/common/providers/clock.service'; +import { SearchKeywordRankScheduler } from '@/features/search/services/search-keyword-rank.scheduler'; +import type { SearchKeywordRankService } from '@/features/search/services/search-keyword-rank.service'; + +describe('SearchKeywordRankScheduler', () => { + const now = new Date('2026-08-31T13:00:00.000Z'); + + function build(captureSnapshot: jest.Mock) { + const clock = new ClockService(); + jest.spyOn(clock, 'now').mockReturnValue(now); + const service = { captureSnapshot } as unknown as SearchKeywordRankService; + return new SearchKeywordRankScheduler(service, clock); + } + + beforeEach(() => { + jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined); + jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('부팅 시 현재 시각으로 스냅샷 생성을 시도한다', async () => { + const capture = jest.fn().mockResolvedValue(true); + const scheduler = build(capture); + + await scheduler.onApplicationBootstrap(); + + expect(capture).toHaveBeenCalledWith(now); + }); + + it('정각 크론도 동일하게 스냅샷 생성을 시도한다', async () => { + const capture = jest.fn().mockResolvedValue(false); + const scheduler = build(capture); + + await scheduler.handleHourly(); + + expect(capture).toHaveBeenCalledTimes(1); + }); + + it('스냅샷 생성 실패는 삼키고 로그만 남긴다', async () => { + const capture = jest.fn().mockRejectedValue(new Error('db down')); + const scheduler = build(capture); + + await expect(scheduler.handleHourly()).resolves.toBeUndefined(); + expect(Logger.prototype.error).toHaveBeenCalled(); + }); +}); diff --git a/src/features/search/services/search-keyword-rank.scheduler.ts b/src/features/search/services/search-keyword-rank.scheduler.ts new file mode 100644 index 0000000..4876dfb --- /dev/null +++ b/src/features/search/services/search-keyword-rank.scheduler.ts @@ -0,0 +1,43 @@ +import { + Injectable, + Logger, + type OnApplicationBootstrap, +} from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; + +import { ClockService } from '@/common/providers/clock.service'; +import { SearchKeywordRankService } from '@/features/search/services/search-keyword-rank.service'; + +/** + * 인기 검색어 스냅샷 크론(매시 정각, KST). 단일 서버 전제 — 다중 인스턴스가 되면 + * 분산 락이 필요하다(현재는 uk(ranked_at, rank) 충돌을 repo가 흡수해 중복 생성은 없다). + * 부팅 직후 현재 정각 스냅샷이 없으면 1회 즉시 만들어, 재시작 시 빈 화면을 줄인다. + * 스냅샷 실패는 앱 기동·다음 정각 실행을 막지 않도록 로그만 남긴다. + */ +@Injectable() +export class SearchKeywordRankScheduler implements OnApplicationBootstrap { + private readonly logger = new Logger(SearchKeywordRankScheduler.name); + + constructor( + private readonly rankService: SearchKeywordRankService, + private readonly clock: ClockService, + ) {} + + async onApplicationBootstrap(): Promise { + await this.captureSafely(); + } + + @Cron(CronExpression.EVERY_HOUR, { timeZone: 'Asia/Seoul' }) + async handleHourly(): Promise { + await this.captureSafely(); + } + + async captureSafely(): Promise { + try { + const created = await this.rankService.captureSnapshot(this.clock.now()); + if (created) this.logger.log('인기 검색어 스냅샷 생성'); + } catch (err) { + this.logger.error('인기 검색어 스냅샷 생성 실패', err); + } + } +} diff --git a/src/features/search/services/search-keyword-rank.service.spec.ts b/src/features/search/services/search-keyword-rank.service.spec.ts new file mode 100644 index 0000000..13504e2 --- /dev/null +++ b/src/features/search/services/search-keyword-rank.service.spec.ts @@ -0,0 +1,210 @@ +import type { PrismaClient } from '@prisma/client'; + +import { SearchRepository } from '@/features/search/repositories/search.repository'; +import { + SearchKeywordRankService, + truncateToHour, +} from '@/features/search/services/search-keyword-rank.service'; +import { disconnectTestPrismaClient } from '@/test/db/prisma-test-client'; +import { closeTruncateConnection, truncateAll } from '@/test/db/truncate'; +import { createKeywordRankSnapshot, createSearchEvent } from '@/test/factories'; +import { createTestingModuleWithRealDb } from '@/test/modules/testing-module.builder'; + +describe('SearchKeywordRankService (real DB)', () => { + let service: SearchKeywordRankService; + let prisma: PrismaClient; + + const NOW = new Date('2026-08-31T13:37:12.000Z'); + const RANKED_AT = new Date('2026-08-31T13:00:00.000Z'); + const PREV_AT = new Date('2026-08-31T12:00:00.000Z'); + + beforeAll(async () => { + const { module, prisma: p } = await createTestingModuleWithRealDb({ + providers: [SearchKeywordRankService, SearchRepository], + }); + service = module.get(SearchKeywordRankService); + prisma = p; + }); + + afterAll(async () => { + await closeTruncateConnection(); + await disconnectTestPrismaClient(); + }); + + beforeEach(async () => { + await truncateAll(); + }); + + async function addEvents(keyword: string, count: number, at: Date) { + for (let i = 0; i < count; i++) { + await createSearchEvent(prisma, { keyword, created_at: at }); + } + } + + async function snapshotRows(rankedAt: Date) { + return prisma.searchKeywordRankSnapshot.findMany({ + where: { ranked_at: rankedAt }, + orderBy: { rank: 'asc' }, + }); + } + + describe('truncateToHour', () => { + it('분·초·밀리초를 버려 정각으로 만든다', () => { + expect(truncateToHour(NOW)).toEqual(RANKED_AT); + }); + }); + + describe('captureSnapshot', () => { + it('직전 24시간 이벤트를 keyword별로 세어 count desc → keyword asc로 저장한다', async () => { + const inWindow = new Date('2026-08-31T00:00:00.000Z'); + await addEvents('생일 케이크', 3, inWindow); + await addEvents('과일 케이크', 2, inWindow); + await addEvents('강아지 케이크', 2, inWindow); + // 윈도우 밖(24시간 이전) — 제외 + await addEvents('오래된', 5, new Date('2026-08-30T12:59:59.000Z')); + // 정각 이후(현재 시각 사이) — 다음 스냅샷 몫 + await addEvents('미래', 5, new Date('2026-08-31T13:10:00.000Z')); + + const created = await service.captureSnapshot(NOW); + + expect(created).toBe(true); + const rows = await snapshotRows(RANKED_AT); + expect(rows.map((r) => [r.rank, r.keyword, r.search_count])).toEqual([ + [1, '생일 케이크', 3], + [2, '강아지 케이크', 2], + [3, '과일 케이크', 2], + ]); + }); + + it('상위 20건까지만 저장한다', async () => { + const at = new Date('2026-08-31T10:00:00.000Z'); + for (let i = 0; i < 25; i++) { + await addEvents(`k${i.toString().padStart(2, '0')}`, 25 - i, at); + } + + await service.captureSnapshot(NOW); + + const rows = await snapshotRows(RANKED_AT); + expect(rows).toHaveLength(20); + expect(rows[19].keyword).toBe('k19'); + }); + + it('같은 정각 스냅샷이 이미 있으면 만들지 않는다(멱등)', async () => { + await addEvents('생일', 1, new Date('2026-08-31T10:00:00.000Z')); + expect(await service.captureSnapshot(NOW)).toBe(true); + + await addEvents('추가', 9, new Date('2026-08-31T11:00:00.000Z')); + expect( + await service.captureSnapshot(new Date('2026-08-31T13:59:00.000Z')), + ).toBe(false); + + const rows = await snapshotRows(RANKED_AT); + expect(rows.map((r) => r.keyword)).toEqual(['생일']); + }); + + it('윈도우에 이벤트가 없으면 스냅샷을 남기지 않는다', async () => { + expect(await service.captureSnapshot(NOW)).toBe(false); + expect(await prisma.searchKeywordRankSnapshot.count()).toBe(0); + }); + + it('soft-delete된 이벤트는 집계에서 제외한다', async () => { + const at = new Date('2026-08-31T10:00:00.000Z'); + await createSearchEvent(prisma, { + keyword: '삭제됨', + created_at: at, + deleted_at: at, + }); + await addEvents('유효', 1, at); + + await service.captureSnapshot(NOW); + + const rows = await snapshotRows(RANKED_AT); + expect(rows.map((r) => r.keyword)).toEqual(['유효']); + }); + }); + + describe('popularSearchKeywords', () => { + it('스냅샷이 없으면 빈 목록 + rankedAt null', async () => { + const result = await service.popularSearchKeywords(); + expect(result).toEqual({ items: [], rankedAt: null }); + }); + + it('최신 스냅샷을 직전 스냅샷과 비교해 UP/DOWN/SAME/NEW를 매긴다', async () => { + await createKeywordRankSnapshot(prisma, { + ranked_at: PREV_AT, + keywords: [ + { keyword: '과일' }, // 1 → 2: DOWN + { keyword: '생일' }, // 2 → 1: UP + { keyword: '크리스마스' }, // 3 → 3: SAME + { keyword: '사라짐' }, + ], + }); + await createKeywordRankSnapshot(prisma, { + ranked_at: RANKED_AT, + keywords: [ + { keyword: '생일', count: 30 }, + { keyword: '과일', count: 20 }, + { keyword: '크리스마스', count: 10 }, + { keyword: '연인', count: 5 }, // 직전에 없음: NEW + ], + }); + + const result = await service.popularSearchKeywords(); + + expect(result.rankedAt).toEqual(RANKED_AT); + expect( + result.items.map((i) => [i.rank, i.keyword, i.trend, i.searchCount]), + ).toEqual([ + [1, '생일', 'UP', 30], + [2, '과일', 'DOWN', 20], + [3, '크리스마스', 'SAME', 10], + [4, '연인', 'NEW', 5], + ]); + }); + + it('직전 스냅샷이 없으면 전부 NEW', async () => { + await createKeywordRankSnapshot(prisma, { + ranked_at: RANKED_AT, + keywords: [{ keyword: '생일' }, { keyword: '과일' }], + }); + + const result = await service.popularSearchKeywords(); + + expect(result.items.map((i) => i.trend)).toEqual(['NEW', 'NEW']); + }); + + it('직전 스냅샷은 정확히 1시간 전이 아니어도 가장 최근 이전 것을 쓴다', async () => { + await createKeywordRankSnapshot(prisma, { + ranked_at: new Date('2026-08-30T20:00:00.000Z'), + keywords: [{ keyword: '과일' }, { keyword: '생일' }], + }); + await createKeywordRankSnapshot(prisma, { + ranked_at: RANKED_AT, + keywords: [{ keyword: '생일' }], + }); + + const result = await service.popularSearchKeywords(); + + expect(result.items[0].trend).toBe('UP'); + }); + + it('limit만큼만 노출하고 변동 비교는 직전 스냅샷 전체(20건)와 한다', async () => { + await createKeywordRankSnapshot(prisma, { + ranked_at: PREV_AT, + keywords: Array.from({ length: 15 }, (_, i) => ({ + keyword: `k${i}`, + })), + }); + // k14(직전 15위)가 1위로: NEW가 아니라 UP + await createKeywordRankSnapshot(prisma, { + ranked_at: RANKED_AT, + keywords: [{ keyword: 'k14' }, { keyword: 'k0' }, { keyword: 'k1' }], + }); + + const result = await service.popularSearchKeywords({ limit: 2 }); + + expect(result.items).toHaveLength(2); + expect(result.items[0]).toMatchObject({ keyword: 'k14', trend: 'UP' }); + }); + }); +}); diff --git a/src/features/search/services/search-keyword-rank.service.ts b/src/features/search/services/search-keyword-rank.service.ts new file mode 100644 index 0000000..6d8759c --- /dev/null +++ b/src/features/search/services/search-keyword-rank.service.ts @@ -0,0 +1,91 @@ +import { Injectable } from '@nestjs/common'; + +import { + DEFAULT_POPULAR_KEYWORDS_LIMIT, + HOUR_MS, + KEYWORD_RANK_SNAPSHOT_SIZE, + KEYWORD_RANK_WINDOW_HOURS, + MAX_POPULAR_KEYWORDS_LIMIT, +} from '@/features/search/constants/search.constants'; +import type { PopularSearchKeywordsInput } from '@/features/search/dto/inputs/popular-search-keywords.input'; +import { SearchRepository } from '@/features/search/repositories/search.repository'; +import type { + PopularSearchKeywordsResult, + SearchKeywordTrend, +} from '@/features/search/types/search-entry-output.type'; + +/** 시 단위 절삭(정각). 시간대 오프셋이 정수 시간이라 UTC/KST 어느 쪽 정각과도 일치한다. */ +export function truncateToHour(date: Date): Date { + return new Date(Math.floor(date.getTime() / HOUR_MS) * HOUR_MS); +} + +@Injectable() +export class SearchKeywordRankService { + constructor(private readonly repo: SearchRepository) {} + + /** + * 인기 검색어 스냅샷 생성. ranked_at = now의 정각, 윈도우 = [ranked_at - 24h, ranked_at). + * 같은 정각 스냅샷이 이미 있으면(크론·부트스트랩 중복 호출) 만들지 않는다(멱등). + * 이벤트가 하나도 없으면 스냅샷을 남기지 않아 직전 유효 스냅샷이 계속 노출된다. + * @returns 실제 생성 여부 + */ + async captureSnapshot(now: Date): Promise { + const rankedAt = truncateToHour(now); + if (await this.repo.snapshotExists(rankedAt)) return false; + + const rows = await this.repo.countKeywordsInWindow({ + since: new Date(rankedAt.getTime() - KEYWORD_RANK_WINDOW_HOURS * HOUR_MS), + until: rankedAt, + limit: KEYWORD_RANK_SNAPSHOT_SIZE, + }); + return this.repo.createSnapshot({ rankedAt, rows }); + } + + /** + * 최신 스냅샷 상위 limit + 직전 스냅샷 대비 변동. + * 직전 스냅샷은 "가장 최근의 이전 스냅샷"(정확히 1시간 전이 아닐 수 있음 — + * 서버 다운타임으로 빈 시간이 있어도 비교가 가능하도록. 자체 판단, 시안 외). + */ + async popularSearchKeywords( + input?: PopularSearchKeywordsInput, + ): Promise { + const limit = Math.min( + input?.limit ?? DEFAULT_POPULAR_KEYWORDS_LIMIT, + MAX_POPULAR_KEYWORDS_LIMIT, + ); + + const rankedAt = await this.repo.findLatestSnapshotAt(); + if (rankedAt === null) return { items: [], rankedAt: null }; + + const previousAt = await this.repo.findLatestSnapshotAt(rankedAt); + const [current, previous] = await Promise.all([ + this.repo.listSnapshotRows(rankedAt, limit), + previousAt === null + ? Promise.resolve([]) + : this.repo.listSnapshotRows(previousAt), + ]); + const previousRankByKeyword = new Map( + previous.map((row) => [row.keyword, row.rank]), + ); + + return { + items: current.map((row) => ({ + rank: row.rank, + keyword: row.keyword, + trend: resolveTrend(row.rank, previousRankByKeyword.get(row.keyword)), + searchCount: row.search_count, + })), + rankedAt, + }; + } +} + +function resolveTrend( + rank: number, + previousRank: number | undefined, +): SearchKeywordTrend { + if (previousRank === undefined) return 'NEW'; + if (previousRank > rank) return 'UP'; + if (previousRank < rank) return 'DOWN'; + return 'SAME'; +} diff --git a/src/features/search/types/search-entry-output.type.ts b/src/features/search/types/search-entry-output.type.ts new file mode 100644 index 0000000..760c734 --- /dev/null +++ b/src/features/search/types/search-entry-output.type.ts @@ -0,0 +1,18 @@ +/** + * search-entry resolver 반환용 도메인 출력 타입. + * SDL(search-entry.graphql)의 타입과 필드 일치. + */ + +export type SearchKeywordTrend = 'UP' | 'DOWN' | 'SAME' | 'NEW'; + +export interface PopularSearchKeyword { + rank: number; + keyword: string; + trend: SearchKeywordTrend; + searchCount: number; +} + +export interface PopularSearchKeywordsResult { + items: PopularSearchKeyword[]; + rankedAt: Date | null; +} diff --git a/src/test/factories/index.ts b/src/test/factories/index.ts index 6f0ea30..d96bd64 100644 --- a/src/test/factories/index.ts +++ b/src/test/factories/index.ts @@ -7,7 +7,9 @@ export * from './product.factory'; export * from './recent-product-view.factory'; export * from './region.factory'; export * from './review.factory'; +export * from './search-event.factory'; export * from './search-history.factory'; +export * from './search-keyword-rank-snapshot.factory'; export * from './seller.factory'; export * from './sequence'; export * from './store-wishlist.factory'; diff --git a/src/test/factories/search-event.factory.ts b/src/test/factories/search-event.factory.ts new file mode 100644 index 0000000..6ea1b35 --- /dev/null +++ b/src/test/factories/search-event.factory.ts @@ -0,0 +1,27 @@ +import type { PrismaClient, SearchEvent } from '@prisma/client'; + +import { nextSeq } from '@/test/factories/sequence'; + +export interface SearchEventOverrides { + account_id?: bigint | null; + keyword?: string; + created_at?: Date; + deleted_at?: Date | null; +} + +/** 검색 집계 이벤트. 기본은 비로그인(account_id null). */ +export async function createSearchEvent( + prisma: PrismaClient, + overrides: SearchEventOverrides = {}, +): Promise { + const seq = nextSeq(); + return prisma.searchEvent.create({ + data: { + account_id: overrides.account_id ?? null, + keyword: overrides.keyword ?? `keyword_${seq}`, + context: 'GLOBAL', + created_at: overrides.created_at ?? new Date(), + deleted_at: overrides.deleted_at ?? null, + }, + }); +} diff --git a/src/test/factories/search-keyword-rank-snapshot.factory.ts b/src/test/factories/search-keyword-rank-snapshot.factory.ts new file mode 100644 index 0000000..57e5557 --- /dev/null +++ b/src/test/factories/search-keyword-rank-snapshot.factory.ts @@ -0,0 +1,16 @@ +import type { PrismaClient } from '@prisma/client'; + +/** 한 정각(ranked_at)의 스냅샷 행들을 순서대로 rank 1..N으로 만든다. */ +export async function createKeywordRankSnapshot( + prisma: PrismaClient, + args: { ranked_at: Date; keywords: { keyword: string; count?: number }[] }, +): Promise { + await prisma.searchKeywordRankSnapshot.createMany({ + data: args.keywords.map((k, i) => ({ + ranked_at: args.ranked_at, + rank: i + 1, + keyword: k.keyword, + search_count: k.count ?? 1, + })), + }); +} diff --git a/yarn.lock b/yarn.lock index 5076ff9..c609429 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4063,6 +4063,18 @@ __metadata: languageName: node linkType: hard +"@nestjs/schedule@npm:^6.1.3": + version: 6.1.3 + resolution: "@nestjs/schedule@npm:6.1.3" + dependencies: + cron: "npm:4.4.0" + peerDependencies: + "@nestjs/common": ^10.0.0 || ^11.0.0 + "@nestjs/core": ^10.0.0 || ^11.0.0 + checksum: 10c0/de6da8d0246f486f7fdefaf6991fea18718f7a2d2eb698cf40094bfaa21e028107e37a27352af4571370c14cd757ca7402b5398af544438cc046d28575dee827 + languageName: node + linkType: hard + "@nestjs/schematics@npm:^11.0.1": version: 11.0.9 resolution: "@nestjs/schematics@npm:11.0.9" @@ -5287,6 +5299,13 @@ __metadata: languageName: node linkType: hard +"@types/luxon@npm:~3.7.0": + version: 3.7.5 + resolution: "@types/luxon@npm:3.7.5" + checksum: 10c0/7b851e94cd0d47784682dd3bc412960519d2c888bcca1408b6c8484742805637ed1a94797ce4e44fc925c8b20b52803035880d0122e3658c17f98838b387c46b + languageName: node + linkType: hard + "@types/methods@npm:^1.1.4": version: 1.1.4 resolution: "@types/methods@npm:1.1.4" @@ -7347,6 +7366,7 @@ __metadata: "@nestjs/jwt": "npm:^11.0.2" "@nestjs/passport": "npm:^11.0.5" "@nestjs/platform-express": "npm:11.2.1" + "@nestjs/schedule": "npm:^6.1.3" "@nestjs/schematics": "npm:^11.1.0" "@nestjs/serve-static": "npm:^5.0.5" "@nestjs/swagger": "npm:^11.4.7" @@ -8209,6 +8229,16 @@ __metadata: languageName: node linkType: hard +"cron@npm:4.4.0": + version: 4.4.0 + resolution: "cron@npm:4.4.0" + dependencies: + "@types/luxon": "npm:~3.7.0" + luxon: "npm:~3.7.0" + checksum: 10c0/e3762fda4a9a2404ac2e54e1104a3c6c695747e14b2321bbc5531aa1192c75a319075695fcaf68b6927e0ffd877b5454f314c680f6586db1a78b2c25b76b8a32 + languageName: node + linkType: hard + "cross-env@npm:^10.1.0": version: 10.1.0 resolution: "cross-env@npm:10.1.0" @@ -12633,6 +12663,13 @@ __metadata: languageName: node linkType: hard +"luxon@npm:~3.7.0": + version: 3.7.2 + resolution: "luxon@npm:3.7.2" + checksum: 10c0/ed8f0f637826c08c343a29dd478b00628be93bba6f068417b1d8896b61cb61c6deacbe1df1e057dbd9298334044afa150f9aaabbeb3181418ac8520acfdc2ae2 + languageName: node + linkType: hard + "magic-string@npm:0.30.17": version: 0.30.17 resolution: "magic-string@npm:0.30.17"