Cloudflare Workers 환경변수 Secrets 설정 및 접근 안 될 때 해결법

Workers에서 환경변수를 다룰 때, 로컬과 프로덕션의 차이를 간과하는 경우가 많다. env.API_KEY를 콘솔에 찍어보면 undefined가 떡하니 나오고, 분명 대시보드에서 설정했는데 왜 안 되는 건지 한참을 헤맸던 경험 — 아마 Workers 개발자라면 한 번쯤 겪어봤을 거다. 로컬에서는 잘 되다가 배포만 하면 터지는 경우, 반대로 프로덕션에서는 되는데 wrangler dev에서만 안 되는 경우, 심지어 둘 다 안 되는 경우까지. 각각 원인이 다르다.

이 포스트에서는 실제로 마주치는 에러 메시지와 함께, 환경변수가 왜 undefined로 나오는지 원인별로 나누어 정리했다. 급한 분은 목차에서 본인 상황에 맞는 섹션으로 바로 이동하자.

증상 확인: env.SECRET_KEY가 undefined인 상황들

Workers에서 환경변수 접근이 실패하면 대체로 아래 세 가지 형태로 나타난다. 에러 메시지가 친절하지 않아서 원인 파악이 까다로운 편이다.

상황 A — 로컬에서만 undefined

wrangler dev로 로컬 서버를 띄운 뒤 요청을 보내면 환경변수가 전부 undefined로 찍힌다. 대시보드에서 분명 Secret을 등록해뒀는데, 로컬에서는 그 값을 가져오지 못한다. 이건 Cloudflare의 의도된 동작이다. 대시보드나 wrangler secret put으로 등록한 Secret은 원격 환경 전용이라, 로컬 개발 서버에서는 별도 파일이 필요하다.

상황 B — 배포 후 undefined

로컬에서 .dev.vars 파일을 만들어 테스트할 때는 잘 된다. 그런데 wrangler deploy 후 실제 Workers 엔드포인트를 호출하면 undefined가 돌아온다. .dev.vars는 로컬 전용 파일이기 때문에 배포 시 포함되지 않는다.

상황 C — 어디서든 undefined

로컬이든 프로덕션이든 어디서든 undefined가 나온다면, 높은 확률로 Worker 코드의 env 접근 패턴 자체가 잘못된 것이다. 특히 예전 Service Worker 문법에서 Module Worker 문법으로 마이그레이션한 경우 자주 발생한다.

원인 1: 로컬 개발 — .dev.vars 파일 누락

처음 Workers를 시작하면 프로젝트 루트에 .dev.vars라는 파일이 존재하지 않는다. 공식 문서에도 작은 글씨로 언급되어 있지만, 초기 셋업 가이드를 따라가다 보면 놓치기 쉬운 부분이다. 나도 처음에 “대시보드에서 Secret 넣었으니까 당연히 로컬에서도 되겠지”라고 생각했다가 30분을 날렸다.

Wrangler 3.x 기준으로, wrangler dev 실행 시 로컬 환경변수 로딩 순서는 다음과 같다:

  1. wrangler.toml[vars] 섹션 (평문 변수)
  2. .dev.vars 파일 (Secret 용도, [vars]보다 우선)

.dev.vars 파일이 없으면, Secret으로 등록해야 할 값들은 undefined가 된다. wrangler.toml[vars]에는 API 키 같은 민감한 값을 넣으면 안 되니까(Git에 커밋되므로), Secret 전용 파일인 .dev.vars가 반드시 필요하다.

원인 2: 프로덕션 — wrangler secret 미설정

프로덕션 배포 후 undefined가 나오는 가장 흔한 원인이다. .dev.vars에만 값을 넣어두고, 프로덕션 Secret 등록을 깜빡하는 경우다. 특히 CI/CD 파이프라인을 처음 구축할 때 이 실수가 잦다.

Cloudflare Workers의 Secret은 다음 두 가지 방법으로만 프로덕션에 등록된다:

  • wrangler secret put <KEY_NAME> CLI 명령
  • Cloudflare 대시보드 > Workers & Pages > 해당 Worker > Settings > Variables

wrangler deploy는 코드만 배포하지, Secret 값은 건드리지 않는다. 이 점을 모르면 “방금 배포했는데 왜 반영이 안 되지?”라는 혼란에 빠진다. Secret은 한 번 등록하면 재배포와 무관하게 유지되고, 코드 배포와 별개의 라이프사이클을 가진다.

원인 3: Module Worker vs Service Worker 문법 차이

이게 가장 짜증나는 원인이다. 코드 문법 자체가 잘못되어 있으면, 아무리 .dev.varswrangler secret을 완벽히 설정해도 undefined가 나온다.

Cloudflare Workers에는 두 가지 작성 방식이 있다:

Service Worker 문법 (레거시)

// Service Worker 문법 — 전역 변수로 접근
addEventListener('fetch', event => {
  // Secret은 전역 변수로 바인딩됨
  const apiKey = MY_SECRET_KEY; // 전역에서 직접 접근
  event.respondWith(
    new Response(`Key: ${apiKey}`)
  );
});

Module Worker 문법 (현재 권장)

// Module Worker 문법 — env 파라미터로 접근
export default {
  async fetch(request, env, ctx) {
    // env 객체를 통해 접근해야 함
    const apiKey = env.MY_SECRET_KEY;
    return new Response(`Key: ${apiKey}`);
  },
};

두 문법을 섞어 쓰면 제대로 동작하지 않는다. wrangler.toml에서 main 필드가 ES Module 파일을 가리키고 있는데, 코드 안에서는 addEventListener로 전역 바인딩을 기대하면 당연히 undefined이다. Wrangler 3.x 이후로는 Module Worker가 기본이므로, 새 프로젝트라면 반드시 export default 패턴을 사용해야 한다.

흔히 빠지는 함정 하나 더: Module Worker에서 env 파라미터를 구조 분해(destructuring)하면서 오타를 내는 경우이다.

// 잘못된 예 — 파라미터 순서 실수
export default {
  async fetch(request, ctx, env) {
    // ctx와 env 순서가 뒤바뀜!
    // env가 실제로는 ExecutionContext 객체가 됨
    console.log(env.MY_SECRET); // undefined (또는 TypeError)
    return new Response('oops');
  },
};

// 올바른 파라미터 순서
// fetch(request, env, ctx) — request, env, ctx 순서 고정

파라미터 순서가 (request, env, ctx)로 고정되어 있다는 걸 까먹고, ctx를 두 번째에 넣는 실수를 하면 에러 메시지도 없이 조용히 undefined가 나온다. TypeScript를 쓰면 타입 체크에서 잡히지만, 순수 JavaScript로 작성할 때는 발견이 늦어진다.

해결: 로컬 환경변수 세팅 (.dev.vars)

로컬 개발 환경에서 Secret을 사용하려면 프로젝트 루트에 .dev.vars 파일을 생성한다. 형식은 일반적인 .env 파일과 동일하다.

Step 1: 프로젝트 루트에 .dev.vars 파일 생성

# 프로젝트 루트에서 실행
touch .dev.vars

# .gitignore에 추가 (필수!)
echo ".dev.vars" >> .gitignore

Step 2: .dev.vars 파일에 키-값 쌍 입력

# .dev.vars
API_KEY=sk-your-api-key-here
DATABASE_URL=postgresql://user:pass@localhost:5432/mydb
JWT_SECRET=my-local-jwt-secret-value
WEBHOOK_TOKEN=whsec_test_1234567890

주의할 점이 몇 가지 있다:

  • 따옴표로 감싸지 않는다. API_KEY="sk-xxx"처럼 쓰면 따옴표 자체가 값에 포함된다.
  • 등호(=) 앞뒤에 공백을 넣지 않는다.
  • 주석은 #으로 시작하는 줄만 가능하다.
  • 값에 공백이 포함된 경우에도 따옴표 없이 그대로 쓴다.

Step 3: wrangler dev를 재시작하면 자동으로 .dev.vars를 읽는다. 핫 리로드로는 반영되지 않으니, 파일을 수정했다면 반드시 서버를 껐다 켜야 한다.

# wrangler dev 실행 시 .dev.vars 로딩 확인 로그
$ wrangler dev
 ⛅️ wrangler 3.57.0
-------------------
Your worker has access to the following bindings:
- Vars:
  - API_KEY: "(hidden)"
  - DATABASE_URL: "(hidden)"
  - JWT_SECRET: "(hidden)"
  - WEBHOOK_TOKEN: "(hidden)"

터미널에 위처럼 (hidden)으로 표시되면 정상적으로 로딩된 것이다. 바인딩 목록에 원하는 키가 안 보이면 파일명이나 위치를 다시 확인하자.

해결: 프로덕션 Secret 등록 (wrangler secret put)

프로덕션에 Secret을 등록하는 방법은 두 가지인데, CLI를 사용하는 편이 훨씬 편하다.

방법 1: CLI로 등록

# 대화형 입력 (터미널에 값이 노출되지 않음)
$ wrangler secret put API_KEY
 ⛅️ wrangler 3.57.0
-------------------
✔ Enter a secret value: ********
🌀 Creating the secret for the Worker "my-worker"
✨ Success! Uploaded secret API_KEY

# 파이프로 전달 (CI/CD에서 유용)
$ echo "sk-prod-api-key-value" | wrangler secret put API_KEY

# 현재 등록된 Secret 목록 확인
$ wrangler secret list
[
  {
    "name": "API_KEY",
    "type": "secret_text"
  },
  {
    "name": "DATABASE_URL",
    "type": "secret_text"
  }
]

방법 2: 대시보드에서 등록

Cloudflare 대시보드 > Workers & Pages > 해당 Worker 선택 > Settings > Variables and Secrets 탭에서 직접 추가할 수 있다. “Encrypt” 버튼을 눌러야 Secret으로 저장되고, 한번 암호화하면 대시보드에서도 값을 다시 볼 수 없다.

Secret 등록 후에는 재배포가 필요 없다. 등록 즉시 다음 요청부터 반영된다. 이건 되게 반가운 부분인데, Secret 값만 바꾸고 싶을 때 불필요한 배포를 하지 않아도 된다는 의미이다.

wrangler.toml [vars]로 비민감 설정값 관리하기

모든 설정값을 Secret으로 넣을 필요는 없다. API 키나 토큰처럼 민감한 값은 반드시 Secret으로 관리해야 하지만, 로그 레벨이나 기능 플래그처럼 노출되어도 무방한 값은 wrangler.toml[vars] 섹션에 직접 작성하는 게 관리가 수월하다.

# wrangler.toml
name = "my-worker"
main = "src/index.ts"
compatibility_date = "2024-09-23"

[vars]
LOG_LEVEL = "info"
FEATURE_FLAG_NEW_UI = "true"
MAX_RETRY_COUNT = "3"
APP_ENV = "production"

[vars]에 정의한 값은 코드에서 env.LOG_LEVEL처럼 Secret과 동일한 방식으로 접근한다. 차이점은 이 값들이 Git에 커밋된다는 것과, wrangler deploy 시 함께 배포된다는 점이다.

한 가지 주의: [vars]의 모든 값은 문자열 타입이다. MAX_RETRY_COUNT = "3"이라고 써도 코드에서는 문자열 "3"으로 들어온다. 숫자로 사용하려면 parseInt(env.MAX_RETRY_COUNT, 10)처럼 변환이 필요하다.

.dev.vars에 같은 키가 있으면 [vars]보다 .dev.vars가 우선한다. 이 우선순위를 이용해서, 프로덕션에서는 [vars]APP_ENV = "production"이 적용되고 로컬에서는 .dev.varsAPP_ENV=development를 넣어 오버라이드하는 패턴을 쓸 수 있다.

환경별 Secret 분리 전략

프로젝트가 커지면 staging과 production 환경을 분리해야 할 때가 온다. Workers에서는 wrangler.toml의 환경 설정을 통해 이를 처리한다.

# wrangler.toml — 환경별 설정
name = "my-worker"
main = "src/index.ts"
compatibility_date = "2024-09-23"

# 기본(production) 환경 변수
[vars]
APP_ENV = "production"
LOG_LEVEL = "warn"

# staging 환경
[env.staging]
name = "my-worker-staging"
[env.staging.vars]
APP_ENV = "staging"
LOG_LEVEL = "debug"

환경별로 Secret을 따로 등록해야 한다:

# production Secret 등록 (기본)
$ wrangler secret put API_KEY

# staging Secret 등록
$ wrangler secret put API_KEY --env staging

# staging 환경으로 배포
$ wrangler deploy --env staging

# staging Secret 목록 확인
$ wrangler secret list --env staging

처음에 이 구조를 잡지 않으면 나중에 꽤 고생한다. production에 등록한 Secret이 staging에는 없어서 staging 배포 테스트가 실패하는 상황이 반복되거든. 나는 배포 스크립트에 Secret 존재 여부를 체크하는 단계를 넣어둔 뒤로 이런 실수가 사라졌다.

CI/CD 파이프라인(GitHub Actions 등)에서 Secret을 자동 등록하려면, 파이프 방식을 활용한다:

# .github/workflows/deploy.yml (발췌)
- name: Set production secrets
  run: |
    echo "${{ secrets.API_KEY }}" | wrangler secret put API_KEY
    echo "${{ secrets.DATABASE_URL }}" | wrangler secret put DATABASE_URL
  env:
    CLOUDFLARE_API_TOKEN: ${{ secrets.CF_API_TOKEN }}

GitHub Secrets에 저장된 값을 꺼내서 wrangler secret put으로 전달하는 구조이다. 매 배포마다 Secret을 덮어쓰게 되는데, 값이 같으면 실질적인 변경은 없으니 성능 걱정은 안 해도 된다.

최종 체크리스트

환경변수가 undefined로 나올 때, 아래 순서대로 확인하면 대부분 원인을 찾을 수 있다.

확인 항목 로컬 프로덕션
Module Worker 문법 사용 여부 export default + env 파라미터 동일
fetch 핸들러 파라미터 순서 (request, env, ctx) 동일
Secret 값 설정 .dev.vars 파일 wrangler secret put
비민감 변수 설정 [vars] 또는 .dev.vars [vars] in wrangler.toml
.dev.vars가 .gitignore에 포함? 필수
환경 지정 (–env) staging이면 –env staging
wrangler dev 재시작 .dev.vars 변경 후 필수

코드 문법부터 확인하는 이유가 있다. 아무리 Secret 설정을 완벽하게 해도, env 접근 방식이 틀리면 소용이 없기 때문이다. 문법이 맞다면 로컬과 프로덕션 각각의 설정 경로를 점검하면 된다.

마지막으로 한 가지 팁: Workers 코드에 환경변수 디버깅용 엔드포인트를 하나 만들어두면 문제 추적이 훨씬 빨라진다. 다만 Secret 값 자체를 응답에 노출하면 안 되니까, 키 존재 여부만 확인하는 형태로 만들자.

// 디버깅용 엔드포인트 예시 (프로덕션에서는 제거하거나 인증 추가)
export default {
  async fetch(request, env, ctx) {
    const url = new URL(request.url);

    if (url.pathname === '/debug/env') {
      const envCheck = {
        API_KEY: env.API_KEY ? 'SET' : 'MISSING',
        DATABASE_URL: env.DATABASE_URL ? 'SET' : 'MISSING',
        LOG_LEVEL: env.LOG_LEVEL ?? 'NOT_DEFINED',
        APP_ENV: env.APP_ENV ?? 'NOT_DEFINED',
      };
      return new Response(JSON.stringify(envCheck, null, 2), {
        headers: { 'Content-Type': 'application/json' },
      });
    }

    // ... 나머지 라우팅 로직
    return new Response('OK');
  },
};

이 엔드포인트를 호출하면 각 키가 SET인지 MISSING인지 한눈에 보인다. 환경변수 관련 문제가 생겼을 때 실제 값을 건드리지 않고도 빠르게 상태를 파악할 수 있어서, 나는 개발 초기에 꼭 넣어두는 편이다. 다만 프로덕션에 나갈 때는 인증 미들웨어를 붙이거나 아예 제거하는 걸 잊지 말자.