Next.js 앱을 배포할 때, 여러분이 구축한 인프라 환경에 맞춰 다양한 기능들이 어떻게 처리될지 설정하고 싶을 수 있습니다.
🎥 영상으로 보기: Next.js 셀프 호스팅에 대해 더 자세히 알아보세요 → YouTube (45분).
직접 호스팅을 할 때, Next.js 서버를 인터넷에 직접 노출시키기보다는 그 앞에 리버스 프록시(Nginx 등)를 두는 것을 강력히 권장합니다.
리버스 프록시는 잘못된 형식의 요청, 느린 연결을 악용한 공격(Slowloris 등), 페이로드 크기 제한, 요청 속도 제한(Rate limiting) 및 기타 여러 보안 문제를 대신 처리해 줍니다. 이렇게 하면 Next.js 서버가 요청의 유효성을 검사하는 데 힘을 빼지 않고, 본연의 임무인 '렌더링'에만 리소스를 집중할 수 있게 되죠.
💡 강사의 보충 설명 & 팁:
리버스 프록시란 클라이언트(브라우저)와 찐 서버(Next.js) 사이에서 문지기 역할을 해주는 서버를 말해요. 실무에서는 주로 Nginx나 Apache, 혹은 클라우드 환경의 Load Balancer(AWS ALB 등)를 사용합니다.
Next.js 앱을 구동할 때 보통pm2같은 프로세스 매니저로 포트 3000번 등에 띄워두고, Nginx가 80(HTTP)이나 443(HTTPS) 포트로 들어오는 요청을 받아서 3000번 포트로 토스(Proxy)해주는 방식을 가장 많이 씁니다. 이렇게 하면 SSL 인증서 관리나 정적 파일 캐싱도 Nginx 단에서 훨씬 효율적으로 처리할 수 있답니다!
next/image를 통한 이미지 최적화 기능은 next start 명령어를 사용하여 배포할 때 아무런 추가 설정 없이도 자체 호스팅 환경에서 잘 작동합니다. 만약 이미지를 최적화하는 별도의 외부 서비스를 선호하신다면, 이미지 로더를 설정할 수도 있습니다.
이미지 최적화는 next.config.js에서 커스텀 이미지 로더를 정의하여 정적 내보내기(Static Export) 방식에서도 사용할 수 있습니다. 단, 이미지는 빌드 타임이 아니라 런타임(실행 중)에 최적화된다는 점을 기억해 주세요.
알아두면 좋은 점:
- glibc 기반의 Linux 시스템에서는 과도한 메모리 사용을 방지하기 위해 이미지 최적화에 추가적인 설정이 필요할 수 있습니다.
- 최적화된 이미지의 캐싱 동작과 TTL(수명)을 어떻게 설정하는지 알아보세요.
- 원한다면 이미지 최적화 기능을 비활성화할 수도 있습니다. 기능은 끄더라도
next/image컴포넌트가 제공하는 다른 이점들은 여전히 누릴 수 있어요. 예를 들어, 이미지를 직접 따로 최적화하고 있는 경우에 유용합니다.
💡 강사의 보충 설명 & 팁:
Next.js의 이미지 최적화는 내부적으로sharp라는 라이브러리를 사용해서 이미지를 압축하고 WebP나 AVIF로 변환해요. 이 작업이 은근히 CPU와 메모리를 많이 잡아먹습니다. 그래서 트래픽이 엄청나게 많은 서비스라면 Next.js 서버가 뻗어버릴 수도 있어요. 규모가 큰 실무 환경에서는 이미지 처리만 전담하는 별도의 서버(또는 AWS CloudFront + Lambda@Edge 조합)를 두고 커스텀 로더를 연결하는 방식을 많이 씁니다!
프록시(Proxy) 기능은 next start로 배포할 때 추가 설정 없이 자체 호스팅 환경에서 작동합니다. 이 기능은 들어오는 요청(Incoming request)에 접근해야 하기 때문에 정적 내보내기(Static Export)를 사용할 때는 지원되지 않아요.
프록시는 짧은 지연 시간(Low latency)을 보장하기 위해 사용 가능한 모든 Node.js API의 부분 집합인 Edge 런타임(Edge runtime)을 사용합니다. 애플리케이션의 모든 라우트나 에셋(Asset) 앞단에서 실행될 수 있기 때문이죠. 만약 이 방식이 싫다면, 전체 Node.js 런타임을 사용해 프록시를 실행할 수도 있습니다.
모든 Node.js API가 필요한 로직을 추가하거나 외부 패키지를 사용하고 싶다면, 이 로직을 서버 컴포넌트(Server Component) 형태의 레이아웃(Layout)으로 옮기는 방법을 고려해 보세요. 예를 들어, 헤더(headers)를 확인하거나 리다이렉트(redirect)하는 작업 말이죠. 또한 헤더, 쿠키, 쿼리 파라미터를 사용해 next.config.js를 통한 리다이렉트(redirect)나 https://www.wordreference.com/enko/rewrite(https://nextjs.org/docs/app/api-reference/config/next-config-js/rewrites#header-cookie-and-query-matching)도 가능합니다. 이 방법들로도 해결이 안 된다면 커스텀 서버(custom server)를 구축할 수도 있습니다.
Next.js는 빌드 타임(Build time)과 런타임(Runtime) 환경 변수를 모두 지원합니다.
기본적으로 환경 변수는 서버에서만 사용할 수 있습니다. 환경 변수를 브라우저(클라이언트)에 노출시키려면 이름 앞에 반드시 NEXT_PUBLIC_을 붙여야 해요. 하지만 이렇게 만들어진 공개 환경 변수는 next build 과정 중에 생성되는 JavaScript 번들 파일 안에 텍스트 그대로 인라인(포함)되어 버립니다.
서버에서는 동적 렌더링(Dynamic rendering)을 하는 동안 환경 변수를 안전하게 읽어올 수 있습니다.
//filename="app/page.ts" switcher
import { connection } from 'next/server'
export default async function Component() {
await connection()
// 쿠키, 헤더 및 기타 동적 API들도 동적 렌더링으로 전환하게 만듭니다.
// 즉, 이 환경 변수는 빌드 타임이 아닌 런타임에 평가(계산)됩니다.
const value = process.env.MY_VALUE
// ...
}
//filename="app/page.js" switcher
import { connection } from 'next/server'
export default async function Component() {
await connection()
// 쿠키, 헤더 및 기타 동적 API들도 동적 렌더링으로 전환하게 만듭니다.
// 즉, 이 환경 변수는 빌드 타임이 아닌 런타임에 평가(계산)됩니다.
const value = process.env.MY_VALUE
// ...
}
이렇게 하면 단일 Docker 이미지를 만들어 두고, 여러 환경(개발, 스테이징, 운영 등)에서 각기 다른 환경 변수 값을 주입하며 승격(Promote)시켜 나갈 수 있어요.
알아두면 좋은 점:
register함수를 사용하면 서버가 시작될 때 특정 코드를 실행할 수 있습니다.
💡 강사의 보충 설명 & 팁:
여기서 Docker 이미지를 하나로 돌려쓴다(promote)는 개념이 정말 실무에서 핵심입니다! 프론트엔드 앱을 빌드할 때 API 주소 같은 걸NEXT_PUBLIC_으로 박아버리면, 개발 서버용으로 빌드한 이미지를 운영 서버에서 못 쓰게 돼요(주소가 하드코딩 되어버리니까요).
하지만 서버 컴포넌트를 사용하고 위 코드처럼 런타임에 환경 변수를 읽도록 설계하면, 빌드는 딱 한 번만 해서 이미지를 만들고, Docker 실행 시점에만process.env.MY_VALUE에 운영용 값을 넘겨주면 되기 때문에 배포 파이프라인이 훨씬 깔끔하고 안전해집니다.
Next.js는 응답, 생성된 정적 페이지, 빌드 결과물, 그리고 이미지나 폰트, 스크립트 같은 정적 에셋들을 캐싱할 수 있습니다.
캐싱 및 페이지 재검증(ISR, Incremental Static Regeneration)은 동일한 공유 캐시를 사용합니다. 기본적으로 이 캐시는 Next.js 서버의 파일 시스템(디스크)에 저장됩니다. 이 기능은 자체 호스팅 환경에서 Pages 라우터와 App 라우터 모두 자동으로 작동합니다.
만약 캐시된 페이지나 데이터를 영구 스토리지에 보존하고 싶거나, 여러 개의 컨테이너나 여러 Next.js 서버 인스턴스 간에 캐시를 공유하고 싶다면 Next.js 캐시 저장 위치를 변경할 수 있습니다.
Cache-Control 헤더를 public, max-age=31536000, immutable로 설정합니다. 이 값은 덮어쓸 수 없어요. 이러한 불변 파일들은 파일 이름에 SHA-해시값을 포함하고 있기 때문에 무기한 캐싱해도 안전합니다. (예: 정적 이미지 임포트). 이미지의 경우 TTL을 별도로 설정할 수 있습니다.Cache-Control 헤더를 s-maxage: <getStaticProps에서의 revalidate 값>, stale-while-revalidate로 설정합니다. 이 재검증(revalidate) 시간은 초 단위로 getStaticProps 함수 내에서 정의됩니다. 만약 revalidate: false로 설정하면 기본적으로 1년의 캐시 기간을 갖게 됩니다.Cache-Control 헤더를 private, no-cache, no-store, max-age=0, must-revalidate로 설정합니다. 이는 App 라우터와 Pages 라우터 모두에 적용되며, Draft Mode(초안 모드)도 포함됩니다.정적 에셋들을 다른 도메인이나 CDN에서 호스팅하고 싶다면, next.config.js의 assetPrefix 설정을 사용할 수 있습니다. Next.js는 JavaScript나 CSS 파일을 불러올 때 이 에셋 접두사(asset prefix)를 사용하게 됩니다. 단, 에셋을 다른 도메인으로 분리하면 DNS와 TLS 확인 과정에서 추가 시간이 소요되는 단점이 있다는 걸 알아두세요.
기본적으로 생성된 캐시 에셋들은 메모리(기본값 50MB)와 디스크에 저장됩니다. 만약 Kubernetes와 같은 컨테이너 오케스트레이션 플랫폼을 사용해 Next.js를 호스팅한다면, 각 파드(Pod)가 개별적인 캐시 복사본을 가지게 됩니다. 기본적으로 파드 간에 캐시가 공유되지 않기 때문에 사용자에게 오래된(Stale) 데이터가 보이는 것을 방지하려면, 커스텀 캐시 핸들러를 제공하고 인메모리 캐시를 비활성화하도록 Next.js 캐시를 설정할 수 있습니다.
자체 호스팅 시 ISR/Data 캐시의 위치를 설정하려면, next.config.js 파일에 커스텀 핸들러를 설정하면 됩니다:
//filename="next.config.js"
module.exports = {
cacheHandler: require.resolve('./cache-handler.js'),
cacheMaxMemorySize: 0, // 기본 인메모리 캐싱 비활성화
}
그런 다음, 프로젝트의 루트 경로에 cache-handler.js 파일을 생성합니다. 예를 들면 다음과 같습니다:
//filename="cache-handler.js"
const cache = new Map()
module.exports = class CacheHandler {
constructor(options) {
this.options = options
}
async get(key) {
// 이 부분은 영구 스토리지 등 어디서든 저장된 데이터를 가져오도록 구현할 수 있습니다.
return cache.get(key)
}
async set(key, data, ctx) {
// 이 부분 역시 영구 스토리지 등 어디든 데이터를 저장하도록 구현할 수 있습니다.
cache.set(key, {
value: data,
lastModified: Date.now(),
tags: ctx.tags,
})
}
async revalidateTag(tags) {
// tags는 문자열이거나 문자열 배열일 수 있습니다.
tags = [tags].flat()
// 캐시에 있는 모든 항목을 순회합니다.
for (let [key, value] of cache) {
// 값의 tags 중에 지정된 태그가 포함되어 있다면, 해당 항목을 삭제합니다.
if (value.tags.some((tag) => tags.includes(tag))) {
cache.delete(key)
}
}
}
// 다음 요청이 오기 전에 리셋되는 단일 요청용 임시 메모리 캐시가 필요하다면
// 이 메서드를 활용할 수 있습니다.
resetRequestCache() {}
}
커스텀 캐시 핸들러를 사용하면 Next.js 애플리케이션을 호스팅하는 모든 파드 간에 데이터의 일관성(Consistency)을 보장할 수 있습니다. 예를 들어, Redis나 AWS S3와 같은 외부 저장소에 캐시 값을 저장할 수 있죠.
알아두면 좋은 점:
revalidatePath는 캐시 태그 기능 위에 만들어진 편리한 래퍼(Layer)입니다.revalidatePath를 호출하면 제공된 페이지에 대한 특별한 기본 태그와 함께revalidateTag함수가 내부적으로 호출됩니다.
💡 강사의 보충 설명 & 팁:
이 부분이 대규모 서비스 환경(Kubernetes 환경 등)에서 Next.js를 호스팅할 때 가장 골치 아픈 문제 중 하나입니다.
유저가 A서버(Pod)에서 글을 수정하고 재검증(revalidate)을 트리거해도, B서버는 여전히 자신의 로컬 디스크에 있는 옛날 데이터를 보여줄 수 있거든요. 이를 해결하기 위해 방금 배운cacheHandler를 사용해서 Redis 같은 외부 캐시 저장소(DB)를 바라보게 설정하는 것이 실무 아키텍처의 정석입니다.
Next.js는 next build 과정 중에 현재 서비스되고 있는 애플리케이션의 버전을 식별하기 위한 고유한 ID를 생성합니다. 다수의 컨테이너를 띄워 서비스할 때는 반드시 이 동일한 빌드 결과물을 사용해서 구동(Boot up)해야 합니다.
만약 개발, 스테이징, 운영 등 환경별 단계마다 새로 빌드를 하고 있다면, 컨테이너들 사이에서 일관되게 사용할 수 있는 빌드 ID를 강제로 생성해 주어야 합니다. 이때 next.config.js에서 generateBuildId 명령어를 사용하세요:
//filename="next.config.js"
module.exports = {
generateBuildId: async () => {
// 최신 git 해시값 등을 사용해 무엇이든 ID로 지정할 수 있습니다.
return process.env.GIT_HASH
},
}
여러 서버 인스턴스(예: 로드 밸런서 뒤에 있는 여러 개의 컨테이너들)에 걸쳐 Next.js를 실행할 때는, 일관된 동작을 보장하기 위해 추가적으로 고려해야 할 사항들이 있습니다.
Next.js는 서버 함수(Server Function)의 클로저 변수들을 클라이언트로 보내기 전에 암호화합니다. 기본적으로 매 빌드마다 고유한 암호화 키가 새로 생성되죠.
여러 서버 인스턴스를 실행할 때는, 모든 인스턴스가 반드시 동일한 암호화 키를 사용해야 합니다. 그렇지 않으면 어떤 인스턴스에서 암호화한 서버 함수를 다른 인스턴스가 복호화(해독)하지 못해서, "Failed to find Server Action"이라는 에러를 뿜어내게 됩니다.
이 문제는 NEXT_SERVER_ACTIONS_ENCRYPTION_KEY 환경 변수를 사용해서 일관된 암호화 키를 설정하여 해결하세요. 이 키는 유효한 AES 키 길이(16, 24, 32 바이트)를 가지는 base64 인코딩 값이어야 합니다. Next.js는 기본적으로 32바이트 키를 생성합니다.
NEXT_SERVER_ACTIONS_ENCRYPTION_KEY=여러분이-생성한-키 next build
이 키는 빌드 결과물에 내장되어 런타임에 자동으로 사용됩니다. 자세한 내용은 데이터 보안 가이드(Data Security guide)를 참고하세요.
롤링 배포(Rolling deployments, 무중단 배포의 한 방식) 중에 발생할 수 있는 버전 꼬임(Version skew) 현상을 방지하려면 deploymentId를 설정하세요. 이렇게 하면 클라이언트가 항상 일관된 배포 버전의 에셋을 받아갈 수 있게 보장해 줍니다.
앞서 설명했듯, 기본적으로 Next.js는 인스턴스 간에 공유되지 않는 인메모리 캐시를 사용합니다. 일관된 캐싱 동작을 위해서는 외부 스토리지에 데이터를 저장하는 커스텀 캐시 핸들러(custom cache handler)와 함께 'use cache: remote' 디렉티브를 사용하세요.
여러 인스턴스에 걸쳐 직접 호스팅을 하거나 롤링 배포를 진행할 때, 버전 불일치(version skew)는 다음과 같은 문제를 일으킬 수 있습니다:
Next.js는 이러한 버전 불일치를 감지하고 처리하기 위해 deploymentId를 사용합니다. 배포 ID가 설정되면 다음과 같이 동작합니다:
?dpl=<deploymentId> 쿼리 파라미터가 포함됩니다.x-deployment-id 헤더가 포함됩니다.만약 두 ID가 일치하지 않는 것을 감지하면, Next.js는 클라이언트 측 네비게이션을 수행하는 대신 하드 네비게이션(Hard navigation, 전체 페이지 새로고침)을 강제로 트리거합니다. 이를 통해 클라이언트가 일관된 최신 배포 버전에서 에셋을 다시 가져오도록 보장하는 것이죠.
module.exports = {
deploymentId: process.env.DEPLOYMENT_VERSION,
}
알아두면 좋은 점: 애플리케이션이 새로고침될 때, 페이지 이동 간에 유지되도록 설계되지 않은 애플리케이션 상태(State)는 유실될 수 있습니다. URL 상태나 로컬 스토리지(local storage)는 유지되지만,
useState같은 컴포넌트 내부 상태는 초기화됩니다.
💡 강사의 보충 설명 & 팁:
"롤링 배포"란 서버 10대가 있다면 2대씩 순차적으로 새 버전으로 교체하는 방식이에요. 이 과정에서 유저는 아직 구버전의 JS 파일을 브라우저에 띄워놓고 있는데 서버는 이미 새 버전으로 다 교체되었을 수 있죠. 이때 유저가 버튼을 눌러 새 페이지로 이동하려 하면, 예전 JS 파일이 요청하는 주소가 새 서버엔 없을 수 있습니다. 이것이 "버전 불일치(Version Skew)"에요! 이럴 때 Next.js가 똑똑하게 "어? 너 구버전이네? 강제로 새로고침해서 최신 파일 다운받아라!" 라고 처리해주는 멋진 기능이랍니다.
Next.js App 라우터는 자체 호스팅 환경에서도 스트리밍 응답(streaming responses)을 훌륭하게 지원합니다. 만약 Nginx나 유사한 프록시 서버를 사용 중이라면, 스트리밍이 제대로 작동할 수 있도록 프록시 서버의 버퍼링(buffering) 기능을 비활성화해야 합니다.
예를 들어, next.config.js에서 헤더를 조작하여 X-Accel-Buffering 값을 no로 설정함으로써 Nginx의 버퍼링을 비활성화할 수 있습니다:
module.exports = {
async headers() {
return [
{
source: '/:path*{/}?',
headers: [
{
key: 'X-Accel-Buffering',
value: 'no',
},
],
},
]
},
}
💡 강사의 보충 설명 & 팁:
스트리밍이란 화면을 조각조각 내서 완성되는 순서대로 유저에게 빠르게 쏴주는 기술(Suspense의 핵심)이에요. 그런데 중간에 있는 Nginx가 "데이터가 다 모일 때까지 기다렸다가 한방에 줘야지!(버퍼링)" 해버리면 스트리밍의 의미가 완전히 사라져 버리겠죠? 그래서 명시적으로 버퍼링을 꺼주는 설정이 꼭 필요합니다.
캐시 컴포넌트(Cache Components)는 Next.js에서 기본적으로 작동하며, Vercel 같은 CDN 환경에서만 쓸 수 있는 기능이 아닙니다. next start를 통한 Node.js 서버 배포 환경이나 Docker 컨테이너를 사용할 때도 완벽하게 지원됩니다.
Next.js 애플리케이션 앞단에 CDN을 사용할 때, 동적인(Dynamic) API에 접근하게 되면 해당 페이지 응답 헤더에 Cache-Control: private가 포함됩니다. 이는 생성된 HTML 페이지가 다른 사람과 공유되지 않도록(캐시되지 않도록) 표시하는 역할을 합니다. 반면에 페이지가 완전히 정적(Static)으로 사전 렌더링(prerendered)되었다면, 페이지가 CDN에 캐시될 수 있도록 Cache-Control: public 헤더가 포함됩니다.
만약 애플리케이션에 정적 컴포넌트와 동적 컴포넌트를 섞어 쓸 필요가 전혀 없다면, 라우트 전체를 정적으로 만들고 결과물인 HTML을 CDN에 통째로 캐싱해 버릴 수 있습니다. 이렇게 동적 API가 쓰이지 않을 때 작동하는 '자동 정적 최적화(Automatic Static Optimization)'는 next build를 실행할 때 발생하는 기본 동작입니다.
부분 사전 렌더링(Partial Prerendering, PPR) 기능이 안정화(Stable) 단계로 접어들면, 배포 어댑터(Deployment Adapters) API를 통해 더 확장된 지원을 제공할 예정입니다.
after 함수 사용하기after 기능은 next start를 사용하여 자체 호스팅할 때 완벽하게 지원됩니다.
서버를 중지할 때는 SIGINT나 SIGTERM 신호를 보내고 기다림으로써 우아한 종료(graceful shutdown)를 보장해야 합니다. 이렇게 해야 Next.js 서버가 after 내부에 사용된 보류 중인(pending) 콜백 함수나 프로미스(promises)들이 끝날 때까지 기다린 후 안전하게 종료될 수 있습니다.
💡 강사의 보충 설명 & 팁:
after함수는 유저에게 응답을 보낸 "후"에 백그라운드에서 실행하고 싶은 작업(로그 전송, 분석 데이터 저장 등)을 처리할 때 써요. 만약 우아한 종료 처리를 안 해두면 서버를 끌 때 백그라운드 작업이 완료되지 않고 강제로 날아가버릴 수 있습니다. PM2나 Docker를 통해 서버를 내릴 때 강제 종료(SIGKILL)가 아닌 정상 종료 신호를 주도록 인프라 셋팅을 잘 하셔야 합니다!
모든 문서에 대한 의미론적 개요는 사이트맵(Sitemap)을 참조하세요.
사용 가능한 모든 문서의 색인은 LLMs용 인덱스를 참조하세요.