리액트 쿼리를 사용하면 비동기 처리를 쉽게 할 수 있다.
따로 상태관리를 하지 않고 서버에서 데이터를 가져와서 모든 컴포넌트들이 사용 가능하도록 캐싱하거나, 주기적으로 데이터 패칭을 한다.
아직은 요구사항에 없지만, 이후 폴더를 수정하고 삭제한다든지 클라이언트 컴포넌트에서 데이터를 수정할 수 있으므로 미리 도입했다.
++
데이터를 요청할 때 모든 요청을 서버 컴포넌트에서 하기 어렵다. 서버 컴포넌트에서 요청해서 prop으로 넘겨주는 건 비효율적이라고 생각했다. 그래서 어쩔 수 없이 클라이언트에서 요청을 하게 될 경우가 있으므로 이 경우를 대비해 클라이언트에서 요청해도 ssr로 할 수 있게 하고 싶었다.
데이터를 가져올 때는 useQuery를 사용하고,
데이터를 수정할 때는 onMutaion을 사용한다.
리액트 쿼리로 ssr 구현 방식에는 2가지가 있다.
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의 상태를 관리하면 훨씬 편리하게 상태를 관리할 수 있고, 클라이언트 컴포넌트를 너무 불필요하게 쓰지 않을 수 있을 거라는 생각이 들었다.

프록시 대신 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",
참고
db 도입까지는 요구사항을 너무 벗어나는 것 같아서 하드 코딩으로만 next-auth를 적용했다. 아래와 같이 [...nextauth]를 작성한다.
JWT(JSON Web Token) : 인증에 필요한 정보들을 암호화한 토큰.
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");
}
참고
module.css로 사용했을 때, 불편하다고 느낀 건 공통 스타일 추출이었다. 단순히 전역으로 관리하기엔 부담스럽고 그렇다고 공통으로 분리를 안 하기에는 중복 코드가 많아서 scss를 사용했다.

styles 폴더를 만들고, 내부적으로 mixin을 모아두거나 변수들을 모아두는 파일들, reset 관련 css 코드들을 파일로 분리하고 전역 css 파일을 생성했다.
그리고 각 컴포넌트에서는 module.scss를 사용했다.

지난 주 요구사항에서 모달이 6개나 있었고, 각 모달마다 공통 레이아웃 부분들이 존재했는데 시간이 없어서 제대로 분리하지 못했다.
ModalLayout 컴포넌트를 만들고 이 안에서 Portal이나 스크롤 방지 훅들을 전부 삽입하고 children으로 내부 콘텐츠 영역만 별도로 작성하도록 구조를 작성했다.

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


그리고 컴포넌트명이나 변수명이 직관적이지 않다는 생각을 많이 했다.
그래서 Material UI와 같은 곳에서 컴포넌트 이름을 많이 참고해 수정했다.
LinkCard 컴포넌트에서 ref를 사용해 클릭 이벤트를 관리하고 있었지만, ref를 prop으로 넘기면 UI 로직을 더 분명하게 분리할 수 있을 것 같았다.
이때 컴포넌트로 묶을만한 컨테이너에 ref를 묶을 때 보다 명시적으로 ref를 쓰고 있다는 걸 알려주고 싶어서 리액트 컴포넌트에 ref를 전달하고 싶었다.
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가지 정의와 각각의 적절한 사용법