KIWI 형태소 분석기 설치부터 PHP 연동까지 - FastAPI 기반 검색 색인 구축

  • 28th June 2026
  • 7 min read

본 문서는 KIWI 형태소 분석기를 FastAPI 기반의 간단한 내부 API 서버로 구성하고, PHP 에서 호출하여 검색용 색인 문자열을 생성하는 과정을 정리한 설치 매뉴얼입니다.

KIWI는 한국어 형태소 분석에 사용할 수 있는 분석기이며, 게시판 검색, 콘텐츠 검색, 뉴스 검색, 통합검색 등의 검색 품질을 높이는 데 활용할 수 있습니다. 특히 MariaDB FULLTEXT 검색이나 별도 검색엔진에 데이터를 넣기 전에 제목, 본문, 키워드에서 의미 있는 단어만 추출해 검색용 텍스트를 만들어두면 검색 정확도를 개선할 수 있습니다.

이 문서에서는 Python 환경에 KIWI와 FastAPI를 설치한 뒤, /indexed-text API를 통해 입력 문장을 분석하고 PHP 에서 해당 API를 호출하는 구조로 구성합니다.


1. 패키지 설치

먼저 Python3, pip, FastAPI, uvicorn, kiwipiepy 패키지를 설치합니다. FastAPI는 API 서버 역할을 하고, uvicorn은 FastAPI 앱을 실행하는 ASGI 서버입니다. kiwipiepy는 Python에서 KIWI 형태소 분석기를 사용할 수 있게 해주는 패키지입니다.

dnf install -y python3 python3-pip
pip3 install --upgrade pip
pip3 install fastapi uvicorn kiwipiepy

설치 후에는 서버에서 python3, pip3, uvicorn 명령이 정상적으로 동작하는지 확인하는 것이 좋습니다.


2. 작업 디렉터리 생성

KIWI API 서버에서 사용할 작업 디렉터리를 생성합니다. 여기서는 /data/kiwi-api 경로를 사용합니다. 실제 운영 환경에서는 서버의 디렉터리 구조에 맞게 변경해도 됩니다.

mkdir -p /data/kiwi-api
cd /data/kiwi-api

3. FastAPI 앱 작성

이제 FastAPI 앱을 작성합니다. 핵심은 Kiwi() 객체를 요청마다 새로 생성하지 않고, 앱 시작 시 한 번만 로딩하는 것입니다. 형태소 분석기는 초기 로딩 비용이 있으므로 매 요청마다 새로 생성하면 성능이 크게 떨어질 수 있습니다.

vi /data/kiwi-api/app.py

아래 내용을 저장합니다.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import re
from fastapi import FastAPI
from pydantic import BaseModel
from kiwipiepy import Kiwi

app = FastAPI()

# Kiwi 1회만 로딩 (핵심)
kiwi = Kiwi()

# 사용자 단어 필요하면 추가
# kiwi.add_user_word("광주여수목포", "NNP", score=10.0)
# kiwi.add_user_word("grpc-web", "NNP", score=10.0)

class TextRequest(BaseModel):
    text: str

allow_tags = {
    "NNG",
    "NNP",
    "SL",
    "SH",
    "SN",
    "VV",
    "VA",
}

stop_words = {
    "하다", "되다", "있다", "없다"
}

def normalize_text(text: str) -> str:
    if not text:
        return ""

    text = text.strip()

    # FULLTEXT 위험 문자 제거
    text = re.sub(r'[+\-><()~*"@]', ' ', text)

    # 일반 특수문자 제거
    text = re.sub(r"[^0-9A-Za-z가-힣\s\-]", " ", text)

    text = re.sub(r"\s+", " ", text).strip()
    return text

def clean_token(token: str) -> str:
    return re.sub(r"[^0-9A-Za-z가-힣]", "", token).strip()

def build_indexed_text(text: str):

    source = normalize_text(text)

    if not source:
        return {
            "input": text,
            "tokens": [],
            "search_text": ""
        }

    tokens = kiwi.tokenize(source)

    words = []
    for token in tokens:
        if token.tag in allow_tags:
            form = clean_token(token.form)

            if not form:
                continue

            if form in stop_words:
                continue

            words.append(form)

    # 중복 제거
    unique_words = list(dict.fromkeys(words))

    search_text = " ".join(unique_words)

    return {
        "input": text,
        "tokens": unique_words,
        "search_text": search_text
    }

@app.get("/health")
def health():
    return {"ok": True}

@app.post("/indexed-text")
def indexed_text(req: TextRequest):
    return build_indexed_text(req.text)

위 코드에서는 명사, 외국어, 한자, 숫자, 동사, 형용사 등을 검색 후보 단어로 사용합니다. NNG는 일반 명사, NNP는 고유 명사, SL은 외국어, SN은 숫자에 해당합니다.

또한 하다, 되다, 있다, 없다처럼 검색어로서 의미가 약한 단어는 stop word로 제외합니다. 운영하면서 불필요한 단어가 반복적으로 검색 색인에 들어간다면 stop_words에 추가하면 됩니다.

사용자 사전이 필요한 경우에는 kiwi.add_user_word()를 사용할 수 있습니다. 예를 들어 지역명, 기관명, 서비스명, 약어, 기술 용어처럼 일반 형태소 분석에서 잘 분리되지 않는 단어는 사용자 단어로 등록하는 것이 좋습니다.


4. 실행 테스트

앱을 systemd에 등록하기 전에 직접 실행해서 정상 동작 여부를 확인합니다.

which python3
python3 -m uvicorn --version
cd /data/kiwi-api
python3 -m uvicorn app:app --host 127.0.0.1 --port 8001

여기서는 외부 공개용 API가 아니라 내부 PHP 애플리케이션에서 호출하는 용도이므로 127.0.0.1로 바인딩합니다. 이렇게 하면 같은 서버 내부에서만 접근할 수 있어 불필요한 외부 노출을 줄일 수 있습니다.


5. systemd 서비스 등록

매번 수동으로 uvicorn을 실행할 수는 없기 때문에 systemd 서비스로 등록합니다.

vi /etc/systemd/system/kiwi-api.service
[Unit]
Description=Kiwi FastAPI Service
After=network.target
[Service]
Type=simple
User=root
WorkingDirectory=/data/kiwi-api
ExecStart=/usr/bin/python3 -m uvicorn app:app --host 127.0.0.1 --port 8001 --workers 1
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target

ExecStart에는 FastAPI 앱 실행 명령을 작성합니다. --workers 1로 설정한 이유는 KIWI 객체를 메모리에 올린 상태로 단순한 내부 API를 처리하기 위한 목적입니다. 트래픽이 많거나 분석 요청이 많은 환경이라면 worker 수를 늘릴 수 있지만, worker 수만큼 KIWI가 각각 로딩되므로 메모리 사용량도 함께 증가합니다.

운영 환경에서는 가능하면 User=root 대신 별도의 서비스 계정을 만들어 사용하는 것이 좋습니다. 다만 내부 서버에서 빠르게 테스트하거나 단독 서버에서 운영하는 경우에는 위 설정으로도 동작합니다.


6. 서비스 시작 및 로그 확인

systemd 설정을 다시 읽어오고 서비스를 등록한 뒤 실행합니다.

systemctl daemon-reload
systemctl enable kiwi-api
systemctl start kiwi-api
systemctl status kiwi-api

서비스 실행 중 오류가 발생하면 아래 명령으로 로그를 확인합니다.

journalctl -u kiwi-api -f

일반적으로 문제가 발생하는 경우는 Python 경로가 다르거나, pip로 설치한 패키지를 systemd 실행 환경에서 찾지 못하는 경우입니다. 이때는 which python3로 실제 Python 경로를 확인하고 ExecStart의 경로를 맞춰주면 됩니다.


7. Health 체크

서비스가 정상적으로 실행되면 /health 엔드포인트로 간단히 상태를 확인할 수 있습니다.

curl http://127.0.0.1:8001/health

정상이라면 아래와 같은 응답이 반환됩니다.

{"ok":true}

실제 형태소 분석 API는 /indexed-text로 호출합니다.

curl -X POST http://127.0.0.1:8001/indexed-text \
    -H "Content-Type: application/json" \
    -d '{"text":"뉴스 검색 시스템을 개선합니다."}'

응답에는 원문, 추출된 토큰 목록, 그리고 검색 색인에 사용할 수 있는 search_text가 포함됩니다.


8. PHP 에서 KIWI API 호출하기

이제 PHP 애플리케이션에서 KIWI API를 호출하는 클래스를 작성합니다. 게시글 저장 시 제목과 본문을 합쳐 API로 전달하고, 반환된 search_text를 별도 컬럼에 저장해두면 검색용 색인 필드로 활용할 수 있습니다.

class KiwiAPI
{
    public static function getIndexedText(string $text): string
    {
        $url = 'http://127.0.0.1:8001/indexed-text';
        
        $payload = json_encode([
            'text' => $text
        ], JSON_UNESCAPED_UNICODE);
        
        $ch = curl_init($url);
        
        curl_setopt_array($ch, [
            CURLOPT_POST => true,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => [
                'Content-Type: application/json',
            ],
            CURLOPT_POSTFIELDS => $payload,
            CURLOPT_TIMEOUT => 2,
            CURLOPT_CONNECTTIMEOUT => 1,
        ]);
        
        $response = curl_exec($ch);
        if ($response === false) {
            throw new Exception('Kiwi API 호출 실패: ' . curl_error($ch));
        }
        curl_close($ch);
        
        $data = json_decode($response, true);
        if (!isset($data['search_text'])) {
            throw new Exception('Kiwi 응답 오류');
        }
        
        return $data['search_text'];
    }
}

위 클래스는 문자열을 KIWI API로 전달한 뒤 search_text만 반환합니다. PHP 쪽에서는 이 값을 게시글의 검색용 컬럼에 저장하면 됩니다.

예를 들어 게시글 제목과 본문을 합쳐 다음과 같이 사용할 수 있습니다.

$sourceText = $title . ' ' . strip_tags($content);
$indexedText = KiwiAPI::getIndexedText($sourceText);

이후 $indexedTextsearch_text, indexed_text, fulltext_text 같은 컬럼에 저장해두면 됩니다.


9. 검색 색인용으로 활용하는 방식

일반적인 게시판 검색은 제목과 본문 전체를 대상으로 LIKE 검색을 수행하는 방식이 많습니다. 하지만 본문이 길어지거나 데이터가 많아지면 LIKE 검색은 성능이 떨어지고, 조사나 어미 때문에 검색 정확도도 낮아질 수 있습니다.

KIWI를 이용하면 원문에서 의미 있는 단어만 추출하여 별도 검색 필드에 저장할 수 있습니다. 예를 들어 다음과 같은 문장이 있다고 가정합니다.

뉴스 검색 시스템을 개선합니다.

이를 형태소 분석하면 검색에 필요한 주요 단어만 추출하여 다음과 같은 형태의 문자열을 만들 수 있습니다.

뉴스 검색 시스템 개선

이렇게 정리된 문자열을 FULLTEXT 인덱스 대상 컬럼에 저장하면, 원문 전체를 그대로 검색하는 것보다 검색 품질과 성능을 관리하기 쉬워집니다.


10. 운영 시 참고사항

  • 외부 공개 금지: 이 API는 내부 PHP 애플리케이션에서 호출하는 용도이므로 127.0.0.1로만 바인딩하는 것이 좋습니다.

  • 타임아웃 설정: PHP cURL 호출 시 CURLOPT_TIMEOUTCURLOPT_CONNECTTIMEOUT을 설정하여 API 장애가 전체 서비스 장애로 이어지지 않게 해야 합니다.

  • 사용자 사전 관리: 기관명, 지역명, 서비스명, 약어처럼 자주 검색되는 단어는 사용자 사전에 추가하는 것이 좋습니다.

  • 불용어 관리: 검색에 의미가 약한 단어는 stop_words에 추가하여 색인 품질을 관리합니다.

  • 저장 시점 분석 권장: 검색할 때마다 형태소 분석을 수행하기보다, 게시글 등록 또는 수정 시점에 분석 결과를 저장해두는 방식이 효율적입니다.


마무리

KIWI 형태소 분석기를 FastAPI로 감싸두면 PHP 프로젝트에서도 간단히 한국어 형태소 분석 기능을 사용할 수 있습니다. 특히 기존 PHP 기반 게시판이나 CMS에서 검색 품질을 개선하고 싶을 때, Python 분석 모듈을 별도 내부 API로 분리하는 방식은 적용하기 쉽고 유지보수도 비교적 단순합니다.

핵심은 게시글 저장 시점에 제목과 본문을 KIWI API로 전달하고, 반환된 search_text를 검색 전용 컬럼에 저장하는 것입니다. 이후 MariaDB FULLTEXT, Elasticsearch, OpenSearch 등 어떤 검색 구조를 사용하더라도 이 분석 결과를 색인 데이터로 활용할 수 있습니다.