donghakim.dev — zsh
← ls ../blog

배포 직후 5분만 멀쩡하고 빈 페이지가 되던 Amplify SSR

콘솔 환경변수는 빌드 시점에만 주입되고 SSR 런타임에는 안 들어온다

배포 직후 5분만 멀쩡하고 빈 페이지가 되던 Amplify SSR · cover

Next.js(App Router) + Amplify Hosting SSR 로 띄운 사이트가 이상한 주기로 깨졌다.

  • 배포 직후 약 5분간은 정상. 동적 데이터가 잘 보인다.
  • 5분이 지나면 그 데이터만 빈 화면. 레이아웃·정적 텍스트는 멀쩡하고, 외부에서 가져오는 카드 데이터만 사라진다.
  • 다음 배포를 하면 다시 5분 정상. 반복.

"5분" 이 단서였다. 이 사이트는 ISR(revalidate: 60 류) 로 외부 콘텐츠 API(예: Notion) 데이터를 캐싱하고 있었고, 5분은 캐시가 stale 로 넘어가 백그라운드 재검증이 처음 도는 시점과 맞물려 있었다.

데이터를 가져오는 코드는 이랬다.

const token = process.env.CONTENT_API_TOKEN;
if (!token || !dbId) return [];   // ← 토큰 없으면 빈 배열을 "정상 결과" 로 반환

빈 배열을 정상으로 반환하니 ISR 은 그걸 멀쩡한 갱신 결과로 보고 캐시에 덮어쓴다. 그래서 5분 후 빈 페이지가 된다. 그런데 왜 토큰이 없을까. 콘솔엔 분명히 넣어 뒀는데.

원인은 단순했다. Amplify Console 에 등록한 환경변수는 빌드 시점에만 주입된다. SSR/ISR 을 실행하는 Lambda 런타임에는 자동으로 전달되지 않는다. Amplify Hosting Gen1 SSR 의 알려진 문제다.

그래서 변수 종류에 따라 운명이 갈린다. NEXT_PUBLIC_* 는 Next.js 가 빌드 때 client 번들에 값을 inline 하니 어디서든 접근된다. 반면 CONTENT_API_TOKEN 같은 server-only 변수는 빌드 시점 process.env 엔 있어서 SSG prerender 는 정상이고(그래서 배포 직후엔 잘 보인다), Lambda 런타임의 process.env 엔 없어서 ISR 백그라운드 갱신이나 동적 SSR 시점엔 undefined 다.

CloudWatch 에 환경변수 존재 여부를 찍어 보니 명확했다.

[init] env presence:
  CONTENT_API_TOKEN = false (len=0)
  NEXT_RUNTIME      = nodejs
  AWS_LAMBDA_FUNCTION_NAME = Compute-...
  env_keys = 43          ← AWS_* 등은 43개나 있는데

AWS_* 같은 런타임 기본 변수는 수십 개인데 우리 server-only 변수만 통째로 빠져 있었다. cold start race 가 아니라 런타임 자체에 안 들어온 거다. 빌드 때 prerender 된 페이지는 토큰이 있어서 잘 만들어졌고(그래서 처음 5분 정상), 이후 ISR 갱신은 매번 토큰 없이 돌아 빈 결과를 캐시에 덮었다.

어떻게 고쳤나

server-only 변수를 런타임 번들 안으로 들여보내야 한다. 가장 단순한 방법은 .env.production 을 레포에 커밋하는 것이다. Next.js 가 빌드 때 이 파일을 읽어 server 번들에 inline 하므로 런타임 process.env 로 접근된다.

# .gitignore: .env* 는 무시하되 .env.production 만 화이트리스트
.env*
!.env.production
# .env.production
CONTENT_API_TOKEN=...
CONTENT_DB_ID=...
NEXT_PUBLIC_MAP_KEY=...

새 변수가 생기면 이 파일에 한 줄 추가하면 끝이고, 콘솔을 따로 만질 필요가 없다.

amplify.yml 빌드 단계에서 환경변수를 .env.production 으로 dump 하는 방법도 되긴 한다.

# 작동은 하지만 버림
- env | grep -E '^(CONTENT_|NEXT_PUBLIC_)' >> .env.production

작동은 하는데 새 변수마다 grep 패턴도 같이 고쳐야 하고 콘솔에도 등록해야 해서 두 곳을 동기화해야 한다. .env.production 커밋이 더 단순하다.

다만 이건 private 레포 전제다. public 으로 전환하면 그 즉시 secret 이 노출되고, git history 에도 영구히 남는다. secret 회전이 필요하면 history 정리(BFG 등)를 따로 봐야 한다. 민감도가 높은 secret 이면 .env 커밋 대신 런타임에서 시크릿 매니저로 가져오는 쪽이 맞다. 노출 확인은 간단하다. curl -I https://<도메인>/.env.production 이 404 면 정적 서빙 대상이 아니라는 뜻이다(public/ 밖이라 노출 안 됨).


사실 이 사고를 키운 건 silent return(return []) 이었다. env 가 없을 때 빈 배열을 정상 결과로 돌려주니, ISR 이 그걸 멀쩡한 갱신으로 믿고 좋은 캐시를 덮어썼다. 실패는 실패답게 throw 해서 이전 prerender 데이터를 유지했으면 적어도 빈 페이지보다는 나았을 거다. 다음에 SSR/ISR 사이트가 배포 직후엔 멀쩡하다가 revalidate 시간쯤 지나 데이터만 빈다면, 런타임 로그에서 server-only env 가 undefined 인지부터 보면 된다. 콘솔 등록만으로는 SSR 런타임에 안 닿는다.

이 문제는 같은 인프라에서 모듈 싱글톤 커넥션 풀이 인스턴스마다 복제되던 문제 와 뿌리가 같다. 서버리스에서는 "빌드 시점" 과 "런타임 인스턴스" 가 서로 다른 세계다. env 든 싱글톤이든 그 경계를 넘는다고 가정하는 순간 깨진다.

#amplify#env#isr#lambda#nextjs#process-env#serverless#ssr#troubleshooting