최근 진행했던 팀 프로젝트에서 필수 기능 구현을 마치고, 백엔드의 도움 없이 프론트엔드에서 구현할 수 있는 요소가 뭐가 있을까 고민하다가 간단하 MBTI 테스트를 구현해보았다. Recoil을 사용하여 더 간단하게 구현할 수 있었는데, 그 과정에 대해 알아보도록 하자.
우선 프로젝트에서 리코일을 사용하기 위해서는 설치를 해주어야 한다. 프로젝트 파일 내에서 아래 코드로 설치를 진행하자.
npm i recoil
// or
yarn add recol
리코일을 사용하는 컴포넌트는 <RecoilRoot>
로 감싸주어야 한다. 이를 위해서 다음과 같이 코드를 작성했다.
우선 RecoilContext 라는 파일을 작성했다.
// RecoilContext.tsx
"use client"; // NextJS에서 RecoilRoot는 클라이언트 컴포넌트 내에서만 사용할 수 있기에 'use client'를 작성해주어야 한다.
import { ReactNode } from "react"; // ReactNode는 렌더링 될 수 있는 컴포넌트의 모든 유형을 의미
import { RecoilRoot } from "recoil"; // RecoilRoot import
type Props = {
children: ReactNode; // TS를 사용했기 때문에 children 요소의 타입을 ReactNode로 선언해주고
};
export default function Recoil({ children }: Props) {
return <RecoilRoot>{children}</RecoilRoot>; // RecoilRoot로 children 요소를 감싸주었다.
}
작성한 Context 파일을 layout.tsx 파일 내에 적용하였다.
// layout.tsx
import ToasterContext from "./context/ToasterContext";
import StyledComponentsRegistry from "./libs/registry";
import "./globals.css";
import Recoil from "./context/RecoilContext"; // RecoilContext 파일에서 import
// 이외 코드 생략 . . .
export const metadata = {
title: "맛이슈",
description: "자신만의 레시피를 올리고 공유하는 플랫폼 입니다.",
// 이외 코드 생략 . . .
},
};
export default async function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<head>
<meta
httpEquiv="Content-Security-Policy"
content="upgrade-insecure-requests"
/>
</head>
<StyledComponentsRegistry>
<body>
<ToasterContext />
<Recoil> {/* import한 Recoil로 children 요소를 감싸준다. */}
{children}
</Recoil>
</body>
</StyledComponentsRegistry>
</html>
);
}
위와 같이 RecoilContext 파일을 작성하고 해당 파일을 layout.tsx 파일에서 import 후 적용시켰다. 이렇게 파일을 나누어 작성한 것은 NextJS에서 <RecoilRoot>
는 "클라이언트 컴포넌트" 내에서만 작동하는데, 프로젝트의 layout.tsx는 SSR을 사용하기 위해 서버 컴포넌트로 사용되고 있어, 이를 분리하여 작성한 후 적용하는 방식을 채택하였다. 일반적인 React 프로젝트에서는 App.js와 같은 'root component' 에서 아래와 같이 작성하면 된다.
// App.js
import React from 'react';
import { RecoilRoot } from 'recoil';
function App() {
return (
<RecoilRoot>
{children}
</RecoilRoot>
);
}
리코일을 사용하기 위해서 필수적으로 거쳐야할 두 번째 관문은 바로 Atom 을 작성하는 것이다. 일반적으로 아톰은 다음과 같이 작성할 수 있다.
const textState = atom({
key : 'stateName', // 고유한 state 명
default : '' // 디폴트 값 설정
이 처럼 state를 선언하고 atom 내에 state의 이름과, 기본 값을 설정해줄 수 있다. 이를 나의 MBTI 테스트에서는 아래와 같이 작성했다.
// mbtiAtom.ts
import { atom } from "recoil";
export const EIState = atom<number>({
key: "EI",
default: 0,
});
export const SNState = atom<number>({
key: "SN",
default: 0,
});
export const TFState = atom<number>({
key: "TF",
default: 0,
});
export const JPState = atom<number>({
key: "JP",
default: 0,
});
export const datasState = atom<string>({
key: "datas",
default: "",
});
export const MBTIState = atom<string>({
key: "MBTI",
default: "",
});
MBTI의 각 4가지 성향을 state로 선언하고, 기본 값은 0으로 해주었다. 또, 최종 결과 값을 저장할 MBTIState와 데이터를 저장할 datasState도 선언해주었다.
아톰까지 작성하였으면, 리코일을 사용할 준비는 어느정도 끝났다. 리코일을 사용할 때 여러가지 hooks을 import 해사 사용할 수 있는데, 아래 대충 정리해보았다.
useRecoilState
우리가 흔히 사용하는 useState와 비슷한 역할을 한다. 아톰의 상태를 설정하거나, 변경할 수 있다.
useRecoilValue
아톰의 값을 조회할 때 사용한다.
useSetRecoilState
useState의 set함수 역할을 한다. 아톰의 상태를 변경시킬 수 있다.
useResetRecoilState
아톰의 값을 디폴트 값으로 초기화 한다.
정리해보면 RecoilValue는 읽기 전용, SetRecoilState는 쓰기 전용, RecoilState는 둘 다 가능하다는 것 정도로 요약할 수 있을 것 같다. 이제 이를 MBTI 테스트에 적용시켜보자.
// StartPage.tsx
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { useRecoilState, useSetRecoilState } from "recoil";
import { EIState, JPState, SNState, TFState } from "@/app/store/mbtiAtom";
const StartPage = () => {
const router = useRouter();
// MBTI 성향 상태
const setEI = useSetRecoilState(EIState);
const setSN = useSetRecoilState(SNState);
const setTF = useSetRecoilState(TFState);
const setJP = useSetRecoilState(JPState);
return (
<>
<StratPageLayout>
<Logo />
{/* 이외 코드 생략 . . . */}
<StartButtonWrapper isAnimateOut={isAnimateOut}>
<Button
onClick={() => {
setIsAnimateOut(true);
setTimeout(() => {
router.push("/mbti/test-page");
setEI(0);
setSN(0);
setTF(0);
setJP(0);
}, 0);
}}
>
테스트 시작하기
</Button>
</StartButtonWrapper>
</StratPageLayout>
</>
);
};
export default StartPage;
시작 페이지에서는 각 성향별 atom을 불러와, 시작 버튼을 클릭했을 때 모든 성향 점수를 0으로 초기화 하고 이를 TestPage로 넘겨주도록 하였다.
// TestPage.tsx
"use client";
import { useState, useEffect } from "react";
import { useRecoilState } from "recoil";
import {
EIState,
SNState,
TFState,
JPState,
MBTIState,
} from "@/app/store/mbtiAtom";
// 이외 코드 생략 . . .
const TestPageClient = () => {
const router = useRouter();
// MBTI 성향 상태
const [EI, setEI] = useRecoilState(EIState);
const [SN, setSN] = useRecoilState(SNState);
const [TF, setTF] = useRecoilState(TFState);
const [JP, setJP] = useRecoilState(JPState);
// MBTI 결과 set
let [MBTI, setMBTI] = useRecoilState(MBTIState);
// MBTI 계산 로직
const calculateMBTI = () => {
let result = "";
if (EI > 0) {
result += "E";
} else {
result += "I";
}
if (SN > 0) {
result += "S";
} else {
result += "N";
}
if (TF > 0) {
result += "T";
} else {
result += "F";
}
if (JP > 0) {
result += "J";
} else {
result += "P";
}
setMBTI(result);
};
// 이전 버튼
const goBack = () => {
if (count > 1) {
setAnswerButtonAnimation(true);
setTimeout(() => {
setCount((prevCount) => prevCount - 1);
setProgressStep((prevStep) => prevStep - 1);
// 이전 문제에서 클릭한 버튼 번호를 null로 초기화
setLastButtonNumbers((lastButtonNumbers) => {
const updatedLastButtonNumbers = lastButtonNumbers.map((num, index) =>
index === count - 2 ? null : num
);
const lastButtonNumber = updatedLastButtonNumbers[count - 2];
if (lastButtonNumber !== null) {
// lastButtonNumber에 따라 MBTI 성향을 업데이트
if (lastButtonNumber === 1) {
if (count <= 3) {
setEI((EI) => EI - 1);
} else if (count >= 4 && count <= 6) {
setSN((SN) => SN - 1);
} else if (count >= 7 && count <= 9) {
setTF((TF) => TF - 1);
} else if (count >= 10 && count <= 12) {
setJP((JP) => JP - 1);
}
} else {
if (count <= 3) {
setEI((EI) => EI + 1);
} else if (count >= 4 && count <= 6) {
setSN((SN) => SN + 1);
} else if (count >= 7 && count <= 9) {
setTF((TF) => TF + 1);
} else if (count >= 10 && count <= 12) {
setJP((JP) => JP + 1);
}
}
}
return updatedLastButtonNumbers;
});
setAnswerButtonAnimation(false);
}, 300);
}
};
// 정답 버튼
const goNext = (buttonNumber: number) => {
setAnswerButtonAnimation(true);
// 클릭한 버튼 번호를 lastButtonNumbers에 업데이트
setLastButtonNumbers((lastButtonNumbers) =>
lastButtonNumbers.map((num, index) =>
index === count - 1 ? buttonNumber : num
)
);
// buttonNumber에 따라 MBTI 성향을 업데이트
if (buttonNumber === 1) {
// 버튼 1
if (count <= 3) {
setEI((EI) => EI + 1);
} else if (count >= 4 && count <= 6) {
setSN((SN) => SN + 1);
} else if (count >= 7 && count <= 9) {
setTF((TF) => TF + 1);
} else if (count >= 10 && count <= 12) {
setJP((JP) => JP + 1);
}
// 버튼 2
} else {
if (count <= 3) {
setEI((EI) => EI - 1);
} else if (count >= 4 && count <= 6) {
setSN((SN) => SN - 1);
} else if (count >= 7 && count <= 9) {
setTF((TF) => TF - 1);
} else if (count >= 10 && count <= 12) {
setJP((JP) => JP - 1);
}
}
setTimeout(() => {
if (count === 12) {
setProgressStep((prevStep) => prevStep + 1);
setTimeout(() => {
calculateMBTI();
router.push("/mbti/result-page");
}, 300);
} else {
setCount((prevCount) => prevCount + 1);
setProgressStep((prevStep) => prevStep + 1);
setAnswerButtonAnimation(false);
}
}, 300);
};
// 이외 코드 생략 . . .
return (
<>
<TestPageLayout className={animation}>
<Logo />
{/* 이외 코드 생략 . . . */}
</TestPageLayout>
</>
);
};
export default TestPageClient;
TestPage에서는 StartPage에서 받아온 성향 값을 사용자가 선택한 버튼에 따라 점수 상태를 업데이트 하고, 마지막 문제에 도달했을 때 최종 결과를 MBTIState에 저장, 이를 ResultPage로 넘겨주었다.
// ResultPage.tsx
"use client";
import { MBTIState } from "@/app/store/mbtiAtom";
import { useRecoilState } from "recoil";
// 이외 코드 생략 . . .
const ResultPageClient = ({ recipes }: { recipes: Recipe[] }) => {
// TestPage에서 저장된 MBTI 결과 상태 받아옴
const [MBTI, setMBTI] = useRecoilState(MBTIState);
// 현재 페이지 url 주소 받아옴
let currentPageUrl;
if (typeof window !== "undefined") {
currentPageUrl = window.location.href;
}
// MBTI 결과가 변경될 때마다 urlParmas에 저장
useEffect(() => {
if (MBTI && typeof window !== "undefined") {
const urlParams = new URLSearchParams(window.location.search);
urlParams.set("MBTI", MBTI);
window.history.replaceState({}, "", `?${urlParams.toString()}`);
}
}, [MBTI]);
// urlParams에서 MBTI 가져와 MBTI state에 저장 (새로고침 해도 결과 데이터 유지됨)
useEffect(() => {
if (typeof window !== "undefined") {
const urlParams = new URLSearchParams(window.location.search);
const savedMBTI = urlParams.get("MBTI");
if (savedMBTI) {
setMBTI((prevMBTI) => (prevMBTI = savedMBTI));
}
}
}, [setMBTI]);
// 이외 코드 생략 . . .
return (
<>
<ResultPageLayout className={animation} isDarkMode={isDarkMode}>
<Logo />
{/* 이외 코드 생략 . . . */}
</ResultPageLayout>
</>
)
};
마지막 ResultPage에서는 TestPage에서 받아온 MBTIState에 따른 결과 값을 출력하도록 하였다. 또한, 이를 urlParams에 저장하여 사용자가 새로고침을 했을 때 결과값 손실을 방지했으며 url 주소로 공유하기 기능을 추가하여, 해당 url을 통해 접속한 사용자가 바로 결과를 확인할 수 있도록 하였다.
이번 프로젝트에서 Recoil을 이용하여 간편하게 전역 상태를 관리하는 방법에 대해 배웠다. 일반적인 useState나 Redux 보다 훨씬 간편하고 좋은 것 같아 애용하게 될 것 같다!