- 프로젝트: SNU FESTIVAL
- 키워드: firestore, redux saga
- 상황
- 축제 사이트를 구현할 때 미션 기능을 추가해달라는 요청이 들어왔다. 페이지 곳곳에 5개의 미션이 나누어져 있고 특정 미션을 클리어하면 미션 카드에 그림 스탬프가 채워지는 형식이었다.
- 웹사이트 전체에 걸쳐 미션 진행 상태를 다뤄야 해야하기 때문에 redux를 이용하여 브라우저 내 상태 관리를 하는 것이 좋다고 생각했다.
- firebase auth 로그인을 한 상태로 미션이 이루어지기 때문에 firestore에 uid(사용자 계정의 unique id)를 문서 이름(key)으로 하고, 미션 진행 상태를 value로 저장하는 방식으로 미션 데이터를 관리하기로 했다.(GET/SET을 할 때 대상 문서를 쉽게 찾을 수 있다.)
- 축제 사이트를 구현할 때 미션 기능을 추가해달라는 요청이 들어왔다. 페이지 곳곳에 5개의 미션이 나누어져 있고 특정 미션을 클리어하면 미션 카드에 그림 스탬프가 채워지는 형식이었다.
- 해결 과정
- 먼저 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 };
- 여러 페이지에서 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;
- 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로 하여 서버 정보를 불러왔음을 표시한다. - 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을 통해 기존 문서를 완전히 덮어쓰지 않고 안전하게 병합할 수 있다. - 미션 카드 컴포넌트에서 완료된 미션에 해당하는 그림 스탬프를 채워넣어주면 미션 기능이 완성된다!
// 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로 다양한 스마트폰 너비 대응하기)
- 먼저 redux state.js에 필요한 상태값을 선언한다. 총 5개의 미션이 있고, 서버(firestore)와의 연동이 이루어졌는지를 나타내는 isLoaded flag도 있다.
'프론트엔드 > React' 카테고리의 다른 글
react-window로 렌더링 성능 최적화하기 (0) | 2022.04.02 |
---|---|
UI library에서 spacing system props 구현하기 (0) | 2022.03.22 |
노래맞히기 게임 구현하기(feat. holwer.js, classList) (0) | 2022.03.06 |
선언형 Portal, Modal 컴포넌트 구현하기 (0) | 2022.02.15 |
Toast 컴포넌트 구현하기 (1) | 2022.01.26 |