DevInsight

개발하며 겪은 판단을 적어요

Frontend
조회 3약 4분 읽기

use client 한 줄로 갈리는 서버와 클라이언트, 번들 크기부터 상태 관리까지 흔들린다

Next.js App Router에서 'use client' 한 줄이 서버·클라이언트 영역을 가르면 번들 크기, 데이터 페칭, 상태 관리 전략이 전부 흔들린다. 경계를 잘못 그으면 번들 비대화와 중복 페칭이 뒤늦게 한꺼번에 터진다. 이 메모는 계층별 책임과 데이터 소유권을 기준으로 경계를 다시 그리는 판단 원칙과, 이미 잘못 그어진 경계를 리팩터링하는 우선순위를 정리한다.

DevInsight에서 발행해요

#React#Next.js#Server Components#Client Components#App Router#use client#번들 최적화#데이터 페칭#리팩터링#아키텍처 설계

'use client' 한 줄은 Next.js App Router에서 가장 많이 복사되는 코드가 됐다. 이 지시문은 컴포넌트의 속성이 아니라 import 그래프의 분기점이다. 지시문이 찍힌 파일과 그 파일이 끌고 들어오는 모듈은 전부 클라이언트 번들에 편입되고, 반대쪽은 서버에서만 실행되다가 브라우저로 나가는 자바스크립트에서 빠진다. 그 한 줄이 어디 있느냐에 따라 번들 크기, 데이터 페칭, 상태 관리 전략이 한꺼번에 달라지는 이유가 여기 있다.

경계는 대개 페이지 파일 상단에서 시작된다. 스크롤 인터랙션 하나, 모달 하나 때문에 그렇다. 지시문은 파일 한 개에만 붙는 게 아니라 그 파일이 전이적으로 import한 그래프 전체에 적용된다. 이벤트 하나 때문에 'use client'를 단 컴포넌트 어딘가에서 날짜 포맷용 유틸을 import했다고 치자. 그 유틸은 서버에서는 아무 문제를 일으키지 않았다. 그런데 클라이언트 컴포넌트가 import하는 순간 코드 전체가 브라우저로 넘어간다. 비싼 라이브러리가 "번들에 실리는 출처"를 추적해 보면, 대개 원인은 로직 자체가 아니라 그 로직을 클라이언트 그래프 안으로 끌어들인 상위 경계다.

무거운 유틸이 서버에서 조용했던 이유

서버 컴포넌트의 강점은 단순하다. 그 모듈의 코드가 클라이언트 번들에 아예 없다. 브라우저에는 렌더링 결과만 내려가고, 소스는 서버에 남는다. 이런 구조에서 몇 달간 페이지가 쌓이면 번들 크기는 완만하게 자란다. 그런데 어느 날 'use client'가 그려지는 순간, 그 지점 아래의 이점이 전부 증발한다. 첫 로드 JS가 수백 킬로바이트 뛰는 일은 이 지점에서 흔히 벌어진다.

검증은 어렵지 않다. next build가 출력하는 First Load JS를 기준 삼아, 'use client'를 추가한 커밋 전후를 비교해 보면 된다. 팀 규모가 커질수록 이 숫자는 자주 안 봐도 되는 것처럼 보이지만, 실제로는 가장 정직한 지표다.

동적인 건 페이지가 아니라 버튼이다

좋아요 버튼, 무한 스크롤, 모달. 클릭하면 화면이 바뀌는 부분은 어쩔 수 없이 클라이언트다. 여기서 반복되는 실수는 "이 페이지는 동적이니까"라는 이유로 페이지 파일 자체를 'use client'로 올리는 것이다. 동적인 건 페이지가 아니라 그 안의 버튼이다. 페이지는 서버 컴포넌트로 남겨 두고, 인터랙션이 필요한 조각만 클라이언트 컴포넌트로 떼어내 그쪽으로 props를 넘기면 된다. 이때 props로 내려가는 값은 직렬화되어야 한다. 함수를 넘기려 하면 곧바로 에러가 난다. 서버에 데이터를 두고 클라이언트는 화면과 상호작용만 담당하는 "데이터 소유권" 관점이 여기서 정해진다.

전역 컨텍스트(테마, 세션, 쿼리 클라이언트)는 구조상 'use client'가 필수다. "루트 레이아웃에 Provider를 감싸면 전체가 클라이언트가 된다"는 경고는 절반만 맞다. Provider가 children으로 받은 자식은 서버에서 렌더링된 결과가 그대로 전달되므로 자식 트리는 서버로 남는다. 문제는 조립 방식에 있다. Provider 파일 안에서 페이지를 직접 import해 배치하면, 그 페이지들은 지시문이 없어도 클라이언트 그래프로 편입된다. 겉보기 구조는 같아도 둘의 결과는 정반대다. 이 차이를 놓치면 "Provider가 전체를 클라이언트로 만든다"는 오해와 "슬롯으로 빼면 된다"는 정답이 같은 저장소 안에서 뒤섞여 돌아간다.

fetch는 서버에, 그리는 일은 클라이언트에

서버 컴포넌트는 본문에서 곧바로 await를 쓸 수 있다. 여러 데이터를 가져온 뒤 병렬로 기다리고, 그 결과를 props로 내려보내면 렌더링이 한 번에 끝난다. 같은 일을 클라이언트에서 하려면 useEffect에 로딩·에러·데이터 상태를 다시 짜고, hydration이 끝난 뒤에야 fetch를 시작해야 한다. 서버 렌더링이 이미 끝난 화면 위에서 데이터가 늦게 채워지는 것은 클라이언트 fetch의 정상 형태지만, 서버에서 처음부터 가져올 수 있는 데이터라면 순수한 낭비다. 게다가 서버 fetch가 제공하는 캐시·요청 중복 제거는 서버 단위로 동작한다. 클라이언트로 옮기는 순간 그 혜택을 잃고, 브라우저마다 같은 데이터를 반복 호출하게 된다. 부모가 데이터를 받아야 자식이 이어서 받는 구조가 클라이언트에서 useEffect 체인으로 이어지면 라운드트립도 늘어난다. 서버에서는 Promise.all로 끝날 일이다.

상태 관리 라이브러리의 역할도 이 경계에서 다시 정해진다. 폼 입력, 선택된 탭, 열린 다이얼로그 같은 건 클라이언트 상태다. 문제는 그런 조각을 다루려고 전역 스토어를 서비스 전체의 기본값으로 삼는 경우다. 어차피 서버가 데이터 원본이라면, 대부분 상태는 컴포넌트 몇 개가 공유하는 로컬 범위에서 끝난다. 전역 스토어가 정당해지는 시점은 "클라이언트 컴포넌트 여러 개가 서로 떨어진 위치에서 같은 값에 쓰고 읽을 때"로 좁혀야 한다. 컨텍스트와 스토어를 어디에 두느냐보다, 어떤 값이 정말 브라우저에서만 존재해야 하는 값인지 먼저 가려내는 편이 낫다.

이미 그어진 경계, 어디부터 풀까

잘못된 경계가 쌓인 상태라면 지우는 순서도 정해져 있다. 먼저 'use client'를 지워도 되는 파일부터 찾는다. 이벤트 핸들러도 훅도 없이 props를 받아 그리기만 하는 컴포넌트가 그 대상이다. 한 줄을 지우고 빌드가 통과하면 그것만으로 그 서브트리 전체가 클라이언트 번들에서 빠진다. 다음으로 데이터를 채우는 useEffect를 찾아, 그 부분만 서버 컴포넌트로 올리고 클라이언트 쪽은 받은 데이터를 그리는 뷰로 줄인다. 마지막에 남는 클라이언트 컴포넌트는 인터랙션이 실제로 일어나는 잎 조각뿐이어야 한다. 이 순서를 거꾸로 하면 실패한다. 큰 파일부터 손대면 변경 범위가 커져 회귀를 찾기 어렵고, 결국 되돌리기 십상이다.

수정이 끝나면 @next/bundle-analyzer로 어느 라이브러리가 클라이언트 번들에 실렸는지 출처를 추적하고, 네트워크 탭에서 같은 엔드포인트가 두 번 호출되는지 확인한다. 서버에서 이미 받은 데이터를 클라이언트가 다시 fetch 하면 중복 페칭은 즉시 드러난다. 그 두 검사가 통과하고 나서야 경계가 제자리를 찾았다고 말할 수 있다.

이 글의 요점은 'use client'를 금기시하라는 게 아니다. 실제로 쿠키를 읽거나 비밀 키에 접근하는 일, 캐시가 필요한 일은 서버 밖에서 할 수 없다. 기준은 단 하나다. 이 파일의 코드가 브라우저에서 실행되어야 하는가. 그 질문에 답이 "아니다"인 파일이 서버 영역에 남아 있는 한, 번들 그래프는 알아서 얇아진다. 저장소 전체에서 'use client' 개수를 세어 보는 것으로 이번 주를 시작해 보면, 그 숫자가 곧 다음 리팩터링 목록이 된다.

댓글

댓글을 읽어오는 중입니다.

같이 읽으면 좋은 글

방금 읽은 주제와 이어지는 글을 골랐습니다.

Frontend 전체 보기
Frontend

폰트가 보이기 전에 페이지는 이미 흔들리고 있다

한글 웹 폰트는 글리프 하나당 용량이 커서 로딩을 지연시키고, 폰트가 늦게 뜰수록 CLS 지표가 요동친다. 이 글에서는 subset으로 글리프를 걸러내고 preload 타이밍을 잡고 font-display 전략을 고르는 과정을 실제 수치와 함께 살펴본다. Next.js의 next/font가 자체적으로 처리하는 일과 처리하지 않는 일을 구분해, 퍼포먼스 예산을 넘지 않으면서 텍스트가 안정적으로 렌더링되는 폰트 파이프라인을 설계하는 방법을 다룬다.

#웹 폰트#CLS#Core Web Vitals#Next.js
Frontend

next/image sizes 한 줄이 LCP를 0.5초 당긴다

LCP 개선을 위해 무작정 이미지를 압축하고 CDN을 도입하기 전에, next/image의 sizes 속성과 priority 플래그가 실제로 어떤 영향을 미치는지 정량적으로 이해해야 한다. 이 글은 next/image 설정값이 LCP에 미치는 영향을 실제 코드 레벨에서 분석하고, 이미지 CDN이 진짜 필요한 상황과 불필요하게 최적화를 도입했다가 역효과를 보는 사례까지 함께 다룬다.

#LCP#이미지최적화#next-image#CoreWebVitals

이전 글

2,731개의 타입 에러와 마주했던 한 주, strict 옵션을 끄고 켜는 사이의 현실

다음 글

검색이 자꾸 엉뚱한 문서를 물어올 때 임베딩보다 먼저 의심해야 할 것

DevInsight Digest

새 글이 쌓이면, 피드에서 바로 이어 읽으세요.

과장된 알림 대신 발행한 글 전체를 RSS로 제공합니다.

RSS 구독