| 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단계-B — ORDER 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() 규약(내림차순 게시물 번호 키) 재현 + PageHandler로 DBResultHelper 조립.
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 수준으로 보시면 안전합니다.
댓글 0