← Ascendy EN

frontend

빌드타임 env vs 런타임 env — 같은 함정에 두 번 빠진 로그인 장애

· Ascendy Engineering


TL;DR

배경 — 첫 번째 502

production 로그인이 502로 죽었다. pod 환경변수는 정확히 설정돼 있었는데도. 원인은 ingress의 host-split 구조였다 — 앱과 API가 다른 호스트로 분리돼 있는데, 클라이언트가 same-origin 상대경로 /api로 호출하니 요청이 frontend로 라우팅돼 502가 났다. backend는 요청 자체를 받지 못했다.

invisible failure 신호: backend 로그가 0줄인데 클라이언트는 502를 받는다면, frontend → frontend self-loop를 의심하라.

복구 경로는 둘이었다 — (A) ingress에 path 규칙 추가(분 단위 복구, 하지만 host-split 디자인을 흐림, infra 작업), (B) 클라이언트가 backend 호스트를 직접 호출(host-split 보존, 빌드+배포 cycle 필요). 시간 여유가 있어 B를 택했다. axios baseURL을 build-time 상대경로에서 runtime env 기반 절대 URL로 옮겼다.

// axios client (excerpt) — env-driven baseURL, dev fallback
const isNative = Capacitor.isNativePlatform()
const config = useRuntimeConfig()
const webApiBase = (config.public.apiBase as string) || '/api'
const baseURL = isNative ? (config.public.nativeApiUrl as string) : webApiBase

두 번째 함정 — 같은 원인, 다른 얼굴

로그인이 backend에 도달하기 시작했다. 그런데 이번엔 RECAPTCHA_REQUIRED. 추적해보니 같은 build-time vs runtime 함정이었다.

reCAPTCHA 스크립트는 nuxt.config.tshead.script 배열로 주입되고 있었다. 그런데 nuxt.config.ts빌드 시점에 evaluate된다. CI Docker 빌드 컨테이너는 .env를 받지 않는다(gitignored + dockerignored). 그래서 build-time process.env.<...>_SITE_KEY는 빈 값이고, <script src=...>가 prod HTML <head>emit조차 되지 않았다. pod의 runtime 환경변수는 useRuntimeConfig().public으로 읽히지만, 그 시점엔 이미 HTML이 빌드돼 굳은 뒤다.

해결: 빌드 시점 head.script 대신, runtime client plugin에서 useRuntimeConfig로 site key를 읽어 주입한다. 빌드 의존성이 사라진다.

// runtime client plugin — third-party 스크립트 주입 (build 의존성 없음)
import { Capacitor } from '@capacitor/core'
const SRC = 'https://www.google.com/recaptcha/api.js'

export default defineNuxtPlugin(() => {
  if (Capacitor.isNativePlatform()) return          // web 전용
  const siteKey = useRuntimeConfig().public.recaptchaSiteKey as string
  if (!siteKey || typeof window === 'undefined') return
  if ((window as any).grecaptcha) return             // idempotent 가드
  if (document.querySelector(`script[src^="${SRC}"]`)) return
  const s = document.createElement('script')
  s.src = `${SRC}?render=${siteKey}`
  s.async = true
  document.head.appendChild(s)
})

결정 / 트레이드오프

build-time env를 쓰려면 Docker build args + CI secret + Dockerfile ARG를 더하는 길도 있었다. 버린 이유: ① secret 노출 surface 증가 ② site key 변경 때마다 재빌드 ③ build env와 runtime env를 두 곳에 sync해야 함. runtime injection은 pod env 한 곳만 보면 된다.

원칙으로 정리하면 — build-time이 필요한 것(preload hint, critical CSS, 빌드에 고정돼야 하는 head src)과 runtime이 필요한 것(API URL, feature flag, third-party site key)을 의식적으로 분리한다. runtimeConfig.public은 server-injected라 클라이언트에서 deploy-time 값이 보이지만, nuxt.config.ts의 정적 부분은 빌드 시점에 굳어 같은 env에서도 다른 결과를 낸다.

Capacitor 네이티브 앱과 web을 같은 Nuxt 코드베이스로 운영한다면, isNativePlatform() 분기로 web 전용 third-party 스크립트를 건너뛰는 패턴이 네이티브 리뷰가 외부 도메인을 의심하지 않게 하는 부수 효과도 있다.

후속


저작·인용: 이 글은 Ascendy Engineering이 작성했으며 출처 표기 시 재인용 가능합니다. 잘못된 정보를 발견하면 GitHub 이슈로 알려주세요.


Tags: nuxt3, recaptcha, env, build-vs-runtime, capacitor