본문 바로가기

Develog/Study

서버컴포넌트와 클라이언트 컴포넌트 분리를 통해 성능 최적화 (with.Next.js)

 
 
 

 

문제발견

  1. 로딩페이지의 LCP 최대 콘텐츠 페인트 속도가 약 5초로 Core Web Vitals 기준 Poor(매우구림) 수준이었다.
  2. 또한 로딩 프로그래스바가 없어서, 리프레쉬토큰을 재발급 할 때, 사용자가 알 지 못한다.

개선 목표

  1. 로딩속도를 최적화 하기위해, 메인페이지(클라이언트 컴포넌트)를 서버컴포넌트와 클라이언트 컴포넌트로 분리하자!!
  2. 로딩프로그래스 바를 추가하여 CLS (누적 레이아웃 시프트)를 최소화하자!

메인 페이지

  • SSR로 페칭하는 부분이 있음,
  • 페칭하고 전역상태로 저장하는 부분이 있음
  • 이를 분리 할 수는 없을까?
  • 이유 : fetching데이터를 외부에서 가져오고, useEffect로 client상태는 분리하자.

결과

 

기존코드

  //[channelID]/[sessionCode]/page.tsx
  const router = useRouter();
  const { channelId, sessionCode } = useParamsParser();
  const setRole = useAuthStore((state) => state.setRole);
  const streamerInfo = useChannelStore((state) => state.streamerInfo);
  const setChannelId = useChannelStore((state) => state.setChannelId);
  const setStreamerInfo = useChannelStore((state) => state.setStreamerInfo);
  const setSessionInfo = useContentsSessionStore((state) => state.setSessionInfo);
  const accessToken = useAuthStore((state) => state.accessToken);
  //로그인 되어있는지
  useEffect(() => {
    const fetchData = async (channelId: string) => {
      const DummyChannelId = channelId || '0dad8baf12a436f722faa8e5001c5011';

      try {
        const streamerInfo = await postStreamerInfo(DummyChannelId);

        if (streamerInfo === null) {
          alert('channelId가 잘못됐거나 해당 스트리머의 방송 정보가 없습니다.');
          router.push(`/${channelId}/error`);
        } else {
          setChannelId(streamerInfo.channel.channelId);
          setStreamerInfo(streamerInfo);
          if (sessionCode)
            setSessionInfo((prev) => ({
              ...prev,
              sessionCode,
            }));
          console.log(streamerInfo);
        }
      } catch (error) {
        console.log('error가 발생했습니다.', error);
        router.push(`/${channelId}/error`);
      }
    };

    fetchData(channelId as string);
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [channelId, setChannelId]);

 

간단하게 설명하자면, 스트리머의 정보를 불러오고 필요한 값들을 useEffect를 사용해 전역상태에 저장해서 사용하는 화면이다.

이 부분을 서버컴포넌트/클라이언트 컴포넌트로 나눌 수 있는 부분은 다음과 같다.

  1. API를 호출해 데이터를 Fetching하는 부분 (Server Componnet)
  2. 전역상태를 초기화 하고 사용자와의 인터렉션을 위한 부분 (Client Component)

나누어보자!

먼저 ClientSide로 활용할 부분을 뽑아낸다.

Fetching한 데이터의 초기화는 로그인 시 필요하기 때문에 이 페이지에서 안해줘도 된다.

 

    <BtnViewerLogin
          channelId={channelId}
          sessionCode={sessionCode}
          streamerInfo={streamerInfo}
          ></BtnViewerLogin>

 

//BtnViewerLogin

'use client';

import BtnWithChildren from './BtnWithChildren';
import useAuthStore from '@/store/authStore';
import useChannelStore from '@/store/channelStore';
import useContentsSessionStore from '@/store/sessionStore';
import { StreamerInfo } from '@/services/streamer/type';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';

type BtnLoginProps = {
  channelId: string;
  sessionCode: string;
  streamerInfo: StreamerInfo | null;
};

const BtnViewerLogin = ({ channelId, sessionCode, streamerInfo }: BtnLoginProps) => {
  const router = useRouter();
  const setRole = useAuthStore((state) => state.setRole);
  const setChannelId = useChannelStore((state) => state.setChannelId);
  const setStreamerInfo = useChannelStore((state) => state.setStreamerInfo);
  const setSessionInfo = useContentsSessionStore((state) => state.setSessionInfo);
  const accessToken = useAuthStore((state) => state.accessToken);

  useEffect(() => {
    if (streamerInfo === null) {
      alert('channelId가 잘못됐거나 해당 스트리머의 방송 정보가 없습니다.');
      router.push(`/${channelId}/error`);
      return;
    }
    setChannelId(streamerInfo.channel.channelId);
    setStreamerInfo(streamerInfo);
    if (sessionCode) {
      setSessionInfo((prev) => ({
        ...prev,
        sessionCode,
      }));
    }
  }, [channelId, router, sessionCode, setChannelId, setSessionInfo, setStreamerInfo, streamerInfo]);

  //로그인 되어있는지

  const onClickLogin = async () => {
    if (accessToken) {
      router.push(`${sessionCode}/participation`);
    } else {
      window.location.href = 'http://localhost:8080/';
    }
    setRole('VIEWER');
  };

  return (
    <BtnWithChildren onClickHandler={onClickLogin}>
      (로그인하고 3초만에) 시참등록하기
    </BtnWithChildren>
  );
};

export default BtnViewerLogin;
  • 우리의 로그인 로직은 경로에 설정되어있는 sessionCode를 로그인시 삽입해야한다.
  • 그래서 로그인 버튼을 clientComponent로 분리했다.
  • 이벤트 핸들러와 기타 Hooks, 전역 상태를 쓰는 코드를 분리했다.

이러면 기존의 Home은 다음과 같이 서버컴포넌트로 만들 수 있다.

interface PageProps {
  params: { channelId: string; sessionCode: string };
}
  • Next.js 13 버전부터 app directory 라우팅방식은 페이지 컴포넌트에 props로 params를 불러올 수 있다. (13 이전엔 getServerSideProps/getStaticProps 로 불러올 수 있었다.)
  • 난 경로로 //[channelID]/[sessionCode]/page.tsx 동적 라우트를 사용하고 있으므로 params로 저런 Dynamic Route Parameters를 받을 수 있다.
 //[channelID]/[sessionCode]/page.tsx
interface PageProps {
  params: { channelId: string; sessionCode: string };
} //동적으로 받은 params를 받아준다.


//page를 Suspense로 감싸기위해 async를 선언했다.
export default async function Home({ params }: PageProps) {
  //로그인 되어있는지
  const sessionCode = params.sessionCode;
  const channelId = params.channelId || '0dad8baf12a436f722faa8e5001c5011';
	
	//API를 통해 데이터를 불러온다
  const streamerInfo = await postStreamerInfo(channelId);

//fetching이 되지않으면 notFound 페이지로

  if (!streamerInfo) {
    notFound();
  }
 //...

 

  • 받아온 streamerInfo와 ClientComponent가 필요로 하는 동적 라우트 값들을 넘겨주기위해 버튼을 재정의 했다.

 

기존

<BtnWithChildren onClickHandler={onClickLogin}>
          (로그인하고 3초만에) 시참등록하기
</BtnWithChildren>

 

변경 후

//[channelID]/[sessionCode]/page.tsx

  return (
        <BtnViewerLogin
          channelId={channelId}   
          sessionCode={sessionCode}
          streamerInfo={streamerInfo}
        ></BtnViewerLogin>
      </CommonLayout>
    )
  );
}

 

시청자의 로그인 버튼은 다른 버튼과 구분되는 특수한 버튼이다.

다른 버튼과는 달리 sessionCode와 channelId, streamerInfo를 필수로 받아야 하기 때문이다. 그래서 공통화 하기 보다는 따로 로그인 버튼을 만들어 주었다.

 


Suspense로 부분 렌더링 및 레이아웃 시프트 방지

Next.js는 Suspense를 이용해 데이터가 fetching 되기 전에 로딩화면을 띄우는 것을 설정할 수 있다.

준비물은 아까 만든 서버컴포넌트가 필요하다.

 

 //[channelID]/[sessionCode]/page.tsx
import Image from 'next/image';
import { notFound } from 'next/navigation';
//{...}

interface PageProps {
  params: { channelId: string; sessionCode: string };
}

export default async function Home({ params }: PageProps) {
  //로그인 되어있는지
  const sessionCode = params.sessionCode;
  const channelId = params.channelId || '0dad8baf12a436f722faa8e5001c5011';
  const streamerInfo = await postStreamerInfo(channelId);

  //....

 

async를 이용해 비동기 작업을 할 거라고 알려주고, 서버컴포넌트가 await을 통해 비동기 작업을 수행한다.

 

 

그리고 다음과 같이 loading.tsx를 같은 폴더에 만들어준다. (전 ts를 쓰고있어서 tsx확장자 입니당)

 

 //[channelID]/[sessionCode]/loading.tsx
import Image from 'next/image';

const Loading = () => {
 console.log('로오오오딩');
  return <Image src="/assets/loading.svg" alt="loading" width="40" height="40"></Image>;
};

export default Loading;

 

ClientComponent

//BtnViewerLogin

'use client';

import BtnWithChildren from './BtnWithChildren';
import useAuthStore from '@/store/authStore';
import useChannelStore from '@/store/channelStore';
import useContentsSessionStore from '@/store/sessionStore';
import { StreamerInfo } from '@/services/streamer/type';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';

type BtnLoginProps = {
  channelId: string;
  sessionCode: string;
  streamerInfo: StreamerInfo | null;
};

const BtnViewerLogin = ({ channelId, sessionCode, streamerInfo }: BtnLoginProps) => {
  const router = useRouter();
  const setRole = useAuthStore((state) => state.setRole);
  const setChannelId = useChannelStore((state) => state.setChannelId);
  const setStreamerInfo = useChannelStore((state) => state.setStreamerInfo);
  const setSessionInfo = useContentsSessionStore((state) => state.setSessionInfo);
  const accessToken = useAuthStore((state) => state.accessToken);

  useEffect(() => {
    if (streamerInfo === null) {
      alert('channelId가 잘못됐거나 해당 스트리머의 방송 정보가 없습니다.');
      router.push(`/${channelId}/error`);
      return;
    }
    
    setChannelId(streamerInfo.channel.channelId);
    setStreamerInfo(streamerInfo);
    
    if (sessionCode) {
      setSessionInfo((prev) => ({
        ...prev,
        sessionCode,
      }));
    }
  }, [channelId, router, sessionCode, setChannelId, setSessionInfo, setStreamerInfo, streamerInfo]);

  const onClickLogin = async () => {
      if (accessToken) {
      router.push(`${sessionCode}/participation`);
    } else {
      window.location.href = process.env.NEXT_PUBLIC_API_URL||"";
    }
    setRole('VIEWER');

  return (
    <BtnWithChildren onClickHandler={onClickLogin}>
      (로그인하고 3초만에) 시참등록하기
    </BtnWithChildren>
  );
};

export default BtnViewerLogin;

 

클라이언트 컴포넌트의 동작은 이렇습니다.

액세스 토큰이 있으면 참여 페이지로 이동시키고 아니라면 서버주소랑 연결된 네이버 로그인 페이지로 이동합니다.

그리고 메모리에 상위컴포넌트 에서 받은 channelId, sessionCode, streamerInfo로 전역상태를 초기화하는 역할입니다.


Suspense 적용 (서버컴포넌트)

//[channelID]/[sessionCode]/layout.tsx
import CommonLayout from '@/components/layout/CommonLayout';
import { Suspense } from 'react';

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <CommonLayout>
      <Suspense>{children}</Suspense> //하위 page를 suspense로 감쌌다.
    </CommonLayout>
  );
}

 

*Next.js에서 제공하는 fetch를 써야합니다,

 

API 세팅

 await new Promise((res) => setTimeout(res, 2000));

 

으로 처리~

//치지직 api에 직접 스트리머 정보 가져오는 api
export const postStreamerInfo = async (channelId: string): Promise<StreamerInfo | null> => {
  await new Promise((res) => setTimeout(res, 2000));
  const res = await fetch(`${process.env.NEXT_PUBLIC_FRONT_API_URL}/api/streamer`, {
    method: 'POST', // POST 메소드 사용
    headers: {
      'Content-Type': 'application/json', // JSON 형식으로 데이터 전송
    },
    body: JSON.stringify({ channelId }), // channelId를 JSON 형식으로 변환하여 전송
    cache: 'no-store', // ✅ SSR 로딩 감지를 위해 추가!
  }).catch((error) => {
    handleFetchError(error);
    throw error;
  });
  if (!res.ok) {
    console.error('스트리머 정보 가져오기 실패');
    return null;
  }

  const json = await res.json();
  return json?.streamerInfo ?? null; // 응답 데이터에서 스트리머 정보 추출
};

 

결과 분리와 Suspense 적용 후

개선!