훌륭한 Nest.js 블로그 만들기: 게시물 전문 검색 기능 추가
Emily Parker
Product Engineer · Leapcell

이전 튜토리얼에서는 블로그 게시물에 이미지 업로드 기능을 추가했습니다.
시간이 지남에 따라 블로그에 꽤 많은 기사가 축적되었다고 상상할 수 있습니다. 새로운 문제가 점차 발생합니다. 독자들이 원하는 기사를 빠르게 찾으려면 어떻게 해야 할까요?
물론 답은 검색입니다.
이 튜토리얼에서는 블로그에 전문 검색 기능을 추가할 것입니다.
SQL LIKE '%keyword%'
쿼리를 사용하여 검색을 구현할 수 없다고 생각할 수도 있습니다. 간단한 시나리오에서는 확실히 할 수 있습니다. 그러나 LIKE
쿼리는 대량의 텍스트를 처리할 때 성능이 저하되고 언어적 복잡성을 이해할 수 없습니다(예: "create"를 검색해도 "creating"과 일치하지 않음).
따라서 더 전문적이고 효율적인 솔루션을 채택할 것입니다. PostgreSQL의 내장 전문 검색(FTS) 기능을 활용하는 것입니다. 번개처럼 빠를 뿐만 아니라 어간 추출을 처리하고 불용어를 무시하며 관련성에 따라 정렬하므로 LIKE
보다 훨씬 뛰어난 검색 경험을 제공합니다.
1단계: 데이터베이스 검색 인프라
PostgreSQL의 FTS 기능을 사용하려면 먼저 post
테이블에 몇 가지 수정이 필요합니다. 핵심 아이디어는 고속으로 검색할 수 있는 최적화된 텍스트 데이터를 저장하기 위한 특수 열을 만드는 것입니다.
1. 핵심 개념: tsvector
post
테이블에 tsvector
유형의 새 열을 추가할 것입니다. 이를 "검색 사전"으로 생각할 수 있습니다. 기사의 제목과 내용을 개별 단어(어휘)로 분해하고 표준화합니다(예: "running"과 "ran"을 모두 "run"으로 처리) 후속 쿼리를 위해.
2. 테이블 구조 수정
PostgreSQL 데이터베이스에 연결하고 다음 SQL 문을 실행하여 post
테이블에 search_vector
열을 추가합니다.
ALTER TABLE "post" ADD COLUMN "search_vector" tsvector;
3. 트리거를 사용한 자동 업데이트
물론 게시물을 만들거나 업데이트할 때마다 이 search_vector
열을 수동으로 업데이트하고 싶지 않을 것입니다. 가장 좋은 방법은 데이터베이스가 이 작업을 자동으로 수행하도록 하는 것입니다. 트리거를 만들어 이를 달성할 것입니다.
먼저 제목과 내용을 연결하고 tsvector
형식으로 변환하는 기능을 만듭니다.
CREATE OR REPLACE FUNCTION update_post_search_vector() RETURNS TRIGGER AS $$ BEGIN NEW.search_vector := setweight(to_tsvector('english', coalesce(NEW.title, '')), 'A') || setweight(to_tsvector('english', coalesce(NEW.content, '')), 'B'); RETURN NEW; END; $$ LANGUAGE plpgsql;
팁:
setweight
함수를 사용하면 서로 다른 필드의 텍스트에 다른 가중치를 할당할 수 있습니다. 여기서는 제목('A')에 내용('B')보다 높은 가중치를 부여했으므로, 검색 결과에서 제목에 키워드가 있는 기사가 더 높은 순위를 차지합니다.
다음으로, 새 게시물이 삽입(INSERT
)될 때마다 또는 업데이트(UPDATE
)될 때마다 방금 만든 함수를 자동으로 호출하는 트리거를 만듭니다.
CREATE TRIGGER post_search_vector_update BEFORE INSERT OR UPDATE ON "post" FOR EACH ROW EXECUTE FUNCTION update_post_search_vector();
4. 검색 인덱스 생성
검색을 매우 빠르게 만들려면 마지막 단계는 새 search_vector
열에 GIN(Generalized Inverted Index)을 만드는 것입니다.
CREATE INDEX post_search_vector_idx ON "post" USING gin(search_vector);
이제 데이터베이스가 준비되었습니다! 모든 기사에 대해 효율적인 검색 인덱스를 자동으로 유지 관리합니다.
2단계: Nest.js에서 검색 로직 구축
데이터베이스 계층이 준비되었으므로 Nest.js 프로젝트로 돌아가 검색 요청을 처리하는 백엔드 코드를 작성해 봅시다.
1. PostsService
업데이트
src/posts/posts.service.ts
를 열고 새 search
메서드를 추가해야 합니다.
// src/posts/posts.service.ts import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Post } from './post.entity'; @Injectable() export class PostsService { constructor( @InjectRepository(Post) private postsRepository: Repository<Post> ) {} // ... findAll, findOne, create 메서드는 변경되지 않음 async search(query: string): Promise<Post[]> { if (!query) { return []; } // 더 복잡한 쿼리를 구성하기 위해 QueryBuilder 사용 return this.postsRepository .createQueryBuilder('post') .select() .addSelect("ts_rank(post.search_vector, to_tsquery('english', :query))", 'rank') .where("post.search_vector @@ to_tsquery('english', :query)", { query: `${query.split(' ').join(' & ')}` }) .orderBy('rank', 'DESC') .getMany(); } }
코드 설명:
- TypeORM의
QueryBuilder
를 사용합니다. 이는 복잡한 SQL 쿼리를 작성하는 데 더 많은 유연성을 제공하기 때문입니다. to_tsquery('english', :query)
: 이 함수는 사용자 입력 검색 문자열(예: "nestjs blog")을tsvector
열과 일치시킬 수 있는 특수 쿼리 유형으로 변환합니다. 모든 단어가 일치해야 함을 나타내기 위해 여러 단어를&
로 연결합니다.@@
연산자: 전문 검색을 위한 "일치" 연산자입니다.where("post.search_vector @@ ...")
줄은 검색 작업의 핵심입니다.ts_rank(...)
: 쿼리 용어가 텍스트와 얼마나 잘 일치하는지에 따라 "관련성 순위"를 계산하는 함수입니다..orderBy('rank', 'DESC')
: 가장 관련성이 높은 기사가 맨 위에 나타나도록 이 순위로 내림차순 정렬합니다.
2. 검색 라우트 생성
다음으로 검색 요청을 처리하는 src/posts/posts.controller.ts
에 새 라우트를 추가합니다.
// src/posts/posts.controller.ts import { Controller, Get, Render, Param, Post, Body, Res, UseGuards, Request, Query } from '@nestjs/common'; // ... 기타 가져오기 @Controller() // 'posts'를 개별 메서드로 이동 export class PostsController { constructor(private readonly postsService: PostsService) {} @Get() // 루트 경로 @Render('index') async root(@Request() req) { // ... } @Get('posts') // 홈페이지 리디렉션 @Render('index') async findAll(@Request() req) { const posts = await this.postsService.findAll(); return { posts, user: req.user }; } // 새 검색 라우트 @Get('search') @Render('search-results') async search(@Query('q') query: string, @Request() req) { const posts = await this.postsService.search(query); return { posts, user: req.user, query }; } @UseGuards(AuthenticatedGuard) @Get('posts/new') // ... // ... 기타 메서드 }
참고: /search
라우트를 작동시키기 위해 PostsController
의 구조를 약간 조정했습니다. @Controller('posts')
를 @Controller()
로 변경하고 @Get('posts')
와 같이 각 메서드에 명시적으로 라우트 경로를 지정했습니다.
3단계: 프론트엔드에 검색 기능 통합
백엔드 API가 준비되었으므로 이제 사용자 인터페이스에 검색 상자와 검색 결과 페이지를 추가해 보겠습니다.
1. 검색 상자 추가
views/_header.ejs
파일을 열고 탐색 모음에 검색 양식을 추가합니다.
<header> <h1><a href="/">My Blog</a></h1> <form action="/search" method="GET" class="search-form"> <input type="search" name="q" placeholder="Search posts..." /> <button type="submit">Search</button> </form> <div class="user-actions"> <% if (user) { %> <span>Welcome, User</span> <a href="/posts/new" class="new-post-btn">New Post</a> <a href="/auth/logout">Logout</a> <% } else { %> <a href="/auth/login">Login</a> <a href="/users/register">Register</a> <% } %> </div> </header>
2. 검색 결과 페이지 생성
views
디렉토리에 search-results.ejs
라는 새 파일을 만듭니다. 이 페이지는 검색 결과를 표시하는 데 사용될 것입니다.
<%- include('_header', { title: 'Search Results' }) %> <div class="search-results-container"> <h2>Search Results for: "<%= query %>"</h2> <% if (posts.length > 0) { %> <div class="post-list"> <% posts.forEach(post => { %> <article class="post-item"> <h2><a href="/posts/<%= post.id %>"><%= post.title %></a></h2> <p><%= post.content.substring(0, 150) %>...</p> <small><%= new Date(post.createdAt).toLocaleDateString() %></small> </article> <% }) %>> </div> <% } else { %> <p>No posts found matching your search. Please try different keywords.</p> <% } %>> </div> <%- include('_footer') %>
이 템플릿은 간단합니다. 먼저 사용자의 검색 쿼리를 표시한 다음 posts
배열을 확인합니다. 배열이 비어 있지 않으면 반복하여 기사 목록을 표시합니다(홈페이지와 동일). 비어 있으면 "게시물을 찾을 수 없음" 메시지가 표시됩니다.
실행 및 테스트
모든 준비가 끝났습니다! 이제 애플리케이션을 다시 시작합니다.
npm run start:dev
브라우저를 열면 페이지 맨 위에 새 검색 상자가 표시됩니다.
- 게시물 제목이나 내용에 있는 단어를 입력하고 Enter를 누릅니다.
- 페이지가 검색 결과 페이지로 이동하여 관련 기사를 표시해야 합니다.
- 존재하지 않는 단어를 검색하면 "게시물을 찾을 수 없음"이라는 메시지가 표시됩니다.
- 기사 중 하나에 "creating"이라는 단어가 포함되어 있다면 "create"를 검색하여 PostgreSQL의 강력한 어간 추출 기능이 올바르게 일치하는지 확인해 보세요!
이제 블로그에서 전문 검색 기능을 지원합니다. 얼마나 많이 쓰든 독자들이 더 이상 길을 잃지 않을 것입니다.