Wails 실전 강좌 #3 전문 검색과 데이터 흐름 — FTS5와 이벤트 기반 갱신
#2에서 노트를 SQLite에 저장했습니다. 노트가 수백 건으로 늘면 LIKE '%검색어%'로는 느려지고 정확도도 떨어집니다. 이번 글은 SQLite에 내장된 전문 검색 엔진 FTS5로 빠른 검색을 붙이고, 데이터가 바뀔 때 프론트엔드를 어떻게 갱신할지 데이터 흐름을 정리합니다.
총 10편(2부 구성) 중 세 번째 글입니다.
- #1 실전 프로젝트 설계
- #2 SQLite 로컬 데이터베이스
- #3 전문 검색과 데이터 흐름 — FTS5와 이벤트 기반 갱신 ← 이번 글
- #4 트레이 상주와 전역 단축키 — 백그라운드에서 빠르게 캡처
- #5 서명과 공증 — 배포된 앱이 신뢰받는 법
- #6 CI/CD 자동 릴리스 — GitHub Actions로 세 플랫폼 배포
왜 LIKE가 아니라 FTS5인가 #
LIKE '%단어%' 검색은 매번 모든 행의 본문을 처음부터 훑습니다. 노트가 적을 때는 괜찮지만 늘어나면 선형으로 느려지고, 단어 단위 매칭이나 여러 단어 조합 같은 것도 직접 만들어야 합니다. FTS5는 SQLite에 내장된 전문 검색 확장으로, 본문을 토큰으로 쪼개 인덱스를 미리 만들어 두므로 검색이 빠르고 단어 기반 질의를 그대로 지원합니다. #2에서 쓴 순수 Go 드라이버 modernc.org/sqlite도 FTS5를 포함하므로 별도 설정 없이 바로 쓸 수 있습니다.
마이그레이션: FTS5 테이블과 동기화 트리거 #
#2의 user_version 마이그레이션에 다음 단계를 이어 붙입니다. FTS5 가상 테이블을 만들고, 원본 notes 테이블의 변경이 검색 인덱스에 자동으로 반영되도록 트리거를 겁니다.
-- 검색 인덱스용 가상 테이블 (title, body만 색인)
CREATE VIRTUAL TABLE notes_fts USING fts5(
title, body, content='notes', content_rowid='id'
);
-- notes가 바뀌면 인덱스도 따라 바뀌도록 트리거로 묶는다
CREATE TRIGGER notes_ai AFTER INSERT ON notes BEGIN
INSERT INTO notes_fts(rowid, title, body) VALUES (new.id, new.title, new.body);
END;
CREATE TRIGGER notes_ad AFTER DELETE ON notes BEGIN
INSERT INTO notes_fts(notes_fts, rowid, title, body) VALUES ('delete', old.id, old.title, old.body);
END;
CREATE TRIGGER notes_au AFTER UPDATE ON notes BEGIN
INSERT INTO notes_fts(notes_fts, rowid, title, body) VALUES ('delete', old.id, old.title, old.body);
INSERT INTO notes_fts(rowid, title, body) VALUES (new.id, new.title, new.body);
END;핵심은 content='notes' 설정입니다. 이렇게 하면 FTS5가 본문을 중복 저장하지 않고 원본 테이블을 참조합니다(외부 콘텐츠 방식). 트리거 세 개(삽입·삭제·수정)가 원본과 인덱스를 항상 같은 상태로 유지하므로, 저장소의 CRUD 코드는 인덱스를 신경 쓸 필요가 없습니다. INSERT ... VALUES ('delete', ...)는 FTS5에서 인덱스 항목을 지우는 관용 구문입니다.
검색을 서비스로 노출 #
저장소에 검색 메서드를 추가합니다. FTS5 테이블을 원본과 조인해 매칭된 노트를 최신순으로 돌려줍니다.
func (r *NoteRepository) Search(query string) ([]Note, error) {
rows, err := r.db.Query(`
SELECT n.id, n.title, n.body, n.created_at, n.updated_at
FROM notes_fts f
JOIN notes n ON n.id = f.rowid
WHERE notes_fts MATCH ?
ORDER BY n.updated_at DESC
`, query)
if err != nil {
return nil, err
}
defer rows.Close()
return scanNotes(rows) // #2의 스캔 로직을 함수로 뽑아 재사용
}서비스 층에서는 빈 검색어를 걸러 전체 목록으로 넘기고, 사용자 입력을 FTS5 질의로 안전하게 다듬습니다.
func (s *NoteService) Search(query string) ([]Note, error) {
query = strings.TrimSpace(query)
if query == "" {
return s.repo.List() // 검색어가 없으면 전체 목록
}
// 입력한 단어로 접두 검색: "회의" → "회의"* 로 "회의록"도 매칭
return s.repo.Search(query + "*")
}검색어가 비면 검색이 아니라 전체 목록을 돌려주는 이 분기 하나가, 프론트엔드에서 “검색창을 지우면 원래 목록으로 돌아온다"는 자연스러운 동작을 만듭니다.
데이터 흐름: 이벤트로 프론트엔드를 갱신 #
#1에서 정한 원칙, 곧 데이터의 진실은 Go에 둔다를 검색과 편집에서 지키는 방법이 데이터 흐름 설계입니다. 노트를 새로 만들거나 지우면 목록이 바뀌는데, 이때 두 가지 길이 있습니다.
- 프론트엔드가 스스로 목록을 고친다: 방금 만든 노트를 화면의 배열에 직접 밀어 넣습니다. 빠르지만, Go의 실제 상태와 화면이 어긋날 위험이 있습니다.
- Go가 바뀌었다고 알리고, 프론트엔드는 다시 물어본다: 변경 후 입문 #3에서 다룬 Wails 이벤트를 쏘고, 프론트엔드는 그 신호를 받아 목록을 다시 불러옵니다.
실전에서는 두 번째가 안전합니다. 화면은 항상 Go의 최신 상태를 반영하고, 나중에 #4에서 트레이나 전역 단축키로 앱 바깥에서 노트가 추가되어도 같은 이벤트 한 줄로 모든 창이 갱신됩니다.
func (a *App) CreateNote(title, body string) (Note, error) {
note, err := a.notes.Create(title, body)
if err != nil {
return Note{}, err
}
runtime.EventsEmit(a.ctx, "notes:changed") // 목록이 바뀌었음을 알린다
return note, nil
}import { EventsOn } from "../wailsjs/runtime/runtime";
import { Search } from "../wailsjs/go/main/App";
EventsOn("notes:changed", async () => {
notes = await Search(currentQuery); // 현재 검색어 기준으로 다시 조회
});빠른 입력감이 중요한 곳(예: 편집기의 저장 표시)은 화면을 먼저 낙관적으로 바꾸고, 실패 시 이벤트로 되돌리는 절충도 가능합니다. 다만 목록·검색 결과처럼 정합성이 중요한 데이터는 Go에 다시 물어보는 쪽을 기본으로 둡니다.
정리 #
LIKE검색은 노트가 늘면 선형으로 느려집니다. SQLite 내장 FTS5로 미리 만든 인덱스를 써서 빠른 단어 기반 검색을 붙입니다. 순수 Go 드라이버도 FTS5를 포함합니다.- FTS5는
content='notes'외부 콘텐츠 방식으로 본문 중복을 피하고, 삽입·삭제·수정 트리거로 원본과 인덱스를 자동 동기화합니다. CRUD 코드는 인덱스를 몰라도 됩니다. - 서비스 층에서 빈 검색어를 전체 목록으로 분기하고, 접두 검색(
단어*)으로 부분 매칭을 지원합니다. - 데이터 흐름은 “Go가 바뀌었다고 이벤트로 알리고 프론트엔드가 다시 물어본다"를 기본으로 합니다. 진실이 Go에 있으니 앱 바깥에서 데이터가 바뀌어도 이벤트 한 줄로 모든 창이 갱신됩니다.
- 다음 글에서 이 이벤트 갱신 위에 트레이 상주와 전역 단축키를 얹어 앱 바깥에서 노트를 캡처합니다.