[Flutter] 크로스플랫폼 노트 앱 개발기 #5 — 검색 기능: SQLite FTS5

크로스플랫폼 노트 앱 개발기 시리즈 #1 프로젝트 설계 | #2 로컬 DB와 CRUD | #3 UI와 4플랫폼 빌드 | #4 3종 에디터 | #5 검색 기능 | #6 테마/설정


#5 — 검색 기능: SQLite FTS5

노트가 10개일 때는 스크롤만으로 충분하지만, 50개, 100개가 넘어가면 원하는 노트를 찾기가 어려워진다. 이번 편에서는 SQLite FTS5(Full-Text Search 5) 엔진을 활용한 전문 검색 기능을 구현한다.




FTS5란 무엇인가

FTS5는 SQLite에 내장된 전문 검색 엔진이다. 별도의 검색 서버(Elasticsearch 등)를 띄울 필요 없이, 앱에 이미 포함된 SQLite만으로 빠른 텍스트 검색을 할 수 있다.

LIKE 검색의 한계

가장 단순한 검색 방법은 LIKE 쿼리다.

SELECT * FROM notes WHERE content LIKE '%검색어%';

이 방식은 동작은 하지만 심각한 문제가 있다.

  • 전체 테이블 스캔: 모든 행의 content 컬럼을 처음부터 끝까지 훑는다. 노트가 1000개면 1000개를 다 읽어야 한다.
  • 인덱스 활용 불가: %로 시작하는 LIKE 패턴은 B-tree 인덱스를 탈 수 없다.
  • 단어 단위 매칭 불가: “Flutter 검색”을 입력하면 “Flutter”와 “검색”이 모두 포함된 노트를 찾고 싶은데, LIKE로는 두 단어를 AND 조건으로 검색하려면 쿼리가 복잡해진다.

FTS5의 동작 방식

FTS5는 역인덱스(Inverted Index)를 사용한다.

일반 테이블:  row → 텍스트 전체
역인덱스:     단어 → [row1, row3, row7, ...]

텍스트를 단어 단위로 분리(토큰화)한 뒤, 각 단어가 어떤 행에 등장하는지를 미리 기록해둔다. 검색 시에는 해당 단어의 인덱스만 조회하면 되므로 테이블 크기에 관계없이 빠르다.

특히 FTS5는 다음을 지원한다:

  • AND 매칭: "Flutter" "검색" — 두 단어가 모두 포함된 행만 반환
  • 접두사 매칭: "Flu"* — “Flutter”, “Fluid” 등
  • unicode61 토크나이저: 한글, 일본어, 영어 등 다국어 단어 분리

이 프로젝트에서는 한글과 영어가 섞인 노트를 검색해야 하므로, unicode61 토크나이저를 사용했다.




search_text_builder — 노트 타입별 검색 텍스트 생성

노트 타입이 3종(plain, markdown, checklist)이므로, 각 타입의 content를 그대로 인덱싱하면 문제가 생긴다.

  • 마크다운: **볼드**, [링크](url), `코드` 같은 문법 기호가 검색에 노이즈를 만든다.
  • 체크리스트: content가 JSON 형태로 저장되어 있어서 그대로 인덱싱하면 {"items":[...]} 같은 JSON 구조가 검색 대상이 된다.

이 문제를 해결하기 위해 순수 텍스트로 변환하는 빌더를 만들었다.

String buildSearchText({
  required NoteType type,
  required String title,
  required String content,
}) {
  switch (type) {
    case NoteType.plain:
      return '$title\n$content';
    case NoteType.markdown:
      return '$title\n${_stripMarkdown(content)}';
    case NoteType.checklist:
      final cl = Checklist.parseContent(content);
      if (cl.items.isEmpty) return title;
      final items = cl.items.map((i) => i.text).join('\n');
      return '$title\n$items';
  }
}

타입별 처리 방식

일반 텍스트(plain): 가공 없이 제목 + 본문을 그대로 사용한다. 이미 검색 가능한 순수 텍스트이기 때문이다.

마크다운(markdown): 문법 기호를 제거하여 자연어 단어만 남긴다.

String _stripMarkdown(String s) {
  return s
      // 코드 블록 제거 (```lang ... ```)
      .replaceAll(RegExp(r'```[\s\S]*?```'), ' ')
      // 인라인 코드 — 백틱만 제거, 내부 텍스트는 유지
      .replaceAllMapped(RegExp(r'`([^`]*)`'), (m) => m[1] ?? '')
      // 링크 — [라벨](url) → 라벨만 남김
      .replaceAllMapped(
          RegExp(r'\[([^\]]*)\]\(([^)]*)\)'), (m) => m[1] ?? '')
      // 강조/헤딩/인용/취소선 마커 제거
      .replaceAll(RegExp(r'[*_~#>]+'), '');
}

예를 들어 **Flutter**로 [노트앱](https://example.com) 만들기Flutter로 노트앱 만들기로 변환된다. URL은 검색 대상에서 제외되고, 사용자가 실제로 검색할 만한 단어만 남는다.

체크리스트(checklist): JSON 구조를 파싱해서 각 항목의 text만 추출한 뒤 줄바꿈으로 합친다.

// content: {"items":[{"id":"1","text":"우유 사기","checked":false,"order":0}, ...]}
// → search_text: "장보기 목록\n우유 사기\n계란 사기\n식빵 사기"

이렇게 하면 체크리스트의 항목 텍스트로 검색이 가능하다. "우유"를 검색하면 해당 체크리스트 노트가 결과에 나온다.

이 함수는 순수 함수(Pure Function)로 설계했다. I/O가 없고, 같은 입력에 대해 항상 같은 출력을 반환한다. 테스트하기 쉽고, isolate에서 실행해도 안전하다.




FTS5 가상 테이블과 트리거

가상 테이블 생성

FTS5는 SQLite의 가상 테이블(Virtual Table) 기능을 사용한다. 일반 테이블과 별도로 역인덱스를 관리하는 테이블을 만든다.

CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts USING fts5(
  id UNINDEXED,
  title,
  search_text,
  content='notes',
  content_rowid='rowid',
  tokenize='unicode61 remove_diacritics 2'
);

각 옵션의 의미:

옵션 설명
id UNINDEXED id는 조인용으로만 저장, 검색 대상에서는 제외
title 제목을 검색 대상으로 인덱싱
search_text 빌더가 생성한 순수 텍스트를 인덱싱
content='notes' 원본 데이터는 notes 테이블에 있음 (content-sync 모드)
content_rowid='rowid' notes 테이블의 rowid와 매핑
tokenize='unicode61 remove_diacritics 2' 유니코드 기반 토큰화, 발음 기호 제거

content='notes' 설정이 핵심이다. 이것은 외부 콘텐츠(External Content) FTS 테이블을 만든다는 의미다. FTS 테이블이 텍스트 원본을 따로 복사하지 않고, notes 테이블의 데이터를 참조한다. 저장 공간을 절약하면서 인덱스만 유지하는 구조다.

자동 동기화 트리거

외부 콘텐츠 모드에서는 notes 테이블이 변경될 때 FTS 인덱스가 자동으로 업데이트되지 않는다. 이를 위해 INSERT, UPDATE, DELETE 트리거를 설정한다.

-- INSERT: 새 노트 추가 시 FTS에도 추가
CREATE TRIGGER IF NOT EXISTS notes_ai AFTER INSERT ON notes BEGIN
  INSERT INTO notes_fts(rowid, id, title, search_text)
  VALUES (new.rowid, new.id, new.title, new.search_text);
END;

-- DELETE: 노트 삭제 시 FTS에서도 제거
CREATE TRIGGER IF NOT EXISTS notes_ad AFTER DELETE ON notes BEGIN
  INSERT INTO notes_fts(notes_fts, rowid, id, title, search_text)
  VALUES ('delete', old.rowid, old.id, old.title, old.search_text);
END;

-- UPDATE: 노트 수정 시 기존 인덱스 삭제 후 새 인덱스 추가
CREATE TRIGGER IF NOT EXISTS notes_au AFTER UPDATE ON notes BEGIN
  INSERT INTO notes_fts(notes_fts, rowid, id, title, search_text)
  VALUES ('delete', old.rowid, old.id, old.title, old.search_text);
  INSERT INTO notes_fts(rowid, id, title, search_text)
  VALUES (new.rowid, new.id, new.title, new.search_text);
END;

FTS5에서 삭제는 특이한 문법을 사용한다. 첫 번째 컬럼에 'delete'라는 문자열을 넣으면 “이 데이터를 인덱스에서 제거하라”는 의미다. UPDATE 트리거는 삭제 후 재삽입하는 방식으로 인덱스를 갱신한다.

DB 마이그레이션에서의 설정

이 FTS 설정은 Drift의 마이그레이션 전략에서 실행된다.

@override
MigrationStrategy get migration => MigrationStrategy(
  onCreate: (m) async {
    await m.createAll();
    await _createFtsAndTriggers(this);
  },
  onUpgrade: (m, from, to) async {
    if (from == 1) {
      await m.addColumn(notes, notes.type);
      await m.addColumn(notes, notes.searchText);
      await customStatement(
        "UPDATE notes SET search_text = title || x'0a' || content",
      );
      await _createFtsAndTriggers(this);
    }
  },
);
  • 신규 설치(onCreate): 테이블 생성 후 FTS 가상 테이블과 트리거를 바로 만든다.
  • 기존 DB 업그레이드(onUpgrade): search_text 컬럼을 추가하고, 기존 노트들의 search_text를 title + 줄바꿈 + content로 채운 뒤, FTS 테이블과 트리거를 생성한다.

마지막으로 INSERT INTO notes_fts(notes_fts) VALUES('rebuild')를 실행해서 전체 인덱스를 한 번 재구축한다. 기존 데이터가 있을 수 있으므로 안전하게 전체 리빌드를 수행하는 것이다.




Repository의 search 메서드

검색 쿼리를 FTS5에 전달하는 Repository 코드를 보자.

@override
Future<List<Note>> search(String query) async {
  final trimmed = query.trim();
  if (trimmed.isEmpty) return const [];

  // 공백으로 분리 → 각 단어를 따옴표로 감쌈 → 공백으로 합침 (AND 의미)
  final escaped = trimmed
      .split(RegExp(r'\s+'))
      .where((w) => w.isNotEmpty)
      .map((w) => '"${w.replaceAll('"', '""')}"')
      .join(' ');

  final rows = await _db.customSelect(
    'SELECT notes.* FROM notes '
    'INNER JOIN notes_fts ON notes_fts.id = notes.id '
    'WHERE notes_fts MATCH ? '
    'ORDER BY notes.updated_at DESC',
    variables: [Variable.withString(escaped)],
    readsFrom: {_db.notes},
  ).get();

  return rows.map((r) => _fromRawRow(r.data)).toList();
}

검색어 처리 과정

사용자가 Flutter 노트를 입력하면 다음과 같이 변환된다:

입력:    "Flutter 노트"
분리:    ["Flutter", "노트"]
이스케이프: ["\"Flutter\"", "\"노트\""]
합침:    "\"Flutter\" \"노트\""

FTS5에서 공백으로 구분된 따옴표 묶인 단어들은 AND 조건으로 동작한다. “Flutter”와 “노트”가 모두 포함된 노트만 반환된다.

각 단어를 따옴표(")로 감싸는 이유는 FTS5의 특수 문자를 이스케이프하기 위해서다. 사용자가 입력한 텍스트에 *, (, ) 같은 FTS5 문법 문자가 포함되어 있어도 안전하게 리터럴 문자열로 처리된다.

notes 테이블과 JOIN

SELECT notes.* FROM notes
INNER JOIN notes_fts ON notes_fts.id = notes.id
WHERE notes_fts MATCH ?
ORDER BY notes.updated_at DESC

FTS 가상 테이블에서 매칭되는 노트 ID를 찾고, 원본 notes 테이블과 JOIN해서 전체 노트 데이터를 가져온다. 정렬은 updated_at 기준 내림차순이므로, 최근에 수정한 노트가 먼저 나온다.

save 시 search_text 자동 갱신

노트를 저장할 때 buildSearchText를 호출해서 search_text 컬럼을 함께 갱신한다.

@override
Future<void> save(Note note) async {
  final searchText = buildSearchText(
    type: note.type,
    title: note.title,
    content: note.content,
  );
  await _db.into(_db.notes).insertOnConflictUpdate(
    NotesCompanion(
      // ...
      searchText: Value(searchText),
      // ...
    ),
  );
}

노트가 저장되면 → notes 테이블의 search_text가 갱신되고 → UPDATE 트리거가 발동해서 → FTS 인덱스가 자동 업데이트된다. 별도의 인덱스 갱신 코드를 작성할 필요가 없다.




디바운스 검색 UI

검색창에 글자를 입력할 때마다 DB 쿼리를 날리면 비효율적이다. 사용자가 “Flutter”를 타이핑하는 동안 “F”, “Fl”, “Flu”, “Flut”, “Flutt”, “Flutte”, “Flutter” 총 7번의 쿼리가 실행된다. 이 중 의미 있는 쿼리는 마지막 하나뿐이다.

이 문제를 디바운스(debounce)로 해결했다. 입력이 멈춘 후 300ms가 지나야 실제 검색을 실행한다.

SearchController

@riverpod
class SearchController extends _$SearchController {
  Timer? _debounce;
  bool _disposed = false;
  static const _debounceDuration = Duration(milliseconds: 300);

  @override
  AsyncValue<List<Note>> build() {
    ref.onDispose(() {
      _disposed = true;
      _debounce?.cancel();
    });
    return const AsyncData(<Note>[]);
  }

  void query(String raw) {
    _debounce?.cancel();
    final trimmed = raw.trim();
    if (trimmed.isEmpty) {
      state = const AsyncData(<Note>[]);
      return;
    }
    state = const AsyncLoading();
    _debounce = Timer(_debounceDuration, () => _run(trimmed));
  }

  Future<void> _run(String trimmed) async {
    try {
      final repo = ref.read(noteRepositoryProvider);
      final results = await repo.search(trimmed);
      if (_disposed) return;
      state = AsyncData(results);
    } catch (err, st) {
      if (_disposed) return;
      state = AsyncError(err, st);
    }
  }
}

디바운스 흐름을 타임라인으로 보면:

사용자 입력:  F ─ l ─ u ─ t ─ t ─ e ─ r ─── (멈춤)
타이머:       [취소][취소][취소][취소][취소][취소][300ms 대기]
쿼리:                                              → search("Flutter") 실행

query()가 호출될 때마다 기존 타이머를 취소하고 새 타이머를 건다. 300ms 동안 추가 입력이 없으면 _run()이 실행된다. 결과적으로 불필요한 쿼리를 제거하고, 사용자가 타이핑을 마친 시점에만 검색이 동작한다.

_disposed 플래그도 중요하다. 사용자가 검색 화면을 벗어났는데 타이머 콜백이 뒤늦게 실행되면, 이미 dispose된 컨트롤러의 state를 업데이트하게 된다. _disposed 체크로 이런 상황을 방지한다.

SearchScreen

class SearchScreen extends ConsumerStatefulWidget {
  const SearchScreen({super.key});

  @override
  ConsumerState<SearchScreen> createState() => _SearchScreenState();
}

class _SearchScreenState extends ConsumerState<SearchScreen> {
  final _ctrl = TextEditingController();

  @override
  Widget build(BuildContext context) {
    final state = ref.watch(searchControllerProvider);

    return Scaffold(
      appBar: AppBar(
        title: TextField(
          controller: _ctrl,
          autofocus: true,
          decoration: const InputDecoration(
            hintText: '제목 또는 본문 검색',
            border: InputBorder.none,
          ),
          onChanged: (v) =>
              ref.read(searchControllerProvider.notifier).query(v),
        ),
        actions: [
          IconButton(
            tooltip: '지우기',
            icon: const Icon(Icons.clear),
            onPressed: () {
              _ctrl.clear();
              ref.read(searchControllerProvider.notifier).query('');
            },
          ),
        ],
      ),
      body: state.when(
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (e, _) => Center(child: Text('오류: $e')),
        data: (results) {
          if (_ctrl.text.trim().isEmpty) {
            return const _SearchEmptyState();
          }
          if (results.isEmpty) {
            return const _NoResults();
          }
          return ListView.builder(
            itemCount: results.length,
            itemBuilder: (context, i) => _ResultTile(note: results[i]),
          );
        },
      ),
    );
  }
}

UI 구성은 단순하다:

  • AppBar에 TextField 삽입: 검색 전용 화면이므로 제목 대신 검색창을 배치했다. autofocus: true로 화면 진입 시 바로 키보드가 올라온다.
  • onChanged → query(): 글자가 변경될 때마다 컨트롤러의 query()를 호출한다. 실제 검색은 컨트롤러의 디바운스 로직이 담당한다.
  • 상태별 분기: AsyncValuewhen으로 로딩/에러/데이터 상태를 분기한다.
    • 검색어가 비어있으면 “검색어를 입력하세요” 안내
    • 결과가 없으면 “검색 결과가 없습니다” 표시
    • 결과가 있으면 ListView로 노트 목록 표시

각 검색 결과 타일은 노트 타입에 따라 아이콘이 다르게 표시된다. plain은 메모 아이콘, markdown은 코드 아이콘, checklist는 체크리스트 아이콘이다. 탭하면 해당 노트의 에디터 화면으로 이동한다.




이번 편 정리

항목 내용
FTS5 SQLite 내장 전문 검색 엔진. 역인덱스로 LIKE 대비 빠른 검색
search_text_builder 노트 타입별로 순수 텍스트 변환 (마크다운 문법 제거, 체크리스트 JSON 파싱)
외부 콘텐츠 모드 FTS 테이블이 원본을 복사하지 않고 notes 테이블을 참조
트리거 INSERT/UPDATE/DELETE 시 FTS 인덱스 자동 동기화
AND 매칭 검색어를 공백 분리 후 따옴표로 감싸서 모든 단어 포함 조건
디바운스 300ms 타이머로 불필요한 쿼리 제거
SearchScreen AppBar 내 검색창, AsyncValue 상태 분기, 타입별 아이콘

FTS5 덕분에 노트가 수천 개가 되어도 검색 성능 걱정 없이 빠르게 결과를 받아볼 수 있다. 별도의 검색 서버 없이 로컬 SQLite만으로 전문 검색을 구현할 수 있다는 점이 오프라인 우선 앱에 딱 맞는 선택이었다.

다음 편에서는 다크/라이트 테마, 폰트 크기 설정, 그리고 2차 개발 마무리를 다룬다.



이전: #4 3종 에디터 — Plain, Markdown, Checklist

다음: #6 테마, 설정, 2차 개발 마무리