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 이 그 값을 정상 갱신으로 판단하고 데이터가 있던 캐시를 덮어썼습니다. env 가 없을 때 throw 해서 이전 prerender 데이터를 유지했다면 적어도 빈 페이지는 표시되지 않았을 것입니다. 다음에 SSR/ISR 사이트가 배포 직후에는 정상이다가 revalidate 시간쯤 지나 데이터만 비어 있다면, 런타임 로그에서 server-only env 가 undefined 인지부터 확인하면 됩니다.

이 문제는 같은 인프라에서 모듈 싱글톤 커넥션 풀이 인스턴스마다 복제되던 문제 와 원인이 같습니다. 서버리스에서는 "빌드 시점" 과 "런타임 인스턴스" 가 서로 다른 실행 맥락입니다.

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