FastAPI로 나만의 포럼 만들기: 8단계 - 전문 검색
Lukas Schneider
DevOps Engineer · Leapcell

이전 글에서는 건강한 커뮤니티를 위한 기반을 다지며 "관리자" 및 "사용자 차단" 기능을 지원하는 포럼의 기본 권한 시스템을 구현했습니다.
포럼에 콘텐츠가 쌓일수록 사용자는 관심 있는 오래된 게시물을 찾는 데 어려움을 겪을 수 있습니다. 새로운 요구 사항이 등장하고 있습니다. 사용자가 읽고 싶은 기사를 빠르게 찾도록 돕는 검색 기능이 있어야 하지 않을까요?
이 글에서는 포럼에 전문 검색 기능을 추가할 것입니다.
SQL에 대한 지식이 있다면 LIKE '%keyword%' 쿼리를 사용하여 검색을 구현할 수 있지 않을까 생각할 수 있습니다. 간단한 시나리오에서는 실제로 가능합니다. 하지만 LIKE 쿼리는 많은 양의 텍스트를 처리할 때 성능이 매우 떨어지고 언어적 복잡성을 이해하지 못합니다(예: "create"를 검색해도 "creating"과 일치하지 않음).
따라서 우리는 더 전문적이고 효율적인 솔루션, 즉 PostgreSQL의 내장 전문 검색(FTS) 기능을 사용하기로 했습니다. 빠를 뿐만 아니라 스테밍, 불용어 무시, 관련성별 정렬도 지원하여 LIKE보다 훨씬 뛰어난 검색 기능을 제공합니다.
1단계: 데이터베이스 검색 인프라(SQL)
PostgreSQL의 FTS 기능을 사용하려면 먼저 posts 테이블을 수정해야 합니다. 최적화되고 고속 검색 가능한 텍스트 데이터를 저장하기 위한 특수 열을 만들 것입니다.
tsvector 열 추가
posts 테이블에 tsvector 유형의 search_vector라는 새 열을 추가할 것입니다. 이 열의 목적은 사전처럼 게시물의 제목과 콘텐츠를 개별 단어(어휘)로 분해하고 처리하는 것입니다.
ALTER TABLE posts ADD COLUMN "search_vector" tsvector;
tsvector 열 자동 업데이트를 위한 트리거 사용
search_vector 열에는 자체 콘텐츠가 없습니다. 제목과 콘텐츠를 tsvector 형식으로 변환하여 이 열에 써야 합니다.
게시물을 만들거나 업데이트할 때마다 search_vector 열을 수동으로 업데이트하고 싶어하는 사람은 아무도 없습니다. 가장 좋은 방법은 트리거를 사용하여 데이터베이스가 이 작업을 자동으로 수행하도록 하는 것입니다.
먼저 함수를 만들어 보겠습니다. 이 함수의 역할은 title과 content를 연결하고 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 posts FOR EACH ROW EXECUTE FUNCTION update_post_search_vector();
검색 인덱스 생성
검색 속도를 보장하기 위한 마지막 단계는 search_vector 열에 GIN(Generalized Inverted Index) 인덱스를 만드는 것입니다.
CREATE INDEX post_search_vector_idx ON posts USING gin(search_vector);
2단계: 기존 데이터 채우기
만든 트리거는 향후에 생성되거나 수정된 게시물에만 작동한다는 점에 유의해야 합니다. 데이터베이스에 이미 있는 게시물의 경우 search_vector 필드에 여전히 NULL이 있습니다.
기존 모든 게시물에 대한 검색 벡터를 생성하기 위해 일회성 UPDATE 문을 실행해야 합니다.
-- 기존 모든 게시물에 대한 search_vector 채우기 UPDATE posts SET search_vector = setweight(to_tsvector('english', coalesce(title, '')), 'A') || setweight(to_tsvector('english', coalesce(content, '')), 'B');
Leapcell을 사용하여 데이터베이스를 만든 경우,
웹 기반 운영 패널에서 이러한 SQL 문을 직접 실행할 수 있습니다.

3단계: 검색 결과 페이지 생성
검색 결과를 표시할 새 HTML 페이지가 필요합니다.
templates 폴더에 search_results.html이라는 새 파일을 만듭니다. 이 페이지는 posts.html과 매우 유사하지만 사용자의 검색 쿼리도 추가로 표시합니다.
templates/search_results.html
<!DOCTYPE html> <html> <head> <title>Search Results - My FastAPI Forum</title> <style> /* (templates/posts.html의 모든 스타일 복사) */ body { font-family: sans-serif; margin: 2em; } input, textarea { width: 100%; padding: 8px; margin-bottom: 10px; box-sizing: border-box; } button { padding: 10px 15px; background-color: #007bff; color: white; border: none; cursor: pointer; } header { display: flex; justify-content: space-between; align-items: center; } .post-item { border: 1px solid #ccc; padding: 10px; margin-bottom: 10px; } /* (복사된 스타일 끝) */ </style> </head> <body> <header> <h1><a href="/posts" style="text-decoration: none; color: black;">Welcome to my Forum</a></h1> <div class="auth-links"> {% if current_user %} <span>Welcome, {{ current_user.username }}!</span> {% if current_user.is_admin %} <a href="/admin" style="color: red; font-weight: bold;">[Admin Panel]</a> {% endif %} <a href="/logout">Logout</a> {% else %} <a href="/login">Login</a> | <a href="/register">Register</a> {% endif %} </div> </header> <form action="/search" method="GET" style="display: inline-block;"> <input type="search" name="q" placeholder="Search posts..." value="{{ query | escape }}" /> <button type="submit">Search</button> </form> <hr /> <h2>Search Results: "{{ query | escape }}"</h2> {% if posts %} {% for post in posts %} <div class="post-item"> <a href="/posts/{{ post.id }}"><h3>{{ post.title }}</h3></a> <p>{{ post.content }}</p> <small>Author: {{ post.owner.username if post.owner else 'Unknown' }}</small> </div> {% endfor %} {% else %} <p>No posts found matching "{{ query | escape }}". Please try different keywords.</p> {% endif %} </body> </html>
value 속성에 {{ query }}도 검색 상자에 넣었습니다. 이렇게 하면 검색 후 검색 상자에 사용자의 검색어가 유지됩니다.
4단계: 검색 백엔드 라우트 구현
데이터베이스와 프런트엔드 페이지가 준비되었으므로 main.py에서 검색 요청을 처리하는 백엔드 로직을 추가할 것입니다.
먼저 Post 모델을 업데이트합니다.
models.py
# ... (이전 임포트) ... from sqlalchemy.dialects.postgresql import TSVECTOR class Post(Base): __tablename__ = "posts" # ... (기타 기존 필드) # --- 새 필드 --- search_vector = Column(TSVECTOR, nullable=True)
다음으로 main.py를 수정합니다.
main.py (새 라우트 및 임포트 추가)
# ... (이전 임포트) ... from fastapi import Query from sqlalchemy import func, desc from sqlalchemy.orm import selectinload # ... (app, templates, dependencies get_db, get_current_user, get_admin_user는 변경되지 않음) ... # --- 라우트 --- # ... (이전 라우트 /, /posts, /api/posts, /admin 등은 변경되지 않음) ... # 1. 새 검색 라우트 추가 @app.get("/search", response_class=HTMLResponse) async def search_posts( request: Request, q: Optional[str] = Query(None), # URL 쿼리 문자열에서 'q' 매개변수 가져오기 db: AsyncSession = Depends(get_db), current_user: Optional[models.User] = Depends(get_current_user) ): posts = [] if q and q.strip(): # 1. 검색어 처리: 공백을 '&'(AND 연산자)로 바꾸기 processed_query = " & ".join(q.strip().split()) # 2. FTS 쿼리 빌드 # func.to_tsquery('english', ...)는 쿼리 문자열을 tsquery 유형으로 변환합니다. # models.Post.search_vector.op('@@')(...)는 FTS 일치 연산자입니다. # func.ts_rank(...)는 관련성 순위를 계산합니다. stmt = select(models.Post) .where(models.Post.search_vector.op('@@')(func.to_tsquery('english', processed_query))) .order_by(desc(func.ts_rank( models.Post.search_vector, func.to_tsquery('english', processed_query) ))) .options(selectinload(models.Post.owner)) # 소유자 정보 미리 로드 result = await db.execute(stmt) posts = result.scalars().all() return templates.TemplateResponse("search_results.html", { "request": request, "posts": posts, "query": q if q else "", "current_user": current_user }) # ... (이후 라우트 /posts/{post_id}, /posts/{post_id}/comments 등은 변경되지 않음) ...
새로운 GET /search 라우트는 주로 다음을 수행합니다.
- q매개변수(검색어)를 읽고 공백을- &로 바꿔 쿼리가 모든 키워드와 일치하도록 합니다.
- func.to_tsquery,- func.ts_rank,- op('@@')를 사용하여 특수 FTS 쿼리를 빌드하고 결과를 관련성(- ts_rank)별로 내림차순 정렬합니다.
- 쿼리 결과와 함께 search_results.html템플릿을 렌더링합니다.
5단계: 홈페이지에 검색 상자 추가
마지막으로 포럼 홈페이지에서 사용자에게 검색 진입점을 제공해야 합니다.
templates/posts.html을 수정하여 <header>에 검색 양식을 추가합니다.
templates/posts.html (헤더 업데이트)
... (head 및 style는 변경되지 않음) ... <body> <header> <h1><a href="/posts" style="text-decoration: none; color: black;">Welcome to My Forum</a></h1> <div class="auth-links"> {% if current_user %} <span>Welcome, {{ current_user.username }}!</span> {% if current_user.is_admin %} <a href="/admin" style="color: red; font-weight: bold;">[Admin Panel]</a> {% endif %} <a href="/logout">Logout</a> {% else %} <a href="/login">Login</a> | <a href="/register">Register</a> {% endif %} </div> </header> <form action="/search" method="GET" style="display: inline-block;"> <input type="search" name="q" placeholder="Search posts..." /> <button type="submit">Search</button> </form> ... (페이지 나머지 부분은 변경되지 않음) ... </body> </html>
또한 <h1> 태그에 <a> 링크를 추가하여 사용자가 제목을 클릭하면 홈페이지로 돌아갈 수 있도록 했습니다.
실행 및 확인
기능이 구현되었습니다. uvicorn 서버를 다시 시작합니다.
uvicorn main:app --reload
브라우저를 열고 http://127.0.0.1:8000으로 이동합니다.
페이지 상단 제목 옆에 새 검색 상자가 표시됩니다.

검색 상자에 단어를 입력하고 Enter 키를 누릅니다. 페이지가 /search 라우트로 리디렉션되고 관련 게시물이 표시됩니다.

요약
PostgreSQL FTS를 활용하여 포럼에 강력하고 전문적인 전문 검색 기능을 추가했습니다. 이제 사용자는 과거 게시물을 쉽게 찾을 수 있습니다.
다음 글에서는 포럼 기능을 계속 풍부하게 만들 것입니다. 게시물은 일반 텍스트만 포함할 수 있으며 이미지는 포함할 수 없다는 점을 알 수 있습니다.
다음 글에서는 다음과 같은 기능을 구현할 것입니다. 사용자가 게시물을 만들 때 이미지를 업로드할 수 있도록 합니다.
