[코드잇] 위클리 미션 수행기 (12주차)

woolee의 기록보관소·2023년 6월 10일

코드잇부트캠프0기

목록 보기
17/24

next js에 react query 적용하기

리액트 쿼리를 사용하면 비동기 처리를 쉽게 할 수 있다.
따로 상태관리를 하지 않고 서버에서 데이터를 가져와서 모든 컴포넌트들이 사용 가능하도록 캐싱하거나, 주기적으로 데이터 패칭을 한다.

아직은 요구사항에 없지만, 이후 폴더를 수정하고 삭제한다든지 클라이언트 컴포넌트에서 데이터를 수정할 수 있으므로 미리 도입했다.

++
데이터를 요청할 때 모든 요청을 서버 컴포넌트에서 하기 어렵다. 서버 컴포넌트에서 요청해서 prop으로 넘겨주는 건 비효율적이라고 생각했다. 그래서 어쩔 수 없이 클라이언트에서 요청을 하게 될 경우가 있으므로 이 경우를 대비해 클라이언트에서 요청해도 ssr로 할 수 있게 하고 싶었다.

데이터를 가져올 때는 useQuery를 사용하고,
데이터를 수정할 때는 onMutaion을 사용한다.

리액트 쿼리로 ssr 구현 방식에는 2가지가 있다.

  • initialData
    서버 쪽에서 데이터를 받아서 prop drilling 방식으로 pre-fetch할 수 있다.
  • Hydration
    hydrate 방식으로 pre-fetch를 할 수 있다.

QueryClient 의 request-scoped 싱글톤 인스턴스를 생성해서,
다른 사용자와 요청 간 데이터 공유하지 않고 요청당 한번만 QueryClient를 생성한다.

// lib/tanstack/getClient.ts 

import { cache } from "react";

import { queryClientOptions } from "@/utils/constants";
import { QueryClient } from "@tanstack/react-query";

const getQueryClient = cache(() => new QueryClient(queryClientOptions));

export default getQueryClient;

axios를 만들고 요청 함수를 별도로 분리하고

// lib/tanstack/queryFns/folderQueryFns.ts

import { getRequest } from "@/utils/api/common";

export const getUserQueryFn = async () => {
  const response = await getRequest(`/users/1`);
  return response.data[0];
};

리액트 쿼리를 전역으로 사용하기 위해 Provider 컴포넌트를 생성한다.
클라이언트 컴포넌트더라도 children을 반환하는 구조로 만들면 상관없다.

// components/Providers/Providers.tsx

"use client";

import { useState } from "react";

import { queryClientOptions } from "@/utils/constants";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

interface Props {
  children: React.ReactNode;
}

const Providers = ({ children }: Props) => {
  const [queryClient] = useState(() => new QueryClient(queryClientOptions));
  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
};

export default Providers;

서버에서 데이터를 pre-fetching한 걸 클라이언트 컴포넌트에서 쓸 수 있게 hydrate을 만들어준다. (react-query 의 Hydrate 요소를 클라이언트 컴포넌트로 래핑해서 클라이언트 컴포넌트에서 사용할 수 있도록 만들어 준다.)

// components/QueryHydrate/QueryHydrate.tsx

"user client";

import { HydrateProps, Hydrate as RQHydrate } from "@tanstack/react-query";

const QueryHydrate = (props: HydrateProps) => {
  return <RQHydrate {...props} />;
};

export default QueryHydrate;

서버에서 prefetchQuery로 데이터를 받아와주고 dehydrate으로 감싼 dehydratedState를 state로 넘겨준다.

// app/shared/page.tsx 

...

export const revalidate = 3600;

export default async function SharedLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const queryClient = getQueryClient();
  await queryClient.prefetchQuery(["user"], getUserQueryFn);
  const dehydratedState = dehydrate(queryClient);

  return (
    <>
      <QueryHydrate state={dehydratedState}>
        <Gnb />
        {children}
        <Footer />
      </QueryHydrate>
    </>
  );
}

위와 같이 데이터를 받아오면, 클라이언트 컴포넌트에서 아래와 같이 데이터를 받아올 수 있다. 서버 컴포넌트에서 작성한 그대로 useQuery의 인자로 넘겨주면 서버에서 데이터를 불러오므로 클라이언트에서는 그대로 받아서 사용할 수 있다.


const Gnb = () => {
  const { data: user } = useQuery({
    queryKey: ["user"],
    queryFn: getUserQueryFn,
  });
  
  return ... 

next js에서 react-query가 필요할까를 많이 생각해봤다.

next에서 제공해준느 fetch 함수라든지, 이런 걸로 충분히 서버 컴포넌트에서 데이터를 처리할 수 있다고 생각했고 이점도 분명 있었다.

하지만 어쨌든 클라이언트에서 서버로 데이터를 수정한다든지 할 때는 fetch의 이점이 없다고 생각했다. 그래서 지금 당장은 서버 쪽에서 확실히 데이터를 받아서 뿌려줄 수 있는 부분은 next의 fetch로 데이터를 받아오고, 불가피하게 클라이언트를 거쳐야 하는 경우 react-query를 사용하기로 결정했다.

// app/shared/layout.tsx

export default async function SharedLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const session = await getServerSession(authOptions);

  if (!session) {
    redirect("/api/auth/signin");
  }

  const userId = session.user.id;

  const queryClient = getQueryClient();
  await queryClient.prefetchQuery(["user"], () =>
    getUserQueryFn(userId as number)
  );
  const dehydratedState = dehydrate(queryClient);

  return (
    <>
      <QueryHydrate state={dehydratedState}>
        <Gnb userId={userId as number} />
        {children}
        <Footer />
      </QueryHydrate>
    </>
  );
}

참고

NextJs 13.4 With React Query And App Router
React Query - Authentication Flow
💻 TanStack Query(React Query v4)
[React Query] 리액트 쿼리 useMutation 기본 편
React Query - Hydration(SSR)
SSR 환경에서의 React Query
Next.js 13 버전에서 ReactQuery 사용시 서버 컴포넌트에서 클라이언트 컴포넌트로 pre-fetch 데이터 전달하는 방법

전역 상태 관리의 필요?

react-query를 사용했지만, 리액트 쿼리를 서버와 통신할 때의 데이터를 전역으로 관리할 때 장점이 있는 것 같다. 반면 서버가 아니라 컴포넌트 간 상태를 관리할 때는 별도로 전역 상태 도구가 필요하다고 생각했다.

예를 들어 아래 코드를 보면, 스크롤 이벤트에 따라 위치가 변경되는 AddLinkField가 있다. 이 컴포넌트는 inView라는 상태에 따라 변경되는데 이 친구 하나 때문에 FolderContents 컴포넌트가 훅을 사용해야 하기에 클라이언트 컴포넌트가 되고 있다.

이걸 분리하려면 inView라는 상태를 prop으로 넘겨줘야 하는데, 이 경우 전역으로 inView의 상태를 관리하면 훨씬 편리하게 상태를 관리할 수 있고, 클라이언트 컴포넌트를 너무 불필요하게 쓰지 않을 수 있을 거라는 생각이 들었다.

개발모드에서 next js의 rewrite로 cors 에러 임시 해결

프록시 대신 rewrite를 사용할 수 있다.

next.config.js에 아래와 같이 추가하고,
단순히 /users/:path*로 작성하면 문제가 될 수 있다.
next js에서 제공하는 기본적인 라우팅들과 충돌이 발생한다.

rewrites 안에서도 시점을 설정할 수 있는데, fallback 같은 걸 사용하면 next의 dynamic routes 이후에 rewrites를 한다든지 디테일하게 세부 설정을 할 수 있다. 그리고 그렇게 해야 할 것 같다.

// next.config.js 

async rewrites() {
    return [
      {
        source: "/users/:path*",
        destination: "https://bootcamp-api.codeit.kr/api/users/:path*",
      },
    ];
  },

axios baseUrl을 개발모드에서는 localhost로 임시 변경했다.

// utils/api/instance.ts 

export const instance = axios.create({
  // baseURL: "~~~",
  baseURL:
    process.env.NODE_ENV === "development"
      ? "http://localhost:3000"
      : "https://bootcamp-api.codeit.kr/api",

참고

[Next.js] Rewrites
rewrites

next-auth 적용하기

db 도입까지는 요구사항을 너무 벗어나는 것 같아서 하드 코딩으로만 next-auth를 적용했다. 아래와 같이 [...nextauth]를 작성한다.

JWT(JSON Web Token) : 인증에 필요한 정보들을 암호화한 토큰.

  • jwt 기반 인증은 쿠키/세션 방식과 유사하게 jwt 토큰(access token)을 http 헤더에 실어 서버가 클라이언트를 식별하도록 한다.
    • 클라이언트 로그인 요청이 들어오면, 서버는 검증 후 클라이언트 고유 ID 등의 정보를 Payload에 담는다.
    • 암호화할 비밀키를 사용해 Access Token(JWT)을 발급한다.
    • 클라이언트는 전달받은 토큰을 저장해두고, 서버에 요청할 때 마다 토큰을 요청 헤더 Authorization에 포함시켜 함께 전달한다.
    • 서버는 토큰의 Signature를 비밀키로 복호화한 다음, 위변조 여부 및 유효 기간 등을 확인한다.
    • 유효한 토큰이라면 요청에 응답한다.

authorize에서 반환한 유저 정보를 토큰에 추가하고, 토큰에 추가한 유저 정보를 session.user에도 추가했다. 그럼 next에서 제공하는 session 훅을 사용해 어디서든 유저 정보를 가져올 수 있다.

이 과정에서 authorize에 타입을 지정하기가 어려웠는데, 일단 jwt, session으로 넘기는 과정에서 타입이 검증되므로 authorize 자체는 any로 처리했다.
관련 이슈

// app/api/auth/[...nextauth]/route.ts

import { NextAuthOptions, Session } from "next-auth";
import { JWT } from "next-auth/jwt";
import NextAuth from "next-auth/next";
import Credentials from "next-auth/providers/credentials";

export const authOptions: NextAuthOptions = {
  secret: "secret",
  providers: [
    Credentials({
      name: "Credentials",
      credentials: {
        email: { label: "Email" },
        password: { label: "Password" },
      },
      async authorize(credentials: Record<string, string> | undefined) {
        // Perform database operations

        try {
          const { email, password } = credentials as {
            email: string;
            password: string;
          };

          // TODO: db 내 유무 확인/비밀번호 검증 코드 작성

          /**
           * @description 임시로 설정한 유저 정보입니다.
           */
          const user = ... 

          if (email === user.email && user.password.includes(password)) {
            return Promise.resolve({
              id: user.id,
              name: user.name,
              image_source: user.image_source,
              email: user.email,
            }) as any;
          }
          return Promise.reject(null);
        } catch (e) {
          console.log(e);
          return Promise.reject(null);
        }
      },
    }),
  ],
  session: {
    strategy: "jwt",
    maxAge: 30 * 24 * 60 * 60, // 30 days
    updateAge: 24 * 60 * 60, // 24 hours
  },
  callbacks: {
    async jwt({ token, user }) {
      /**
       * "user" parameter is the object received from "authorize"
       * "token" is being send below to "session" callback...
       * authorize에 리턴했던 값이 user 정보에 있면 token에 추가
       */
      user && (token.user = user);
      return token;
    },
    async session({ session, token }: { session: Session; token: JWT }) {
      /**
       * "session" is current session object
       * below we set "user" param of "session" to value received from "jwt" callback
       * token에 포함된 user 정보를 session.user에도 추가
       * 이후 client side의 session.user에서 token.user 정보 확인 가능
       */
      session.user = token.user;
      if (session.user != null && token.hasAcceptedTerms != null) {
        session.user.hasAcceptedTerms = token?.hasAcceptedTerms;
      }
      return Promise.resolve(session);
    },
  },
};

const handler = NextAuth(authOptions);

export { handler as GET, handler as POST };

각 페이지에서 session이 없으면 api/auth/signin 페이지로 이동하도록 해두었다. api/auth/signin은 next에서 기본으로 제공해주는 로그인 페이지인데, 이후 요구사항이 제대로 나오면 기존에 만들어뒀던 로그인 페이지를 가져올 생각이다.

// app/page.tsx 

export default async function Home() {
  const session = await getServerSession(authOptions);

  if (!session) {
    redirect("/api/auth/signin");
  }

참고

Route Handlers (app/)
Callbacks

module.css => module.scss로 변경하기

module.css로 사용했을 때, 불편하다고 느낀 건 공통 스타일 추출이었다. 단순히 전역으로 관리하기엔 부담스럽고 그렇다고 공통으로 분리를 안 하기에는 중복 코드가 많아서 scss를 사용했다.

styles 폴더를 만들고, 내부적으로 mixin을 모아두거나 변수들을 모아두는 파일들, reset 관련 css 코드들을 파일로 분리하고 전역 css 파일을 생성했다.

그리고 각 컴포넌트에서는 module.scss를 사용했다.

지난 주 코드리뷰를 기반으로 리팩토링 - UI 로직 분리 및 컴포넌트명을 분명하게

모달 공통레아웃 제작

지난 주 요구사항에서 모달이 6개나 있었고, 각 모달마다 공통 레이아웃 부분들이 존재했는데 시간이 없어서 제대로 분리하지 못했다.

ModalLayout 컴포넌트를 만들고 이 안에서 Portal이나 스크롤 방지 훅들을 전부 삽입하고 children으로 내부 콘텐츠 영역만 별도로 작성하도록 구조를 작성했다.

UI 로직 분리

그리고 기존에 작성했던 코드들은 UI 로직 위주였지만, 이후 api 관련 로직들이 추가될 예정이다. 이때 UI 로직들이 같이 있으면 코드 가독성이 떨어질 거라고 생각해서 UI 로직들은 최대한 컴포넌트로 별도 분리했다.

그리고 컴포넌트명이나 변수명이 직관적이지 않다는 생각을 많이 했다.

그래서 Material UI와 같은 곳에서 컴포넌트 이름을 많이 참고해 수정했다.

forwardRef을 사용한 UI 로직 분리

LinkCard 컴포넌트에서 ref를 사용해 클릭 이벤트를 관리하고 있었지만, ref를 prop으로 넘기면 UI 로직을 더 분명하게 분리할 수 있을 것 같았다.

이때 컴포넌트로 묶을만한 컨테이너에 ref를 묶을 때 보다 명시적으로 ref를 쓰고 있다는 걸 알려주고 싶어서 리액트 컴포넌트에 ref를 전달하고 싶었다.

  • HTML 엘리먼트가 아닌 React 컴포넌트에서 ref prop을 사용하려면 React에서 제공하는 forwardRef()라는 함수를 사용해야 했다.

forwardRef를 사용하면 디버깅이 어렵다 하여 기명함수로 한번더 컴포넌트 이름을 붙여줬다.

ref의 타입은 ForwardedRef<HTMLDivElement>로 붙여줬다.

덕분에 ref 타입이 3가지로 분류된다는 것도 배웠다.

// components/LinkCard/LinkCard.tsx 

... 
return (
  ... 
<Kebab
  ref={(el: HTMLDivElement) => (notTargetRefs.current[1] = el)}
  linkUrl={link.url}
  isClickedKebab={isClickedKebab}
  handleClickOpenKebab={handleClickOpenKebab}
  handleClickCloseKebab={handleClickCloseKebab}
  />
// components/LinkCard/Kebab.tsx 

"use client";

import { ForwardedRef, forwardRef, useRef, useState } from "react";

import useOutsideClick from "@/hooks/useOutsideClick";

import AddLinkModal from "../Modals/AddLinkModal/AddLinkModal";
import DeleteLinkModal from "../Modals/DeleteLinkModal/DeleteLinkModal";
import styles from "./LinkCard.module.scss";

interface IKebab {
  linkUrl: string;
  isClickedKebab: boolean;
  handleClickOpenKebab: () => void;
  handleClickCloseKebab: () => void;
}

const Kebab = forwardRef(function Kebab(
  {
    linkUrl,
    isClickedKebab,
    handleClickOpenKebab,
    handleClickCloseKebab,
  }: IKebab,
  ref: ForwardedRef<HTMLDivElement>
) {
  const kebabRef = useRef<HTMLDivElement | null>(null);

  const [openDeleteLinkModal, setOpenDeleteLinkModal] =
    useState<boolean>(false);
  const [openAddLinkModal, setOpenAddLinkModal] = useState<boolean>(false);

  useOutsideClick(kebabRef, handleClickCloseKebab);
  return (
    <div ref={ref} className={styles.kebabMenu} onClick={handleClickOpenKebab}>
      <span className={styles.kebabDot}></span>
      <span className={styles.kebabDot}></span>
      <span className={styles.kebabDot}></span>
      {isClickedKebab && (
        <div className={styles.popOverWrapper} ref={kebabRef}>
          <div
            className={styles.deleteButton}
            onClick={() => setOpenDeleteLinkModal(true)}
          >
            삭제하기
          </div>
          <div
            className={styles.addFolderButton}
            onClick={() => setOpenAddLinkModal(true)}
          >
            폴더에 추가
          </div>
        </div>
      )}

      {openAddLinkModal && (
        <AddLinkModal
          setOpenAddLinkModal={setOpenAddLinkModal}
          selectedLinkValue={linkUrl}
        />
      )}
      {openDeleteLinkModal && (
        <DeleteLinkModal
          setOpenDeleteLinkModal={setOpenDeleteLinkModal}
          selectedLinkValue={linkUrl}
        />
      )}
    </div>
  );
});

export default Kebab;

참고

[React] forwardRef 사용법
TypeScript React에서 useRef의 3가지 정의와 각각의 적절한 사용법

profile
https://medium.com/@wooleejaan

0개의 댓글