프로젝트/할일목록 앱 (RN)

4. 파이어베이스 데이터베이스에 할일목록 저장하고 불러오기

syleemomo 2023. 9. 14. 18:05
728x90

https://iamthejiheee.tistory.com/246

 

Firebase Realtime, Cloud Firestore [의미, 공통점, 차이점, 앱 기능에 따라 데이터베이스 추천]

우선 Firebase에 대해 먼저 알아보자! Firebase란 구글이 소유하고 있는 모바일 애플리케이션 개발 플랫폼이다. 앱을 개발하고 개선할 수 있는 도구 모음을 제공한다. 사실 처음에 Firebase는 단순히 데

iamthejiheee.tistory.com

https://rnfirebase.io/database/usage

 

Realtime Database | React Native Firebase

Copyright © 2017-2020 Invertase Limited. Except as otherwise noted, the content of this page is licensed under the Creative Commons Attribution 3.0 License, and code samples are licensed under the Apache 2.0 License. Some partial documentation, under the

rnfirebase.io

https://rnfirebase.io/firestore/usage

 

Cloud Firestore | React Native Firebase

Copyright © 2017-2020 Invertase Limited. Except as otherwise noted, the content of this page is licensed under the Creative Commons Attribution 3.0 License, and code samples are licensed under the Apache 2.0 License. Some partial documentation, under the

rnfirebase.io

https://invertase.io/blog/getting-started-with-cloud-firestore-on-react-native

 

Getting started with Cloud Firestore on React Native - Invertase

Building a TODO app with realtime updates using Firebase Cloud Firestore & React Native Firebase.

invertase.io

 

파이어베이스 콘솔의 해당 프로젝트 대쉬보드에서 데이터베이스 만들기를 수행한다. 우선 개발중인 경우에는 테스트 모드에서 시작 옵션을 선택한다. 

데이터베이스 위치는 서울로 설정한다.

 

Cloud Firestore 보안규칙을 수정해서 모든 사용자가 읽기/쓰기 권한을 가질수 있도록 허용한다.

https://firebase.google.com/docs/firestore/security/get-started?hl=ko&authuser=0&_gl=1*pbcfxt*_ga*MjE0Mjc0NzM2NS4xNjk0NzU1NjQ3*_ga_CW55HF8NVT*MTY5NDc1NTY0Ny4xLjEuMTY5NDc1NTk3Ni4wLjAuMA..#allow-all 

 

Cloud Firestore 보안 규칙 시작하기  |  Firebase

Google I/O 2023에서 Firebase의 주요 소식을 확인하세요. 자세히 알아보기 의견 보내기 Cloud Firestore 보안 규칙 시작하기 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하

firebase.google.com

rules_version = '2';

service cloud.firestore {
  match /databases/{database}/documents {

    // This rule allows anyone with your Firestore database reference to view, edit,
    // and delete all data in your Firestore database. It is useful for getting
    // started, but it is configured to expire after 30 days because it
    // leaves your app open to attackers. At that time, all client
    // requests to your Firestore database will be denied.
    //
    // Make sure to write security rules for your app before that time, or else
    // all client requests to your Firestore database will be denied until you Update
    // your rules
    match /{document=**} {
      allow read, write: if true;
    }
  }
}

https://momentjs.com/

 

Moment.js | Home

Format Dates moment().format('MMMM Do YYYY, h:mm:ss a'); moment().format('dddd'); moment().format("MMM Do YY"); moment().format('YYYY [escaped] YYYY'); moment().format(); Relative Time moment("20111031", "YYYYMMDD").fromNow(); moment("20120620", "YYYYMMDD"

momentjs.com

npm install moment

날짜/시간 포맷을 설정하기 위하여 moment 라이브러리를 설치한다.

 

* 파이어베이스 데이터베이스 조회/추가 api 구현하기

import firestore from '@react-native-firebase/firestore';

const getRef = (collections) => {
    return firestore().collection(collections);
}
export const addData = async (collections, data) => {
    await getRef(collections).add(data)
    console.log(`${collections} : ${JSON.stringify(data)} added in firestore!`)
}
export const getCollection = (collections, onResult, onError, query, order, limit) => {
    let ref = getRef(collections)
    
    // 조건쿼리
    if(query && query.exists && query.condition && query.condition.length !== 0){
        ref = ref.where(...query.condition)
    }
    if(order && order.exists && order.condition && order.condition.length !== 0){
        ref = ref.orderBy(...order.condition)
    }
    if(limit && limit.exists && limit.condition){
        ref = ref.limit(limit.condition)
    }
    return ref.onSnapshot(onResult, onError)
}
export const getCurrentTime = () => {
    return firestore.FieldValue.serverTimestamp()
}

루트 디렉터리에 apis 폴더를 생성하고, 그 안에 firebase.js 파일을 생성한 다음 위와 같이 작성한다. 

const getRef = (collections) => {
    return firestore().collection(collections);
}

getRef 함수는 Cloud Firestore 에서 쿼리를 하기 위한 참조값(reference)을 반환한다. 해당 함수는 외부에서 사용하지 않고, 파일 안에서만 사용된다. 

export const addData = async (collections, data) => {
    await getRef(collections).add(data)
    console.log(`${collections} : ${JSON.stringify(data)} added in firestore!`)
}

addData 함수는 collections 에 data 를 추가한다. 현재 collections 는 "todos" 컬렉션이다. data 는 할일목록에 추가할 TODO 객체이다. 해당 함수는 외부에서 사용할 것이므로 export 한다. 

export const getCollection = (collections, onResult, onError, query, order, limit) => {
    let ref = getRef(collections)
    
    // 조건쿼리
    if(query && query.exists && query.condition && query.condition.length !== 0){
        ref = ref.where(...query.condition)
    }
    if(order && order.exists && order.condition && order.condition.length !== 0){
        ref = ref.orderBy(...order.condition)
    }
    if(limit && limit.exists && limit.condition){
        ref = ref.limit(limit.condition)
    }
    return ref.onSnapshot(onResult, onError)
}

getCollection 함수는 collections 를 조회한다. 현재 collections 는 "todos" 컬렉션이다. 즉, 해당함수는 할일목록을 조회한다. 전체 할일목록이 저장된 초기 ref 에서 조건에 따라 검색/정렬/갯수제한을 순차적/선택적으로 수행한다. onSnapshot 메서드는 마지막 결과 ref (필터링된 할일목록) 을 onResult 함수로 전달한다. 해당 함수는 외부에서 사용할 것이므로 export 한다. 

export const getCurrentTime = () => {
    return firestore.FieldValue.serverTimestamp()
}

getCurrentTime 함수는 serverTimestamp 라는 static 메서드를 이용하여 새로운 할일이 Firestore 데이터베이스에 저장될때의 시각을 저장한다. 이렇게 하면 서로 다른 Timezone 에 사는 사용자들이 새로운 할일을 추가할때 자신의 로컬시간을 저장하지 않고, 동일한 서버 시각으로 통일할 수 있다. 

 

import React, { useState, useEffect, useRef } from 'react' // 카테고리 저장을 위한 useRef 임포트
import { 
  addData,
  getCollection,
  getCurrentTime
} from '../apis/firebase' // api 함수 (추가)

import { 
  SafeAreaView, 
  View, Text, 
  StyleSheet, 
  StatusBar, 
  Keyboard, 
  FlatList,
  TouchableHighlight 
} from 'react-native'

import DateHeader from '../components/DateHeader'
import Default from '../components/Default'
import TodoInsert from '../components/TodoInsert'
import TodoList from '../components/TodoList'
import DropdownItem from '../components/DropdownItem'

function HomeScreen({ navigation, caretType, setCaretType }){
  const date = new Date()
  const categories = ['자기계발', '업무', '오락', '여행', '연애', 'IT', '취미']
  const [todos, setTodos] = useState([])  // todos 초기화 (수정)
  const [todoText, setTodoText] = useState('')
  const [warning, setWarning] = useState(false)
  const [loading, setLoading ] = useState(true) // 로딩상태 (추가)

  // const [category, setCategory] = useState('')
  const category = useRef('') // 카테고리 변수


  const onInsertTodo = async (trimedText) => {  // async (추가)
    if(!category.current){ // 카테고리를 선택하지 않은 경우
      setTodoText('카테고리를 먼저 선택해주세요!')
      setWarning(true)
      return 
    }
    if(trimedText && trimedText.length > 3){ // 최소 글자수 제한
      // const nextId = todos.length + 1 // 주석처리
      // const todoContents = trimedText.split(',') // 주석처리
      // const createdTime = new Date() // 주석처리

      if(todos.filter(todo => todo.title === trimedText).length > 0){ // trimedText (수정)
        setTodoText('중복된 할일입니다.')
        setWarning(true)
      }else{
        const newTodo = { // 필드 변경 및 수정 (수정)
          title: trimedText,
          category: category.current || '자기계발', // 선택한 카테고리 설정 
          isDone: false,
          createdAt: getCurrentTime(), // 클라이언트 기준이 아니라 서버기준 저장시각 
          // createdAt: `${createdTime.getFullYear()}-${createdTime.getMonth()+1}-${createdTime.getDate()}`
        }
        await addData('todos', newTodo) // Firestore 에 새로운 할일 추가 (추가)
        Keyboard.dismiss() // 추가버튼 클릭시 키보드 감추기 
        setTodoText('') // 입력창 초기화
        category.current = '' // 카테고리 초기화 
      }
    }else{
      console.log('3자 이상 입력하세요!')
      setTodoText('3자 이상 입력하세요!')
      setWarning(true)
    }
  }

  const closeDropdown = () => {
    caretType && setCaretType(false)
  }
  const selectCategory = (item, e) => { // 카테고리 드롭다운 선택시
    console.log("카테고리: ", item)
    closeDropdown()
    category.current = item 
  }
  const handleOutSideOfMenu = (e) => {
    console.log('홈화면을 터치하셨습니다.')
    closeDropdown()
  }

  useEffect(() => navigation.addListener('focus', () => console.log('페이지 로딩')), [])

  useEffect(() => navigation.addListener('blur', () => console.log('페이지 벗어남')), [])

  useEffect(() => { // 할일목록 조회 (추가)
    function onResult(querySnapshot){
      const list = []
      querySnapshot.forEach(doc => {
        console.log(doc.data())
        list.push({
          ...doc.data(),
          id: doc.id,
        })
      })
      setTodos(list)

      if (loading) {
        setLoading(false)
      }
    }
    function onError(error){
      console.error(`${error} occured when reading todos`)
    }
    return getCollection('todos', 
                          onResult, onError,
                          null,
                          {exists: true, condition: ['createdAt', 'asc']},
                          null)
  }, [])

  if (loading) { // 로딩화면 (추가)
    return (
      <View>
        <Text>로딩중...</Text>
      </View> 
    )
  }

  return (
    <SafeAreaView 
        style={styles.block} 
        onTouchStart={handleOutSideOfMenu}>
      <StatusBar backgroundColor="#a8c8ffff"></StatusBar>
      
        {caretType 
        && (
            <View 
              style={styles.dropdownShadow}
              onTouchStart={(e) => { // 터치 시작점 설정 : 캡쳐링 방지 
                console.log('여기를 지나침')
                e.stopPropagation() // 터치 버블링 방지
              }}
              >
              <FlatList
                data={categories} 
                keyExtractor={item => item}
                renderItem={({item}) => (
                  <DropdownItem category={item} selectCategory={(e) => selectCategory(item, e)}/> // 아이템 각각의 뷰 화면 : 카테고리 선택시 이벤트핸들러 함수 등록
                )}
                style={styles.dropdownList}
              />
          </View>
        )}
        <DateHeader date={date}/>
        {todos.length === 0 ? <Default/> : <TodoList todos={todos} />}
        <TodoInsert 
          onInsertTodo={onInsertTodo} 
          todoText={todoText} 
          setTodoText={setTodoText} 
          warning={warning} 
          setWarning={setWarning}/>
    </SafeAreaView>
  )
}

const styles = StyleSheet.create({
  block: {
    flex: 1,
  },
  dropdownList: {
    padding: 5
  },
  dropdownShadow: {
    shadowOffset: { width: 0, height: 20 },
    shadowColor: '#000',
    shadowOpacity: 0.25,
    backgroundColor : "#fff", // invisible color
    zIndex: 1,
    elevation: 1,
    position: 'absolute',
    top: -15,
    borderRadius: 5,
    margin: 15
  }
})
export default HomeScreen

 screens > HomeScreen.js 파일을 위와 같이 수정한다. 

import { 
  addData,
  getCollection,
  getCurrentTime
} from '../apis/firebase'

Cloud Firestore API 를 사용하기 위한 함수를 임포트한다. 

const [todos, setTodos] = useState([])

todos 배열을 빈 배열([])로 초기화한다. 

const [loading, setLoading ] = useState(true)

로딩상태를 조회하기 위하여 loading 상태를 정의한다.

const onInsertTodo = async (trimedText) => {
// 중략
}

Cloud Firestore 데이터베이스에 비동기로 접속하기 우하여 async 키워드를 추가한다.

// const nextId = todos.length + 1
// const todoContents = trimedText.split(',')
// const createdTime = new Date()

새로운 할일에 대한 id 값은 Cloud Firestore 데이터베이스에서 자동으로 생성하므로 주석처리한다. 할일 생성시각도 데이터베이스에서 자동으로 생성해주므로 주석처리한다. 

if(todos.filter(todo => todo.title === trimedText).length > 0){
    setTodoText('중복된 할일입니다.')
    setWarning(true)
  }else{
    // 중략
  }

newTodo.title 을 trimedText 로 수정하였다. 이유는 할일이 중복되면 데이터베이스에 저장하지 않으므로 굳이 그전에 newTodo 객체를 생성할 필요가 없기 때문이다. 

const newTodo = {
  title: trimedText,
  category: category.current || '자기계발', // 선택한 카테고리 설정 (수정)
  isDone: false,
  createdAt: getCurrentTime(), // 클라이언트 기준이 아니라 서버기준 저장시각
  // createdAt: `${createdTime.getFullYear()}-${createdTime.getMonth()+1}-${createdTime.getDate()}`
}
await addData('todos', newTodo)

새로운 할일인 newTodo 객체에서 id 필드는 제거한다. 왜냐하면 데이터베이스에서 자동으로 생성해주기 때문이다. title 값은 trimedText 그대로 전달해준다. 이전에는 사용자가 입력한 텍스트(trimedText)에서 콤마(,)로 할일과 카테고리를 구분해서 저장했지만 카테고리는 드롭다운에서 선택하므로 더이상 그렇게 할 필요가 없다. isDone 필드는 디폴트값으로 false 로 설정한다. 할일 생성시각은 getCurrentTime 함수를 사용하여 자동으로 저장한다. 마지막으로 addData 함수를 이용하여 'todos' 컬렉션에 새 할일을 추가한다.

useEffect(() => {
    function onResult(querySnapshot){
      const list = []
      querySnapshot.forEach(doc => {
        console.log(doc.data())
        list.push({
          ...doc.data(),
          id: doc.id,
        })
      })
      setTodos(list)

      if (loading) {
        setLoading(false)
      }
    }
    function onError(error){
      console.error(`${error} occured when reading todos`)
    }
    return getCollection('todos', 
                          onResult, onError,
                          null,
                          {exists: true, condition: ['createdAt', 'asc']},
                          null)
  }, [])

useEffect 안에 콜백함수를 등록한다. 콜백함수 안에서는 getCollection 함수를 이용하여 Cloud Firestore 에서 할일목록을 조회한다. onResult 는 쿼리에 성공한 경우에 실행할 함수이다. querySnapshot 은 할일목록 중 일부가 추가/변경/삭제될때마다 언제나 최근에 업데이트된 할일목록을 새롭게 조회한다. 이는 getCollection 함수가 Cloud Firestore 의 onSnapshot 메서드를 호출하기 때문이다. doc.data() 는 하나의 할일 정보이다. getCollection 의 4~6번째 파라미터는 할일목록을 조회할때 각각 검색/정렬/갯수 제한을 추가로 설정할 수 있다. 설정값은 exists 와 condition 을 프로퍼티로 가지는 객체를 설정하거나 null 을 설정하면 된다. 

if (loading) {
    return (
      <View>
        <Text>로딩중...</Text>
      </View> 
    )
  }

Cloud Firestore 에서 데이터를 조회하는 동안 보여줄 로딩화면이다.

<FlatList
    data={todos}
    style={styles.container}
    keyExtractor={item => item.id}
    ItemSeparatorComponent={() => <View style={styles.line}/>}
    renderItem={({item}) => (
        <TodoItem {...item}/> // 아이템 각각의 뷰 화면
    )}
/>

components > TodoList.js 파일에서 해당 부분을 위와 같이 수정한다.

keyExtractor={item => item.id.toString()}

 각 TODO 의 키 값 설정을 아래와 같이 수정한다.

keyExtractor={item => item.id}

 

<TodoItem item={item}/>

하나의 할일에 대한 정보를 담고 있는 item 객체를 그대로 props 로 전달하지 않고, 아래와 같이 풀어서 전달한다.

<TodoItem {...item}/> // 아이템 각각의 뷰 화면

 

import React from 'react'
import { View, Text, StyleSheet } from 'react-native'
import moment from 'moment' // 시간포맷 설정 (추가)

function TodoItem({ id, title, category, isDone, createdAt }){ // TODO 속성 풀어헤치기 (수정)
    console.log("할일 생성시각: ", title, createdAt) // 디버깅용 (추가)
    return (
        <View style={styles.item}>
            <View style={styles.titleMargin}>
                <Text style={styles.title}>{title}</Text>
            </View>
            <View>
                <Text>{category} ({isDone ? "종료": "진행중"})</Text>
                <Text style={styles.dateText}>{createdAt && moment(createdAt.toDate()).format('hh:mm:ss')}</Text>
            </View>
        </View>
    )
}

const styles = StyleSheet.create({
    item: {
        flexDirection: 'row',
        alignItems: 'flex-start',
        paddingLeft: 10,
        paddingVertical: 10,
        // backgroundColor: '#d6e3ffff',
        // borderBottomWidth: 1,
        // borderBottomColor: '#a8c8ffff',
    },
    titleMargin: {
        marginRight: 10
    },
    title: {
        fontWeight: 'bold',
        fontSize: 20,
    },
    dateText: {
        fontSize: 12
    }
})

export default React.memo(TodoItem) // 컴포넌트 캐슁

components > TodoItem.js 파일을 위와 같이 작성한다. 

import moment from 'moment'

할일의 생성시각에 대한 포맷을 설정하기 위하여 임포트한다.

function TodoItem({ id, title, category, isDone, createdAt }){
// 중략
}

할일에 대한 속성값(props)을 모두 풀어서 전달받는다.

<View style={styles.item}>
    <View style={styles.titleMargin}>
        <Text style={styles.title}>{title}</Text>
    </View>
    <View>
        <Text>{category} ({isDone ? "종료": "진행중"})</Text>
        <Text style={styles.dateText}>{createdAt && moment(createdAt.toDate()).format('hh:mm:ss')}</Text>
    </View>
</View>

 item.title 은 title 로, item.category 는 category 로, item.createdAt 은 createdAt 으로 수정한다. isDone 값에 따라 할일에 대한 종료여부를 표시한다. createdAt 은 Cloud Firestore 의 타임스탬프 포맷이다. 해당 포맷은 seconds 나 nanoseconds 단위의 숫자값이다. 타임스탬프에서 제공하는 toDate 함수를 이용하면 날짜 포맷으로 변경된다. 또한, 이를 다시 moment 라이브러리를 이용하여 특정 포맷으로 변경한다.

export default React.memo(TodoItem)

React.memo 를 이용하면 해당 할일에 대한 정보가 변경되지 않으면 Virtual DOM 에 새로 렌더링되지 않는다. 즉, 전체 할일목록에서 업데이트된 특정 할일에 대한 TodoItem 컴포넌트만 새로 렌더링되고 나머지 할일에 대한 TodoItem 컴포넌트는 캐슁된다. 

 

* 파이어베이스에 새로운 할일 추가하고 조회하기 테스트

Firestore database
할일목록 시간순으로 정렬해서 보여주기

 

728x90