Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[BE] feat#329 퀴즈검색 성능개선 #330

Merged
merged 5 commits into from
Dec 23, 2024
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
2 changes: 1 addition & 1 deletion BE/src/InitDB/QUIZ_SET_TEST_DATA.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ export const QUIZ_SET_TEST_DATA = [
},
{
title: '영어 문법 테스트',
category: 'ENGLISH',
category: 'LANGUAGE',
quizList: [
{
quiz: '다음 중 현재완료 시제는?',
Expand Down
4 changes: 1 addition & 3 deletions BE/src/main.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import { config } from 'dotenv';
import { join } from 'path';
import 'pinpoint-node-agent';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { Logger } from '@nestjs/common';
import { GameActivityInterceptor } from './game/interceptor/gameActivity.interceptor';

// env 불러오기
config({ path: join(__dirname, '..', '.env') }); // ../ 경로의 .env 로드

async function bootstrap() {
const app = await NestFactory.create(AppModule);
Expand Down
6 changes: 5 additions & 1 deletion BE/src/quiz-set/entities/quiz-choice.entity.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Column, Entity, JoinColumn, ManyToOne } from 'typeorm';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { QuizModel } from './quiz.entity';
import { BaseModel } from '../../common/entity/base.entity';

Expand All @@ -11,6 +11,10 @@ export class QuizChoiceModel extends BaseModel {
isAnswer: boolean;

@Column({ name: 'choice_content', type: 'text' })
@Index({
fulltext: true,
parser: 'ngram'
})
choiceContent: string;

@Column({ name: 'choice_order', type: 'integer' })
Expand Down
4 changes: 4 additions & 0 deletions BE/src/quiz-set/entities/quiz-set.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ const CategoriesEnum = Object.freeze({
@Entity('quiz_set')
export class QuizSetModel extends BaseModel {
@Column()
@Index({
fulltext: true,
parser: 'ngram' // ngram 파서 사용
}) // Full Text Search 인덱스 추가
title: string;

@Column({ name: 'user_id' })
Expand Down
5 changes: 5 additions & 0 deletions BE/src/quiz-set/entities/quiz.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
CreateDateColumn,
DeleteDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
OneToMany,
Expand All @@ -22,6 +23,10 @@ export class QuizModel extends BaseModel {
quizSetId: number;

@Column('text')
@Index({
fulltext: true,
parser: 'ngram'
})
quiz: string;

@Column({ name: 'limit_time' })
Expand Down
5 changes: 4 additions & 1 deletion BE/src/quiz-set/quiz-set.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,16 @@ export class QuizSetController {
@Query('take', new ParseIntOrDefault(10)) take: number,
@Query('search', new DefaultValuePipe('')) search: string
) {
const start = Date.now();
const result = await this.quizService.findAllWithQuizzesAndChoices(
category,
cursor,
take,
search
);
this.logger.verbose(`퀴즈셋 목록 조회: ${result}`);
const end = Date.now();
this.logger.verbose(`퀴즈셋 목록 조회: ${result}, ${end - start}ms`);
// this.logger.verbose(`퀴즈셋 목록 조회: ${result}`);
return result;
}

Expand Down
106 changes: 76 additions & 30 deletions BE/src/quiz-set/service/quiz-set-read.service.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
import {
Injectable,
NotFoundException
} from '@nestjs/common';
import { Injectable, NotFoundException } from '@nestjs/common';
import { QuizModel } from '../entities/quiz.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository, SelectQueryBuilder } from 'typeorm';
Expand All @@ -18,7 +15,7 @@ export class QuizSetReadService {
@InjectRepository(QuizSetModel)
private readonly quizSetRepository: Repository<QuizSetModel>,
@InjectRepository(QuizChoiceModel)
private readonly quizChoiceRepository: Repository<QuizChoiceModel>,
private readonly quizChoiceRepository: Repository<QuizChoiceModel>
) {}

/**
Expand Down Expand Up @@ -48,7 +45,9 @@ export class QuizSetReadService {
const quizzes = await this.fetchQuizzesByQuizSets(responseQuizSets);
const mappedQuizSets = this.mapRelations(responseQuizSets, quizzes);

const nextCursor = hasNextPage ? responseQuizSets[responseQuizSets.length - 1].id.toString() : null;
const nextCursor = hasNextPage
? responseQuizSets[responseQuizSets.length - 1].id.toString()
: null;

return new QuizSetListResponseDto(mappedQuizSets, nextCursor, hasNextPage);
}
Expand All @@ -61,7 +60,7 @@ export class QuizSetReadService {
): Promise<QuizSetModel[]> {
let searchTargetIds: number[] | undefined;
if (search) {
searchTargetIds = await this.findSearchTargetIds(search);
searchTargetIds = await this.findSearchTargetIdsFS(search);
if (!searchTargetIds?.length) {
return [];
}
Expand All @@ -74,9 +73,7 @@ export class QuizSetReadService {
queryBuilder.andWhere('quizSet.id > :cursor', { cursor });
}

return queryBuilder
.take(take)
.getMany();
return queryBuilder.take(take).getMany();
}

/**
Expand All @@ -103,6 +100,60 @@ export class QuizSetReadService {
return queryBuilder.orderBy('quizSet.id', 'ASC');
}

/**
* Search quiz sets using Full Text Search
* @param search - Search term
* @returns Promise<number[]> - Array of matching quiz set IDs
*
* @description
* This method performs a Full Text Search across multiple tables:
* - quiz_sets: searches in title
* - quizzes: searches in quiz content
* - quiz_choices: searches in choice content
* Performance metrics:
* - Average query time: ~50ms with proper indexing
* - Memory usage: O(n) where n is the number of matches
*/
private async findSearchTargetIdsFS(search: string): Promise<number[]> {
const searchQuery = `+"${search.split(' ').join('" +"')}"`;

const quizSetIds = await this.quizSetRepository
.createQueryBuilder('quizSet')
.select('quizSet.id')
.where('MATCH(quizSet.title) AGAINST (:search IN BOOLEAN MODE)', {
search: searchQuery
})
.andWhere('quizSet.deletedAt IS NULL')
.getMany();

const quizResults = await this.quizRepository
.createQueryBuilder('quiz')
.select('DISTINCT quiz.quizSetId')
.where('MATCH(quiz.quiz) AGAINST (:search IN BOOLEAN MODE)', {
search: searchQuery
})
.andWhere('quiz.deletedAt IS NULL')
.getMany();

const choiceResults = await this.quizChoiceRepository
.createQueryBuilder('choice')
.select('DISTINCT quiz.quizSetId')
.innerJoin(QuizModel, 'quiz', 'quiz.id = choice.quizId AND quiz.deletedAt IS NULL')
.where('MATCH(choice.choiceContent) AGAINST (:search IN BOOLEAN MODE)', {
search: searchQuery
})
.andWhere('choice.deletedAt IS NULL')
.getMany();

const matchedQuizSetIds = new Set([
...quizSetIds.map((qs) => qs.id),
...quizResults.map((q) => q.quizSetId),
...choiceResults.map((c) => (c as any).quizSetId)
]);

return Array.from(matchedQuizSetIds);
}

private async findSearchTargetIds(search: string): Promise<number[]> {
const searchTerm = `%${search}%`;

Expand All @@ -126,45 +177,38 @@ export class QuizSetReadService {
const choiceResults = await this.quizChoiceRepository
.createQueryBuilder('choice')
.select('DISTINCT quiz.quizSetId')
.innerJoin(
QuizModel,
'quiz',
'quiz.id = choice.quizId AND quiz.deletedAt IS NULL'
)
.innerJoin(QuizModel, 'quiz', 'quiz.id = choice.quizId AND quiz.deletedAt IS NULL')
.where('choice.choiceContent LIKE :search', { search: searchTerm })
.andWhere('choice.deletedAt IS NULL')
.getMany();

// 결과 병합 및 중복 제거
const matchedQuizSetIds = new Set([
...quizSetIds.map(qs => qs.id),
...quizResults.map(q => q.quizSetId),
...choiceResults.map(c => (c as any).quizSetId)
...quizSetIds.map((qs) => qs.id),
...quizResults.map((q) => q.quizSetId),
...choiceResults.map((c) => (c as any).quizSetId)
]);

return Array.from(matchedQuizSetIds);
}

private async fetchQuizzesByQuizSets(quizSets: QuizSetModel[]): Promise<QuizModel[]> {
const quizSetIds = quizSets.map(qs => qs.id);
const quizSetIds = quizSets.map((qs) => qs.id);
return this.quizRepository
.createQueryBuilder('quiz')
.where('quiz.quizSetId IN (:...quizSetIds)', { quizSetIds })
.andWhere('quiz.deletedAt IS NULL')
.getMany();
}

private mapRelations(
quizSets: QuizSetModel[],
quizzes: QuizModel[]
): QuizSetDto[] {
private mapRelations(quizSets: QuizSetModel[], quizzes: QuizModel[]): QuizSetDto[] {
const quizzesByQuizSetId = groupBy(quizzes, 'quizSetId');

return quizSets.map(quizSet => ({
return quizSets.map((quizSet) => ({
id: quizSet.id.toString(),
title: quizSet.title,
category: quizSet.category,
quizCount: (quizzesByQuizSetId[quizSet.id] || []).length,
quizCount: (quizzesByQuizSetId[quizSet.id] || []).length
}));
}

Expand All @@ -177,7 +221,7 @@ export class QuizSetReadService {
async findOne(id: number) {
const quizSet = await this.findQuizSetById(id);
const quizzes = await this.findQuizzesByQuizSetId(id);
const choices = await this.findChoicesByQuizIds(quizzes.map(q => q.id));
const choices = await this.findChoicesByQuizIds(quizzes.map((q) => q.id));
return this.mapToQuizSetDetailDto(quizSet, quizzes, choices);
}

Expand All @@ -202,7 +246,9 @@ export class QuizSetReadService {
}

private async findChoicesByQuizIds(quizIds: number[]): Promise<QuizChoiceModel[]> {
if (quizIds.length === 0) return [];
if (quizIds.length === 0) {
return [];
}

return this.quizChoiceRepository
.createQueryBuilder('choice')
Expand All @@ -222,16 +268,16 @@ export class QuizSetReadService {
id: quizSet.id.toString(),
title: quizSet.title,
category: quizSet.category,
quizList: quizzes.map(quiz => ({
quizList: quizzes.map((quiz) => ({
id: quiz.id.toString(),
quiz: quiz.quiz,
limitTime: quiz.limitTime,
choiceList: (choicesByQuizId[quiz.id] || []).map(choice => ({
choiceList: (choicesByQuizId[quiz.id] || []).map((choice) => ({
content: choice.choiceContent,
order: choice.choiceOrder,
isAnswer: choice.isAnswer
}))
}))
};
}
}
}
Loading
Loading