무색
기술블로그
에세이
연구
소개

무색

소프트웨어로 비즈니스의 가능성을 만듭니다. 웹·앱 개발, 음성 AI, 자동화 콘텐츠 제작까지 — 기술이 필요한 곳에 무색이 있습니다.

연락처

contact@museck.com

사업자 정보

상호: 무색

대표: 배성재

사업자등록번호: 577-58-00836

인천광역시 연수구 인천타워대로 323, 에이동 8층 801-802호 AB-132 (송도동, 송도 센트로드)

© 2026 무색. All rights reserved.
개인정보처리방침·이용약관·연락처
INCHEON, KR
Next.js 셀프호스팅 강화 — generative 키 비주얼
museck 만들기
2026. 2. 17.

Next.js 셀프호스팅 강화: tini, Build ID, Version Skew 방지까지

K8s 롤링 업데이트 중에 사용자가 흰 화면을 본다. 구버전 클라이언트가 신버전 서버에 요청을 보냈기 때문이다. Vercel이라면 알아서 해주는 것들을, 셀프호스팅에서는 직접 챙겨야 한다.

Next.js 16 업그레이드 후 공식 셀프호스팅 가이드를 따라 Docker/K8s 배포를 손봤다. tini로 시그널 처리, Build ID 일관화, Version Skew 방지, ISR + Suspense 스트리밍까지 — 프로덕션 수준의 셀프호스팅에 필요한 것들을 한 번에 정리한 기록이다.

전체 구조

강화 포인트를 한눈에 보면 이렇다.

tini: PID 1 시그널 처리

Docker 컨테이너에서 Node.js가 PID 1로 실행되면 SIGTERM 처리가 달라진다. 일반적으로 PID 1 프로세스는 명시적 시그널 핸들러가 없으면 시그널을 무시한다. K8s가 terminationGracePeriodSeconds 후에 SIGKILL을 보내기 전까지, 앱이 graceful shutdown을 할 기회를 놓치는 것이다.

tini를 ENTRYPOINT로 두면 tini가 PID 1이 되고, Node.js 프로세스에 시그널을 올바르게 전달한다.

# Alpine 기반 이미지에서 tini 설치
RUN apk add --no-cache tini

# tini가 PID 1, node가 자식 프로세스
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]

이제 K8s가 SIGTERM을 보내면 tini가 받아서 Node.js에 전달하고, Node.js는 진행 중인 요청을 마무리한 뒤 종료한다. terminationGracePeriodSeconds: 30과 조합하면 롤링 업데이트 시 요청 유실이 없다.

Build ID와 Version Skew 방지

K8s 롤링 업데이트의 본질적 문제가 있다. 새 팟이 올라오고 구 팟이 내려가는 사이, 클라이언트는 구버전 JS 번들을 들고 있고 서버는 신버전이다. Next.js에서는 이걸 Version Skew라고 부른다.

해결책은 두 가지 환경변수다.

# Dockerfile에서 GIT_SHA를 빌드 인자로 받는다
ARG GIT_SHA=dev
ENV GIT_SHA=${GIT_SHA}

# Next.js가 이 값으로 버전을 식별한다
ENV NEXT_DEPLOYMENT_ID=${GIT_SHA}
// next.config.mjs
generateBuildId: async () => process.env.GIT_SHA || undefined,
cacheMaxMemorySize: 0, // K8s 팟별 인메모리 캐시 비활성화

generateBuildId가 GIT_SHA를 Build ID로 사용하고, NEXT_DEPLOYMENT_ID가 클라이언트 요청에 버전 정보를 포함시킨다. 서버가 다른 버전의 요청을 받으면 full page reload를 트리거해서 신버전 번들을 받게 한다.

cacheMaxMemorySize: 0도 중요하다. K8s에서 여러 팟이 각각 인메모리 캐시를 갖고 있으면 팟마다 다른 데이터를 서빙할 수 있다. Redis 같은 외부 캐시를 쓰지 않는다면 인메모리 캐시를 꺼두는 게 안전하다.

public/ 디렉토리 함정

Next.js의 output: 'standalone'는 public/ 디렉토리를 standalone 출력에 포함하지 않는다. 이걸 모르면 배포 후에 파비콘, 이미지, 로봇txt 같은 정적 파일이 전부 404가 된다.

# builder 스테이지에서 runner로 public/ 명시 복사
COPY --from=builder --chown=nextjs:nodejs /app/public ./public

공식 문서에도 나와 있지만, standalone이 알아서 해줄 거라고 생각하기 쉬운 부분이다. Dockerfile 멀티스테이지 빌드에서 잊지 말고 복사해야 한다.

ISR + Suspense 스트리밍

배포 인프라와 함께 렌더링 전략도 정리했다. 페이지 레벨은 ISR, 컴포넌트 레벨은 Suspense 스트리밍으로 나눴다.

// 페이지 레벨: 60초마다 재검증
export const revalidate = 60

// 컴포넌트 레벨: 데이터 로딩 중에도 UI 표시
<Suspense fallback={<Skeleton />}>
  <AsyncDataComponent />
</Suspense>

ISR은 페이지를 미리 렌더링해두고 주기적으로 갱신한다. Suspense는 데이터를 기다리는 동안 스켈레톤을 보여주고, 데이터가 준비되면 실제 컴포넌트를 스트리밍한다. 두 가지를 조합하면 첫 방문에도 빠르고, 데이터가 늦어도 UI가 멈추지 않는다.

배운 것들

  • tini는 선택이 아니다. Docker + K8s 환경에서 Node.js를 돌린다면 PID 1 시그널 처리는 기본이다.
  • Build ID = Git SHA가 정답이다. 빌드마다 랜덤 ID가 생기면 디버깅이 힘들다. 커밋 해시와 1:1 대응되니까 문제 추적이 쉬워진다.
  • Version Skew는 K8s에서만 생기는 문제다. 단일 서버라면 신경 쓸 필요 없다. 하지만 여러 팟이 돌아가는 순간 반드시 챙겨야 한다.
  • standalone 출력의 한계를 알아야 한다. public/ 미포함은 문서에 나와 있지만 쉽게 놓친다. 배포 후 404를 보고야 깨닫는 경우가 많다.

Vercel이 해주는 것들을 직접 챙기는 건 귀찮지만, 그 과정에서 Next.js가 내부적으로 어떻게 동작하는지 이해하게 된다. 셀프호스팅의 진짜 가치는 비용 절감이 아니라 이 이해도에 있다.

자주 묻는 질문

tini 대신 dumb-init을 써도 되나요?

된다. dumb-init도 같은 역할을 한다. tini는 Alpine 패키지 매니저에서 바로 설치할 수 있어서 Docker Alpine 이미지와 궁합이 좋다. 용도와 동작은 동일하니 이미 dumb-init을 쓰고 있다면 굳이 바꿀 필요 없다.

NEXT_DEPLOYMENT_ID 없이 Version Skew를 방지할 방법은?

블루-그린 배포를 쓰면 된다. 신버전이 완전히 올라온 후에 트래픽을 한 번에 전환하면 구버전 클라이언트와 신버전 서버가 공존하는 시간이 없다. 다만 K8s에서 블루-그린은 리소스를 두 배로 사용하므로 트레이드오프가 있다.

cacheMaxMemorySize: 0으로 설정하면 성능이 떨어지지 않나요?

인메모리 캐시가 없으므로 매 요청마다 파일시스템이나 DB를 조회한다. 하지만 ISR 캐시는 파일시스템에 별도로 저장되므로 실제 성능 영향은 크지 않다. 트래픽이 많아서 인메모리 캐시가 필요하다면 Redis 같은 외부 캐시 스토어를 도입하는 게 K8s 환경에서는 올바른 방향이다.

자주 묻는 질문

tini 대신 dumb-init을 써도 되나요?
된다. dumb-init도 같은 역할을 한다. tini는 Alpine 패키지 매니저에서 바로 설치할 수 있어서 Docker Alpine 이미지와 궁합이 좋다.
NEXT_DEPLOYMENT_ID 없이 Version Skew를 방지할 방법은?
블루-그린 배포를 쓰면 된다. 신버전이 완전히 올라온 후에 트래픽을 한 번에 전환하면 구버전 클라이언트와 신버전 서버가 공존하는 시간이 없다.
cacheMaxMemorySize: 0으로 설정하면 성능이 떨어지지 않나요?
ISR 캐시는 파일시스템에 별도로 저장되므로 실제 성능 영향은 크지 않다. 트래픽이 많다면 Redis 같은 외부 캐시 스토어를 도입하는 게 올바른 방향이다.
museck 만들기(14/22)
Prev

Next.js 16 + Tailwind v4 + React Compiler 메가 업그레이드: 한 번에 세 개 올리기

Next

Next.js 16 캐싱 삽질 오디세이: 하루에 12커밋, 세 번의 전략 전환