· Next.js· Performance· i18n· SSR· SaaS

초기 로딩 10초 흰 화면을 줄인 과정

Next.js SSR 환경에서 i18n 초기화 구조를 개선하여 초기 렌더링 지연 문제를 해결한 경험

운영 중인 B2C SaaS 서비스에서 사용자 문의로 비슷한 피드백이 계속 들어왔다. “첫 화면이 너무 늦게 떠요”, “로딩 중인지 멈춘 건지 모르겠어요”. 실제로 메인 페이지 진입 시 아무것도 없는 흰 화면이 길게는 10초 가까이 유지되는 경우가 있었다. 스피너도 없고, 스켈레톤도 없이 그냥 하얀 화면. 당연히 사용자 이탈로 이어지고 있었다.

어디서 막히는 걸까

처음엔 API 응답이 느린 줄 알았다. 하지만 DevTools로 확인해보니 서버 응답은 빠르게 오고 있었다. HTML도 정상적으로 도착했는데 화면에는 아무것도 안 그려지는 상황.

Network 탭과 Performance 탭을 번갈아 보면서 타임라인을 추적했다. HTML 파싱 완료 후 JS 번들이 로드되고, 그 다음에 뭔가 이상한 구간이 있었다. JS 실행은 시작됐는데 렌더링은 안 되고, 대신 네트워크 요청이 여러 개 날아가고 있었다. 그리고 그 요청들이 모두 완료된 후에야 비로소 첫 페인트가 찍혔다.

범인은 i18n 초기화였다.

기존 구조의 문제

프로젝트는 i18nextreact-i18next를 사용하고 있었다. Next.js SSR 환경인데, i18n 설정은 전형적인 클라이언트 초기화 패턴으로 되어 있었다.

// _app.tsx
function MyApp({ Component, pageProps }) {
  const [ready, setReady] = useState(false);

  useEffect(() => {
    i18n
      .use(HttpBackend)
      .init({
        backend: { loadPath: '/locales/{{lng}}/{{ns}}.json' }
      })
      .then(() => setReady(true));
  }, []);

  if (!ready) return null; // 여기서 막힘

  return <Component {...pageProps} />;
}

readytrue가 될 때까지 아무것도 렌더링하지 않는다. 그리고 readytrue가 되려면 번역 파일을 네트워크로 받아와야 한다. 서버에서 아무리 빠르게 HTML을 내려줘도, 클라이언트에서 다시 번역 리소스를 fetch하느라 렌더링이 막혀버리는 구조였다.

네트워크 상태가 좋으면 1-2초, 나쁘면 10초. 모바일 환경이나 해외 접속자는 더 심했다.

해결 방향

문제의 핵심은 간단했다. 클라이언트에서 번역 데이터를 기다리지 말고, 서버에서 미리 준비해서 내려주면 된다.

목표는 두 가지였다.

  1. 첫 HTML 응답 시점에 이미 번역이 적용된 상태로 내려주기
  2. 클라이언트에서는 서버가 준비한 데이터를 그대로 사용하기

서버 사이드 i18n 선로딩

먼저 getServerSideProps에서 현재 locale에 맞는 번역 데이터를 로드하도록 변경했다.

// lib/i18n-server.ts
export async function loadServerTranslations(locale: string, namespaces: string[]) {
  const translations: Record<string, any> = {};

  for (const ns of namespaces) {
    const data = await import(`../public/locales/${locale}/${ns}.json`);
    translations[ns] = data.default;
  }

  return { locale, translations };
}
// pages/index.tsx
export async function getServerSideProps({ locale }) {
  const i18nData = await loadServerTranslations(locale, ['common', 'home']);

  return {
    props: { i18nData }
  };
}

이제 페이지 컴포넌트는 props로 번역 데이터를 받는다. 서버에서 이미 로드가 끝난 상태이므로 네트워크 대기가 없다.

클라이언트 동기화

클라이언트에서는 서버가 내려준 데이터로 i18n 인스턴스를 초기화한다. 중요한 건 initImmediate: false 옵션이다. 비동기 로딩 없이 전달받은 리소스를 바로 사용하겠다는 의미다.

// _app.tsx
function MyApp({ Component, pageProps }) {
  const { i18nData } = pageProps;

  useEffect(() => {
    if (i18nData && !i18n.isInitialized) {
      i18n.init({
        lng: i18nData.locale,
        resources: {
          [i18nData.locale]: i18nData.translations
        },
        initImmediate: false
      });
    }
  }, [i18nData]);

  return <Component {...pageProps} />;
}

더 이상 ready 상태를 기다릴 필요가 없다. 서버에서 이미 번역된 HTML이 내려왔고, 클라이언트는 hydration만 하면 된다.

삽질했던 부분들

구조 변경 자체는 어렵지 않았는데, 운영 환경에서 몇 가지 이슈가 터졌다.

hydration mismatch: 서버에서 렌더링한 HTML과 클라이언트에서 렌더링한 결과가 달라서 React가 경고를 뱉었다. 원인은 locale 판단 로직이 서버와 클라이언트에서 달랐기 때문. 서버에서는 Accept-Language 헤더를 보고, 클라이언트에서는 navigator.language를 보고 있었다. 서버의 locale 판단 결과를 클라이언트에 그대로 전달하는 것으로 해결했다.

번역 키 누락: 개발 환경에서는 잘 되는데 프로덕션에서 일부 번역이 안 나왔다. dynamic import 경로 문제였다. Webpack이 빌드 타임에 경로를 분석하는데, 변수로 된 경로는 제대로 번들링이 안 됐다. 명시적인 locale 분기로 우회했다.

namespace 로딩 순서: 공통 번역(common)과 페이지별 번역을 함께 쓰는데, 로딩 순서가 꼬이면 일부 키가 undefined로 나왔다. Promise.all로 병렬 로딩하되 결과를 merge하는 순서를 고정했다.

페이지별로 나눠 내려주기

번역을 한 번에 다 내려주면 페이지가 늘고 키가 쌓일수록 초기 payload가 커진다. 그래서 페이지마다 실제로 쓰는 namespace만 넘기도록 했다.

// pages/home/index.tsx
export async function getServerSideProps({ locale }) {
  // home 페이지에서는 common + home만 로드
  const i18nData = await loadServerTranslations(locale, ['common', 'home']);
  return { props: { i18nData } };
}

// pages/settings/index.tsx
export async function getServerSideProps({ locale }) {
  // settings 페이지에서는 common + settings만 로드
  const i18nData = await loadServerTranslations(locale, ['common', 'settings']);
  return { props: { i18nData } };
}

각 페이지가 실제로 쓰는 번역만 내려가니 payload도 줄고, 번역 관리도 페이지 단위로 할 수 있어서 유지보수가 편해졌다.

결과

구조 개선 후 초기 로딩 시 흰 화면 구간이 사실상 사라졌다. Lighthouse 성능 점수도 올랐지만, 그보다 체감이 확실히 달랐다. 페이지에 진입하면 바로 콘텐츠가 보인다.

네트워크가 느린 환경에서 효과가 더 컸다. 예전에는 번역 파일 로딩에 묶여서 아무것도 못 보여줬는데, 이제는 서버에서 완성된 HTML이 오기 때문에 JS가 로드되기 전에도 기본적인 화면은 보인다.

Lighthouse로 잰 개선 전후는 이렇다. 한 화면에 많은 데이터를 펼쳐놓고 보는 서비스라 사용자 대부분이 PC로 접속한다. 반응형으로 만들긴 했지만 성능 기준을 데스크톱으로 잡는 이유다.

지표개선 전개선 후
First Contentful Paint2.3초0.8 ~ 0.9초
Largest Contentful Paint3.3초1.6 ~ 1.7초

개선 후를 범위로 적은 건 네 번 연속으로 재서 나온 폭이라서다. 한 번의 측정값은 그날 서버와 로컬 상태까지 같이 재는 셈이라 대표값으로 쓰기 어렵다. 같은 측정에서 Speed Index는 1.3~1.6초, Total Blocking Time은 네 번 모두 0ms였다.

FCP가 이 작업과 가장 직접 맞물린 지표다. 첫 페인트 앞에 번역 파일을 기다리는 구간이 있었고, 지금은 그 구간 자체가 없다.

다만 이 표는 데스크톱에서 잰 값이라, 앞에서 말한 “나쁘면 10초”짜리 흰 화면과는 조건이 다르다. 그쪽은 느린 네트워크와 해외 접속까지 포함한 실제 사용자 최악 케이스이고, 이 표는 같은 조건에서 개선 전후만 나란히 비교한 값이다.