NextJS (Recoil을 활용한 MBTI 테스트 구현하기)

Jeonghun·2023년 7월 11일
2

NextJS

목록 보기
2/6


NextJS 13에서 Recoil 이용해보기

최근 진행했던 팀 프로젝트에서 필수 기능 구현을 마치고, 백엔드의 도움 없이 프론트엔드에서 구현할 수 있는 요소가 뭐가 있을까 고민하다가 간단하 MBTI 테스트를 구현해보았다. Recoil을 사용하여 더 간단하게 구현할 수 있었는데, 그 과정에 대해 알아보도록 하자.

- Recoil install

우선 프로젝트에서 리코일을 사용하기 위해서는 설치를 해주어야 한다. 프로젝트 파일 내에서 아래 코드로 설치를 진행하자.

npm i recoil
// or
yarn add recol

- RecoilRoot로 컴포넌트 감싸기

리코일을 사용하는 컴포넌트는 <RecoilRoot> 로 감싸주어야 한다. 이를 위해서 다음과 같이 코드를 작성했다.

📌 RecoilContext.tsx

우선 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 요소를 감싸주었다.
}

📌 layout.tsx

작성한 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 파일 작성하기

리코일을 사용하기 위해서 필수적으로 거쳐야할 두 번째 관문은 바로 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도 선언해주었다.

- RecoilState 사용하기

아톰까지 작성하였으면, 리코일을 사용할 준비는 어느정도 끝났다. 리코일을 사용할 때 여러가지 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 보다 훨씬 간편하고 좋은 것 같아 애용하게 될 것 같다!

profile
안녕하세요, 프론트엔드 개발자 임정훈입니다.

0개의 댓글