Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
21 changes: 21 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions prisma/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -67,6 +68,9 @@ async function main(): Promise<void> {
log('검색 히스토리 시드 중...');
await seedSearchHistory(prisma, { users });

log('검색 이벤트 + 인기 검색어 스냅샷 시드 중...');
await seedSearchEvents(prisma, { users });

log('완료. 발급된 테스트 계정:');
for (const u of users) {
const status =
Expand Down
5 changes: 5 additions & 0 deletions prisma/seed/idempotent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,11 @@ export async function resetSeedScope(prisma: PrismaClient): Promise<void> {
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 } },
Expand Down
83 changes: 83 additions & 0 deletions prisma/seed/search-events.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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,
})),
],
});
}
5 changes: 5 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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';
Expand Down Expand Up @@ -56,6 +58,8 @@ import { PrismaModule } from '@/prisma';
AuthGlobalModule,
GraphqlGlobalModule,
StorageModule,
// 인기 검색어 스냅샷 크론(SearchModule) 활성화
ScheduleModule.forRoot(),
GraphQLModule.forRootAsync<ApolloDriverConfig>({
driver: ApolloDriver,
inject: [ConfigService],
Expand Down Expand Up @@ -90,6 +94,7 @@ import { PrismaModule } from '@/prisma';
AuthModule,
PickupModule,
RegionModule,
SearchModule,
StoreModule,
UserModule,
SellerModule,
Expand Down
49 changes: 49 additions & 0 deletions src/common/utils/search-keyword.spec.ts
Original file line number Diff line number Diff line change
@@ -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(['레터링']);
});
});
});
37 changes: 37 additions & 0 deletions src/common/utils/search-keyword.ts
Original file line number Diff line number Diff line change
@@ -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 !== ''))];
}
4 changes: 4 additions & 0 deletions src/features/search/constants/search-error-messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export const SEARCH_ERROR_MESSAGES = {
KEYWORD_EMPTY: '검색어를 입력해 주세요.',
KEYWORD_TOO_LONG: '검색어는 200자 이하여야 합니다.',
} as const;
16 changes: 16 additions & 0 deletions src/features/search/constants/search.constants.ts
Original file line number Diff line number Diff line change
@@ -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;
11 changes: 11 additions & 0 deletions src/features/search/dto/inputs/popular-search-keywords.input.ts
Original file line number Diff line number Diff line change
@@ -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;
}
61 changes: 61 additions & 0 deletions src/features/search/repositories/search.repository.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
});
Loading
Loading