환경 변수 검증은 언제 실행해야 할까요?
10주차에는 기존 CI에 환경 변수 검증을 추가했습니다. 타입 검사와 테스트가 통과하더라도 실행에 필요한 설정이 빠져 있으면 앱은 정상적으로 동작하지 않을 수 있습니다. 이런 오류도 빌드와 배포 과정에서 확인하고 싶었습니다.
처음에는 Zod로 검증 스크립트를 만들고 pnpm build 앞에 붙였습니다. 그런데 개발 서버와 이미 빌드한 서버를 실행하는 경로까지 살펴보니, 검증을 어디에서 호출해야 할지 다시 고민하게 됐어요.
빌드 명령 앞에 붙인 Zod 검증
첫 구현에서는 Zod로 환경 변수의 규칙을 정의했습니다. 앱의 기본 주소인 APP_ORIGIN은 HTTP·HTTPS URL이어야 하고, 인증에 사용하는 AUTH_SESSION_SECRET은 비어 있으면 안 됩니다. 아래는 당시 스크립트에서 로컬 환경의 필수 값 검사와 실패 처리만 추린 예시입니다.
// scripts/week-10-ci/validate-env.mjs — 로컬 검증 흐름을 추린 예시
import nextEnv from '@next/env';
import { z } from 'zod';
const { loadEnvConfig } = nextEnv;
loadEnvConfig(process.cwd(), false);
const requiredValue = z.string('누락').trim().min(1, '공백');
const schema = z.looseObject({
APP_ORIGIN: requiredValue.pipe(
z.url({ protocol: /^https?$/, error: 'http·https URL이 아니다' }),
),
AUTH_SESSION_SECRET: requiredValue,
});
const result = schema.safeParse(process.env);
if (!result.success) {
const problems = result.error.issues.map(
(issue) => `${String(issue.path[0] ?? 'env')}: ${issue.message}`,
);
console.error(`env 검증 실패 — ${problems.join('; ')}`);
process.exit(1);
}이 스크립트는 Next.js보다 먼저 실행되므로 @next/env로 환경 변수 파일을 읽습니다. 검증이 실패하면 변수명과 이유를 출력하고 종료 코드 1로 프로세스를 끝냅니다. 인증 시크릿이 로그에 남지 않도록 실제 값은 출력하지 않았습니다.
배포 환경에는 추가 규칙도 있었습니다. origin이 해당 환경의 배포 주소와 일치하는지 확인하고, 로컬에서만 허용하는 데모 시크릿은 배포에서 거부했습니다. 서버 비밀 변수에 NEXT_PUBLIC_ 접두사를 붙인 설정도 빈 값인지와 관계없이 막았습니다. 초기 구현 코드
CI가 이미 빌드를 실행하고 있었기 때문에, 그 앞에 검증을 연결하면 기존 실패 처리 흐름을 이용할 수 있었습니다.
{
"scripts": {
"dev": "next dev",
"build": "node scripts/week-10-ci/validate-env.mjs && next build",
"start": "next start"
}
}&&로 연결했으므로 검증 스크립트가 성공해야 next build가 시작됩니다. CI에서는 테스트용 환경 변수 파일을 준비하고 같은 명령을 호출했습니다.
- name: Build
id: build
run: |
cp .env.example .env
pnpm build검증에 실패하면 Build step과 해당 job이 실패하고, 필수 검사 결과를 모으는 guard도 실패합니다. 실제로 오류값을 넣은 실험 PR에서는 테스트와 lint, 타입 검사가 통과했지만 빌드 단계의 env 검증이 실패해 병합이 차단됐습니다. 코드 검사와 설정 검사가 서로 다른 오류를 잡고 있었습니다.
여기서 CI와 배포 환경이 사용하는 값은 다릅니다. CI는 .env.example의 테스트용 값으로 실행하고, Vercel은 배포 환경에 저장된 값을 사용합니다. CI가 통과했다고 운영 설정까지 확인한 것은 아니므로, 실제 배포 빌드에서도 해당 환경의 값으로 같은 검증을 실행했습니다. 처음 원했던 CI 게이트는 동작했어요.
빌드에서 확인한 값이 서버의 값과 같을까
구현을 다시 보면서 걸린 건 검증이 pnpm build에만 붙어 있다는 점이었습니다. pnpm dev로 개발 서버를 켜거나, 빌드한 산출물을 pnpm start로 실행하면 위 스크립트를 거치지 않습니다.
두 명령 앞에도 검증을 붙일 수는 있어요. 다만 그러면 서버를 실행하는 경로마다 호출을 연결해야 합니다. 규칙을 한곳에 모아도, 그 규칙이 필요한 곳에서 실행되는지는 계속 제가 챙겨야 했습니다. 앱 실행에 필요한 검증이 특정 명령에 너무 가깝게 붙어 있다는 느낌이 들었어요.
빌드와 서버 실행에 같은 검사 대상을 적용하는 것도 맞지 않았습니다. 이 앱의 AUTH_SESSION_SECRET은 인증 요청을 처리할 때만 사용합니다. 빌드에는 필요하지 않은데 기존 스크립트는 이 값이 없으면 빌드부터 막았습니다. 반대로 빌드가 끝난 뒤 서버에 다른 시크릿을 주입하면, 빌드 당시의 검사로는 새 값이 올바른지 알 수 없습니다.
결국 서버용 설정은 서버가 실제로 사용할 값을 읽은 뒤, 요청을 처리하기 전에 검사해야 했습니다. 그 시점을 모든 실행 명령 앞에서 각각 챙기기보다, 서버가 시작하면서 공통으로 거치는 곳에 연결하고 싶었습니다.
서버 시작 시점에 연결할 방법을 찾았습니다
어디에 연결하면 좋을지 고민하던 중, 이번 주 발제에서 키보 멘토님이 소개해주신 instrumentation이 기억났습니다.
Instrumentation은 앱에 로깅이나 모니터링 도구를 연결해 동작을 관찰하는 것을 말합니다. Next.js에서는 instrumentation.ts에서 register() 함수를 내보내면 새 서버 인스턴스가 시작될 때 호출합니다. 공식 문서는 이 함수가 서버의 요청 처리 준비가 끝나기 전에 완료되어야 한다고 설명합니다. Next.js instrumentation 문서
제가 찾던 건 이 호출 시점이었어요. 서버가 시작할 때 register()를 거친다면 그 안에서 환경 변수를 검사할 수 있습니다. dev와 start 앞에 별도 스크립트를 붙이지 않아도, Next.js가 서버 인스턴스를 시작하면서 검증을 호출해주는 겁니다.
이 방향을 떠올리고 나서 AI에게도 구체적으로 요청할 수 있었습니다. 기존 Zod 검증을 instrumentation에 연결하고, 서버가 잘못된 설정으로 시작하지 못하게 바꿔달라고 했습니다.
빌드용 값과 서버용 값을 나눠 연결하기
모든 검증을 register()로 옮기기 전에, 각 변수를 어디서 읽는지 확인했습니다. 두 주요 변수의 사용처는 다음과 같았습니다.
| 변수 | 빌드에서 사용 | 서버 요청에서 사용 |
|---|---|---|
APP_ORIGIN | 루트 metadata 생성 | 서버의 API 주소 구성 |
AUTH_SESSION_SECRET | 사용하지 않음 | 인증 요청 처리 |
APP_ORIGIN은 서버가 자기 API를 호출할 때뿐 아니라 루트의 metadataBase를 만들 때도 읽습니다. 이 값의 검증을 서버 시작까지 미루면 빌드에서는 잘못된 설정을 놓칠 수 있습니다. 따라서 origin은 빌드와 서버 양쪽에서 검사하고, 인증 시크릿의 필수 검사는 서버에만 두기로 했습니다.
기존 규칙은 src/env/validate.ts로 옮겨 두 함수에서 사용하도록 했습니다. validateBuildEnv()는 origin과 알려진 서버 비밀 변수의 공개 접두 변형을 검사합니다. validateServerEnv()는 여기에 인증 시크릿 검사를 더합니다. 모듈을 import하는 것만으로 실행하지 않고, 함수가 호출될 때의 process.env를 읽도록 했습니다.
서버용 검증은 register에서 실행합니다
서버용 함수는 Node 런타임의 register()에 연결했습니다. 검증이 실패하면 오류를 출력한 뒤 프로세스를 종료합니다.
// src/instrumentation.ts
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { validateServerEnv } = await import('./env/validate');
try {
validateServerEnv();
} catch (error) {
// Next 16.2.10 실측: 예외만 던지면 start 프로세스가 유지됐다.
console.error(
error instanceof Error ? error.message : '서버 env 검증 실패',
);
process.exit(1);
}
}
}process.exit(1)은 실제 실행을 확인하면서 넣은 처리입니다. Next.js 16.2.10에서 next start를 실행했을 때, register()가 예외를 던져도 오류를 로그에 남긴 뒤 프로세스가 유지됐습니다. 예외만 던지면 서버도 종료될 거라는 예상과 달라서, 종료 코드 1로 직접 중단하도록 했습니다.
빌드용 검증은 Next 설정을 읽을 때 실행합니다
빌드용 함수는 next.config.ts에 연결했습니다. Next.js가 전달하는 phase가 개발 서버 또는 프로덕션 빌드일 때 호출합니다.
// next.config.ts
import type { NextConfig } from 'next';
import {
PHASE_DEVELOPMENT_SERVER,
PHASE_PRODUCTION_BUILD,
} from 'next/constants';
import { validateBuildEnv } from './src/env/validate';
export default function nextConfig(phase: string): NextConfig {
if (phase === PHASE_DEVELOPMENT_SERVER || phase === PHASE_PRODUCTION_BUILD) {
validateBuildEnv();
}
return {};
}이제 next build를 실행하면 설정 로딩 과정에서 빌드용 검증이 실행됩니다. 빌드에 필요한 값이 잘못되면 여기서 실패하고, 서버를 시작할 때는 register()가 서버용 값을 확인합니다.
값을 읽는 시점에 따라 검증을 나누고, 각 진입점은 Next.js가 호출하도록 연결했습니다. 그림을 누르면 크게 볼 수 있습니다.
package.json의 세 실행 명령은 dev: next dev, build: next build, start: next start로 정리했습니다. 환경 변수 파일도 Next.js가 로딩하므로 별도 CLI와 그 CLI에서 사용하던 @next/env 직접 의존성은 제거했습니다. Zod는 서버 실행에도 필요해져 기존 버전 그대로 dependencies로 옮겼습니다.
NEXT_PUBLIC_만으로 검사 대상을 나눌 수 없는 이유
검증을 호출하는 시점은 프레임워크에 맡겼지만, 어느 변수를 어느 시점에 검사할지는 여전히 직접 정하고 있습니다. 이 부분도 NEXT_PUBLIC_ 접두사로 자동 분류할 수 있을까요? 접두사가 있으면 빌드에서, 없으면 서버 시작 시 검사하도록 나누는 방식입니다.
공개 변수의 빌드 검사를 선택하는 기준으로는 사용할 수 있습니다. process.env.NEXT_PUBLIC_…처럼 직접 참조한 값은 Next.js가 빌드할 때 번들에 넣기 때문입니다. 다만 접두사가 없다고 런타임에서만 읽는 것은 아닙니다. 빌드 중 실행되는 서버 코드에서도 비공개 환경 변수를 읽을 수 있습니다. Next.js 환경 변수 문서
앞서 본 APP_ORIGIN이 그 예입니다. AUTH_SESSION_SECRET과 마찬가지로 접두사가 없지만, metadata 생성 때문에 빌드에서도 필요합니다. 접두사가 없는 변수를 모두 서버 시작 시에만 검사하면 origin의 빌드 검증이 빠집니다.
따라서 자동화하더라도 빌드 검사 대상은 공개 변수에 더해, 빌드에서 사용하는 비공개 변수를 별도로 지정해야 합니다. 현재 이 앱에는 NEXT_PUBLIC_ 변수가 없고 주요 검사 대상도 두 개여서, 사용처에 따라 명시적으로 나누는 편이 간단합니다. 변수 이름만으로 사용 시점까지 추론하는 규칙은 추가하지 않았습니다.
CI에서도 빌드한 서버를 실행해 검증하기
서버용 검증을 빌드에서 빼면 CI도 달라져야 해요. CI가 빌드만 한다면 인증 시크릿 검증은 더 이상 실행되지 않으니까요. register()에 검증을 연결하는 것과 CI 게이트에서 그 결과를 확인하는 것은 별개의 일이었습니다.
그래서 빌드가 성공한 뒤, 그 산출물로 실제 next start를 실행하는 검사를 추가했습니다. 이 검사가 실패하면 같은 job과 필수 guard가 실패합니다.
- name: Server env lifecycle
id: env-lifecycle
if: ${{ !cancelled() && steps.build.outcome == 'success' }}
run: node scripts/week-10-ci/check-env-lifecycle.mjs검사하는 동작은 네 가지입니다. 빈 시크릿, 잘못된 origin, 서버 비밀 변수의 공개 접두 변형을 각각 주입했을 때 종료 코드 1로 끝나야 해요. 정상 값으로 실행했을 때는 실제 API가 기대한 응답을 반환해야 하고요. 검사 도구가 타임아웃으로 프로세스를 강제 종료한 경우는 검증 성공으로 인정하지 않았습니다.
정상 기동의 기준도 Ready 로그에서 API 응답으로 바꿨습니다. Next.js 16.2.10에서는 register()의 검증이 완료되기 전에 Ready 로그가 나올 수 있었기 때문입니다. /api/auth/me를 호출해 비로그인 상태의 응답인 HTTP 401과 {"message":"로그인이 필요합니다."}를 함께 확인했습니다. 이 검사는 앱의 모든 기능이 정상이라는 보장은 아니지만, 적어도 서버가 시작되어 해당 요청을 처리하는지는 확인할 수 있습니다.
배포에서도 빌드 성공과 요청 처리를 구분합니다
CI에서 서버를 실행해도 검사하는 값은 여전히 테스트용 값입니다. 실제 배포 환경의 설정은 배포한 서버에 요청해서 확인해야 했습니다. 특히 Vercel의 후보 배포는 빌드가 성공해 READY 상태가 되어도, 서버용 값이 잘못되어 요청 처리에 실패할 수 있었습니다.
공유 환경 설정을 바꾸지 않고 배포별로 오류값을 주입해 확인한 결과는 다음과 같습니다.
| 오류를 넣은 위치 | 빌드 결과 | 요청과 배포 검사 결과 |
|---|---|---|
Preview 빌드의 APP_ORIGIN | 실패 | 빌드 중단 |
| Preview 런타임의 인증 시크릿 | 성공, 배포 상태 READY | 동적 API 500, 후보 검사 실패 |
| Production 후보 런타임의 인증 시크릿 | 성공, 배포 상태 READY | 동적 API 500, 승격 미실행 |
서버용 시크릿에는 로컬에서만 허용하는 데모 기본값을 주입했습니다. 빌드는 통과했지만 서버가 실제 값을 읽는 시점에 검증이 실패했고, 런타임 로그에서 오류와 종료 코드 1을 확인했습니다. 빌드 성공만 확인했다면 통과했을 후보입니다.
Production에서는 --prod --skip-domain으로 운영 도메인에 연결하지 않은 후보를 만들고, 후보의 인증 API 응답을 검사한 뒤 같은 배포를 승격하도록 했습니다. 여기서도 HTTP 401만 보면 배포 보호 페이지의 응답을 통과시킬 수 있어 앱의 JSON 본문까지 대조했습니다. 오류 후보는 승격하지 않아 기존 운영 배포가 유지됐고, 정상 후보는 API 검사와 main 최신성 확인을 거친 뒤 동일 배포가 승격되는 것까지 확인했습니다. Production 검증·승격 실행 기록
검증 함수가 실제로 호출되는지도 확인합니다
검사 코드를 보완하는 과정에서는 빌드 검증 호출을 지워보기도 했습니다. 그런데 기존 테스트 25개가 그대로 통과했어요. 검증 함수에 값을 넣으면 어떤 결과가 나오는지는 확인하고 있었지만, Next.js가 그 함수를 호출하는지는 보지 못하고 있었던 거죠.
저장소의 next.config.ts와 검증 모듈을 복사한 최소 앱에서 실제 next build를 실행하도록 검사를 보완했습니다. 같은 호출을 제거하자 이번에는 오류 사례 두 개가 실패했습니다. 배포 검사도 CLI를 직접 실행하는 테스트로 바꾸고, 응답 판정 호출을 제거하면 실패하는지 확인했습니다. 실행 연결 검사 보완 기록
이번 변경에서 지키고 싶었던 건 올바른 검증 규칙뿐 아니라, 빌드와 서버 시작이 그 검증을 실제로 거친다는 점이었습니다. 그래서 함수 테스트에 더해 실행 경로도 확인해야 했습니다.
알아야 요청할 수 있는 방향
지난 8주차 글에서는 모든 테스트가 jsdom에서 실행되는 설정을 발견하고, 아는 것이 있어야 AI가 만든 결과를 의심할 수 있다고 느꼈습니다. 이번에는 그 지식이 수정 방향을 떠올리는 데도 필요했어요.
처음에는 검증이 빌드 명령에 너무 붙어 있다는 느낌만 있었습니다. 키보 멘토님의 발제에서 instrumentation을 듣지 않았다면 계속 dev나 start 앞에 스크립트를 붙이는 방법만 고민했을 것 같아요. 서버가 시작할 때 호출되는 함수가 있다는 걸 알고 있었기 때문에, AI에게도 그 시점에 검증을 연결해달라고 요청할 수 있었습니다.
구현이 어색한 이유를 찾는 데서 한 걸음 더 나아가, 다른 구조를 시도할 수 있었던 경험입니다. AI와 함께 개발해도 제가 접해본 것의 범위는 여전히 중요했어요. 같은 코드를 보고도 떠올릴 수 있는 선택지가 달라졌으니까요.
그래서 기술 블로그와 공식 문서를 읽고, 다른 개발자의 이야기를 들으면서 꾸준히 인풋을 늘리려 합니다. 당장 쓸 일이 없는 기능도 어떤 문제에 활용할 수 있는지는 알아두려고 해요. 새로운 문제를 만났을 때 사용법을 다시 찾아볼 수 있도록, 먼저 떠올릴 수 있는 방향을 넓혀두고 싶습니다.