훌륭한 Nest.js 블로그 만들기: 게시글 전체 텍스트 검색
Emily Parker
Product Engineer · Leapcell

이전 튜토리얼에서 블로그 게시물에 이미지 업로드 기능을 추가했습니다.
시간이 지남에 따라 블로그에 꽤 많은 기사가 쌓였다고 상상해 보세요. 새로운 문제가 점차 나타납니다. 독자들이 원하는 기사를 어떻게 빠르게 찾을 수 있을까요?
물론 답은 검색입니다.
이 튜토리얼에서는 블로그에 전체 텍스트 검색 기능을 추가할 것입니다.
SQL LIKE '%keyword%'
쿼리를 사용하여 검색을 구현할 수 있지 않을까 생각할 수도 있습니다.
간단한 시나리오에서는 가능합니다. 그러나 LIKE
쿼리는 긴 텍스트 블록을 처리할 때 성능이 떨어지고 퍼지 검색(예: "creation"을 검색해도 "create"와 일치시키지 못함)을 처리할 수 없습니다.
따라서 더 효율적인 솔루션을 채택할 것입니다. PostgreSQL의 내장 전체 텍스트 검색(FTS) 기능을 사용하는 것입니다. 빠를 뿐만 아니라 스테이밍, 관련성 순위 지정도 지원하며 LIKE
보다 훨씬 뛰어난 검색 기능을 제공합니다.
1단계: 데이터베이스 검색 인프라
PostgreSQL의 FTS 기능을 사용하려면 먼저 post
테이블을 수정해야 합니다. 핵심 아이디어는 최적화되고 고속으로 검색 가능한 텍스트 데이터를 저장하기 위한 특수 열을 만드는 것입니다.
핵심 개념: tsvector
post
테이블에 tsvector
유형의 새 열을 추가할 것입니다. 기사의 제목과 내용을 개별 단어로 분해하고 정규화합니다(예: "running"과 "ran"을 모두 "run"으로 처리).
테이블 구조 수정
다음 SQL 문을 PostgreSQL 데이터베이스에서 실행하여 post
테이블에 search_vector
열을 추가합니다.
ALTER TABLE "post" ADD COLUMN "search_vector" tsvector;
데이터베이스가 Leapcell에서 생성된 경우
그래픽 인터페이스를 사용하여 SQL 문을 쉽게 실행할 수 있습니다. 웹사이트에서 데이터베이스 관리 페이지로 이동하여 위의 문을 SQL 인터페이스에 붙여넣고 실행하기만 하면 됩니다.
기존 게시물에 대한 검색 벡터 업데이트
검색 벡터(search_vector
)를 게시물에 대해 업데이트하면 검색 가능하게 됩니다.
블로그에 이미 일부 기사가 있으므로 다음 SQL 문을 실행하면 해당 게시물에 대한 search_vector
데이터를 생성할 수 있습니다.
UPDATE "post" SET search_vector = setweight(to_tsvector('english', coalesce(title, '')), 'A') || setweight(to_tsvector('english', coalesce(content, '')), 'B');
트리거를 사용한 자동 업데이트
기사가 생성되거나 업데이트될 때마다 search_vector
열을 수동으로 업데이트하고 싶지 않을 것입니다. 가장 좋은 방법은 데이터베이스가 이 작업을 자동으로 수행하도록 하는 것입니다. 트리거를 생성하여 이를 달성할 수 있습니다.
먼저 이전과 마찬가지로 기사에 대한 search_vector
데이터를 생성하는 함수를 만듭니다.
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();
검색 인덱스 생성
마지막으로 search_vector
열에 GIN(Generalized Inverted Index)을 생성합니다.
CREATE INDEX post_search_vector_idx ON "post" USING gin(search_vector);
이제 데이터베이스가 검색 준비를 마쳤습니다. 모든 기사에 대한 효율적인 검색 인덱스를 자동으로 유지 관리합니다.
2단계: Nest.js에서 검색 로직 구축
데이터베이스 계층이 준비되었습니다. 이제 검색 요청을 처리하기 위한 백엔드 코드를 작성하기 위해 Nest.js 프로젝트로 돌아갑니다.
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> ) {} // ... 다른 메서드는 변경되지 않음 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(); } }
코드 설명:
to_tsquery('english', :query)
: 이 함수는 사용자 입력 검색 문자열(예: "nestjs blog")을tsvector
열과 일치시킬 수 있는 특수 쿼리 유형으로 변환합니다. 여러 단어를 연결하기 위해&
를 사용하여 모든 단어가 일치해야 함을 나타냅니다.@@
연산자: 전체 텍스트 검색과의 "일치" 연산자입니다.where("post.search_vector @@ ...")
줄은 검색을 수행하는 핵심 작업입니다.ts_rank(...)
: 쿼리 용어가 블로그 게시물과 얼마나 잘 일치하는지에 따라 "관련성 순위"를 계산하는 함수입니다..orderBy('rank', 'DESC')
: 가장 관련성이 높은 기사가 먼저 표시되도록 이 순위별로 내림차순으로 정렬합니다.
검색 경로 생성
다음으로 검색 요청을 처리하는 새 경로를 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, private readonly commentsService: CommentsService, ) {} // ... 다른 메서드는 변경되지 않음 // 새 검색 경로 @Get('search') @Render('search-results') async search(@Query('q') query: string, @Request() req) { const posts = await this.postsService.search(query); return { posts, user: req.session.user, query }; } // 컨트롤러는 위에서 아래로 경로를 일치시키므로 :id 경로는 마지막에 배치해야 합니다 @Get(':id') @Render('post') async post(@Param('id') id: string, @Request() req) { // ... } }
컨트롤러는 경로를 위에서 아래로 일치시키므로 search
경로와의 충돌을 피하기 위해 :id
경로를 마지막에 배치해야 합니다.
3단계: 검색 기능을 프론트엔드에 통합
백엔드 API가 준비되었습니다. 이제 사용자 인터페이스에 검색 상자와 검색 결과 페이지를 추가해 보겠습니다.
검색 상자 추가
views/_header.ejs
파일을 열고 탐색 모음에 검색 입력 양식을 추가합니다.
<header> <h1><a href="/">My Blog</a></h1> <form action="/posts/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.username %></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') %>
실행 및 테스트
애플리케이션을 다시 시작합니다.
npm run start:dev
브라우저를 열고 다음으로 이동합니다. http://localhost:3000/
"testing" 키워드로 새 기사를 작성해 보세요.
기사를 저장한 후 검색 상자에 "test"를 입력하고 검색을 수행합니다.
검색 결과 페이지에 방금 만든 기사가 이제 결과에 나타납니다.
이제 블로그에서 전체 텍스트 검색을 지원합니다. 아무리 많이 써도 독자들은 더 이상 길을 잃지 않을 것입니다.
이전 튜토리얼: