클라우드 Mac에서 검증하는 iOS FTS5 검색과 색인 동기화
고객이 티켓 제목을 바꾸면 목록에는 새 제목이 표시되지만, 오프라인 검색에서는 여전히 예전 단어로 검색되는 경우가 있습니다. 스튜디오에서 빌드에 사용할 클라우드 Mac을 번갈아 쓰다 보면 이런 문제를 놓치기 쉽습니다. 프로젝트는 컴파일되고 데이터베이스도 열리지만, 전문 검색 색인은 이미 업무 테이블과 어긋나 있는 것입니다. 납품 전에 검색 동작이 명세대로인지 검증하려면 고정된 샘플 데이터로 먼저 확인하는 편이 좋습니다. 운영 데이터로 직접 테스트하는 것은 추가 관찰에는 도움이 되지만, 결과를 일관되게 재현하기 어려우므로 유일한 검증 기준으로 삼기에는 적합하지 않습니다.
실제로 사용하는 SQLite부터 확인하기
클라우드 Mac의 터미널에서 sqlite3 :memory: 'SELECT sqlite_version();'을 실행하고 테스트에 사용한 버전을 기록합니다. 이어서 FTS5 가상 테이블을 만들어 봅니다. 생성에 실패하면 여기서 중단해야 합니다. “검색 결과가 없음”을 토큰화 문제로 오인해서는 안 됩니다.
명령줄 도구로 확인할 수 있는 것은 테스트 환경뿐입니다. iOS 프로젝트는 시스템 라이브러리에 연결할 수도 있고 SQLite를 자체 포함할 수도 있으므로, 앱이 실제로 사용하는 연결에서도 같은 테이블 생성 구문을 실행해야 합니다. 이 검사를 테스트 타깃에 넣고 대상 운영체제에서 실행하세요. SQL만으로 구성된 샘플은 Mac에서 빠르게 수정하며 확인할 수 있지만, 모바일 환경의 실행 결과를 대신하지는 못합니다.
토큰화와 쿼리 의미를 명확히 정하기
먼저 “리터럴 구문 검색”과 “고급 검색 문법” 중 어느 쪽을 제공할지 정합니다. 전자는 일반적인 티켓 검색창에, 후자는 불리언 표현식을 명시적으로 지원하는 도구에 적합합니다. 아무 안내도 없는 입력창 하나에 두 동작을 섞는 것은 적절하지 않습니다.
unicode61 remove_diacritics 2는 라틴 문자의 일반적인 발음 구별 부호를 처리할 수 있지만, 범용 중국어 토큰화 도구는 아닙니다. 샘플에는 적어도 대소문자, 발음 구별 부호, 하이픈, 따옴표, 띄어쓰기 없이 이어지는 중국어를 포함해야 합니다. 영어 테스트가 통과했다고 해서 중국어에서도 임의의 단어를 검색할 수 있다고 단정해서는 안 됩니다.
SQL 매개변수 바인딩과 MATCH 이스케이프는 서로 다른 계층의 처리입니다. 매개변수 바인딩은 입력이 SQL 구조로 해석되는 것을 막지만, MATCH 자체의 쿼리 문법까지 비활성화하지는 않습니다. 리터럴 구문은 내부 큰따옴표를 두 개로 늘린 다음 전체를 큰따옴표 한 쌍으로 감싸서 만들 수 있습니다.
func literalFTSQuery(_ text: String) -> String? {
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
return "\"" + trimmed.replacingOccurrences(of: "\"", with: "\"\"") + "\""
}
반환값은 WHERE ticket_fts MATCH ?에 바인딩하고, SQL 문자열에 이어 붙이지 마세요. 여기서 “리터럴” 검색도 토큰화를 거치므로 바이트 단위의 부분 문자열 일치와는 다릅니다. 빈 입력에는 바로 빈 결과를 반환하거나 제품에서 정한 기본 목록을 표시합니다.
어떤 입력이 검색에 일치하는지는 제품의 동작 명세입니다. 접두어 검색, 구문 검색, 부분 문자열 검색은 서로 대체할 수 없습니다. 구문 오류를 숨겨서 “검색 결과 없음”으로 처리해서는 더더욱 안 됩니다.
트리거로 외부 콘텐츠 색인 동기화하기
외부 콘텐츠 테이블 방식은 기존 업무 테이블이 있고 본문을 중복 저장하고 싶지 않은 프로젝트에 적합합니다. 반면 FTS5가 업무 테이블의 변경을 자동으로 감지할 것이라고 기대하는 구현에는 맞지 않습니다. content는 연결 관계만 선언할 뿐이며, 추가·삭제·수정은 별도로 동기화해야 합니다.
다음 예제는 터미널에서 바로 실행할 수 있습니다. 모든 데이터는 메모리에만 존재하며, 검증 조건 중 하나라도 실패하면 명령이 0이 아닌 종료 상태를 반환합니다.
sqlite3 -bail :memory: <<'SQL'
CREATE TABLE ticket(id INTEGER PRIMARY KEY, title TEXT NOT NULL);
CREATE VIRTUAL TABLE ticket_fts USING fts5(
title,
content='ticket',
content_rowid='id',
tokenize='unicode61 remove_diacritics 2'
);
CREATE TRIGGER ticket_ai AFTER INSERT ON ticket BEGIN
INSERT INTO ticket_fts(rowid, title) VALUES(new.id, new.title);
END;
CREATE TRIGGER ticket_ad AFTER DELETE ON ticket BEGIN
INSERT INTO ticket_fts(ticket_fts, rowid, title)
VALUES('delete', old.id, old.title);
END;
CREATE TRIGGER ticket_au AFTER UPDATE ON ticket BEGIN
INSERT INTO ticket_fts(ticket_fts, rowid, title)
VALUES('delete', old.id, old.title);
INSERT INTO ticket_fts(rowid, title) VALUES(new.id, new.title);
END;
CREATE TEMP TABLE assertion(ok INTEGER NOT NULL CHECK(ok = 1));
INSERT INTO ticket VALUES(1, 'Café delivery');
INSERT INTO assertion
SELECT count(*) = 1 FROM ticket_fts
WHERE ticket_fts MATCH '"cafe"';
UPDATE ticket SET title = 'Invoice approved' WHERE id = 1;
INSERT INTO assertion
SELECT count(*) = 0 FROM ticket_fts
WHERE ticket_fts MATCH '"cafe"';
INSERT INTO assertion
SELECT count(*) = 1 FROM ticket_fts
WHERE ticket_fts MATCH '"invoice"';
BEGIN;
DELETE FROM ticket WHERE id = 1;
INSERT INTO assertion
SELECT count(*) = 0 FROM ticket_fts
WHERE ticket_fts MATCH '"invoice"';
ROLLBACK;
INSERT INTO assertion
SELECT count(*) = 1 FROM ticket_fts
WHERE ticket_fts MATCH '"invoice"';
DELETE FROM ticket WHERE id = 1;
INSERT INTO assertion
SELECT count(*) = 0 FROM ticket_fts
WHERE ticket_fts MATCH '"invoice"';
SQL
색인 항목을 삭제할 때는 기존 텍스트를 제공해야 하므로, 수정 트리거는 이전 항목을 먼저 삭제한 뒤 새 항목을 삽입합니다. 업무 데이터 변경과 트리거 실행은 같은 트랜잭션에 속하므로, 롤백하면 업무 테이블과 색인이 함께 복구되어야 합니다. 예제가 오류 출력 없이 끝나면 검증을 통과한 것입니다. 터미널에 표시된 표를 사람이 직접 확인할 필요는 없습니다.
기존 데이터와 정렬도 검증하기
업무 테이블을 먼저 만들고 나중에 색인을 추가한 데이터베이스에서는 트리거를 설치하는 것만으로 기존 레코드가 자동 색인되지 않습니다. 마이그레이션할 때 쓰기가 통제되는 구간에서 INSERT INTO ticket_fts(ticket_fts) VALUES('rebuild');를 실행한 뒤, 마이그레이션 전부터 있던 고정 레코드로 검색 결과를 확인합니다. 재구축은 마이그레이션 작업이며, 검색 화면을 열 때마다 실행해서는 안 됩니다.
SELECT count(*) FROM ticket_fts로 색인의 완전성을 판단하지 마세요. 외부 콘텐츠 테이블은 MATCH를 사용하지 않으면 콘텐츠 테이블을 직접 읽을 수 있습니다. 반드시 실제 검색 쿼리에 대한 검증 조건으로 이전 단어는 더 이상 검색되지 않고 새 단어는 검색되는지 각각 확인해야 합니다.
검색 순위가 같을 때는 ORDER BY rank, rowid처럼 업무 기본 키를 보조 정렬 기준으로 추가해 순서를 안정적으로 유지합니다. 페이지네이션 샘플에는 점수가 같은 결과를 여러 개 포함해야 합니다. 그렇지 않으면 반복 실행 중 결과 순서가 바뀌는 현상을 데이터 누락으로 오인하기 쉽습니다.
실패 사례를 납품 점검 항목으로 만들기
납품할 때마다 SQLite 버전, 테이블 생성 매개변수, 샘플 버전, 실패한 테스트 케이스 이름을 기록하되, 고객이 실제로 입력한 검색 텍스트는 저장하지 않습니다. 빈 문자열, 문장 부호만 있는 입력, 지나치게 긴 입력, 큰따옴표가 포함된 샘플도 추가합니다. 길이 제한을 넘는 입력은 제품 규칙에 따라 명시적으로 거부하고, 데이터베이스 예외에 기대어 처리하지 마세요.
검수할 때는 새로 만든 데이터베이스와 마이그레이션한 데이터베이스의 결과가 같은지, 제목 변경 후 이전 단어로 검색되지 않는지, 삭제 후 잔여 검색 결과가 없는지, 트랜잭션 롤백으로 결과가 복구되는지, 중국어 검색 동작이 설명과 일치하는지를 하나씩 확인합니다. 마지막으로 iOS 테스트 타깃에서 같은 데이터 세트를 실행합니다. 이렇게 해야 클라우드 Mac을 바꾸거나 고객 프로젝트를 인계할 때 특정 머신에 우연히 올바르게 남아 있던 색인이 아니라, 검색 동작 자체를 검증할 수 있습니다.
자주 묻는 질문
Mac의 sqlite3에서 FTS5가 동작하면 iOS 앱에서도 지원되나요?
보장되지 않습니다. 앱이 다른 SQLite 라이브러리를 사용할 수 있으므로 실제 앱 연결에서 임시 FTS5 테이블을 만들고 대상 시스템에서 같은 데이터를 검사해야 합니다.
unicode61이 중국어 문장을 단어별로 나누나요?
일반적인 중국어 단어 분할을 제공하지 않습니다. 실제 문장으로 동작을 확인하고, 단어 검색이 필요하면 저장과 질의에 동일한 별도 분할 방식을 적용해야 합니다.
프로젝트 일정에 맞춰 필요한 기간만 이용하세요
임시 빌드, 납품 주간의 추가 용량, 여러 고객 프로젝트를 위한 장비 분리에 적합합니다. 팀의 빌드 작업이 연중 계속되고 이미 보유 장비가 있다면, 구매와 임대 비용을 먼저 비교해 보세요.
VMSSD 클라우드 Mac은 가상 머신이 아닌 독립형 물리 Mac mini를 제공합니다. 일·주·월·분기 단위로 이용 기간을 선택할 수 있으며, 만료 후 연장하지 않으면 서비스가 종료됩니다. 이용 가능한 모델과 노드는 콘솔에서 실시간으로 확인할 수 있습니다.