
Next.js 사이드 프로젝트를 운영하다 보면 초기에는 Vercel의 편리함에 감탄하지만, 서비스 규모가 커지거나 인프라 통합이 필요해질 때 다른 플랫폼으로의 이전을 고민하게 됩니다.
이번 글에서는 Next.js 서비스를 Vercel에서 Google Cloud Run으로 마이그레이션하면서 겪었던 기술적인 고민들과 해결 방법, 특히 Docker 빌드 최적화와 ISR 캐시 공유 문제를 어떻게 해결했는지 공유합니다.
Cloud Run은 컨테이너 기반의 서버리스 플랫폼입니다. 따라서 가장 먼저 해야 할 일은 Next.js 애플리케이션을 Docker 이미지로 만드는 것입니다. 저희는 빌드 속도와 이미지 크기를 모두 잡기 위해 Bun과 Next.js의 standalone 모드를 활용했습니다.
Node.js 대신 oven/bun 이미지를 베이스로 사용하여 패키지 설치 시간을 단축했습니다. 또한 빌드 스테이지(builder)와 실행 스테이지(runner)를 분리하여, 실제 런타임에는 불필요한 파일들을 제외했습니다.
# Build stage
FROM oven/bun:1.3 AS builder
WORKDIR /app
# ... 환경변수 설정 ...
COPY package.json bun.lock ./
RUN bun install --no-save --frozen-lockfile
COPY . .
RUN bun run build
# Runtime stage
FROM oven/bun:1.3-alpine AS runner
WORKDIR /app
# ... 사용자 설정 ...
# Standalone 결과물만 복사
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
CMD ["bun", "server.js"]
Next.js의 Standalone output을 사용하면 프로덕션 배포에 필요한 파일만 별도로 모아줍니다. node_modules 전체를 복사하는 대신, 실제 사용되는 의존성만 추려내기 때문에 이미지 크기를 획기적으로 줄일 수 있습니다.
const nextConfig: NextConfig = {
// ...
...(process.env.BUILD_OUTPUT === 'standalone' && {
output: 'standalone',
// 모노레포 환경 등에서 필요한 패키지 트랜스파일
transpilePackages: ['@t3-oss/env-nextjs', '@t3-oss/env-core'],
}),
}
Vercel을 쓸 때는 고민할 필요가 없었던 부분입니다. Vercel은 Edge Network가 알아서 정적 페이지와 ISR(Incremental Static Regeneration) 캐시를 관리해 줍니다. 하지만 Cloud Run은 Stateless 컨테이너입니다. 인스턴스가 여러 개로 스케일 아웃되면, A 인스턴스에서 생성한 캐시를 B 인스턴스는 알 수 없습니다.
이를 해결하기 위해 Next.js의 Custom Cache Handler 기능을 사용하여 캐시 저장소를 외부(Redis)로 뺐습니다.
서버리스 환경에서는 Redis Connection Pool 관리가 까다로울 수 있어, HTTP 기반으로 통신하는 Upstash Redis를 선택했습니다. cache-handler.js를 작성하여 Next.js가 캐시를 읽고 쓰는 방식을 재정의했습니다.
const TAG_PREFIX = 'next:tag:'
module.exports = class UpstashCacheHandler {
async get(key) {
// Redis에서 키 조회
const result = await upstashPipeline([['GET', key]])
// ... 파싱 로직 ...
}
async set(key, data, ctx) {
// 데이터 저장 및 태그(Tag) 관리
const payload = JSON.stringify(data)
const commands = [['SET', key, payload]]
// on-demand revalidation을 위한 태그 매핑 저장
const tags = ctx?.tags || []
for (const t of tags) {
commands.push(['SADD', tagKey(t), key])
}
await upstashPipeline(commands)
}
async revalidateTag(tag) {
// 태그에 해당하는 모든 키를 찾아 삭제
const members = await upstashPipeline([['SMEMBERS', tagKey(tag)]])
// ... 삭제 로직 ...
}
}
그리고 next.config.ts에서 이 핸들러를 사용하도록 설정합니다.
...(hasCacheStore && {
cacheHandler: join(fileURLToPath(new URL('.', import.meta.url)), 'cache-handler.js'),
cacheMaxMemorySize: 0, // 인메모리 캐시 비활성화 (전적으로 Redis 의존)
}),
이제 어떤 인스턴스가 요청을 받든 Redis를 통해 동일한 캐시 데이터를 바라보게 됩니다.
Next.js 애플리케이션을 도커라이징할 때 주의할 점은 NEXT_PUBLIC_으로 시작하는 환경변수입니다. 이 변수들은 빌드 타임에 자바스크립트 번들에 인라인으로 포함(baked-in)됩니다. 따라서 Docker 이미지를 빌드하는 시점에 해당 값들이 주입되어야 합니다.
Google Cloud Build를 사용하여 배포 파이프라인을 구성했으며, Dockerfile에 ARG를 선언하고 Cloud Build 설정에서 이를 넘겨주는 방식을 택했습니다.
steps:
- name: gcr.io/cloud-builders/docker
args:
- build
- '--build-arg'
- 'NEXT_PUBLIC_BACKEND_URL=$_NEXT_PUBLIC_BACKEND_URL'
# ... 기타 환경변수들 ...
- .
이렇게 하면 런타임에 환경변수를 바꾸는 것은 불가능하지만(이미지 다시 빌드 필요), 클라이언트 사이드 코드에 환경변수가 확실하게 주입됨을 보장할 수 있습니다.
Vercel은 훌륭한 플랫폼이지만, 직접 인프라를 제어해야 하는 시점이 오면 Cloud Run은 훌륭한 대안이 됩니다.
이 세 가지를 통해 Vercel 못지않은 성능과 경험을 Cloud Run 위에서도 구현할 수 있었습니다. 특히 캐시 핸들러를 직접 구현해 보면서 Next.js의 내부 동작 원리를 더 깊이 이해하게 된 것은 덤이었습니다.
| 온디멘드 컴퓨팅 | CPU (vCPU-hr) | 메모리 (GB-hr) | 호출 횟수 (1M) |
|---|---|---|---|
| Vercel | $0.1690 | $0.0140 | $0.6 |
| GCP Cloud Run (요청 기반) | $0.0864 [50h] | $0.0090 [100GiB] | $0.4 [2M] |
| Cloudflare Workers | $0.0720 [매일 0.28h] | 무료 | $0.3 [매일 0.1M] |
| GCP Cloud Run (인스턴스 기반) | $0.0648 [66h] | $0.0072 [125GiB] | 무료 |
| Mac Studio | $0.0030 (=4원) | 무료 | 무료 |