production 컨테이너에 env가 보이는데도 브라우저의 Mixpanel Session Replay 설정은 바뀌지 않았다. 처음에는 런타임 환경 변수 누락을 의심했지만, 실제 경계는 컨테이너 시작 시점이 아니라 next build 시점이었다. NEXT_PUBLIC_ 값은 runtime config가 아니라 client bundle에 inline되는 build artifact라는 점을 기준으로 GitHub Actions build arg, Dockerfile ARG/ENV, 실제 browser bundle을 순서대로 확인했다.
요약
- 문제: production 컨테이너에는 env가 보이는데, 브라우저에서 실행되는 Mixpanel Session Replay 설정은 바뀌지 않았다.
- 판단: 컨테이너 runtime env가 아니라 image build pipeline을 먼저 봐야 하는 문제였다.
- 원인:
NEXT_PUBLIC_값은 Next.js client bundle에 build time에 박히므로, runtime env만 바꿔서는 이미 빌드된 JS가 바뀌지 않는다. - 배운 점: 프론트엔드 env 문제는 서버에 들어가
printenv를 보는 것으로 끝나지 않는다. 최종 사용자가 받는 bundle이 어떤 값으로 만들어졌는지 확인해야 한다.
상황
Place2Page에 Mixpanel Session Replay 설정을 붙이면서 production flag 하나가 문제를 만들었다.
원하는 동작은 단순했다. production에서 특정 NEXT_PUBLIC_ flag를 켜면 브라우저의 Mixpanel Replay 설정이 달라져야 했다. GitHub Actions variable도 넣었고, production 컨테이너에 들어가서 printenv를 보면 값도 있었다.
그런데 브라우저 동작은 바뀌지 않았다.
처음에는 이상했다. 컨테이너에 환경 변수가 있는데 왜 프론트엔드는 모를까?
문제
백엔드 서버라면 runtime env를 읽는다는 말이 자연스럽다. 컨테이너가 시작될 때 환경 변수가 있고, 서버 코드가 요청 시점에 그 값을 읽으면 된다.
하지만 Next.js client code는 다르다.
브라우저에서 실행되는 코드는 컨테이너 안에서 직접 process.env를 읽지 않는다. next build 시점에 만들어진 JavaScript bundle을 내려받아 실행한다.
Next.js 공식 문서도 NEXT_PUBLIC_ 환경 변수는 build time에 client JavaScript bundle 안으로 inline 된다고 설명한다. 즉, NEXT_PUBLIC_은 이름만 보면 runtime public env처럼 느껴지지만, 실제로는 build artifact의 일부가 된다.
그래서 production 컨테이너에서 printenv NEXT_PUBLIC_SOMETHING=true가 보여도, 이미 그 전에 만들어진 JS bundle 안에는 false가 들어가 있을 수 있다.
처음 생각한 가설
처음에는 production env 설정이 잘못됐다고 생각했다. 그래서 서버에 들어가 env를 확인했다.
하지만 컨테이너 runtime env만 확인해서는 부족했다. 중요한 질문은 따로 있었다.
이 값이
next build가 실행되는 시점에 들어갔는가?
Next.js client env 문제를 보면 확인 순서를 바꿔야 한다.
- GitHub Actions가 Docker build에 build arg를 넘기는가?
- Dockerfile이 그
ARG를 받는가? next build가 실행되는 stage에서ENV로 노출되는가?- 실제 production bundle이 그 값으로 만들어졌는가?
- 배포된 브라우저 코드가 새 bundle을 받고 있는가?
runtime container의 printenv는 이 중 일부만 답한다.
확인한 증거
이 문제는 확인 순서가 중요했다. repo에서 단서를 따라가면 왜 runtime env만으로는 부족한지 보인다.
GitHub Actions의 web image build step은 build-args로 client env를 넘긴다.
NEXT_PUBLIC_MIXPANEL_TOKEN=...
NEXT_PUBLIC_MIXPANEL_RECORD_SESSIONS_PERCENT=...
NEXT_PUBLIC_UNMASKED_SESSION_REPLAY=...
APP_ENV=productionDockerfile에서는 이 값들을 ARG로 받고, next build 전에 ENV로 다시 노출한다.
ARG NEXT_PUBLIC_MIXPANEL_TOKEN
ARG NEXT_PUBLIC_MIXPANEL_RECORD_SESSIONS_PERCENT=100
ARG NEXT_PUBLIC_UNMASKED_SESSION_REPLAY=false
ENV NEXT_PUBLIC_MIXPANEL_TOKEN=$NEXT_PUBLIC_MIXPANEL_TOKEN \
NEXT_PUBLIC_MIXPANEL_RECORD_SESSIONS_PERCENT=$NEXT_PUBLIC_MIXPANEL_RECORD_SESSIONS_PERCENT \
NEXT_PUBLIC_UNMASKED_SESSION_REPLAY=$NEXT_PUBLIC_UNMASKED_SESSION_REPLAY
RUN npm run build그리고 client code는 이 값을 process.env.NEXT_PUBLIC_UNMASKED_SESSION_REPLAY에서 읽어 Mixpanel Replay masking 설정을 만든다.
const UNMASKED_SESSION_REPLAY = resolveUnmaskedSessionReplay(
process.env.NEXT_PUBLIC_UNMASKED_SESSION_REPLAY
)따라서 값이 production container runtime에만 있고 npm run build 시점에 없다면, 브라우저 bundle은 예전 값으로 만들어질 수 있다.
커밋 기록도 같은 결론을 가리킨다. 98c38da2 fix(ci): wire NEXT_PUBLIC_MIXPANEL_TOKEN into web image build는 GitHub Actions build arg와 Dockerfile을 같이 바꿨고, fc262431 fix: wire mixpanel replay unmask flag도 workflow, Dockerfile, Mixpanel wrapper, 테스트, 문서를 같이 바꿨다. 즉 수정 지점은 production .env 하나가 아니라 build pipeline 전체였다.
Docker를 다시 보게 된 부분
이 문제를 보면서 Docker의 ARG와 ENV 차이를 다시 봤다.
Docker 공식 문서 기준으로 ARG는 build time에 넘길 수 있는 값이고, ENV는 build stage의 이후 instruction과 container runtime에서 사용할 수 있는 환경 변수다. 둘은 비슷해 보이지만 수명이 다르다.
Next.js client env는 next build가 실행될 때 필요하다. 그러면 GitHub Actions에서 --build-arg로 값을 넘기고, Dockerfile에서 ARG로 받고, build stage에서 ENV NEXT_PUBLIC_...=$NEXT_PUBLIC_... 형태로 노출해야 한다.
반대로 이미 이미지가 만들어진 뒤 production .env에 값을 넣는 것은 서버 runtime에는 의미가 있어도, client bundle에는 늦다.
이 지점이 헷갈리기 쉽다.
- Docker container에는 env가 있다.
- Next.js server code는 그 env를 읽을 수 있다.
- 하지만 browser code는 이미 빌드된 JS만 받는다.
세 문장이 동시에 참일 수 있다.
해결한 방식
디버깅 순서는 이렇게 잡았다.
먼저 GitHub Actions workflow에서 web image build step을 봤다. 문제가 된 NEXT_PUBLIC_ 값이 build args로 넘어가는지 확인했다.
다음으로 Dockerfile을 봤다. build stage에서 해당 값을 ARG로 선언하고, next build가 실행되는 시점에 ENV로 노출되는지 확인했다.
마지막으로 production container와 실제 브라우저 동작을 확인했다. container env만 보고 끝내지 않고, 브라우저에서 Session Replay 관련 event가 의도대로 나오는지까지 확인했다.
결국 수정은 "production env에 값을 추가한다"가 아니라 "이미지 build 시점에 client env를 넣는다"였다.
배운 점
이번 문제는 Docker를 제대로 이해하지 못하면 계속 헷갈릴 수밖에 없었다.
나는 처음에 컨테이너 안에 env가 보이면 충분하다고 생각했다. 하지만 프론트엔드 번들은 컨테이너가 시작될 때 만들어지는 것이 아니라, 이미지가 빌드될 때 만들어진다.
Next.js의 NEXT_PUBLIC_은 public runtime config가 아니라 public build-time constant에 가깝다. 이름에 PUBLIC이 붙었다고 해서 언제든 바꿀 수 있는 값이라는 뜻은 아니다. 브라우저에 공개될 값이라는 뜻에 가깝다.
그래서 Next.js + Docker 조합에서 client env 문제가 생기면, 서버 안에 들어가 printenv만 보는 것으로는 부족하다.
다음에는 이렇게 판단한다
Next.js client env 문제가 다시 나오면 production container의 printenv를 최종 증거로 보지 않는다. 먼저 이 값이 client bundle이 만들어지는 시점에 있었는지 확인한다.
앞으로 Next.js client env를 production에 넣을 때는 체크리스트를 이렇게 가져간다.
- GitHub Actions에서 build arg로 넘겼는가?
- Dockerfile build stage에
ARG가 있는가? next build전에ENV로 노출되는가?- 값이 secret인지 public이어도 되는 값인지 확인했는가?
- 배포 후 실제 브라우저 bundle 동작으로 확인했는가?
특히 마지막이 중요하다. client env 문제는 코드와 컨테이너만 보면 맞아 보일 수 있다. 하지만 최종 사용자는 브라우저 bundle을 실행한다. 결국 그 bundle이 어떤 값으로 만들어졌는지를 확인해야 한다.
