호진방 블로그
기술

Offispace 프로젝트 최적화 해보기

# Offispace 프로젝트 최적화 경험을 소개합니다.

2024년 06월 01일

최적화 #1 CSR → SSR 전환

Offispace 프로젝트는 Next.js로 개발되고 있었지만, SSR을 적용하고 있지 않아 Next.js가 제공하는 가장 큰 장점을 활용하지 못하고 있다. 이에 따라, 내가 담당하고 있는 커뮤니티 페이지에 SSR을 적용하여 LCP 성능을 향상시켜보고자 한다. 이번 글에서는 CSR 방식과 SSR 방식을 비교하여 성능 향상이 어느 정도 이루어졌는지에 대해 소개하고자 한다.

SSR을 적용해볼 커뮤니티 글 상세 페이지

❗ 커뮤니티 글 상세페이지에서 query를 사용하는 부분은 Main 컨텐츠와, 댓글이고, 해당 영역의 컴포넌트를 SSR로 전환하고 성능 개선을 확인해 볼 예정이다.


SSR 적용 전, 글 상세페이지의 Lighthouse 성능 측정

현재 CSR 방식의 성능 점수는 41점이다. 스크린샷 올리기 부끄러울 정도로 낮다.


CSR vs SSR vs SSG

시작 전에 간단히 CSR, SSR, 그리고 SSG 방식의 장단점을 비교해보자

CSR

클라이언트 측에서 페이지 렌더링을 수행하는 방식

  1. 사용자가 새로운 사이트 요청
  2. 서버에서 빈 HTML 파일 제공 (화면x, 상호작용x)
  3. 브라우저가 JS파일 다운로드
  4. 브라우저가 JS실행

주로 웹 애플리케이션에서 클라이언트 측 라우팅 및 상호작용이 많은 경우에 사용한다.

[장점]

[단점]

SSG

정적 사이트 생성. SSG는 페이지를 사전에 빌드 시점에서 생성하고 정적 파일로 제공하는 방식

[장점]

[단점]

SSR

서버에서 페이지를 미리 생성한 뒤, 사용자에게 페이지를 렌더링 하는 방식

  1. 사용자가 새로운 사이트 요청
  2. 서버에서 미리 생성된 HTML 파일 제공 (화면O, 상호작용X)
  3. 브라우저가 JS파일 다운로드
  4. 브라우저가 JS실행

주로 웹 애플리케이션에서 클라이언트 측 라우팅 및 상호작용이 많은 경우에 사용한다.

[장점]

[단점]

정리

CSR : 초기 로딩이 빠르지만 SEO가 어려우며 클라이언트에서 데이터 로딩이 필요

SSR : 초기 로딩이 빠르고 SEO가 우수하지만 서버 부하가 증가 할 수 있음

SSG : 초기 로딩이 빠르고 SEO가 우수하며 서버 부하가 낮지만 동적 데이터에 제한이 있음

Main 컨텐츠 SSR 전환

#1

클라이언트 측에서 데이터를 가져오는 방식 때문에 초기 로딩 시간이 길어졌다고 판단하여 초기 페이지 로드 시 서버에서 데이터를 미리 가져와 props로 initialPostData를 내려주고, 해당 데이터를 useQueryinitialData로 넣어줬다. 해당 방식은 매우 단순하고, 클라이언트 측에서는 해당 데이터를 받아서 처리할 수 있다.

export async function getServerSideProps(context: GetServerSidePropsContext) {
  const { query, req } = context;
  const { cookie } = req.headers;
  const postId = query.id as string;
  const token = cookie ? cookie.split('=')[1] : '';
 
  if (token !== '') {
    try {
      const { data } = await axios.get(`https://joo-api.store/posts/${postId}`, {
        headers: {
          Authorization: `Bearer ${token}`,
        },
      });
      return {
        props: {
          initialPostData: data,
        },
      };
    } catch (error: any) {
      return {
        props: {
          initialPostData: null,
        },
      };
    }
  }
}
const PostDetailIndex = ({ initialPostData }: { initialPostData: PostDetailType }) => {
  const router = useRouter();
  const { id } = router.query as { id: string };
 
  const { data: postData } = useQuery(['post', id], () => getPostDetail(id), {
    enabled: id != null,
    initialData: initialPostData
  });

다만, 글을 가져오기 위해서는 로그인 상태가 되어 있어야 header 의 Access Token을 포함해 Api요청을 날리게 되는데 해당 방식을 사용 할 시 미리 Next 서버측에서 Token이 없는 상태로 Api 요청을 날리게 되어, 글을 가져오지 못했다. 이 코드에서는 getServerSideProps 함수에서 req.headers를 사용하여 쿠키에서 토큰을 추출한다. 추출된 토큰은 API 요청의 Authorization 헤더에 포함되어서 해당 포스트 데이터를 가져오는 데 사용된다. 토큰이 없거나 API 요청이 실패할 경우 적절히 처리하여 initialPostData를 null로 설정한다.

결과


#2

또 다른 방식으로 QueryClient를 사용하여 서버에서 데이터를 미리 가져올 수 있다. 이후 dehydrate를 사용하여 QueryClient의 상태를 직렬화한 dehydratedState를 설정한다. 클라이언트 측에서는 dehydratedState를 사용하여 초기 상태를 설정하고 react-query를 통해 추가적인 데이터 관리 및 캐싱 기능을 활용할 수 있다.

export default function App({ Component, pageProps }: AppProps) {
  return (
    <QueryClientProvider client={queryClient}>
      <Hydrate state={pageProps.dehydratedState}>
        <Component {...pageProps} />
      </Hydrate>
      <ReactQueryDevtools initialIsOpen={false} />
    </QueryClientProvider>
  );
}
export async function getServerSideProps(context: GetServerSidePropsContext) {
  const { query, req } = context;
  const { cookie } = req.headers;
  const postId = query.id as string;
  const token = cookie ? cookie.split('=')[1] : '';
 
  if (token !== '') {
    const client = new QueryClient();
    try {
      await client.prefetchQuery(['post', postId], async () => {
        const { data } = await axios.get(`https://joo-api.store/posts/${postId}`, {
          headers: {
            Authorization: `Bearer ${token}`,
          },
        });
        return data.data;
      });
      return {
        props: {
          dehydratedState: JSON.parse(JSON.stringify(dehydrate(client))),
        },
      };
    } catch (error) {
      return {
        props: {
          error: 'Fail',
        },
      };
    }
  }
 
  return {
    props: {},
  };
}

주의할점은 prefetchQuery를 사용할 때, 쿼리 키값을 정확히 설정하는 것이 매우 중요하다. 글 상세 페이지에서는 query.id를 사용하여 해당 포스트의 ID를 가져오고 있으므로, 이를 getServerSideProps에서도 같은 query.id로 가져와 prefetchQuery의 쿼리 키로 사용 하고 있다. prefetchQuery 메소드는 useQuery 메소드와 유사하지만 실제로 데이터를 반환하지는 않고 해당 API에서 넘어온 데이터를 캐싱하는 역할만 한다.

dehydrate은 hydration의 반대의 개념인데, React Query에서는 쿼리 결과를 서버에서 클라이언트로 전송할 수 있도록 쿼리 캐시를 직렬화하는 과정을 의미한다. dehydratedState는 페이지 컴포넌트의 props에 할당 되어 _app.tsx 파일에서 pageProps 객체로 참조가 가능하다. Dehydrate된 QueryClient를 Hydrate라는 컴포넌트가 CSR을 시작할 때 동일한 Query를 호출하는 부분을 찾아 initialData에 할당하는 역할을 수행하게 되어 initialData 없이 SSR 처리가 가능하게 해준다.

hydrate는 서버에서 만들어진 데이터를 클라이언트에서 활용할 수 있도록 동기화 시켜주는 작업이라고 생각하자.

결과


최적화 #2 Next Image로 변환

커뮤니티 페이지 기능 완료후 배포된 사이트의 커뮤니티 페이지를 Light House로 측정한 결과, 매우 처참한 점수을 얻었다. 사실 어느정도 이미지 최적화를 해야지 생각은 하고 있었는데, 생각보다 더 안좋은 결과인것 같아서, 빠르게 이미지 최적화를 시도해보려고 한다.

현재 Next로 개발하고 있음에도, 이미지는 img 태그를 사용하고 있다. Next에서는 Image 컴포넌트를 자체적으로 제공해주고 있다. 공식문서에 따르면, 우수한 Core Web Vitals를 달성하기 위해 Image 컴포넌트에 기본으로 최적화 기능이 포함되어 있다고 한다. 장점으로는 아래와 같다.

Faster Page Loads : 이미지가 뷰포트에 들어왔을 때만 로드되기 때문에 초기 페이지 로드 속도가 빠름 • Improved Performance : 최신 이미지 형식을 사용하여 디바이스 사이즈에 맞게 최적화된 이미지를 제공 • 자동 스켈레톤 UI(placeholder통해서), CLS 방지 • 자동으로 Lazy Loading을 통해 이미지 최적화를 지원 • next.config.js를 통해 지정된 곳에서만 이미지를 받아오며 악의적인 유저로부터 앱을 보호

그렇다면 본격적으로 Next Image로 변환해보고 얼만큼 성능이 향상 됐는지 알아보자


커뮤니티 메인에 접속했을 때 모습이다. 이미지 용량 자체가 매우 크고, 뷰포트에 존재하지 않는 이미지도 불러온 모습이다. 보통 이런 경우 lazy loading을 구현하려면, scroll 이벤트를 이용해 해당 section이 뷰포트 안에 들어왔을 때 이미지를 로드하거나, Intersection Observer, Native Lazy loading(img 태그에 loading=”lazy” 설정) 등의 방법을 사용해서 개발을 해야 한다.


하지만, Next/Image 컴포넌트를 사용하면 lazy loading을 따로 구현할 필요가 없다. 위 적용 전 사진에 빨간 상자 부분의 이미지가 아직 뷰포트에 진입하지 않았기 때문에 아직 로딩 되지 않았고

진입 시 로딩 속도도 1.17초 → 0.88초로 줄어들었다.


또한 이미지 포맷을 png에서 webp로 변환해줘서

이미지 용량 자체도 2.6Mb → 94.7kb로 매우 크게 줄어들었다.


#3 배포 사이트 성능 측정

me
@banhogu
안녕하세요 배움을 나누며 함께 전진하는 1년차 주니어 개발자 방호진입니다.