나는 이렇게 논다/Flutter 로 간단한 노트 앱을 만들어보자

Flutter로 간단한 노트 앱을 만들어보자 ⑤ - 노트 검색 기능 구현 (유사도 알고리즘을 곁들인)

daco2020 2024. 11. 7. 14:00
반응형

이번 글에서는 노트 목록 화면에서 검색 기능을 추가하는 과정을 보여드리겠습니다. 검색어를 입력하면 해당 키워드, 혹은 유사한 키워드를 포함한 노트를 즉시 필터링하여 가져옵니다.

 

검색 기능의 결과는 다음과 같습니다.

 

- 기존 노트 목록 화면에 2개의 노트가 있습니다.

- `안녕` 이라는 검색어를 입력하면 `안녕하세요` 노트만 표시됩니다.

- `안녕하파트~` 라는 검색어를 입력하면 `아파트~ 아파트~` 노트와 `안녕하세요` 노트가 함께 표시됩니다.

 

세 번째 이미지처럼 검색어가 정확히 일치하지 않아도 검색되는 이유는 유사도 알고리즘을 적용했기 때문입니다.

 

 

주요 구현 내용

1. 검색 바 UI 추가: TextField를 이용하여 상단에 검색 바를 추가합니다.

2. 검색 로직: 검색어를 입력할 때마다 노트 리스트를 필터링하고 결과를 리스트로 표시합니다.

3. 검색 결과 UI: 필터링된 결과를 업데이트하여 검색 결과와 일치하는 노트를 보여줍니다.

4. 유사도 알고리즘 적용: 유사도 알고리즘을 적용하여 검색어와 유사한 노트도 함께 보여줍니다.

 

 

 

구현 1. 검색 바 UI 추가

노트 목록 화면 상단에 검색 바 UI를 추가했습니다. TextField를 사용해 스타일링하고, 검색어 입력 시 onChanged 콜백을 통해 실시간으로 _searchQuery 상태를 업데이트합니다. 

// lib/views/note_list_view.dart
final TextEditingController _searchController = TextEditingController();
String _searchQuery = '';

 

검색어가 들어간 _searchQuery 는 이후 노트를 필터링할 때 사용합니다.

 

 

검색 바 UI는 Container로 감싸고, TextField 내부에 검색 아이콘과 삭제 아이콘을 배치해 사용자가 검색어를 입력하거나 초기화할 수 있도록 합니다.

// lib/views/note_list_view.dart
Container(
  margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
  decoration: BoxDecoration(
    color: Colors.grey[900],
    borderRadius: BorderRadius.circular(12.0),
    boxShadow: [
      BoxShadow(
        color: Colors.black.withOpacity(0.2),
        blurRadius: 8,
        offset: const Offset(0, 2),
      ),
    ],
  ),
  child: TextField(
    controller: _searchController,
    style: TextStyle(color: ColorPalette.textWhite),
    decoration: InputDecoration(
      hintText: '노트 검색...',
      hintStyle: TextStyle(color: Colors.grey[400]),
      prefixIcon: Icon(Icons.search, color: Colors.grey[400]),
      suffixIcon: _searchController.text.isNotEmpty
          ? IconButton(
              icon: Icon(Icons.clear, color: Colors.grey[400]),
              onPressed: () {
                _searchController.clear();
                setState(() {
                  _searchQuery = '';
                });
              },
            )
          : null,
      border: InputBorder.none,
      contentPadding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 14.0),
    ),
    onChanged: (value) {
      setState(() {
        _searchQuery = value;
      });
    },
  ),
),

 

 

 

구현 2. 검색 로직 구현

입력된 _searchQuery를 바탕으로 노트 내용을 필터링합니다. Consumer<NoteService>에서 필터링된 노트를 filteredNotes 리스트에 담고, 검색 결과를 createdAt 기준으로 내림차순 정렬합니다.

// lib/views/note_list_view.dart
var filteredNotes = noteService.notes
    .where((note) => note.content
        .toLowerCase()
        .contains(_searchQuery.toLowerCase()))
    .toList();

final sortedNotes = sortList(
  filteredNotes,
  (note) => note.createdAt,
  descending: true,
);

 

검색어 필터링은 노트의 content가 _searchQuery를 포함하는 경우에만 리스트에 포함됩니다. 대소문자 구분 없이 검색할 수 있도록 toLowerCase()를 적용합니다.

 

검색된 노트를 sortList 함수를 이용해 createdAt 기준 내림차순으로 정렬하여 최신 노트를 상단에 표시합니다.

 

여기서 sortList 는 제가 직접 만든 유틸 함수입니다. 함수 내부는 이렇게 생겼습니다.

// lib/utils/sort.dart
List<T> sortList<T>(
    List<T> list, // 정렬할 리스트
    Comparable Function(T) key, // 기준 컬럼을 정의하는 함수
    {bool descending = false} // 내림차순 여부 (기본값은 오름차순)
    ) {
  list.sort((a, b) {
    final valueA = key(a);
    final valueB = key(b);

    // 내림차순 여부에 따라 정렬 방식 결정
    return descending ? valueB.compareTo(valueA) : valueA.compareTo(valueB);
  });

  return list;
}

 

정렬처럼 자주 사용하는 로직들은 이렇게 유틸 함수로 분리하여 재사용하는 것을 권합니다.

 

 

 

구현 3. 검색 결과 UI 업데이트

검색 결과에 따라 화면에 표시되는 UI를 동적으로 업데이트합니다. 이때 검색어가 입력된 상태에서 필터링된 노트가 없으면 "검색 결과가 없습니다"라는 메시지를 보여줍니다.

// lib/views/note_list_view.dart
if (sortedNotes.isEmpty && _searchQuery.isNotEmpty) {
  return Center(
    child: Text(
      '검색 결과가 없습니다.',
      style: TextStyle(color: ColorPalette.textWhite),
    ),
  );
}

 

 

필터링된 노트가 존재하면 ListView.builder로 리스트를 출력하며, 검색어에 따라 실시간으로 리스트가 업데이트됩니다.

// lib/views/note_list_view.dart
return ListView.builder(
  itemCount: sortedNotes.length,
  itemBuilder: (context, index) {
    final note = sortedNotes[index];
    return ListTile(
      title: Text(
        note.content,
        style: TextStyle(color: ColorPalette.textWhite),
      ),
      subtitle: Text(
        note.createdAt.toString(),
        style: TextStyle(color: Colors.grey),
      ),
      trailing: Row(
        mainAxisSize:
            MainAxisSize.min,
        children: [
          IconButton(
            icon: Icon(Icons.edit, color: Colors.blue),
            onPressed: () {
              context.push('/noteDetail?id=${note.id}');
            },
          ),
          IconButton(
            icon: Icon(Icons.delete_outline, color: Colors.red),
            onPressed: () async {
              await noteService.deleteNoteById(note.id);
            },
          ),
        ],
      ),
    );
  },
);

 

 

여기까지 하면 일반적인 검색 기능은 구현이 끝났습니다. 하지만 단순 키워드 검색은 정확한 키워드를 잊어버리거나 일부 단어만 기억날 경우 애써 적어둔 노트를 찾지 못할 수 있습니다.

 

그렇기 때문에 우리는 유사도 알고리즘을 적용하여 일정 유사도 이상의 노트들도 검색이 가능하도록 추가해 보겠습니다.

 

 

유사도란?

유사도는 두 문자열 간의 비슷함의 정도를 측정하는 값으로, 일반적으로 0에서 1 사이의 값으로 표현됩니다. 값이 1에 가까울수록 두 문자열이 더 유사하며, 0에 가까울수록 차이가 큽니다. 우리는 Levenshtein 거리(편집 거리) 알고리즘을 사용하여 유사도를 측정하겠습니다. 

 

Levenshtein 거리 알고리즘은 두 문자열이 얼마나 비슷한지를 나타내는 지표로, 한 문자열을 다른 문자열로 바꾸기 위해 필요한 최소 편집 횟수(삽입, 삭제, 교체)를 의미합니다. 이를 기반으로 유사도를 계산할 수 있습니다. 

 

예를 들어, 문자열 "note"와 "not"의 Levenshtein 거리를 계산해 보면 다음과 같습니다.

 

- "note"에서 "not"으로 바꾸려면 1번 삭제가 필요합니다. 따라서 Levenshtein 거리는 1입니다.

- 최대 길이는 4이므로, 유사도는 (4 - 1) / 4 = 0.75가 됩니다.

 

 

 

구현 4. 유사도 알고리즘 적용

우선, Levenshtein 거리 알고리즘을 사용한 StringSimilarity 클래스를 만들어보겠습니다. 

// lib/utils/string_similarity.dart
class StringSimilarity {
  // Levenshtein 거리 계산
  static int _levenshteinDistance(String s1, String s2) {
    if (s1.isEmpty) return s2.length;
    if (s2.isEmpty) return s1.length;

    List<List<int>> matrix = List.generate(
      s1.length + 1,
      (i) => List.generate(s2.length + 1, (j) => 0),
    );

    for (int i = 0; i <= s1.length; i++) {
      matrix[i][0] = i;
    }
    for (int j = 0; j <= s2.length; j++) {
      matrix[0][j] = j;
    }

    for (int i = 1; i <= s1.length; i++) {
      for (int j = 1; j <= s2.length; j++) {
        int cost = (s1[i - 1] == s2[j - 1]) ? 0 : 1;
        matrix[i][j] = [
          matrix[i - 1][j] + 1,
          matrix[i][j - 1] + 1,
          matrix[i - 1][j - 1] + cost,
        ].reduce((curr, next) => curr < next ? curr : next);
      }
    }

    return matrix[s1.length][s2.length];
  }

  // 유사도 계산 (0.0 ~ 1.0)
  static double calculateSimilarity(String s1, String s2) {
    int maxLength = s1.length > s2.length ? s1.length : s2.length;
    if (maxLength == 0) return 1.0;

    return (maxLength - _levenshteinDistance(s1, s2)) / maxLength;
  }
}

 

유사도 계산 공식은 (최대 길이 - 편집 거리) / 최대 길이로 설정해, 유사도가 0.0에서 1.0 사이의 값으로 반환합니다. 이제 StringSimilarity 클래스를 이용하면 각 검색어와 노트 내용의 유사도를 계산할 수 있습니다.

 

 

다음으로, 노트 목록에서 사용자가 입력한 검색어와 유사한 단어가 포함된 노트를 필터링하는 로직을 추가합니다. 검색어가 비어 있을 경우에는 모든 노트를 표시하고, 검색어가 입력된 경우에는 정확하게 일치하는지 여부와 유사도 기준으로 필터링합니다.

// lib/views/note_list_view.dart
var filteredNotes = noteService.notes.where((note) {
  if (_searchQuery.isEmpty) return true;

  String noteContent = note.content.toLowerCase();
  String searchQuery = _searchQuery.toLowerCase();

  // 정확히 포함되어 있는 경우
  if (noteContent.contains(searchQuery)) return true;

  // 유사도 검색 (단어 단위로 비교)
  List<String> noteWords = noteContent.split(' ');
  List<String> searchWords = searchQuery.split(' ');

  for (String searchWord in searchWords) {
    for (String noteWord in noteWords) {
      double similarity = StringSimilarity.calculateSimilarity(
        searchWord,
        noteWord,
      );
      if (similarity >= 0.3) return true;
    }
  }

  return false;
}).toList();

 

noteWords와 searchWords를 단어 단위로 나눈 후, 각각 유사도를 비교해 유사도가 0.3 이상인 경우 해당 노트를 검색 결과에 포함하도록 했습니다.

 

유사도가 0.3 이상이라는 것은 두 문자열이 30% 이상 일치한다는 뜻입니다. 즉, 전체 문자 중 약 30% 정도가 동일하거나 비슷한 경우입니다. 지금은 0.3 이라는 기준을 고정해 두었지만 필요에 따라 수정하거나 동적으로도 변경할 수 있습니다.

 

 

검색 기능의 최종 결과를 영상으로 보겠습니다.

 

 

 

 

이번 글에서 수정한 note_list_view.dart 파일의 전체 코드는 다음과 같습니다.

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:ttingnote/services/note_service.dart';
import 'package:ttingnote/utils/color_palette.dart';
import 'package:ttingnote/utils/sort.dart';
import 'package:ttingnote/utils/string_similarity.dart';

class NoteListView extends StatefulWidget {
  const NoteListView({super.key});

  @override
  _NoteListViewState createState() => _NoteListViewState();
}

class _NoteListViewState extends State<NoteListView> {
  final TextEditingController _searchController = TextEditingController();
  String _searchQuery = '';

  @override
  void initState() {
    super.initState();
    // 처음 화면이 로드될 때 노트를 불러옵니다.
    Provider.of<NoteService>(context, listen: false).getNotes();
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: ColorPalette.background,
      appBar: AppBar(
        title: Text('노트 목록', style: TextStyle(color: ColorPalette.textWhite)),
        backgroundColor: ColorPalette.background,
      ),
      body: Column(
        children: [
          Container(
            margin:
                const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12.0),
            decoration: BoxDecoration(
              color: Colors.grey[900],
              borderRadius: BorderRadius.circular(12.0),
              boxShadow: [
                BoxShadow(
                  color: Colors.black.withOpacity(0.2),
                  blurRadius: 8,
                  offset: const Offset(0, 2),
                ),
              ],
            ),
            child: TextField(
              controller: _searchController,
              style: TextStyle(color: ColorPalette.textWhite),
              decoration: InputDecoration(
                hintText: '노트 검색...',
                hintStyle: TextStyle(color: Colors.grey[400]),
                prefixIcon: Icon(Icons.search, color: Colors.grey[400]),
                suffixIcon: _searchController.text.isNotEmpty
                    ? IconButton(
                        icon: Icon(Icons.clear, color: Colors.grey[400]),
                        onPressed: () {
                          _searchController.clear();
                          setState(() {
                            _searchQuery = '';
                          });
                        },
                      )
                    : null,
                border: InputBorder.none,
                contentPadding: const EdgeInsets.symmetric(
                  horizontal: 16.0,
                  vertical: 14.0,
                ),
              ),
              onChanged: (value) {
                setState(() {
                  _searchQuery = value;
                });
              },
            ),
          ),
          Expanded(
            child: Consumer<NoteService>(
              builder: (context, noteService, child) {
                if (noteService.notes.isEmpty) {
                  return Center(
                    child: Text(
                      '저장된 노트가 없습니다.',
                      style: TextStyle(color: ColorPalette.textWhite),
                    ),
                  );
                }

                // 검색어로 노트 필터링 (정확한 일치 + 유사도 검색)
                var filteredNotes = noteService.notes.where((note) {
                  // 검색어가 비어있으면 모든 노트 표시
                  if (_searchQuery.isEmpty) return true;

                  String noteContent = note.content.toLowerCase();
                  String searchQuery = _searchQuery.toLowerCase();

                  // 정확한 포함 관계 확인
                  if (noteContent.contains(searchQuery)) return true;

                  // 단어 단위로 유사도 검색
                  List<String> noteWords = noteContent.split(' ');
                  List<String> searchWords = searchQuery.split(' ');

                  for (String searchWord in searchWords) {
                    for (String noteWord in noteWords) {
                      // 유사도가 0.3 이상인 경우 포함
                      double similarity = StringSimilarity.calculateSimilarity(
                        searchWord,
                        noteWord,
                      );
                      if (similarity >= 0.3) return true;
                    }
                  }

                  return false;
                }).toList();

                final sortedNotes = sortList(
                  filteredNotes,
                  (note) => note.createdAt,
                  descending: true,
                );

                if (sortedNotes.isEmpty && _searchQuery.isNotEmpty) {
                  return Center(
                    child: Text(
                      '검색 결과가 없습니다.',
                      style: TextStyle(color: ColorPalette.textWhite),
                    ),
                  );
                }

                return ListView.builder(
                  itemCount: sortedNotes.length,
                  itemBuilder: (context, index) {
                    final note = sortedNotes[index];
                    return ListTile(
                      title: Text(
                        note.content,
                        style: TextStyle(color: ColorPalette.textWhite),
                      ),
                      subtitle: Text(
                        note.createdAt.toString(),
                        style: TextStyle(color: Colors.grey),
                      ),
                      trailing: Row(
                        mainAxisSize:
                            MainAxisSize.min, // Row가 ListTile에 맞춰지도록 크기 설정
                        children: [
                          IconButton(
                            icon: Icon(Icons.edit, color: Colors.blue),
                            onPressed: () {
                              context.push('/noteDetail?id=${note.id}');
                            },
                          ),
                          IconButton(
                            icon: Icon(Icons.delete_outline, color: Colors.red),
                            onPressed: () async {
                              await noteService.deleteNoteById(note.id);
                            },
                          ),
                        ],
                      ),
                    );
                  },
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}

 

 

마무리

노트 목록 화면에 검색 기능을 추가하고 유사도 알고리즘을 적용해 보았습니다. 만약 사용자가 정확한 키워드를 기억하지 못하더라도 유사한 단어를 통해 필요한 노트를 쉽게 찾을 수 있습니다.

 

다음 글에서는 노트에 중요도를 나타내는 중요 표시 기능을 다뤄보겠습니다. 추가로, 노트 정렬 기준을 사용자가 선택할 수 있는 기능도 추가해 보겠습니다.

 

 

[Flutter로 간단한 노트 앱을 만들어보자] 시리즈는 직접 독학으로 하나씩 만들어나가는 과정이므로 틀리거나 부족한 내용이 있을 수 있습니다. 조언과 피드백을 댓글로 남겨주시면 적극 반영하겠습니다. 감사합니다.

 

 

반응형