프로젝트/하루한냥

[트러블 슈팅] 서버 사이드 렌더링(SSR)과 동적 스타일 문제 해결하기

알파카털파카 2024. 5. 18. 00:58
[트러블 슈팅]
서버 사이드 렌더링(SSR)과 동적 스타일 문제 해결하기

 

 

랜딩 페이지를 만들어 프로젝트를 소개하고, 유입될 수 있도록 하면 어떨까? 서비스 기능 소개와 스크린샷 첨부 정도면 끝나는 랜딩 페이지를 기획했다. 필수 개발 사항으로는 프로젝트 사이트로 이동하는 버튼과 반응형 대응을 고려했다. 복잡한 API 요청도 없고, 백엔드 서버도 따로 필요 없으니 Next.js 14를 사용해 서버 사이드 렌더링으로 구현하기로 해본다. SSR로 정적인 페이지를 빨리 보여줄 수 있으니 유저 사용성도 좋겠지? 기획 단계에선 분명 '간단한' 프로젝트였다. 

 

⚒️ 기술스택 선정 이유
  - Next.js 14 
  - React 18
  - vanilla-extract
❓ 서버 사이드 렌더링(SSR)과 마찰이 생기다
🎨 vanilla-extract와 동적 스타일 이슈 
  - 1. lazy loading 시도
  - 2. useCalcWidthRef 커스텀 훅 분리
  - 3. isMounted로 마운트 시 렌더링
  - 4. 로딩바 추가 
😇 내용 요약과 회고
👩🏻‍💻 마치며

 

 


 

 

기술스택 선정 이유

프로젝트를 개발하면서 사용한 주요 기술스택과 선정 이유를 적어본다.

개념 증명(Proof of Concept)을 하면서 도입하려고 했으나 지나고 보니 고민이 많이 부족했음을 알게됐다. 

 

 

Next.js 14

넥스트는 리액트팀과 밀접하게 협업하고 있다. 리액트 공식문서에서도 '프로덕션 수준의 리액트 프레임워크' 중 하나로 넥스트를 소개하고 있다. 이처럼 넥스트는 대표적인 리액트 기반의 서버 사이드 렌더링 프레임워크다.

 

리액트 18에서 서버 컴포넌트가 새로 도입되었고, 넥스트도 13 버전에서 서버 컴포넌트를 도입했다. 서버 컴포넌트는 /app 디렉토리에 구현되어있다. /app 디렉토리 내부에서 예약어인 layout과 page를 사용해 레이아웃과 페이지를 생성할 수 있다. 

 

https://react.dev/learn/start-a-new-react-project#nextjs-pages-router
Next.js Pages Router
Next.js App Router

 

이전의 페이지 라우터와 달리 리액트 18 서버 컴포넌트의 도입과 앱 라우터는 커다란 지각 변화라며 SNS나 개발자들의 블로그에서 매우 화제가 됐었다. 내 기존의 하루한냥 프로젝트는 React와 Vite로 개발한 싱글 페이지 어플리케이션(SPA)방식의 클라이언트 사이드 렌더링(CSR)을 채택했으므로, 이번에는 새로운 방식을 해보고 싶었다. 마침 리액트 SSR로 유명한 프레임워크가 가져온 변화가 어떨지 궁금했고 개발자로서 욕심이 났다.

 

마침 이번에는 한 페이지짜리의 간단한 랜딩 페이지를 만들 예정이었기 때문에 유저 인터랙션이 적을 것이라고 판단했다. 서버에서 반환된 HTML을 빠르게 내려줄 수 있는 서버 사이드 렌더링의 장점과 맞을 거라고 생각했다. 이는 아주 큰 오판이었는데, 그 이유는 아래에서 적도록 하겠다.

 

 

React 18

리액트 18에서 새로 서버 컴포넌트가 도입됐다. 넥스트 14를 사용하려면 당연히 서버 컴포넌트가 도입된 리액트 18 버전을 사용해야 했다. "use client", "use server"만 붙이면 되는 방식이 간단해 보였다. 서버 사이드 렌더링 구현 방법을 깊이 알지 못해서 SSR로 구현하려면 서버 컴포넌트를 사용해야 한다고 생각했다.

 

 

vanilla-extract

바닐라 익스트랙트는 넥스트 앱 라우터의 클라이언트 컴포넌트에서도 지원하고 있는 라이브러리다. 타입스크립트로 개발됐기 때문에 타입스크립트의 장점을 그대로 활용할 수 있다. 스타일을 런타임에 생성하는 styled-components와 달리, 바닐라 익스트랙트는 빌드 타임에 CSS를 생성(제로 런타임)하기 때문에 서버 사이드 렌더링과 잘 맞는다는 장점도 있다. 

 

https://vanilla-extract.style/, https://nextjs.org/docs/app/building-your-application/styling/css-in-js

 

 

 

 

서버 사이드 렌더링(SSR)과 마찰이 생기다

앞의 기술스택을 가지고 한 페이지로 이루어진 간단한, 서버 사이드 렌더링 방식을 채택한 프로젝트를 시작했다. 다양한 사이트의 레퍼런스를 조사하고 내 사이트에 필요한 디자인 시안을 피그마로 작업해 두었기에 따라 그리면 되었다. Next.js로 환경을 프로젝트를 세팅하고 개발하는데, 프로젝트가 단조롭다는 느낌이 계속 들었다. 

 

대개 랜딩페이지라 하면, 그 안에서 다양한 기능은 없더라도 화면 모션 효과가 화려하거나 이미지가 많이 사용되거나 복잡한 UI 요소가 사용되기도 한다. 그렇다면 내 프로젝트도 다양한 이펙트를 넣으면 어떨까? 기존에 기획해둔 캐러셀 배너와 반응형 구현 외에 스크롤 이펙트 등을 추가로 고려했다.

 

여기서 이제 SSR과의 전쟁이 시작된다.

 

 

 

 

vanilla-extract와 동적 스타일 이슈

CSR 방식에서 하던대로 Typography 등 공통 컴포넌트를 만들어서 동적으로 스타일을 주입하다보니 문제가 생겼다. 초기 렌더링이 되었을 때와, 동적으로 스타일이 적용됐을 때의 CSS가 서로 맞지 않아서 오류가 발생하는 것이다.

 

공통 컴포넌트 스타일 에러

 

SSR과 하이드레이션 에러

 

추가적으로는 이런 심각한 문제도 있었다.

 

반응형으로 캐러셀 배너를 구현했다. 캐러셀에 들어갈 이미지를 모바일과 데스크탑 2가지 사이즈를 만들어 놓고, 기본 사이즈를 모바일로 설정해 두었다. 그러면 초기 렌더링 됐을 때는 모바일 사이즈의 이미지가 튀어나왔다가, 이내 데스크탑 사이즈의 이미지로 맞춰진다. 초기 렌더링과 새로고침 시에 지속적으로 발생하는 문제다. 

 

새로고침 시 배너 크기 이슈 gif

 

(gif로 변환하면서 영상 속도가 많이 느려진 점을 고려해야 한다. 실제로는 저정도로 느리지 않다.)

 

서버 사이드 렌더링과 관련된 문제를 해결하기 위한 해결책은 결국 CSR 방식으로 바꿔나가기였다. 내 선에서 당장 할 수 있는 해결 방법이었다. 상위 몇 개의 컴포넌트를 제외하고 대부분 클라이언트 컴포넌트로 교체했으며 지연 로딩, 커스텀 훅 등의 여러 시도를 해봤다. 해결 과정에서는 캐러셀 배너와 반응형 사이즈 측정하기를 위주로 작성해본다. 

 

 

1.  lazy loading 시도

넥스트에서 지연 로딩(lazy loading)은 라우트를 렌더링할 때 필요한 JS의 양을 줄여서 초기 로딩 성능을 개선하는데 도움이 된다. 로딩 시간을 줄이기 위해 지연 로딩을 시도했다. 지연 로딩과 suspense를 사용할 때 기본적으로 클라이언트 컴포넌트는 사전 렌더링(SSR)되기 때문에, 이를 비활성하기 위해 ssr 옵션을 false로 줬다. 

 

next/dynamic을 이용해 이를 구현했다. next/dynamic는 React.lazy()와 Suspense의 혼합물이다. 이렇게해서 기본적인 레이아웃은 초기에 미리 렌더링되도록 하고, 계산이 필요한 부분은 스트리밍을 사용해 점진적으로 전달받도록 했다. 

 

"use client";
import dynamic from 'next/dynamic';

const DynamicCarouselBanner = dynamic(() => import('./ActualCarouselBanner.tsx'), {
  ssr: false,
});

export default DynamicCarouselBanner;

 

지연 로딩 시도

 

그래도 원하는 많큼 성능이 나오질 않았다. 특히 새로고침 시 배너 크기 이슈가 해결되지 않았다.

 

 

2. useCalcWidthRef 커스텀 훅 분리

DOM 요소의 width값을 구하고, 화면 크기에 변화가 있을 때 값이 업데이트 되도록 하는 커스텀 훅을 만들었다.

 

import { useCallback, useEffect, useRef, useState } from 'react';
import throttle from '@lib/utils/throttle';

export const useCalcWidthRef = <T extends HTMLElement>() => {
  const ref = useRef<T>(null);
  const [width, setWidth] = useState(0);

  const handleResize = useCallback(() => {
    if (ref.current && ref.current.clientWidth) {
      setWidth(ref.current.offsetWidth || 0);
    }
  }, []);

  const handleThrottleResize = throttle(handleResize, 100);

  useEffect(() => {
    handleResize();
    window.addEventListener(`resize`, handleThrottleResize);
    return () => {
      window.removeEventListener(`resize`, handleThrottleResize);
    };
  }, [ref, handleResize, handleThrottleResize]);

  return { ref, width };
};

 

 

3. isMounted로 마운트 시 렌더링

사용자 기기의 사이즈를 알아야 하는 반응형 구현의 경우 이렇게 처리했다. 2에서의 커스텀 훅을 이용해 width를 구하고, 이 값에 따라 모바일과 데스크탑 여부를 가렸다. 또한 변수 isMounted를 추가해 화면에 요소가 그려졌는지를 확인했다.

 

export default function CarouselBanner({ content }: CarouselBannerProps) {
  const { width, ref: containerRef } = useCalcWidthRef<HTMLDivElement>();

  const isMobile = width < 768;
  const imageWidth = isMobile ? '380px' : '740px';
  const isMounted = width > 0;

  return (
    <div className={styles.container} ref={containerRef}>
      <div className={styles.carouselContainer}>
        {isMounted ? (
          <section className={styles.carouselSection}>
            <Carousel />

// ...

 

 

4. 로딩바 추가

콘텐츠가 보여지기까지 시간이 걸리더라도 로딩바 등의 UI가 있으면 사용자는 페이지가 어떤 상황인지 알 수 있다. 그래서 요소가 마운트되기 전에 로딩바를 보여주도록 추가했다. react-spinners 라이브러리를 이용했다. 사용 방법이 간단하고, 내가 원하는 디자인의 로딩바가 있었기 때문에 선택했다.

PulseLoader

 

{!isMounted ? <PulseLoader color={styleToken.color.primary} speedMultiplier={1.5} size={12} /> : null}
  {isMounted ? (
      <section className={styles.infoSection}>
      
// ...

 

완성된 로딩바 gif

 

 

 

 

내용 요약과 회고

🔗 랜딩 페이지

🔗 깃허브

 

랜딩 페이지 사이트와 깃허브 링크다. 

 

이 프로젝트를 진행하며 깨달은 점은, 랜딩 페이지는 한 페이지짜리 간단한 프로젝트가 아니라 사용자에게 한 눈에 어필해야하는 광고판으로 접근해야 한다는 것이다. 페이지 분량이 많고 적고가 문제가 아니라, 많은 정보를 제공하면서도 지루하지 않도록 사용자 이펙트가 있어야 한다. 그렇기에 서버 사이드 렌더링으로 접근할 경우 많은 고려가 필요했다. 특히 사용자 기기의 크기를 측정해서 화면을 조정하는 반응형, 스크롤 이펙트 등을 SSR로 다루려면 미리 서버에서 만들어진 HTML을 내려주는 SSR 방식을 능숙하게 사용할 줄 알아야 했다. 

 

기획 단계에서, 기술 스택을 정하는 개념 증명 단계에서 더 깊게 생각하고 결정해야 하지 않았을까? 사실은 쓰고 싶은 기술이 정해져 있고 이유는 갖다 붙인게 아니었는지 돌아보았다. 욕심내서 어려운 기술을 시도했다가 트러블 슈팅만 잔뜩 했고 뚝딱뚝딱 만들어나가는 재미는 떨어진게 사실이다. 결국 동기부여 저하로 이어졌다. 

 

렌더링 패턴으로 인한 이슈를 겪고 나니 특히나 프로젝트에 SSR을 도입하려면 심사숙고해야겠다고 느꼈다. 클라이언트 컴포넌트에서만 사용할 수 있는 라이브러리가 따로 있었고, 이렇게 클라이언트 컴포넌트와 서버 컴포넌트의 차이점을 계속 구분하면서 개발하는 것이 복잡했다. Next.js 환경 설정이 추가로 필요한 라이브러리도 있고(바닐라 익스트랙트 등) 환경 세팅이 어려웠다. 결과물을 놓고 봤을 때도 대부분의 컴포넌트에 "use client"를 붙여 작업했기 때문에 CSR과 비교해 SSR의 장점을 가져가지 못했다. 서버 컴포넌트란 서버 사이드 렌더링과 무슨 관련이 있는지도 학습이 필요했다. 

 

바닐라 익스트랙트라는 새로운 스타일 라이브러리를 사용한 것도 문제가 됐다. 공통 컴포넌트를 사용해 동적 스타일링이 원활하게 적용되도록 recipesdynamic 등의 패키지도 사용해봤지만 오류가 계속되거나, 학습 곡선만 높아졌다. 차라리 넥스트 공식문서에 있는 styled-components의 방법을 따랐으면 수월했을까? 과거로 돌아가 프로젝트를 다시 세팅한다면 vite, react, ts로 아는 맛의 개발을 진행할 것이다...

 

During streaming, styles from each chunk will be collected and appended to existing styles. After client-side hydration is complete, styled-components will take over as usual and inject any further dynamic styles.

스트리밍 중에는 각 청크의 스타일이 수집되어 기존 스타일에 추가됩니다. 클라이언트 측 하이드레이션이 완료되면 styled-components가 평소와 같이 작동하며 이후 동적 스타일을 주입합니다.

- Next.js 공식문서 styling/css-in-js#styled-components

 

 

라이트하우스

 

 

 

 

마치며

다 만들었음에도 자랑스럽지 못한 프로젝트였다. 처음부터 끝까지 내 손으로 만들었는데 누굴 탓하겠는가. 그래도 SSR 렌더링 패턴에 대해 많이 깨져보고 얻는 것이 많았다. 다음에 프로젝트를 진행하게 되면 기술 스택부터 신중할 것이다. 개념 증명은 남을 설득할 수 있을 만큼 자신에게 확신이 있어야겠다. 그래도 글로 정리하고 나니 마무리 봉합은 잘 된 느낌이라(?) 마음이 좀 놓이는 것 같기도 하다. 🥲