관리 메뉴

Bull

[Dev] LLM을 활용 단어장 앱 개발일지 008: 단어 데이터 Bloc 처리, SQLite 통신, CRUD 작업 본문

일상/개발일지

[Dev] LLM을 활용 단어장 앱 개발일지 008: 단어 데이터 Bloc 처리, SQLite 통신, CRUD 작업

Bull_ 2024. 9. 3. 10:51

항상 기능을 넣을 때 신중히 작성한다. 그래서 어떤 것이 우선순위를 두어야 할지 고민을 많이 했다. 이전에는 단어 데이터를 json으로 받는 방법을 성공했다. 이제 받은 단어 데이터를 bloc으로 관리하고 SQLite로 저장하여 앱을 껐다가 다시켜도 데이터를 유지할 수 있도록 바꿔보겠다.

 

작성날이랑 개발날이랑 갭차이가 있어서 약간 까먹었다. 순서가 정확하지 않을 수 있다.

Word Bloc

일단 event와 state 정의는 다음과 같다.

import '../../models/word.dart';

abstract class WordEvent {}

class LoadWord extends WordEvent {}

class AddWord extends WordEvent {
  final Word word;
  AddWord(this.word);
}

class DeleteWord extends WordEvent {
  final int wordId;
  DeleteWord(this.wordId);
}

class UpdateWord extends WordEvent {
  final Word word;
  UpdateWord(this.word);
}

LoadWord : 로컬 저장소나 단어장 페이지를 렌더링했을 때 데이터를 불러오는 작업을 하는 이벤트다. 참고로 매번 DB에서 데이터를 불러오는 것이 아니라 초기에 처음 Bloc을 생성할 때만 DB에서 불러온다.

 

AddWord : 단어를 추가할 때 이벤트이다. Word 인스턴스 정보를 받아야 한다.

 

DeleteWord : 단어 지울 때의 이벤트이다. id값만 알면 리스트에서 지울 수 있다.

 

UpdateWord : 단어를 고치는 작업이다. 기존의 단어를 불러오고 바뀌어진 Word 정보를 통해 id값을 찾고 바뀐 Word 정보로 업데이트한다.

import '../../models/word.dart';

abstract class WordState {}

class WordInitial extends WordState {}

class WordLoading extends WordState {}

class WordLoaded extends WordState {
  final List<Word> words;
  WordLoaded(this.words);
}

class WordAdding extends WordState {}

class WordDeleting extends WordState {}

class WordUpdating extends WordState {}

class WordError extends WordState {
  final String message;
  WordError(this.message);

WordInitial : Bloc이 생성될 때 생성자로 상태값을 표시한다.

 

WordLoading : Bloc의 단어를 로드하는 상태이다. 단어가 많아지면 로딩상태가 길어질 수 있기 때문에 빌더의 상태로 로딩을 표시하는 인디케이터를 나타내기 위함이다.

 

WordLoaded : 단어가 모두 받아진 상태이다. 단어를 전달해준다.

 

WordAdding : 단어 추가.

 

WordDeleting : 단어 삭제.

 

WordUpdating 단어 업데이트.

 

WordError : 단어 관련 에러 상태.

 

다음은 word_bloc의 이벤트 핸들러이다.

import 'package:flutter_bloc/flutter_bloc.dart';
import 'word_event.dart';
import 'word_state.dart';
import '../../models/word.dart';
import '../../repositories/word_repository.dart';

class WordBloc extends Bloc<WordEvent, WordState> {
  final WordRepository wordRepository;
  late List<Word> words;

  WordBloc({required this.wordRepository}) : super(WordInitial()) {
    on<LoadWord>(_onLoadWord);
    on<AddWord>(_onAddWord);
    on<DeleteWord>(_onDeleteWord);
    on<UpdateWord>(_onUpdateWord);
    _loadInitialWords();
  }

  Future<void> _loadInitialWords() async {
    words = await wordRepository.loadWords();
    add(LoadWord());
  }

  Future<void> _onLoadWord(LoadWord event, Emitter<WordState> emit) async {
    emit(WordLoading());
    try {
      emit(WordLoaded(words));
    } catch (e) {
      emit(WordError("Failed to load words"));
    }
  }

  Future<void> _onAddWord(AddWord event, Emitter<WordState> emit) async {
    final word = event.word.copyWith(id: words.last.id! + 1);
    try {
      await wordRepository.addWord(word);
      words.add(word);
      emit(WordLoaded(words));
    } catch (e) {
      emit(WordError("Failed to add word"));
    }
  }

  Future<void> _onUpdateWord(UpdateWord event, Emitter<WordState> emit) async {
    print("Update word: ${event.word.id}");
    try {
      await wordRepository.updateWord(event.word);
      int index = words.indexWhere((word) => word.id == event.word.id);
      if (index != -1) {
        words[index] = event.word;
      }
      emit(WordLoaded(words));
    } catch (e) {
      emit(WordError("Failed to update word"));
    }
  }

  Future<void> _onDeleteWord(DeleteWord event, Emitter<WordState> emit) async {
    try {
      await wordRepository.deleteWord(event.wordId); // 데이터베이스에서 삭제
      print("Deleted word id: ${event.wordId}");
      words.removeWhere((word) => word.id == event.wordId); // 메모리 리스트에서 삭제
      emit(WordLoaded(words)); // 업데이트된 리스트로 상태 전환
    } catch (e) {
      emit(WordError("Failed to delete word"));
    }
  }
}

_loadInitialWords : DB에서 데이터를 꺼내오는 작업.

 

_onLoadWord : Bloc에서 단어들을 전달하는 작업.

 

_onAddWord : 단어를 bloc과 DB에 추가하는 작업.

 

_onUpdateWord : 단어를 bloc과 DB에 업데이트하는 작업.

 

_onDeleteWord : 단어를 bloc과 DB에 삭제하는 작업.

presentaion layer

단어를 추가, 업데이트, 삭제 작업을 할 때 이벤트 디스패칭을 해주어야한다. 큰 변화는 없지만 추가하는 바텀시트를 업데이트와 같이 하게 만들어 주었다. word_sheet_page에 콜백을 전달할까 상태를 나타나는 상수를 전달할까 고민하다가 상태를 전달했다. 이유는 콜백을 전달하게 되면 위젯의 추가되었는지 수정되었는지 표시할 텍스트 위젯을 분기할 방법이 없기 때문이다.

switch (wordState) {
  case wordAddState:
    BlocProvider.of<WordBloc>(context)
        .add(AddWord(updatedWord));
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text(
          '단어가 추가되었습니다!',
          style: TextStyle(
              color: Colors.white,
              fontSize: 14),
        ),
        duration: Duration(milliseconds: 2000),
        shape: RoundedRectangleBorder(
          borderRadius:
              BorderRadius.circular(30),
        ),
        backgroundColor: Colors.lightGreen,
        behavior: SnackBarBehavior.floating,
        margin: EdgeInsets.only(
            bottom: 100, left: 50, right: 50),
      ),
    );
  case wordUpdateState:
    BlocProvider.of<WordBloc>(context)
        .add(UpdateWord(updatedWord));
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(
        content: Text(
          '단어가 수정되었습니다!',
          style: TextStyle(
              color: Colors.white,
              fontSize: 14),
        ),
        duration: Duration(milliseconds: 2000),
        shape: RoundedRectangleBorder(
          borderRadius:
              BorderRadius.circular(30),
        ),
        backgroundColor: Colors.lightGreen,
        behavior: SnackBarBehavior.floating,
        margin: EdgeInsets.only(
            bottom: 20,left: 50, right: 50),
      ),
    );
    break;
}

지금 보니 개선의 여지가 있어보이긴 하는 거 같기도 하지만 두 경우 밖에 없으니 나중에 추가되면 봐야겠다.

 

그나저나 나는 당연히 context.read<WordBloc>().add 로 디스패칭한 줄 알았는데 아니었네...?
BlocProvider.of<WordBloc>(context).add 도 근데 기능상 같은 역할일테니 상관없을 것이다.

Word Repository (DB helper)

import '../data/word_db_helper.dart';
import '../models/word.dart';

class WordRepository {
  final WordDBHelper dbHelper;

  WordRepository({required this.dbHelper});

  // Read: 모든 단어 로드
  Future<List<Word>> loadWords() async {
    return await dbHelper.getWords();
  }

  // Create: 단어 추가
  Future<int> addWord(Word word) async {
    return await dbHelper.insertWord(word);
  }

  // Update: 단어 업데이트
  Future<int> updateWord(Word word) async {
    return await dbHelper.updateWord(word);
  }

  // Delete: 단어 삭제
  Future<int> deleteWord(int id) async {
    return await dbHelper.deleteWord(id);
  }
}

repository는 중개 해주는 역할에 가깝다. DB helper를 보자.

import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import 'dart:async';

import '../models/word.dart';

class WordDBHelper {
  static final WordDBHelper _instance = WordDBHelper._internal();
  factory WordDBHelper() => _instance;
  WordDBHelper._internal();

  static Database? _database;

  Future<Database> get database async {
    if (_database != null) return _database!;
    _database = await _initDB();
    return _database!;
  }

  Future<Database> _initDB() async {
    String path = join(await getDatabasesPath(), 'words_database.db');
    return await openDatabase(
      path,
      version: 1,
      onCreate: (db, version) async {
        await _createTables(db);
      },
    );
  }

  Future<void> _createTables(Database db) async {
    const String schema = '''
    CREATE TABLE IF NOT EXISTS Words (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      word TEXT NOT NULL,
      meaning TEXT NOT NULL
    );

    CREATE TABLE IF NOT EXISTS Examples (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      word_id INTEGER NOT NULL,
      example TEXT NOT NULL,
      exampleMeaning TEXT NOT NULL,
      FOREIGN KEY(word_id) REFERENCES Words(id) ON DELETE CASCADE
    );

    CREATE TABLE IF NOT EXISTS Tags (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      word_id INTEGER NOT NULL,
      tag TEXT NOT NULL,
      FOREIGN KEY(word_id) REFERENCES Words(id) ON DELETE CASCADE
    );
    ''';

    await db.execute(schema);
  }

  Future<int> insertWord(Word word) async {
    final db = await database;
    int wordId = await db.insert('Words', {
      'word': word.word,
      'meaning': word.meaning,
    });

    for (var example in word.example) {
      await db.insert('Examples', {
        'word_id': wordId,
        'example': example,
        'exampleMeaning': word.exampleMeaning[word.example.indexOf(example)],
      });
    }

    for (var tag in word.tag) {
      await db.insert('Tags', {
        'word_id': wordId,
        'tag': tag,
      });
    }

    return wordId;
  }

  Future<List<Word>> getWords() async {
    final db = await database;

    final List<Map<String, dynamic>> wordMaps = await db.query('Words');
    List<Word> words = [];
    for (var wordMap in wordMaps) {
      int wordId = wordMap['id'];

      final List<Map<String, dynamic>> exampleMaps =
          await db.query('Examples', where: 'word_id = ?', whereArgs: [wordId]);

      List<String> examples = [];
      List<String> exampleMeanings = [];

      for (var exampleMap in exampleMaps) {
        examples.add(exampleMap['example']);
        exampleMeanings.add(exampleMap['exampleMeaning']);
      }

      final List<Map<String, dynamic>> tagMaps =
          await db.query('Tags', where: 'word_id = ?', whereArgs: [wordId]);

      List<String> tags =
          tagMaps.map((tagMap) => tagMap['tag'] as String).toList();

      words.add(Word(
        id: wordId,
        word: wordMap['word'],
        meaning: wordMap['meaning'],
        example: examples,
        exampleMeaning: exampleMeanings,
        tag: tags,
      ));
    }

    return words;
  }

  Future<int> updateWord(Word word) async {
    final db = await database;

    int result = await db.update(
      'Words',
      {
        'word': word.word,
        'meaning': word.meaning,
      },
      where: 'id = ?',
      whereArgs: [word.id],
    );

    await db.delete('Examples', where: 'word_id = ?', whereArgs: [word.id]);

    // 새로운 예문 삽입
    for (var example in word.example) {
      await db.insert('Examples', {
        'word_id': word.id,
        'example': example,
        'exampleMeaning': word.exampleMeaning[word.example.indexOf(example)],
      });
    }

    // Tags 테이블에서 기존 태그 삭제
    await db.delete('Tags', where: 'word_id = ?', whereArgs: [word.id]);

    // 새로운 태그 삽입
    for (var tag in word.tag) {
      await db.insert('Tags', {
        'word_id': word.id,
        'tag': tag,
      });
    }

    return result;
  }

  Future<int> deleteWord(int id) async {
    final db = await database;
    await db.delete('Examples', where: 'word_id = ?', whereArgs: [id]);
    await db.delete('Tags', where: 'word_id = ?', whereArgs: [id]);
    return await db.delete('Words', where: 'id = ?', whereArgs: [id]);
  }
}

sqlite, path 패키지를 사용해야 한다.

 

WordDBHelper : 싱글톤 패턴으로 작성되었다. 싱글톤 패턴은 인스턴스를 하나로만 쓰일 때 작성하는데 그렇다고 완전한 static을 쓰진 않을 때 사용한다. DBHelper는 여러 인스턴스일 필요가 없기 때문이다. 근데 난 아직 완전한 static과 싱글톤의 차이를 잘 모르겠다. 나중에 알아보자.

 

_initDB : DBHelper 인스턴스는 Bloc 상태관리를 통해 Repository에서 관리된다. 따라서 한번 밖에 생성되지 않지만 여러 파일에서 접근할 때 database를 한 번만 가져와도 싱글톤으로 관리가 되기 때문에 _initDB는 최초 1회 일어난다. null이 아니라면. getDatabasesPath을 통해 로컬 DB 경로를 가져오고 openDatabase 을 통해 파일을 연다. 만약 최초 접근이라 파일이 없다면 생성된다.

 

_createTables : 테이블을 스키마를 통해서 생성한다. IF NOT EXISTS 쿼리를 통해 이미 테이블이 존재하면 생성하지 않는다. 단어의 인덱스는 AUTOINCREMENT를 통해서 자동으로 관리된다. 그러나 실제로 해본 결과 1,2,3,4 일때 2가 삭제되면 1,3,4가 되고 다시 생성하면 5가 생성되어 1,3,4,5 가 된다. 이러한 문제로 오버플로우가 발생하지 않냐고 GPT에게 물어본 결과 그렇게 큰 수는 갈 일 없다는 답변을 내놓았다. 만약 그러한 일이 일어난다해도 다른 조건문을 통해서 막는 코드를 제시해주었지만 귀찮고 그정도로 큰 수로 가진 않을 거 같아서 그냥 유지했다.

 

insertWord : DB에 단어를 추가한다. id를 통해 테이블을 삼분할해놨다. 하나의 테이블에 두려면 예문과 태그를 하나의 문장에 ,로 구분해야하는데 CHAR 255 크기까지만 해놓고 안정성을 위해 분리시켰다.

 

getWords : 단어가 생성될 때 초기에 단어를 불러오는 작업이다.

 

insertWord : 단어를 추가할 때 bloc과 같이 추가한다.

 

updateWord : 단어를 업데이트할 때 bloc과 같이 업데이트한다.

 

deleteWord : 단어를 삭제할 때 bloc과 같이 삭제한다.

UI 변경

크나큰 수정은 없지만 기존의 오른쪽 버튼을 누르면 수정과 삭제를 할 수 있는 위젯을 추가했다.

 

업데이트를 누르면 해당 리스트의 word 정보를 가져와서 기존의 add에 있었던 바텀시트에 정보가 추가되고 사진에는 안나왔지만 아래에 수정 버튼이 있다. 또한 바텀시트 높이를 수정해주는 자잘한 작업이 일어났다.

 

삭제 버튼 디자인이 마음에 들진 않지만 톤이 그나마 맞기 때문에 일단은 사용. 나중에 고칠 예정이다.