Firebase Firestore Database 사용해 보기 2편

Firebase Firestore Documentation

firebase_core | Flutter Package
cloud_firestore | Flutter Package

Firebase 세팅하기 - Flutter 3.0 이후
Firebase 세팅하기 - Flutter 3.0 이전
Firebase Authentication 사용해 보기 1편
Firebase Authentication 사용해 보기 2편

이번 글에서는 이전 글에서 작성한 Firestore 사용 방법을 이어서 작성하도록 하겠다. 이번에는 데이터를 불러오는 READ와 데이터를 불러올 시 Query, 정렬, 특정 document 및 field 데이터 조회 등을 알아볼 예정이고, 추가로 Firestore DB의 커서 기반 Pagenation 처리에 대해서도 알아볼 예정이다.

Flutter

Flutter에서 데이터를 저장하고 Firestore DB를 읽어들이는 방법에 대해서 확인해보자. READ 기능은 단순 조회가 있고, Stream으로 DB 컬렉션의 변경이 수신될 때마다 조회할 수 있다.

우선 데이터를 저장하고 단순히 DB에서 데이터를 받아오는 것 외에도 Query를 조회하는 법과 Document ID 관리에 대한 내용을 순차적으로 Flutter를 통해서 배워보도록 하자.

READ

먼저 데이터를 읽기 전에 불러올 수 있는 데이터가 있어야 하기에 데이터를 생성해보자.

예제로 사용하기 위해 json 파싱을 할 수 있도록 만든 간단한 클래스이다.

class Test {
  final int id;
  final int rank;
  final String name;

  Test({required this.id, required this.rank, required this.name});

  factory Test.fromJson(Map<String, dynamic> json) {
    return Test(
      id: json["id"],
      rank: json["rank"],
      name: json["name"],
    );
  }
  Map<String, dynamic> toJson() {
    return {
      "id": id,
      "rank": rank,
      "name": name,
    };
  }
}

아래 코드를 복사 붙여넣기 해서 데이터를 먼저 저장해 주자. 일단 10개의 데이터만 생성하도록 하자.

FirebaseFirestore _firestore = FirebaseFirestore.instance;
	for (int i = 0; i < 10; i++) {
		await _firestore.collection("read_test").doc()
                    .set(Test(id: i, rank: i + 1, name: "Tyger $i").toJson());
              }

저장이 된것을 확인할 수 있다. No-SQL DB인 Firestore에서는 DB에 데이터가 저장되는 순서가 규칙적이지 않다는 것을 알 수 있다. 한 번 데이터를 읽어서 확인을 해보도록 하겠다.

데이터를 읽는 방법에는 크게 두 가지 방식이 있는데, 한 번만 불러오는 것과 지속적으로 DB의 변경이 있을 때마 불러오는 방식이 있다. 각각에 대해서 사용해보도록 하자.

OneTime

컬렉션의 도큐먼트를 한 번만 호출해서 보여주는 방식이다. Flutter에서는 Future 비동기로 구현을 하면 된다.
QuerySnapshot을 사용해서 데이터를 가져오는데, 리스트안에 데이터는 객체 형태가 아닌 json 형태의 데이터 이기에 객체로 변경해주어야 한다.

출력을 해보면 데이터가 DB의 순서대로 불러와지는 것을 알 수 있다.

 Future<List<Test>> _fromFirestore() async {
    FirebaseFirestore _firestore = FirebaseFirestore.instance;
    QuerySnapshot<Map<String, dynamic>> _snapshot =
        await _firestore.collection("read_test").get();
    List<Test> _result =
        _snapshot.docs.map((e) => Test.fromJson(e.data())).toList();
    return _result;
  }

RealTime

이번엔 OneTime 방식처럼 한 번만 호출해서 데이터를 가져오는게 아닌 실시간으로 DB 컬렉션의 변경이 발생할 떄마다 데이터를 불러오는 RealTime 방식에 대해서 알아보도록 하겠다.

Flutter에서는 Steam을 사용하여 처리할 수 있는데, StreamBuilder를 사용하여 처리할 수도 있고, Stream 구독 모델을 생성하여 비동기 통신으로 구현할 수도 있다.

먼저 Streambuildr를 사용하는 경우이다.

StreamBuilder(
	stream: FirebaseFirestore.instance.collection("read_test").snapshots(),
	builder: ((context, snapshot) {
			return Container();
          }))

StreamBuilder를 사용하지 않고 구독 스트림을 생성하여 비동기 처리를 하는 방법이다.

Stream<List<Test>> _fromFirestore() async* {
    FirebaseFirestore _firestore = FirebaseFirestore.instance;
    yield* _firestore.collection("read_test").snapshots().map((snapshot) =>
        snapshot.docs.map((e) => Test.fromJson(e.data())).toList());
  }

Limit

이번에는 원하는 갯수 만큼만 데이터를 가져오는 방법을 알아보자.
Collection내에 있는 Document를 한 번에 불러오는 방식은 좋지 않은 방식이다. 컬렉션 내에 생성되어 있는 document가 10개나 100개 이런 경우에는 큰 문제가 없지만 만개, 십만개가 넘어간다고 하면 데이터 조회시 많은 리소스를 낭비할 수 있기 때문이다ㅏ.
이럴 떄 원하는 갯수만 불러오기 위해서는 Collection안에 있는 Document의 갯수를 설정해 주면 되는데, limit을 사용하면 된다.

 await _firestore.collection("read_test").limit($_limit).get();

Query

이번에는 특정 조건의 필드 값을 가지고 있는 데이터만 불러오도록 해보자.

필드 데이터 id 값이 3인 데이터만 불러오는 코드이다.

await _firestore
        .collection("read_test")
        .where("id", isEqualTo: 3)
        .get();

이번엔 id 값이 3이 아닌 경우의 데이터만 불러와보자.

await _firestore
        .collection("read_test")
        .where("id", isNotEqualTo: 3)
        .get();

특정 값이 같거나 다른 경우가 아닌 특정 값보다 크거나 작은 경우에 DB 데이터를 불러오는 코드이다.

await _firestore
        .collection("read_test")
        .where("id", isGreaterThan: 3)
        .get();

값이 작은 경우의 데이터만 호출하고 싶을 때 사용하는 코드이다.

await _firestore
        .collection("read_test")
        .where("id", isLessThan: 3)
        .get();

OrderBy

이번에는 정렬 기준에 따른 데이터베이스를 읽어오는 방법에 대해서 살펴보자.

위에서 확인했듯이 DB에 저장되어 있는 Document를 보면 순서대로 저장되어 있지 않다는 것을 알 수 있다. 이렇게 순서대로 저장되어 있지 않은 데이터를 특정 필드의 순서대로 데이터를 불러오기 위해서 orderBy 기능을 사용하면 된다.

이번에는 rank 데이터를 기준으로 정렬시켜 데이터를 읽어오자.

await _firestore.collection("read_test").orderBy("rank").get();

반대로 정렬하고 싶다면 아래와 같이 사용하면 된다.

await _firestore
        .collection("read_test")
        .orderBy("rank", descending: true)
        .get();

이번에는 데이터가 저장된 순서대로 가져오고 싶다면 어떻게 해야될까 ? 데이터를 저장할 때에 DateTime을 저장하고 OrderBy 조건을 DateTime 값을 기준으로 정해주면 될 것이다.

위에서 생성한 Test 모델을 DateTime 값도 추가될 수 있도록 변경해보자. Firestore DB의 서버 시간은 DateTime을 기준으로 하지 않고 TimeStamp 값을 사용하여야 한다.

class Test {
  final int id;
  final int rank;
  final String name;
  final TimeStamp dateTime;

  Test({
    required this.id,
    required this.rank,
    required this.name,
    required this.dateTime,
  });

  factory Test.fromJson(Map<String, dynamic> json) {
    return Test(
      id: json["id"],
      rank: json["rank"],
      name: json["name"],
      dateTime: json["dateTime"],
    );
  }
  Map<String, dynamic> toJson() {
    return {
      "id": id,
      "rank": rank,
      "name": name,
      "dateTime": dateTime,
    };
  }
}

현재 시간을 추가하여 새로운 데이터를 추가해보자.

 await _firestore.collection("read_test").doc().set(Test(
                    id: i,
                    rank: i + 1,
                    name: "Tyger $i",
                    dateTime: TimeStamp.now(),
                  ).toJson());

이제 데이터를 불러와 보면 시간의 순서대로 불러와지는 것을 확인할 수 있다.

FirebaseFirestore _firestore = FirebaseFirestore.instance;
    QuerySnapshot<Map<String, dynamic>> _snapshot = await _firestore
        .collection("read_test")
        .orderBy("dateTime")
        .get();
    setState(() {
      testData = _snapshot.docs.map((e) => Test.fromJson(e.data())).toList();
    });

만약에 사용자 프로필 정보를 가지고 있는 DB에서 사용자가 로그인을 진행함과 동시에 로그인된 사용자의 프로필만 가져오기 위해서 어떻게 해야할까 ? 사용자 이메일 주소 등을 가져와서 위에서 Query 요청을 한 것처럼 호출해야 할까 ? 하지만 이메일 주소를 사용하지 않은 로그인인 경우에는 어떻게 해야할까

대부분 Firestore DB를 사용하는 프로덕트에서는 사용자 데이터 등 특정 사용자의 데이터 한 개만을 가지는 document를 생성하고 싶을 것이다. 이럴 때 사용할 수 있는게 Firebase Authentication에서 제공하는 UID 값을 document ID로 생성하는 것이다.

이렇게 하면 UID가 동일한 데이터는 쌓이지 않을 것이고, DB에서 Query를 요청하지 않고 직접 document만 호출할 수 있다.

아래의 코드를 통해서 자세히 알아보도록 하자.

먼저 Authentication 기능 중 익명(Anonymouse) 로그인을 사용하여 로그인을 진행하고 사용자 프로필을 생성하도록 하겠다. 그 다음 익명 로그인을 자동 로그인을 유지시킨 상태에서 앱 진입시 사용자 프로필을 조회해오는 기능을 만들어 보도록 하겠다.

Authentication의 signInAnonymously() 기능을 사용하여 익명 로그인을 진행하고 doc id로 UID 값을 사용하여 데이터를 저장하자.

FirebaseAuth _auth = FirebaseAuth.instance;
            FirebaseFirestore _firestore = FirebaseFirestore.instance;
            UserCredential _credential = await _auth.signInAnonymously();
            if (_credential.user != null) {
              await _firestore
                  .collection("user_profile")
                  .doc(_credential.user!.uid)
                  .set({
                "uid": _credential.user!.uid,
                "isAnonymous": _credential.user!.isAnonymous,
              });
            }

앱 진입시 Authentication의 현재 유저 정보를 받아와 UID 값을 통해서 원하는 document의 데이터 필드만 가져오도록 하자. 정상적으로 사용자 정보를 가져온 것을 확인할 수 있다.

FirebaseFirestore _firestore = FirebaseFirestore.instance;
FirebaseAuth _auth = FirebaseAuth.instance;
    User? _user = _auth.currentUser;
    if (_user != null) {
      DocumentSnapshot<Map<String, dynamic>> _snapshot =
          await _firestore.collection("user_profile").doc(_user.uid).get();
      logger.e(_snapshot.data());
    }

batch

이번에는 batch에 대해서 알아보도록 하자. 일괄쓰기라고도 한다. Firestore의 transaction과 비슷해보이지만 다른 차이점이 있다. 차이점은 transaction을 설명할 때 추가적으로 설명하도록 하겠다.

우리가 인스타그램의 팔로우/팔로잉 기능을 만든다고 가정해보자. 이런 기능을 만들게 되면 DB상에는 내가 팔로잉을 한 유저의 정보를 저장하면서, 내가 팔로잉 한 유저도 나를 팔로우한 유저의 정보로 저장을 하여야 할 것이다. 근데 한 쪽에서만 저장이 되고, 다른 쪽에서 저장이 되지 않았다면 어떻게 할 것인가 ?
콜백을 기다렸다가 실패가 되면 저장된 데이터를 다시 삭제해서 싱크를 맞췄어야 할 것인데, Firestore에서 제공하는 batch 기능을 사용하면 손쉽게 이러한 기능을 처리할 수 있게된다.

batch는 데이터를 한 번에 저장할 때에 실패가 발생하게 되면, 모든 데이터를 저장하지 않고 실패처리를 하는 기능이다.

testRef와 testRef2 라는 각각의 컬렉션을 생성해보자.

FirebaseFirestore _firestore = FirebaseFirestore.instance;
DocumentReference _testRef = _firestore.collection("batch_test").doc();
DocumentReference _test2Ref = _firestore.collection("batch_test_2").doc();
WriteBatch _batch = _firestore.batch();

batch를 세팅하여 각각 해당하는 reference를 넣어주고 데이터를 동일하게 해줬다. 그 다음에 batch에 등록을 하고 commit을 해주면, 2개의 batch 중 하나만 실패하게 되면 모두 저장되지 않게 된다.

_batch.set(_testRef, {"id": 1});
_batch.set(_test2Ref, {"id": 1});
_batch.commit();


Transaction

Transaction은 1개 이상의 문서에 대한 읽기 및 쓰기 작업의 집합이라고 한다. batch는 일괄 쓰기 작업으로 batch 작업은 각각 다른 컬렉션 및 도큐먼트에 대해서 쓰기 작업만 수행한다.

만약에 읽기와 쓰기 작업을 한 번에 동시에 처리해야 하는 경우에는 어떻게 해야될까 ? 이럴 때 사용할 수 있는 기능이 바로 transaction이다.

우리가 위에서 살펴본 인스타그램 좋아요 기능을 이번에는 단순히 팔로우/팔로잉 유저의 데이터를 저장하는게 아닌 좋아요 숫자 카운트 싱크를 맞춰야 한다고 가정해보자.

좋아요한 게시글에 현재 카운트 수에 +1을 하여 저장한다고 하자. 좋아요 카운트 숫자를 확실히 알고 있는 경우라면 문제가 없지만, 현재 카운트 수를 모르거나 또는 너무 많은 사용자가 한 번에 좋아요를 눌렀다고 한다면 좋아요 카운트를 읽어와 +1을 하여 저장하는 사이에 다른 사용자의 데이터로 먼저 저장이 된다면 카운트 수가 제대로 잡히지 않을 것이다. 이럴 경우 transaction 기능을 사용해 데이터를 읽어와 저장하는 작업을 수행하면 된다.

쉽게 생각해서 은행 서비스의 송금 시스템이라고 생각하면 된다.

우리가 batch를 알아보면서 사용한 batch_test 컬렉션의 id 값을 가져와 id값을 +1해서 저장하는 작업을 flutter에서 처리를 해보자.

먼저 runTransaction에서 해당하는 document의 id 값을 가져와 가져온 id값에 +1을 해서 저장을 하였다.

참고로 batch, transaction 모두 한 번에 500개 까지 처리가 가능하다고 한다.

FirebaseFirestore _firestore = FirebaseFirestore.instance;
DocumentReference _docRef = _firestore.collection("batch_test")
                                .doc("tzG7mrBJgb4CT5sYepiO");
await _firestore.runTransaction((transaction) async {
		DocumentSnapshot _snapshot = await transaction.get(_docRef);
			if (!_snapshot.exists) {
				throw Exception('Does not exists');
			}
			int _currentId = (_snapshot.data() as Map<String, dynamic>)["id"];
			_docRef.update({"id": _currentId + 1});
		});


Firestore Infinity Scroll

이번에는 Firestore의 무한 스크롤 기능을 구현한 것이다.

DB 데이터의 페이지네이션 처리를 하는 방법에는 두 가지 방법이 있다. 요청 페이지를 통해서 페이지네이션 처리를 할 수 있고, 마지막 불러온 데이터 이후 부터 불러올 수 있도록 하는 커서 기반의 페이지네이션 처리 방법이 있다.

Firestore DB는 커서 기반의 페이지네이션 처리를 지원하고 있다. startAt, startAfter 기능을 사용하면 페이지네이션을 손쉽게 처리할 수 있다.

먼저 startAt은 현재 불러온 마지막 데이터를 포함한 데이터를 호출하는 방식이고 startAfter는 마지막 데이터 이 후 데이터 부터 호출해오는 방식이다.

저는 UI 부분을 최대한 단순하게 사용하기 위해 DocumentSnapshot을 따로 분리하여 처리하도록 하겠다. DocumentSnapshot이 필요하지만 이렇게 되면 UI에서 데이터를 접근할 때에 코드가 지저분해져서 데이터 모델 자체를 사용하였다.

아래와 같이 모델을 만들어보자.

class _InfinityScrollModel {
  final int id;
  final String name;
  final Timestamp dateTime;

  _InfinityScrollModel({
    required this.id,
    required this.name,
    required this.dateTime,
  });

  factory _InfinityScrollModel.fromJson(Map<String, dynamic> json) {
    return _InfinityScrollModel(
      id: json["id"],
      name: json["name"],
      dateTime: json["dateTime"],
    );
  }
  Map<String, dynamic> toJson() {
    return {
      "id": id,
      "name": name,
      "dateTime": dateTime,
    };
  }
}

우선 데이터를 저장하도록 하자.

FirebaseFirestore _firestore = FirebaseFirestore.instance;
for (int i = 0; i < 100; i++) {
	await _firestore.collection("infinity_scroll").doc().set(InfinityScrollModel(
               id: i, name: "Tyger $i", dateTime: Timestamp.now()).toJson());
             }

infinityData 모델을 사용해서 UI 부분에 사용될 예정이고, DocumentSnapshot에 마지막으로 불러온 snapshot을 전달하여 페이지네이션에서 사용될 예정이다.

  List<_InfinityScrollModel> infinityData = [];
  DocumentSnapshot? lastSnapshot;

초기 진입시 데이터를 호출하는 기능이다. limit을 2개로 정하고 정렬 기준으로 dateTime 값을 사용하였다.

infinityData에는 객체를 전달하고, lastSnapshot에 DocumentSnapshot의 마지막 snapshot 값을 넣어주자.

  Future<void> _initData() async {
    FirebaseFirestore _firestore = FirebaseFirestore.instance;
    QuerySnapshot<Map<String, dynamic>> _snapshot = await _firestore
        .collection("infinity_scroll")
        .limit(2)
        .orderBy("dateTime")
        .get();
    setState(() {
      lastSnapshot = _snapshot.docs.last;
      infinityData = _snapshot.docs
          .map((e) => _InfinityScrollModel.fromJson(e.data()))
          .toList();
    });
  }

initState에 위에서 작성한 함수를 실행할 수 있도록 해주자.


  void initState() {
    _initData();
    super.initState();
  }

무한 스크롤에 사용할 함수이다. 여기서 보면 startAfterDocument가 있는데, 여기에 위에서 저장한 lastSnapshot 값을 던져주면 우리가 던져준 snapshot 다음 데이터 부터 불러오게 된다.

불러온 데이터의 마지막 snapshot값으로 lastSnapshot을 변경하여 관리를 해주자.

 Future<void> _infinityScroll() async {
    FirebaseFirestore _firestore = FirebaseFirestore.instance;
    QuerySnapshot<Map<String, dynamic>> _snapshot = await _firestore
        .collection("infinity_scroll")
        .orderBy("dateTime")
        .startAfterDocument(lastSnapshot!)
        .limit(2)
        .get();
    setState(() {
      lastSnapshot = _snapshot.docs.last;
      infinityData.addAll(_snapshot.docs
          .map((e) => _InfinityScrollModel.fromJson(e.data()))
          .toList());
    });
  }

Code

전체 소스코드이다. 코드 복사해서 사용해 보시면 페이지네이션 처리에 대해서 이해하기가 더 수월하실 겁니다.

import 'dart:async';
import 'dart:ui';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter/material.dart';
import 'package:flutter_velog_sample/_core/app_bar.dart';

class _InfinityScrollModel {
  final int id;
  final String name;
  final Timestamp dateTime;

  _InfinityScrollModel({
    required this.id,
    required this.name,
    required this.dateTime,
  });

  factory _InfinityScrollModel.fromJson(Map<String, dynamic> json) {
    return _InfinityScrollModel(
      id: json["id"],
      name: json["name"],
      dateTime: json["dateTime"],
    );
  }
  Map<String, dynamic> toJson() {
    return {
      "id": id,
      "name": name,
      "dateTime": dateTime,
    };
  }
}

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

  
  State<FirebaseFirestoreScreen> createState() =>
      _FirebaseFirestoreScreenState();
}

class _FirebaseFirestoreScreenState extends State<FirebaseFirestoreScreen> {
  List<_InfinityScrollModel> infinityData = [];
  DocumentSnapshot? lastSnapshot;

  Future<void> _initData() async {
    FirebaseFirestore _firestore = FirebaseFirestore.instance;
    QuerySnapshot<Map<String, dynamic>> _snapshot = await _firestore
        .collection("infinity_scroll")
        .limit(2)
        .orderBy("dateTime")
        .get();
    setState(() {
      lastSnapshot = _snapshot.docs.last;
      infinityData = _snapshot.docs
          .map((e) => _InfinityScrollModel.fromJson(e.data()))
          .toList();
    });
  }

  Future<void> _infinityScroll() async {
    FirebaseFirestore _firestore = FirebaseFirestore.instance;
    QuerySnapshot<Map<String, dynamic>> _snapshot = await _firestore
        .collection("infinity_scroll")
        .orderBy("dateTime")
        .startAfterDocument(lastSnapshot!)
        .limit(2)
        .get();
    setState(() {
      lastSnapshot = _snapshot.docs.last;
      infinityData.addAll(_snapshot.docs
          .map((e) => _InfinityScrollModel.fromJson(e.data()))
          .toList());
    });
  }

  
  void initState() {
    _initData();
    super.initState();
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: appBar(title: "Firebase Firestore"),
      body: ListView.separated(
        itemCount: infinityData.length,
        itemBuilder: (context, index) {
          return DefaultTextStyle(
            style: TextStyle(
                fontWeight: FontWeight.bold, color: Colors.accents[index % 15]),
            child: Padding(
              padding: const EdgeInsets.symmetric(horizontal: 20),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text("ID : ${infinityData[index].id}"),
                  Text("Rank : ${infinityData[index].name}"),
                  Text("DateTime : ${infinityData[index].dateTime.toDate()}"),
                  if (infinityData.length - 1 == index) ...[
                    SizedBox(
                      height: 100,
                      child: Center(
                        child: IconButton(
                          onPressed: () async {
                            await _infinityScroll();
                          },
                          icon: const Icon(
                            Icons.add_circle_outline,
                            size: 30,
                          ),
                        ),
                      ),
                    ),
                  ],
                ],
              ),
            ),
          );
        },
        separatorBuilder: (BuildContext context, int index) {
          return Padding(
            padding: const EdgeInsets.symmetric(vertical: 12),
            child: Container(
              height: 1,
              width: MediaQueryData.fromWindow(window).size.width,
              color: const Color.fromRGBO(91, 91, 91, 1),
            ),
          );
        },
      ),
    );
  }
}

Git

https://github.com/boglbbogl/flutter_velog_sample/blob/main/lib/firebase/firestore/firebase_firestore_screen.dart

마무리

Firestore 데이터베이스에 대해서 2편에 걸쳐 살펴보았다. 어느 정도 이해는 됬을 것이다.

Firestore는 여기서 다루지 않은 더 다양한 기능의 CRUD 부터 Query 기능이 더 많기 때문에 직접 사용해 보면서 배워보면 재밌는 경험이 될 것같다.

실제로 프로덕션 서비스에서 Firestore를 사용하고 있는 만큼, 백엔드를 완전히 대체할 수는 없지만 앱 개발자로써 결과물을 만들어 낼 수 있게 도와주는 고마운 서비스라고 생각한다.

다음 글에서는 Firestore 서비스 출시 이전에 대표 저장소였던 Realtime 데이터베이스에 대해서 살펴보도록 하겠다.

궁금한 점이나 잘 안되시는 부분 있으면 댓글 남겨주세요 !

profile
Flutter Developer

1개의 댓글

comment-user-thumbnail
2023년 9월 18일

최근 본 firestore 글 중에서 가장 디테일하게 설명되어있는것 같네요. 도움이 많이 되었어요 감사합니다

답글 달기