티스토리 뷰

이번 포스팅에서는 직전에 했던 Todo List Project를 SQLite가 아닌 Firebase에서 제공하는 DB service를 사용하여 다시 구성해보려고 한다.

 

Firebase는 이전에 Android native의 push 알림을 다루면서 잠시 살펴본 개념이다. 

 

Firebase는 구글의 Backend as a Service이다. 즉, 백엔드 기능을 서비스화 하여 개발자가 직접 기능을 구현하지 않아도 알아서 해준다는 것이다.

 

Firebase의 수많은 기능 중 이번에는 직전에 했던 프로젝트를 살짝 다듬 어보며, SQLite로 DB를 다루던 것을 Firestore로 대체하려고 한다. 

 

간단히 Firestore를 설명하자면, 앞서 사용했던 관계형 데이터베이스인 SQLite와는 달리 NoSQL 기반의 데이터베이스이다. RDBMS와는 달리 테이블 구조가 아니라 문서 형태로 저장하게 된다.

 

서론은 여기까지 하고, 바로 한번 Firebase 세팅부터 시작해보자.


우선 Firebase에 어플을 등록해야 하는데, Flutter는 ios와 android app의 정보를 모두 등록해야 한다.

 

이미 Android에서 다룬 적이 있지만, 다시 한번 짚고 넘어가자.

위 Firebase 에서 프로젝트를 생성한 뒤에, android 버튼을 클릭하여 android app을 등록할 수 있다.


등록에 필요한 패키지 정보는 AndroidManifest.xml 파일에서 참고하면 되며, 

SHA-1 해시 키의 경우는 안드로이드 스튜디오의 debug.keystore 파일에서 가져올 수 있다. 

 

Intellij의 경우, Teminal에서 

keytool -list -v -keystore C:\Users\유저 이름\.android\debug.keystore -alias androiddebugkey -storepass android -keypass android

커멘드를 입력해주면 확인할 수 있다. 이때, 반드시 안드로이드 스튜디오와 안드로이드 SDK가 설치되어 있어야 하며 앱이 1번 이상 안드로이드 스튜디오로 빌드가 되어 Users/유저 이름/. android 하위에 debug.keystore 파일이 있어야 한다.

(필자는 이 keystore를 안드로이드 스튜디오를 재설치하는 과정에서 없애버려서 시간을 매우 많이 버렸다...)


이제, Next를 눌러서 나오는 google-services.json 파일을 android > app 하위에 삽입해주자.


이제 설명에 나와있는 그대로 프로젝트의 build.gradle 과 android.app 내부에 있는 build.gradle에 Firebase 사용을 위한 설정과 Dependency를 추가해줘야 한다.

◎app/build.gradle

def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
    localPropertiesFile.withReader('UTF-8') { reader ->
        localProperties.load(reader)
    }
}

def flutterRoot = localProperties.getProperty('flutter.sdk')
if (flutterRoot == null) {
    throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.")
}

def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) {
    flutterVersionCode = '1'
}

def flutterVersionName = localProperties.getProperty('flutter.versionName')
if (flutterVersionName == null) {
    flutterVersionName = '1.0'
}

apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'

apply plugin: 'com.google.gms.google-services'

apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"

android {
    compileSdkVersion flutter.compileSdkVersion
    ndkVersion flutter.ndkVersion

    compileOptions {
        sourceCompatibility JavaVersion.VERSION_1_8
        targetCompatibility JavaVersion.VERSION_1_8
    }

    defaultConfig {
        // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
        applicationId "com.example.todo_list"
        // You can update the following values to match your application needs. 
        // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-build-configuration.
        minSdkVersion flutter.minSdkVersion
        targetSdkVersion flutter.targetSdkVersion
        versionCode flutterVersionCode.toInteger()
        versionName flutterVersionName
    }

    buildTypes {
        release {
            // TODO: Add your own signing config for the release build.
            // Signing with the debug keys for now, so `flutter run --release` works.
            signingConfig signingConfigs.debug
        }
    }
}

flutter {
    source '../..'
}

tasks.register("prepareKotlinBuildScriptModel"){}


dependencies {
    // Import the Firebase BoM
    implementation platform('com.google.firebase:firebase-bom:30.2.0')

    // Add the dependency for the Firebase SDK for Google Analytics
    // When using the BoM, don't specify versions in Firebase dependencies
    implementation 'com.google.firebase:firebase-analytics'

    // Add the dependencies for any other desired Firebase products
    // https://firebase.google.com/docs/android/setup#available-libraries
}

 

◎android/build.gradle

buildscript {
    ext.kotlin_version = '1.6.10'
    repositories {
        google()
        mavenCentral()
    }

    dependencies {
        classpath 'com.android.tools.build:gradle:7.1.2'
        classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"

        classpath 'com.google.gms:google-services:4.3.13'
    }
}

allprojects {
    repositories {
        google()
        mavenCentral()
    }
}

rootProject.buildDir = '../build'
subprojects {
    project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
    project.evaluationDependsOn(':app')
}

task clean(type: Delete) {
    delete rootProject.buildDir
}

Flutter에서 해당 gradle 파일을 변경 후에는 반드시 android studio로 열어서 sync 해줘야 한다. (왜인지 아예 감도 안오지만, 필자는 android studio가 이미 열려있는 상태여야만 에러 없이 잘 열린다...)


여기까지 끝났으면, 이제 firebase를 사용할 준비가 완료된 것이다!!


이제, 방금 생성한 Firebase 프로젝트로 들어가서

Cloud Firestore를 클릭하여 데이터 베이스를 하나 생성하자.

테스트 용도로만 사용할 예정이니 테스트 모드로 설정을 하자. 테스트 모드는 보안 규칙을 따로 추가하지 않으면 30일 이후 사용할 수 없다.

 

서버 위치를 선택해주고, 생성하면 된다. (서버 위치는 한번 선택하면 옮길 수 없으니 주의하자) 

생성은 조금 시간이 걸릴 수 있으나, 한 5분 정도만 기다리면 된다.

 

 Firebase Database는 NoSQL 기반의 Database이기 때문에 테이블을 정의하는 것이 아닌, Collection과 Document 단위로 데이터를 처리한다. RDBMS와 비교하자면 Collection은 Table, Document는 한 데이터 Row라고 생각하면 된다.

 

이제, todos 라는 이름의 Collection을 생성하자.

다음으로 넘어가서 Documents를 작성해주고, 그 ID는 자동으로 생성되도록 두자.


이제, 본격적으로 Flutter쪽을 건드려보자. 

 

Todo List 프로젝트로 돌아와서, firebase를 위한 Flutter dependency를 추가한다.

 

◎pubspec.yaml

dependencies:
  flutter:
    sdk: flutter

  # 아이콘 사용을 위한 패키지
  cupertino_icons: ^1.0.2

  # shared-prefs 를 사용하기 위한 패키지
  shared_preferences: ^2.0.6

  # SQLite
  sqflite: ^2.0.0+3

  # firebase
  firebase_core: ^1.0.1

  # firestore
  cloud_firestore: ^2.2.2

Model 객체도 Firebasestore 사용에 맞게 수정을 해주어야 한다.

 

◎todo.dart

import 'package:cloud_firestore/cloud_firestore.dart';

class Todo {
  late int? id;
  late String title;
  late String description;

  // DocumentReference: Firebase에서 문서를 처리하기 위한 객체, Document를 가리키는 pointer
  // DocumentReference 객체가 있어야지만, 해당 document에 접근하고, 수정, 삭제 등의 작업을 수행할 수 있다.
  late DocumentReference? reference;

  Todo({
    this.id,
    required this.title,
    required this.description,
    this.reference
  });

  Map<String, dynamic> toMap() {
    return {
      'id' : id,
      'title' : title,
      'description' : description,
    };
  }

  Todo.fromMap(Map<dynamic, dynamic>? map) {
    id = map?['id'];
    title = map?['title'];
    description = map?['description'];
  }

  Todo.fromSnapshot(DocumentSnapshot documentSnapshot) {
    // Firebase는 DocumentSnapshot 이라는 형태로 데이터를 제공하며,
    Map<String, dynamic> map = documentSnapshot.data() as Map<String, dynamic>;

    // 이를 처리하기 위해  DocumentSnapshot 를 인자로 받아 Map 으로 변환 후 다시 Todo로 변환하여 저장한다.
    id = map['id'];
    title = map['title'];
    description = map['description'];
    reference = documentSnapshot.reference;
  }

}

DocumentReference 라는 조금은 생소한 객체가 추가되었는데, DocumentReference 은 db의 collection 주소에 접근하기 위해 사용하는 포인터 객체 정도로 우선은 생각하면 된다.


repository 객체도 마찬가지로 Firebasestore에서 데이터를 다룰 수 있게 수정한다.

 

◎TodoFirebase.dart (repository)

import 'dart:developer';

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/cupertino.dart';

import '../models/todo.dart';

class TodoFirebase {
  // Project의 Collection 주소를 다루는 객체
  late CollectionReference todosReference;

  // Stream 형태로 제공되는 Todo 데이터이며, 이는 todosReference.snapshots() 로 사용 가능
  // Firestore는 지속적인 데이터 관리 및 변화 감지 등을 위해 Stream 형태로 데이터를 제공한다.
  late Stream<QuerySnapshot> todoStream;

  void initDb() {
    todosReference = FirebaseFirestore.instance.collection('todos');
    todoStream = todosReference.snapshots();
  }

  List<Todo> getTodos(AsyncSnapshot<QuerySnapshot> snapshot) {
    // snapshot에 데이터가 있다면, 해당 데이터를 List형태로 반환
    return snapshot.data!.docs.map((DocumentSnapshot documentSnapshot) {
      return Todo.fromSnapshot(documentSnapshot);
    }).toList();
  }

  Future addTodo(Todo todo) async{
    todosReference.add(todo.toMap());
  }

  Future updateTodo(Todo todo) async{
    todo.reference?.update(todo.toMap());
  }

  Future deleteTodo(Todo todo) async {
    todo.reference?.delete();
  }
}

위 코드를 보면, todosReference와 todoStream 객체가 조금 생소하다. 먼저 todosReference는 해당 프로젝트의 Collection 주소를 다루는 객체이며, 앞서 만든 Collection에 접근하기 위해 생성, 관리한다.

 

todoStream은 Stream 형태로 제공되는 Todo 데이터이며, todosReference로 Collection에 접근한 뒤, snopshots() 메서드를 사용하여 인스턴스를 생성할 수 있다.

 

또한 getTodos 에서 사용한 QuerySnapshot 객체는 Firebasestore에서 실행한 query 값에 대한 결과 데이터를 가지고 있다.


이제, list_screen 부분의 UI와 firebasestore repository를 연결해야 한다. 

 

firebasestore에서 stream 형태로 데이터를 제공하기 때문에 데이터를 모두 가져왔는 지를 확인하지 않아도 된다. 

 

즉, 기존에 사용했던 타이머와 isLoading 변수는 필요가 없다는 뜻이다.

 

◎list_screen.dart

import 'dart:async';

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:todo_list/%20providers/TodoFirebase.dart';
import 'package:todo_list/%20providers/todo_default.dart';
import 'package:todo_list/%20providers/todo_sqlite.dart';

import '../models/todo.dart';

class ListScreen extends StatefulWidget {
  @override
  _ListScreenState createState() => _ListScreenState();
}

class _ListScreenState extends State<ListScreen> {

  // 화면에 표시하기 위한 Todo 배열
  List<Todo> todos = [];

  // Repository
  TodoFirebase todoFirebase = TodoFirebase();

  @override
  void initState() {
    super.initState();
    setState(() {
      todoFirebase.initDb();
    });
  }

  @override
  Widget build(BuildContext context) {
    // Stream 데이터를 빌드할 수 있도록 StreamBuilder를 사용
    return StreamBuilder<QuerySnapshot> (
        stream: todoFirebase.todoStream,
        builder: (context, AsyncSnapshot<QuerySnapshot> snapshot) {
          if (!snapshot.hasData) {
            return Scaffold(
              appBar: AppBar(),
              body: Center(child: CircularProgressIndicator())
            );
          } else {
            todos = todoFirebase.getTodos(snapshot);
            return Scaffold(
                appBar: AppBar(
                  title: Text('할 일 목록'),
                  actions: [
                    // 탭했을 때, 잉크가 퍼져나가는 이펙트를 가진 영역
                    InkWell(
                        onTap: () {},
                        child: Container(
                          padding: EdgeInsets.all(5),
                          child: Column(
                            mainAxisAlignment: MainAxisAlignment.center,
                            // Open API 실습을 위한 뉴스 아이콘
                            children: [
                              Icon(Icons.book),
                              Text('뉴스')
                            ],
                          ),
                        )
                    )
                  ],
                ),
                // Todo 추가를 위한 Floating Button
                floatingActionButton: FloatingActionButton(
                  child: Text('+', style: TextStyle(fontSize: 25)),
                  onPressed: () {
                    // Dialog창을 띄우기 위한 함수 context, builder, action을 인자로 가진다.
                    // context: 알림창을 띄우는 스크린
                    // builder: 알림창 설정 후 반환함
                    // action: 알림참에서 버튼을 클릭 시 이벤트를 지정
                    showDialog(
                        context: context,
                        builder: (BuildContext context) {
                          String title = '';
                          String description = '';
                          return AlertDialog(
                            title: Text('할 일 추가하기'),
                            content: Container(
                                height: 200,
                                child: Column(
                                  children: [
                                    // 입력 영역, 변경이 생기면 바로 그 값을 title, description에 저장
                                    TextField(
                                      onChanged: (value) {
                                        title = value;
                                      },
                                      decoration: InputDecoration(labelText: '제목'),
                                    ),
                                    TextField(
                                      onChanged: (value) {
                                        description = value;
                                      },
                                      decoration: InputDecoration(labelText: '설명'),
                                    )
                                  ],
                                )
                            ),
                            actions: [
                              TextButton(
                                child: Text('추가'),
                                onPressed: () {
                                  Todo newTodo = Todo(
                                    title: title, description: description
                                  );

                                  todoFirebase.todosReference
                                    .add(newTodo.toMap())
                                    .then((value) {
                                      Navigator.of(context).pop();
                                    });
                                },
                              ),
                              TextButton(
                                child: Text('취소'),
                                onPressed: () {
                                  Navigator.of(context).pop();
                                },

                              )
                            ],
                          );
                        }
                    );
                  },
                ),
                // TodoList를 표시하기 위한 Body - ListView
                body:
                ListView.separated(
                  itemCount: todos.length,
                  itemBuilder: (context, index) {
                    return ListTile(
                      title: Text(todos[index].title),
                      onTap: () {
                        showDialog(
                            context: context,
                            builder: (BuildContext context) {
                              return SimpleDialog(
                                title:Text('할 일'),
                                children: [
                                  Container(
                                    padding: EdgeInsets.all(10),
                                    child: Text('제목: ' + todos[index].title),
                                  ),
                                  Container(
                                    padding: EdgeInsets.all(10),
                                    child: Text('설명: ' + todos[index].description),
                                  )
                                ],
                              );
                            }
                        );
                      },
                      // trailing 아이콘을 놓기 위한 영역
                      trailing: Container(
                        width: 80,
                        child: Row(
                          mainAxisAlignment: MainAxisAlignment.end,
                          children: [
                            // 수정 아이콘을 위한 Container
                            Container(
                                padding: EdgeInsets.all(5),
                                child: InkWell(
                                    child: Icon(Icons.edit),
                                    onTap: () {
                                      showDialog(
                                          context: context,
                                          builder: (BuildContext context) {
                                            String title = todos[index].title;
                                            String description = todos[index].description;

                                            return AlertDialog(
                                              title: Text('할 일 수정하기'),
                                              content: Container(
                                                height: 200,
                                                child: Column(
                                                  children: [
                                                    TextField(
                                                      onChanged: (value) {
                                                        title = value;
                                                      },
                                                      decoration: InputDecoration(
                                                          hintText: todos[index].title
                                                      ),
                                                    ),
                                                    TextField(
                                                      onChanged: (value) {
                                                        description = value;
                                                      },
                                                      decoration: InputDecoration(
                                                          hintText: todos[index].description
                                                      ),
                                                    ),
                                                  ],
                                                ),
                                              ),
                                              actions: [
                                                TextButton(
                                                    onPressed: () {
                                                      Todo newTodo = Todo(
                                                        title: title,
                                                        description: description,
                                                        reference: todos[index].reference
                                                      );
                                                      todoFirebase
                                                        .updateTodo(newTodo)
                                                        .then((value) {
                                                          Navigator.of(context).pop();
                                                        });
                                                    },
                                                    child: Text('수정')
                                                ),
                                                TextButton(
                                                    onPressed: () {
                                                      Navigator.of(context).pop();
                                                    },
                                                    child: Text('취소')
                                                )
                                              ],
                                            );
                                          }
                                      );
                                    }
                                )
                            ),
                            // 삭제 아이콘을 위한 Container
                            Container(
                                padding: EdgeInsets.all(5),
                                child: InkWell(
                                    child: Icon(Icons.delete),
                                    onTap: () async {
                                      showDialog(
                                          context: context,
                                          builder: (BuildContext context) {
                                            return AlertDialog(
                                              title: Text('할 일 삭제하기'),
                                              content: Container(
                                                  child: Text('삭제하시겠습니까?')
                                              ),
                                              actions: [
                                                TextButton(
                                                  child: Text('삭제'),
                                                  onPressed: () {
                                                    todoFirebase
                                                      .deleteTodo(todos[index])
                                                      .then((value) {
                                                        Navigator.of(context).pop();
                                                      });
                                                  },
                                                ),
                                                TextButton(
                                                    onPressed: () {
                                                      Navigator.of(context).pop();
                                                    },
                                                    child: Text('취소')
                                                )
                                              ],
                                            );
                                          }
                                      );
                                    }
                                )
                            )
                          ],
                        ),
                      ),
                    );
                  },
                  // ListView의 Separator
                  separatorBuilder: (context, index) {
                    return Divider();
                  },
                )
            );
          }
        }
    );

  }
}

list_screen을 제외한 나머지 screen은 직전 포스팅과 동일하다.

 

하지만 main.dart 에서 app을 run 하기 전에 먼저

await Firebase.initializeApp();

위 코드를 작성하여 Firebase app을 초기화 해줘야 한다.

 

주의할 점은 initializeApp()을 사용하기 전에 반드시 

WidgetsFlutterBinding.ensureInitialized();

를 먼저 작성하여 flutter - native 사이 위젯 안정성을 보장해줘야 에러가 없다는 것인데, 이는 여기에서 매우 잘 설명하고 있다.

 

◎main.dart

import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:todo_list/screens/splash_screen.dart';

Future<void> main() async {

  // 플랫폼(ios, android)의 네이티브 위젯의 안정성을 보장하기 위함
  WidgetsFlutterBinding.ensureInitialized();

  // Firebase 초기화를 하기 위해 네이티브 코드를 호출한다.
  // but 플러그인은 네이티브 코드를 호출할 플랫폼 채널을 사용하는데, 플랫폼 채널이 비동기이기 때문에
  // WidgetsFlutterBinding.ensureInitialized(); 를 먼저 사용하여 플랫폼 채널의 위젯 바인딩을 보장해야 한다.
  await Firebase.initializeApp();

  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Todo List',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: SplashScreen()
    );
  }
}

 


어쩌다 보니 Firebase setting까지 해서 정말 내용이 많은 포스팅이 되어 버렸는데, 이제 정말 마지막으로 모든 기능이 정상 동작하는지 확인만 하면 된다.

 

정말 긴... 여정이었는데, 잘 마무리가 된 거 같다.

 

 

 

 

끝!

Comments