지금 우리 서비스가 전체적으로 SSR을 목표로 작성했다. 그 결과로 전부 SSR로 동작한다. 그런데도 이상하게 페이지 전환이 오래 걸렸다. 물론 아무리 SSR을 완벽하게 적용한다고 해도 당연히 초기 로딩은 피할수가 없다. 하지만 그래도 뭔가 느렸다. 그래서 코드를 확인해보니 느린 이유들이 있었다.
export default async function Page({
searchParams,
}: {
searchParams: { sort: QuestionSort; location: string };
}) {
const { sort, location } = searchParams;
const qusetions = await getQuestions({
take: 7,
order: sort,
answerTake: 5,
location,
});
...
<div className="flex items-center justify-between w-full">
<h2 className="text-xl font-semibold">질문 목록</h2>
<SortingDropdown sorts={QUESTIONSORTS} />
</div>
<QuestionList
questions={qusetions.questions}
questionCursorId={qusetions.cursorId}
location={location}
/>
...
우선 위의 코드가 page.tsx에서 데이터를 처리하는 컴포넌트이다. 페이지 자체에서 데이터를 async await으로 처리하다보니 데이터를 받아오는데 오랜 시간이 걸리고 초기 로딩시간이 오래걸릴수 밖에 없는 것이다. 게다가 우리가 쓰는 db의 위치가 미국이기때문에 데이터 수신 속도가 떨어지는 문제까지 해서 복합적인 이유로 속도가 느려진 것이다.
해결방법으로 react의 suspense를 사용해서 코드 스플리팅을 통해 속도를 개선시킬수 있다. 그리고 db에 접근하는 로직을 최대한 감소시켜서 속도 개선을 시켜주는 것이다.
suspense는 리액트에서 비동기 동작을 다룰수 있는 기능이다.
<Suspense fallback={<CarouselCardSkeleton />}>
<MainCarousel />
</Suspense>
그래서 비동기로 동작하는 컴포넌트를 감싸주고 fallback에 비동기 동작동안 실행시킬 컴포넌트를 넣어주면 된다. 그러면 비동기로 동작하는 컴포넌트들은 fallback에 지정한 컴포넌트가 렌더링되고 비동기 동작이 마무리되면 완료된 컴포넌트가 다시 렌더링된다.
이렇게 코드를 나눠서 필요한 부분을 먼저 렌더링하는 방식을 코드 스플리팅이라고 한다.
그렇게 하기 위해선 페이지 자체에서 비동기 동작을 하면 suspense를 사용할 수 없기에 모든 페이지에서 비동기 작업을 하지 않고 container를 만들어 추가적으로 감싸주었다.
export default async function Page({
searchParams: { sort, location },
}: {
searchParams: { sort: Sort; location: string };
}) {
const { activities, cursorId } = await getActivities({ type: sort, location, size: 5 });
기존에선 page자체에서 sort와 location을 받아 바로 데이터를 받고, 해당 데이터를 리스트 컴포넌트에 보내주는 순서였다.
export default async function Page({
searchParams: { sort, location },
}: {
searchParams: { sort: Sort; location: string };
}) {
return (
...
<Suspense fallback={<CarouselCardSkeleton />}>
<MainCarousel />
</Suspense>
...
이렇게 해주면 페이지 컴포넌트에서는 비동기 동작하지 않고 suspense로 비동기 동작 처리를 해줄수가 있다. 이제 데이터를 받아서 페이지를 완성할때까지 기다리는 것이 아니라 우선적으로 스켈레톤이 렌더링되고 이후에 메인 컨텐트가 추가된 페이지가 렌더링된다.
여기에서 스켈레톤은 shadcn의 skeleton 컴포넌트를 사용했다. 해당 컴포넌트를 사용하면 펄스 이벤트가 포함되어 있어서 간단하게 커스텀해서 만들기 좋다.
스켈레톤 적용되는걸 찍어보려했는데 너무 찰나라서 확인이 안된다,,,그래도 빨라진건 약간 체감이 된다.
이제 메인리스트인 활동과 질문에 대한 최적화는 끝이 났다. 하지만 대시보드도 모두 적용해줘야한다. 그래서 해당 페이지의 코드를 보니 추가적으로 현재 유저의 데이터를 받아오는 코드가 포함되어 있었다.
export default async function Page() {
const user = await getCurrentUser();
이 로직을 통해 유저 데이터를 받아오는 과정은
auth()를 통한 auth.js 세션에 담긴 유저 이메일 확인 -> 이메일로 현재 유저 db에서 찾기
순서로 이뤄진다. 위에서 작성한 코드로 사용하는 데이터는 겨우 유저 닉네임이다. 그래서 불필요한 db요청이 가는것 같아 유저 닉네임도 세션에 저장하면 어떨까 싶다. 그러면 db에서 찾는 과정 없이 즉각적로 auth.js 세션 데이터를 받아오기 때문에 시간이 단축될 것이다.
우리는 prisma를 사용하고 있고 Auth.js에서 prisma와 사용할때 유저 모델을 제공해준다. 그 모델을 그대로 사용하면 해당 모델에 있는 정보중 몇가지가 자동으로 auth.js session 타입에 맞춰서 저장된다. 그렇게 저장되는 데이터는 email, image, name이다.
하지만 우리 prisma모델은 특이하게 유저 모델에 name이 아니라 nickname으로 되어있다보니 세션에서 name에 해당하는 정보를 찾지 못해 세션에 저장하지 못하고 있었다.
그래서 간단하게 유저 데이터를 추가해보도록 하겠다.
import NextAuth, { NextAuthConfig } from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import { PrismaAdapter } from '@auth/prisma-adapter';
import { verifyPassword } from './lib/utils';
import { LoginSchema } from './schema';
import db from './lib/db';
export const authConfig = {
providers: [
Credentials({
credentials: {
email: {},
password: {},
},
authorize: async (credentials) => {
try {
const validate = LoginSchema.safeParse(credentials);
if (validate.success) {
const { email, password } = validate.data;
const user = await db.user.findUnique({
where: {
email,
},
});
if (!user) return null;
const passwordsMatch = verifyPassword(password, user.password);
if (passwordsMatch) return user;
}
return null;
} catch (error) {
return null;
}
},
}),
],
} satisfies NextAuthConfig;
export const {
handlers: { GET, POST },
signIn,
signOut,
auth,
} = NextAuth({
adapter: PrismaAdapter(db),
session: { strategy: 'jwt' },
...authConfig,
trustHost: true,
});
auth.ts파일에서 수정을 해주면 된다. 간단하게 코드 설명을 해보자면 인증 정보에 저장된 이메일과 패스워드를 확인하고 해당 유저의 데이터를 찾아 리턴시켜주고 있다. 그렇게 리턴되는 값은 user뿐인데 위에서 얘기했듯이 유저 객체의 프로퍼티명이 일치하지 않아서 생긴 문제다.
if (passwordsMatch) return { ...user, name: user.nickname };
그래서 user를 그대로 리턴하지 않고 name이라는 프로퍼티를 생성해서 user의 nickname값을 할당해줬다. 그러면 이제 auth.js에서 인식가능한 유저 모델로 만들어준 것이다.
만약 auth.js에서 제공하는 세션 값이 아니라면 jwt와 session의 타입을 수정해서 callback값으로 줘야한다. 하지만 email, image, name외에 데이터는 사실상 개인적인 정보이고 자주 사용하는 값들이 아니기때문에 여기서는 더 추가하지 않겠다.
export default async function Page() {
const user = await auth();
이제 페이지에 접근할때마다 db에서 유저 데이터를 찾아오지 않고 auth.js의 세션에 담긴 유저 정보를 활용함으로서 불필요한 요청을 줄였다.
추가적으로 대시보드의 모든 페이지마다
<Suspense fallback={<WishListSkeleton />}>
<ChatContainer />
</Suspense>
이렇게 suspense를 적용시켜 최적화도 시켜주었다.
최적화를 한다고 하긴 했지만 솔직하게 다이나믹한 변화는 눈에 보이진 않는다. 하지만 매번 같은 상황에서 유저가 접속하는 것이 아니니 여러 상황에 맞춰 구현하는 것이 맞다고 생각한다. 그리고 누군가는 더 사용성이 좋다고 느낄수도 있다!
아직 상세페이지는 진행도 하지 않았다. 이렇게하는게 리팩토링이 맞나 싶긴하지만 동일한 동작에서 성과가 있었다면 리팩토링이라고 생각한다. 앞으로 더 깔끔한 프로젝트를 만들어보고 싶다.