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

- Name
- Hyo814
루트 레이아웃에 '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/head는 Pages 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.js와 next.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']+ S3remotePatterns - 정적 자산
Cache-Control: public, max-age=31536000, immutable헤더 - API
rewrites
.js를 지우고 .mjs로 통합했습니다. 여기서도 에러는 안 났습니다 — 설정이 없는 채로 그냥 기본값으로 돌았을 뿐이에요.
같은 이름의 설정 파일이 확장자만 다르게 둘 있으면, 이긴 쪽이 조용히 이깁니다. 프레임워크가 "둘 다 있는데요"라고 말해주지 않는 한, 이건 발견될 때까지 계속됩니다.
4. 검증과, 검증 못 한 것
정직하게 적습니다.
- 점수를 개선 전후로 기록해두지 않았습니다. 라이트하우스를 열어보고 "낮다"고 판단해서 들어갔는데, 시작 시점 숫자를 안 남겼어요. 그래서 이 글에는 "몇 점에서 몇 점이 됐다"를 쓸 수가 없습니다.
- 확인한 건 이런 것들입니다 — 빌드 통과,
metadata가 최초 HTML의<head>에 들어가는 것, 홈 화면 렌더 정상,next.config.mjs의 설정이 실제로 적용되는 것(이미지가 webp/avif로 나가는지). createPortal빌드 에러가 하나 났습니다. 서버 컴포넌트로 바뀐 경로에서react-dom의createPortal이 문제가 됐고, 해당 컴포넌트를 클라이언트 쪽에 두는 것으로 처리했습니다.
측정을 안 한 게 이 작업의 가장 큰 흠입니다. "라이트하우스 개선"이라는 제목을 달았으면 숫자가 있어야 하는데, 결과적으로 이 작업의 실제 가치는 점수가 아니라 구조를 바로잡은 것이었어요. 제목과 내용이 어긋난 채로 커밋됐습니다.
5. 남은 것
- 라우트 그룹으로 경로 분기 없애기(2절 ②).
ClientLayout의isAuthPage/isSchedulePage조건문은 폴더 구조로 표현하는 게 맞습니다. react-ga4제거.next/script로 대체했는데 패키지가 남아 있습니다.- 개선 전후 라이트하우스 기록. 지금이라도 현재 점수를 기준선으로 남겨야 다음 작업을 잴 수 있습니다.
- 한글 폰트 서브셋 처리.
정리
'use client'는 파일 하나가 아니라 하위 트리 전체를 옮깁니다. 루트에 붙으면 App Router를 쓰는 의미가 사라져요.childrenprop은 클라이언트 경계를 넘지 않습니다. Provider는 클라이언트에, 페이지는 서버에 둘 수 있습니다.- 프레임워크 API가 무동작으로 실패하는 자리를 조심합니다. App Router의
next/head는 에러 없이 아무것도 안 합니다. priority는 첫 화면을 채우는 이미지에만. 여러 개에 붙으면 우선순위가 없어집니다.- 외부 호스트 이미지에는
preload와preconnect가 둘 다 필요합니다. 하나는 파일을, 하나는 연결을 미리 잡습니다. - 확장자만 다른 설정 파일이 둘 있으면 이긴 쪽이 조용히 이깁니다.
- 그리고 — "개선"이라는 제목을 달 거면 개선 전 숫자를 먼저 남겨야 합니다.
관련 글: [TripTune 프로젝트 회고](/blog/웹 기술로 만드는 협업형 여행 계획 플랫폼 TripTune 개발) · 2년 묵은 사이드 프로젝트 되살리기