이번 주 위클리 미션은 페어프로그래밍으로 진행했다.
지난 주에는 기존에 각자 진행하던 위클리 미션을 베이스로 페어프로그래밍을 진행했으나, 이번 주에는 새로운 프로젝트를 생성해 페어프로그래밍으로 처음부터 끝까지 완주하는 방식으로 변경되었다.
그래서 이번 주 주제는 todoList.
react를 활용하고 netlify로 배포하되, 그외에는 별도의 요구사항 없이 자유롭게 진행하는 방식이었다.
익스텐션 설치하고, 세션을 링크로 전달받아 join하면 같이 코드를 작성할 수 있다. 아무래도 3명이서 진행하면 조금 버벅이거나 딜레이가 있어서 2명이서 하는 게 제일 좋은 것 같다.
git이 익숙하지 않은 나는 매번 무지성 git add .만 했었다.
그러다 보니 커밋 단위를 잘게 쪼개지 못해 문제가 종종 있었는데, 그냥 이렇게 add를 하나하나 해서 할 수 있었다..

이번 페어프로그래밍을 진행하면서
PR을 날릴 줄 모르고 새로 만든 repo에서 main 브랜치에 계속 push를 해왔다. 근데 pr을 날려야 할 것 같아서 멘토분께 여쭤보고 답을 찾을 수 있었다.
첫번째 커밋으로 checkout을 한 뒤, git checkout {최신커밋}
그 상태에서 새로운 브랜치를 만들고 (예를 들어 review 브랜치)
git switch -c review
git push origin review
git switch main
git checkout -
원격저장소로 가서 main => review로 PR을 날리면 끝..!
(repo를 새로 생성했다면 리뷰어를 invite도 해줘야 한다)
매번 커밋을 할 때, 대충대충 커밋해왔었다.
근데 이번에 페어프로그래밍을 하면서 그게 아니라 이렇게 하나하나 비교사항을 체크하면서 커밋을 정확히하는 습관을 들여야 하는 걸 새로 배웠다.

이번에는 사실 가볍게 시작한 페어프로그래밍이었기에 바로 main 브랜치로 push했지만, 점점 기능이 많아짐에 따라 main에 바로 push 하는 것보다 기능별로 브랜치를 파서 PR을 날리는 게 좋아 보인다.
npm i firebase
npm i react-firebase-hooks
백엔드?를 구성하기 위해 firebase를 사용했다.
처음 위클리 미션 안내사항으로는 화면만 구성하고, 추가적으로 localStorage를 사용해도 된다고만 전달 받았다.
하지만 localStorage로 데이터를 저장한다든지 하기에는 아쉬운 부분이 있어 마음을 접었다가 케니의 의견으로 firebase를 사용하게 됐다. 예전부터 들어만 봤지 제대로 사용해본 적이 없어 항상 궁금했던지라 이때부터 본격적으로 위클리미션에 올인했던 것 같다.
src/service 폴더 안에 firebase.js 파일을 생성하고 기본 설정을 해준다.
firebase/app으로부터 initializeApp을 받아오고
firebaseConfig에 각종 설정값을 작성한 뒤,
const app = initializeApp(firebaseConfig)로 선언하면 된다.
그리고 추가적으로 db를 구성하려면 이렇게 선언한 app을 getFireStore에 담아 저장하면 된다.
일반적으로 스택오버플로우에서 찾아볼 때는 db라는 변수명으로 사용하는 것 같지만, 우리는 firestore = getFirestore(app) 이렇게 사용했다.
import { initializeApp } from 'firebase/app';
import { getFirestore, collection, getDocs } from 'firebase/firestore/lite';
// Follow this pattern to import other Firebase services
// import { } from 'firebase/<service>';
// TODO: Replace the following with your app's Firebase project configuration
const firebaseConfig = {
//...
apiKey:
authDomain:
projectId:
stroageBucket:
messageSenderId:
appId:
};
const app = initializeApp(firebaseConfig);
const db = getFirestore(app);
추가적으로 인증 기능도 구현하기 위해 const firebaseAuth = getAuth(app)이라는 것도 작성해준다.
// src/service/firebase.js
import { initializeApp } from "firebase/app";
import { getFirestore } from "firebase/firestore";
import { getAuth } from "firebase/auth";
const firebaseConfig = {
apiKey: ...,
authDomain: ...,
projectId: ...,
storageBucket: ...,
messagingSenderId: ...,
appId: ...,
};
const app = initializeApp(firebaseConfig);
export const firebaseAuth = getAuth(app);
export const firestore = getFirestore(app);
App.jsx에서 인증을 관리하는데,
useEffect를 통해 초기에 onAuthStateChanged 함수를 통해 사용자 옵저빙을 해준다.
유저 정보가 일치하면, getDocs(query) 함수에서 스냅샷을 찍어서 데이터를 저장한다. 이를 userInfo라는 state에 저장해 관리한다.
const q = query(...)
const querySnapshot = await getDocs(q)
querySnapshot.forEach((doc) => { doc.data() ... }
그리고 동시에 데이터를 받아오면 isLoggedIn이라는 state를 boolean 값으로 관리해 로그인이 됐는지 안 됐는지를 prop으로 전달해 체크한다.
그리고 그에 맞게 화면을 보여준다.
// src/App.jsx
import { firebaseAuth, firestore } from "./service/firebase";
import { collection, query, where, getDocs } from "firebase/firestore";
import { onAuthStateChanged } from "firebase/auth";
// ...
function App() {
const [isLoggedIn, setIsLoggedIn] = useState(false);
const [userInfo, setUserInfo] = useState(null);
const [init, setInit] = useState(false);
const { theme } = useContext(ThemeContext);
useEffect(() => {
onAuthStateChanged(firebaseAuth, async (user) => {
if (user) {
const q = query(
collection(firestore, "users"),
where("uid", "==", user.uid)
);
const querySnapshot = await getDocs(q);
querySnapshot.forEach((doc) => {
const data = doc.data();
setUserInfo({
uid: data.uid,
email: data.email,
displayName: data.displayName,
date_created: data.date_created,
});
});
setIsLoggedIn(true);
} else {
setIsLoggedIn(false);
setUserInfo(null);
}
setTimeout(() => {
setInit(true);
}, 1000);
});
}, []);
return (
<>
{init ? (
<AppRouter isLoggedIn={isLoggedIn} userInfo={userInfo} />
) : (
<>
보내기자바스크립트를 사용하여 비밀번호 기반 계정으로 Firebase에 인증하기
로그인 페이지에서는 signInWithEmailAndPassword 함수에 사용자 이메일과 비밀번호를 전달하면 된다.
이 함수 실행 이후 페이지 이동을 위해 navigate 처리를 해줬는데, 이 부분에서 애를 많이 먹었다. 멘토링 때 다시 한번 보는 걸로...
// src/pages/SigninPage.jsx
import { firebaseAuth } from "../service/firebase";
import { signInWithEmailAndPassword } from "firebase/auth";
const SigninPage = () => {
const handleSignin = (e) => {
setLoading(true);
e.preventDefault();
signInWithEmailAndPassword(firebaseAuth, input.email, input.password)
.then(() => {
setTimeout(() => {
setLoading(false);
navigate("/");
}, 500);
})
.catch((error) => {
setLoading(false);
setShowFail(true);
setFailMessage(authFailMessageMap[error.code]);
});
};
return (
<form className={styles.form} onSubmit={handleSignin}>
...
}
회원 가입 페이지는 createUserWithEmailAndPassword 함수에 신규 이메일 주소와 비밀번호를 전달하면 된다.
계속 쓰다보면 사용방법이 비슷한데, 첫번째 인자로 firebase의 auth, store 등 종류에 맞게 넘겨주고 그 다음 인자부터 그에 맞는 데이터들을 넘겨주는 방식이다.
// src/pages/SignupPage.jsx
import { firebaseAuth, firestore } from "../service/firebase";
import { createUserWithEmailAndPassword } from "firebase/auth";
const SignupPage = () => {
const handleSignup = async (e) => {
e.preventDefault();
if (input.displayName === "") {
setShowFail(true);
setFailMessage("Enter a nickname!");
return;
}
setIsLoading(true);
await createUserWithEmailAndPassword(
firebaseAuth,
input.email,
input.password
)
.then(async (userCredential) => {
const user = userCredential.user;
await setDoc(doc(firestore, "users", user.uid), {
uid: user.uid,
...input,
});
setIsLoading(false);
navigate("/");
})
.catch((error) => {
setIsLoading(false);
setShowFail(true);
setFailMessage(authFailMessageMap[error.code]);
});
};
const handleChangeInput = (e) => {
const { name, value } = e.currentTarget;
setInput({ ...input, [name]: value });
};
if (isLoading)
return (
<div className={styles.spinnerWrapper}>
<Spinner />
</div>
);
return (
<form className={styles.form} onSubmit={handleSignup} noValidate>
}
원리는 비슷한데, getDocs를 통해 querySnapshot을 만들어주면,
그 안에서 crud를 진행하면 된다.
이때 getDocs의 인자로 query를 넘겨주는데, query를 작성할 때 doc인지 collection인지 잘 구분해야 한다.
이때 우리가 겪었던 문제는 뭐였냐면, 사용자 id즉, userInfo라는 state에 저장해둔 uid를 users라는 문서 안에 저장해두었는데 계속 undefined가 뜨는 문제였다.
이리저리 찾아보다 알게 된 건, user.uid를 담고 있는 document의 id가 유저의 uid와 일치하지 않는다는 것이었다.
그래서 이걸 SignupPage에서 유저 아이디를 생성할 때 setDoc으로 전달할 때, users 라는 collection? 안에 문서 id를 firebase가 임의로 생성하도록 놔두지 않고 user.uid로 우리가 직접 지정하는 방식으로 진행했다.
await setDoc(doc(firestore, "users", user.uid), { uid: user.uid, ...input, });
이때 보면 document까지 지정했기 때문에 collection이 아니라 doc을 사용한 걸 볼 수 있다!
await createUserWithEmailAndPassword( firebaseAuth, input.email, input.password ) .then(async (userCredential) => { const user = userCredential.user; await setDoc(doc(firestore, "users", user.uid), { uid: user.uid, ...input, }); setIsLoading(false); navigate("/"); }) .catch((error) => { setIsLoading(false); setShowFail(true); setFailMessage(authFailMessageMap[error.code]); });
데이터 구조가 계속해서 collection > document > collection > document... 이렇게 파고 든다.
그래서 예를 들어 아래 HomePage에서는
users라는 collection 안에 userInfo.uid라는 document들 개별 안에 todos라는 또 다른 collection 까지 파고 들고 있기 때문에
collection(firestore, querypath)를 query 문 안에 넣은 것이다.
해당 collection 안에서 where을 통해 추가적으로 id 값이 일치하는 녀석을 query로 저장해 getDocs를 사용하면 querySnapshot을 찾을 수 있다.
체크 박스를 클릭했을 때, 해당 아이템의 체크 여부를 업데이트해줬어야 했는데, todos라는 문서 안에서 또 문서의 id를 찾는 게 어려웠다.
에러문으로는 계속 lh를 내놓으라는데, lh를 도저히 찾을 수가 없었다..
기존에 users 컬렉션 안에 있는 문서의 id 값의 경우 우리가 회원가입을 할 때 id 값을 넘겨주기 때문에 그걸 사용할 수 있었지만, 그 안에 컬렉션 안의 또 다른 문서의 id를 알기가 어려웠는데, 챗gpt가 허무하게 답을 알려줬다.
getDocs를 사용해서 만든 querySnapshot의 개별 문서들 doc에 ref 라는 프로퍼티를 사용하면 그게 해당 문서의 id 값이었다.
아무리 찾아도 나오지 않고 콘솔로 찍었을 때의 객체 정보에도 담기지 않았는데, 어떻게 찾은 걸까.
알고 보니 우리는 계속해서 console.log(doc)만 찍었었는데,
console.dir(doc)을 찍어보니 ref라는 프로퍼티 안에 lh가 담겨 있다.
// src/pages/HomePage.jsx
import { firestore } from "../service/firebase";
import {
collection,
query,
where,
updateDoc,
getDocs,
deleteDoc,
} from "firebase/firestore";
import { useCollectionData } from "react-firebase-hooks/firestore";
const HomePage = ({ userInfo, isLoggedIn }) => {
const [showModal, setShowModal] = useState(false);
const [todoItems, setTodoItems] = useState([]);
const queryPath = `/users/${userInfo?.uid}/todos`;
const _query = collection(firestore, queryPath);
const [todos, loading, error] = useCollectionData(_query);
const [editTarget, setEditTarget] = useState();
const [showDone, setShowDone] = useState(false);
const handleClickCheckBox = async (id) => {
const q = query(collection(firestore, queryPath), where("id", "==", id));
const querySnapshot = await getDocs(q);
querySnapshot.forEach(async (doc) => {
const data = doc.data();
await updateDoc(doc.ref, {
isComplete: !data.isComplete,
});
});
};
const handleDeleteItem = async (id) => {
const q = query(collection(firestore, queryPath), where("id", "==", id));
const querySnapshot = await getDocs(q);
querySnapshot.forEach(async (doc) => {
await deleteDoc(doc.ref);
});
};
useEffect(() => {
setTodoItems(todos);
}, [todos]);
처음에 버튼의 이벤트 발생 시 실행할 함수들을 작성할 때 뇌정지가 왔었다.
왜냐면, 리액트를 써본 적이 없어, 각 버튼들에서 함수를 실행할 때 어떤 흐름으로 진행되는지 전혀 몰랐기 때문이다.....
알고보니 이런 흐름이었다.
컴포넌트를 사용하는 페이지에서 관리하는 state와 setState, 그리고 이를 관리하는 handle 함수를, 예를 들어 아래 코드처럼 Button 컴포넌트에 넘겨주면, 이 버튼 컴포넌트에서 handle 함수를 실행하는 식으로 state를 관리하는 식이었다.
왜 prop drilling이 문제라고 하는지 알게 됐다.
겨우 이 정도 prop 전달만으로도 머리가 하얘졌는데,
프로젝트 규모가 커지면 커질수록 엄청나게 문제겠구나... 싶었다.
// src/page/HomePage.jsx
const HomePage = ({ userInfo, isLoggedIn }) => {
const [showModal, setShowModal] = useState(false);
const handleClickCloseModal = () => {
setShowModal(false);
};
return (
// ...
<Button buttonType="create" onClick={handleClickOpenModal} />
)
}
// src/components/Button.jsx
const Button = ({ onClick, buttonType }) => {
return (
<motion.button
// ...
onClick={onClick}
// ...
>
버튼 컴포넌트를 사용할 일이 많았는데,
이 버튼들의 용도가 다양했다. 그리고 버튼들이 다크모드인지, 라이트모드인지에 따라 사용되는 그림자들도 전부 달랐고, 그에 맞는 이미지들도 전부 달랐다.
이걸 어떻게 처리해야 할지 전혀 감도 못잡았는데, maps를 사용해서 관리하는 걸 보고 많이 배웠다.
버튼 컴포넌트에서는 buttonType만 prop으로 받는다.
그리고 이렇게 prop으로 내려줄 buttonType은 maps.js에서 각각 프로퍼티로 정의해둔다. 그에 맞게 스타일, 클래스 등등을 한번에 적용하기 위해 maps를 사용한 것이다.
import React, { useContext, useEffect, useState } from "react";
import { desktopButtonMap, mobileButtonMap } from "../../static/maps";
import { ThemeContext } from "../../Contexts/ThemeContext";
import styles from "./Button.module.css";
import { useMediaQuery } from "react-responsive";
import { motion } from "framer-motion";
const Button = ({ onClick, buttonType }) => {
const { theme } = useContext(ThemeContext);
const isMobile = useMediaQuery({ query: "(max-width: 768px)" });
const buttonTypeMap = isMobile ? mobileButtonMap : desktopButtonMap;
const targetBtn = buttonTypeMap[buttonType];
const buttonStyle = `${styles[buttonType]}`;
const [buttonClass, setButtonClass] = useState(
`${buttonStyle} ${targetBtn[theme].className} ${theme}`
);
const changeToConcave = (e) => {
e.stopPropagation();
setButtonClass((prev) => prev.replace("convex", "concave"));
};
const changeToConvex = (e) => {
e.stopPropagation();
setButtonClass((prev) => prev.replace("concave", "convex"));
};
useEffect(() => {
setButtonClass(`${buttonStyle} ${targetBtn[theme].className} ${theme}`);
}, [theme]);
return (
<motion.button
initial={{ opacity: 0, scale: 0.5, x: 100 }}
animate={{ opacity: 1, scale: 1, x: 0 }}
exit={{ opacity: 0, scale: 0.5, x: 100 }}
onClick={onClick}
onPointerDown={changeToConcave}
onPointerLeave={changeToConvex}
className={buttonClass}
type={targetBtn.type ?? ""}
>
<div
style={{ width: targetBtn.imgBoxWidth, height: targetBtn.imgBoxHeight }}
>
<img
src={targetBtn[theme].src}
style={{ width: "100%", display: "block" }}
/>
</div>
</motion.button>
);
};
export default Button;
// src/static/maps.js
export const desktopButtonMap = {
edit: {
imgBoxWidth: "24px",
imgBoxHeight: "24px",
type: "button",
light: {
src: "assets/pencil.png",
className: "convex-light-sm",
},
dark: {
src: "assets/pencil-dark.png",
className: "convex-dark-sm",
},
},
// ...
이 방식도 너무 좋다고 생각했는데,
라이트모드인지 다크모드인지를 전역context로 관리하기에 theme 변수 안에 light, dark가 들어간다. 이를 css 클래스명으로도 정의해서 리터럴로 적용하기 좋도록 css를 정리하는 방법도 신기했다.
사이즈 별로 클래스를 분류하는 것도 신기했다..!
/* src/App.css */
/************LIGHT MODE********************/
/* Logo, AddButton */
.convex-light-lg {
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25),
-4px -4px 10px rgba(255, 255, 255, 0.94),
5px 5px 10px rgba(36, 65, 93, 0.18);
}
/* AddButton(mobile) */
.convex-light-md {
box-shadow: 0px 3px 2px rgba(0, 0, 0, 0.25),
-2px -2px 5px rgba(255, 255, 255, 0.94), 2px 2px 5px rgba(36, 65, 93, 0.18);
}
/* CheckBox, EditButton, DeleteButton*/
.convex-light-sm {
box-shadow: 0px 2px 2px rgba(60, 60, 60, 0.25),
-2px -2px 5px rgba(255, 255, 255, 0.94), 2px 2px 5px rgba(36, 65, 93, 0.18);
}
/* TodoListItem box, AddButton(active) */
.concave-light-lg {
box-shadow: inset 0px 4px 4px rgba(0, 0, 0, 0.25),
inset -6px -6px 12px rgba(255, 255, 255, 0.94),
inset 5px 5px 10px rgba(36, 65, 93, 0.25);
}
/* EditButton(active), DeleteButton(active), AddButton(mobile, active)*/
.concave-light-md {
box-shadow: inset 0px 3px 4px rgba(0, 0, 0, 0.25),
inset -2px -2px 4px rgba(255, 255, 255, 0.94),
inset 2px 2px 6px rgba(36, 65, 93, 0.25);
}
/* ProgressBar, CheckBox(active) */
.concave-light-sm {
box-shadow: inset 0px 2px 2px rgba(60, 60, 60, 0.25),
inset -2px -2px 4px rgba(255, 255, 255, 0.94),
inset 2px 2px 6px rgba(36, 65, 93, 0.25);
}
/********************DARK MODE******************/
/* Logo */
.convex-dark-lg {
box-shadow: -4px -4px 10px rgba(113, 113, 113, 0.7),
5px 5px 9px rgba(16, 16, 16, 0.61);
}
/* AddButton, AddButton(mobile) */
.convex-dark-md {
box-shadow: 5px 5px 8px rgba(16, 16, 16, 0.69),
-4px -4px 6px rgba(113, 113, 113, 0.71);
}
/* EditButton, DeleteButton */
.convex-dark-sm {
box-shadow: -2px -2px 4px #717171, 2px 2px 11px #101010;
}
/* CheckBox */
.convex-dark-ex-sm {
box-shadow: -2px -2px 5px rgba(113, 113, 113, 0.7),
2px 2px 5px rgba(16, 16, 16, 0.6);
}
.concave-dark-lg {
box-shadow: inset -5px -5px 10px rgba(113, 113, 113, 0.75),
inset 5px 5px 10px rgba(16, 16, 16, 0.6);
}
/* EditButton(active), DeleteButton(active), TodoListItem, AddButton(active), AddButton(mobile, active)*/
.concave-dark-md {
box-shadow: inset -3px -3px 6px rgba(121, 121, 121, 0.8),
inset 3px 3px 6px rgba(16, 16, 16, 0.9);
}
/* CheckBox(active), ProgressBar*/
.concave-dark-sm {
box-shadow: inset -2px -2px 2px rgba(113, 113, 113, 0.72),
inset 2px 2px 3px rgba(16, 16, 16, 0.75);
}
.modal-box-light {
box-shadow: inset -4px -4px 3px rgba(16, 16, 16, 0.53),
inset 1px 1px 5px rgba(65, 57, 57, 0.53);
}
.modal-box-dark {
box-shadow: inset -4px -4px 4px rgba(43, 43, 43, 0.91),
inset 4px 4px 4px rgba(167, 167, 167, 0.73);
}
이미지들도 적용한 theme에 맞게 이름을 정의하니 가져다 쓰기 편했다.

로그인 여부에 맞게 로그인 했으면 signin에서 홈으로 이동시켜주고
아니라면 다른 곳으로 리다이렉트시켜주는 로직을 Router, Route로 하려했으나 내부적으로 Route 컴포넌트만 사용해야 한다고 에러가 떠서
다음과 같이 일단 임시로 해결했다.
// src/Routers/AppRouter.jsx
return (
// ...
<Route
path="/signin"
element={
<AuthRouter
condition={!isLoggedIn}
destinationPage={<SigninPage />}
redirectPage={
<HomePage isLoggedIn={isLoggedIn} userInfo={userInfo} />
}
/>
}
></Route>
// ...
)
// src/Routers/AuthRouter.jsx
const AuthRouter = ({ condition, destinationPage, redirectPage }) => {
if (condition) return destinationPage;
return redirectPage;
};
export default AuthRouter;
Route 컴포넌트를 사용해야 하기 때문에 AuthRouter라는 라우터를 하나 더 만들되, 이 컴포넌트이 리턴값을 넘겨받은 페이지 컴포넌트로 바로 해주면 에러 없이 중간에서 isLoggedIn을 통해 조건에 따라 라우팅 기능을 처리할 수 있게 된다.
위와 같이 작성한 코드의 문제점은 경로와 렌더링되는 페이지가 안 맞는 거였다.
예를 들어 로그인이 된 상태에서 /signin을 url에 입력하면 다시 홈페이지로 리다이렉트되더라도 url은 /signin 그대로 인 게 문제였다.
이건 멘토님의 도움으로 해결했다...



원래는 Routers/AppRouters.jsx에서 사용할 AuthRouter 컴포넌트를 따로 만들었다. 그리고 요소 2개를 인자로 넘겨받아 바로 return에 작성해주거나 Navigate 컴포넌트로 이동시켜주는 거였다.
그게 아니라 2개 중 1개는 children prop을 사용해서 넘겨주니 해결됐다.
그리고 SigninPage.jsx에서 원래 문제가 있었는데,
로그인이 확인되면 원래는 navigate()을 통해 페이지 이동을 해줬었다.
이때의 문제는, 로그인 처리가 되지도 않았는데 navigate 함수가 발동돼서 이를 막고자 임시로 setTimeout을 사용했다.
근데 이렇게가 아니라 위에서처럼 라우팅 처리를 해줘버리면, SigninPage에서 굳이 navigate 함수를 사용해서 이동시키지 않아도 문제를 해결할 수 있었다..
Framer를 사용해서 toast modal을 만들 때 문제가 약간 있었다.
하나의 #root 요소를 두고 사용하다 보니, 전역이 아니어서 위치가 이상하게 꼬이는 문제였다.
해결하기 위해 modal용 요소 하나를 index.html에 만들고
createPortal 포탈을 사용해서 밖에 모달을 그렸더니 해결됐다.
// index.html
...
<body>
<div id="toast-portal"></div>
<div id="root"></div>
<div id="modal-portal"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
index.html에서 만든 div#toast-portal을 선택한 뒤
createPortal을 사용해서 부모 컴포넌트의 DOM 계층 구조 바깥에 있는 DOM 노드로 모달을 렌더링하게 해준다.
// src/components/Modal/ToastPortal.jsx
import { createPortal } from "react-dom";
const ToastPortal = ({ children }) => {
const el = document.getElementById("toast-portal");
return createPortal(children, el);
};
export default ToastPortal;
만든 ToastPortal로 이전에 만들어둔 모달을 감싸주면 된다.
그럼 내부 자식 요소들이 밖에서 렌더링된다. 덕분에 위치값 문제도 해결할 수 있었다.
// src/components/Modal/DoneModal.jsx
import React, { useState, useContext, useEffect } from "react";
import { motion } from "framer-motion";
import { ThemeContext } from "../../Contexts/ThemeContext";
import styles from "./DoneModal.module.css";
import ToastPortal from "./ToastPortal";
const DoneModal = ({ message, onClose }) => {
const { theme } = useContext(ThemeContext);
const [containerTheme, setContainerTheme] = useState(
theme === "light" ? "convex-light-lg" : "convex-dark-md"
);
useEffect(
() =>
setContainerTheme(
theme === "light" ? "convex-light-md" : "convex-dark-sm"
),
[theme]
);
useEffect(() => {
let timer = setTimeout(() => onClose(), 2000);
return () => {
clearTimeout(timer);
};
}, []);
return (
<ToastPortal>
<motion.div
initial={{ opacity: 0, x: -240, scale: 0.3 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: -240, scale: 0.5 }}
transition={{ type: "spring", stiffness: 100 }}
>
<div
className={`${styles.wrapper} ${containerTheme} ${
styles[`${theme}Background`]
}`}
>
<p className={styles.content}>{message}</p>
</div>
</motion.div>
</ToastPortal>
);
};
export default DoneModal;
참고로
useEffect 내에서 setTimeout
useEffect 내에서 setTimeout을 넘겨줄 때는 함수 레퍼런스를 넘겨줘야 cleanup을 할 수 있다.
useEffect(() => { let timer = setTimeout(() => onClose(), 2000); return () => { clearTimeout(timer); }; }, []);
// src/components/Modal/Modal.jsx
const validateForm = (e) => {
e.preventDefault();
if (titleRef.current.value === "") {
setWarning(true);
setTimeout(() => {
setWarning(false);
}, 400);
return;
}
handleSubmit(e);
onClose();
};
모달창에서 입력값이 없을 때 흔들리는 애니메이션을 적용할 때 warning이라는 state로 판단하도록 코드를 작성했다.
이때 입력값이 없으면 warning을 true로 해주고, true가 되는 순간 애니메이션 효과가 발동되고 나서 다시 false로 초기화해주어야 반복적으로 빈 문자열을 삽입하려고 할 때마다 경고를 줄 수 있다.
근데 단순히
setWarning(true)
setWarning(false)
로 처리해버리면, 리액트의 배치 시스템 때문에 false로 한번에 처리가 돼버린다. 이때, setTimout으로 setter를 비동기로 처리하도록 로직을 구현하면 된다.
그럼 코드를 전부 실행한 후에야 setTimeout을 큐에서 꺼내 setWarning(false)를 처리하므로 하나의 배치 시스템 안에 한번에 처리되어 버리는 걸 막을 수 있다.

이렇게 header 컴포넌트에서 바디 스타일을 바꾸는 코드를 작성했을 때의 문제점은 드러나지 않는 의존 관계를 만들어 유지보수를 어렵게 만든다.
"어디서 body 태그에 스타일을 주고 있는 건지?"라는 생각이 들게 만든다.
이 경우 App.jsx의 return 문을 감싸는 div 태그를 생성해서 body 밑에 두게 만드는 방법이 있다.
// App.jsx
return (
<div className={them === light ? light : dark}> // 이런 식으로 div로 감싼다.
<>
{init ? (
// ...
</div>
이런 테마는, toast-modal과 같이 div#root와 분리된 요소에는 적용이 안되겠지만, 굳이 필요 없으며, 우리처럼 간단하게 div#root에만 요소들이 담기는 게 아니라 여러 개로 나뉘어져 있다면, 그때 비로소 document.body.style로 값을 주는 식으로 생각하면 좋을 것 같다.

라이트/다크 모드를 변경하는 스위치를 만들기 위해 react-switch 라이브러리를 쓰고 있었으나, 이 녀석의 handleDiameter 속성이 제대로 반영하지 못하는 문제가 있었다.
모바일 - 데스크탑으로 이동할 때 사이즈 변경은 되는데, 새로고침을 해야만 반영이 되는 문제였다.
라이브러리 패키지에 들어가보면 ref.handleDiameter만 뭔가 다르게 생겼는데 이게 문제일까. 고민만 하다가 해결이 안 돼서 일단 뒀는데 다시는 안 쓸 것 같다..
// src/components/Header/Header.jsx
return (
// ...
<div className={styles.switchContainer}>
{device === "mobile" ? (
<ToggleSwitch
checked={theme === "light"}
onChange={handleChange}
isMobile={isMobile}
handleDiameter={20}
/>
) : (
<ToggleSwitch
checked={theme === "light"}
onChange={handleChange}
isMobile={isMobile}
handleDiameter={38}
/>
)}
)
일단 임시로 해결은 했다. 컴포넌트에 key 값을 변수로 주면,
key 값이 달라질 때마다 리렌더링이 아니라 마운트가 돼서
모바일에서 데크스탑으로, 혹은 반대로 사이즈를 임의로 줄일 때 새로고침을 하지 않아도 핸들러의 크기가 자연스럽게 바뀌었다.
하지만 매번 마운트가 되도록 하는 게 권장하는 방법은 아니라 하여 애초에 이 라이브러리를 쓰지 않는 방법으로 리팩토링을 해야겠다..
라이브러리를 쓸 것인지 직접 쓸 것인지를 기회비용을 따져볼 줄 알아야 했다.
기능을 직접 구현하는 게 그렇게까지 복잡하지 않은 경우, 직접 개발하는 게 이후 유지보수 측면에서도 좋을 수 있다는 걸 배웠다.
framer-motion 기본 개념
framer-motion에서는 framer component를 만들고, 이 컴포넌트가 prop으로 애니메이션 속성들을 전달받는다.
motion.div, motion.header ... 이렇게 컴포넌트를 만들 수 있다.
그리고 기본적으로 이 framer component는 애니메이션에서 중요한 3가지 상태를 설정할 수 있다.
initial, transition prop의 경우 작성하지 않아도 기본값이 설정되며,
animate prop 대신 gesture prop들을 입력해도 된다.
(hover 시에는 whileHover 등)
애니메이션은 3가지 타입을 가지며
prop으로 애니메이션 작성하다보면 길어지는데 변수로 뺄 수 있다.
const variants = {
first: { x: 20, y: 30 },
second: { x: 30, y: 40 },
}
return (
<motion.div
variants={variants}
initial="first" // variants에서 설정한 key를 string 형태로 삽입.
animate="second"
>
<motion.div>
)
variants는 함수 레퍼런스를 넘겨줘서 동적인 애니메이션으로 만들 수도 있다.
애니메이션 상태 관리
리액트의 useState처럼 애니메이션 상태를 관찰할 수 있다(useState처럼 리렌더링은 안 해준다)
import { motion, useMotionValue } from "framer-motion"
export function MyComponent() {
const x = useMotionValue(0)
return <motion.div style={{ x }} />
}
const x = useMotionValue(0)
const input = [-200, 0, 200]
const output = [0, 1, 0]
const opacity = useTransform(x, input, output)
return <motion.div drag="x" style={{ x, opacity }} />
레이아웃 변경시 부드럽게 하려면
layout prop을 true로 주면 설정한 컴포넌트이 layout이 변할 때, 그 변화를 부드럽게 만들어준다.
return (
<motion.div layout>...<motion.div>
)
AnimatePresence
AnimatePresence 컴포넌트는 컴포넌트가 언마운트될 때 exit animation을 가능하게 해준다.
실제 적용해보기
AnimatePresence 컴포넌트로 감싸줘서 애니메이션들이 언마운트일 때도 애니메이션을 적용할 수 있게 해준다.
key로 유일한 값을 저장해둔다.
// src/components/TodoList/TodoListItem.jsx
const variants = {
open: { x: 0, width: "70%" },
closed: { x: 0, width: "100%" },
desktop: { x: 0, width: 638 },
};
return (
// ...
<AnimatePresence>
<motion.div
animate={isMobile ? (showButtons ? "open" : "closed") : "desktop"}
variants={variants}
transition={{ type: "ease" }}
className={`${styles.textBox} ${textBoxClass} ${styles[showButtons]}`}
// key="vweoub"
>
<CheckBox
onClick={handleClickCheckBox}
checked={isComplete}
itemId={id}
keys="vbouwe"
/>
<div
title={title}
className={styles.textWrapper}
style={{ textDecoration }}
onClick={toggleShowButtonsOnMobile}
key="uitpwe"
>
{title}
</div>
</motion.div>
<div
className={`${styles.dummyItem}`}
// style={{ display: `${showButtons ? "block" : "none"}` }}
key="vqeipn"
></div>
{showButtons && (
<>
<div className={styles.btnWrapper} key="qwvbip">
<Button buttonType="edit" onClick={handleEdit} key="pqmzb" />
</div>
<div className={styles.btnWrapper} key="qtyiuv">
<Button buttonType="delete" onClick={handleDelete} key="csyue" />
</div>
</>
)}
</AnimatePresence>
)
예를 들어 toast modal의 경우 다음과 같이 구현했다.
초기값을 x축 왼쪽으로 밀어넣고, 애니메이션이 동작하면 0 지점으로 왔다가 다시 되돌아간다.
사라질 때는 어떻게 사라지냐면, useEffect를 통해 setTimeout을 설정하는 방식을 사용했다. 보여주고 2초 뒤에 토스트 모달이 사라진다.
// src/components/Modal/DoneModal.jsx
import React, { useState, useContext, useEffect } from "react";
import { motion } from "framer-motion";
import { ThemeContext } from "../../Contexts/ThemeContext";
import styles from "./DoneModal.module.css";
import ToastPortal from "./ToastPortal";
const DoneModal = ({ message, onClose }) => {
const { theme } = useContext(ThemeContext);
const [containerTheme, setContainerTheme] = useState(
theme === "light" ? "convex-light-lg" : "convex-dark-md"
);
useEffect(
() =>
setContainerTheme(
theme === "light" ? "convex-light-md" : "convex-dark-sm"
),
[theme]
);
useEffect(() => {
let timer = setTimeout(() => onClose(), 2000);
return () => {
clearTimeout(timer);
};
}, []);
return (
<ToastPortal>
<motion.div
initial={{ opacity: 0, x: -240, scale: 0.3 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: -240, scale: 0.5 }}
transition={{ type: "spring", stiffness: 100 }}
>
<div
className={`${styles.wrapper} ${containerTheme} ${
styles[`${theme}Background`]
}`}
>
<p className={styles.content}>{message}</p>
</div>
</motion.div>
</ToastPortal>
);
};
export default DoneModal;
닷(.)이 아니라 대괄호([]) 안에 변수를 넣으면 리터럴로 변수를 클래스명처럼 사용할 수 있다.
return (
// ...
<motion.div
animate={isMobile ? (showButtons ? "open" : "closed") : "desktop"}
variants={variants}
transition={{ type: "ease" }}
className={`${styles.textBox} ${textBoxClass} ${styles[showButtons]}`}
// key="vweoub"
>
)
반응형에서 너비가 줄어들 때 width가 갑자기 줄거나 하지 않고 자연스럽게 줄도록 하려면 clamp을 사용하면 좋다.
자연스럽게 줄이고 싶은 요소의 최소/최대 너비를 작성하고
그 요소가 존재하는 뷰포트의 최소/최대 너비를 calculator 같은 걸로 계산해서 입력하면 자연스럽게 요소가 줄어들도록 할 수 있다.
.textBox {
height: 50px;
width: clamp(20.125rem, 5.454rem + 62.595vw, 35.5rem);
position: relative;
flex-grow: 1;
transition: flex-grow 2s ease;
}
투두리스트 아이템을 클릭했을 때 길이가 자연스럽게 늘어나도록 하려고 했었다.
이때 framer를 사용했었는데, width를 % 값으로 넣어놓고, 클릭하면 옆에 있던 편집/삭제 버튼을 지워버리는 식으로 구현했다.
이때 문제가 2가지였다.
- width가 %면서 옆에 있던 요소들이 사라지면서 넓어지는 거라서(width의 값이 변하지 않고 똑같은 100%인데 요소가 사라져서 넓어지는 방식) animation trigger가 제대로 동작하지 않는 문제(transtion이 width를 감지하지 못한다)
- width가 작아졌다가 넓어질 때 가운데에서부터 넓어지는 문제(flex 아이템으로 여러 요소가 있다가 혼자가 되어 버리기 혼자 가운데로 가버린다).
이를 해결하기 위해 더미 요소를 만들고 이 더미 요소에 flex-grow 값을 줬다.
그러면 클릭했을 때, 편집/삭제 버튼은 사라지더라도 flex-grow를 갖고 있는 더미 요소는 남아 있으므로 투두리스트 아이템은 왼쪽 끝에 잘 붙어 있게 된다.
이 상태에서 flex-grow의 값에 변화를 주어(더미 요소의 flex-grow가 3에서 1로 줄도록) 아이템의 길이가 자연스럽게 늘어나도록 했다.
그리고 이 더미 아이템은 모바일 버전에서만 필요하므로 그 외에서는 none 처리해줬다.
/* src/components/TodoList/TodoListItem.module.css */
.dummyItem {
display: none;
width: 0px;
flex-grow: 3;
}
@media only screen and (max-width: 430px) {
.dummyItem {
display: block;
}
}
@media only screen and (max-width: 768px) {
.wrapper {
display: flex;
gap: 10px;
justify-content: center;
}
.textBox {
height: 50px;
width: clamp(20.125rem, 5.454rem + 62.595vw, 35.5rem);
position: relative;
flex-grow: 1;
transition: flex-grow 2s ease;
}
아래 설명에 오류가 있는데, localStorage는 window 객체의 프로퍼티이므로 리액트의 useState에서는 값을 읽을 수 있다. 다만 블로그들에서 나오는 예시들은 next.js를 사용한 SSR에서의 문제였다. 이때는 window 객체를 읽지 못하기 때문이다!
prefers-color-scheme을 통해 사용자가 컴퓨터에서 설정한 테마 정보를 판단할 수 있다. 그래서 접속하는 순간, 사용자가 지정해둔 테마 정보를 얻어서 이걸 localStorage에 저장하도록 했다. 그래서 이후에는 저장한 값을 계속 쓰고, 오직 토글 스위치로만 다크모드/라이트모드를 변경할 수 있도록 했다.
문제는, 새로고침했을 당시 초기에 앱에서 localStorage 값을 읽지 못하는 것이었다. 그래서 무조건 처음에는 localStorage.getItem("theme")에 값을 찾을 수가 없다. 사용자가 새로고침을 하거나 url에 뭔가를 입력해서 이동했을 때 사용자의 테마가 계속해서 초기화되는 문제가 있었다.
일단 어쩔 수 없다고 판단했는데, localStorage에 담긴 값을 초기에 못 읽는다는 사실을 이번 위클리미션을 통해 알게 돼서 다행이다.
그래서 localStorage는 렌더링 이후 실행되는 useEffect 안에서 처리해야 한다!
// src/contexts/ThemeContext.jsx
import { createContext, useState, useEffect } from "react";
import { useMediaQuery } from "react-responsive";
export const ThemeContext = createContext(null);
export const ThemeProvider = (props) => {
const isDark = useMediaQuery({ query: "(prefers-color-scheme: dark" });
const [theme, setTheme] = useState(
localStorage.getItem("theme") || (isDark ? "dark" : "light")
);
useEffect(() => {
setTheme(theme);
}, []);
useEffect(() => {
localStorage.setItem("theme", theme);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{props.children}
</ThemeContext.Provider>
);
};
직접 프로젝트 폴더에서 디버깅을 할 수도 있지만,
네트워크 탭에서 cmd + p로 파일을 검색할 수 있다.
이때 번들된 파일들은 minified 되어 있기도 해서, 되도록이면 ~.map.js 파일을 찾으면 되는데, 경우에 따라 이걸 안 만들었을 경우
config에서 sourcemap 관련 설정을 true로 해주면 된다.
예를 들어 vite에서는 build.sourcemap을 만져주면 된다.
선언형 프로그래밍 이면에 명령형 프로그래밍이 담겨 있다.
선언형 프로그래밍은 명령형 프로그래밍을 추상화한 것이다.
코드를 작성할 때도 추상화를 잘 하는 게 중요하다.
=> 어떻게 하면 추상화를 잘 할 수 있을까?
컴포넌트를 예로 들면, 외부에서 보여질 prop을 잘 정리하고, 내부적으로 로직을 잘 구현하는 것.
예를 들어 우리 코드에서는 react-switch라는 라이브러리를 사용해서 컴포넌트를 만들었다. 이때 예를 들어 라이브러리에서 제공해주는 기본 prop 중에서 사실 우리 프로젝트에서 필요한 prop은 몇 개 안 될 수 있다. 게다가 기능적으로 부담스럽지 않으므로 이런 케이스에서는 직접 스위치 컴포넌트를 구현하는 게 더 좋다. 이렇게 불필요한 prop을 제거하는 것도 중요하다.
그리고 ToggleSwitch에서 isMobile 과 같은 prop을 받는데,
이런 prop들은 직접 코드를 작성하는 우리들은 이해하지만, 다른 개발자 혹은 1년 뒤의 나 자신이 봤을 때 직관적이지 않을 수 있다.
prop을 직관적으로 잘 구성하고 내부 로직을 잘 구현하려고 노력해야 좋은 코드를 작성할 수 있을 것 같다.

https://github.com/codeit-bootcamp-frontend/henry-kenny-ian-todolist
구현 자체를 목적으로 두기도 했고, 처음에 설계를 전혀 고려하지 않고 페어프로그래밍 연습 겸으로 시작했다 보니, 리팩토링이 많이 필요해 보인다.
그래서 여기서 끝내지 않고 계속해서 조금씩이라도 발전해나갈 생각이다.

