본문 바로가기

프론트엔드/React

스탬프 투어 미션 구현하기(feat. firestore, redux saga)

  • 프로젝트: SNU FESTIVAL
  • 키워드: firestore, redux saga
  • 상황
    1. 축제 사이트를 구현할 때 미션 기능을 추가해달라는 요청이 들어왔다. 페이지 곳곳에 5개의 미션이 나누어져 있고 특정 미션을 클리어하면 미션 카드에 그림 스탬프가 채워지는 형식이었다.
      빈 미션 카드
    2. 웹사이트 전체에 걸쳐 미션 진행 상태를 다뤄야 해야하기 때문에 redux를 이용하여 브라우저 내 상태 관리를 하는 것이 좋다고 생각했다. 

    3. firebase auth 로그인을 한 상태로 미션이 이루어지기 때문에 firestore에 uid(사용자 계정의 unique id)를 문서 이름(key)으로 하고, 미션 진행 상태를 value로 저장하는 방식으로 미션 데이터를 관리하기로 했다.(GET/SET을 할 때 대상 문서를 쉽게 찾을 수 있다.)
      firestore mission 관련 collection
  • 해결 과정
    1. 먼저 redux state.js에 필요한 상태값을 선언한다. 총 5개의 미션이 있고, 서버(firestore)와의 연동이 이루어졌는지를 나타내는 isLoaded flag도 있다.
        // redux/mission/state.js
      /** prefix */
      const PREFIX = 'MISSION';
      
      /** initial state */
      const INITIAL_STATE = {
        isLoaded: false, // firestore 서버에서 데이터를 들고왔는지 나타내는 flag
        guestBook: false, // 미션 1
        performance: false, // 미션 2
        competition: false, // 미션 3
        miniOne: false, // 미션 4
        miniTwo: false, // 미션 5
      };


    2. 여러 페이지에서 mission 상태에 접근해야하기 때문에 useMission custom hook을 만든다.
      useUser라는 auth custom hook을 이용하여 로그인이 되어있을 때(isAuthorized)에 '로그인된 user'에 대한 미션 정보를 firestore에서 불러온다.
        // useMission.js
      import { useEffect } from 'react';
      import { useDispatch, useSelector } from 'react-redux';
      import { actions } from '@/redux/mission/state';
      import { useUser } from '@U/hooks/useAuth';
      
      const useMission = () => {
        const mission = useSelector(state => state.mission);
        const { user, isAuthorized } = useUser();
      
        const dispatch = useDispatch();
        useEffect(() => {
          if (isAuthorized && !mission.isLoaded) {  // 로그인이 되었지만 미션 정보는 아직 불러오지 않았을 때
            dispatch(actions.fetchMissions(user));  // 로그인한 유저의 미션 정보를 불러온다.
          }
        }, [dispatch, mission, user, isAuthorized]);
      
        return mission;  // 필요한 미션이 무엇인지 parameter로 받은 후 필요한 값만 return 하도록 개선 가능
      };
      export default useMission;


    3. 2번에서 actions.fetchMissions(user)를 통해서 미션 정보를 불러오게 되는데, 해당 action은 redux saga와 연결되어 있다.
        // redux/mission/saga.js
      import { fetchMissionsFromFirestore } from './api';
      ...
      export function* fetchMissions(action) {
        try {
          const missions = yield call(fetchMissionsFromFirestore, action.user);
          if (missions) yield put(actions.setMissions(missions));  // setMissions: redux 미션 상태를 업데이트하는 action
          yield put(actions.setLoaded(true));  // isLoaded 상태를 true로 변환
        } catch {
          ...
        }
      }
      ...
      export default function* () {
        yield all([
          takeLeading(types.FETCH_MISSIONS, fetchMissions), // fetchMissions action을 받는다
          ...
        ]);
      }
      
      // redux/mission/api.js
      import { missionCollectionRef } from '@U/initializer/firebase';
      
      export function fetchMissionsFromFirestore(user) {
        return missionCollectionRef.doc(user.uid).get().then((doc) => (doc.exists ? doc.data() : null));
      }

      saga에서 firestore API를 호출한다(fetchMissionsFromFirestore). 이 때 doc id는 user.uid가 된다.
      미션 data를 얻어온 뒤 setMissions action을 통해 서버 데이터와 redux 데이터를 동기화한다.
      그 후, isLoaded를 true로 하여 서버 정보를 불러왔음을 표시한다.

    4. GET이 완료되었으니 이제 미션을 클리어했을 때 redux와 firestore 상태를 SET 해주면된다.
      공모전(competition) 페이지에서 미션을 클리어한 상황을 가정해보자.
        // 'competition' 페이지에서 미션을 수행하는 컴포넌트.js
      const mission = useMission();
      const dispatch = useDispatch();
      
      useEffect(() => {
        if (isAuthorized && mission.isLoaded && !mission.competition) {
          if ('competition' 페이지의 미션 완료 조건 충족) {
            dispatch(actions.setFirestoreMission(user, 'competition', true));
          }
        }
      }, [isAuthorized, mission.isLoaded, mission.competition, dispatch]);

      로그인이 되어있고, mission.competition이 아직 false일 때, 미션 완료 조건이 충족되어 있다면, 처음으로 미션을 클리어한 것이므로 redux dispatch를 실행한다.
      setFirestoreMission action은 saga와 연결되어있으며 코드는 아래와 같다.
        // redux/mission/saga.js
      import { setMissionInFirestore } from './api';
      ...
      export function* setFirestoreMission(action) {  // 함수명 수정 필요
        try {
          yield call(setMissionInFirestore, action.user, action.mission, action.isCompleted);
          yield put(actions.setMission(action.mission, action.isCompleted));
        } catch {
          ...
        }
      }
      ...
      export default function* () {
        yield all([
          takeLeading(types.SET_FIRESTORE_MISSION, setFirestoreMission),
        ]);
      }
      
      
      // redux/mission/api.js
      export function setMissionInFirestore(user, mission, isCompleted) {
        return missionCollectionRef.doc(user.uid).set({
          [mission]: isCompleted,
        }, { merge: true });
      }

      parameter로 받은 user, mission 정보대로 firestore(setMissionInFirestore)와 redux(setMission action)를 둘 다 업데이트한다.
      firestore doc을 업데이트할 때, set과 merge를 사용하면 편하게 update 로직을 작성할 수 있다.
      (https://firebase.google.cn/docs/firestore/manage-data/add-data?hl=ko#set_a_document)
      set은 기존에 문서가 없다면 새로 만들어주고, 있다면 기존 문서를 덮어쓸 수 있다. 이 때 merge option을 통해 기존 문서를 완전히 덮어쓰지 않고 안전하게 병합할 수 있다.

    5. 미션 카드 컴포넌트에서 완료된 미션에 해당하는 그림 스탬프를 채워넣어주면 미션 기능이 완성된다!
        // MissionCardComponent.js
      function MissionCard() {
        const mission = useMission();
      
        return (
          <...>
            ...
            <S.StyledMissionCard>
              <S.Card src={Card} alt="미션 카드" />
              {mission.guestBook && <S.Stamp src={GuestBookStamp} alt="방명록 도장" width={16.3} top={17.7} left={12.3} />}
              {mission.performance && <S.Stamp src={PerformanceMascot} alt="공연 도장" width={15.4} top={26.5} left={24} />}
              {mission.competition && <S.Stamp src={CompetitionStamp} alt="공모전 도장" width={18.5} top={28} left={37.5} />}
              {mission.miniOne && <S.Stamp src={ActivityStampOne} alt="행사 도장" width={31} top={8.68} left={53.25} />}
              {mission.miniTwo && <S.Stamp src={ActivityStampTwo} alt="행사 도장" width={19.1} top={3.7} left={39.3} />}
            </S.StyledMissionCard>
          </...>
        );
      }
      
      // styles.js
      ...
      export const Card = styled.img`
        min-width: 264px;
        max-width: 500px;
        
        width: 100%;
        height: 100%;
      `;
      
      export const Stamp = styled.img`
        position: absolute;
        width: ${props => props.width}%;
        ${props => props.top && css`top: ${props.top}%`};
        ${props => props.left && css`left: ${props.left}%`};
        ${props => props.right && css`right: ${props.right}%`};
      `;

      각 미션 스탬프의 위치는 absolute 속성과 width, top, left, right 속성을 하드코딩하여 어느 기기에서나 똑같이 보이도록 해주었다.
      (참고: absolute와 fixed로 다양한 스마트폰 너비 대응하기)

미션 3개를 완료한 상태의 미션 카드