본문으로 건너뛰기

Next.js 15 핵심 변화 정리: 서버 컴포넌트의 진화와 하이드레이션

Next.js36분 읽기본문 17,850

핵심 요약

이 글에서 확인할 내용

Next.js 15는 단순히 성능을 조금 올린 버전이 아니라, "서버 사이드 렌더링"이라는 개념 자체를 한 단계 확장한 릴리스입니다. React 19와 맞물려 달라진 서버 액션(Server Actions)과 부분적 사전 렌더링(Partial Prerendering, PPR)을 중심으로, 실제 프로젝트에 어떤 영향을 주는지 정리해 봅니다.

  • RSC 렌더링 모델부터 다시 보기: 무엇이 진화했나
  • 1. 컴파일러 세대교체: Turbopack의 역습
  • 2. 캐싱 전략의 세분화 (Granular Caching)

Next.js Evolution

Next.js 15는 단순히 성능을 조금 올린 버전이 아니라, "서버 사이드 렌더링"이라는 개념 자체를 한 단계 확장한 릴리스입니다. React 19와 맞물려 달라진 서버 액션(Server Actions)과 부분적 사전 렌더링(Partial Prerendering, PPR)을 중심으로, 실제 프로젝트에 어떤 영향을 주는지 정리해 봅니다.

우리가 이 릴리스를 눈여겨봐야 하는 이유는, 겉으로 드러난 API 몇 개가 바뀌었기 때문이 아닙니다. 렌더링이 일어나는 "장소"와 자바스크립트가 실행되는 "시점"에 대한 기본 가정이 바뀌었기 때문입니다. 지난 몇 년간 우리는 브라우저에서 모든 것을 조립하는 SPA에 익숙해져 있었고, 서버는 그저 JSON을 뱉어내는 창구에 가까웠습니다. 그런데 서버 컴포넌트가 표준 렌더링 모델로 자리 잡으면서, "이 컴포넌트는 서버에서 끝나는가, 브라우저까지 따라오는가"가 아키텍처의 첫 번째 질문이 되었습니다. 이 질문에 답하지 못하면 번들은 예전 그대로이고, 하이드레이션은 여전히 무겁고, 캐싱은 어긋납니다. 그래서 이 글은 새 기능을 나열하기보다, 그 기능들이 어떤 내부 모델 위에서 동작하는지, 그리고 실무에서 어디를 밟으면 넘어지는지를 함께 짚어 보려 합니다.

RSC 렌더링 모델부터 다시 보기: 무엇이 진화했나

Next.js 15의 변화를 제대로 이해하려면, 먼저 "서버 렌더링"이라는 단어에 뭉뚱그려 담겨 있던 두 가지 개념을 분리해야 합니다. 하나는 전통적인 SSR(Server-Side Rendering)이고, 다른 하나는 서버 컴포넌트(React Server Components, RSC)입니다. 둘은 이름이 비슷하지만 목적이 다릅니다.

서버 렌더링과 SSR은 같은 말이 아니다

전통적인 SSR은 "초기 HTML을 서버에서 미리 만들어 보내는 것"입니다. 하지만 그렇게 보낸 컴포넌트는 브라우저에 도착하면 다시 한번 자바스크립트로 렌더링되어야 합니다. 서버가 그린 HTML은 껍데기일 뿐이고, 그 껍데기에 이벤트 핸들러와 상태를 다시 붙이는 과정이 바로 하이드레이션입니다. 즉 SSR만으로는 컴포넌트의 자바스크립트가 여전히 클라이언트로 전부 내려갑니다. 첫 화면은 빨리 보이지만, 그 대가로 같은 렌더링을 서버와 클라이언트가 두 번 하는 셈입니다.

서버 컴포넌트는 여기서 한 발 더 나아갑니다. 서버 컴포넌트는 서버에서 실행되고 나면 그것으로 끝입니다. 컴포넌트의 코드도, 그 컴포넌트가 의존하는 무거운 라이브러리도 브라우저로 내려가지 않습니다. 브라우저는 서버가 그려낸 결과물만 받습니다. 다시 말해 SSR이 "렌더링을 한 번 더 앞당기는" 최적화라면, 서버 컴포넌트는 "애초에 클라이언트가 하지 않아도 될 일을 서버에 남겨두는" 구조적 분업입니다. Next.js App Router는 이 두 가지를 합쳐서, 정적 마크업은 SSR로 빠르게 내보내면서 컴포넌트 코드는 서버에 묶어두는 방식으로 동작합니다. 이 구분을 놓치면 "서버 컴포넌트를 썼는데 왜 번들이 안 줄었지"라는 흔한 착각에 빠집니다. 번들이 줄어드는 건 SSR 때문이 아니라, 서버 컴포넌트 경계 안쪽의 코드가 클라이언트로 넘어가지 않기 때문입니다.

RSC 페이로드(Flight)와 재조정

서버 컴포넌트가 서버에서 실행되면, 그 결과는 HTML만이 아니라 별도의 직렬화된 형식으로도 표현됩니다. React 내부에서 흔히 'Flight'라고 부르는 이 RSC 페이로드는, 컴포넌트 트리를 JSON에 가까운 스트림으로 기술한 것입니다. 여기에는 "이 자리에는 이런 태그가 있고, 저 자리에는 클라이언트 컴포넌트가 들어가며, 그 컴포넌트에 넘길 props는 이것"이라는 정보가 담깁니다. 브라우저는 이 페이로드를 받아 기존 React 트리에 병합(재조정)합니다.

이 구조가 중요한 이유는 두 가지입니다. 첫째, 클라이언트 사이드 내비게이션이 일어날 때 브라우저는 전체 HTML을 새로 받는 대신 이 페이로드만 받아 필요한 부분만 갱신합니다. 페이지를 옮겨 다녀도 이미 로드된 클라이언트 상태와 스크롤이 유지되는 것은 이 덕분입니다. 둘째, 서버 컴포넌트에서 클라이언트 컴포넌트로 넘기는 props는 이 페이로드에 실려 네트워크를 건너야 하므로 반드시 직렬화 가능해야 합니다. 함수나 클래스 인스턴스는 넘어가지 못하고, 거대한 객체를 통째로 넘기면 페이로드 자체가 비대해집니다. 서버 컴포넌트의 직렬화 규칙과 페이로드 최적화를 더 깊이 파고든 내용은 React Server Components(RSC) 아키텍처 깊게 파헤치기에서 다루고 있으니, 경계 설계를 본격적으로 고민하는 단계라면 함께 읽어보길 권합니다.

1. 컴파일러 세대교체: Turbopack의 역습

Next.js 14까지 베타에 머물던 Turbopack이 15에서는 개발 서버의 기본 선택지로 자리를 잡아가고 있습니다. Rust로 작성된 Turbopack은 Webpack 대비 코드 변경 반영(HMR)이 몇 배 이상 빠르고, 대규모 프로젝트에서 개발 서버 기동 시간을 크게 줄여줍니다. 프로젝트가 커질수록 저장할 때마다 몇 초씩 기다리던 개발 경험이 밀리초 단위로 바뀌는 것이므로, 체감 효과는 벤치마크 수치 이상입니다.

이 차이가 어디에서 오는지 이해하면, 단순히 "빠른 번들러"라는 표현이 얼마나 부족한지 알게 됩니다. Webpack 기반의 기존 개발 서버는 프로젝트가 커지면 의존성 그래프 전체를 다시 훑는 비용이 눈덩이처럼 불어났습니다. 반면 Turbopack은 함수 단위까지 내려가는 세밀한 증분 계산과 캐싱을 전제로 설계되어, 한 파일을 고쳤을 때 그 변경이 실제로 영향을 미치는 최소한의 범위만 다시 계산합니다. 개발자 입장에서 이것은 "코드를 고치고 브라우저를 봤을 때 이미 반영되어 있는" 경험으로 나타납니다. 저장과 확인 사이의 지연이 사라지면 사고의 흐름이 끊기지 않고, 하루에 수백 번 반복되는 그 작은 대기가 없어질 때 생산성은 정성적으로 크게 달라집니다.

다만 실무에 도입할 때는 경계도 분명히 알아야 합니다. 개발 서버(next dev --turbo)는 안정 단계로 넘어왔지만, 프로덕션 빌드까지 Turbopack으로 완전히 대체하는 것은 아직 점진적으로 안정화되는 중입니다. 그래서 우리는 "개발 경험은 Turbopack, 최종 빌드 검증은 기존 파이프라인"이라는 이원 전략을 당분간 유지하는 편이 안전하다고 봅니다. 커스텀 Webpack 로더나 특정 플러그인에 강하게 의존하는 프로젝트라면, 동등한 Turbopack 설정이 준비되었는지부터 확인해야 마이그레이션 중간에 막히지 않습니다.

2. 캐싱 전략의 세분화 (Granular Caching)

Next.js 13/14의 가장 큰 불만 중 하나는 "너무 과격한 캐싱"이었습니다. fetch 요청이 기본적으로 무한정 캐싱되어 데이터가 갱신되지 않는 문제가 빈번했습니다.

이 불만은 단순한 취향 문제가 아니라 실제 장애로 이어지곤 했습니다. 관리자 페이지에서 데이터를 수정했는데 목록에는 옛날 값이 그대로 보이고, 배포 직후에는 멀쩡하다가 시간이 지나면 값이 굳어버리는 식이었습니다. 원인을 추적해 보면 대개 "기본값이 캐시였다"는 사실을 팀이 인지하지 못한 데서 출발합니다. 프레임워크가 좋은 뜻으로 켜둔 캐시가, 개발자의 멘탈 모델과 어긋나는 순간 데이터 정합성 사고로 바뀌는 것입니다.

2.1 'use cache' 디렉티브

React 컴포넌트나 함수 레벨에서 'use cache'를 선언하여 캐싱 범위를 직관적으로 제어하게 하는 접근입니다. 아직 실험 플래그가 필요한 단계지만 방향은 명확합니다. 더 이상 복잡한 revalidateTagcache: 'no-store' 옵션과 씨름하지 않아도 됩니다. 우리가 원하는 것은 "적당히 빠르고, 적당히 최신인" 데이터니까요.

'use cache'가 흥미로운 지점은, 캐싱의 단위를 "요청"에서 "코드 조각"으로 옮긴다는 데 있습니다. 기존에는 개별 fetch 호출마다 옵션을 붙이거나 세그먼트 단위 설정으로 캐시 정책을 흩뿌려야 했습니다. 그러다 보니 한 페이지 안에서도 어떤 데이터는 캐시되고 어떤 데이터는 매번 새로 오는지 추적하기가 어려웠습니다. 'use cache'는 함수나 컴포넌트 상단에 디렉티브 한 줄을 두어 "이 조각의 결과는 캐시 가능하다"고 선언합니다. 여기에 캐시 수명(cacheLife)과 무효화 태그(cacheTag)를 함께 지정하면, 캐시 정책이 데이터가 만들어지는 그 자리에 붙어 다니게 됩니다. 정책이 코드 가까이에 있으면 리뷰할 때도, 나중에 고칠 때도 추적 범위가 좁아집니다.

물론 실험 플래그가 필요하다는 것은 프로덕션에서 무겁게 기대기에는 이르다는 뜻입니다. 우리는 이 기능을 "곧 표준이 될 방향"으로 이해하되, 지금 당장은 명시적인 revalidate 값과 태그 기반 무효화로 캐시 동작을 손으로 통제하는 편을 권합니다. 방향성을 알고 코드를 짜두면, 정식 안정화되었을 때의 이행 비용이 훨씬 줄어듭니다.

2.2 fetch 기본값이 캐시에서 no-store로 바뀐 이유

Next.js 15에서 실무적으로 가장 즉각 체감되는 변화는, fetch의 기본 캐싱이 사실상 꺼진 방향으로 바뀌었다는 점입니다. 이전에는 별도 옵션이 없으면 요청 결과가 캐시되었지만, 이제는 기본적으로 매번 새로 가져오도록 바뀌었습니다. GET 방식 라우트 핸들러와 클라이언트 라우터 캐시의 기본 동작도 "덜 공격적인" 쪽으로 조정되었습니다. 클라이언트 라우터 캐시는 페이지 세그먼트의 기본 재사용 시간이 짧아져서, 뒤로 가기나 링크 이동 시 옛 데이터를 그대로 재사용하기보다 필요한 부분을 다시 확인하는 쪽으로 기울었습니다.

이 변화의 철학은 분명합니다. "기본값은 예측 가능한 정확성이어야 하고, 성능 최적화는 개발자가 의도적으로 켜는 것"이라는 방향입니다. 예전에는 캐시를 끄는 법을 몰라 데이터가 안 바뀌는 사고가 났다면, 이제는 캐시를 켜지 않아 성능이 최적화되지 않는 상황이 됩니다. 두 실패 중 후자가 훨씬 발견하기 쉽고 덜 위험합니다. 값이 안 바뀌는 버그는 사용자에게 잘못된 정보를 보여주지만, 캐시가 안 걸린 상태는 그저 조금 느릴 뿐이기 때문입니다. 그래서 우리는 이 기본값 변경을 환영하는 쪽입니다. 다만 14에서 15로 올릴 때, 예전에 "알아서 캐시되던" 것에 무의식적으로 기대고 있던 페이지가 갑자기 매 요청마다 원본을 때리게 될 수 있으므로, 성능이 예민한 경로에는 캐시를 명시적으로 다시 켜주어야 합니다. 서버 데이터의 일관성을 클라이언트 캐시 계층과 어떻게 맞출지에 대한 사고 틀은 React 서버 상태 관리: Query Cache, Mutation, Invalidation에서 정리한 원칙과도 맞닿아 있습니다.

3. 하이드레이션 개선: Partial Hydration

3.1 선택적 하이드레이션의 원리

기존의 하이드레이션 방식은 서버에서 렌더링된 HTML 전체에 대해 자바스크립트를 매칭하는 전체 하이드레이션이었습니다. App Router의 RSC 구조에서는 서버 컴포넌트가 하이드레이션 대상에서 제외되고, 클라이언트 컴포넌트만 필요한 시점에 하이드레이션됩니다. 초기 자바스크립트 페이로드가 극적으로 줄어들어 TTI(Time To Interactive)가 대폭 개선됩니다.

여기서 한 가지 개념을 정확히 구분해 두는 것이 좋습니다. 서버 컴포넌트는 "나중에 하이드레이션되는" 것이 아니라 "애초에 하이드레이션할 필요가 없는" 것입니다. 서버 컴포넌트에는 클라이언트로 내려가는 자바스크립트가 없으므로, 브라우저가 되살릴 상태나 이벤트 핸들러가 존재하지 않습니다. 반면 'use client'로 표시된 클라이언트 컴포넌트만이 하이드레이션 대상이 됩니다. 그리고 React의 동시성 렌더링 위에서 이 하이드레이션은 한 번에 통짜로 일어나지 않고, 사용자의 상호작용 우선순위에 따라 조각조각 진행됩니다. 사용자가 먼저 클릭한 영역을 우선 살아나게 하고, 나머지는 뒤로 미루는 식입니다. 이런 우선순위 기반 렌더링의 동작 원리를 코드 수준에서 이해하려면 React useTransition과 Concurrent 렌더링을 함께 보면 그림이 또렷해집니다.

정리하면, App Router에서 초기 자바스크립트가 줄어드는 진짜 이유는 "선택적으로 하이드레이션해서"가 아니라, 애초에 트리의 상당 부분이 서버 컴포넌트라서 하이드레이션 대상 자체가 작아지기 때문입니다. 선택적 하이드레이션은 그 위에 얹히는 추가 최적화입니다. 이 둘을 뭉뚱그리면 "왜 우리 앱은 클라이언트 컴포넌트투성이인데 번들이 안 줄지"라는 질문에 답할 수 없게 됩니다.

3.2 Streaming SSR과 Suspense의 결합

Streaming SSR이 더욱 안정화되었습니다. 서버가 HTML을 한꺼번에 보내는 것이 아니라, 준비된 부분부터 스트리밍으로 전송합니다. Suspense 경계로 감싼 컴포넌트는 데이터가 준비될 때까지 폴백 UI를 보여주다가, 데이터가 도착하면 실제 콘텐츠로 교체됩니다.

이 방식이 실무에서 값진 이유는, 페이지 전체가 가장 느린 데이터 하나에 인질로 잡히는 상황을 없애기 때문입니다. 예를 들어 상품 상세 페이지에 상품 정보, 리뷰 요약, 추천 목록이 있다고 합시다. 추천 목록을 만드는 쿼리가 느리다고 해서 상품 정보와 가격까지 그 뒤에 기다릴 이유는 없습니다. 추천 목록만 Suspense 경계로 감싸면, 서버는 상품 정보가 담긴 껍데기를 먼저 흘려보내 사용자에게 즉시 보여주고, 추천이 준비되는 대로 그 자리를 채워 넣습니다. 사용자는 "전부 다 될 때까지의 백지"가 아니라 "핵심은 즉시, 부가는 순차적으로"를 경험합니다.

App Router에서는 이 경계를 두 가지 방식으로 그을 수 있습니다. 라우트 세그먼트 단위로는 loading.tsx 파일을 두면 그 세그먼트 전체가 자동으로 Suspense 경계로 감싸집니다. 더 세밀하게는 컴포넌트 트리 안에서 원하는 지점을 직접 <Suspense>로 감싸 스트리밍 단위를 조절합니다. 어디에 경계를 두느냐가 곧 사용자가 무엇을 먼저 보게 될지를 결정하므로, 경계 설계는 성능 문제이자 UX 설계입니다. 그리고 스트리밍 중 특정 조각이 실패했을 때 페이지 전체가 무너지지 않도록, Suspense 경계와 짝을 이루는 에러 경계 설계가 반드시 필요합니다. 로딩과 에러 상태를 선언적으로 다루는 패턴은 React Error Boundary와 Suspense 패턴에 정리되어 있으니, 스트리밍을 실전에 적용하기 전에 한 번 훑어보길 권합니다.

이 흐름을 한 단계 더 밀어붙인 것이 부분적 사전 렌더링(Partial Prerendering, PPR)입니다. PPR은 하나의 페이지 안에서 정적으로 미리 만들 수 있는 껍데기(정적 셸)와, 요청 시점에만 알 수 있는 동적 구멍을 나눕니다. 정적 셸은 CDN에서 즉시 응답되고, 동적인 부분은 그 셸 안의 Suspense 구멍을 통해 스트리밍으로 채워집니다. "이 페이지는 정적인가 동적인가"라는 이분법을 "한 페이지 안에서 정적인 부분과 동적인 부분을 섞는다"로 바꾸는 것이 PPR의 핵심입니다. 아직 실험 단계라 프로덕션 전면 도입에는 신중해야 하지만, 그 지향점만큼은 이후 App Router 설계의 기본 감각이 될 가능성이 높습니다.

3.3 하이드레이션 비용의 실체

하이드레이션을 "가볍게 하자"고 말하려면, 그것이 왜 비싼지부터 정확히 알아야 합니다. 하이드레이션 비용은 단순히 자바스크립트 파일의 크기 문제가 아닙니다. 브라우저는 그 코드를 내려받고(다운로드), 파싱하고(구문 분석), 실행하고, 서버가 그린 DOM과 대조해 이벤트 핸들러를 붙이는(재조정) 여러 단계를 거칩니다. 특히 파싱과 실행은 메인 스레드를 점유하는 작업이라, 이 시간 동안 브라우저는 사용자의 클릭이나 입력에 반응하지 못합니다. 화면은 이미 그려졌는데 버튼을 눌러도 반응이 없는, 이른바 "죽은 화면" 구간이 바로 여기서 생깁니다.

그래서 하이드레이션 비용을 줄이는 가장 확실한 방법은 "빠르게 하이드레이션하기"가 아니라 "하이드레이션할 것을 줄이기"입니다. 인터랙션이 필요 없는 텍스트, 레이아웃, 정적 마크업을 서버 컴포넌트에 남겨두면 그만큼 클라이언트가 되살릴 대상이 사라집니다. 반대로 습관적으로 파일 상단에 'use client'를 붙이면, 인터랙션이 전혀 없는 컴포넌트까지 하이드레이션 대상으로 끌려 들어가 이 비용을 되살려냅니다. Next.js 15가 제공하는 개선은 이 비용을 다룰 더 좋은 도구를 준 것이지, 우리가 경계를 잘못 그으면 여전히 예전만큼 무거워질 수 있다는 점을 잊지 말아야 합니다.

'use client' 경계 설계: 번들을 결정하는 한 줄

RSC 시대에 아키텍처를 좌우하는 가장 작은 단위는 파일 상단의 'use client' 한 줄입니다. 이 한 줄의 위치가 곧 클라이언트 번들의 크기를 결정하기 때문입니다.

경계는 트리의 말단에 둔다

'use client'를 선언하는 순간, 그 파일과 그 파일이 import하는 모든 하위 모듈이 클라이언트 번들에 포함됩니다. 여기서 흔한 오해가 생깁니다. "이 컴포넌트에 버튼 하나가 필요하니까 클라이언트로 만들어야지"라며 큰 컨테이너 컴포넌트 상단에 'use client'를 붙이면, 그 안에 있던 정적인 자식들까지 전부 클라이언트로 딸려 갑니다. 경계가 트리의 위쪽에 있을수록 클라이언트 번들은 커집니다.

원칙은 간단합니다. 경계는 가능한 한 트리의 말단(leaf)으로 밀어내는 것입니다. 상호작용이 필요한 최소 단위—좋아요 버튼, 검색 입력창, 토글 스위치—만 작은 클라이언트 컴포넌트로 떼어내고, 그 바깥은 서버 컴포넌트로 남깁니다. 그리고 서버 컴포넌트를 클라이언트 컴포넌트 안에 넣고 싶을 때는 직접 import하지 말고 children으로 조합하는 컴포지션 패턴을 씁니다. 클라이언트 컴포넌트가 children으로 받은 노드는 이미 서버에서 렌더링된 결과이므로, 클라이언트 경계 안에 있어도 그 자식은 클라이언트 번들로 끌려오지 않습니다. 이 패턴을 모르면 "레이아웃에 테마 토글 하나 넣었을 뿐인데 페이지 전체가 클라이언트가 되어버리는" 사고가 반복됩니다.

Provider와 Context는 어디에 두는가

컴포지션 패턴이 가장 빛나는 지점이 바로 Context Provider 배치입니다. 테마, 인증 상태, 전역 스토어 같은 Provider는 대부분 클라이언트 컴포넌트여야 합니다. 그런데 이걸 최상위 레이아웃에서 잘못 감싸면 트리 전체가 클라이언트 경계 안으로 들어가는 착각을 하기 쉽습니다. 정답은 Provider를 얇은 클라이언트 컴포넌트로 만들고, 실제 페이지 콘텐츠는 그 Provider의 children으로 흘려보내는 것입니다. 이렇게 하면 Provider 자체는 클라이언트지만, 그 안에서 렌더링되는 서버 컴포넌트들은 여전히 서버에 남습니다.

여기서 자연스럽게 따라오는 질문이 "그럼 상태는 어디에 두어야 하는가"입니다. 서버에서 내려온 데이터를 클라이언트 스토어에 통째로 복제해 두는 습관은 RSC 환경에서 오히려 독이 됩니다. 서버가 이미 최신 데이터를 렌더링해 주는데, 그것을 클라이언트가 또 들고 있으면 두 벌의 진실이 어긋나기 시작합니다. 어떤 상태가 진짜 클라이언트에 머물러야 하는지—입력 중인 폼 값, 열려 있는 모달, 스크롤 위치처럼 서버가 알 필요 없는 것들—를 골라내는 감각이 필요합니다. 이 경계 감각을 잡는 데는 2026년 모던 React 상태 관리 생태계 완벽 분석이 좋은 참고가 됩니다. 클라이언트 상태를 최소화할수록 경계도 얇아지고, 번들도 가벼워집니다.

App Router 데이터 페칭 실전

서버 컴포넌트가 비동기 함수가 될 수 있다는 사실은 데이터 페칭의 문법을 근본적으로 바꿉니다. 더 이상 컴포넌트가 마운트된 뒤에 useEffect로 데이터를 긁어오지 않고, 렌더링 과정 자체에서 await로 데이터를 기다립니다. 하지만 문법이 쉬워졌다고 성능까지 저절로 좋아지는 것은 아닙니다.

병렬 페칭과 워터폴 제거

가장 흔한 함정은 순차적 await입니다. 한 컴포넌트에서 A를 await한 다음 그 결과와 무관한 B를 다시 await하면, 두 요청은 줄을 서서 순서대로 실행됩니다. 서로 의존하지 않는 데이터인데도 첫 번째 응답이 올 때까지 두 번째 요청이 시작조차 하지 않는 것입니다. 이것이 서버 안에서 재현된 워터폴입니다. 클라이언트-서버 왕복은 없앴지만, 서버 내부에서 순차 대기가 새로 생긴 셈입니다.

해결책은 의존 관계가 없는 요청을 Promise.all로 함께 시작하는 것입니다. 두 요청이 동시에 출발하면 전체 대기 시간은 둘 중 느린 하나에 수렴합니다. 더 나아가, 부모가 자식에게 필요한 데이터를 미리 시작해 주는 preload 패턴을 쓰면, 자식이 렌더링되기 전에 그 데이터 요청이 이미 진행 중이도록 만들 수 있습니다. "언제 데이터를 기다리느냐"보다 "언제 요청을 시작하느냐"가 성능을 가른다는 감각이 App Router 데이터 페칭의 핵심입니다.

요청 메모이제이션과 중복 제거

App Router에는 같은 렌더링 패스 안에서 동일한 fetch 요청을 자동으로 합쳐주는 요청 메모이제이션이 있습니다. 덕분에 우리는 "이 데이터를 상위에서 한 번 가져와 props로 내려줘야 하나"를 고민하지 않고, 필요한 컴포넌트에서 각자 데이터를 요청해도 됩니다. 헤더에서 사용자 정보를 부르고 사이드바에서도 같은 사용자 정보를 부르면, 실제 네트워크 요청은 한 번만 나갑니다. 이 특성은 컴포넌트를 데이터 관점에서 독립적으로 설계할 수 있게 해주는 중요한 편의입니다. 다만 이 중복 제거는 fetch 기반 요청과 단일 렌더링 패스라는 조건 안에서 동작하므로, ORM이나 직접 DB 드라이버로 가져오는 데이터에는 React의 cache 함수로 같은 효과를 명시적으로 만들어 주어야 합니다.

서버에서 DB에 직접 접근할 때의 커넥션 관리

서버 컴포넌트의 매력 중 하나는 데이터베이스에 직접 붙을 수 있다는 점입니다. API 계층을 하나 거치지 않고 렌더링 중에 바로 쿼리를 날릴 수 있으니 코드가 간결해집니다. 하지만 여기에는 운영상의 함정이 숨어 있습니다. 서버 컴포넌트는 렌더링될 때마다 실행되고, 트래픽이 몰리면 그만큼 동시에 많은 렌더링이 일어납니다. 각 렌더링이 데이터베이스 커넥션을 직접 잡는다면, 순식간에 커넥션이 고갈되어 "너무 많은 연결" 오류로 서비스가 무너질 수 있습니다. 특히 서버리스나 엣지 환경처럼 인스턴스가 수평으로 폭증하는 곳에서는 이 문제가 더 극적으로 드러납니다.

그래서 서버 컴포넌트에서 DB를 직접 다룰 때는 커넥션 풀링과 그 앞단의 풀러 전략을 반드시 함께 설계해야 합니다. 렌더링 편의성에 취해 커넥션 수명 관리를 소홀히 하면, 프론트엔드 코드처럼 보이는 렌더링 함수가 사실은 데이터베이스에 부하를 주는 백엔드 코드였다는 사실을 장애가 나서야 깨닫게 됩니다. 서버리스 환경의 커넥션 폭증을 어떻게 막는지는 PostgreSQL 커넥션 풀링 이해하기: PgBouncer 모드 선택부터 서버리스 커넥션 폭증 대응까지에서 구체적으로 다루고 있으니, 서버 컴포넌트에서 DB를 직접 호출하는 구조라면 반드시 짚고 넘어가야 할 주제입니다.

본문 중간 핵심 흐름을 시각화한 Next.js 일러스트

4. 보안 가이드라인: 서버 데이터의 안전한 처리 (Server-Only)

RSC 환경에서는 서버 전용 코드와 클라이언트 전용 코드가 혼재되기 쉽습니다. 실수로 서버 환경 변수(API 키)가 클라이언트로 유출되는 것을 막기 위해 server-only 패키지를 사용해야 합니다.

import 'server-only';

export async function getSecureData() {
  // 이 함수는 절대로 클라이언트 컴포넌트에서 임포트될 수 없습니다.
  // 빌드 타임에 에러를 발생시켜 보안 사고를 방지합니다.
}

이러한 방어적 설계는 대규모 팀 협업에서 신입 개발자의 실수를 원천 차단하는 훌륭한 도구가 됩니다.

이 장치가 왜 필요한지는 실패 시나리오를 그려보면 분명해집니다. 서버에서만 쓰려고 만든 데이터 접근 함수가 있고, 그 안에 시크릿 키나 내부 API 주소가 들어 있다고 합시다. 어느 날 누군가 이 함수를 편의상 클라이언트 컴포넌트에서 import합니다. 코드는 멀쩡히 컴파일되고, 그 시크릿은 조용히 클라이언트 번들에 실려 브라우저로 전송됩니다. 배포가 끝난 뒤에야, 누군가 개발자 도구로 번들을 열어 키를 발견하고 나서야 사고를 알게 됩니다. server-only는 바로 이 경로를 빌드 타임에 차단합니다. 서버 전용 모듈을 클라이언트에서 불러오려는 시도가 있으면 빌드가 실패하므로, 사고가 프로덕션까지 흘러가지 못하고 CI에서 멈춥니다.

여기에 더해 잊지 말아야 할 것이 서버 액션의 보안입니다. 서버 액션은 클라이언트에서 함수처럼 호출하지만 실제로는 프레임워크가 만들어 낸 공개 HTTP 엔드포인트입니다. "우리 코드에서만 부르니까 안전하다"는 가정은 성립하지 않습니다. 누구든 그 엔드포인트로 임의의 요청을 보낼 수 있으므로, 모든 서버 액션의 첫 줄은 인증·인가 검증이어야 하고, 클라이언트가 보낸 입력은 신뢰할 수 없는 외부 입력으로 취급해 반드시 서버에서 다시 검증해야 합니다. RSC의 읽기 경로와 서버 액션의 쓰기 경로가 모두 이 원칙 위에서 설계되어야 안전합니다.

5. 마이그레이션 실무 가이드

5.1 점진적 마이그레이션 전략

이미 대규모 SPA를 운영 중인 조직이라면 한꺼번에 모든 것을 옮기기보다, next.config.mjs의 실험 플래그를 켜고 페이지 단위로 넓혀가는 점진적 접근이 안전합니다.

  1. 잎(Leaf) 노드부터 정리: 공통 UI 컴포넌트들을 순수 UI(클라이언트)와 데이터 포함(서버)으로 분리하기 시작합니다.
  2. 부분적 App Router 도입: 특정 서브 경로(예: /blog, /about)부터 App Router를 적용하여 점진적으로 범위를 넓힙니다.
  3. 성능 측정 도구 활용: Next.js Speed Insights나 Lighthouse를 통해 RSC 도입 전후의 번들 크기 변화와 TTI 개선 수치를 수치화하여 조직에 보고하십시오. 데이터로 증명된 개선만이 아키텍처 변경의 정당성을 부여합니다.

이 전략이 유효한 이유는, App Router와 기존 Pages Router가 한 프로젝트 안에서 공존할 수 있기 때문입니다. app 디렉터리와 pages 디렉터리는 동시에 존재할 수 있고, 라우트가 겹치지 않는 한 경로별로 어느 라우터가 처리할지를 나눌 수 있습니다. 덕분에 우리는 트래픽이 적고 위험이 낮은 경로—회사 소개, 블로그, 도움말—부터 App Router로 옮기며 팀의 학습 곡선을 태울 수 있습니다. 이 저위험 경로에서 서버 컴포넌트 경계 설계, 캐싱, 스트리밍의 감각을 충분히 익힌 뒤에야 결제나 대시보드 같은 핵심 경로로 넘어가는 것이 순리입니다. 처음부터 가장 복잡한 화면을 App Router로 다시 쓰겠다고 덤비면, 익숙하지 않은 모델과 익숙하지 않은 도메인 복잡성이 동시에 덮쳐 마이그레이션이 좌초하기 쉽습니다.

5.2 주의해야 할 Breaking Changes

  • Dynamic Rendering 기본값 변경: 이전 버전에서 암묵적으로 동적 렌더링되던 페이지가 정적으로 변경될 수 있습니다.
  • 캐싱 동작 변경: fetch()의 기본 캐싱 동작이 변경되었습니다.

이 두 가지 외에 Next.js 15에서 가장 많은 마이그레이션 코드를 만들어 낸 변화는 요청 관련 API의 비동기화입니다. cookies(), headers(), draftMode()처럼 요청 컨텍스트를 읽는 함수들, 그리고 페이지에 전달되는 paramssearchParams가 이제 Promise를 반환하거나 Promise로 넘어옵니다. 예전 코드처럼 const cookie = cookies()로 바로 값을 쓰던 방식은 더 이상 통하지 않고, const cookieStore = await cookies()처럼 await를 붙여야 합니다. 다행히 공식 코드모드가 제공되어 기계적인 부분은 자동 변환할 수 있지만, 자동 변환이 닿지 못하는 커스텀 유틸이나 조건부 호출은 손으로 마무리해야 합니다. 이 변화를 놓치면 런타임에서 "Promise를 객체처럼 다뤘다"는 종류의 미묘한 오류가 흩어져 나타납니다.

정적/동적 렌더링 기본값 변화도 실무에서 자주 헷갈립니다. 서버 컴포넌트는 기본적으로 정적으로 렌더링되려 하지만, cookies()headers()처럼 요청에 종속된 동적 API를 쓰는 순간 그 세그먼트는 동적 렌더링으로 전환됩니다. 문제는 이 전환이 암묵적으로 일어난다는 점입니다. 정적으로 캐시되기를 기대한 페이지가, 무심코 추가한 헤더 읽기 한 줄 때문에 매 요청마다 서버에서 다시 그려지고 있을 수 있습니다. 그래서 마이그레이션 후에는 반드시 next build의 라우트 출력에서 각 경로가 정적인지 동적인지를 눈으로 확인하는 습관이 필요합니다. 빌드 로그의 라우트 표시가 우리가 의도한 렌더링 전략과 일치하는지가 첫 번째 점검 항목입니다.

5.3 실전에서 가장 자주 밟는 함정

첫째, 하이드레이션 불일치(hydration mismatch)입니다. 서버가 그린 HTML과 클라이언트가 처음 렌더링한 결과가 다르면 React는 경고를 뱉고, 심하면 해당 트리를 클라이언트에서 다시 그립니다. new Date()Math.random()처럼 서버와 클라이언트에서 값이 달라지는 것을 렌더링에 직접 쓰거나, localStorage를 초기 렌더링에서 참조할 때 흔히 발생합니다. 이런 값은 렌더링 결과가 아니라 마운트 이후의 효과에서 다뤄야 합니다.

둘째, 'use client'의 무의식적 확산입니다. 개발 중 급하게 onClick 하나를 붙이려고 상위 컴포넌트에 'use client'를 선언하고, 그대로 잊혀지는 경우가 많습니다. 시간이 지나면 트리의 절반이 이유 없이 클라이언트 컴포넌트가 되어 있고, 번들은 App Router로 옮기기 전보다 오히려 커져 있습니다. 정기적으로 클라이언트 경계가 최소한으로 유지되고 있는지 점검해야 합니다.

셋째, 서드파티 라이브러리의 경계 문제입니다. 서버 컴포넌트에서 바로 쓸 수 없는, 내부적으로 브라우저 API나 React Context에 의존하는 라이브러리가 아직 많습니다. 이런 라이브러리를 감싸는 얇은 클라이언트 래퍼를 두고, 그 바깥은 서버로 유지하는 식으로 격리해야 합니다. 라이브러리가 RSC를 공식 지원하는지 확인하지 않고 서버 컴포넌트에 그대로 넣었다가 알 수 없는 빌드 오류에 시간을 쏟는 일이 잦습니다.

넷째, 환경 변수 노출입니다. 클라이언트 컴포넌트에서 참조하는 환경 변수는 NEXT_PUBLIC_ 접두사가 붙은 것만 값이 채워지고, 나머지는 빈 값이 됩니다. 반대로 서버 전용 시크릿에 이 접두사를 붙이면 그 순간 클라이언트 번들에 노출됩니다. 접두사 규칙을 기계적으로 이해하고, 시크릿과 공개 값의 경계를 코드 리뷰에서 늘 확인해야 합니다.

언제 App Router와 RSC를 미루는 게 나은가

기술의 방향이 옳다고 해서 지금 당장 모든 프로젝트가 옮겨야 하는 것은 아닙니다. 우리는 몇 가지 상황에서는 신중하게 미루는 편이 낫다고 봅니다.

우선, 강한 인터랙션 위주의 애플리케이션입니다. 화면 대부분이 실시간 편집기, 그래픽 도구, 캔버스 기반 대시보드처럼 거의 전부가 클라이언트 상호작용으로 이루어진 앱이라면, 서버 컴포넌트로 얻을 수 있는 이득이 크지 않습니다. 어차피 대부분이 클라이언트 컴포넌트가 될 것이고, RSC의 경계 관리 부담만 새로 지게 됩니다. 이런 앱은 기존 SPA 구조가 오히려 명료할 수 있습니다.

다음으로, 팀의 준비 상태입니다. 서버 컴포넌트는 "어디서 코드가 실행되는가"에 대한 새로운 멘탈 모델을 요구합니다. 팀 전체가 이 경계를 이해하지 못한 채 마이그레이션하면, 시크릿 유출이나 커넥션 고갈 같은 사고가 오히려 늘어납니다. 도구가 좋아졌다고 해서 잘못 쓸 여지가 사라지는 것은 아닙니다. 소수의 저위험 경로에서 팀이 충분히 학습하기 전에는, 핵심 서비스를 전면 이전하지 않는 편이 안전합니다.

마지막으로, 안정성이 최우선인 레거시입니다. 이미 잘 돌아가고 있고 변경 빈도가 낮은 서비스라면, 아키텍처를 갈아엎는 위험이 얻는 이득을 넘어설 수 있습니다. "새 프로젝트는 App Router로, 안정적인 레거시는 그대로"라는 이원 전략은 부끄러운 타협이 아니라 성숙한 판단입니다.

자주 묻는 질문

서버 컴포넌트를 쓰면 클라이언트 상태 관리 라이브러리는 필요 없어지나요? 아닙니다. 서버 컴포넌트는 서버 데이터를 화면에 반영하는 경로를 단순화할 뿐, 클라이언트에만 존재하는 상태—폼 입력, 모달 열림, 필터 토글—는 여전히 클라이언트에서 관리해야 합니다. 다만 서버 데이터를 클라이언트 스토어에 복제하던 관성은 크게 줄어듭니다. 무엇을 클라이언트 상태로 남길지의 판단 기준은 앞서 언급한 상태 관리 생태계 글에서 자세히 다룹니다.

모든 컴포넌트를 서버 컴포넌트로 만드는 게 최선인가요? 최선은 "인터랙션이 필요한 최소 지점만 클라이언트, 나머지는 서버"입니다. 서버 컴포넌트가 많을수록 번들은 가벼워지지만, 사용자 상호작용이 필요한 곳까지 억지로 서버로 밀어붙이면 오히려 어색한 왕복이 생깁니다. 목표는 서버 비율을 최대화하는 것이 아니라 경계를 올바른 위치에 긋는 것입니다.

Pages Router는 사라지나요? 당장은 아닙니다. App Router가 권장되는 방향인 것은 분명하지만, Pages Router도 계속 지원되며 두 라우터는 공존할 수 있습니다. 그래서 점진적 마이그레이션이 현실적인 선택지가 됩니다. 조급하게 전면 재작성을 감행할 이유는 없습니다.

PPR을 지금 프로덕션에 써도 되나요? 부분적 사전 렌더링은 아직 실험 단계입니다. 지향점은 분명하고 미리 감을 잡아둘 가치는 충분하지만, 핵심 매출 경로에 전면 도입하기에는 이릅니다. 저위험 경로에서 동작을 관찰하며 정식 안정화를 기다리는 편을 권합니다.

운영 체크리스트

  • 새로 만든 컴포넌트마다 'use client'가 정말 필요한지, 경계를 더 말단으로 내릴 수 없는지 점검했는가
  • 클라이언트 컴포넌트에 넘기는 props가 직렬화 가능한지, 불필요하게 거대한 객체를 통째로 넘기고 있지 않은지 확인했는가
  • 정적으로 기대한 페이지가 동적 API 사용으로 조용히 동적 렌더링되고 있지 않은지 next build 라우트 출력으로 검증했는가
  • 성능이 예민한 경로에 캐싱을 명시적으로 다시 켰는가 (Next.js 15의 기본값은 캐시가 아님)
  • 서버 전용 모듈에 server-only가 붙어 있고, 시크릿에 NEXT_PUBLIC_ 접두사가 잘못 붙지 않았는가
  • 모든 서버 액션의 첫 단계에 인증·인가 검증과 입력 검증이 들어 있는가
  • 서버 컴포넌트에서 DB에 직접 접근한다면 커넥션 풀과 풀러 전략이 함께 설계되어 있는가
  • 스트리밍 경계(loading.tsx, <Suspense>)와 에러 경계가 짝을 이루고 있는가
  • 요청 API 비동기화(await cookies() 등) 마이그레이션이 코드 전반에 반영되었는가

마치며: 프레임워크의 진화가 우리에게 주는 의미

Next.js 15는 단순히 기능을 덕지덕지 붙이는 것이 아니라, "웹 개발의 복잡성을 플랫폼 내부로 숨기고, 개발자는 비즈니스 로직에만 집중하게 만든다"는 철학을 완성해가고 있습니다. 우리는 이제 인프라 설정이나 번들러 튜닝보다는, "사용자에게 어떤 가치를 줄 것인가"에 더 많은 시간을 쏟을 수 있게 되었습니다. 이 방향성을 이해하고 나면, 15로의 업그레이드는 단순한 버전 올리기가 아니라 아키텍처를 정리할 기회가 됩니다.

다만 한 가지는 분명히 해두고 싶습니다. 플랫폼이 복잡성을 숨겨준다는 것은 그 복잡성이 사라졌다는 뜻이 아니라, 우리가 볼 자리가 바뀌었다는 뜻입니다. 서버와 클라이언트의 경계, 캐싱의 기본값, 하이드레이션의 비용, 커넥션의 수명은 여전히 그 자리에 있고, 잘못 다루면 예전과 똑같이 사고로 돌아옵니다. 좋은 도구는 실수의 여지를 줄여줄 뿐 없애주지는 않습니다. 그래서 우리는 이 진화를 "고민할 것이 줄어든 시대"가 아니라 "고민할 자리가 더 정교해진 시대"로 받아들입니다.

서버 컴포넌트의 진화, 정밀한 하이드레이션, 스트리밍 기반 렌더링은 웹 성능의 새로운 기준을 세우고 있습니다. 점진적 채택과 신중한 마이그레이션으로 이 변화를 자신의 프로젝트에 적용하고, React와 Next.js 생태계가 제시하는 방향에 발맞춰 나가시길 권합니다. 결국 남는 질문은 언제나 같습니다. "이 코드는 어디에서 실행되어야 하는가." 이 질문에 또렷하게 답할 수 있게 되는 순간, Next.js 15의 변화들은 낯선 기능 목록이 아니라 하나의 일관된 그림으로 보이기 시작할 것입니다.


관련해서 정리해둔 다른 글