본문 바로가기

Flutter Todo App 프로젝트 정리(카테고리) - 6탄(Flutter Drift를 활용한 블로그 카테고리 기능 완전 구현 가이드)

ironwhale 2025. 7. 16.

할 일 앱을 만들다 보면 가장 먼저 마주치는 고민이 바로 카테고리 기능입니다. 단순해 보이지만, 실제로는 복잡한 데이터베이스 조인과 상태 관리가 필요한 기능이죠.

이번 포스트에서는 Drift ORM을 사용해서 카테고리별 게시물 개수를 실시간으로 보여주는 기능을 구현해보겠습니다. SQL 조인부터 Riverpod을 활용한 상태 관리까지, 실무에서 바로 사용할 수 있는 완전한 구현을 다룹니다.

특히 트랜잭션을 활용한 안전한 삭제 로직UNION ALL을 활용한 카테고리 없는 항목 처리는 놓치기 쉬운 핵심 포인트입니다.

개발자라면 한 번쯤은 구현해야 할 카테고리 기능, 이제 제대로 만들어보시죠! 🚀

🔄 데이터베이스 쿼리 구현

SQL 쿼리 작성

먼저 sql.drift 파일에 카테고리별 게시물 개수를 조회하는 쿼리를 추가합니다.

getCategoryWithCount: SELECT c.*, (SELECT COUNT(*) FROM todo_entries te WHERE te.category = c.id) AS amount
                     FROM categories c
                     UNION ALL
                     SELECT NULL, NULL, NULL, (SELECT COUNT(*) FROM todo_entries te WHERE te.category IS NULL);

SQL 쿼리 동작 원리:

이 쿼리는 두 부분으로 나뉩니다:

첫 번째 부분: 카테고리별 게시물 개수 조회

SELECT c.*, (SELECT COUNT(*) FROM todo_entries te WHERE te.category = c.id) AS amount
FROM categories c

두 번째 부분: 카테고리 없는 게시물 개수 조회

SELECT NULL, NULL, NULL, (SELECT COUNT(*) FROM todo_entries te WHERE te.category IS NULL)

📊 쿼리 결과 예시 테이블

데이터베이스 상태가 다음과 같다고 가정해봅시다:

categories 테이블:

id name color
1 업무 red
2 개인 blue
3 공부 green

todo_entries 테이블:

id description category
1 회의 준비 1
2 운동하기 2
3 독서하기 3
4 장보기 2
5 청소하기 NULL

getCategoryWithCount 쿼리 결과:

id name color amount
1 업무 red 1
2 개인 blue 2
3 공부 green 1
NULL NULL NULL 1

💡 : UNION ALL을 사용하여 카테고리가 없는 항목들도 함께 조회할 수 있습니다. 마지막 행의 NULL 값들은 "카테고리 없음"을 나타냅니다.

⚠️ 주의사항: 쿼리 추가 후 반드시 flutter packages pub run build_runner build 명령으로 코드 생성을 실행해야 합니다.

📝 데이터 모델 정의

CategoryWithCount 클래스 구현

쿼리 결과를 담을 데이터 클래스를 만들어야 합니다.

class CategoryWithCount {
  final Category? category;
  final int count;

  CategoryWithCount({required this.category, required this.count});
}

코드 설명:

  • Category? category: 카테고리 정보를 담는 필드입니다. null인 경우 "카테고리 없음"을 의미합니다.
  • int count: 해당 카테고리에 속한 게시물의 개수입니다.
  • 사용 이유: Drift의 쿼리 결과를 타입 안전하게 변환하기 위해 별도의 모델 클래스를 정의합니다.

🔧 카테고리 서비스 로직 구현

상태 관리 및 CRUD 서비스

@riverpod
class CategoryState extends _$CategoryState {
  @override
  Category? build() {
    return null;
  }

  void changeCategory(Category? category) {
    state = category;
  }
}

@riverpod
class CategoryService extends _$CategoryService {
  @override
  Category? build() {
    return null;
  }

  Future<void> saveCategory({required String name}) async {
    final random = Random(42);
    final color = Colors.primaries[random.nextInt(Colors.primaries.length)];
    await ref
        .read(databaseProvider)
        .categories
        .insertOne(CategoriesCompanion.insert(name: name, color: color));
  }

  Future<void> deleteCategory(Category category) async {
    final database = ref.read(databaseProvider);
    database.transaction(() async {
      await (database.todoEntries.update()
            ..where((row) => row.category.equals(category.id)))
          .write(TodoEntriesCompanion(category: Value(null)));

      await database.categories.deleteOne(category);
    });
  }

  Future<void> updateCategory(Category category) async {
    final database = ref.read(databaseProvider);
    await database.categories.replaceOne(category);
  }

  Stream<List<CategoryWithCount>> getCategories() {
    return ref
        .read(databaseProvider)
        .getCategoryWithCount()
        .map(
          (row) => CategoryWithCount(
            category: row.id != null
                ? Category(id: row.id!, name: row.name!, color: row.color!)
                : null,
            count: row.amount,
          ),
        )
        .watch();
  }
}

코드 설명:

CategoryState 클래스

  • build(): 초기 상태를 null로 설정합니다.
  • changeCategory(): 현재 선택된 카테고리를 변경하는 메서드입니다.
  • 사용 이유: 현재 선택된 카테고리 상태를 전역적으로 관리하기 위해 사용합니다.

CategoryService 클래스

saveCategory 메서드 - 랜덤 색상 생성 로직

final random = Random(42);
final color = Colors.primaries[random.nextInt(Colors.primaries.length)];

랜덤 색상 생성 상세 설명:

  • Random(42): 시드값 42를 사용하여 예측 가능한 랜덤 시퀀스를 생성합니다.
  • Colors.primaries: Flutter에서 제공하는 기본 색상 팔레트 배열입니다.
  • nextInt(): 0부터 배열 길이-1까지의 랜덤 인덱스를 생성합니다.
  • 사용 이유: 사용자가 색상을 직접 선택하지 않아도 시각적으로 구분 가능한 카테고리를 만들 수 있습니다.

💡 : 시드값을 고정하면 동일한 순서로 색상이 생성되어 테스트 시 일관된 결과를 얻을 수 있습니다.

deleteCategory 메서드 - 트랜잭션 처리 로직

database.transaction(() async {
  await (database.todoEntries.update()
        ..where((row) => row.category.equals(category.id)))
      .write(TodoEntriesCompanion(category: Value(null)));

  await database.categories.deleteOne(category);
});

트랜잭션 처리 상세 설명:

  • transaction(): 여러 데이터베이스 작업을 하나의 원자적 단위로 묶어 처리합니다.
  • 1단계: 삭제할 카테고리에 속한 모든 할 일의 category 필드를 null로 업데이트합니다.
  • 2단계: 카테고리 자체를 삭제합니다.
  • 사용 이유: 카테고리 삭제 중 오류가 발생하면 모든 작업이 롤백되어 데이터 무결성을 보장합니다.

⚠️ 주의사항: 트랜잭션 없이 카테고리를 먼저 삭제하면 외래 키 제약 조건 오류가 발생할 수 있습니다.

getCategories 메서드 - 스트림 데이터 변환

Stream<List<CategoryWithCount>> getCategories() {
  return ref
      .read(databaseProvider)
      .getCategoryWithCount()
      .map(
        (row) => CategoryWithCount(
          category: row.id != null
              ? Category(id: row.id!, name: row.name!, color: row.color!)
              : null,
          count: row.amount,
        ),
      )
      .watch();
}

스트림 데이터 변환 상세 설명:

  • getCategoryWithCount(): 앞서 정의한 SQL 쿼리를 실행합니다.
  • map() 변환: 각 쿼리 결과 행을 CategoryWithCount 객체로 변환합니다.
  • null 체크: row.id != null을 통해 실제 카테고리와 "카테고리 없음" 항목을 구분합니다.
  • watch(): 데이터베이스 변경사항을 실시간으로 감지하는 스트림을 반환합니다.
  • 사용 이유: UI에서 카테고리 목록과 각 카테고리의 게시물 개수를 실시간으로 업데이트할 수 있습니다.

🚀 구현 완료 후 다음 단계

1단계: 코드 생성 실행

flutter packages pub run build_runner build

2단계: UI 컴포넌트 연결

// 사용 예시
Consumer(
  builder: (context, ref, child) {
    return StreamBuilder<List<CategoryWithCount>>(
      stream: ref.read(categoryServiceProvider.notifier).getCategories(),
      builder: (context, snapshot) {
        // UI 구현
      },
    );
  },
)

3단계: 상태 관리 연결

// 카테고리 선택 시
ref.read(categoryStateProvider.notifier).changeCategory(selectedCategory);

💡 : 이 구현을 통해 카테고리 추가/삭제 시 실시간으로 개수가 업데이트되는 완전한 카테고리 시스템을 구축할 수 있습니다.

📋 마무리

이번 포스트에서는 Drift ORM을 활용해 카테고리 기능을 완전히 구현해봤습니다. 핵심 포인트들을 정리하면:

  • UNION ALL을 활용한 카테고리 없는 항목 처리
  • 트랜잭션을 통한 안전한 데이터 삭제
  • Riverpod을 활용한 효율적인 상태 관리
  • 실시간 스트림을 통한 UI 업데이트

다음 포스트에서는 이 카테고리 기능을 활용한 UI 구현을 다뤄보겠습니다! 🎨

댓글