
이전 글에서 블로그 게시물에 이미지 업로드 기능을 추가했습니다.
시간이 지남에 따라 블로그에 상당한 수의 기사가 축적되었다고 상상해 보세요. 새로운 문제가 서서히 나타납니다. 독자들이 원하는 기사를 빠르게 어떻게 찾을 수 있을까요?
물론 답은 검색입니다.
이 튜토리얼에서는 블로그에 전체 텍스트 검색 기능을 추가할 것입니다.
SQL LIKE '%keyword%' 쿼리를 사용하여 검색을 구현할 수 없다고 생각할 수도 있습니다.
간단한 시나리오에서는 확실히 할 수 있습니다. 그러나 LIKE 쿼리는 대규모 텍스트 블록을 처리할 때 성능이 좋지 않고 퍼지 검색을 처리할 수 없습니다(예: "creation"을 검색하면 "create"와 일치하지 않음).
따라서 우리는 더 효율적인 솔루션을 채택할 것입니다. PostgreSQL의 내장 전체 텍스트 검색(FTS) 기능을 활용하는 것입니다. 빠를 뿐만 아니라 스태밍 및 관련성 순위 지정과 같은 기능도 지원하여 LIKE보다 훨씬 뛰어난 검색 기능을 제공합니다.
PostgreSQL의 FTS 기능을 사용하려면 먼저 post 테이블을 일부 수정해야 합니다. 핵심 아이디어는 고속 검색이 가능한 최적화된 텍스트 데이터를 저장하기 위한 특수 열을 만드는 것입니다.
tsvectorpost 테이블에 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);
이제 데이터베이스가 검색 준비가 되었습니다. 각 기사에 대한 효율적인 검색 인덱스를 자동으로 유지 관리합니다.
데이터베이스 계층이 준비되었습니다. 이제 검색 요청을 처리하기 위한 백엔드 코드를 작성하기 위해 FastAPI 프로젝트로 돌아가겠습니다.
routers/posts.py 파일에 검색 관련 로직을 직접 추가할 것입니다. SQLModel은 SQLAlchemy를 기반으로 하므로 SQLAlchemy의 text() 함수를 사용하여 원시 SQL 쿼리를 실행할 수 있습니다.
routers/posts.py를 열고 다음 변경 사항을 적용하십시오.
# routers/posts.py
import uuid
from fastapi import APIRouter, Request, Depends, Form, Query
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from sqlmodel import Session, select
from sqlalchemy import text # text 함수 가져오기
from database import get_session
from models import Post
from auth_dependencies import get_user_from_session, login_required
import comments_service
import markdown2
router = APIRouter()
templates = Jinja2Templates(directory="templates")
# ... 다른 라우트 ...
@router.get("/posts/search", response_class=HTMLResponse)
def search_posts(
request: Request,
q: str = Query(None), # 쿼리 매개변수에서 검색어 가져오기
session: Session = Depends(get_session),
user: dict | None = Depends(get_user_from_session)
):
posts = []
if q:
# 사용자 입력(예: "fastapi blog")을 to_tsquery가 이해할 수 있는 형식("fastapi & blog")으로 변환
search_query = " & ".join(q.strip().split())
# 전체 텍스트 검색에 대한 원시 SQL 사용
statement = text("""
SELECT id, title, content, "createdAt"
FROM post
WHERE search_vector @@ to_tsquery('english', :query)
ORDER BY ts_rank(search_vector, to_tsquery('english', :query)) DESC
""")
results = session.exec(statement, {"query": search_query}).mappings().all()
posts = list(results)
return templates.TemplateResponse(
"search-results.html",
{
"request": request,
"posts": posts,
"query": q,
"user": user,
"title": f"Search Results for '{q}'"
}
)
# 이 라우트를 /posts/search 뒤에 배치하여 라우트 충돌을 피하십시오.
@router.get("/posts/{post_id}}", response_class=HTMLResponse)
def get_post_by_id(
# ... 함수 내용이 동일하게 유지됨
# ...
코드 설명:
from sqlalchemy import text를 추가합니다./posts/search 라우트를 추가합니다. /posts/{post_id} 라우트와 충돌하지 않도록 이 새 라우트를 get_post_by_id 라우트 앞에 배치해야 합니다.q: str = Query(None): FastAPI는 URL의 쿼리 문자열(예: /posts/search?q=keyword)에서 q의 값을 가져옵니다.to_tsquery('english', :query): 이 함수는 사용자 제공 검색 문자열을 tsvector 열과 일치시킬 수 있는 특수 쿼리 유형으로 변환합니다. &를 사용하여 여러 단어를 연결하여 모든 단어가 일치해야 함을 나타냅니다.@@ 연산자: 전체 텍스트 검색을 위한 "일치" 연산자입니다. WHERE search_vector @@ ... 줄이 검색 작업의 핵심입니다.ts_rank(...): 이 함수는 쿼리 용어가 블로그 게시물과 얼마나 잘 일치하는지에 따라 "관련성 순위"를 계산합니다. 가장 관련성 높은 기사가 먼저 나타나도록 이 순위로 정렬합니다.session.exec(statement, {"query": search_query}).mappings().all()): 원시 SQL 쿼리를 실행하고 .mappings().all()을 사용하여 결과를 딕셔너리 목록으로 변환하여 템플릿에서 쉽게 사용할 수 있습니다.백엔드 API가 준비되었습니다. 이제 사용자 인터페이스에 검색 상자와 검색 결과 페이지를 추가해 보겠습니다.
templates/_header.html 파일을 열고 탐색 모음에 검색 양식을 추가합니다.
<header>
<h1><a href="/">My Blog</a></h1>
<nav>
<form action="/posts/search" method="GET" class="search-form">
<input type="search" name="q" placeholder="Search posts..." required>
<button type="submit">Search</button>
</form>
{% if user %}
<span class="welcome-msg">Welcome, {{ user.username }}</span>
<a href="/posts/new" class="new-post-btn">New Post</a>
<a href="/auth/logout" class="nav-link">Logout</a>
{% else %}
<a href="/users/register" class="nav-link">Register</a>
<a href="/auth/login" class="nav-link">Login</a>
{% endif %}
</nav>
</header>
templates 디렉토리에 search-results.html이라는 새 파일을 만듭니다. 이 페이지는 검색 결과를 표시하는 데 사용됩니다.
{% include "_header.html" %}
<div class="search-results-container">
<h2>Search Results for: "{{ query }}"</h2>
{% if posts %}
<div class="post-list">
{% for post in posts %}
<article class="post-item">
<h2><a href="/posts/{{ post.id }}">{{ post.title }}</a></h2>
<p>{{ post.content[:150] }}...</p>
<small>{{ post.createdAt.strftime('%Y-%m-%d') }}</small>
</article>
{% endfor %}
</div>
{% else %}
<p>No posts found matching your search. Please try different keywords.</p>
{% endif %}
</div>
{% include "_footer.html" %}
애플리케이션을 다시 시작합니다.
uvicorn main:app --reload
브라우저를 열고 블로그 홈페이지로 이동합니다.
"testing"이라는 키워드가 포함된 새 기사를 작성해 보겠습니다.

게시물을 저장한 후 검색 상자에 "test"를 입력하고 검색을 수행합니다.
검색 결과 페이지에서 방금 만든 기사가 결과에 나타납니다.

이제 블로그에서 전체 텍스트 검색 기능을 지원합니다. 얼마나 많이 쓰든 독자들이 더 이상 길을 잃지 않을 것입니다.
X에서 저희를 팔로우하세요: @LeapcellKR
관련 글: