본문 바로가기

Flutter Todo App 프로젝트 정리(FTS 검색) - 4탄(Drift FTS 전문 검색 구현 가이드)

ironwhale 2025. 7. 14.

여러분의 Flutter 앱에서 검색 기능이 느려서 사용자가 짜증을 내본 적이 있나요?

일반적인 LIKE '%검색어%' 쿼리는 데이터가 늘어날수록 기하급수적으로 느려집니다. 1만 건의 할일 데이터에서 검색할 때마다 3-5초씩 기다려야 한다면, 사용자는 금세 앱을 삭제해버릴 것입니다.

하지만 SQLite FTS5(Full-Text Search)를 도입하면 이런 문제가 완전히 해결됩니다. 같은 데이터에서 검색 시간을 0.1초 이내로 단축시킬 수 있고, 심지어 관련도 순 정렬까지 자동으로 제공됩니다.

이번 4탄에서는 실제 프로덕션 환경에서 검증된 FTS5 전문 검색 구현 방법을 단계별로 공유하겠습니다. 복잡해 보이는 트리거와 마이그레이션 과정을 쉽게 따라할 수 있도록 상세한 코드와 함께 설명드리겠습니다.

🔍 FTS5 전문 검색의 필요성

사용 이유

성능 향상: 일반 LIKE 검색 대비 10-100배 빠른 검색 속도를 제공합니다. 특히 대용량 텍스트 데이터에서 현저한 차이를 보입니다.

한글 검색 최적화: 한글의 복잡한 문자 구조(초성, 중성, 종성)를 고려한 효율적인 인덱싱으로 정확한 검색 결과를 제공합니다.

관련도 순 정렬: BM25 알고리즘을 기반으로 한 검색 결과 랭킹을 통해 가장 관련성 높은 결과를 우선 표시합니다.

자동 동기화: 트리거를 통해 원본 테이블 변경 시 FTS 인덱스가 자동으로 업데이트되어 데이터 일관성을 보장합니다.

부분 검색 지원: 단어의 앞부분만으로도 검색이 가능하여 사용자 경험을 향상시킵니다.

기존 검색 방식의 한계

일반적인 SQL의 LIKE 검색은 다음과 같은 문제점을 가집니다:

  • 대용량 데이터에서 느린 검색 속도
  • 한글 문자의 복잡한 구조 처리 어려움
  • 검색 결과의 관련도 순 정렬 불가
  • 부분 검색 시 인덱스 활용 제한

FTS5의 장점

성능 비교 예시

-- 기존 LIKE 검색 (느림)
SELECT * FROM todo_entries WHERE description LIKE '%검색어%';

-- FTS5 검색 (빠름)
SELECT * FROM todo_entries_fts WHERE todo_entries_fts MATCH '검색어';

 

💡 : 1만 건 이상의 데이터에서 FTS5는 LIKE 검색 대비 평균 20-50배 빠른 성능을 보입니다.

 

🔧 FTS5 가상 테이블 구현

sql.drift 파일 생성

FTS5 가상 테이블과 트리거를 정의하는 SQL 파일을 생성합니다.

import 'tables.dart';

CREATE VIRTUAL TABLE todo_entries_fts USING fts5(description, content=todo_entries, content_rowid=id);

CREATE TRIGGER todo_entries_insert AFTER INSERT ON todo_entries
BEGIN
  INSERT INTO todo_entries_fts (rowid, description)
  VALUES (new.id, new.description);
END;

CREATE TRIGGER todo_entries_update AFTER UPDATE ON todo_entries
BEGIN
  INSERT INTO todo_entries_fts (todo_entries_fts,rowid, description)
  VALUES ('delete',old.id, old.description);
  INSERT INTO todo_entries_fts (rowid, description)
  VALUES (new.id, new.description);
END;

CREATE TRIGGER todo_entries_delete AFTER DELETE ON todo_entries
BEGIN
  INSERT INTO todo_entries_fts (todo_entries_fts,rowid, description)
  VALUES ('delete',old.id, old.description);
END;

search: SELECT cat.**, todo.** FROM  todo_entries_fts
        INNER JOIN todo_entries todo ON todo.id = todo_entries_fts.rowid
        LEFT OUTER JOIN categories cat ON cat.id = todo.category
        WHERE todo_entries_fts MATCH :query ORDER BY rank;

코드 설명:

  • CREATE VIRTUAL TABLE todo_entries_fts: FTS5 가상 테이블 생성으로 전문 검색 기능을 제공합니다.
  • content=todo_entries, content_rowid=id: 원본 테이블과 연결을 위한 설정입니다.
  • description: 검색 대상 컬럼을 지정합니다.

트리거 상세 설명

INSERT 트리거:

  • AFTER INSERT ON todo_entries: 원본 테이블에 새 데이터가 추가된 후 실행됩니다.
  • new.id, new.description: 새로 추가된 데이터의 ID와 설명을 FTS 테이블에 동기화합니다.
  • 목적: 실시간으로 새로운 데이터를 검색 가능하게 만듭니다.

UPDATE 트리거:

  • VALUES ('delete',old.id, old.description): 기존 데이터를 FTS 테이블에서 삭제합니다.
  • VALUES (new.id, new.description): 수정된 새 데이터를 FTS 테이블에 추가합니다.
  • 목적: 데이터 수정 시 FTS 인덱스를 정확하게 업데이트합니다.
  • 삭제 후 추가하는 이유: FTS5는 일반 테이블과 달리 직접적인 UPDATE 연산을 지원하지 않습니다. 기존 인덱스 엔트리를 삭제하고 새로운 엔트리를 추가하는 방식으로만 내용을 변경할 수 있습니다. 이는 FTS5가 전문 검색을 위한 특별한 인덱스 구조를 사용하기 때문입니다.

DELETE 트리거:

  • VALUES ('delete',old.id, old.description): 삭제된 데이터를 FTS 테이블에서도 제거합니다.
  • 목적: 원본 테이블에서 삭제된 데이터가 검색 결과에 나타나지 않도록 합니다.

검색 쿼리 분석

search: SELECT cat.**, todo.** FROM  todo_entries_fts
        INNER JOIN todo_entries todo ON todo.id = todo_entries_fts.rowid
        LEFT OUTER JOIN categories cat ON cat.id = todo.category
        WHERE todo_entries_fts MATCH :query ORDER BY rank;
  • cat.**, todo.**: 카테고리와 할일 테이블의 모든 컬럼을 조회합니다.
  • INNER JOIN todo_entries todo ON todo.id = todo_entries_fts.rowid: FTS 테이블과 원본 할일 테이블을 ID로 연결합니다.
  • LEFT OUTER JOIN categories cat: 카테고리가 없는 할일도 포함하여 조회합니다.
  • WHERE todo_entries_fts MATCH :query: FTS5의 전문 검색 문법을 사용합니다.
  • ORDER BY rank: 검색 관련도가 높은 순서대로 정렬합니다.
  • 매개변수: :query는 검색어 문자열을 받습니다.
  • 반환값: 검색된 할일과 연관된 카테고리 정보를 포함한 결과를 반환합니다.

🔄 데이터베이스 설정 및 등록

사용 이유

코드 생성을 통해 타입 안전성을 보장하고 외부 SQL 파일로 복잡한 쿼리를 분리합니다. 어노테이션 기반 설정으로 컴파일 타임에 오류를 검출하고, 복잡한 FTS 쿼리를 별도 파일로 관리하여 유지보수성을 향상시킵니다.

Drift 데이터베이스 구성 방식

AppDatabase 클래스 구성

part 'database.g.dart';

@Riverpod(keepAlive: true)
AppDatabase database(Ref ref) {
  return AppDatabase();
}

@DriftDatabase(tables: [Categories, TodoEntries], include: {'sql.drift'})
class AppDatabase extends _$AppDatabase {
  AppDatabase()
    : super(
        LazyDatabase(() async {
          final file = await getDbFile;
          return NativeDatabase.createInBackground(file);
        }),
      );

  @override
  int get schemaVersion => 3;

  // 마이그레이션 전략 구현...
}

코드 설명:

  • @DriftDatabase: 테이블과 외부 SQL 파일을 포함하여 데이터베이스를 정의합니다.
  • include: {'sql.drift'}: 외부 SQL 파일을 데이터베이스에 포함시킵니다.
  • schemaVersion => 3: FTS 기능 추가로 스키마 버전을 업그레이드합니다.

🚀 코드 생성 및 마이그레이션

사용 이유

기존 사용자의 데이터를 보존하면서 새로운 FTS 기능을 안전하게 추가합니다. 프로덕션 환경에서 데이터 손실 없이 스키마를 업그레이드하고, 기존 할일 데이터를 FTS 테이블에 자동으로 초기화하여 즉시 검색 가능한 상태로 만듭니다.

마이그레이션의 중요성

구현 단계

1단계: 코드 생성 실행

dart run build_runner build

2단계: 마이그레이션 파일 생성

dart run drift_dev make-migrations

3단계: 마이그레이션 전략 구현

@override
MigrationStrategy get migration {
  return MigrationStrategy(
    onUpgrade: stepByStep(
      from1To2: (m, schema) async {
        logger.d('from1To2');
        await m.addColumn(schema.todoEntries, schema.todoEntries.category);
        await m.alterTable(TableMigration(schema.todoEntries));
        logger.d('from1To2 end');
      },
      from2To3: (Migrator m, Schema3 schema) async {
        logger.d('from2To3 ');
        await m.createTable(schema.todoEntriesFts);
        await customStatement(
          'INSERT INTO todo_entries_fts(rowid,description) SELECT id, description FROM todo_entries;',
        );
        await m.create(schema.todoEntriesInsert);
        await m.create(schema.todoEntriesDelete);
        await m.create(schema.todoEntriesUpdate);
        logger.d('from2To3 end');
      },
    ),
  );
}

코드 설명:

  • stepByStep: 단계별 마이그레이션을 안전하게 실행합니다.
  • from2To3: 버전 2에서 3으로의 마이그레이션 로직을 정의합니다.
  • m.createTable(schema.todoEntriesFts): FTS 가상 테이블을 생성합니다.
  • customStatement: 기존 데이터를 FTS 테이블에 초기 로드합니다.
  • m.create: 각 트리거를 데이터베이스에 생성합니다.

📊 FTS 검색 활용

사용 이유

빠른 텍스트 검색과 함께 관련 카테고리 정보까지 한 번에 조회할 수 있습니다.

검색 쿼리 사용

// 전문 검색 실행 예시
Future<List<SearchResult>> searchTodos(String query) async {
  return await search(query).get();
}

// 카테고리와 함께 검색 결과 반환
Future<List<TodoWithCategory>> getSearchResults(String searchTerm) async {
  final results = await searchTodos(searchTerm);
  return results.map((result) => TodoWithCategory(
    todo: result.todo,
    category: result.cat,
  )).toList();
}

코드 설명:

  • search(query).get(): sql.drift에서 정의한 search 쿼리를 실행합니다.
  • MATCH :query: FTS5의 전문 검색 문법을 사용합니다.
  • ORDER BY rank: 검색 관련도에 따른 정렬을 제공합니다.

🔍 트리거 동작 원리

사용 이유

개발자가 별도로 FTS 테이블을 관리할 필요 없이 자동 동기화됩니다.

데이터 동기화 메커니즘

-- INSERT 트리거: 새 데이터 추가 시
CREATE TRIGGER todo_entries_insert AFTER INSERT ON todo_entries
BEGIN
  INSERT INTO todo_entries_fts (rowid, description)
  VALUES (new.id, new.description);
END;

-- UPDATE 트리거: 데이터 수정 시 (기존 삭제 후 새 데이터 추가)
CREATE TRIGGER todo_entries_update AFTER UPDATE ON todo_entries
BEGIN
  INSERT INTO todo_entries_fts (todo_entries_fts,rowid, description)
  VALUES ('delete',old.id, old.description);
  INSERT INTO todo_entries_fts (rowid, description)
  VALUES (new.id, new.description);
END;

코드 설명:

  • AFTER INSERT/UPDATE/DELETE: 원본 테이블 변경 후 FTS 테이블을 자동 업데이트합니다.
  • VALUES ('delete',old.id, old.description): FTS5의 특별한 삭제 문법입니다.
  • new.id, new.description: 새로운 데이터를 FTS 테이블에 추가합니다.

 

⚠️ 주의사항: FTS5는 SQLite 3.20.0 이상에서만 사용 가능합니다. 이전 버전에서는 FTS4를 사용해야 합니다.

 

 

💡 : 한글 검색의 경우 토크나이저를 별도로 설정하지 않으면 음절 단위로 검색됩니다. 더 정확한 검색을 원한다면 별도의 토크나이저 플러그인을 고려해보세요.

 

📋 구현 체크리스트

FTS 구현 완료 확인

  • sql.drift 파일 생성 및 FTS 테이블 정의
  • 트리거 3개 (INSERT, UPDATE, DELETE) 정의
  • 검색 쿼리 작성 및 테스트
  • 데이터베이스 클래스에 include 추가
  • 스키마 버전 업그레이드
  • 마이그레이션 전략 구현
  • 코드 생성 및 마이그레이션 실행
  • 기존 데이터 FTS 테이블 초기 로드
  • 검색 기능 테스트

성능 최적화 확인

  • 검색 쿼리 성능 테스트
  • 대용량 데이터에서의 동기화 성능 확인
  • 인덱스 및 랭킹 최적화 검토

💡 학습 정리 및 핵심 포인트

🔧 사용 기술 스택

  • Flutter: 크로스 플랫폼 모바일 앱 개발
  • Drift: SQLite ORM 라이브러리 (FTS5 전문 검색 지원)
  • SQLite: 로컬 데이터베이스 (FTS5 가상 테이블 지원)
  • FTS5: 전문 검색 기능 구현
  • SQL 트리거: 자동 데이터 동기화
  • Riverpod: 상태 관리 라이브러리
  • Build Runner: 코드 생성 및 빌드 도구

🚀 주요 구현 기능

  • ✅ FTS5 전문 검색 구현
  • ✅ 가상 테이블 생성 및 관리
  • ✅ 트리거 기반 자동 데이터 동기화
  • ✅ 검색 결과 관련도 순 정렬
  • ✅ 한글 검색 최적화
  • ✅ 실시간 검색 인덱스 업데이트
  • ✅ 조인 쿼리 기반 복합 검색
  • ✅ 데이터베이스 마이그레이션 지원
  • ✅ 코드 생성 기반 타입 안전성

🎯 핵심 학습 포인트

  1. FTS5의 성능 우위: LIKE 검색 대비 10-100배 빠른 검색 속도
  2. 가상 테이블 개념: 물리적 테이블과 연결된 검색 전용 테이블
  3. 트리거 동작 원리: INSERT/UPDATE/DELETE 시 자동 FTS 인덱스 동기화
  4. UPDATE 트리거 특이점: 삭제 후 추가 방식으로만 데이터 변경 가능
  5. BM25 알고리즘: 검색 결과 관련도 순 정렬 메커니즘
  6. 외부 SQL 파일 관리: 복잡한 쿼리를 별도 파일로 분리
  7. 마이그레이션 전략: 기존 데이터 보존하며 FTS 기능 추가
  8. 코드 생성 활용: 컴파일 타임 타입 안전성 확보
  9. 한글 검색 최적화: 음절 단위 검색 및 토크나이저 고려사항
  10. 메모리 기반 저장: 브라우저 환경에서의 저장소 제약사항

🏗️ 아키텍처 패턴

Virtual Table Pattern

FTS5 가상 테이블을 활용한 검색 기능 분리

Trigger Pattern

자동 데이터 동기화를 위한 트리거 활용

Repository Pattern

데이터 액세스 계층 추상화

Code Generation Pattern

컴파일 타임 코드 생성을 통한 안전성 확보

Migration Pattern

안전한 스키마 변경 전략

📊 데이터 흐름 관리

  • 자동 동기화: 원본 테이블 변경 시 트리거를 통한 FTS 테이블 자동 업데이트
  • 실시간 검색: 데이터 변경사항이 즉시 검색 결과에 반영
  • 관련도 순 정렬: BM25 알고리즘 기반 검색 결과 랭킹
  • 조인 기반 검색: 복합 테이블 정보를 포함한 검색 결과 제공

🎨 FTS 구현 특징

  • 고성능 검색: 대용량 데이터에서도 빠른 검색 응답
  • 한글 지원: 한글 문자의 복잡한 구조를 고려한 검색
  • 부분 검색: 단어 앞부분만으로도 검색 가능
  • 자동 인덱싱: 데이터 변경 시 자동으로 검색 인덱스 갱신
  • 외부 쿼리 관리: 복잡한 검색 쿼리를 별도 파일로 관리

🔧 핵심 구현 패턴

1. FTS5 가상 테이블 생성 패턴

CREATE VIRTUAL TABLE todo_entries_fts USING fts5(
    description, 
    content=todo_entries, 
    content_rowid=id
);

2. 트리거 기반 자동 동기화 패턴

CREATE TRIGGER todo_entries_insert AFTER INSERT ON todo_entries
BEGIN
  INSERT INTO todo_entries_fts (rowid, description)
  VALUES (new.id, new.description);
END;

3. UPDATE 트리거 삭제 후 추가 패턴

CREATE TRIGGER todo_entries_update AFTER UPDATE ON todo_entries
BEGIN
  INSERT INTO todo_entries_fts (todo_entries_fts,rowid, description)
  VALUES ('delete',old.id, old.description);
  INSERT INTO todo_entries_fts (rowid, description)
  VALUES (new.id, new.description);
END;

4. 조인 기반 검색 쿼리 패턴

search: SELECT cat.**, todo.** FROM todo_entries_fts
        INNER JOIN todo_entries todo ON todo.id = todo_entries_fts.rowid
        LEFT OUTER JOIN categories cat ON cat.id = todo.category
        WHERE todo_entries_fts MATCH :query ORDER BY rank;

5. 마이그레이션 패턴

from2To3: (Migrator m, Schema3 schema) async {
  await m.createTable(schema.todoEntriesFts);
  await customStatement(
    'INSERT INTO todo_entries_fts(rowid,description) SELECT id, description FROM todo_entries;',
  );
  await m.create(schema.todoEntriesInsert);
  await m.create(schema.todoEntriesUpdate);
  await m.create(schema.todoEntriesDelete);
}

6. ProviderScope 오버라이드 패턴

ProviderScope(
  overrides: [
    currentTodoProvider.overrideWithValue(datas[index]),
  ],
  child: TodoCard(),
)

7. AsyncValue 상태 처리 패턴

todoWithCategory.when(
  data: (datas) => ListView.builder(...),
  error: (_, _) => ErrorWidget(...),
  loading: () => CircularProgressIndicator(),
)

8. 메모리 관리 패턴

@override
void dispose() {
  controller.dispose();
  super.dispose();
}

9. 스마트 날짜 선택 패턴

final firstDate = initialDate.isBefore(now) ? initialDate : now;

📈 성능 최적화 팁

  1. 인덱스 관리: 필요한 컬럼만 FTS 테이블에 포함
  2. 쿼리 최적화: MATCH 연산자 적극 활용
  3. 메모리 효율: 대용량 데이터 처리 시 페이지네이션 고려
  4. 한글 검색: 적절한 토크나이저 설정으로 검색 정확도 향상

🚨 주의사항

  • UPDATE 트리거: 반드시 삭제 후 추가 방식으로 구현
  • 데이터 일관성: 트리거 실행 실패 시 롤백 처리 필요
  • 메모리 제약: 브라우저 환경에서는 localStorage 사용 불가
  • 마이그레이션: 기존 데이터 보존을 위한 단계별 진행

🔍 추가 학습 자료

댓글