Published on

루트 레이아웃에 'use client'가 붙어 있었다 — 라이트하우스 점수를 보러 갔다가

Authors
  • avatar
    Name
    Hyo814
    Twitter

루트 레이아웃에 'use client'가 붙어 있었다

[TripTune](/blog/웹 기술로 만드는 협업형 여행 계획 플랫폼 TripTune 개발) 홈 화면의 라이트하우스 점수가 낮다는 걸 알고 있었습니다. 이미지 최적화나 번들 크기 같은 걸 손보면 되겠지 하고 src/app/layout.tsx를 열었는데, 첫 줄이 이랬습니다.

'use client';

import React from 'react';
import { usePathname } from 'next/navigation';
import Head from 'next/head';
...

루트 레이아웃이 클라이언트 컴포넌트였습니다.

App Router에서 'use client'는 그 파일과 거기서 import하는 하위 트리 전체를 클라이언트 경계 안으로 넣습니다. 루트에 붙어 있으면 앱 전체가 그 안에 들어가요. 서버 컴포넌트를 쓰려고 App Router를 골랐는데, 실제로는 서버 렌더를 한 번 하고 전부 클라이언트로 넘기는 SPA를 돌리고 있었던 겁니다.

점수를 올리는 작업인 줄 알고 들어갔다가, 경계를 다시 긋는 작업이 됐습니다.


1. 'use client' 하나가 데려온 것들

한 줄이 원인이라기보다, 그 한 줄이 있어서 아무도 이상하다고 못 느낀 코드들이 쌓여 있었습니다. 하나씩 보면 이렇습니다.

next/head가 App Router에서 아무 일도 안 한다

<Head>
  <title>TripTune</title>
  <meta name='description' content='TripTune은 여행자들을 위한 일정 플랫폼 서비스 입니다.' />
  <meta http-equiv='Content-Security-Policy' content='upgrade-insecure-requests' />
  <link rel='icon' href='/favicon.ico' />
  <script async src={`https://www.googletagmanager.com/gtag/js?id=${GA4_MEASUREMENT_ID}`} />
  ...
</Head>

next/headPages Router의 API입니다. App Router에서는 metadata export를 씁니다. 즉 이 블록에 적힌 title·description·favicon의도한 대로 문서 head에 들어가고 있지 않았어요.

이게 무서운 이유는 에러가 안 난다는 겁니다. import도 되고, 렌더도 되고, 타입도 맞아요. 브라우저에서 탭 제목을 보면 "TripTune"이 보이기까지 합니다(클라이언트에서 늦게 붙어서). 그런데 크롤러가 받는 최초 HTML에는 없습니다.

// 고친 뒤 — 서버 컴포넌트가 된 layout.tsx
export const metadata: Metadata = {
  title: 'TripTune',
  description: 'TripTune은 여행자들을 위한 일정 플랫폼 서비스 입니다.',
  icons: { icon: '/favicon.ico' },
};

프레임워크 API가 무동작으로 실패하는 자리가 가장 위험합니다. 라우터를 옮기면서 next/head를 그대로 들고 온 건 자연스러운 일이고, 아무도 안 알려줍니다.

QueryClient가 모듈 최상단에서 만들어지고 있었다

// 모듈 최상단
const queryClient = new QueryClient();

Next 서버에서 모듈은 프로세스 단위로 한 번 평가됩니다. 즉 이 queryClient 하나를 서버가 처리하는 모든 요청이 공유해요. 사용자 A의 캐시가 사용자 B에게 보일 수 있는 구조입니다.

// 고친 뒤 — 클라이언트 컴포넌트 안에서 마운트마다
const [queryClient] = useState(() => new QueryClient());

useState의 초기화 함수로 감싼 게 요점입니다. useState(new QueryClient())라고 쓰면 렌더마다 인스턴스를 만들고 버리니까요.

이 문제도 'use client' 때문에 안 보였습니다. 루트가 클라이언트라 개발 중에는 브라우저에서만 돌아갔고, 브라우저에서는 탭 하나가 곧 프로세스 하나라 "공유"가 문제로 안 나타났어요.

③ GA를 모듈 최상단에서 초기화

const GA4_MEASUREMENT_ID = process.env.NEXT_PUBLIC_GA4_MEASUREMENT_ID;

if (GA4_MEASUREMENT_ID) {
  ReactGA.initialize(GA4_MEASUREMENT_ID);   // 모듈 최상단 부수효과
}

import만 해도 실행되는 부수효과입니다. next/script로 옮기면서 로딩 전략까지 명시했습니다.

<Script
  src={`https://www.googletagmanager.com/gtag/js?id=${GA4_MEASUREMENT_ID}`}
  strategy='afterInteractive'
/>

afterInteractive페이지가 상호작용 가능해진 다음에 로드하라는 뜻입니다. 분석 스크립트는 첫 렌더를 막을 이유가 없어요. 그리고 react-ga4 패키지는 이 시점에 필요 없어졌는데 — 아직 package.json에 남아 있습니다. 걷어낸 쪽 정리를 안 한 거고, 이건 지금도 그대로예요.

④ 푸터 로고에 priority가 붙어 있었다

// 푸터
<Image className={styles.logoImg} src={FooterLogoImage} alt='FooterLogoImage' priority />

priority는 "이 이미지를 먼저 불러라"입니다. 화면 맨 아래 푸터 로고에 붙어 있었어요.

LCP(Largest Contentful Paint)는 대개 히어로 이미지입니다. 그런데 푸터 로고가 priority를 달고 같은 우선순위 큐에 끼면 정작 먼저 나와야 할 이미지가 밀립니다.

<Image ... loading='lazy' />   // 푸터는 lazy

그리고 진짜 먼저 필요한 건 <head>에서 직접 예약했습니다.

<link rel='preload' href='/assets/images/메인화면/ocean_title.png' as='image' />
<link rel='preconnect' href='https://triptune.s3.ap-northeast-2.amazonaws.com' />
<link rel='dns-prefetch' href='https://triptune.s3.ap-northeast-2.amazonaws.com' />

이미지가 S3에 있어서 DNS 조회 + TLS 핸드셰이크가 이미지 요청 전에 붙습니다. preconnect는 그 왕복을 미리 해두라는 뜻이에요. preload가 "이 파일을 받아라"라면 preconnect는 "이 서버와 미리 악수해둬라"입니다. 둘은 다른 일을 하고, 외부 호스트 이미지에는 둘 다 필요합니다.

priority는 "중요한 이미지"에 붙이는 표시가 아니라 "첫 화면을 채우는 이미지"에 붙이는 표시입니다. 여러 개에 붙이면 아무것도 우선순위를 못 가집니다.

⑤ 폰트 웨이트 8종

const notoSansKR = Noto_Sans_KR({
  subsets: ['latin'],
  weight: ['100','300','400','500','600','700','800','900'],   // 8종
  display: 'swap',
});

쓰지도 않는 웨이트를 다 받고 있었습니다. 한글 폰트는 글자 수 때문에 웨이트당 용량이 큽니다. 실제 사용하는 6종으로 줄였어요.

subsets: ['latin']은 그대로 뒀습니다. next/font의 서브셋 옵션에 한글이 없어서, 한글 글리프는 어차피 별도로 처리해야 하는 부분이라 이번 작업 범위 밖으로 뒀습니다.


2. 경계를 어디에 그을 것인가

문제를 정리하면 "루트가 클라이언트여야 할 이유" 를 찾는 일이었습니다. 실제로 클라이언트가 필요한 건 셋뿐이었어요.

  • usePathname() — 경로별로 헤더/푸터를 보일지 결정
  • QueryClientProvider — 컨텍스트라 클라이언트 필요
  • 헤더 컴포넌트의 상호작용
방법얻는 것포기하는 것판단
① 그냥 둔다, 이미지·번들만 최적화작은 변경원인이 그대로. metadata도 못 씀기각
② 경로별 레이아웃을 라우트 그룹 (auth)/(schedule)으로 분리usePathname 자체가 불필요해짐. 가장 정석라우트 폴더 구조를 통째로 옮겨야 함. 화면 20개 이동보류
③ 루트는 서버로 두고 클라이언트가 필요한 부분만 하위 컴포넌트로파일 2개 변경으로 경계 확보usePathname 분기가 남음채택

②가 더 옳습니다. 경로 분기를 조건문이 아니라 폴더 구조로 표현하는 게 App Router의 방식이니까요. 그런데 라우트 그룹으로 옮기면 URL은 그대로여도 파일이 전부 움직이고, 그 시점에 진행 중이던 디자인 작업과 충돌합니다.

그래서 ③으로 경계만 먼저 확보하고 ②는 남겼습니다. 지금도 ClientLayout 안에 isSchedulePage/isAuthPage 분기가 그대로 있어요.

// layout.tsx — 서버 컴포넌트
export const metadata: Metadata = { ... };

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang='ko' className={notoSansKR.className}>
      <head>
        <link rel='preload' ... />
        <link rel='preconnect' ... />
      </head>
      <body>
        {GA4_MEASUREMENT_ID && (
          <Script src={...} strategy='afterInteractive' />
        )}
        <ClientLayout>{children}</ClientLayout>
      </body>
    </html>
  );
}
// ClientLayout.tsx — 여기서부터 클라이언트
'use client';

export default function ClientLayout({ children }: { children: React.ReactNode }) {
  const [queryClient] = useState(() => new QueryClient());
  const pathname = usePathname();
  ...
}

children으로 받는다는 게 핵심입니다. ClientLayout이 클라이언트 컴포넌트라도, children으로 넘어온 페이지는 서버에서 렌더된 결과입니다. 클라이언트 경계는 import 그래프를 타고 전파되지 children prop을 타고 전파되지 않아요. 이 성질을 쓰면 Provider를 클라이언트에 두면서 페이지는 서버 컴포넌트로 유지할 수 있습니다.

같은 작업을 홈 화면에도 했습니다. page.tsx가 165줄짜리 'use client' 컴포넌트였는데, useRouter·useState(버튼 아이콘 호버)·useEffect(body.overflow)를 쓰는 부분만 HomePageClient로 빼서 20줄로 줄였어요.


3. next.config.jsnext.config.mjs가 같이 있었다

경계를 정리하고 설정을 손보는데, 고친 값이 반영이 안 됐습니다. 저장소에 설정 파일이 두 개 있었어요.

next.config.js
next.config.mjs

Next.js는 둘 중 하나만 씁니다. .mjs가 우선이라 .js에 적어둔 설정은 전부 무시되고 있었습니다.

무시되고 있던 것들:

  • compiler.styledComponents — SSR 시 스타일 추출
  • experimental.optimizePackageImports (@mui/material, @mui/icons-material, swiper)
  • 이미지 formats: ['image/avif','image/webp'] + S3 remotePatterns
  • 정적 자산 Cache-Control: public, max-age=31536000, immutable 헤더
  • API rewrites

.js를 지우고 .mjs로 통합했습니다. 여기서도 에러는 안 났습니다 — 설정이 없는 채로 그냥 기본값으로 돌았을 뿐이에요.

같은 이름의 설정 파일이 확장자만 다르게 둘 있으면, 이긴 쪽이 조용히 이깁니다. 프레임워크가 "둘 다 있는데요"라고 말해주지 않는 한, 이건 발견될 때까지 계속됩니다.


4. 검증과, 검증 못 한 것

정직하게 적습니다.

  • 점수를 개선 전후로 기록해두지 않았습니다. 라이트하우스를 열어보고 "낮다"고 판단해서 들어갔는데, 시작 시점 숫자를 안 남겼어요. 그래서 이 글에는 "몇 점에서 몇 점이 됐다"를 쓸 수가 없습니다.
  • 확인한 건 이런 것들입니다 — 빌드 통과, metadata가 최초 HTML의 <head>에 들어가는 것, 홈 화면 렌더 정상, next.config.mjs의 설정이 실제로 적용되는 것(이미지가 webp/avif로 나가는지).
  • createPortal 빌드 에러가 하나 났습니다. 서버 컴포넌트로 바뀐 경로에서 react-domcreatePortal이 문제가 됐고, 해당 컴포넌트를 클라이언트 쪽에 두는 것으로 처리했습니다.

측정을 안 한 게 이 작업의 가장 큰 흠입니다. "라이트하우스 개선"이라는 제목을 달았으면 숫자가 있어야 하는데, 결과적으로 이 작업의 실제 가치는 점수가 아니라 구조를 바로잡은 것이었어요. 제목과 내용이 어긋난 채로 커밋됐습니다.


5. 남은 것

  • 라우트 그룹으로 경로 분기 없애기(2절 ②). ClientLayoutisAuthPage/isSchedulePage 조건문은 폴더 구조로 표현하는 게 맞습니다.
  • react-ga4 제거. next/script로 대체했는데 패키지가 남아 있습니다.
  • 개선 전후 라이트하우스 기록. 지금이라도 현재 점수를 기준선으로 남겨야 다음 작업을 잴 수 있습니다.
  • 한글 폰트 서브셋 처리.

정리

  • 'use client'는 파일 하나가 아니라 하위 트리 전체를 옮깁니다. 루트에 붙으면 App Router를 쓰는 의미가 사라져요.
  • children prop은 클라이언트 경계를 넘지 않습니다. Provider는 클라이언트에, 페이지는 서버에 둘 수 있습니다.
  • 프레임워크 API가 무동작으로 실패하는 자리를 조심합니다. App Router의 next/head는 에러 없이 아무것도 안 합니다.
  • priority는 첫 화면을 채우는 이미지에만. 여러 개에 붙으면 우선순위가 없어집니다.
  • 외부 호스트 이미지에는 preloadpreconnect가 둘 다 필요합니다. 하나는 파일을, 하나는 연결을 미리 잡습니다.
  • 확장자만 다른 설정 파일이 둘 있으면 이긴 쪽이 조용히 이깁니다.
  • 그리고 — "개선"이라는 제목을 달 거면 개선 전 숫자를 먼저 남겨야 합니다.

관련 글: [TripTune 프로젝트 회고](/blog/웹 기술로 만드는 협업형 여행 계획 플랫폼 TripTune 개발) · 2년 묵은 사이드 프로젝트 되살리기