내가 슈퍼 면역이 아니라니... 왠지 주말동안 몸이 너무 아프다 싶었는데, 쌍둥이 언니가 확진 판정을 받았다는 소식을 듣고 병원에 가보니 나도 양성이라고 하였다(사실 내가 먼저 아팠으나 자가 키트로는 음성이었다). 이번 주에는 노마드 코더 TS 챌린지, 우테코 프리코스 시작, 이력서 작성 등 최근 들어 가장 바쁜 일정이 기다리고 있는데 이 중요한 시기에 코로나라니.. 이왕 격리해야 한다면 골골 아프면서 강의를 들을 바에 토이 프로젝트를 하나 만드는게 낫다는 판단이 들었다. 이 글은 자가 격리를 하면서 갓생을 산 프론트엔드 개발자의 이야기이다.
프로젝트 아이디어는 많지만 "이것만 배우고!"라는 생각에 계속 미루는 경우가 많았다. 그 중 가장 해보고 싶었던 쌍둥이 맞추기 퀴즈 프로젝트를 선정하였다. 오랜만에 지인들을 만나 심심할 때마다 내 갤러리에 있는 50 여장의 쌍둥이 맞추기 사진으로 게임을 하곤 한다. 특히 명절이나 공휴일 시즌에 누가 더 많이 맞추는지를 체크하면 더욱 재미있는데, 이에서 착안한 프로젝트가 바로 "Who is Quartz?" 이다.
프로젝트의 구현 기능은 깃허브 저장소의 README.md에 적어두었으며 배포된 웹사이트는 아래 링크에서 확인할 수 있다. 이글에는 프로젝트를 하며 느낀 개발기를 적으려 한다.
내가 이 토이 프로젝트를 만들면서 점검하고 공부하고 싶은 5 가지 기술/라이브러리 스택을 중요도 순으로 나열해보았다.
- TypeScript 사용하여 런타임 에러 줄이기
- Recoil을 사용한 전역 상태 관리
- 쉬운 유지 보수를 위한 component 구성
- 일관성 있는 git commit log
- Firebase를 사용한 간단한 백엔드 구현
1-3 내용을 아래 3 장에서 자세히 다룰 예정이다. 4 번의 일관성 있는 커밋을 위해서는 우테코에서 사용하는 AngularJS Commit Message Conventions 을 사용하였으며, README.md 파일에 정리한 기능 목록 단위로 커밋하는 것은 부족하지만 일관성 있게 목적을 나누어 커밋 제목을 올리는 것에는 성공하였다. 5 번은 이전 프로젝트를 하면서도 겪었던 백엔드 구현의 어려움을 줄이기 위해 Firebase의 Cloud Firestore를 사용였고, 별도의 작업을 거의 하지 않고 사용자의 닉네임-점수가 저장되는 DB를 쉽게 구현할 수 있었다.
이 프로젝트에서 가장 중요하게 여긴 것은 "타입스크립트를 제대로 활용하기"이다. 지난 3 주 동안 타입스크립트를 공부한 이야기를 정리하면서 객체 지향적인 사고를 기를 수 있었고, 타입을 미리 선언하여 런타임 에러를 방지하는 연습도 할 수 있었다.
다음은 이모티콘을 클릭하면 style이 토글되는 애니메이션이다. Fontawsome에서 가져온 아이콘을 사용할 때 하나의 IconState를 정의하는 IconInterface
를 만들어 color와 inconName을 함께 관리하였다. 다음은 하나의 아이콘의 state를 변경하는 로직이다.
// Define type
interface IconInterface {
color: string;
iconName: IconDefinition;
}
function HomeIcons() {
// Icon state
const [firstIconState, setFirstIconState] = useState<boolean>(false);
// Create Icons
let firstIcon: IconInterface = {
color: "black",
iconName: BLANK_FACE_ICON,
};
// Toggle icons by clicking event
if (firstIconState) {
firstIcon.color = "#6BCB77";
firstIcon.iconName = SMILE_FACE_ICON;
}
const toggleIcon = (icon: "first" | "second") => {
if (icon === "first") {
setFirstIconState((prev) => !prev);
} else {
setSecondIconState((prev) => !prev); // 두 번째 아이콘에 대한 로직(이 글에서는 지움)
}
};
return (
<Icons>
<FontAwesomeIcon
icon={firstIcon.iconName}
onClick={toggleIcon.bind(null, "first")}
style={{ color: firstIcon.color, cursor: "pointer" }}
/>
</Icons>
);
}
export default HomeIcons;
리액트에서 TS를 활용하는 대표적인 예시 중 하나는 하위 컴포넌트 props의 타입 정의이다.
<DBResultComp users={users} />
가 다음과 같은 prop을 가질 때 이에 대한 generic type을 추가하는 작업이 필요하다. 이 때 Firebase의 DB에서 가져오는 데이터인 users
의 타입 정의는 firebase에서 제공하지 않으므로, 구글링을 통해 DocumentData
를 임포트하여 사용하였다.
DBResultComp.tsx
import { DocumentData } from "@google-cloud/firestore";
interface DBResultCompProps {
users: DocumentData[];
}
const DBResultComp: React.FC<DBResultCompProps> = ({ users }) => {
return (
<>
<ScrollWrapper>
{users.map((u, i) => {
return (
<UserWrapper key={i}>
<UserName>{u.user}</UserName>
<UserScore>{u.score} 점</UserScore>
</UserWrapper>
);
})}
</ScrollWrapper>
</>
);
};
export default DBResultComp;
이 프로젝트에서 두 번째로 중요하게 여긴 것은 명료한 전역 상태 관리 방법이며, local/global state의 분류 기준을 router가 매칭되는 메인 컴포넌트 3 개로 나누었다.
동일한 라우팅 컴포넌트 내 = local state, 다른 라우팅 컴포넌트와 상태를 공유 = global state
개인적으로 이 방법이 추후 리팩토링할 때도 더 편했다. 상태 관리 툴로 Recoil을 사용한 이유는 토이 프로젝트에서 간편하게 사용할 수 있는 리액트 상태관리 라이브러리이기 때문이다. Redux는 보일러 플레이트가 커서 조금 더 규모가 큰 프로젝트에 적합하다고 생각한다.
App.tsx
const App: React.FC = () => {
return (
<Wrapper>
<BrowserRouter basename={process.env.PUBLIC_URL}>
<Routes>
<Route path="/quiz/:id" element={<QuizScreen />} />
<Route path="/result" element={<ResultScreen />} />
<Route path="/" element={<Home />} />
</Routes>
</BrowserRouter>
</Wrapper>
);
};
3 개의 메인 컴포넌트(Home, QuizScreen, ResultScreen)에서 발생하는 상태 이동은 아래와 같다. Fig 1.1은 로컬 상태, Fig 1.2는 전역 상태의 이동을 도식하였다. 4 가지 전역 상태는 다음과 같으며 메인 컴포넌트 사이에서 상태 공유가 발생한다.
quizDataState
: DB에서 가져온 퀴즈 배열routesSelector
: DB에서 가져온 퀴즈 배열 기반으로 quiz route를 담은 array 생성currRouteState
: 사용자가 현재 몇 번째 퀴즈를 풀고 있는지 route 저장scoreState
: 사용자가 맞춘 퀴즈 점수 정보를 가진 배열Atom은 우리가 사용하는 state를 담는 저장소이다. Recoil을 만든 페이스북에서는 이를 비눗방울로 표현하는데, 우리가 전역으로 사용할 상태를 atom 비눗방울에 담아 어디든지 사용할 수 있다는 비유적 표현이다. 나는 본 프로젝트에서 quizDataState
, currRouteState
, scoreState
를 모두 atom에 저장하였으며 그 중quizDataState
예시 코드는 다음과 같다.
atom.ts // atom 만들기
interface QuizImg {
url: string;
answer: Answer;
}
export interface Quiz {
quizId: string;
quizName: string;
images: QuizImg[];
}
export const quizDataState = atom<Quiz[]>({
key: "quizDataState", //JSON data from fakeDB
default: [],
});
Home.tsx // post: quizDataState에 퀴즈 배열 저장하기
const setQuiz = useSetRecoilState<Quiz[]>(quizDataState);
useEffect(() => {
fetch(url)
.then((response) => response.json())
.then((json) => {
setQuiz(json);
})
.catch((error) => console.log(error));
}, []);
QuizScreen.tsx // get: quizDataState 접근하여 사용하기
const quiz = useRecoilValue<Quiz[]>(quizDataState);
4 개의 전역 상태 중 routesSelector
는 quizDataState
에 의존하는 특성을 가지는데, 이 때 Recoil의 selector를 활용할 수 있다. Selector에서는 원하는 atom의 값을 뽑아서 사용할 수 있으며, get
을 사용하여 atom의 형태를 변환한 값을 리턴할 수 있다.
routesSelector
: DB에서 가져온 퀴즈 배열 기반으로 quiz route를 담은 array 생성export const routesSelctor = selector<string[]>({
key: "quizRoutes", //Route array refer JSON data: depends on quizDataState
get: ({ get }) => {
const quiz = get(quizDataState); // atom의 quizData array를 사용하여
let quizRoutes: string[] = [];
quiz.forEach((quiz) => {
quizRoutes.push(`/quiz/${quiz.quizId}`);
});
quizRoutes.push("/result");
return quizRoutes; // Routes가 담긴 array를 반환한다.
},
});
마지막으로 다른 프로젝트들을 해보면서 가장 보완해보고 싶었던 유지 보수 관리가 쉬운 컴포넌트 구성이다. 유튜브에서 컴포넌트, 다시 생각하기 컨퍼런스를 듣고 Fig. 2와 같이 이 프로젝트의 컴포넌트 구성을 그리게 되었고, 여러 CSS 프레임워크를 거쳐 다시 CSS-in-JS로 돌아오게 되었다.
이 프로젝트에서 더 나은 리팩토링에 기여한 툴은 두 가지라고 생각한다.
1. CSS-in-JS: 컴포넌트를 이동할 때 관련 CSS가 에디터에 표시되는 것이 편했다. css 파일을 사용하는 경우 클래스 명을 일일히 확인해야 하는 번거로움이 있었다.
2. TypeScript: 컴포넌트간에 state가 이동할 때 타입이 명시되어 있어 헷갈리지 않고 따로 주석을 달지 않아도 되어 편했다.
이 프로젝트의 퀴즈 데이터의 관리를 Fig 3으로 나타내보았다. 먼저, JSON file의 퀴즈 배열을 quizDataState
로 fetching하여 전역 상태로 quiz set을 가져온다. QuizScreen 마다 퀴즈 배열 중 하나의 퀴즈를 가져와야 하고 정보는 currQuiz
에 담겨야 한다. 이 정보는 QuizScreen이 가징 고유한 id인 currRouteState
로 quizDataState
배열의 인덱스에 접근하여 가져온다. currRouteState
를 전역 변수로 설정한 이유는 QuizScreen이 다른 메인 컴포넌트보다 더 복잡하므로 prop chain이 길어질 것을 대비하는 목적으로 사용하였으며, 추후 3.1 에 나오는 버그 해결에도 활용되는 것을 확인할 수 있다.
Local quiz data = currQuiz : quizDataState 의 quiz array + 현재 위치의 고유한 값인 currRouteState 를 조합하
웹사이트를 배포하고 테스트하던 중 Fig 4.와 같이 <4 문제 중 5 문제를 맞추는>
버그가 발생하였다. 이 문제는 아래와 같은 상황에서 발생한다.
홈 > 퀴즈를 풀어서 1 문제를 맞춘다 > 뒤로가기 > 홈 > 퀴즈 재도전 > 다 맞춤 > 5 문제를 맞추었습니다!
이를 해결하기 위해 전역 상태인 점수scoreState
와 QuizScreen마다 가지는 고유한 id currRouteState
를 초기화 하는 위치를 변경하였다. 기존에는 ResultScreen > Home으로 이동 시 초기화했지만, 이 로직으로 홈 컴포넌트가 렌더링되면 실행하도록 바꾸어 아래와 같이 버그를 해결하였다.
<버그 해결 전>
<버그 해결 후>
Firebase DB를 관리하면서 닉네임이 없는 유저들을 간혹 발견하였다. 결과 제출하기
버튼을 두 번 눌렀을 때 발생하는 버그로 input이 빈 상태 submit event가 추가로 발생하는 것이다. 이를 해결하기 위해 required
속성을 추가하고, submit event가 일어난 후에는 form을 닫는 것으로 수정하였다.
새로운 개념들을 활용하면서 프로젝트를 만드니 정말 재미있었다. 하지만 짧은 기간에 만든 코드여서 개선할 부분이 정말 많고 앞으로 더 공부하면서 프로젝트를 디벨롭하려 한다. 현재 가장 개선하고 싶은 부분은 두 가지이다.
이 프로젝트에서 가장 복잡한 컴포넌트인 QuizScreend을 보면 정말 많은 로컬/전역 상태들이 얽혀있다. 이 로컬 상태들이 하위 컴포넌트로 가지 못하고 남아있는 이유는 localScore
, currRoute
에 의존성을 가진 상태로 초기화가 필요하기 때문이다. 이 부분은 상태 초기화나 useEffect
hook의 대안에 대해 더 공부를 해야 할 것 같다.
QuizScreen.tsx
const QuizScreen: React.FC = () => {
// routes info
const navigate = useNavigate();
const routes = useRecoilValue(routesSelctor);
const { pathname } = useLocation();
const quizId = pathname.split("/")[2];
const currRoute = useRecoilValue<number>(currRouteState);
// quiz info
const quiz = useRecoilValue<Quiz[]>(quizDataState);
const [currQiuz, setCurrQuiz] = useState<Quiz>(quiz[currRoute]);
// scores info
const [scores, setScores] = useRecoilState<Score[]>(scoreState);
const [correct, setCorrect] = useState<boolean | null>(null);
const [localScore, setLocalScore] = useState<Score>({});
// Handle Img Background: color and pointer-event
const [pointerEvent, setPointerEvent] = useState<PointerEvent>({});
// After Submit Btn Clicked: Notice answer and fetch scores
useEffect(() => {
if (Object.keys(localScore).length !== 0) {
setPointerEvent({ "pointer-events": "none" });
setCorrect(null);
setScores((prev) => [...prev, localScore]);
}
}, [localScore]);
// After Next Btn Clicked: Initialize for next quiz
useEffect(() => {
setCurrQuiz(quiz[currRoute]);
if (routes[currRoute] === "/result") {
navigate(`/result`, { state: { isFromHome: false } });
} else {
navigate(`${routes[currRoute]}`);
}
setLocalScore({});
setPointerEvent({});
}, [currRoute]);
return (
<Wrapper>
<TitleWrapper>
<Title>{currQiuz.quizName}</Title>
<span>답을 선택한 후 제출 버튼을 눌러주세요!</span>
</TitleWrapper>
<QuizComp
pointerEvent={pointerEvent}
currQiuz={currQiuz}
setCorrect={setCorrect}
/>
<QuizBtns
localScore={localScore}
correct={correct}
setLocalScore={setLocalScore}
/>
</Wrapper>
);
};
export default QuizScreen;
이미지 최적화없이 사진을 그대로 사용하다보니 현재는 사진이 로딩되는 모습이 눈에 보이게 느리다. 이미지 로딩이 본 프로젝트의 성능을 좌우하기 때문에 이미지 최적화, pre-loading 등을 활용하여 이를 개선할 예정이다.
그동안 프로젝트 만들기 위해서는 1 달 이상의 시간이 소요될 것이라 생각하며 미루고 있었는데, 이렇게 짧은 시간 안에 만족스러운 결과물이 나왔다는 것이 고무적이다. 앞으로 공부해보고 싶은 기술이 있으면 바로 작은 토이 프로젝트를 만드는 습관을 가져야겠다. 추가로 공휴일마다 지인들을 즐겁게 할 수 있는 수단이 생겨 매우 기쁘다!
FEConf 2021 A Track [A3] 컴포넌트, 다시 생각하기
Recoil 공식 문서
[Recoil] Recoil 200% 활용하기
Recoil, 리액트의 상태관리 라이브러리