본문 바로가기
개발/웹

Flask 블로그 제작기 (6) - 검색 기능

by pandatta 2022. 9. 13.

플라스크로 서비스되던 구버전 블로그에서 옮겨왔습니다.


검색(search)정렬(sort)은 알고리즘계의 양대산맥입니다. 자료구조에 따라 다양한 방식으로 검색과 정렬을 구현할 수 있지만, 그 때 그 때마다 메모리적으로나 시간적으로 적합한 알고리즘은 따로 있죠. 이 블로그와 같은 플라스크 블로그에서도 1) 직접 검색 알고리즘을 데이터베이스 상대로 구현하거나, 2) 처음부터 Elasticsearch같은 검색 전문 데이터베이스에 데이터를 저장하는 등 복잡한 방법들이 있겠지만, 사실 가장 쉬운 방법은 RDBMS에 LIKE SQL 쿼리를 날리는 방법입니다. 한 번 쉽게 구현해보도록 합시다.

HTML view

일단 html 부분입니다. 저는 블로그 랜딩 페이지에서만 검색 기능이 있으면 충분하다고 생각해서 templates/blog/index.html에만 검색 기능을 구현했지만, 모든 페이지에서 필요하다고 생각되면 templates/base.html에 구현한 다음 템플릿 상속하게끔 하면 됩니다.

<!-- templates/blog/index.html -->
{% extends 'base.html' %}
{% block header %} <!-- 검색 기능을 헤더에 넣겠습니다 -->
<form method="post">
        <small class="text-muted text-nowrap mx-2">
                총 {{ total }}개 <!-- 검색 결과가 몇 개인지 출력 -->
        </small>
        <!-- 검색창 -->
        <input name="query" id="query" value="{{ request.form['query'] }}"
                placeholder="검색" class="form-control form-control-sm" required>
        <button type="submit" class="btn"></button> <!-- 검색버튼 -->
</form>
{% endblock %}
{% block content %}
{% for post in posts %}
<article class="my-2">
        <h4 class="my-0">
                {{ post['title'] }} <!-- 검색 결과 포스트 제목들 -->
        </h4>
</article>
{% endfor %}
{% endblock %}
  1. <form method="post"> 태그를 걸어 POST HTTP request를 웹서버에 보낼 수 있게끔 합니다.
  2. <small> 태그 안에는 검색 결과 포스트가 총 몇 개인지를 나타낼 수 있게끔 {{ total }}이라는 변수를 jinja로 보여줍니다.
  3. <input> 태그는 검색할 내용을 입력할 수 있는 입력칸입니다. "query"라는 이름으로 플라스크에 전달할 것이고, placeholder는 "검색"으로 검색할 내용을 입력할 수 있게 사용자를 가이드해줍니다. 그리고 required로 내용 없이는 버튼을 누를 수 없게 강제합니다.
  4. <button> 태그는 <form>의 내용이 다 작성되었을 경우 이를 플라스크에 보내주는 역할을 합니다.

Flask controller

중요한 지점은 이제 랜딩 페이지가 GET뿐만 아니라 POST HTTP request도 받는다는 점입니다. GET request에 해당할 때, 즉 사용자가 블로그를 처음 봤을 때에는 모든 포스트를 보여주고, POST request에 해당할 때, 즉 사용자가 검색을 했을 때에는 검색 결과만 보여주게끔 SQL 쿼리를 날려주어야 합니다.

from .db import get_conn, get_cur

BP = Blueprint("blog", __name__)

@BP.route("/", methods=("GET", "POST"))
def index():
    cur = get_cur()  # DB와 연결
    if request.method == "POST":  # POST request라면,
        query = request.form["query"]  # "query" 입력칸에서 받은 글자
        query_for_like = ("%" + query + "%").lower()  # 검색 편의를 위해 소문자로 변환
        cur.execute(
            "SELECT COUNT(*) FROM posts "
            "WHERE (LOWER(title) LIKE %s) OR (LOWER(body) LIKE %s);",
            (query_for_like, query_for_like),  # 포스트 제목과 내용도 소문자 변환해서 검색
        )
        total = cur.fetchone()[0]
        cur.execute(
            "SELECT p.id, title, body, created, modified, author_id, views, username "
            "FROM posts p JOIN users u ON p.author_id = u.id "
            "WHERE (LOWER(title) LIKE %s) OR (LOWER(body) LIKE %s) "
            "ORDER BY created DESC;",  # POST라면 LIKE 검색 결과를 보여줌
            (query_for_like, query_for_like),
        )
    else: // POST가 아니라면, 즉 GET request라면,
        cur.execute("SELECT COUNT(*) FROM posts p;")
        total = cur.fetchone()[0]
        cur.execute(
            "SELECT p.id, title, body, created, modified, author_id, views, username "
            "FROM posts p JOIN users u ON p.author_id = u.id "
            "ORDER BY created DESC;",  # GET이라면 검색 없이 모든 포스트를 보여줌
            (per_page, offset),
        )
    posts = cur.fetchall()

    return render_template(  # 검색 결과를 페이지에 보여줌
        "blog/index.html", posts=posts, total=total
    )

POST request일 경우에 집중하자면, DB에 "WHERE (LOWER(title) LIKE %s) OR (LOWER(body) LIKE %s);", (query_for_like, query_for_like)와 같은 식의 SQL 쿼리를 날립니다.

예를 들어 "Python"이라는 글자를 검색했다고 하면, SQL 쿼리는 SELECT * FROM posts WHERE (LOWER(title) LIKE %python%) OR (LOWER(body) LIKE %python%);와 같은 형식이 됩니다. posts 테이블의 title과 body 속성을 소문자로 치환해서 둘 중 하나라도 있는 python이라는 글자가 있는 포스트를 모두 가져오겠다는 뜻이죠.
이 때 query_for_like 변수는 1) 대문자는 소문자로 변환, 2) 글자 앞뒤로 % 기호가 붙어있습니다. 글자 앞뒤로 미리 % 기호를 붙인 이유는 Python에서 SQL 구문을 편집할 때의 편의성 때문입니다.

아직 갈 길이 멀다

하지만 이런 방식의 검색 기능은 이해가 쉬운 반면에 개선의 여지가 많습니다.

  1. 속도가 느립니다. posts 테이블의 title과 body 속성은 인덱스(index)가 걸려있지 않아 검색 속도가 매우 느리죠.
  2. 속성을 한정해서 검색할 수 없습니다. title만 한정해서, 또는 user만 한정해서 검색하려면? 따로 방법을 마련해야겠죠. 드롭다운(dropdown) 박스를 만들어서 검색 범위를 정해줘야할 것입니다.
  3. 여러 검색어를 한꺼번에 검색할 수 없습니다. 예를 들어 "파이썬 python"을 검색하고싶다면? 두 단어 사이 공백에 split()을 걸어 두 번 검색해야 할까요?
  4. 비슷한 단어를 검색할 수 없습니다. 지금은 대문자와 소문자만 교차검색할 수 있지만, 예를 들어 "pyton"을 잘못 검색해도 "python"을 검색한 결과가 나오게 하려면? 지금 구조론 힘들겠죠.

그래서 앞서 설명드린 Elasticsearch같은 검색 전문 데이터베이스를 여러 검색엔진에서 사용하고있습니다. 물론 검색 전문 데이터베이스를 사용한 상용 검색엔진 API, 예를 들면 Google Programmable Search Engine같은 것을 가져와서 쓸 순 있겠죠. 하지만 블로그를 직접 만드는 백엔드 개발자라면 직접 검색 엔진을 만들어보는 것이 더 좋을 것 같습니다. 저도 더 노력해야겠죠ㅋㅋㅋ

마지막으로 제 블로그에는 태그 기능이나 페이지네이션 기능 등이 적용되어있지만, 그런 내용들은 일단 빼고 최대한 검색 기능만 한정해서 코드를 개편해서 보여드렸습니다. 페이지네이션이 궁금하시면 예전 포스트: Flask 블로그 제작기 (4) - 페이지네이션 구현을 참고하시고, 태그 기능은 나중에 따로 포스트를 작성해보도록 하겠습니다. 혹시 DB 모델과 사용법에 대한 부분이 궁금하시면 역시 예전 포스트: Flask 블로그 제작기 (3) - PostgreSQL 연결을 참고해주세요.

댓글