Vite로 만든 앱을 Cloudflare Workers에 올리면서 서버 전용 코드를 클라이언트 번들에서 떼어내려고 import.meta.env.SSR 같은 가드를 쓰는 개발자라면, 이 글의 사건이 낯설지 않을 것입니다. 이 글은 제 다른 프로젝트인 개인 영상 워크플로 앱(Cloudflare Workers와 D1 사용)의 2026-07-24~25 기록값을 따라가며, 테스트 3,668개가 모두 통과한 상태에서 배포본만 깨진 이유를 정리합니다. 이 수치는 이 블로그 저장소의 상태가 아니라 그 프로젝트의 기록입니다.

답은 이렇습니다. 가드는 Vite가 빌드하는 loader와 action 경로에서는 정상이었고, Vitest도 import.meta.env를 정의해 주므로 전부 통과했습니다. 그러나 wrangler(esbuild)가 번들하는 worker 진입점인 queue consumer, Workflow, Durable Object, cron 경로에서는 그 표현식이 치환되지 않은 채 번들에 남았습니다.

다루지 않는 것도 먼저 적습니다. 서버 모듈이 클라이언트 번들에 딸려 나가는 일반적인 원인은 클라이언트 번들 누출 글에서 이미 다뤘으므로 여기서는 반복하지 않습니다. 한계도 결론 앞에 둡니다. 기록에는 이 결함이 실제 프로덕션 큐나 워크플로 요청에서 한 번이라도 발현됐는지가 없고, 가드가 어떤 workerd 버전에서 어떤 TypeError를 냈는지 재현 로그도 없습니다. 아래 내용은 배포 전 리뷰가 차단한 결함과 그 수정·검증 기록이며, 피해 규모를 주장하지 않습니다. 기록상 2차 최적화는 코딩 에이전트 3개 레인에 병렬로 맡겨졌고, 리뷰는 코드 리뷰 에이전트가 맡았습니다.

같은 소스, 다른 번들러

앱 코드는 한 저장소에 있지만 실행 경로마다 코드를 만드는 도구가 다릅니다. UI의 loader와 action은 Vite가 빌드합니다. Vite의 환경 변수 문서(조회일 2026-10-30, 페이지 표기 v8.3.4)는 import.meta.env의 내장 상수가 “defined as global variables during dev and statically replaced at build time to make tree-shaking effective.”라고 설명합니다. 같은 문서는 import.meta.env.SSR을 “whether the app is running in the server.”를 뜻하는 boolean 값으로 정의합니다.

worker 진입점은 다른 도구로 묶입니다. queue consumer, Workflow, Durable Object, cron은 wrangler가 esbuild로 번들합니다. 기록은 이 구간에서도 Vite의 정적 치환이 적용됐다고 확인하지 않았습니다. 여기서 드리는 해석은 필자의 분석입니다. Vite 문서가 말한 “빌드 시 정적 치환”은 Vite가 빌드하는 코드에 대한 설명이고, 별도 번들러가 묶는 진입점에 같은 치환이 보장된다는 말이 아닙니다. Vite 문서가 이 상황을 직접 언급하지는 않습니다.

Wrangler 명령 문서의 deploy 절(조회일 2026-10-30)은 --dry-run을 “Compile a project without actually deploying to live servers.”로, --outdir을 “Path to directory where Wrangler will write the bundled Worker files.”로 설명합니다. 같은 절은 둘을 함께 쓰면 npx wrangler deploy의 출력을 테스트하는 데도 쓸 수 있다고 적습니다. 이 글이 확인할 수 있는 것은 여기까지입니다. 기록은 산출물을 실제로 빌드해 검사했다는 사실만 남기고, 검사 구현의 세부는 남기지 않았습니다.

그림은 세 실행 경로를 기록 기준으로 옮긴 것입니다. 칸마다 근거 수준이 다릅니다. 문서 설명, dry-run 관찰, 리뷰 판정을 본문에서 따로 구분합니다.

UI loader와 action 경로는 Vite 빌드에서 치환되어 정상 동작하고, worker 진입점 queue, Workflow, Durable Object, cron 경로는 wrangler 번들에 치환되지 않은 표현식이 남으며, Vitest 경로는 테스트 환경이 값을 정의해 통과하는 세 실행 경로를 나열한 개념도
세 실행 경로의 도구와 기록상 결과를 나열한 개념도입니다. 실제 실행 화면이 아닙니다. worker 칸의 잔존 표현식 2건은 dry-run 산출물에서 확인했다는 기록을 따랐습니다.

가드가 들어가고 퍼진 순서

기록은 날짜순으로 네 단계를 남깁니다. 날짜와 수치는 기록에 적힌 그대로 옮겼습니다.

  • 2026-07-24, 1차 패스. 서버 전용 모듈을 클라이언트 번들에서 떼는 작업이 진행됐습니다. prompt-versions.ts는 SSR 가드 뒤의 동적 import로 바뀌었습니다. 기록상 이 방식은 fail-closed이며, 이 모듈을 가져오는 곳이 30개가 넘어 파일 이름을 바꾸는 대신 가드를 택했습니다. 빌드 검증에서 클라이언트 번들의 프롬프트 청크는 제거됐습니다.
  • 2026-07-25, 병합과 배포. 1차 패스가 main에 병합되고 worker가 배포됐습니다. 기록은 이 가드가 이 시점에 이미 프로덕션 배포본에 들어 있었다고 적습니다. 배포 뒤 /login의 SSR 렌더와 콘솔 에러 0건을 확인했습니다.
  • 2026-07-25, 2차 최적화. 같은 날 D1 복합 인덱스, 쿼리 병렬화, 스키마 DDL 문자열의 클라이언트 청크 격리, 자산 캐시가 함께 들어갔습니다. 기록상 이 최적화는 코딩 에이전트 3개 레인에 병렬로 맡겨졌습니다. 이 과정에서 1차의 가드 패턴이 ensureD1Schema 전역으로 복제되어, 기록의 표현으로는 결함이 닿는 범위가 커졌습니다.
  • 2차 배포 전 리뷰. 코드 리뷰 에이전트가 배포 차단 블로커를 냈습니다. worker 진입점에서 import.meta.env.SSR이 치환되지 않아, queue consumer, Workflow, Durable Object, cron 경로의 첫 D1 접근에서 TypeError가 날 수 있다는 판단이었습니다.

1차 패스의 목적은 정당했고 성과도 기록에 있습니다. 기록의 빌드 검증에서 클라이언트 번들의 프롬프트 템플릿 청크(12.7KB, gzip 4.4KB)가 완전히 사라졌고, 시스템 프롬프트 텍스트는 클라이언트 번들에 0건, 서버에만 존재했습니다. 이 결과는 클라이언트 번들만 본 검증입니다. 같은 변경이 worker 진입점의 번들에 어떤 영향을 주는지는 이 검증이 묻지 않은 질문이었습니다. 검증 범위가 목적과 같은 방향이었기 때문에 반대편 경로의 결함이 보이지 않았다는 것이 제 해석입니다. 기록이 그렇게 설명한 것은 아니며, 그래서 근거 등급은 추정입니다. 이 해석에서 얻는 기준은 단순합니다. 번들 경계를 바꾸는 변경은 줄이려는 쪽의 산출물뿐 아니라 그 코드를 쓰는 모든 진입점의 산출물을 함께 확인해야 합니다.

2026-07-24 가드 도입, 2026-07-25 병합과 배포, 같은 날 2차 패스에서 패턴이 확산된 일, 배포 전 리뷰 블로커, 인라인 단축평가 수정, 산출물 테스트의 red에서 green까지 이어지는 순서를 나타낸 타임라인 개념도
사건의 순서를 보여 주는 개념도입니다. 시간 간격은 비율대로 그리지 않았습니다. 리뷰 이후의 수정과 산출물 테스트 검증까지 포함합니다.

테스트 3,668개가 이 결함을 잡지 못한 이유

기록의 수정 전 수치는 3668개 테스트 전부 통과입니다. 같은 기록의 수정 후 수치는 319개 파일, 3670개 테스트 통과, 커버리지 100%입니다. 통과 수가 많다는 사실은 배포 아티팩트가 맞다는 근거가 아닙니다. 기록은 3668개가 모두 통과하는 동안 배포 아티팩트만 깨져 있었다고 적습니다.

구조적 이유는 테스트 환경에 있습니다. Vitest는 import.meta.env를 정의하므로 가드 표현식은 테스트에서 값을 가집니다. 소스 레벨의 단위 테스트는 가드가 소스에서 어떻게 보이는지는 보지만, wrangler가 묶은 뒤 그 표현식이 번들에 남았는지는 보지 못합니다. 기록은 이를 “소스 레벨 테스트로는 구조적으로 검출 불가”라고 적었습니다. 그래서 검사 대상을 소스에서 번들 산출물로 옮겨야 했습니다.

커버리지도 같은 한계를 가집니다. 기록은 수정 전후 모두 커버리지 100%를 적었습니다. 100%는 소스의 모든 줄이 테스트 중 실행됐다는 뜻이지, 그 줄이 번들 뒤에도 같은 값을 만든다는 뜻이 아닙니다. 가드 줄은 Vitest에서 실행되어 커버된 것으로 셌을 것입니다. 이 문장은 제 추정이고 기록에는 커버리지 보고서의 줄 단위 내용이 없습니다. 다만 숫자 두 개, 곧 3668개 통과와 100%가 모두 초록이던 상태에서 리뷰만이 결함을 잡았다는 사실은 기록에 분명합니다. 사람이든 에이전트든 리뷰가 마지막 방어선이 되었다는 점이 이 사건에서 가장 불편한 대목입니다.

이 차이는 테스트 수를 늘리는 것으로 메워지지 않습니다. 경로가 다르면 같은 소스가 다른 결과를 냅니다. 검사는 경로마다 실제로 만들어진 결과를 봐야 합니다.

리뷰가 막은 지점과 버려진 중간안

리뷰가 막은 것은 배포 차단 블로커였습니다. 기록의 dry-run 확인에서도 번들에 import.meta.env가 2건 남아 있었으므로, 리뷰의 판단과 산출물 관찰이 같은 방향을 가리켰습니다. 기록에 있는 것은 여기까지입니다. 두 건이 정확히 어느 진입점에 있었는지는 본문에 적히지 않았습니다.

첫 번째 중간안은 가드 조건을 헬퍼 함수로 빼는 것이었습니다. 같은 패턴이 두 곳에 있으므로 한곳에 모으면 읽기 쉬워 보입니다. 기록에 따르면 이 안은 실패했습니다. 헬퍼로 추출하자 Vite의 상수 폴딩이 깨져 dynamic import가 번들에 살아남았고, 서버 전용 모듈 경계를 지키는 빌드 플러그인(기록의 이름은 dot-server 플러그인)이 빌드를 거부했습니다. 기록은 이를 리뷰 지적이 아니라 실제 빌드에서 겪은 실패로 적었습니다. 이 실패가 남긴 조건은, 헬퍼 추출이 빌드 결과를 바꾼다는 점입니다.

그래서 기록의 수정은 두 가드를 인라인으로 남기는 것이었습니다. 조건을 typeof import.meta.env !== "undefined" && … 형태의 단축평가로 바꿨습니다. 기록은 이 형태가 왜 동작하는지를 따로 설명하지 않습니다. 필자의 해석으로는, 가드 표현식이 헬퍼 호출 뒤로 숨지 않고 원래 자리에 있어야 Vite 치환과 경계 플러그인이 같은 코드를 본다는 점을 위 실패에서 읽을 수 있습니다. 이 해석은 기록이 확인한 내용이 아니므로 근거 등급은 추정입니다.

산출물 테스트의 red에서 green까지

기록의 핵심 증거는 새 테스트가 실제로 실패할 수 있었다는 점입니다. 새 테스트는 wrangler deploy --dry-run의 산출물을 실제로 빌드해 검사하는 아키텍처 테스트입니다. 기록은 옛 형태의 가드로 되돌렸을 때 expected 5 to be 3이 실패했다고 적습니다. 수정된 형태에서는 통과했습니다. 실패가 먼저 관찰되고 수정 뒤에 통과가 확인된, red에서 green으로의 순서입니다.

이 테스트가 어떤 문자열이나 구조를 세었는지는 기록에 없습니다. 이 글도 그 구현을 묘사하지 않습니다. 기록이 확인한 것은 세 가지입니다. 산출물을 실제로 빌드했다는 것, 옛 형태에서 실패했다는 것, 수정 뒤 전체 스위트가 통과했다는 것입니다.

수정 뒤 기록된 검증은 319개 파일, 3670개 테스트 통과, 커버리지 100%, typecheck와 format 클린입니다. 아래 블록은 기록의 수치를 한곳에 모은 요약이며, 원문 명령 출력이 아닙니다.

기록 요약(원문 출력 아님)
수정 전 dry-run 번들: import.meta.env 2건 잔존
옛 형태로 되돌림: expected 5 to be 3 (실패)
수정 후: 319개 파일, 3670개 테스트 통과, 커버리지 100%

preview workerd에서 본 것

기록에는 preview 환경의 workerd에서 POST /signup.data를 호출한 결과도 있습니다. 응답은 400이었고, 기록은 이를 정상 검증 경로의 응답으로 읽었습니다. TypeError는 나타나지 않았습니다. 기록은 이 확인을 facade가 workerd에서 동작한다는 근거로 씁니다.

이 확인의 범위는 좁습니다. preview 요청은 프로덕션 요청과 같지 않습니다. 기록에는 큐 consumer나 Workflow 진입점을 preview에서 실행한 결과가 없으므로, 이 응답으로 그 경로의 동작을 말할 수 없습니다. 이 글은 그 빈칸을 채우지 않습니다.

독자가 할 수 있는 점검

아래 절차는 기록의 교훈에서 끌어낸 권장입니다. 이 글을 쓰는 과정에서 실행한 것이 아니며, 실행 결과가 있는 항목은 기록에서 가져왔다고 적었습니다.

  1. 진입점마다 어떤 도구가 묶는지 표를 만듭니다. UI 경로, worker 진입점, 테스트 환경을 한 줄씩 채우고, 각 칸에 기록인지 추정인지 붙입니다.
  2. 가드는 인라인으로 둡니다. 헬퍼로 빼면 빌드 플러그인이 빌드를 거부하거나 dynamic import가 살아남을 수 있다는 기록의 실패를 기준으로 삼습니다.
  3. dry-run 산출물을 만들고 import.meta.env 문자열을 셉니다. 기록은 수정 전 이 검사로 2건을 확인했습니다.
  4. 배포 뒤에는 큐와 워크플로 경로의 오류 로그를 따로 봅니다. 기록에는 이 확인이 아직 없습니다.

배포 게이트에서는 “테스트 전부 통과”를 “배포 가능”과 같은 뜻으로 읽지 않는 편이 안전합니다. 테스트 통과와 산출물 검사를 별도 항목으로 적어 두면, 한쪽이 초록이어도 다른 쪽이 빈칸으로 남은 상태가 보입니다. 이것도 권장이며, 기록에 이런 게이트 운영이 있었다는 뜻은 아닙니다.

npx wrangler deploy --dry-run --outdir /tmp/worker-bundle
grep -o "import.meta.env" /tmp/worker-bundle/*.js | wc -l

위 명령은 Wrangler 문서가 설명하는 옵션을 쓴 권장 형태입니다. 산출물 파일 이름은 프로젝트마다 다를 수 있으므로 글롭을 맞춰야 합니다. 0이 나오면 문자열이 남지 않았다는 뜻이지만, 진입점별 치환이 올바른 값을 만든다는 증명은 아닙니다. 번들에 문자열이 남았는지와 각 경로가 올바른 값을 쓰는지는 다른 질문입니다.

같은 날 곁가지 한 가지

같은 날 인덱스를 추가한 마이그레이션의 인라인 CREATE TABLE이 런타임 정의와 어긋나지 않도록, 기록은 pragma_table_info를 비교하는 테스트를 더했습니다. 가드 결함과는 다른 문제이므로 여기서는 이 한 문장으로 끝냅니다.

기록에서 확인하지 못한 것

  • 프로덕션에서 이 결함이 한 번이라도 발현됐는지. 기록에는 배포 전 리뷰가 잡은 차단 결함만 있습니다.
  • 가드가 어떤 workerd 버전에서 어떻게 TypeError를 냈는지. 재현 로그가 없습니다.
  • 두 건의 잔존 import.meta.env가 정확히 어느 진입점에 있었는지. 기록은 개수만 적었습니다.
  • 산출물 테스트의 검사 구현 세부. 기록은 빌드와 검사까지만 적었습니다.
  • 수정 뒤 재리뷰 결과. 기록에 보이지 않으므로, 수정이 새 문제를 만들지 않았다는 근거는 테스트와 산출물 검사뿐입니다.

자주 묻는 질문

테스트가 전부 통과했다면 배포 전에 무엇을 더 봐야 하나?

기록 기준으로 이 결함은 소스 테스트에서 보이지 않았습니다. 번들러가 만든 산출물에서 진입점별 결과를 봐야 합니다. 권장 절차는 dry-run으로 산출물을 만들고 import.meta.env 문자열을 세는 것입니다. 이 절차는 기록에서 실행한 것이 아니라 권장입니다.

가드 조건을 헬퍼 함수로 빼면 안 되나?

기록에서는 헬퍼로 빼자 Vite의 상수 폴딩이 깨져 dynamic import가 번들에 남았고, 빌드 플러그인이 빌드를 거부했습니다. 그래서 기록의 수정은 조건을 인라인으로 두는 것이었습니다.

이 결함이 실제 사용자 요청에서 일어났나?

기록에는 프로덕션 큐나 워크플로 요청에서 발현됐다는 내용이 없습니다. 배포 전 리뷰가 잡은 차단 결함으로만 기록되어 있으며, 발현 여부는 이 글에서 확인하지 못한 것으로 남깁니다.