
저번주에 next.js로 마이그레이션이 완료되었다. 이번주는 프로젝트에 Typescript를 적용함과 동시에 folder 페이지를 user에 맞게 동적으로 보여주는 기능이 핵심이다. Next.js 13버전의 app routing을 사용하며 수많은 삽질과 시행착오를 거쳐서 조금 늦게 제출하였다.
app routing은 next.js 13버전부터 새롭게 지원하기 시작한 라우팅 시스템이다. 기존의 pages 라우팅과 비슷하게 파일 시스템 라우팅 기반이지만, 예약 파일들이 존재하며 app router 밑의 페이지들은 디폴트로 서버 컴포넌트이다. 서버 컴포넌트의 장점중 하나가 data fetching을 서버에서 할 수 있다는 점인데, next.js에서 신경을 많이 써서 app routing을 사용하면 서버 컴포넌트의 장점을 쉽게 활용할 수 있게 되었다.
저번주 위클리 미션에서 pages 라우팅으로 시작했다가 빠르게 app routing으로 전환하였다. 점진적으로 도입할 수 있게 해놓아서 app routing으로 전환하는게 어렵지 않았다.
둘의 차이는 SSR, CSR과는 사뭇 다르다. 서버 컴포넌트는 완전히 서버에서만 렌더링된다. 완전히 정확한 표현인지는 모르겠으나 쉽게 와닿게 설명하자면, 하이드레이션이 필요없는(이벤트 핸들러, 리액트 훅 등을 사용하지 않는)컴포넌트라면 서버 컴포넌트로 만들어 서버에서 완전히 렌더링한 후 브라우저로 보낼 수 있어서 브라우저가 자바스크립트 번들을 추가적으로 load할 필요가 없다.
클라이언트 컴포넌트는 기존 SSR방식처럼 서버에서 html만 만들어 브라우저로 보내고 동적인 요소에 필요한 js번들을 보내면 브라우저에서 번들을 파싱하고 리액트를 실행하여 하이드레이션을 하는 대상이 되는 컴포넌트이다. 따라서 클라이언트 컴포넌트라고 전통적인 CSR방식으로만 렌더링 되는 것이 아니다.
Next.js 공식문서에도 잘 나와있는데, app routing을 사용한다면 그 아래에 있는 컴포넌트는 페이지를 포함하여 기본적으로 모두 서버 컴포넌트이기 때문에, 클라이언트 컴포넌트와 서버 컴포넌트를 페이지에 적절히 배치시키는 것이 관건이다.
디폴트로 서버 컴포넌트로 해놓아서 그런지 app routing을 사용하면 클라이언트, 서버 컴포넌트를 구분하고 배치시키는 것이 약간 반 강제적이게 되어 있다. 흔히 사용하는 훅이나 이벤트 핸들러, 브라우저에서 사용하는 web api등을 컴포넌트들에서 생각없이 사용하려 하다보면 다음과 같은 에러를 마주하게 된다.
서버 컴포넌트와 그 장점을 이해한다면, get 요청은 최대한 서버 컴포넌트에서 하는것이 맞다. 그러기 위해서는 기본적으로 page가 서버 컴포넌트가 되어야 한다. 적어도 가장 상위 페이지는 get 요청을 먼저 하고 가져온 데이터를 기반으로 자식 컴포넌트들에게 넘겨주는 방식이 이상적인 것 같다. (자식이 클라이언트 컴포넌트든, 아니든..)
fine tuning이라는 용어는 순전히 내가 붙였다. 머신 러닝에서 쓰는 용어인데, 내가 활용할 수 있게 적절히 가공한다 라는 의미적으로 잘 어울리는 말인것 같아서 이렇게 적어봤다.
컴포넌트를 서버, 클라이언트로 잘 나누어야 한다. 그리고 특히 클라이언트 컴포넌트는 꼭 필요한 부분만 따로 빼서 작게 만드는 것이 좋은것 같다. 나머지는 서버 컴포넌트로 유지시키면서 partial hydration이 되도록 해야 app routing을 제대로 사용하는 것이라고 할 수 있다.
공식 문서에도 나오는데, 클라이언트 컴포넌트를 최대한 트리의 leaf로 가져가라고 한다. 이 말은 공식 문서에 나와있는 다음 사진처럼 클라이언트 컴포넌트를 최대한 말단쪽에 배치하라는 얘기다.

서버 컴포넌트 안에는 클라이언트 컴포넌트가 위치할 수 있다. 서버에서 그 부분만 js번들을 만들고 나머지는 전부 렌더링 하여 브라우저에 보내기 때문에 partial hydration이 가능해지는 것이다.
여기서 클라이언트 컴포넌트가 상단에 있다면?? 클라이언트 컴포넌트도 서버 컴포넌트를 자식으로 가질순 있다. 바로 children prop을 통해서이다. 하지만 이 방법은 부모 컴포넌트에서 children과 데이터를 주고받지 않아도 될 때만 가능한 방법이다. children은 그냥 그 위치에 구멍만 뚫어놓는 개념이다. 부모 입장에서 어떤 자식 컴포넌트가 올 지 모른다는 설정인 것이다. import문을 사용해서 서버 컴포넌트를 불러오는 것인 에러가 난다. 그래서 클라이언트 컴포넌트다 너무 상단에 있거나, 최악의 경우 페이지 전체가 클라이언트 컴포넌트라면, 수많은 자식 컴포넌트들 중 동적인 요소가 전혀 없는 서버컴포넌트로 있어야 좋은 컴포넌트들이 클라이언트에서 렌더링되게 된다. 그래서 클라이언트 컴포넌트들은 최대한 말단으로 빼라는 얘기이다.
필자가 앞서 붙여준 이름의 컴포넌트 fine tuning이라는 요령(?)을 잘 써야 컴포넌트들을 효율적으로 배치할 수 있다. 기존 리액트로 개발할 때는 크게 필요성을 느끼지 못했던 컴포넌트 잘게 쪼개기가 app routing을 사용하다 보면 절실해진다. 대신 pathname이나 api호출을 통해 받아온 데이터들은 페이지에서 prop으로 받거나 SSR방식으로 받을 수 있어서 관리하기가 굉장히 편하다.
서버 컴포넌트의 최대 장점중 하나가 데이터 요청을 서버에서 하기 때문에 속도가 빠르고, next.js의 fetch api를 사용하면 데이터 캐싱도 된다는 점이다. 때문에 데이터 요청은 되도록이면 서버 컴포넌트에서 해야 좋다. 동적인 요소가 필요한 클라이언트 컴포넌트에서 서버에서 가져온 데이터가 필요하다면 그 컴포넌트를 데이터를 가져오는 서버 컴포넌트와 동적인 요소를 사용하는 클라이언트 컴포넌트로 쪼개는 것이 현명한 접근인것 같다.
app routing과 서버 컴포넌트, 클라이언트 컴포넌트에 대한 사전 지식을 가지고 본격적으로 위클리미션을 시작해보자.
위의 요구사항을 보면, /folder페이지에 현재 로그인된 유저가 저장한 folder 리스트(즐겨찾기, 코딩 팁, ...)를 가져와서 칩(폴더) 별로 어떤 링크가 저장되어 있는지 화면에 뿌려줘야 한다.
요구사항에는 없지만, 유저 인증 기능(로그인)이 무조건 필요하다고 판단했다. 아마 nodejs를 배우고 나면 해당 주 요구사항에 반영이 될 것이지만 미리 해보기로 했다.
next-auth는 nextjs와 가장 합이 잘 맞는 인증 라이브러이이다. 공식 문서를 뒤지고, 유튜브 강의를 보며 사용법을 익혔다. 부트캠프에서 제공하는 사용자 관련 api는 현재 유저 아이디를 넣으면 이름, 이메일정도를 보내주는 get api뿐이다.

회원 가입 기능이 없으니 비밀번호나 토큰 등도 당연히 없다. 데이터베이스에 부트캠프 수강생들이 있는것 같다. 아래처럼 1번 user에 대해 요청해보면 샘플 회원 정보가 나온다.

나의 경우 6번 user로 등록이 되어있었다.

그래도 email은 등록이 되어 있으니, 이것을 활용해서 다음과 같은 로직으로 로그인을 임시로 mocking해보기로 했다.
- 로그인 페이지를 만들고, 로그인 폼을 화면에 띄운다.
- 사용자 입력 이메일에 따라 userId를 구분한다.
- 부트캠프에서 제공하는 이메일에 따른 userId를 미리 정적 데이터 폴더에 다음과 같이 export하였다.
const UserIdMap = [ "codeit@codeit.com", // 0번 "tl*****90713@gmail.com", // 1번 "nh****0@gmail.com", // ... "d****dk37@gmail.com", "***tony@gmail.com", "t***183@gmail.com", "t****3@naver.com", "f****@gmail.com", "*****dl@gmail.com", "****12@gmail.com", "w*****aan@gmail.com", ];- 비밀번호는 어디에도 없기때문에 임시로 내가 정한 6자리 비밀번호와 매치하는지 확인한다.
- 사용자가 이메일, 비밀번호를 입력하고 로그인 버튼을 누르면 미리 만들어둔 api라우트(/api/signin)를 통해 POST 요청한다.
- api 라우트에서 입력 이메일이 Map안에 있고, 비밀번호가 임의로 정한 6자리와 일치하면 부트캠프 api로 get요청을 날린다. 예를 들어 사용자가 입력한 이메일이 Map의 6번 인덱스에 해당하는 이메일이면 /부트캠프api주소/users/6 로 get요청을 날리고 다음과 같은 응답을 받게 된다.
- 응답을 활용해서 Gnb에 로그인 버튼 대신 유저 정보를 그리고, userId를 라우팅에 활용하는 등 인가 관련 로직을 전개한다.
next-auth 설치
`npm install nex-auth``
next-auth route파일 생성
next-auth를 활용하기 위해서는 api route를 지정해줘야 한다.
next-auth가 처리할 인증 인가 로직을 담당하는 api route는 /api/auth/[...nextauth]이다. api폴더 안에 라우트에 맞는 폴더와 파일을 만들어준다.
import NextAuth, { AuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
const authOptions: AuthOptions = {
providers: [
CredentialsProvider({
// The name to display on the sign in form (e.g. 'Sign in with...')
name: "Credentials",
// The credentials is used to generate a suitable form on the sign in page.
// You can specify whatever fields you are expecting to be submitted.
// e.g. domain, username, password, 2FA token, etc.
// You can pass any HTML attribute to the <input> tag through the object.
credentials: {
username: { label: "Username", type: "text" },
password: { label: "Password", type: "password" },
},
async authorize(credentials, req) {
const res = await fetch("http:localhost:3000/api/signin", {
method: "POST",
body: JSON.stringify({
username: credentials?.username,
password: credentials?.password,
}),
headers: { "Content-Type": "application/json" },
});
const { data } = await res.json();
const user = {
id: data[0].id,
name: data[0].name,
email: data[0].email,
image: data[0].image_source,
};
if (res.ok && user) {
return user;
}
return null;
},
}),
],
callbacks: {
async jwt({ token, user }) {
return { ...token, ...user };
},
async session({ session, token }) {
session.user = token as any;
return session;
},
},
secret: process.env.NEXT_PUBLIC_NEXTAUTH_SECRET,
};
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST, authOptions };
provider는 credentials로 정했다. 이메일과 비밀번호를 활용한 인증 방식이다. 다른 provider로는 깃허브, 구글, 42스쿨 등의 Oauth 제공자들이 올 수도 있다.
위의 route파일을 보면 authorize함수가 있는데, 이 함수가 바로 인증이 필요할 때 next-auth가 알아서 호출해주는 함수이다.
signIn함수
signIn()함수를 호출하면 된다. redirect옵션과 callbackUrl옵션을 줘서 로그인에 성공할 시 home으로 redirect 해주고 있다. const handleSignin: FormEventHandler<HTMLFormElement> = (e) => {
e.preventDefault();
if (validateEmailFormat(usernameInput)) {
signIn("credentials", {
username: usernameInput,
password: passwordInput,
redirect: true,
callbackUrl: "/",
});
}
};authorize 함수
async authorize(credentials, req) {
const res = await fetch("http:localhost:3000/api/signin", {
method: "POST",
body: JSON.stringify({
username: credentials?.username,
password: credentials?.password,
}),
headers: { "Content-Type": "application/json" },
});
const { data } = await res.json();
const user = {
id: data[0].id,
name: data[0].name,
email: data[0].email,
image: data[0].image_source,
};
if (res.ok && user) {
return user;
}
return null;
},
authorize함수를 보면 나의 서버의 /api/signin으로 post요청을 보낸다. 응답을 기반으로 user정보가 담긴 객체를 만들어내고 응답이 정상일 때 user객체를 리턴한다. 응답이 실패하면 null을 리턴해서 next-auth가 성공, 실패 여부를 알 수 있게 해주면 된다. singin api 라우트
export const POST = async (req: Request) => {
const body: ReqBody = await req.json();
const userId = UserIdMap.indexOf(body.username) + 1;
// TODO: 실제 db에서 body 정보와 매칭되는 회원 정보 확인 로직 필요
if (body.password === "******" && userId > 0) {
const res = await getUserById(`${userId}`);
return new Response(JSON.stringify(res));
} else return new Response(JSON.stringify(null));
};
body.password가 내가 임의로 정한 6자리와 맞는지, 들어온 username이 UserIdMap에 포함되어 있는지 확인하는 과정도 있다. 추후에 인증 관련 요구사항이 들어오면 이 부분에 진짜 로그인 인증 로직이 들어가게 될 것이다. session
next-auth를 사용하여 인증을 처리하면 session에 저장하고 꺼내보는 기능도 쉽게 사용할 수 있다.
callbacks: {
async jwt({ token, user }) {
return { ...token, ...user };
},
async session({ session, token }) {
session.user = token as any;
return session;
},
},
secret: process.env.NEXT_PUBLIC_NEXTAUTH_SECRET,
/api/auth/[...nextauth]의 route.ts에 이렇게 옵션을 넣어주면, 로그인이 성공할 시 jwt토큰화된 session을 저장한다.
인가가 필요한 페이지에서 다음과 같이 session을 꺼내볼 수 있다. 참고로 getServerSession은 서버 컴포넌트에서 사용하는 세션을 리턴하는 함수이고, 클라이언트 컴포넌트에서는 useSession훅을 사용해야 한다.
const layout = async ({ children }: { children: ReactNode }) => {
const session = await getServerSession(authOptions);
if (!session) { // session확인해서 null이면 로그인 페이지로 redirect
redirect("/signin");
}
return <>{children}</>;
};
export default layout;
우리가 제작해야 할 folder 페이지는 다음과 같이 생겼다.
그리고 이 페이지에 대한 url은 다음과 같다
/folder/{folderId}
언뜻 보면 전형적인 정적 사이트의 url같다. 마치 정적인 블로그 사이트의 /posts/{postId}처럼 url이 n번 폴더를 보여주는 페이지 처럼 생겼다.
그랬다면 좋았을텐데....
요구사항을 다시 한 번 보면...
현재 로그인된 유저가 저장한 폴더들 중, 맞는 id를 보여주는 페이지인것이다. 예를 들어 내가 userId 6번 유저인데, 즐겨찾기, 취업준비 팁, 요리 레시피, 프론트엔드 정보 라는 폴더들을 생성했고, 이 폴더들의 아이디가 각각 1,2,3,4일때, 로그인 후/folder/3으로 들어가면 내가 저장했던 요리 레시피폴더의 링크들이 보이는 페이지를 만들어야 하는 것이다.
여기에 더해, /folder 페이지에는 해당 유저가 저장한 폴더들이 chip형태로 보여야 한다. 그리고 그 칩을 클릭하면 그 폴더의 아이디에 해당하는 folder페이지를 보여줘야 한다.
먼저 요구사항을 보고 생각난 것이 optional catch all route였다. 원래는 dynamic route를 사용하려고 헀지만, /folder 페이지와 /folder/{folderId}가 보여줘야 할 링크 데이터가 다른것 외에는 완전히 똑같은 구조라서 단순 dynamic route로는 중복이 발생하는 것을 확인한 후 optional catch-all route를 사용하기로 했다.
여기서 catch-all route는 왜 사용하지 않았냐면 catch-all route를 사용해서 페이지를 만들면 /folder, 즉 파라미터가 없는 경로는 해당 페이지로 인식 자체를 안하기 때문이다. 따라서 /folder로 접속하려 하면 404에러가 뜬다. optional catch-all route를 사용하면 파라미터가 없는 페이지로 인식한다.

위와 같이 optional catch-all route를 만들고, 페이지를 다음과 같이 배치시킨다.
<Layout> // 헤더, 푸터 등
<Page> // page.tsx
<Hero /> // 링크 추가하기 영역
<SearchBar /> // 검색바
<MainContent /> // 경로의 folderId에 따라서 달라지는 부분
</Page>
</Layout>
folder 페이지의 경로는 단순히 /folder/{folder}이지만, 인가가 필요한 페이지이다. 현재 로그인된 유저가 저장한 folder들을 찾아서 그 폴더들중에 경로로 넣은 id에 해당하는 폴더에 관한 페이지이기 때문이다. 따라서 이 경로로 접근하려 하면 먼저 로그인이 되어있는지 확인하는 로직이 필요하다.
/folder/[[...id]]/layout.tsx에서 먼저 확인const layout = async ({ children }: { children: ReactNode }) => {
const session = await getServerSession(authOptions);
if (!session) {
redirect("/signin");
}
return <>{children}</>;
};
이렇게 먼저 상위 컴포넌트에서 session을 체크한 뒤 session이 없으면 로그인 페이지로 이동시켰다.
우리의 folder페이지는 data fetching이 필수적이다. 사용자가 폴더 페이지로 이동했을 때, 보여줘야 하는 데이터를 부트캠프 제공 api에서 가져오는데, end point가 다음과 같다.
/api/users/{userId}/folders/{folderId}
따라서 페이지로 이동했을 때 userId와 folderId를 알아야 한다. folderId는 params로 취득하면 되고, userId는 session에서 취득하면 된다.
app/folder/[[...id]]/page.tsx는 요구 사항중 화면에 스크롤에 따라 동적으로 레이아웃이 바뀌는 부분이 있어 불가피하게 클라이언트 컴포넌트가 되었다. 그리고 page.tsx의 자식 컴포넌트인 MainContent(이 친구 역시 클라이언트 컴포넌트)에서 folder 데이터를 요청하게 된다.
const getFolderList = async (userId: number) => {
const res = await getFoldersByUserId(String(userId));
return res.data as Folder[];
};
interface MainContentProps {
folderId: number;
userId: number;
}
const MainContent = ({ userId, folderId }: MainContentProps) => {
const [cardListProps, setCardListProps] = useState([]);
const folderList = use(getFolderList(userId));
...
return (<>..{대충 화면에 링크 카드 보이는 UI}..</>);
}
위의 getFolderList에서 데이터를 가져온다. 여기서 조금 이상한 현상을 마주하게 되었는데, use훅을 사용하기 전에 서버 컴포넌트에서 data fetching을 하듯이 다음과 같이 짰을 시에 무한으로 요청을 쏘는 현상을 발견하였다.
const getFolderList = async (userId: number) => {
const res = await getFoldersByUserId(String(userId));
return res.data as Folder[];
};
interface MainContentProps {
folderId: number;
userId: number;
}
const MainContent = async ({ userId, folderId }: MainContentProps) => {
const [cardListProps, setCardListProps] = useState([]);
const folderList = await getFolderList(userId);
...
return (<>..{대충 화면에 링크 카드 보이는 UI}..</>);
}

찾아본 결과 정확한 답을 찾지는 못하였는데, 자체적으로 여러 실험을 해 본 바로는 async 키워드를 컴포넌트에 붙이면 저렇게 무한으로 요청이 가는 것을 확인할 수 있었다.
그래서 async await 키워드를 붙이지 않아도 프로미스를 핸들링해주는 use훅을 사용하여 데이터를 가져오는 함수를 wrapping한 뒤 결과값을 컴포넌트에서 사용하는 방식으로 고쳤더니 해결되었다. 하지만 공식문서에서 다음과 같이 use로 api요청을 wrapping하는 것을 권장하지 않는다고 나와있다. 추후 미션에서 분명히 고쳐야 할 부분인 것이다.

아니나 다를까 use훅을 사용했더니 다음과 같이 캐싱이 전혀 되지 않고 매번 로딩이 되고 같은 요청이 계속해서 가는 것을 확인할 수 있다.

위의 화면을 잘 보면, 전체, 즐겨찾기, 코딩 팁 등의 폴더 이름들은 한 번 가져오면 다시 가져오지 않아도 되고, 서버 컴포넌트에서 요청을 해서 받아와도 되는 데이터이다. MainContent에서 요청을 하지 않아도 된다는 말이다. MainContent에서는 각 폴더 id에 해당하는 링크들만 가져오면 된다.
문제는 MainContent의 부모 컴포넌트가 page.tsx인데, 이 컴포넌트가 클라이언트 컴포넌트이다. 그러면 또 다시 서버에서 유저 폴더 데이터를 가져올 수 없게 된다. 그런데 만약 children prop을 사용해서 MainContent를 서버 컴포넌트로 만든다면 얘기가 달리지지 않을까?
우선 MainContent에는 폴더 칩 리스트와 폴더 제목, 그리고 폴더 내용물인 링크들이 렌더링 된다. 여기서 MainContent가 fetch 해야할 데이터는 특정 폴더의 내용물 링크 뿐이다. MainContent에서 동적인 요소들은 전부 컴포넌트로 빼서 import하여 렌더링하면 서버 컴포넌트로 전환할 수 있을것이다.
<button onClick={...}/>--> <AddFolderBtn />use훅으로 불러오던 것을 일반 비동기함수로 fetch하고, use client 디렉티브를 삭제한다. import React, { Suspense } from "react";
...
const getFolderList = async (userId: number) => {
...
};
const getLinkProps = async (userId: number, folderId: string) => {
...
};
const MainContent = async ({ userId, folderId }: MainContentProps) => {
const folderList = await getFolderList(userId);
const cardList = await getLinkProps(userId, folderId);
return (
<div className={styles.mainContentContainer}>
<div className={styles.chipListRow}>
<ul className={styles.chipListContainer}>
...
</ul>
<AddFolderBtn /> // 클라이언트 컴포넌트로 분리시킨 버튼(모달 포함)
</div>
<div className={styles.titleRow}>
<h2 className={styles.title}>
{folderId
? folderList.find((folder: Folder) => folder.id === +folderId)
?.name ?? "제목없음"
: "전체"}
</h2>
<OptionList /> // 클라이언트 컴포넌트로 분리시킨 옵션 버튼(모달 포함)
</div>
<article className={styles.cardList}>
{cardList.length > 0 ? (
<LinkCardList cardDataList={cardList} />
) : (
<EmptyLinks />
)}
</article>
{/* <Modal {...modalProps} /> */} // 모달은 자식요소로 옮김
</div>
);
};
export default MainContent;
이렇게 해서 MainContent 컴포넌트가 서버 컴포넌트가 되었다. 다음과 같이 불필요한 fetching을 하지 않는다.
