커뮤니티

CMS/프레임워크 Rhymix 2.0
개발 언어 PHP 8.4

서버 모니터를 만들고 나서, 확인해보니 모든 지연 이슈가 "검색"이었습니다. 

저도 검색이 느리고, 나뉘어 검색되는 것이 답답하다 생각했는데,

클로드가 검색을 새로 만들자고 해서 만들어봤습니다.  

현재 사이트에서 작동은 잘 하는 것 같은데, 혹시 문제될 부분이 없는지 궁금하여 질문 드립니다. 

 

아래는 어떻게 만들었나?


기존 검색이 느렸던 이유

기존 방식(LIKE 검색)은 검색할 때마다 11만 개 글의 제목과 본문 전체를 처음부터 끝까지 훑습니다. 책에서 단어를 찾을 때 첫 페이지부터 한 장씩 넘겨보는 것과 같아서, 검색어가 뭐든 매번 1.5~4.5초가 걸렸습니다.

새 검색의 핵심: "찾아보기(색인)"를 미리 만들어 두기

책 뒤에 있는 '찾아보기'처럼, 어떤 단어 조각이 어떤 글에 들어있는지 목록을 미리 만들어 둡니다 (별도 색인 테이블, 267MB). 검색할 때는 이 찾아보기만 펴보면 되니 수 밀리초면 끝납니다. 색인 관리 탭에서 하셨던 "재색인"이 바로 이 찾아보기를 만드는 작업입니다.

여기에 한국어 특유의 문제가 있었습니다. DB의 기본 색인은 띄어쓰기 단위라 "무선이어폰"이라는 단어에서 "이어폰"을 못 찾습니다. 그래서 모든 한글을 두 글자씩 겹쳐 잘라서 색인합니다 — "무선이어폰" → 무선·선이·이어·어폰. 검색할 때도 같은 방식으로 잘라서 "조각들이 연속으로 이어진 글"만 찾으니, 기존 검색과 같은 범위를 찾으면서 속도만 빨라집니다. (DB가 3글자 미만 조각을 버리는 설정이라, 조각마다 'x'를 붙여 3글자로 만드는 트릭도 썼습니다 — 덕분에 DB 재시작 없이 파일 업로드만으로 적용됐습니다.)

안전을 위한 설계 (이번 작업에서 가장 공들인 부분)

  • 코어 파일 수정 0개 — 라이믹스가 공식으로 제공하는 "검색 방식을 교체할 수 있는 연결 지점"에 모듈을 꽂았습니다. 업그레이드해도 안 깨지고, 재패치도 필요 없습니다.
  • 비밀글·권한은 이중 검증 — 색인은 후보 목록만 뽑고, 실제로 보여줄지는 항상 살아있는 원본 게시글에 라이믹스 코어의 권한 검사를 그대로 통과시킵니다. 색인이 낡아도 비밀글이 새어나갈 수 없는 구조입니다 (비로그인 상태로 실측 검증 완료).
  • 9가지 자동 안전장치 — 색인이 없거나, 검색어가 처리 불가하거나, 어떤 오류가 나든 자동으로 기존 검색으로 넘어갑니다. 최악의 경우가 "지금과 똑같음"이고, 설정에서 끄면 완전히 원상복구됩니다.
  • 글쓰기와 완전 분리 — 새 글의 색인은 저장 응답이 끝난 뒤 백그라운드에서 처리해서, 글 쓰는 속도나 안정성에 영향이 없습니다.

실사용에서 다듬은 것들

  • 흔한 단어 자동 전환: "이어폰"처럼 2만 건 넘게 매치되는 단어는 색인 방식이 오히려 느려서, 3천 건을 넘으면 기존 방식으로 자동 전환하고 그 단어를 3일간 기억합니다. 어떤 검색어도 이전보다 느려지지 않게 하는 장치입니다.
  • 여러 단어 영문 검색: "tour one"은 두 단어가 붙어 나오는 글만 찾도록 해서, iPhone 속의 "one" 같은 엉뚱한 매치를 없앴습니다.
  • 전체글(통합게시판) 지원: 전체글은 12개 게시판을 묶어 보여주는 특수 구조라, 연결된 게시판 목록을 자동 인식해서 검색하고, 회원 등급별 표시 권한도 timeline 모듈의 검사 함수를 그대로 빌려 씁니다.

결과

희귀 검색어 4.5초 → 0.01초, 검색 범위는 "최근 일부"에서 "전체 글"로, 정확도는 기존과 같거나 더 좋게 — 이게 한 줄 요약입니다.


 

 

어떤 방식으로 만들었는지 자세하게


1. 아키텍처 개요

modules/ftsearch/ — Rhymix modern PSR-4 모듈 (Rhymix\Modules\Ftsearch\*). 코어 수정 0. 핵심은 코어가 공식 제공하는 확장점입니다:

// modules/document/document.model.php:261-274 (코어)
$output = ModuleHandler::triggerCall('document.getDocumentList', 'before', $obj);
if (isset($obj->use_alternate_output) && $obj->use_alternate_output instanceof BaseObject) {
  $output = $obj->use_alternate_output;   // ← 여기에 우리 결과를 꽂음
}

게시판 검색(board.view.php), 통합검색(integration_search), timeline이 전부 DocumentModel::getDocumentList() 하나로 수렴하므로, before 트리거 1개로 3개 진입점을 커버합니다. 실패 시 프로퍼티를 안 건드리고 return만 하면 코어가 기존 LIKE 경로를 타는 것이 폴백의 전부입니다.

2. 색인 설계 — 바이그램 그림자 테이블

테이블 xe_ftsearch_index: document_srl(PK), module_srl, list_order, ft_title(TEXT), ft_body(LONGTEXT), indexed_at, 좁히기용 is_public/is_notice + FULLTEXT KEY ft_all(ft_title, ft_body). 원본 xe_documents는 ALTER하지 않습니다 (첫 FULLTEXT 인덱스는 hidden FTS_DOC_ID 추가로 테이블 재작성 유발).

토큰화 (Tokenizer::tokenizeForIndex): HTML strip → 문자종별 런 분리(정규식으로 한글/CJK 런과 [A-Za-z0-9]+ 런 구분) →

  • CJK 런: 겹침 2-gram — 무선이어폰 → x무선 x선이 x이어 x어폰
  • 라틴 런: 소문자 단어 그대로 (sony)
  • 본문은 8,000자 캡 (디스크 절약, 색인 267MB)

x 접두는 MariaDB의 innodb_ft_min_token_size=3 (read-only 변수, 변경엔 DB 재시작 필요) 아래에서 2글자 바이그그램이 통째로 버려지는 것을 우회하는 트릭입니다. 사전에 임시 테이블로 실측 검증 후 채택했습니다 — 덕분에 운영 DB 설정 변경 없이 배포됐습니다.

검색어 변환 (buildBooleanExpr): BOOLEAN MODE 메타문자 전량 제거 후 재조립.

  • 한글: 같은 바이그램을 구문 검색으로 — +"x이어 x어폰". InnoDB FTS가 토큰 위치를 저장하므로 phrase 매칭이 인접성을 강제 → 바이그램 인접 = 원문 글자 인접 = LIKE '%이어폰%'과 동일 의미.
  • 라틴 1단어: +fiio* (prefix). 라틴 2단어+: +"tour one" (phrase — +tour* +one* AND 방식이 만들던 원거리 오탐 제거).
  • 1글자 한글, min_token_size 미만 라틴, (그룹) 문법 → null 반환 → LIKE 폴백.

3. 검색 실행 — 3단계 쿼리

EventHandlers::onBeforeGetDocumentList의 게이트(프로퍼티 비교 2회로 비검색 요청 즉시 탈출) 통과 후:

$opt = clone $obj;                 // 원본 오염 방지 (폴백 시 재사용되므로 필수)
$opt->use_division = false;        // 코어의 5000건 분할·Context 부작용 차단
DocumentModel::_setSearchOption($opt, $args, $queryId, $useDivision);  // public static 재사용
unset($args->s_title, $args->s_content);   // LIKE 조건만 제거, 비밀글·상태·권한 필터는 유지

권한 정책을 재구현하지 않고 코어의 _setSearchOption()을 그대로 호출$args(statusList, module_srl, 비밀글 제외 등)를 받아오는 게 보안 설계의 핵심입니다.

1단계-A — 캡 서브쿼리 COUNT (파라미터 바인딩, DB::query() 원시 SQL):

SELECT COUNT(*) FROM (SELECT document_srl FROM xe_ftsearch_index
  WHERE MATCH(ft_title, ft_body) AGAINST(? IN BOOLEAN MODE)
    AND module_srl IN (...) [AND is_public=1] [AND is_notice='N']
  LIMIT 3001) t

LIMIT cap+1로 조기 종료 — 캡 미만이면 이 값이 곧 정확한 total이고(에어팟 291ms→33ms), 캡 도달(3001)이면 흔한 단어로 판정해 files/ftsearch/hotwords.json(TTL 3일, 최대 200항목)에 기록 후 LIKE 폴백. 이후 그 단어는 판정 쿼리조차 생략합니다.

1단계-BORDER BY list_order ASC LIMIT n OFFSET m으로 이번 페이지 몫의 srl만 추출 (≤30개 — IN 폭발 원천 차단. LIMIT/OFFSET은 PDO 네이티브 프리페어드에서 바인딩 불가라 int 캐스팅 후 인라인).

3단계 — 코어 getDocumentList.xml을 복사해 document_srls IN 조건을 추가한 모듈 XML 쿼리로, 살아있는 xe_documents에 코어 필터를 재적용. 색인이 낡아도 비밀글은 여기서 걸러집니다(색인엔 상태 컬럼이 '좁히기'용으로만 있고 최종 판정은 항상 원본). 결과는 1-B의 srl 순서로 재정렬 + 코어 DB::fetch() 규약(내림차순 게시물 번호 키) 재현 + PageHandlerDBResultHelper 조립.

4. 색인 동기화 — 트랜잭션 밖으로

document.insertDocument/updateDocument/deleteDocument/moveDocumentToTrash after 트리거는 코어의 begin()~commit() 사이에서 호출됩니다. 여기서 FULLTEXT 테이블에 직접 쓰면 데드락(ER_LOCK_DEADLOCK) 시 MySQL이 트랜잭션 전체를 롤백하는데 코어는 무조건 commit하므로 "등록 성공인데 글이 없는" 사고가 가능합니다 (코드 리뷰에서 잡은 치명 이슈). 그래서:

// after 트리거: static 배열에 srl만 적재 + register_shutdown_function 1회 등록
// shutdown 콜백(응답 전송·commit 이후): try/catch(\Throwable)로 REPLACE INTO 실행

실패해도 글쓰기는 무사하고, 해당 문서만 미색인으로 남아 대시보드 배치가 안전망이 됩니다.

5. timeline(전체글) 통합

트리거 체인이 ftsearch → supercache → timeline 순서인데, timeline도 같은 use_alternate_output 훅을 쓰면서 "검색어 + 선행 출력 존재 시 양보"하는 가드가 있습니다. 우리가 전체글의 자기 module_srl(문서 2건뿐)로 0건을 만들어 놓으면 timeline이 양보해버려 빈 화면이 되는 충돌이 있었고, 다음으로 해결했습니다:

  • timelineModel::getTimelineInfo()로 timeline 부착 감지 → attach_info(연결 게시판 12개, 캐시 드라이버에 따라 배열/JSON 문자열 양쪽 방어) + 부모 srl로 module_srl 목록을 확장해 검색
  • grant_display_list_use='Y'(회원 등급별 게시판 필터)는 timeline의 getReplaceAttachInfoByGrant()가 정적 상태 의존 없는 public 메서드임을 확인하고 그대로 호출해 재사용
  • 재현이 위험한 조건(날짜 범위, 조회수 조건 필터, 카테고리 검색 등)이 활성인 경우에만 양보

6. 개발 프로세스

설계는 코어 소스를 파일:라인 단위로 검증한 설계 문서를 먼저 만들고(대체 출력 훅·_setSearchOption 재사용·IN 폭발 회피가 여기서 확정), 구현 후 별도 에이전트의 적대적 코드 리뷰(치명 1건·중요 5건 발견·수정), 기계 QA(문법·XML·lang 정합성), 그리고 실측 벤치마크를 각 단계마다 돌렸습니다. LIKE 대비 실측: count 1.41.8초 → 130ms, 희귀어 목록 4.5초 → 0.5ms, 초고빈도어는 핫워드 전환으로 기존과 동일 상한.

더 깊이 궁금한 부분(예: 특정 파일 코드, 쿼리 실행 계획, 바이그램 인접성 판정 원리 등)이 있으면 말씀해 주세요.


 

 

 

 

색인으로 추가된 용량은?


  • 테스트 서버 실측: 문서 109,726건 → 색인 테이블 267MB
  • 운영은 113,749건으로 약 3.7% 많으므로 → 색인 테이블 약 275~280MB 예상
  • 여기에 DB 내부의 전문검색 보조 파일(InnoDB가 FULLTEXT용으로 따로 만드는 역색인 파일)이 추가되는데, 이건 위 수치에 다 안 잡힐 수 있어서 전체 실사용량은 최대 400~500MB 수준으로 보시면 안전합니다.

 

 

 

 

 

 

 

lucas Lv. 1

댓글 2

  • https://rhymix.org/pds/1947889

    외부 프로그램을 활용하여 빠른 검색을 구현한 솔루션도 있어요.

    개발하시는 리소스와 저걸 사용할때의 리소스를 비교해보셔서 선택하는게 좋을거 같아요

  • 모두들 엘라스틱서치만 바라보고 MySQL의 fulltext index를 활용하려는 시도는 보이지 않았는데, 드디어 나왔군요. 8.0 초기 버전까지는 ngram을 지원하지 않아서 한글 검색이 영 별로였는데, 아마 그것 때문에 안 좋은 이미지가 생겼는지도? 이제는 엘라스틱서치를 따로 설치하기 귀찮거나 불가능한 경우에 좋은 대안이 되어줄 것 같습니다.

     

    AI가 작성한 장황한 글은 AI를 시켜서 조금 요약하시는 것을 추천드립니다. 원체 분량이 많은데다가, 문장 중간중간에 코드가 섞여 있으니 게시판 환경에서는 가독성이 별로 안 좋네요.