Skip to content

D-Sup/PlanOn

Repository files navigation

플랜온


📱 앱 이용해보기

테스트 계정
ID : [email protected]
PW : test123


앱소개

Intro

플랜온(Plan-On)은 자신만의 일정을 관리하고, 일정에 대한 이야기를 공유할 수 있는 플랫폼입니다. 플랜온과 함께 당신의 하루를 계획하고, 공유해보세요!


플랜온에서는,

  1. 구글 및 깃허브 소셜 로그인을 통해 간편하게 이용할 수 있습니다.
  2. 일정을 짜고 지도를 통해 동선을 안내받을 수 있습니다.
  3. 일정 완료 후 관련 스토리를 게시하고 공유 할 수 있습니다.
  4. 필요한 장소 정보를 지도에서 쉽게 확인할 수 있습니다.
  5. 마음에 드는 유저를 팔로우하거나 필터 기능을 통해 원하는 스토리를 찾을 수 있습니다.
  6. 다른 유저와 실시간 채팅이 가능합니다.
  7. 다른 유저와 원활한 소통을 위해 푸시 알림을 지원합니다.
  8. PWA(Progressive Web App) 기술을 사용하여 다양한 환경에서 원활하게 이용할 수 있습니다.

개발 일정

Schedule


개발 환경

사용 기술                
백엔드 서비스      
API  
패키지  
포맷터
빌드  
배포  

아키텍처

Architecture


앱 살펴보기

초기화면


앱설치 스플래시

회원가입 페이지


이메일 회원가입 구글 회원가입 깃허브 회원가입

로그인 페이지


이메일 로그인 구글 로그인 깃허브 로그인

스토리 페이지


앱 소개 스토리 상호작용
필터 검색
스토리 추가 스토리 수정 스토리 삭제

일정 페이지


일정 일정 추가
일정 수정 일정 삭제

맵뷰 페이지


위치 상세보기

채팅 페이지


채팅 상호작용 채팅방 나가기

프로필 페이지


팔로잉 프로필 편집

설정 페이지


다크모드 알림설정 잠금설정
폰트설정 문의하기 로그아웃/계정탈퇴

푸시 알림


알림 전송 백그라운드 알림 포그라운드 알림
백그라운드 딥링크 포그라운드 딥링크

링크 공유


스토리 공유 일정 공유 위치 공유

구현 과정 소개

아토믹 디자인 패턴 적용

Why?

아토믹 디자인 패턴이 제공하는 컴포넌트의 명확한 분류 체계와 재사용 가능성은
1인 개발 환경에서 큰 이점으로 작용할 것이라 생각했습니다.

팀 프로젝트의 경우, 각 컴포넌트를 어떻게 분류할지 모호하고 논의할 여지가 많지만
1인 개발에서는 이러한 기준을 쉽게 확립할 수 있고
이를 통해 디자인 과정에서 자연스럽게 컴포넌트 재활용성을 높이는 구조로 설계할 수 있습니다.

또한, 작은 단위의 컴포넌트부터 상위 레벨의 컴포넌트를 설계해보면서
컴포넌트에 대한 개념성을 높일 수 있는 기회라고 여겼습니다.

How?

아토믹 디자인에서는 컴포넌트를 atom, molecule, organism, template, page의 5가지 레벨로 나누는데,
각 레벨에 대해 나누는 기준을 아래와 같이 정의하여 프로젝트를 진행했습니다.

├── 📂components
  ├── 📁atoms
  ├── 📁molecules
  ├── 📁organisms
  ├── 📁pages
  ...

atom

  • 더 이상 분해할 수 없는 컴포넌트
  • 단일적으로 사용하기 어려운 컴포넌트
  • molecule 혹은 organism 단위에서 유용하게 사용될 수 있는 원소 단위의 컴포넌트

molecule

  • atom, molecule으로 구성할 수 있는 컴포넌트
  • 버튼 그룹이나 입력 필드와 레이블을 포함하는 폼 요소 등과 같이 한 가지 역할을 명확하게 수행하는 컴포넌트
  • 컨텍스트 없이 단순히 UI를 구성하는 작은 단위의 컴포넌트

organism

  • atom, molecule, organism으로 구성할 수 있는 컴포넌트
  • atom과 molecule에 비해 더 구체적으로 표현된 컴포넌트
  • 특정 컨텍스트를 가지는 컴포넌트
  • 독립성이 높은 컴포넌트
  • 재사용성은 낮은 컴포넌트

template

  • page에서 데이터 결합을 제거하여 layout과 비슷한 역할을 하는 컴포넌트
  • 프로젝트에 포함되는 각 페이지는 독립적이고 서로 다른 레이아웃 스타일을 가졌기 때문에 template 레벨의 이점을 충분히 활용할 수 없다고 판단하여 해당 레벨을 생략했습니다.

page

  • molecule, organism으로 구성할 수 있는 컴포넌트
  • 컴포넌트를 레이아웃에 배치하여 실제 콘텐츠를 담고 있는 페이지 단위의 컴포넌트


DB 계층 구조를 고려한 CRUD 설계

Why?

FireStore는 Firebase에서 지원하는 NoSQL 데이터베이스 서비스로,
collection, document, field라는 계층 구조를 가지고 있습니다.
FireStore는 이러한 계층 구조에서 데이터를 추가, 읽기, 업데이트, 삭제하는 작업이 반복적으로 수행되기 마련입니다.
이로 인해, 비슷한 코드 패턴이 여러 곳에서 반복되면서 코드의 중복성이 증가할 수 있다고 판단했습니다.

How?

FireStore와의 유연한 통신을 지원하는 CRUD(Create, Read, Update, Delete) 훅을 구현하고, 각 기능에 맞게 활용했습니다.

💡 useFirestoreCreate

FireStore의 addDoc, setDoc, updateDoc 메서드를 활용하여
새로운 문서 생성, 특정 ID로 문서 생성, 배열 필드에 데이터 추가, 객체 필드에 데이터 추가, 서브컬렉션에 데이터 생성 등의 기능을 제공하도록 구현했습니다.

코드보기
  • createDocument - 새로운 문서 생성
    addDoc 메서드를 사용하여 새로운 문서를 컬렉션에 추가할 수 있습니다.

  • createDocumentManual - 특정 ID로 문서 생성
    setDoc 메서드를 사용하여 특정 ID를 가진 문서를 생성할 수 있습니다.

  • createFieldArray - 배열 필드에 데이터 추가
    updateDoc 메서드와 arrayUnion 메서드를 사용하여 배열 필드에 데이터를 추가할 수 있습니다.

  • createFieldObject - 객체 필드에 데이터 추가
    updateDoc 메서드와 arrayUnion 메서드를 사용하여 객체 형태의 데이터를 배열 필드에 추가할 수 있습니다.

  • createSubcollection - 서브컬렉션에 데이터 생성
    서브컬렉션 경로를 지정하고 addDoc 메서드를 사용하여 데이터를 추가할 수 있습니다.

import { appFireStore, timestamp } from "../firebase/config";
import { collection, doc, setDoc, addDoc, updateDoc, arrayUnion } from "firebase/firestore";

import getAccountId from "@/utils/getAccountId";

import { DataModelType, ModelValue } from "@/types/dataModel.type";

const useFirestoreCreate = (collectionName: string) => {

  const accountId = getAccountId()
  const createdAt = timestamp.fromDate(new Date());

  const createDocument = async (data: DataModelType): Promise<undefined | string> => {
    try {
      if (accountId) {
        const docRef = await addDoc(collection(appFireStore, collectionName),
          {
            ...data,
            authorizationId: accountId,
            createdAt
          });
        console.log("데이터가 성공적으로 생성되었습니다:", docRef.id);
        return docRef.id
      } else {
        console.error("토큰이 존재하지 않습니다");
      }
    } catch (error) {
      console.error("데이터 생성을 실패했습니다:", error);
      throw error;
    }
  }

  const createDocumentManual = async (documentId: string, data: DataModelType): Promise<undefined | string> => {
    try {
      if (accountId) {
        const docRef = doc(appFireStore, collectionName, documentId);
        await setDoc(docRef, {
          ...data,
          authorizationId: accountId,
          createdAt
        });
        console.log("데이터가 성공적으로 생성되었습니다:", docRef.id);
        return docRef.id;
      } else {
        console.error("토큰이 존재하지 않습니다");
      }
    } catch (error) {
      console.error("데이터 생성을 실패했습니다:", error);
      throw error;
    }
  }

  const createFieldArray = async (documentId: string, fieldName: string, data: ModelValue): Promise<undefined | string> => {
    try {
      if (accountId) {
        const docRef = doc(appFireStore, collectionName, documentId);
        await updateDoc(docRef, {
          [fieldName]: arrayUnion(data)
        });
        console.log("데이터가 성공적으로 추가되었습니다", docRef.id);
        return docRef.id;
      } else {
        console.error("토큰이 존재하지 않습니다");
      }
    } catch (error) {
      console.error("데이터 생성을 실패했습니다:", error);
      throw error;
    }
  }

  const createFieldObject = async (documentId: string, fieldName: string, data: DataModelType): Promise<undefined | string> => {
    try {
      if (accountId) {
        const docRef = doc(appFireStore, collectionName, documentId);
        const createData = {
          [fieldName]: arrayUnion({ ...data, createdAt })
        };
        await updateDoc(docRef, createData);
        console.log("데이터가 성공적으로 추가되었습니다", docRef.id);
        return docRef.id;
      } else {
        console.error("토큰이 존재하지 않습니다");
      }
    } catch (error) {
      console.error("데이터 생성을 실패했습니다:", error);
      throw error;
    }
  }

  const createSubcollection = async (documentId: string, subcollectionName: string, data: DataModelType): Promise<undefined | string> => {
    try {
      if (accountId) {
        const subcollectionRef = collection(appFireStore, `${collectionName}/${documentId}/${subcollectionName}`);
        const subDocRef = await addDoc(subcollectionRef, {
          ...data,
          createdAt
        });
        console.log("서브컬렉션 내 문서가 성공적으로 생성되었습니다:", subDocRef.id);
        return subDocRef.id;
      } else {
        console.error("토큰이 존재하지 않습니다");
      }
    } catch (error) {
      console.error("서브컬렉션 내 문서 생성을 실패했습니다:", error);
      throw error;
    }
  }

  return { createDocument, createDocumentManual, createFieldArray, createFieldObject, createSubcollection };
}

export default useFirestoreCreate;

💡 useFirestoreRead

FireStore의 getDoc, getDocs, query, where, limit, startAfter 메서드를 활용하여
특정 문서 읽기, 조건부 쿼리 읽기, 특정 서브컬렉션 읽기 등의 기능을 제공하도록 구현했습니다.

코드보기
  • readDocumentSingle - 특정 문서 읽기
    getDoc 메서드를 사용하여 특정 문서를 읽어올 수 있습니다.

  • readDocumentAll - 모든 문서 읽기
    getDoc 메서드를 사용하여 특정 문서를 읽어올 수 있습니다.

  • readDocumentsSimplePaged - 조건부 쿼리 및 정렬을 통한 문서 페이지네이션
    limit과 startAfter 메서드를 사용하여 조건 필터링 및 정렬된 문서를 페이지 단위로 불러올 수 있습니다.

  • readDocumentsPaged - 정렬을 통한 문서 페이지네이션
    limit과 startAfter 메서드를 사용하여 정렬된 문서를 페이지 단위로 불러올 수 있습니다.

  • readDocumentQuery - 조건부 쿼리 읽기
    query와 where 메서드를 결합하여 조건에 맞는 문서를 읽어올 수 있습니다.

  • readSubCollection - 특정 서브컬렉션 내 조건부 쿼리 읽기
    서브컬렉션 경로를 지정하고, query와 where 메서드를 활용하여 조건에 맞는 서브컬렉션 내의 문서를 읽어올 수 있습니다.

import { appFireStore } from "../firebase/config";
import {
  collection,
  query,
  orderBy,
  where,
  getDocs,
  doc,
  getDoc,
  startAfter,
  limit,
  OrderByDirection,
  QueryConstraint,
  WhereFilterOp,
  DocumentData,
  Timestamp
} from "firebase/firestore";
import useModalStack from "./useModalStack";

export interface ReadDocumentType<T> {
  id: string;
  data: T;
}

const useFirestoreRead = (collectionName: string) => {

  const { openModal } = useModalStack()

  const readDocumentSingle = async <T>(documentId: string): Promise<undefined | ReadDocumentType<T>> => {
    try {
      const docRef = doc(appFireStore, collectionName, documentId);
      const docSnapshot = await getDoc(docRef);
      const result = { id: docSnapshot.id, data: docSnapshot.data() as T };
      return result;
    } catch (error) {
      console.error("데이터 조회를 실패했습니다:", error);
      throw error;
    }
  };

  const readDocumentAll = async <T>(sortFieldName?: string, sortOrder: OrderByDirection = "desc"): Promise<undefined | ReadDocumentType<T>[]> => {
    try {
      const collectionRef = collection(appFireStore, collectionName);
      const querySnapshot =
        sortFieldName !== undefined && sortOrder !== undefined ?
          await getDocs(query(collectionRef, orderBy(sortFieldName, sortOrder))) :
          await getDocs(collectionRef)
      const result = querySnapshot.docs.map((doc) => ({
        id: doc.id,
        data: doc.data() as T
      }));
      return result;
    } catch (error) {
      console.error("데이터 조회를 실패했습니다:", error);
      throw error;
    }
  };

  const readDocumentsSimplePaged = async <T>(
    data: ReadDocumentType<T>[],
    fieldName: string,
    whereOperator: WhereFilterOp,
    filterValues: string[],
    sortFieldName: string,
    sortOrder: OrderByDirection = "desc",
    includePrivate: boolean,
    pageSize: number = 1,
    handleFunc?: (data: DocumentData | boolean) => void,
    lastVisible?: null | DocumentData,
    isDataEnd?: boolean
  ): Promise<undefined | ReadDocumentType<T>[]> => {
    try {
      if (isDataEnd && lastVisible) {
        openModal("Toast", { type: "info", message: "데이터를 모두 불러왔습니다." });
        return data;
      }
      const collectionRef = collection(appFireStore, collectionName);
      const documents: ReadDocumentType<T>[] = data;
      if (fieldName === "id") {
        const docRefs = filterValues.map(id => doc(collectionRef, id));
        const documentSnapshots = await Promise.all(docRefs.map(docRef => getDoc(docRef)));
        documentSnapshots.forEach(doc => {
          if (doc.exists()) {
            documents.push({ id: doc.id, data: doc.data() as T });
          }
        });
        documents.sort((a, b) => {
          const aSeconds = (a.data as Record<string, Timestamp>)[sortFieldName].seconds;
          const bSeconds = (b.data as Record<string, Timestamp>)[sortFieldName].seconds;

          if (sortOrder === "asc") {
            return aSeconds - bSeconds;
          } else {
            return bSeconds - aSeconds;
          }
        });
      } else {
        let q;
        if (!includePrivate) {
          if (lastVisible) {
            q = query(
              collectionRef,
              where(fieldName, whereOperator, filterValues.length === 0 ? [""] : filterValues),
              where("private", "==", false),
              orderBy(sortFieldName, sortOrder),
              startAfter(lastVisible),
              limit(pageSize)
            );
          } else {
            q = query(
              collectionRef,
              where(fieldName, whereOperator, filterValues.length === 0 ? [""] : filterValues),
              where("private", "==", false),
              orderBy(sortFieldName, sortOrder),
              limit(pageSize)
            );
          }
        } else {
          if (lastVisible) {
            q = query(
              collectionRef,
              where(fieldName, whereOperator, filterValues.length === 0 ? [""] : filterValues),
              orderBy(sortFieldName, sortOrder),
              startAfter(lastVisible),
              limit(pageSize)
            );
          } else {
            q = query(
              collectionRef,
              where(fieldName, whereOperator, filterValues.length === 0 ? [""] : filterValues),
              orderBy(sortFieldName, sortOrder),
              limit(pageSize)
            );
          }
        }

        const documentSnapshots = await getDocs(q);

        documentSnapshots.forEach((doc) => {
          documents.push({ id: doc.id, data: doc.data() as T });
        });

        if (documentSnapshots.size < pageSize) {
          isDataEnd = true;
          lastVisible && pageSize !== Infinity && openModal("Toast", { type: "info", message: "데이터를 모두 불러왔습니다." });
        }

        if (documentSnapshots.size > 0) {
          handleFunc && handleFunc(documentSnapshots.docs[documentSnapshots.docs.length - 1]);
        } else {
          handleFunc && handleFunc(true);
        }
      }
      return documents;
    } catch (error) {
      console.error("데이터 조회를 실패했습니다:", error);
      throw error;
    }
  };

  const readDocumentsPaged = async <T>(
    data: ReadDocumentType<T>[],
    sortFieldName: string,
    sortOrder: OrderByDirection = "desc",
    pageSize: number = 1,
    handleFunc: (data: DocumentData | boolean) => void,
    lastVisible: null | DocumentData,
    isDataEnd: boolean
  ): Promise<undefined | ReadDocumentType<T>[]> => {
    try {
      if (isDataEnd) {
        openModal("Toast", { type: "info", message: "데이터를 모두 불러왔습니다." });
        return data;
      }
      const collectionRef = collection(appFireStore, collectionName);
      let q;
      if (lastVisible) {
        q = query(
          collectionRef,
          where("private", "==", false),
          orderBy(sortFieldName, sortOrder),
          startAfter(lastVisible),
          limit(pageSize)
        );
      } else {
        q = query(
          collectionRef,
          where("private", "==", false),
          orderBy(sortFieldName, sortOrder),
          limit(pageSize)
        );
      }
      const documentSnapshots = await getDocs(q);
      const lastDocs = documentSnapshots.docs[documentSnapshots.docs.length - 1];

      if (lastDocs) {
        handleFunc(lastDocs);
      } else {
        openModal("Toast", { type: "info", message: "데이터를 모두 불러왔습니다." });
        handleFunc(true);
      }
      const documents: ReadDocumentType<T>[] = data;
      documentSnapshots.forEach(doc => {
        documents.push({ id: doc.id, data: doc.data() as T });
      });
      return documents;
    } catch (error) {
      console.error("데이터 조회를 실패했습니다:", error);
      throw error;
    }
  };


  const readDocumentQuery = async <T>(fieldName: string, operator: WhereFilterOp, value: string | string[]): Promise<undefined | ReadDocumentType<T>[]> => {
    try {
      const q = query(
        collection(appFireStore, collectionName),
        where(fieldName, operator, value)
      );
      const querySnapshot = await getDocs(q);
      const result = querySnapshot.docs.map((doc) => ({
        id: doc.id,
        data: doc.data() as T,
      }));
      return result;
    } catch (error) {
      console.error("데이터 조회를 실패했습니다:", error);
      throw error;
    }
  };

  const readSubCollection = async <T>(
    documentId: string,
    subCollectionName: string,
    whereFieldName?: string,
    whereOperator?: WhereFilterOp,
    whereValue?: string | null
  ): Promise<undefined | ReadDocumentType<T>[]> => {
    try {
      const subCollectionRef = collection(appFireStore, collectionName, documentId, subCollectionName);
      const queryConstraints: QueryConstraint[] = [];

      if (whereFieldName) {
        queryConstraints.push(where(whereFieldName, whereOperator, whereValue));
      }

      const q = query(subCollectionRef, ...queryConstraints);
      const querySnapshot = await getDocs(q);

      const result = querySnapshot.docs.map((doc) => ({
        id: doc.id,
        data: doc.data() as T,
      }));

      return result;
    } catch (error) {
      console.error("서브 컬렉션 데이터 조회를 실패했습니다:", error);
      throw error;
    }
  };

  return { readDocumentSingle, readDocumentAll, readDocumentsSimplePaged, readDocumentsPaged, readDocumentQuery, readSubCollection };
};

export default useFirestoreRead;

💡 useFirestoreUpdate

FireStore의 updateDoc 메서드를 활용하여
문서 업데이트, 특정 필드 업데이트 등의 기능을 제공하도록 구현했습니다.

코드보기
  • updateField - 문서의 특정 필드 업데이트
    updateDoc 메서드를 사용하여 문서의 특정 필드를 업데이트할 수 있습니다.

  • updateFieldObject - 배열 필드 내 조건을 만족하는 특정 객체 업데이트
    updateDoc 메서드와 getDoc 메서드를 사용하여 배열 필드 내 조건을 만족하는 특정 객체를 찾아 업데이트할 수 있습니다.

import { appFireStore } from "../firebase/config";
import { doc, updateDoc, getDoc } from "firebase/firestore";

import getAccountId from "@/utils/getAccountId";

import { DataModelType } from "@/types/dataModel.type";


const useFirestoreUpdate = (collectionName: string) => {

  const accountId = getAccountId()

  const updateField = async (documentId: string, data: DataModelType): Promise<undefined | string> => {
    try {
      if (accountId) {
        const docRef = doc(appFireStore, collectionName, documentId);
        const updateData: DataModelType = {};
        for (const [key, value] of Object.entries(data)) {
          updateData[key] = value;
        }
        await updateDoc(docRef, updateData);
        console.log("데이터가 성공적으로 수정되었습니다", docRef.id);
        return docRef.id;
      } else {
        console.error("토큰이 존재하지 않습니다");
      }
    } catch (error) {
      console.error("데이터 수정을 실패했습니다:", error);
      throw error;
    }
  };

  const updateFieldObject = async (
    documentId: string,
    fieldName: string,
    targetObject: DataModelType,
    updateAttributes: DataModelType
  ): Promise<undefined | string> => {
    try {
      const docRef = doc(appFireStore, collectionName, documentId);
      const docSnapshot = await getDoc(docRef);
      if (docSnapshot.exists()) {
        const result = docSnapshot.data()[fieldName];
        for (let i = 0; i < result.length; i++) {
          const element = result[i];
          const isTargetObject = Object.keys(targetObject).every(key => element[key] === targetObject[key]);
          if (isTargetObject) {
            result[i] = { ...element, ...updateAttributes };
            break;
          }
        }
        await updateDoc(docRef, {
          [fieldName]: result
        });
        console.log("데이터가 성공적으로 수정되었습니다", docRef.id);
        return docRef.id;
      } else {
        console.error("해당 문서를 찾을 수 없습니다: ", documentId);
        throw new Error(`해당 문서를 찾을 수 없습니다: ${documentId}`);
      }
    } catch (error) {
      console.error("데이터 수정을 실패했습니다:", error);
      throw error;
    }
  };

  return { updateField, updateFieldObject };
};

export default useFirestoreUpdate;

💡 useFirestoreDelete

FireStore의 deleteDoc, query, where, getDocs 메서드를 활용하여
단일 문서 삭제, 배열 필드 특정 데이터 삭제, 객체 필드 특정 데이터 삭제, 서브컬렉션 내 문서 삭제 등의 기능을 제공하도록 구현했습니다.

코드보기
  • deleteDocument - 단일 문서 삭제
    deleteDoc 메서드를 사용하여 특정 문서를 삭제할 수 있습니다.

  • deleteFieldArray - 배열 필드 특정 데이터 삭제
    updateDoc 메서드와 arrayUnion 메서드를 사용하여 배열 필드에서 특정 원소를 삭제할 수 있습니다.

  • deleteFieldObject - 객체 필드 특정 데이터 삭제
    updateDoc 메서드와 getDoc 메서드를 사용하여 배열 필드 내 조건을 만족하는 특정 객체를 찾아 삭제할 수 있습니다.

  • deleteSubcollection - 서브컬렉션 내 모든 문서 삭제
    getDocs, deleteDoc 메서드를 사용하여 서브컬렉션 내의 모든 문서를 삭제할 수 있습니다.

import { appFireStore } from "../firebase/config";
import { doc, deleteDoc, getDoc, updateDoc, arrayRemove, collection, query, getDocs } from "firebase/firestore";

import getAccountId from "@/utils/getAccountId";

import { DataModelType, ModelValue } from "@/types/dataModel.type";

const useFirestoreDelete = (collectionName: string) => {

  const accountId = getAccountId()

  const deleteDocument = async (documentId: string): Promise<undefined | string> => {
    try {
      if (accountId) {
        const docRef = doc(appFireStore, collectionName, documentId);
        await deleteDoc(docRef);
        console.log("데이터가 성공적으로 삭제되었습니다", docRef);
        return docRef.id;
      } else {
        console.error("토큰이 존재하지 않습니다");
      }
    } catch (error) {
      console.error("데이터 삭제를 실패했습니다:", error);
      throw error;
    }
  };

  const deleteFieldArray = async (documentId: string, fieldName: string, data: ModelValue): Promise<undefined | string> => {
    try {
      if (accountId) {
        const docRef = doc(appFireStore, collectionName, documentId);
        await updateDoc(docRef, {
          [fieldName]: arrayRemove(data)
        });
        console.log("데이터가 성공적으로 삭제되었습니다", docRef.id);
        return docRef.id;
      } else {
        console.error("토큰이 존재하지 않습니다");
      }
    } catch (error) {
      console.error("데이터 삭제를 실패했습니다:", error);
      throw error;
    }
  }

  const deleteFieldObject = async (documentId: string, fieldName: string, targetObject: DataModelType): Promise<undefined | string> => {
    try {
      if (accountId) {
        const docRef = doc(appFireStore, collectionName, documentId);
        const docSnapshot = await getDoc(docRef);
        if (docSnapshot.exists()) {
          const result = docSnapshot.data()[fieldName];
          for (let i = 0; i < result.length; i++) {
            const element = result[i];
            const isTargetObject = Object.keys(targetObject).every(key => element[key] === targetObject[key]);
            if (isTargetObject) {
              result.splice(i, 1);
              break;
            }
          }
          await updateDoc(docRef, {
            [fieldName]: result
          });
          console.log("데이터가 성공적으로 삭제되었습니다", docRef.id);
          return docRef.id;
        } else {
          console.error("해당 문서를 찾을 수 없습니다: ", documentId);
        }
      } else {
        console.error("토큰이 존재하지 않습니다");
      }
    } catch (error) {
      console.error("데이터 삭제를 실패했습니다:", error);
      throw error;
    }
  }

  const deleteSubcollection = async (documentId: string, subcollectionName: string): Promise<void> => {
    try {
      const subcollectionRef = collection(appFireStore, `${collectionName}/${documentId}/${subcollectionName}`);
      const q = query(subcollectionRef);
      const querySnapshot = await getDocs(q);

      const deletePromises = querySnapshot.docs.map(doc => deleteDoc(doc.ref));
      await Promise.all(deletePromises);

      console.log("데이터가 성공적으로 삭제되었습니다", subcollectionName);
    } catch (error) {
      console.error("데이터 삭제를 실패했습니다:", error);
      throw error;
    }
  };

  return { deleteDocument, deleteFieldArray, deleteFieldObject, deleteSubcollection };
};

export default useFirestoreDelete;

🔒 FireStore 보안 규칙 설정

유연한 통신이 가능한 만큼,
보안 규칙을 통해 각 사용자에게 적절한 접근 권한을 부여하여
무단 접근과 불필요한 데이터 노출을 방지할 수 있어야 했습니다.
이를 위해 FireStore 보안 규칙을 작성했습니다.

코드보기
rules_version = "2";

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

    match /hashtags/{hashtagId} {
      allow read: if true;
      allow create: if request.auth != null;
      allow update, delete: if request.auth != null && request.resource.data.diff(resource.data).affectedKeys().hasOnly(["taggedPostIds"]);
    }
    
    match /locations/{locationId} {
      allow read: if true;
      allow create: if request.auth != null;
      allow update, delete: if request.auth != null && request.resource.data.diff(resource.data).affectedKeys().hasOnly(["taggedPostIds"]);
    }
    
    match /posts/{postId} {
      allow read: if true;
      allow create: if request.auth != null;
  		allow update, delete: if (request.auth != null && request.auth.uid == resource.data.authorizationId) ||
      	(request.auth != null && (request.resource.data.diff(resource.data).affectedKeys().hasOnly(["likedUsers"]))) ||
        (request.auth != null && (request.resource.data.diff(resource.data).affectedKeys().hasOnly(["comments"]))) 
    }
    
    match /schedules/{scheduleId} {
      allow read: if true;
      allow create: if request.auth != null;
      allow update, delete: if request.auth != null && request.auth.uid == resource.data.authorizationId;
    }
    
    match /users/{userId} {
      allow read: if true;
      allow create: if true;
      allow update, delete: if (request.auth != null && request.auth.uid == resource.data.authorizationId) ||
      	(request.auth != null && (request.resource.data.diff(resource.data).affectedKeys().hasOnly(["followers"]))) ||
				(request.auth != null && (request.resource.data.diff(resource.data).affectedKeys().hasOnly(["followers"]))) ||
				(request.auth != null && (request.resource.data.diff(resource.data).affectedKeys().hasOnly(["chats"]))) ||
        (request.auth != null && (request.resource.data.diff(resource.data).affectedKeys().hasOnly(["notificationHistory"])))
    }
    
    match /chats/messages/{messageId} {
      match /{document=**} {
      	allow read, write: if true;
    	}	
    }
    
    match /message-6TH4LJn9lGl7P3ngwR05/{messageId} {
      match /{document=**} {
      	allow read, write: if true;
    	}	
    }
    
  }
}


React Query 기반 쿼리 및 뮤테이션 훅 생성

Why?

React Query를 통한 데이터 패칭, 업데이트가 빈번해지면서,
여러 컴포넌트에서 공통적으로 사용하는 쿼리(서버로부터 데이터를 읽어오는 작업)와 뮤테이션(서버의 데이터를 변경하는 작업)의 수가 증가했습니다.
이에 따라, 각 컴포넌트에서 동일한 형태의 쿼리와 뮤테이션을 반복적으로 import하고 호출해야 했고,
이 과정에서 발생하는 비효율성을 해소하고자 했습니다.

How?

React Query는 데이터를 읽기 위한 useQuery와 데이터를 변형하기 위한 useMutation이라는 두 가지 훅을 제공합니다.
이를 활용하여 데이터 패칭, 캐싱, 에러 처리, 업데이트 등
다양한 옵션을 포함한 훅을 구현하고, 모든 데이터의 패칭, 업데이트 과정에 적용했습니다.

💡 useDataQuery

useQuery 훅 기반으로
데이터의 신선도 및 캐시 시간, 초기 데이터 패칭 여부를 설정할 수 있는 기능을 제공하도록 구현했습니다.

코드보기
  • queryKey
    쿼리를 식별하는 키로, 해당 키를 통해 캐싱과 쿼리 무효화를 관리할 수 있습니다.

  • queryFn
    데이터를 패치하는 함수입니다.

  • select
    데이터를 선택하고 변환할 수 있는 옵션으로, 쿼리에서 데이터를 가져온 후 원하는 형태로 가공할 수 있습니다.

  • queryOptions
    staleTime, cacheTime 등의 옵션을 설정하여 데이터의 신선도와 캐시 시간을 관리할 수 있습니다.

  • enabled
    쿼리 실행 여부를 결정할 수 있는 옵션으로, 초기 데이터 패칭을 제어할 수 있습니다.

import { useQuery } from "@tanstack/react-query";

export interface QueryOptions {
  staleTime?: number;
  gcTime?: number;
}

const useDataQuery = <TQueryFnData, TError, TData>(
  queryKey: string, 
  queryFn: any, 
  select: any,
  queryOptions?: QueryOptions,
  enabled = true
) => {

  const { data, isLoading, isSuccess, isError, isFetching, refetch } = useQuery<TQueryFnData, TError, TData>({
    queryKey: [queryKey],
    queryFn: queryFn,
    select: select,
    enabled: enabled,
    ...queryOptions
  })  

  return { data, isLoading, isSuccess, isError, isFetching, refetch }
}

export default useDataQuery

💡 useDataMutation

useMutation 훅 기반으로
데이터 변형 후 콜백 함수, 뮤테이션 성공 시 쿼리 무효화 여부를 설정할 수 있는 기능을 제공하도록 구현했습니다.

코드보기
  • mutationKey
    뮤테이션을 식별하는 키입니다.

  • mutationFn
    데이터를 변형하는 함수입니다.

  • onSuccess
    뮤테이션이 성공했을 때 호출되는 콜백 함수입니다.

  • onError
    뮤테이션이 실패했을 때 호출되는 콜백 함수입니다.

  • clientUpdate
    성공 시 쿼리를 무효화할지 여부를 결정할 수 있는 옵션입니다.

import { useMutation, useQueryClient } from "@tanstack/react-query";

const useDataMutation = (
  mutationKey: any, 
  mutationFn: any,
  onSuccess: () => void,
  onError: () => void,
  clientUpdate = true
) => {

  const queryClient = useQueryClient();
    
  const { mutate, isPending } = useMutation({
    mutationKey: [mutationKey],
    mutationFn: mutationFn,
    onSuccess: () => {
      onSuccess()
      clientUpdate && queryClient.invalidateQueries(mutationKey)
    },
    onError: () => {
      onError()
    },
  })

  return { mutate, isPending };
};

export default useDataMutation;


재조회와 낙관적 업데이트

How?

데이터를 업데이트 하는 과정에서
게시물 처리나 프로필 수정 등과 같이 정확하고 신뢰할 수 있는 업데이트가 필요한 경우에는
재조회(쿼리 무효화)를 통해 항상 최신 데이터를 가져오도록 구현했습니다.

반면, 데이터의 중요도는 상대적으로 낮지만 상호작용이 빈번하고
사용자에게 즉각적인 피드백을 통해 시스템이 빠르게 반응한다는 인식을 심어줘야 하는 경우에는
낙관적 업데이트 방식을 적용했습니다.

💡 재조회

queryClient의 invalidateQueries 기능을 사용했습니다.
이를 통해 GET 요청 시 생성된 쿼리 키와 일치하는 캐시 데이터를 무효화하고,
쿼리가 다시 실행될 때 서버로부터 최신 데이터를 받아와서 데이터를 갱신하도록 했습니다.

코드보기
onSuccess: () => {
  onSuccess()
  clientUpdate && queryClient.invalidateQueries(mutationKey)
},

💡 낙관적 업데이트

onMutate 메서드와 onError 메서드를 사용했습니다.
onMutate 메서드를 통해 API 요청이 성공했을 경우를 가정하여, 데이터를 가공해 화면을 업데이트 하고,
onError 메서드를 통해 API 요청을 보내 성공을 가정하고 화면은 업데이트 했지만,
만약 서버에서 에러를 반환했다면 기존 데이터로 풀백하도록 했습니다.

코드보기
  • queryKeys
    업데이트할 여러 쿼리 키를 정의합니다.

  • mutationFn
    데이터 변형을 수행하는 함수로, 서버에 데이터 변경을 요청하는 로직이 포함됩니다.

  • cancelQueries
    API 요청 이후에 발생하는 요청을 취소하는 메서드로 요청이 에러가 발생했다면 refetch가 일어나지 않도록 방지합니다.

  • oldPostsData
    각 쿼리 키에 대한 이전 데이터를 저장합니다. 만약 나중에 에러가 발생했다면 데이터 복원을 위해 사용됩니다.

  • setQueryData
    쿼리 키에 대한 데이터를 낙관적으로 업데이트를 수행하는 메서드로, 업데이트하는 로직이 포함됩니다.

  • onError
    오류가 발생하면 oldPostsData 통해 저장해두었던 데이터를 사용하여 이전 데이터를 복원합니다.

import { useMutation, useQueryClient } from "@tanstack/react-query";

...

const LikeService = () => {

  ...

  const queryClient = useQueryClient();

  const queryKeys = ["all-posts", "following-posts", "like-posts", "tag-posts", "single-posts"];

  return useMutation({
    mutationFn: (...) => {
      ...
    },
    onMutate: (...) => {
      queryKeys.forEach(async (key) => {
        await queryClient.cancelQueries({ queryKey : [key] });
      });
    
      const oldPostsData = queryKeys.map((key) => queryClient.getQueryData<...>([key]));
    
      queryKeys.forEach((key, index) => {
        const currentOldPostsData = oldPostsData[index];
          queryClient.setQueryData([key], () => {
            ...
          })
      });
    
      return { oldPostsData };
    },
    onError: (error: Error, _, context: { oldPostsData?: ... } | undefined) => {
      if (context?.oldPostsData) {
        queryKeys.forEach((key) => {
          queryClient.setQueryData([key], context.oldPostsData);
        });
      } else {
        console.error(error);
        throw error;
      }
    },
  });
};

export default LikeService;


Context API를 활용한 쿼리 상태 전달

Why?

사용자의 회원 정보나 설정 값은
앱이 로드되는 시점에서 데이터 패칭을 통해 불러와야 했습니다.
이를 위해 useQuery 훅을 통해 반환된 쿼리 상태(data, isLoading, isFetching, refetch)를 활용했지만,
이러한 상태를 여러 컴포넌트에서 일관성 있게 재사용할 수 있도록 구현하는 방식이 필요했습니다.

How?

useQuery 훅을 통해 반환받은 data, isLoading, isFetching, refetch 등의 쿼리 상태를
Context API를 활용하여 부모 컴포넌트에서 자식 컴포넌트로 전달하는 방식으로 여러 컴포넌트에서 상태를 공유하도록 했습니다.

코드보기

UserInfoProvider 라는 컴포넌트를 생성하여
Context API를 통해 쿼리 상태를 제공하고,
useContext 훅을 통해 자식 컴포넌트에서 해당 상태를 사용할 수 있도록 구현했습니다.

import { useRef, useEffect, createContext } from "react";

import UserService from "@/services/userService";

interface UserContextType {
  data: ReadDocumentType<UsersType> | undefined,
  isLoading: boolean,
  isFetching: boolean,
  refetch: () => void
}

const UserContext = createContext<UserContextType>({
  data: undefined,
  isLoading: true,
  isFetching: true,
  refetch: () => { }
});

const UserInfoProvider = ({ children }: { children: React.ReactNode }) => {

  const { ReadUser } = UserService();
  const { data, isLoading, isFetching, refetch } = ReadUser();

  return (
    <UserContext.Provider value={{ data, isLoading, isFetching, refetch }}>
      <div
        id="wrapper"
        className="flex flex-col bg-background w-screen h-dvh transition duration-300"
      >
        {children}
      </div>
    </UserContext.Provider>
  );
};

export { UserInfoProvider, UserContext };

const App = () => {

  const location = useLocation();

  return (
    <UserInfoProvider>
      <RouteTransition location={location}>
        <Routes location={location}>
          ...
        </Routes>
      </RouteTransition>
      <NavigationMenu />
      <ModalStack />
    </UserInfoProvider>
  )
}
import { useContext } from "react";
import { UserContext } from "./UserInfoProvider";

const Component = () => {

  const { data, data, isLoading, isFetching, refetch } = useContext(UserContext);

  ...

  return (
    ...
  )
}


데이터 임시저장

How?

게시물이나 일정을 작성하는 도중 페이지를 이탈할 경우를 대비하여
작성에 필요한 폼에 대해 데이터가 변경되었는지 확인하고,
폼의 초기값과 일치하지 않을 경우, 팝업을 띄워 임시 저장을 할 수 있도록 안내했습니다.

코드보기
  • selectorFamily
    Recoil에서 동적 파라미터를 받아 셀렉터를 생성할 수 있게 해주는 API 입니다.

  • hasValueChangedSelector
    selectorFamily를 통해 상태의 초기값과 현재값을 받고, SON.stringify를 통해 깊은 비교를 진행하여 두 값이 다른지 여부를 반환합니다.

  • isPostFormModifiedSelector
    hasValueChangedSelector를 사용하여 현재 작성 중인 폼 상태값이 초기값과 다른지 확인할 수 있습니다.

import { selector, selectorFamily } from "recoil";

import { postFormValueDefault, postFormValue } from "../atoms/postFormValueAtom";

type Params = {
  defaultValue: any;
  compareValue: any;
};

export const hasValueChangedSelector = selectorFamily<boolean, Params>({
  key: "hasValueChangedSelector",
  get: ({ defaultValue, compareValue }) => ({ get }) => {
    const currentValue = get(compareValue);
    return JSON.stringify(defaultValue) !== JSON.stringify(currentValue);
  },
});

export const isPostFormModifiedSelector = selector({
  key: "isPostFormModifiedSelector",
  get: ({get}) => {
    const hasChanged = get(hasValueChangedSelector({ defaultValue: postFormValueDefault, compareValue: postFormValue }));
    return hasChanged;
  },
});
import { useRecoilValue } from "recoil";
import {isPostFormModifiedSelector } from "@/store";

const PostPage = () => {

  const isPostFormModified = useRecoilValue(isPostFormModifiedSelector);

  return (
    <FeedHeader 
      handleFunc={
        () => {
          if (isPostFormModified) {
            openModal("Alert", "이전에 작성하던 내용이 있습니다.", ["새로 작성", "이어서 작성"], [
              () => {
                closeModal()
                resetPostFormState()
                navigate("/post/update", {
                  state: { direction: "next" },
                })
              },
              () => {
                closeModal()
                navigate("/post/update", {
                  state: { direction: "next" },
                })
              }
            ])
          } else {
            navigate("/post/update", {
              state: { direction: "next" },
            })
          }
        }
      }
    />
  )
}


검색 디바운스

Why?

Firebase와 같은 클라우드 서비스 사용 시 요청 수에 따라 비용이 증가할 수 있습니다.
이에 따라 사용자가 검색 기능을 이용할 때,
입력할 때마다 불필요한 요청이 다수 발생하면 비용이 급증할 수 있기 때문에
이를 방지하고 경제적인 부담을 최소화하고자 하였습니다.

How?

사용자가 검색 기능을 이용 시
입력을 할 때마다 서버에 요청을 보내는 대신,
입력이 멈춘 후 일정 시간이 지나도 추가 입력이 없을 때만 요청을 보내도록 했습니다.

코드보기

입력값이 변경될 때마다 이전 타이머를 클리어하고 새 타이머를 설정합니다.
만약, 일정 시간 동안 입력값이 변경되지 않으면
입력이 모두 완료되었다고 판단하고 isInputDone 상태를 반환하여 isInputDone이 true일 때 데이터 패칭을 진행하도록 했습니다.

import { useState, useEffect } from "react";

const useDebounce = (value: string, delay: number, fetching: boolean) => {
  const [isInputDone, setIsInputDone] = useState<boolean>(false);
  const [isFetching, setIsFetching] = useState<boolean>(fetching);
  const [timerId, setTimerId] = useState<ReturnType<typeof setTimeout> | null>(null);

  useEffect(() => {
    if (timerId) {
      clearTimeout(timerId);
      setIsInputDone(false);
    }

    if (value) {
      setIsFetching(true);
      setTimerId(setTimeout(() => {
        setIsInputDone(true);
      }, delay));
    }
  }, [value, delay]);

  useEffect(() => {
    if (!fetching) {
      setIsFetching(false);
    }
  }, [fetching])

  return { isInputDone, isFetching };
}


export default useDebounce;
import useDebounce from "@/hooks/useDebounce";
import LocationService from "@/services/locationService";

const LocationLinkList = ( ... ) => {

  const { SearchLocation } = LocationService()
  const { data, isFetching: isFetchingSearchLocation, refetch } = SearchLocation()

  const { isInputDone, isFetching } = useDebounce(inputValueState, 500, isFetchingSearchLocation);

  useEffect(() => {
    if (isInputDone) {
      refetch()
    }
  }, [isInputDone])

  return (
    {isFetching && 
      ...
    }
  )
}


무한 스크롤

Why?

데이터 패칭 시 대량의 데이터를 한 번에 불러올 경우
네트워크 트래픽이나 페이지 로딩 시간이 크게 증가할 수 있기 때문에
초기 페이지 로드 시 모든 콘텐츠를 불러오는 대신,
필요한 만큼만 데이터를 점진적으로 불러오는 방식이 필요했습니다.

How?

스크롤이 상단이나 하단으로 일정 수준 도달하면,
마지막으로 불러온 문서를 기준으로 다음 문서부터 데이터를 불러오는 방식으로 무한스크롤을 구현하여
불필요한 요청을 최소화할 수 있었습니다.

코드보기

사용자가 페이지 상단이나 하단에 도달했는지를 감지하여
도달했을 때 데이터 패칭 함수를 호출해 더 많은 데이터를 불러오도록 했습니다.
상단에 도달했을 경우에는 새로운 데이터가 추가된 후
문서의 전체 높이에서 새로운 데이터가 추가되기 이전에 저장된 스크롤 위치를 빼는 방법으로
자연스럽게 이어지는 데이터를 볼 수 있도록 했습니다.

import { useState, useEffect } from "react";

const useScrollTop = (): boolean => {
  const [isTop, setIsTop] = useState<boolean>(false);

  useEffect(() => {
    const handleScroll = (): void => {
      const scrollTop = window.scrollY || document.documentElement.scrollTop;
      setIsTop(scrollTop <= 50);
    };

    window.addEventListener("scroll", handleScroll);

    return () => {
      window.removeEventListener("scroll", handleScroll);
    };
  }, []);

  return isTop;
}

export default useScrollTop;
import useScrollTop from "@/hooks/useScrollTop";

const ChatRoomPage = () => {

  const [scrollPosition, setScrollPosition] = useState<number>(0)

  const isTop = useScrollTop()

  useEffect(() => {
    if (isTop) {
      loadMoreMessages();
    }
  }, [isTop]);

  useEffect(() => {
    if (scrollPosition !== 0) {
      const newScrollHeight = document.body.scrollHeight;

      window.scrollTo({
        top: newScrollHeight - scrollPosition,
        behavior: "auto"
      });

      setScrollPosition(0);
    }
  }, [scrollPosition]);
}

import { useState, useEffect } from "react";

const useScrollBottom = (): boolean => {
  const [isBottom, setIsBottom] = useState<boolean>(false);

  useEffect(() => {
    const handleScroll = (): void => {
      const scrollTop = window.scrollY || document.documentElement.scrollTop;
      const clientHeight = window.innerHeight;
      const scrollHeight = document.documentElement.scrollHeight;

      setIsBottom(scrollTop + clientHeight + 50 >= scrollHeight);
    };

    window.addEventListener("scroll", handleScroll);

    return () => {
      window.removeEventListener("scroll", handleScroll);
    };
  }, []);

  return isBottom;
}

export default useScrollBottom;
import useScrollBottom from "@/hooks/useScrollBottom";

const PostPage = () => {

  const isBottom = useScrollBottom();

  useEffect(() => {
    if (isBottom) {
      if (currentCategory === "all-posts") {
        refetchPostAll()
      } else if (currentCategory === "following-posts") {
        refetchPostFollow()
      } else if (currentCategory === "like-posts") {
        refetchPostLike()
      } else if (currentCategory === "tag-posts") {
        refetchPostTag()
      }
    }
  }, [isBottom]);
}


스크롤 위치 저장

Why?

라우트 이동 시 스크롤 위치가 초기화되는 동작은
사용자가 페이지를 탐색할 때의 연속성과 편의성을 크게 좌우한다고 생각했습니다.
따라서, 스크롤 위치를 저장하여 사용자가 이전에 보던 위치로 쉽게 되돌아갈 수 있도록 하고자 하였습니다.

How?

페이지 별로 스크롤 위치값을 전역 상태로 관리하여
스크롤 이벤트가 발생할 때마다 현재 페이지의 스크롤 위치를 저장하고,
사용자가 다른 페이지로 이동한 후 다시 돌아올 때,
저장된 스크롤 위치로 돌아가도록 구현했습니다.

코드보기
import { atom } from "recoil";

interface scrollPositionValueType {
  post: number,
  schedule: number,
  message: number,
  setting: number
}

export const scrollPositionValueDefault = {
  post: 0,
  schedule: 0,
  message: 0,
  setting: 0
};

export const scrollPositionValue = atom<scrollPositionValueType>({
  key: "scrollPositionValue",
  default: scrollPositionValueDefault,
});
import { useRecoilState } from "recoil"

const [scrollPositionValueState, setScrollPositionValueState] = useRecoilState(scrollPositionValue)

const pathToSave = [
  "/post",
  "/schedule",
  "/message",
  "/setting",
];

const handleScroll = () => {
  const scrollTop = window.scrollY || document.documentElement.scrollTop

  if (pathToSave.includes(location.pathname)) {
    setScrollPositionValueState(prev => ({
      ...prev,
      [location.pathname.slice(1)]: scrollTop
    }));
  }
};

useEffect(() => {
  if (location.pathname === "/post" || location.pathname === "/schedule" || location.pathname === "/message" || location.pathname === "/setting") {
    window.scrollTo({
      behavior: "smooth",
      top: (location.pathname === "/post" && scrollPositionValueState["post"] ||
        location.pathname === "/schedule" && scrollPositionValueState["schedule"] ||
        location.pathname === "/message" && scrollPositionValueState["message"] ||
        location.pathname === "/setting" && scrollPositionValueState["setting"]) || 0
    })
  }

  window.addEventListener("scroll", handleScroll);

  return () => {
    window.removeEventListener("scroll", handleScroll);
  };
}, [navigate]);


Intersection Observer API를 활용한 채팅 읽음 처리

Why?

웹 애플리케이션에서 채팅 룸에 접속한 상태로 다른 작업을 하는 경우
사용자의 의도와 다르게 채팅이 읽히는 것을 방지하려고 했습니다.
이는 실제로 사용자가 채팅을 읽었는지 정확하게 판단하고
잘못된 읽음 표시로 인한 혼선을 방지하기 위함이었습니다.

How?

Intersection Observer API를 활용하여 특정 메시지가 사용자의 뷰포트에 진입할 때,
Firestore의 해당 채팅에 해당하는 문서 값이 업데이트 되도록 했습니다.
또한, 현재 로그인 된 계정 ID와 메시지의 사용자 ID를 비교하여,
본인의 메시지가 아닐 경우에만 읽음 상태를 업데이트하도록 구현했습니다.

코드보기

메시지 요소에 대한 ref를 설정하여, 이를 Intersection Observer로 관찰하도록 했습니다.
메시지가 뷰포트에 들어오면 observe 이벤트가 발생하여 Firestore 필드를 업데이트하고,
메시지 요소가 뷰포트에서 벗어나면 unobserve를 호출하여 관찰을 중지하게 됩니다.

import useFirestoreUpdate from "@/hooks/useFirestoreUpdate";

interface MessagesType {
  userId: string,
  isRead: boolean,
  id?: string,
  isLocal?: boolean
  text?: string,
  link?: LinkType
  photoURL?: string[],
  createdAt?: Timestamp,
}

const ChatMessageBox = ( { data }: { data: MessagesType } ) => {
  const messageRef = useRef<HTMLLIElement | null>(null);
  const { id, userId, text, photoURL, link, isRead, createdAt, isLocal } = data

  const { updateFieldObject } = useFirestoreUpdate("users")

  const accountId = getAccountId()

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting && !data.isRead && accountId !== userId) {
            db.collection(`chats/messages/message-${chatRoomId}`).doc(id).update({
              isRead: true,
            });
          }
        });
        if (isRead === false && userId !== accountId) {
          updateFieldObject(
            accountId,
            "chats",
            { id: chatRoomId },
            {
              unreadLength: 0,
            }
          )
        }
      },
      {
        rootMargin: "0px",
        threshold: 0.1,
      }
    );

    if (messageRef.current) {
      observer.observe(messageRef.current);
    }

    return () => {
      if (messageRef.current) {
        observer.unobserve(messageRef.current);
      }
    };
  }, [data]);

  return (
    <li ref={messageRef}>
      ...
    </li>
  )
}


모달 관리

Why?

프로젝트에서 다양한 유형의 모달이 필요했기 때문에,
모달 컴포넌트를 유형별로 렌더링하고, 효율적으로 모달을 관리할 수 있는 방식이 필요했습니다.

How?

모달을 효과적으로 관리하기 위해 전역 상태로 관리되는 모달 스택을 정의했습니다.
원하는 모달을 스택에 추가하면, 추가된 모달은 switch-case 문을 통해 적절한 모달 컴포넌트가 렌더링되고,
닫고자 하는 모달은 스택에서 제거하여 모달 스택을 효율적으로 관리할 수 있도록 했습니다.

💡 modalStackAtom

modalStackAtom은 모달 스택을 관리하는 atom입니다.
해당 atom은 모달 스택의 각 항목을 배열 형태로 저장하고,
각 항목은 특정 모달 컴포넌트와 그 모달이 작동하는 데 필요한 속성들을 포함하고 있습니다.

코드보기
  • Component - 모달 컴포넌트의 타입을 정의할 수 있습니다.
    React.ComponentType, 문자열 리터럴 타입, null 타입을 정의하여 특정 모달 컴포넌트를 지정할 수 있습니다.

  • props - 모달 컴포넌트에 전달될 추가 속성들입니다.
    사용자 정의 데이터나 콜백 함수 등을 포함시키고, 이를 모달 컴포넌트에서 사용할 수 있습니다.

  • selectOptions - 모달 내에서 사용자가 선택할 수 있는 옵션들의 배열입니다.
    각 항목은 문자열 또는 null일 수 있습니다.

  • actions - 모달에서 실행될 수 있는 액션(함수)들의 배열입니다.
    각 항목은 함수 또는 null일 수 있습니다.

  • isOpen - 모달의 열림/닫힘 상태를 나타내는 불리언 값입니다.
    해당 불리언 값을 통해 side effect를 발생시켜 모달을 열고 닫을 수 있습니다.

import { atom } from "recoil";

export interface ComponentProps {
  closeModal: () => void,
  props: any,
  handleScroll: (e: React.UIEvent<HTMLDivElement>) => void,
  handleScrollLock: () => void,
  selectOptions?: (null | string)[],
  actions?: (null | (() => void))[]
}

export interface ModalStackType {
  Component: React.ComponentType<ComponentProps> | "Toast" | "Loading" | "Alert" | "Popup" | "PhotoView" | null,
  props?: any,
  selectOptions?: (null | string)[],
  actions?: (null | (() => void))[],
  isOpen?: boolean,
}

export const modalStackDefault = [
    {
      Component: null,
      actions: [],
      isOpen: false,
      props: {},
      selectOptions: [],
    }
  ]

export const modalStack = atom<ModalStackType[]>({
  key: "modalStack",
  default: modalStackDefault
});

💡 useModalStack

모달 스택을 관리하는 여러 함수들을 제공합니다.
해당 함수들을 통해 모달을 열고 닫거나, 모달의 속성을 업데이트할 수 있습니다.

코드보기
  • openModal - 새로운 모달을 스택에 추가합니다.

  • closeModal - 가장 최근에 활성화된 모달의 isOpen 불리언값을 false로 변경합니다.

  • closeModalDirect - 모달을 즉시 제거합니다.

  • updateModal - 가장 최근에 활성화된 모달의 속성을 업데이트 합니다

  • clearModal - 모달 스택을 초기화합니다.

import useBodyScrollLock from "./useBodyScrollLock";
import { useResetRecoilState, useRecoilState, useRecoilValue } from "recoil";
import { modalStack, isModalStackModifiedSelector, inputValue, ComponentProps } from "@/store";
import { produce } from "immer"

const useModalStack = () => {

  const resetInputValueState = useResetRecoilState(inputValue)
  const [modalStackState, setModalStackState] = useRecoilState(modalStack);
  const isModalStackModified = useRecoilValue(isModalStackModifiedSelector);
  const resetModalStack = useResetRecoilState(modalStack);

  const { lockScroll, openScroll } = useBodyScrollLock();

  const { Component: lastComponent } = modalStackState[modalStackState.length - 1];
  
  const openModal = (
    Component: React.ComponentType<ComponentProps> | "Toast" | "Loading" | "Alert" | "Popup" | "PhotoView" | null,
    props?: any,
    selectOptions?: string[],
    actions?: (null | (() => void))[] ): void => {
      const { Component: lastComponent } = modalStackState[modalStackState.length - 1];
      if ( lastComponent !== "Toast" ) {
        setModalStackState((Prev) => [
          ...Prev,
          { isOpen: true, Component, props, selectOptions, actions },
        ]);
        Component !== "Toast" && lockScroll()
      }
  };

  const closeModal = (): void => {
    lastComponent !== "Toast"  && openScroll()
    setModalStackState((Prev) => 
      produce(Prev, draft => {
        const lastElement = draft[draft.length - 1];
        lastElement.isOpen = false;
      })
    );
  };

  const closeModalDirect = () => {
    lastComponent !== "Toast"  && openScroll()
    if (lastComponent !== "Toast" && location.pathname !== "/post/detail") {
      resetInputValueState()
    }
    if (isModalStackModified && modalStackState.length !== 1) {
      setModalStackState((Prev) => {
        const newModalStack = [...Prev];
        newModalStack.pop();
        return newModalStack;
      });
    }
  }

  const updateModal = (newProps): void => {
    if (isModalStackModified && lastComponent !== "Alert" && lastComponent !== "Loading" && lastComponent !== "Toast" && lastComponent !== "PhotoView") {
      setModalStackState((Prev) => {
        const newModalStack = [...Prev];
        const lastIndex = newModalStack.length - 1;
        newModalStack[lastIndex] = {
          ...newModalStack[lastIndex],
          props: typeof newProps === "string" ? newProps : { ...newModalStack[lastIndex].props, ...newProps } 
        };
        return newModalStack;
      });
    }
  };

  const clearModal = () => resetModalStack();

  return { openModal, closeModal, closeModalDirect, updateModal, clearModal };
};

export default useModalStack;

💡 ModalStack

전역 상태로 관리되는 모달 스택을 통해 화면에 컴포넌트를 렌더링하는 역할을 합니다.

코드보기
  • useEffect
    모달 스택 상태가 변경될 때마다 모달의 열림/닫힘 상태를 업데이트합니다.

  • closeModal
    모달을 닫는 함수입니다.

  • handleScroll
    모달 내부 스크롤 이벤트를 처리하는 함수로 스크롤이 최상단에 도달했는지 여부를 확인합니다.

  • switch-case
    switch-case 문을 활용하여 modal.Component에 따라 적절한 모달 컴포넌트를 렌더링합니다.

import { useState, useEffect } from "react";
import useModalStack from "@/hooks/useModalStack";

import { useRecoilValue } from "recoil";
import { modalStack } from "@/store";

import Toast from "./Toast";
import LoadingModal from "./LoadingModal";
import Alert from "./Alert";
import Popup from "./Popup";
import Modal from "./Modal";
import PhotoSingleViewer from "./PhotoSingleViewer";

import { ModalStackType } from "@/store";

const ModalComponent = ({ modal }: { modal: ModalStackType }) => {

  const modalStackState = useRecoilValue(modalStack);

  const [isOpen, setIsOpen] = useState<boolean>(false);
  const [isScrolledToTop, setIsScrolledToTop] = useState<boolean>(true);

  const { Component, props, selectOptions, actions } = modal;
  const { closeModalDirect } = useModalStack();

  useEffect(() => {
    const { isOpen } = modalStackState[modalStackState.length - 1];
    if (isOpen) {
      setIsOpen(true)
    } else if (!isOpen) {
      setIsOpen(false)
      const timeoutId = setTimeout(() => {
        closeModalDirect()
      }, 400)
      return () => {
        clearTimeout(timeoutId)
      }
    }
  }, [modalStackState]);

  const closeModal = () => {
    setIsOpen(false)
    setTimeout(() => {
      closeModalDirect()
    }, 400)
  }

  const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
    if (e.currentTarget.scrollTop <= 0) {
      setIsScrolledToTop(true)
    } else {
      setIsScrolledToTop(false)
    }
  }

  const handleScrollLock = () => {
    setIsScrolledToTop(false)
  }

  if (!Component) {
    return null;
  }

  switch (Component) {
    case "Toast":
      return <Toast isOpen={isOpen} closeModal={closeModal} props={props} />
    case "Loading":
      return <LoadingModal isOpen={isOpen} props={props} />
    case "Alert":
      return <Alert isOpen={isOpen} closeModal={closeModal} props={props} selectOptions={selectOptions} actions={actions} />
    case "Popup":
      return (
        <Popup isOpen={isOpen} closeModal={closeModal} title={props.title}>
          <props.component closeModal={closeModal} props={props.props} />
        </Popup>
      );
    case "PhotoView":
      return <PhotoSingleViewer isOpen={isOpen} closeModal={closeModal} props={props} />
    default:
      return (
        <Modal isOpen={isOpen} closeModal={closeModal} props={props} isScrolledToTop={isScrolledToTop}>
          <Component closeModal={closeModal} props={props} handleScroll={handleScroll} handleScrollLock={handleScrollLock} />
        </Modal>
      )
  }
};

const ModalStack = () => {
  const modalStackState = useRecoilValue(modalStack);

  return (
    <>
      {modalStackState.map((modal, index) => (
        <ModalComponent key={index} modal={modal} />
      ))}
    </>
  );
};

export { ModalStack };


스와이프 효과 분기처리

Why?

웹 개발 기술로 앱을 개발하면서, 터치 기반의 모바일 환경에 적합하게 설계하는 것이 중요하다고 생각했습니다.
특히, 스와이프 동작은 사용자가 직관적으로 앱을 탐색하고 상호작용할 수 있도록 도와주기 때문에,
이를 중점적으로 고려하여 사용자의 편의성과 앱의 사용성을 극대화하고자 하였습니다.

How?

사용자의 터치 시작 위치, 이동 위치, 종료 위치에 따라 분기 처리를 통해 모달 동작을 제어하는 방식으로,
스와이프 동작을 통해 모달을 열고 닫거나 모달의 높이를 조절하는 것은 물론,
이미지를 스와이프하거나, 리스트 항목을 조작하거나, 스와이프하여 새로고침하는 등 사용자의 의도에 맞는 반응을 제공하도록 구현했습니다.

스와이프

코드보기
  • sliderRef - 슬라이더를 참조합니다.

  • backdropRef - 슬라이더 뒤의 배경을 참조합니다.

  • startY - 터치나 마우스 이벤트가 시작된 Y 좌표를 저장합니다.

  • isOpenMore - 모달이 확장 되었는지 여부를 나타냅니다.

  • isMoving - 슬라이더가 움직이고 있는지 여부를 나타냅니다.

  • startTouchTime - 터치가 시작된 시간을 저장합니다.

  • isScrolledToTop - 슬라이더가 최상단에 있는지 여부를 나타냅니다.
    최상단에 있는 경우 슬라이더의 transform을 설정 가능하게 하여 모달을 닫는 동작까지 수행할 수 있도록 합니다.

  • handleTouchStart - 터치나 마우스 이벤트가 시작될 때 호출됩니다.
    이벤트가 시작된 Y 좌표와 터치가 시작된 시간을 저장합니다.

  • handleTouchMove - 터치나 마우스 이벤트가 움직일 때 호출됩니다.
    현재 Y 좌표와 시작 Y 좌표의 차이를 계산하여 슬라이더의 transform 또는 height를 설정합니다.

  • handleTouchEnd - 터치나 마우스 이벤트가 끝날 때 호출됩니다.
    종료 Y 좌표와 시작 Y 좌표의 차이 및 터치가 시작된 시간과의 경과 시간을 기준으로
    모달을 확장하거나 닫거나 열리는 동작을 수행합니다.

import { useState, useRef } from "react";

interface ModalProps {
  isOpen: boolean,
  props?: { isHeightAuto: boolean }
  closeModal: () => void,
  isScrolledToTop: boolean,
  children: React.ReactNode
}

const Modal = ({ isOpen, props, closeModal, isScrolledToTop, children }: ModalProps) => {

  const [startY, setStartY] = useState<number | null>(null);
  const [isMoving, setIsMoving] = useState<boolean>(false);
  const [isOpenMore, setIsOpenMore] = useState<boolean>(false);
  const [startTouchTime, setStartTouchTime] = useState<number>(0);

  const backdropRef = useRef<HTMLDivElement | null>(null);
  const sliderRef = useRef<HTMLDivElement | null>(null);

  const isHeightAuto = props?.isHeightAuto;

  const handleTouchStart = (e: React.TouchEvent | React.MouseEvent) => {
    if ("touches" in e) {
      setStartY(e.touches[0].clientY);
    } else {
      setStartY(e.clientY);
    }

    setIsMoving(true);
    setStartTouchTime(Date.now());
  };

  const handleTouchMove = (e: React.TouchEvent | React.MouseEvent) => {
    if (isHeightAuto || startY === null) return;

    let currentY: number
    if ("touches" in e) {
      currentY = e.touches[0].clientY;
    } else {
      currentY = e.clientY;
    }
    const subtract = Math.abs(startY - currentY);
    const sliderRefCurrent = sliderRef.current;

    if (sliderRefCurrent && backdropRef.current) {
      const transformPercentage = (subtract / sliderRefCurrent.clientWidth) * 100;
      if (startY < currentY && !isOpenMore) {
        sliderRefCurrent.style.transform = `translateY(${transformPercentage}%)`;
        backdropRef.current.style.opacity = "0";
      } else if (startY < currentY && isOpenMore) {
        sliderRefCurrent.style.height = `calc(100% - ${subtract}px)`;
      } else if (startY > currentY && isOpenMore) {
        sliderRefCurrent.style.height = "100%";
      }
      else if (startY > currentY) {
        sliderRefCurrent.style.height = `calc(75% + ${subtract}px)`;
      }
    }
  }

  const handleTouchEnd = (e: React.TouchEvent | React.MouseEvent) => {
    if (isHeightAuto || startY === null) return;
    let endY: number
    if ("changedTouches" in e) {
      endY = e.changedTouches[0].clientY;
    } else {
      endY = e.clientY;
    }

    const subtract = Math.abs(startY - endY);
    const sliderRefCurrent = sliderRef.current;
    const elapsedTime = Date.now() - startTouchTime;

    let transformPercentage: number = 0;
    if (sliderRefCurrent) {
      transformPercentage = (subtract / sliderRefCurrent.clientWidth) * 100;
    }

    if (sliderRefCurrent) {
      if (startY < endY && !isOpenMore && elapsedTime < 100) {
        setIsMoving(false);
        closeModal();
      } else if (startY < endY && !isOpenMore && transformPercentage > 75 / 2) {
        setIsMoving(false);
        closeModal();
      } else if (backdropRef.current && startY < endY) {
        setIsMoving(false);
        setIsOpenMore(false)
        sliderRefCurrent.style.transform = `translateY(0%)`;
        backdropRef.current.style.opacity = "1";
      } else if (backdropRef.current && startY > endY) {
        setIsMoving(false);
        setIsOpenMore(true)
        sliderRefCurrent.style.transform = `translateY(0%)`;
        backdropRef.current.style.opacity = "1";
      } else {
        sliderRefCurrent.style.transform = `translateY(0%)`;
      }
      setStartY(null);
    }
  }


  return (
    <div className="z-40 fixed left-0 top-0 h-full w-screen">
      <div
        className={`z-50 absolute bottom-0 w-full rounded-t-[10px] bg-background flex flex-col items-center transform ${isOpen ? "ease-out" : "ease-in"}`} style={{
          transform: `${isOpen ? "translateY(0%)" : "translateY(100%)"}`,
          transition: `${isMoving ? "" : ".4s"}`,
          height: `${isHeightAuto ? "auto" : isOpenMore ? "100%" : "75%"}`,
        }}
        ref={sliderRef}
      >
        <div className="pt-[35px] w-full h-full"
          onClick={() => setIsMoving(false)}
          onTouchStart={handleTouchStart}
          onTouchMove={handleTouchMove}
          onTouchEnd={handleTouchEnd}
          onMouseDown={handleTouchStart}
          onMouseMove={handleTouchMove}
          onMouseUp={handleTouchEnd}
        >
          <div className="absolute top-[15px] left-1/2 -translate-x-1/2 w-[30px] h-[5px] bg-gray-old rounded-full m-auto"></div>
          <div
            className="w-full h-full flex flex-col overflow-y-scroll"
            onTouchStart={(e) => {
              e.stopPropagation()
              if (isScrolledToTop) {
                handleTouchStart(e)
              }
            }}
            onTouchMove={(e) => {
              e.stopPropagation()
              if (startY === null || isScrolledToTop === false) return;

              const currentY = e.touches[0].clientY;
              const subtract = Math.abs(startY - currentY);
              const sliderRefCurrent = sliderRef.current;

              if (sliderRefCurrent && backdropRef.current) {
                const transformPercentage = (subtract / sliderRefCurrent.clientWidth) * 100;
                if (startY < currentY && !isOpenMore && isScrolledToTop) {
                  sliderRefCurrent.style.transform = `translateY(${transformPercentage}%)`;
                  backdropRef.current.style.opacity = "0";
                } else if (startY < currentY && isOpenMore) {
                  sliderRefCurrent.style.height = `calc(100% - ${subtract}px)`;
                }
              }
            }}
            onTouchEnd={(e) => {
              e.stopPropagation()
              if (startY === null || isScrolledToTop === false) return;

              const endX = e.changedTouches[0].clientY;
              const subtract = Math.abs(startY - endX);
              const sliderRefCurrent = sliderRef.current;
              const elapsedTime = Date.now() - startTouchTime;

              let transformPercentage: number = 0;
              if (sliderRefCurrent) {
                transformPercentage = (subtract / sliderRefCurrent.clientWidth) * 100;
              }

              if (sliderRefCurrent) {
                if (startY < endX && !isOpenMore && subtract > 20 && elapsedTime < 100) {
                  setIsMoving(false);
                  closeModal();
                } else if (startY < endX && !isOpenMore && transformPercentage > 75 / 2) {
                  setIsMoving(false);
                  closeModal();
                } else if (backdropRef.current && startY < endX) {
                  setIsMoving(false);
                  setIsOpenMore(false)
                  sliderRefCurrent.style.transform = `translateY(0%)`;
                  backdropRef.current.style.opacity = "1";
                } else {
                  sliderRefCurrent.style.transform = `translateY(0%)`;
                }
                setStartY(null);
              }
            }}
          >
            {children}
          </div>
        </div>
      </div >
      <div
        className={`z-40 absolute top-0 left-0 h-dvh w-screen backdrop-blur-sm ${isOpen ? "ease-out" : "ease-in"}`}
        style={{
          background: "rgba(255,255,255, .1)",
          opacity: `${isOpen ? 1 : 0}`,
          transition: ".4s",
        }}
        ref={backdropRef}
        onClick={(e) => {
          e.stopPropagation()
          setIsMoving(false);
          closeModal()
        }}
      >
      </div >
    </div>
  );
}

export default Modal


페이지 전환 효과

Why?

일반적인 앱에서 부드러운 화면 전환이 없다면,
사용자는 페이지 전환이 갑작스럽게 느껴져 혼란스럽거나 맥락을 잃을 가능성이 있기때문에 사용자가 이전 페이지와 현재 페이지 간의 관계를 직관적으로 이해하는 데 도움을 줄 수 있도록 하고자 하였습니다.

How?

react-transition-group 라이브러리를 사용하여 페이지 전환 시 애니메이션 효과를 추가했습니다.
TransitionGroup과 CSSTransition 라는 컴포넌트를 활용하여 각 페이지 전환에 대해 클래스명을 동적으로 적용하고,
해당 클래스명에 따라 CSS를 통해 애니메이션 효과를 부여했습니다.

코드보기
  • TransitionGroup - 자식 요소의 전환을 관리하는 컴포넌트입니다.
    자식 요소가 추가되거나 제거될 때 애니메이션 효과를 적용할 수 있습니다.
    childFactory 속성은 각 자식 요소에 추가적인 속성을 설정할 수 있는 함수로,
    classNames 속성을 추가하여 클래스를 설정할 수 있습니다.

  • CSSTransition - 개별 자식 요소에 대해 애니메이션을 적용하는 컴포넌트입니다.
    key 속성을 통해 라우트 경로를 기반으로 설정하여
    라우트가 변경될 때마다 새로운 애니메이션이 적용되도록 할 수 있습니다.
    timeout 속성을 통해 애니메이션 지속 시간을 설정할 수 있습니다.

  • routeDirectionValueState - 페이지 전환 시 방향과 관련된 상태를 관리하는 Recoil atom입니다.
    routeDirectionValueState.direction이 빈 문자열이 아닌 경우,
    설정된 전환 방향에 따라 애니메이션 클래스를 동적으로 설정할 수 있습니다.

import React from "react";
import { useRecoilState } from "recoil";
import { routeDirectionValue } from "@/store";

import { TransitionGroup, CSSTransition } from "react-transition-group";

interface RouteState {
  pathname: string
  state: {
    direction: "next" | "prev" | "up" | "down" | "fade";
  },
}

interface RouteTransitionProps {
  location: RouteState,
  children: React.ReactNode
}

const RouteTransition = ({ location, children }: RouteTransitionProps) => {

  const [routeDirectionValueState, setRouteDirectionValueState] = useRecoilState(routeDirectionValue)

  return (
    <TransitionGroup
      childFactory={(child) => {
        return React.cloneElement(child, {
          classNames:
            routeDirectionValueState.direction !== "" ? (
              routeDirectionValueState.direction === "next" && "navigate-next" ||
              routeDirectionValueState.direction === "up" && "navigate-up" ||
              routeDirectionValueState.direction === "prev" && "navigate-prev" ||
              routeDirectionValueState.direction === "down" && "navigate-down" ||
              routeDirectionValueState.direction === "fade" && "navigate-fade"
            ) : (
              location.state?.direction === "next" && "navigate-next" ||
              location.state?.direction === "up" && "navigate-up" ||
              location.state?.direction === "prev" && "navigate-prev" ||
              location.state?.direction === "down" && "navigate-down" ||
              location.state?.direction === "fade" && "navigate-fade"
            )
        });
      }}
    >
      <CSSTransition
        key={location.pathname}
        timeout={300}
      >
        <div className="w-full overflow-x-clip">
          {children}
        </div>
      </CSSTransition>
    </TransitionGroup>
  );
};

export default RouteTransition;
import { Routes, useLocation } from "react-router-dom";
import RouteTransition from "./RouteTransition";

const App = () => {

  const location = useLocation();

  return (
    <RouteTransition location={location}>
      <Routes location={location}>
        ...
      </Routes>
    </RouteTransition>
  )
}
.slide-fade-enter,
.navigate-fade-enter {
  position: absolute;
  height: 100dvh;
  opacity: 0;
  transform: translateY(50%);
}

.slide-fade-enter-active,
.navigate-fade-enter-active {
  opacity: 1;
  transform: translateY(0);
  transition: opacity 300ms ease-in-out, transform 300ms ease-in-out;
}

.slide-fade-exit,
.navigate-fade-exit {
  position: absolute;
  height: 100dvh;
  opacity: 1;
  transform: translateY(0%);
}

.slide-fade-exit-active,
.navigate-fade-exit-active {
  opacity: 0;
  transform: translateY(50%);
  transition: opacity 300ms ease-in-out, transform 300ms ease-in-out;
}

...


새로고침

Why?

앱의 특성상 데이터가 실시간으로 변동되며,
최신 데이터를 유지하는 것이 매우 중요하기때문에,
데이터를 다시 가져오는 (refetch) 기능이 필수적이라고 여겼습니다.

How?

사용자의 터치 및 마우스 이벤트를 감지하여
화면 상단에서 아래로 스와이프하는 동작을 통해 데이터를 다시 불러오는 새로고침 기능을 구현했습니다.

코드보기

스크롤 위치가 상단에 있다면
터치나 마우스 이벤트가 시작될 때 시작 위치를 저장하고,
터치나 마우스 이벤트가 끝날 때 사용자가 화면을 얼마나 끌어내렸는지 계산합니다.

사용자가 화면을 일정 거리 이상 끌어내렸다면,
refetch 함수를 호출하여 데이터를 다시 불러옵니다.
로딩 상태를 통해 로딩이 종료되면 로딩 컴포넌트의 위치를 초기화시킵니다.

import { useState, useEffect, useRef } from "react"
import useScrollTop from "@/hooks/useScrollTop"

import Loader from "./Loader"

interface ScrollRefreshContainerProps {
  children: React.ReactNode,
  isLoading: boolean,
  refetch: () => void
}

const ScrollRefreshContainer = ({ children, isLoading, refetch }: ScrollRefreshContainerProps) => {
  const [isVisible, setIsVisible] = useState<boolean>(false);
  const [isRefresh, setIsRefresh] = useState<boolean>(false);
  const [startY, setStartY] = useState<number | null>(null);
  const [isMoving, setIsMoving] = useState<boolean>(false);

  const isTop = useScrollTop()

  const scrollTop = window.scrollY || document.documentElement.scrollTop;


  const refreshRef = useRef<HTMLDivElement | null>(null);

  const handleTouchStart = (e: React.TouchEvent | React.MouseEvent) => {
    if (scrollTop !== 0 && !isTop) return;
    if ("touches" in e) {
      setStartY(e.touches[0].clientY);
    } else {
      setStartY(e.clientY);
    }
    setIsVisible(true)
    setIsMoving(true);
  };

  const handleTouchMove = (e: React.TouchEvent | React.MouseEvent) => {
    if (scrollTop !== 0 && !isTop || startY === null) return;
    let currentY: number
    if ("touches" in e) {
      currentY = e.touches[0].clientY;
    } else {
      currentY = e.clientY;
    }
    const subtract = Math.abs(startY - currentY);
    const refreshRefCurrent = refreshRef.current;


    if (refreshRefCurrent) {
      const transformPercentage = (subtract / refreshRefCurrent.clientWidth) * 100;
      if (startY < currentY) {
        refreshRefCurrent.style.transform = `translate(-50%, ${transformPercentage <= 120 ? transformPercentage : 120}%)`;
      } else if (startY > currentY) {
        refreshRefCurrent.style.transform = `translate(-50%, ${-transformPercentage <= 0 ? 0 : transformPercentage}%)`;
      }
    }
  }

  const handleTouchEnd = (e: React.TouchEvent | React.MouseEvent) => {
    if (scrollTop !== 0 && !isTop || startY === null) return;
    let endY: number
    if ("changedTouches" in e) {
      endY = e.changedTouches[0].clientY;
    } else {
      endY = e.clientY;
    }
    const subtract = Math.abs(startY - endY);

    const refreshRefCurrent = refreshRef.current;

    let transformPercentage: number = 0;
    if (refreshRefCurrent) {
      transformPercentage = (subtract / refreshRefCurrent.clientWidth) * 100;
    }
    if (refreshRefCurrent && startY < endY && transformPercentage > 50) {
      setIsMoving(false)
      setIsRefresh(true)
      setTimeout(() => {
        refreshRefCurrent.style.transform = "translate(-50%, 120%)";
        refetch()
      }, 0)
    } else {
      setIsMoving(false)
      setTimeout(() => {
        refreshRefCurrent.style.transform = "translate(-50%, 0%)";
      }, 0)
    }
    setStartY(null)
  };

  useEffect(() => {
    if (!isLoading) {
      refreshRef.current.style.transform = "translate(-50%, 0%)";
      setTimeout(() => {
        setIsVisible(false)
        setIsRefresh(false)
      }, 300)
    }
  }, [isLoading])

  return (
    <div
      className="w-full h-full"
      onTouchStart={handleTouchStart}
      onTouchMove={handleTouchMove}
      onTouchEnd={handleTouchEnd}
      onMouseDown={handleTouchStart}
      onMouseMove={handleTouchMove}
      onMouseUp={handleTouchEnd}
    >
      <div className={`${isMoving ? "" : "transition-transform duration-300 ease-in-out"} ${isVisible ? "opacity-100" : "opacity-0"} h-[50px] w-[100px] rounded-lg bg-white fixed left-1/2 top-[-50px] z-50`} ref={refreshRef}>
        <div className={`absolute-center w-[50px] transition duration-100 ${isRefresh ? "opacity-100" : "opacity-0"}`}>
          <Loader isSmallUse={true} color={"black"} />
        </div>
      </div>
      {children}
    </div >
  );
}

export default ScrollRefreshContainer
import PostService from "@/services/postService";
import ScrollRefreshContainer from "./ScrollRefreshContainer"

const PostPage = () => {

const { ReadPostAllPaged } = PostService()
  const { data, isFetching, refetch } = ReadPostAllPaged()

  return (
    <ScrollRefreshContainer 
      isLoading={isFetching} 
      refetch={() => {
        refetch()
      }}>
      ...
    </ScrollRefreshContainer>
  )
}


FCM을 활용한 실시간 푸시 알림 구현

Why

사용자가 앱을 실행하지 않고도 실시간으로 중요한 정보를 전달하고
앱의 참여성을 높이기 위해 FCM(Firebase Cloud Messaging)을 사용했습니다.

How

사용자의 동의를 얻어 알림 권한을 요청합니다.
알림 권한이 부여되면 FCM을 통해 기기 토큰을 받아와서 Firestore에 저장합니다.
이후, 저장된 토큰과 함께 알림의 딥링크, 제목, 본문 등의 정보를
API를 통해 전송하여 사용자에게 푸시 알림을 전달하도록 했습니다.

코드보기
import { getToken } from "firebase/messaging";
import { messaging } from "@/firebase/config";

import useFirestoreUpdate from "@/hooks/useFirestoreUpdate";
import getAccountId from "./getAccountId";

const NotificationPermission = (isUpdateToken? : boolean) => {
  const accountId = getAccountId();
  const { updateField } = useFirestoreUpdate("users");

  const requestPermission = async () => {
    try {
      const permission = await Notification.requestPermission();

      if (permission === "granted") {
        const token = await getToken(messaging, {
          vapidKey: process.env.REACT_APP_VAPID_KEY,
        });
        updateField(accountId, { deviceToken: token, isAlert: true });
      } else if (permission === "denied" && !isUpdateToken) {
        updateField(accountId, { deviceToken: "off", isAlert: false });
      }
    } catch (error) {
      console.error("알림 권한 요청 중 오류 발생:", error);
    }
  };

  return { requestPermission }
}

export default NotificationPermission;
const notificationService = (token: string, action: string, title: string, body?: string) => {
  try {
    fetch(`${process.env.REACT_APP_SEND_MESSAGE_API_URL}?token=${token}&action=${action}&title=${title}&body=${body ? body : "none"}&icon=${"none"}`, {
      method: "POST",
    });
  } catch (error) {
    console.error(error);
    throw error;
  }
}

export default notificationService
notificationService(
  userInfo.deviceToken,
  `chat/${id}`,
  "회원님에게 메시지를 보냈습니다.",
  "hi",
  `image`
)



트러블 슈팅


의도치 않은 Navigate Back

What?

Navigate Back을 수행할 때,
의도한 페이지로 정확히 돌아가지 못하는 이슈가 있었습니다.

예를 들어, 사용자가 A 페이지에서 B 페이지로 이동하고 다시 A 페이지로 돌아온 후
C 페이지로 이동한 다음, 다시 A 페이지로 돌아왔다고 가정 했을 때
이 상황에서 Navigate Back을 수행하면
사용자는 A 페이지 이전으로 돌아가길 기대할 수 있지만
실제로는, C 페이지로 이동하는 문제가 발생합니다.

How?

브라우저 히스토리에만 의존하지 않고
Navigate Back 을 수행할 시 리다이렉트할 URL을 저장하고
명시적으로 리다이렉트하는 방법으로 해결했습니다.

코드보기
import { atom } from "recoil";

interface routeDirectionValueType {
  direction: "next" | "prev" | "up" | "down" | "fade" | "",
  previousPageUrl: string[]
  data: any[],
}

export const routeDirectionValue = atom<routeDirectionValueType>({
  key: "routeTransitionDirection",
  default: {direction: "", previousPageUrl: [], data: []},
});
import { routeDirectionValue } from "@/store";

const RouteTransition = ( ... ) => {

  const [routeDirectionValueState, setRouteDirectionValueState] = useRecoilState(routeDirectionValue)

  useEffect(() => {
    if (routeDirectionValueState.direction !== "") {
      const previousPageUrl = routeDirectionValueState.previousPageUrl
      const data = routeDirectionValueState.data

      if (previousPageUrl.length !== 0) {
        navigate(
          previousPageUrl[previousPageUrl.length - 1], 
          { 
            state: data[data.length - 1] 
            ? JSON.parse(JSON.stringify(data[data.length - 1])) 
            : {} 
          }
        )
      } else {
        navigate(-1)
      }

      if (data.length === 0 && previousPageUrl.length === 0) {
        setTimeout(() => resetRouteDirectionValueState(), 300)
      } else {
        setTimeout(() => setRouteDirectionValueState(Prev => ({
          ...Prev,
          previousPageUrl: Prev.previousPageUrl.filter((url) => (url !== previousPageUrl[previousPageUrl.length - 1])),
          data: Prev.data.filter((url) => (url !== data[data.length - 1])),
          direction: ""
        })), 300)
      }
    }
  }, [routeDirectionValueState])

  return (...)
}

이전 페이지 상태값 손실

What?

브라우저 히스토리를 이용한 Navigate Back은 전달된 상태값이 보존되지만,
명시적으로 URL을 설정하여 리다이렉트하는 경우 전달되었던 상태가 보존되지 않는 이슈가 있었습니다.

How?

Navigate Back을 수행할 때 리다이렉트할 URL을 저장하는 동시에,
해당 페이지에서 요구되는 상태값도 함께 저장하여
명시적 리다이렉트 시에도 상태값을 보존할 수 있도록 했습니다.

코드보기

routeDirectionValueState 상태는
페이지 전환 시 방향과 관련된 상태를 관리하는 Recoil atom입니다.
해당 상태를 통해 side effect를 발생시키면 페이지 전환을 처리합니다.

direction 값이 비어 있지 않으면,
previousPageUrl 배열에서 마지막 URL을 가져와 해당 URL로 명시적으로 리다이렉트합니다.
이때 data 배열에서 마지막 데이터를 가져와 state로 전달합니다.

만약 previousPageUrl과 data 배열이 비어 있다면,
기본적으로 navigate(-1)을 호출하여 브라우저 히스토리의 이전 페이지로 이동합니다.

페이지 전환이 완료되면, previousPageUrl과 data 배열에서 마지막 값을 제거하고 direction 값을 초기화합니다.

import { atom } from "recoil";

interface routeDirectionValueType {
  direction: "next" | "prev" | "up" | "down" | "fade" | "",
  previousPageUrl: string[]
  data: any[],
}

export const routeDirectionValue = atom<routeDirectionValueType>({
  key: "routeTransitionDirection",
  default: {direction: "", previousPageUrl: [], data: []},
});
import { routeDirectionValue } from "@/store";

const RouteTransition = ( ... ) => {

  const [routeDirectionValueState, setRouteDirectionValueState] = useRecoilState(routeDirectionValue)

  useEffect(() => {
    if (routeDirectionValueState.direction !== "") {
      const previousPageUrl = routeDirectionValueState.previousPageUrl
      const data = routeDirectionValueState.data

      if (previousPageUrl.length !== 0) {
        navigate(
          previousPageUrl[previousPageUrl.length - 1], 
          { 
            state: data[data.length - 1] 
            ? JSON.parse(JSON.stringify(data[data.length - 1])) 
            : {} 
          }
        )
      } else {
        navigate(-1)
      }

      if (data.length === 0 && previousPageUrl.length === 0) {
        setTimeout(() => resetRouteDirectionValueState(), 300)
      } else {
        setTimeout(() => setRouteDirectionValueState(Prev => ({
          ...Prev,
          previousPageUrl: Prev.previousPageUrl.filter((url) => (url !== previousPageUrl[previousPageUrl.length - 1])),
          data: Prev.data.filter((url) => (url !== data[data.length - 1])),
          direction: ""
        })), 300)
      }
    }
  }, [routeDirectionValueState])

  return (...)
}


모달 접근 시 스크롤로 인한 사용성 저하

What?

모달이 열렸을 때,
모달 상위 요소의 스크롤이 가능하여
스크롤 시 상위 요소의 스크롤도 함께 움직이면서 모달과 상호작용하는 동안 사용성에 문제가 되는 이슈가 있었습니다.

How?

모달이 열렸을 때,
현재 스크롤 위치를 저장한 후
body의 스타일에 fixed 속성을 추가하여 스크롤을 방지하여
스크롤 위치를 유지한 상태로 모달을 표시할 수 있도록 했습니다.
그리고 모달을 닫을 때, body에 부여된 속성들을 모두 제거하여 스크롤을 다시 허용하도록 했습니다.

코드보기
import { useCallback } from "react";

const useBodyScrollLock = () => {
  const lockScroll = useCallback(() => {
    const { body } = document;

    if (!body.getAttribute("scrollY")) {
      const pageY = window.scrollY;
      body.setAttribute("scrollY", pageY.toString());
      body.style.overflow = "hidden";
      body.style.position = "fixed";
      body.style.left = "0px";
      body.style.right = "0px";
      body.style.bottom = "0px";
      body.style.top = `-${pageY}px`;
    }
  }, []);

  const openScroll = useCallback(() => {
    const { body } = document;

    if (body.getAttribute("scrollY")) {
      body.style.removeProperty("overflow");
      body.style.removeProperty("position");
      body.style.removeProperty("top");
      body.style.removeProperty("left");
      body.style.removeProperty("right");
      body.style.removeProperty("bottom");
  
      window.scrollTo(0, Number(body.getAttribute("scrollY")));
  
      body.removeAttribute("scrollY");
    }
  }, [])
  return { lockScroll, openScroll };
}

export default useBodyScrollLock;
import useBodyScrollLock from "./useBodyScrollLock";

const useModalStack = () => {

  const { lockScroll, openScroll } = useBodyScrollLock();

  const openModal = (): void => {
    lockScroll()
    ...
  };

  const closeModal = (): void => {
    openScroll()
    ...
  }; 

  ...
}


CORS 문제

What?

클라이언트 측에서 Google Places API 웹 서비스를 호출할 때,
Google Places API 서버가 응답 헤더에 Access-Control-Allow-Origin을 포함하지 않기 때문에
브라우저가 동일 출처 정책에 따라 다른 출처의 자원에 접근하려는 시도를 차단하는 과정에서 CORS 오류가 발생하는 이슈가 있었습니다.

How?

AWS Lambda와 API Gateway를 통해 프록시 서버를 구축하여 클라이언트 요청을 서버 측 프록시 서버를 통해 Google Places API로 보내고,
프록시 서버가 응답을 받아 클라이언트로 전달하는 방식으로 CORS 문제를 해결했습니다.

코드보기

fetch를 사용하여 프록시 서버에 GET 요청을 보내면
Lambda 함수는 클라이언트의 요청을 받아 Google Places API로 프록시 요청을 보내고,
응답 데이터를 클라이언트로 반환합니다.

const readLocationDetail = async (keyword: string) => {
  const response = await fetch(`${process.env.REACT_APP_SEARCH_PLACES_API_URL}?request=details&keyword=${keyword}&apikey=${process.env.REACT_APP_GOOGLE_MAPS_API_KEY}`,
    {
      method: "GET",
    }
  )
  const data = await response.json();
  return data;
}
const AWS = require("aws-sdk");
const axios = require("axios");

exports.handler = async (event, context, callback) => {
  try {
    const queryStringParameters = event.queryStringParameters;
    if (queryStringParameters && queryStringParameters.request && queryStringParameters.keyword && queryStringParameters.apikey) {
      const request = queryStringParameters.request
      const keyword = queryStringParameters.keyword
      const apikey = queryStringParameters.apikey

      const response = await axios.get(`https://maps.googleapis.com/maps/api/place/${request}/json?${request === "details" ? "place_id" : "query"}=${keyword}&language=ko&key=${apikey}`);

      callback(null, {
        "statusCode": 200,
        "headers": {
          "Content-Type": "application/json; charset=utf-8",
          "Access-Control-Allow-Origin": "*"
        },
        "body": JSON.stringify(response.data)
      });
    } 
    else {
      callback(null, {
        "statusCode": 400,
        "headers": {
          "Content-Type": "application/json; charset=utf-8",
          "Access-Control-Allow-Origin": "*"
        },
        "body": JSON.stringify({ error: "URL에 문제가 있습니다." })
      });
    }
  } catch (error) {
    callback(null, {
      "statusCode": 500,
      "headers": {
        "Content-Type": "application/json; charset=utf-8",
        "Access-Control-Allow-Origin": "*"
      },
      "body": JSON.stringify({ error: "파일을 읽어오는 중 오류 발생: " + error.message })
    });
  }
};

데이터 패칭 최적화

What?

일부 비지니스 로직은
데이터 고유 ID를 가지고 따로 DB에 배열형태로 저장한 다음 이것을 가지고 병렬 데이터 패칭 하는 형태 였지만,
FireStore의 데이터가 추가/삭제하는 과정에서 별도로 데이터에 대한 고유 ID를 별도로 추가/삭제해야하는 번거로움이 있었습니다

How?

따로 고유 ID를 저장하지않고 FireStore의 where 쿼리를 사용하여
데이터 패칭하는 방식을 통해 요청을 단일화 하고 전체적으로 비지니스 로직의 양을 줄이는 방식으로 해결했습니다.

수정 전

의존성 리액트 쿼리(useDependentDataQuery)를 통해 병렬 데이터 패칭을 하는 형태로,
병렬 데이터 패칭을 위해 필요한 고유 ID를 특정 배열 필드 안에 저장하고 관리해야 했습니다

코드보기
import { useQueries, QueryCache } from "@tanstack/react-query";

import useDataQuery from "./useDataQuery";

import { QueryOptions } from "./useDataQuery";

interface DependentQueriesResult<T> {
  isLoading: boolean,
  data: T, 
}

const useDependentDataQuery = <T>(
  primaryKey: string, 
  dependentKey: string, 
  dependentDataKeys: string[],
  functions: any, 
  params: ((string | null | any)[])[],
  queryOptions: QueryOptions,
  onSuccess?: () => void,
  onError?: () => void, 
): DependentQueriesResult<T> => {

  const { data: primaryFunctionResults, isSuccess: isSuccessPrimaryQuery } = useDataQuery<any,Error,string[]>(
    primaryKey, 
    () => functions[0](...params[0]), 
    primaryKey => dependentDataKeys.reduce((obj, key) => obj[key], primaryKey), 
    queryOptions
  )

  const dependentQueries = useQueries({
    queries: primaryFunctionResults
      ? primaryFunctionResults.map((primaryFunctionResult) => ({
          queryKey: [dependentKey, primaryFunctionResult],
          queryFn: () => functions[1](...params[1].map(i => i === null ? primaryFunctionResult : i)),
          enabled: isSuccessPrimaryQuery,
          ...queryOptions,
        }))
      : [],
    combine: (results) => {
      return {
        combinedData: results.map((result) => result.data) as T,
        isLoading: results.some((result) => result.isLoading),
        isSuccessDependentQuery: results.every((result) => result.isSuccess),
        isError: results.some((result) => result.isError),
      };
    },
  });

  const queryCache = new QueryCache()
  
  queryCache.subscribe(() => {
    if (dependentQueries.isSuccessDependentQuery) {
      onSuccess && onSuccess()
    } else if (dependentQueries.isError) {
      onError && onError()
    }
  });

  return { 
    isLoading: dependentQueries.isLoading, 
    data: dependentQueries.combinedData 
  };
};

export default useDependentDataQuery;
import useFirestoreCreate from "@/hooks/useFirestoreCreate";
import { v4 as uuidv4 } from "uuid";

const ReadSchedule = () => {
  const { readDocumentSingle: readDocumentUsers } = useFirestoreRead("users")
  const { readDocumentSingle: readDocumentSchedules } = useFirestoreRead("schedules")
  
  const { data, isLoading } = useDependentDataQuery<ReadDocumentType<SchedulesType>[]>(
    "user", 
    "schedules", 
    ["data", "scheduleIds"], 
    [readDocumentUsers<UsersType>, readDocumentSchedules<SchedulesType>],
    [[accountId], [null]],
    {
      staleTime: Infinity,
      gcTime: Infinity,
    }
  );

  return { data, isLoading }
}

const CreateSchedule = ( ... ) => {
  const { createFieldArray } = useFirestoreCreate("schedules")
  
  const { mutate, isPending } = useDataMutation(
    "schedules",
    async() => {
      await createFieldArray(
        documentId,
        scheduleIds,
        uuidv4()
      )
    },
    onSuccess,
    onError
  )

  return { mutate, isPending }
}

수정 후

특정 필드에 대해 Firestore의 where 쿼리를 통해 데이터를 패칭하는 형태로 변경하여,
병렬 데이터 패칭 대신 간단한 쿼리로 데이터를 가져올 수 있게 되었습니다.
따라서 고유 ID를 따로 관리할 필요가 없고 로직의 양이 줄어들었습니다.

코드보기
const ReadSchedule = () => {
  const { readDocumentsSimplePaged } = useFirestoreRead("schedules")

  const { data, isLoading, isFetching, refetch } = useDataQuery<ReadDocumentType<SchedulesType>[], Error, ReadDocumentType<SchedulesType>[]>(
    "schedules",
    ()=> readDocumentsSimplePaged<SchedulesType>([], "authorizationId", "in", [accountId], "startTime", "desc", true, Infinity),
    (data) => data,
    {
      staleTime: Infinity,
      gcTime: Infinity,
    },
  )
  return { data, isLoading, isFetching, refetch }
}


Firebase 쿼리 연산자의 한계

What?

키워드를 검색하는 과정에서
Firebase에서는 문서의 전체 텍스트 검색을 자체적으로 지원하지는 않고 있기 때문에
정확한 키워드를 검색하지 않는이상 원하는 검색결과가 노출되지 않는 이슈가 있었습니다.

How?

검색어를 다양한 조각으로 분해하고 조합한 후,
이를 검색용 필드에 배열로 저장하여
검색 시 해당 필드를 기준으로 쿼리하여 정확한 검색 결과를 제공하도록 했습니다.

코드보기
  • generateKeywordCombinations
    매개변수로 받은 문자열로 이중 루프를 통해 모든 가능한 부분 문자열 조합을 생성하여 배열로 반환합니다.
const generateKeywordCombinations = (inputString: string): string[] => {
  const inputArray: string[] = inputString.split(""); 
  const combinations: string[] = []; 

  for (let i = 0; i < inputArray.length; i++) {
    let substring: string = "";
    for (let j = i; j < inputArray.length; j++) {
      substring += inputArray[j];
      if (substring.trim() !== "" && !substring.startsWith(" ")) {
        combinations.push(substring);
      }
    }
  }

  return combinations;
};

export default generateKeywordCombinations;
import useFirestoreUpdate from "@/hooks/useFirestoreUpdate";

  const UpdateUser = ( 
    data: { 
      accountName: string, 
      description: string 
    },
  ) => {

  const { updateField } = useFirestoreUpdate("users")

    updateField(accountId, {
      description: data.description,
      accountName: data.accountName,
      accountNameKeywords: generateKeywordCombinations(data.accountName)
    })
  }

About

자신만의 일정관리 플랫폼

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published