-
Next.js SSR 핵심 정리 — 서버 렌더링 원리부터 App Router 적용까지카테고리 없음 2026. 4. 12. 21:49

SSR이란 무엇인가
SSR(Server-Side Rendering)은 웹 페이지의 HTML을 서버에서 미리 생성해 클라이언트에 전달하는 렌더링 방식입니다.
전통적인 웹 서버 방식과 유사하지만, 현대 SSR은 React 같은 컴포넌트 기반 프레임워크와 결합해 더 유연하게 동작합니다.
사용자는 브라우저에서 JavaScript가 실행되기 전에도 완성된 HTML 콘텐츠를 볼 수 있어 초기 로딩 경험이 크게 개선됩니다.CSR과 SSR의 차이
CSR(Client-Side Rendering)은 브라우저가 빈 HTML을 받은 뒤 JavaScript를 다운로드하고 실행해 화면을 그리는 방식입니다.
반면 SSR은 서버가 완성된 HTML을 응답으로 보내기 때문에, 브라우저가 즉시 콘텐츠를 표시할 수 있습니다.
두 방식의 차이는 단순히 렌더링 위치만이 아니라 SEO, 초기 로딩 속도(FCP), 서버 부하 측면에서도 뚜렷하게 나타납니다.항목 CSR SSR 초기 HTML 비어 있음 완성된 콘텐츠 포함 SEO 불리 (크롤러가 JS 실행 필요) 유리 (HTML 직접 읽기 가능) 서버 부하 낮음 높음 (요청마다 렌더링) 인터랙션 시작 JS 번들 로드 후 Hydration 완료 후 Next.js Pages Router에서 SSR 구현하기
Next.js의 Pages Router 방식에서는
getServerSideProps함수를 사용해 SSR을 구현합니다.
이 함수는 매 요청마다 서버에서 실행되며, 반환된props가 페이지 컴포넌트로 전달됩니다.
아래 예제처럼 외부 API를 호출해 데이터를 가져온 뒤, 그 결과를 컴포넌트에 주입할 수 있습니다.// pages/posts/[id].tsx import { GetServerSideProps } from 'next' interface Post { id: number title: string body: string } interface Props { post: Post } export default function PostPage({ post }: Props) { return ( <article> <h1>{post.title}</h1> <p>{post.body}</p> </article> ) } export const getServerSideProps: GetServerSideProps = async (context) => { const { id } = context.params! const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`) const post: Post = await res.json() return { props: { post }, } }getServerSideProps는 빌드 시점이 아닌 요청 시점에 실행되므로, 항상 최신 데이터를 반영할 수 있습니다.
반면 매 요청마다 서버 연산이 발생하기 때문에, 데이터 변경이 드문 페이지에는 적합하지 않을 수 있습니다.
이런 경우에는 SSG(Static Site Generation)나 ISR(Incremental Static Regeneration)을 함께 고려해야 합니다.Next.js App Router에서의 SSR
Next.js 13 이후 도입된 App Router에서는 React Server Components(RSC)를 기반으로 SSR이 동작합니다.
app/디렉터리 내의 컴포넌트는 기본적으로 서버 컴포넌트로 처리되어, 별도 함수 없이도 서버에서 데이터를 fetching할 수 있습니다.
클라이언트에서만 동작해야 하는 컴포넌트는 파일 상단에"use client"지시어를 명시해야 합니다.// app/posts/[id]/page.tsx interface Post { id: number title: string body: string } async function getPost(id: string): Promise<Post> { const res = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`, { cache: 'no-store', // SSR: 캐시 없이 매 요청마다 새로운 데이터 }) return res.json() } export default async function PostPage({ params, }: { params: { id: string } }) { const post = await getPost(params.id) return ( <article> <h1>{post.title}</h1> <p>{post.body}</p> </article> ) }fetch옵션의cache: 'no-store'를 설정하면 매 요청마다 새로운 데이터를 가져오는 동적 렌더링(SSR)이 적용됩니다.
반대로cache: 'force-cache'(기본값)를 사용하면 정적 렌더링(SSG)처럼 동작합니다.
App Router는 이처럼fetch옵션 하나로 렌더링 전략을 세밀하게 제어할 수 있어 매우 유연합니다.SSR vs SSG vs ISR 선택 기준
세 가지 렌더링 전략은 데이터의 특성과 업데이트 빈도에 따라 선택합니다.
실시간 데이터나 사용자별 맞춤 콘텐츠가 필요하다면 SSR이 적합합니다.
반대로 블로그 글이나 공식 문서처럼 변경이 드문 콘텐츠는 SSG나 ISR이 훨씬 효율적입니다.- SSR (
cache: 'no-store'/getServerSideProps): 요청마다 최신 데이터 — 뉴스, 실시간 대시보드, 로그인 유저 전용 페이지 - SSG (
cache: 'force-cache'/getStaticProps): 빌드 시 생성 — 블로그, 마케팅 랜딩, 문서 사이트 - ISR (
revalidate설정): 주기적 재생성 — 상품 목록, 가격 정보, 반주기적 업데이트가 필요한 콘텐츠
실제 프로젝트에서는 하나의 전략만 고집하기보다, 페이지 특성에 따라 혼합해서 사용하는 것이 일반적입니다.
예를 들어 메인 페이지는 ISR, 마이페이지는 SSR, 이용약관 페이지는 SSG로 구성할 수 있습니다.Hydration과 주의사항
SSR로 렌더링된 HTML은 브라우저에 도착한 뒤 JavaScript를 통해 인터랙티브하게 만드는 Hydration 과정을 거칩니다.
서버에서 생성된 HTML과 클라이언트에서 React가 예상하는 DOM이 다를 경우 Hydration 오류가 발생합니다.
대표적인 원인은window,localStorage처럼 서버에서 사용 불가능한 브라우저 API를 서버 컴포넌트에서 호출하는 경우입니다.이를 방지하기 위해 브라우저 전용 로직은 반드시
useEffect내부나"use client"컴포넌트로 분리해야 합니다.
또한 서버와 클라이언트 간 날짜, 난수 등 비결정적 값이 다를 경우에도 Hydration 불일치가 발생하므로 주의가 필요합니다.
Next.js는 이러한 오류를 개발 모드에서 콘솔 경고로 알려주므로, 배포 전에 반드시 확인하는 것이 좋습니다.Next.js 캐싱 시스템 개요
Next.js App Router는 성능 최적화를 위해 네 가지 독립적인 캐시 레이어를 제공합니다.
각 캐시는 동작 위치(서버/클라이언트)와 생명주기(요청 단위/빌드 단위/세션 단위)가 다르기 때문에, 이를 정확히 이해하지 못하면 데이터가 예상치 못하게 오래된 상태로 남을 수 있습니다.
네 가지 캐시를 한 눈에 정리하면 아래와 같습니다.캐시 이름 저장 위치 지속 범위 무효화 방법 Request Memoization 서버 메모리 단일 렌더링 요청 자동 (요청 완료 시) Data Cache 서버 (파일시스템/CDN) 영구 (명시적 무효화 전까지) revalidatePath,revalidateTagFull Route Cache 서버 (파일시스템/CDN) 빌드 결과물 재배포 또는 on-demand revalidation Router Cache 브라우저 메모리 세션 (탭 생존 시간) router.refresh(),revalidatePath네 레이어가 모두 독립적으로 작동하므로, 하나를 무효화해도 다른 레이어에 남은 오래된 데이터가 사용자에게 노출될 수 있습니다.
캐시 관련 버그를 디버깅할 때는 어느 레이어에서 데이터가 굳어 있는지를 먼저 파악하는 것이 핵심입니다.fetch 캐시 옵션 상세 이해
App Router에서
fetch를 호출할 때 사용하는cache옵션과next.revalidate옵션은 Data Cache 레이어를 제어합니다.
각 옵션의 동작 방식을 정확히 알지 못하면 의도치 않은 캐싱이 발생하거나 반대로 캐시 효과를 전혀 누리지 못할 수 있습니다.// 1. force-cache (기본값): 최초 요청 결과를 영구 캐싱 // → 데이터가 변경되어도 명시적 무효화 전까지 오래된 값을 반환 const res1 = await fetch('/api/products', { cache: 'force-cache', }) // 2. no-store: 캐시를 완전히 비활성화 // → 매 요청마다 원본 서버에서 새 데이터를 가져옴 (SSR 동작) const res2 = await fetch('/api/user/profile', { cache: 'no-store', }) // 3. revalidate: 지정한 초(seconds) 단위로 백그라운드 재검증 (ISR 동작) // → 첫 요청 후 60초 동안 캐시 사용, 이후 요청에서 백그라운드 갱신 const res3 = await fetch('/api/news', { next: { revalidate: 60 }, }) // 4. tags: 태그 기반 on-demand 무효화를 위한 태그 지정 const res4 = await fetch('/api/posts', { next: { tags: ['posts'] }, })force-cache는 기본값이기 때문에 옵션을 아무것도 지정하지 않으면 자동으로 캐싱이 적용됩니다.
이 점이 자주 혼란을 유발하는데, 특히 외부 API 응답이 바뀌었는데도 Next.js 서버가 오래된 데이터를 계속 반환하는 문제의 주요 원인입니다.
확실하지 않다면 명시적으로 옵션을 지정하는 습관을 들이는 것이 안전합니다.캐시 무효화 — revalidatePath와 revalidateTag
데이터가 변경되었을 때 특정 경로나 태그에 연결된 캐시를 즉시 비우는 on-demand revalidation을 사용할 수 있습니다.
revalidatePath는 특정 URL 경로의 캐시를,revalidateTag는fetch에 지정한 태그가 달린 모든 캐시를 무효화합니다.
두 함수는 주로 Server Actions나 Route Handlers에서 데이터 변경 작업 직후에 호출합니다.// app/actions/post.ts — Server Action 예시 'use server' import { revalidatePath, revalidateTag } from 'next/cache' export async function createPost(formData: FormData) { const title = formData.get('title') as string // 1. DB에 데이터 저장 await db.post.create({ data: { title } }) // 2-A. 경로 기반 무효화: /posts 페이지의 Full Route Cache 및 Data Cache 초기화 revalidatePath('/posts') // 2-B. 태그 기반 무효화: 'posts' 태그가 달린 모든 fetch 결과 초기화 revalidateTag('posts') }revalidatePath에'layout'타입을 두 번째 인수로 전달하면 해당 경로 아래의 모든 페이지 캐시를 한 번에 무효화할 수 있습니다.
반면revalidateTag는 영향 범위를 태그 단위로 좁힐 수 있어, 연관된 경로가 많을 때 더 효율적입니다.
두 방법을 혼용할 수 있으므로, 경로가 명확할 때는revalidatePath, 데이터 소스 단위로 관리할 때는revalidateTag를 선택하면 됩니다.Router Cache — 클라이언트 캐시 주의사항
Router Cache는 브라우저 메모리에 존재하는 클라이언트 사이드 캐시로, 이전에 방문한 페이지 세그먼트를 저장해 빠른 뒤로 가기/앞으로 가기를 지원합니다.
서버 측 캐시를 무효화해도 Router Cache에 남아 있는 데이터가 사용자에게 먼저 보일 수 있어, 데이터 갱신 후에도 UI가 즉시 반영되지 않는 것처럼 보이는 문제가 발생합니다.
Router Cache의 기본 지속 시간은 정적 페이지 5분, 동적 페이지 30초이며, Next.js 15부터는 기본값이 0초(비활성화)로 변경되었습니다.'use client' import { useRouter } from 'next/navigation' export function RefreshButton() { const router = useRouter() const handleClick = () => { // Router Cache를 즉시 비우고 서버에서 새 데이터를 가져옴 router.refresh() } return <button onClick={handleClick}>최신 데이터 불러오기</button> }router.refresh()를 호출하면 현재 경로의 Router Cache가 무효화되고 서버 컴포넌트가 재렌더링됩니다.
Server Action 실행 후 화면이 즉시 갱신되지 않을 때 이 방법이 유효합니다.
단,router.refresh()는 전체 페이지를 새로고침하는 것이 아니라 서버 컴포넌트만 재요청하므로 클라이언트 상태(useState, 폼 입력값 등)는 유지됩니다.캐싱 사용 시 반드시 지켜야 할 주의사항
Next.js 캐싱은 강력하지만, 잘못 사용하면 민감한 데이터 노출이나 데이터 불일치 같은 심각한 문제로 이어집니다.
아래 사항들은 실제 프로젝트에서 자주 발생하는 캐시 관련 문제와 그 해결 원칙입니다.1. 사용자별 데이터에 캐시를 적용하지 말 것
캐시는 기본적으로 모든 사용자에게 동일한 결과를 반환합니다.
로그인한 사용자의 프로필, 장바구니, 알림 목록 등 개인화된 데이터를force-cache로 캐싱하면, A 사용자의 데이터가 B 사용자에게 노출될 수 있습니다.
사용자별 데이터는 반드시cache: 'no-store'를 사용하거나, 쿠키/세션 토큰을 포함한 요청에 캐시 태그를 사용자 ID 기반으로 분리해야 합니다.// 잘못된 예: 사용자 데이터를 공유 캐시에 저장 const profile = await fetch('/api/me', { cache: 'force-cache' }) // 위험! // 올바른 예: 캐시 비활성화 const profile = await fetch('/api/me', { cache: 'no-store' })2. cookies(), headers() 호출 시 자동으로 동적 렌더링 전환됨을 인지할 것
cookies()나headers()함수를 서버 컴포넌트 내에서 호출하면, Next.js는 해당 컴포넌트를 자동으로 동적 렌더링(SSR)으로 처리합니다.
이때 같은 컴포넌트 트리 내의fetch호출도cache: 'no-store'처럼 동작할 수 있습니다.
의도치 않게 전체 페이지가 동적으로 전환되어 성능이 저하될 수 있으므로,cookies()를 사용하는 컴포넌트는 가능한 한 트리의 하위 레벨로 격리하는 것이 좋습니다.3. 동일한 URL이라도 요청 헤더가 다르면 별도 캐시 엔트리로 처리되지 않음을 주의할 것
Next.js의 Data Cache는 기본적으로 URL을 키로 캐시를 식별합니다.
인증 토큰이나 Accept-Language 헤더가 달라도 같은 URL이면 동일한 캐시 엔트리를 반환할 수 있습니다.
이를 방지하려면fetch요청에 사용자별 식별자를 URL 파라미터로 포함하거나,cache: 'no-store'로 캐시 자체를 비활성화해야 합니다.4. revalidate 설정이 중복될 경우 가장 짧은 값이 우선 적용됨
하나의 라우트 세그먼트 안에서 여러
fetch호출에 다른revalidate값이 설정되어 있으면, Next.js는 그 중 가장 짧은 값을 해당 세그먼트 전체에 적용합니다.
예를 들어 한 페이지에서 60초와 3600초 revalidate를 각각 설정했다면, 페이지 전체가 60초 주기로 재검증됩니다.
이 동작을 예상하지 못하면 불필요하게 잦은 서버 요청이 발생하므로, 세그먼트 단위로export const revalidate = N을 명시적으로 선언하는 방법도 고려할 수 있습니다.5. 개발 환경에서는 캐시가 대부분 비활성화됨을 유의할 것
next dev로 실행한 개발 서버에서는 Data Cache와 Full Route Cache가 기본적으로 비활성화되어 있습니다.
덕분에 개발 중에는 항상 최신 데이터를 볼 수 있지만, 이는 프로덕션 환경과 동작이 다르다는 의미이기도 합니다.
캐싱 동작을 정확히 검증하려면next build && next start로 프로덕션 빌드를 직접 실행해 확인해야 합니다.6. unstable_cache 사용 시 직렬화 가능한 값만 캐싱 가능
unstable_cache는 데이터베이스 쿼리나 외부 SDK 호출처럼fetch를 사용하지 않는 비동기 함수의 결과를 Data Cache에 저장할 수 있게 해줍니다.
단, 캐싱되는 반환값은 반드시 JSON 직렬화가 가능한 형태여야 합니다.
Date 객체, Map, Set, 클래스 인스턴스, 함수 등은 직렬화가 불가능하므로, 이를 반환하면 런타임 오류가 발생하거나 예상과 다른 값이 복원될 수 있습니다.import { unstable_cache } from 'next/cache' // 올바른 예: 순수 객체/배열 반환 const getCachedPosts = unstable_cache( async () => { const posts = await db.post.findMany() // Date 객체를 문자열로 변환 후 반환 return posts.map((p) => ({ ...p, createdAt: p.createdAt.toISOString() })) }, ['posts'], { revalidate: 3600, tags: ['posts'] } )마무리
SSR은 SEO와 초기 로딩 성능이 중요한 서비스에서 강력한 도구입니다.
Next.js는 Pages Router의getServerSideProps부터 App Router의 React Server Components까지, 다양한 방식으로 SSR을 지원합니다.
렌더링 전략은 "무조건 SSR"이 아니라 페이지별 데이터 특성을 분석해 SSG, ISR과 조합하는 것이 성능과 비용 면에서 최선입니다.
캐싱 레이어는 성능 향상의 핵심이지만, 사용자 데이터 보호와 데이터 신선도를 함께 고려해야 올바르게 활용할 수 있습니다.
Next.js 공식 문서의 Caching 섹션과 Data Fetching 섹션을 함께 참고하면 각 전략의 동작 방식을 더 깊이 이해할 수 있습니다. - SSR (