Fresh Notes
Fresh 환경 변수는 언제 읽힐까? Vite 빌드 값과 Deno 실행 값 구분하기 | Fresh Notes

Vite

Fresh 환경 변수는 언제 읽힐까? Vite 빌드 값과 Deno 실행 값 구분하기

Fresh에서 VITE_, FRESH_PUBLIC_, Deno.env.get()을 비교합니다. 가짜 환경 변수로 빌드와 실행 시점을 나누어 실험하고, 공개 값의 번들 포함 여부와 .env 파일 로딩 차이를 확인합니다.

2026.10.27·Vite·7 min read

환경 변수를 바꿨는데 화면은 그대로인 이유

환경 변수는 코드를 수정하지 않고 설정을 바꾸는 데 사용합니다.

그런데 Fresh 프로젝트에서는 다음과 같은 상황이 생길 수 있습니다.

빌드할 때 표시 문구를 A로 설정
→ 서버를 실행할 때 B로 변경
→ 화면에는 여전히 A가 표시됨

반대로 서버 핸들러에서 읽는 값은 같은 빌드 결과를 사용해도 실행 환경에 따라 바뀔 수 있습니다.

차이는 환경 변수라는 이름에 있지 않습니다. 어느 코드에서, 언제 읽는지에 있습니다.

4편에서는 Vite와 Deno의 역할을 나눠 살펴봤습니다. 이번에는 그 구분을 환경 변수에 적용해 보겠습니다.

실험은 Fresh Notes의 임시 복사본에서 진행했습니다. Fresh 2.3.3, @fresh/plugin-vite 1.1.2, Vite 7.3.6, Deno 2.8.3을 기준으로 확인했습니다.

실제 프로젝트의 환경 변수 파일은 복사하지 않았습니다. 아래에 나오는 값은 모두 실험용 가짜 값입니다.

먼저 읽는 경로를 세 가지로 나눕니다

이번에 비교할 코드는 다음 세 가지입니다.

import.meta.env.VITE_NOTES_BADGE;
Deno.env.get("FRESH_PUBLIC_NOTES_BADGE");
Deno.env.get("NOTES_SERVER_LABEL");

첫 번째는 Vite가 제공하는 환경 변수 접근 방식입니다.

Vite는 기본 설정에서 VITE_ 접두사가 붙은 값을 처리한 코드에 노출하며, 빌드할 때 정적으로 치환합니다. 사용자 정의 환경 변수의 값은 문자열로 취급합니다. Vite 7 환경 변수 문서

두 번째는 Fresh의 공개 환경 변수 처리 방식입니다.

Fresh는 Island 번들링 과정에서 FRESH_PUBLIC_ 접두사가 붙은 값을 인라인으로 넣을 수 있습니다. 문법이 Deno.env.get()이어도 브라우저에서 Deno의 환경 변수 API를 호출하는 것은 아닙니다. Fresh 환경 변수 문서

세 번째는 이번 실험에서 서버 핸들러 안에 작성할 일반적인 환경 변수 읽기입니다.

이 값은 서버가 요청을 처리할 때 Deno 프로세스의 환경에서 읽도록 하겠습니다.

코드 이번 실험의 사용 위치 확인할 시점
import.meta.env.VITE_NOTES_BADGE Island 빌드
Deno.env.get("FRESH_PUBLIC_NOTES_BADGE") Island 빌드
Deno.env.get("NOTES_SERVER_LABEL") 서버 핸들러 요청 처리

같은 Deno.env.get() 문법이라도 공개 변수에 대한 빌드 변환이 적용되는 경우는 구분해야 합니다.

Vite가 읽을 파일을 준비합니다

임시 프로젝트 루트에 .env.production을 만듭니다.

VITE_NOTES_BADGE=VITE_BUILD_A

Vite의 기본 빌드 모드는 production이며, 해당 모드의 환경 변수 파일을 읽습니다.

파일 이름에 들어간 production은 서버에서 항상 이 파일을 읽으라는 지시가 아닙니다. Vite가 빌드 모드에 따라 선택하는 파일 이름입니다.

이번에는 다른 파일과의 우선순위에 영향을 받지 않도록, 기존 .env 파일을 제외한 임시 복사본에서 실험했습니다.

Island에서 공개 값을 읽습니다

islands/EnvProbe.tsx를 만듭니다.

export default function EnvProbe() {
  return (
    <section>
      <p id="vite-value">
        {import.meta.env.VITE_NOTES_BADGE}
      </p>

      <p id="fresh-value">
        {Deno.env.get(
          "FRESH_PUBLIC_NOTES_BADGE",
        )}
      </p>

      <p id="private-value">
        {String(
          import.meta.env.NOTES_PRIVATE_TOKEN ??
            "missing",
        )}
      </p>
    </section>
  );
}

첫 번째와 두 번째 값은 공개 표시용입니다.

세 번째는 일반 이름의 변수가 import.meta.env를 통해 노출되는지 확인하기 위한 대조군입니다. 실제 비밀값을 넣는 실험은 하지 않습니다.

Fresh의 공개 변수 접근에는 문자열 이름을 직접 작성했습니다.

Deno.env.get(
  "FRESH_PUBLIC_NOTES_BADGE",
);

공식 문서는 빌드 도구가 분석할 수 있는 접근 형태를 사용하도록 안내합니다. 변수 이름을 동적으로 조합하는 방식까지 같은 변환을 기대하면 안 됩니다.

const name = "FRESH_PUBLIC_NOTES_BADGE";

// 공개 변수 치환에 의존할 때는 피합니다.
Deno.env.get(name);

서버에서는 실행 환경을 읽습니다

routes/env-probe.tsx를 만듭니다.

import { define } from "../utils.ts";
import EnvProbe from "../islands/EnvProbe.tsx";

export const handler = define.handlers({
  GET() {
    return {
      data: {
        serverLabel: Deno.env.get(
          "NOTES_SERVER_LABEL",
        ) ?? "unset",

        configured: Boolean(
          Deno.env.get(
            "NOTES_PRIVATE_TOKEN",
          ),
        ),
      },
    };
  },
});

export default define.page<typeof handler>(
  function EnvProbePage({ data }) {
    return (
      <main>
        <h1>Environment probe</h1>

        <p id="server-value">
          {data.serverLabel}
        </p>

        <p id="configured">
          {String(data.configured)}
        </p>

        <EnvProbe />
      </main>
    );
  },
);

서버 핸들러는 공개 접두사가 없는 값을 읽습니다.

Deno.env.get("NOTES_SERVER_LABEL");

이 코드는 GET() 안에서 실행됩니다. 따라서 이번 구현에서는 요청을 처리할 때 환경 값을 읽습니다.

가짜 토큰은 값 자체를 반환하지 않고, 설정되어 있는지만 확인합니다.

configured: Boolean(
  Deno.env.get("NOTES_PRIVATE_TOKEN"),
)

이는 토큰 내용을 화면에 출력하지 않기 위한 실험용 구성입니다. 실제 서비스에서는 설정 여부를 공개할 필요가 있는지도 따로 판단해야 합니다.

A라는 값으로 빌드합니다

.env.production의 Vite 값은 다음과 같습니다.

VITE_NOTES_BADGE=VITE_BUILD_A

나머지 값은 빌드 명령의 환경으로 전달합니다.

FRESH_PUBLIC_NOTES_BADGE=FRESH_BUILD_A \
NOTES_SERVER_LABEL=SERVER_BUILD_A \
NOTES_PRIVATE_TOKEN=FAKE_PRIVATE_TOKEN_A \
deno task build

이때 사용한 값은 네 가지입니다.

이름 빌드 환경의 값
VITE_NOTES_BADGE VITE_BUILD_A
FRESH_PUBLIC_NOTES_BADGE FRESH_BUILD_A
NOTES_SERVER_LABEL SERVER_BUILD_A
NOTES_PRIVATE_TOKEN FAKE_PRIVATE_TOKEN_A

빌드가 성공한 뒤 생성된 JavaScript 파일에서 값을 검색했습니다.

검색한 문자열 클라이언트 번들 서버 번들
VITE_BUILD_A 발견 발견
FRESH_BUILD_A 발견 발견
SERVER_BUILD_A 발견되지 않음 발견되지 않음
FAKE_PRIVATE_TOKEN_A 발견되지 않음 발견되지 않음

이번 코드에서 공개 표시값은 클라이언트와 서버의 렌더링 코드에 모두 포함되었습니다.

반면 일반 서버 변수는 값 대신 읽기 코드가 남았습니다. 서버 번들에는 NOTES_SERVER_LABEL, NOTES_PRIVATE_TOKEN이라는 이름이 있었지만, 빌드 환경에 넣었던 값 자체는 발견되지 않았습니다.

환경 변수를 사용했다는 사실만으로 값이 번들에 들어가는지 판단할 수는 없습니다. 접근 방식과 변환 결과를 함께 봐야 합니다.

같은 빌드에 B라는 실행 값을 전달합니다

이제 다시 빌드하지 않고 실행 환경만 바꿉니다.

13편의 HTTP 응답 확인 방식을 사용하므로 브라우저를 열 필요가 없습니다.

프로젝트 루트에 verify-env.ts를 만듭니다.

const { default: server } = await import(
  "./_fresh/server.js"
);

const response = await server.fetch(
  new Request(
    "http://localhost:8000/env-probe",
  ),
);

const html = await response.text();

if (response.status !== 200) {
  throw new Error(
    `Unexpected status: ${response.status}`,
  );
}

for (
  const id of [
    "server-value",
    "configured",
    "vite-value",
    "fresh-value",
    "private-value",
  ]
) {
  const match = html.match(
    new RegExp(
      `<p id="${id}">([\\s\\S]*?)</p>`,
    ),
  );

  console.log(
    `${id}: ${match?.[1] ?? "NOT FOUND"}`,
  );
}

if (
  html.includes("FAKE_PRIVATE_TOKEN_B")
) {
  throw new Error(
    "Private token leaked into HTML",
  );
}

다음과 같이 실행합니다.

FRESH_PUBLIC_NOTES_BADGE=FRESH_RUN_B \
VITE_NOTES_BADGE=VITE_RUN_B \
NOTES_SERVER_LABEL=SERVER_RUN_B \
NOTES_PRIVATE_TOKEN=FAKE_PRIVATE_TOKEN_B \
deno run \
  --cached-only \
  --no-prompt \
  --allow-read \
  --allow-env=FRESH_PUBLIC_NOTES_BADGE,VITE_NOTES_BADGE,NOTES_SERVER_LABEL,NOTES_PRIVATE_TOKEN \
  --deny-net \
  verify-env.ts

실제로 확인한 출력은 다음과 같습니다.

server-value: SERVER_RUN_B
configured: true
vite-value: VITE_BUILD_A
fresh-value: FRESH_BUILD_A
private-value: missing

서버 핸들러에서 읽은 값은 실행 환경의 SERVER_RUN_B였습니다.

Island의 두 공개 값은 실행 환경에 다른 값을 넣어도 빌드 당시의 값으로 남았습니다.

VITE_RUN_B를 실행 환경에 설정
→ VITE_BUILD_A 유지

FRESH_RUN_B를 실행 환경에 설정
→ FRESH_BUILD_A 유지

생성된 클라이언트 번들에서도 같은 빌드 값이 확인되었습니다. 다만 이 실험은 번들 내용과 서버 응답 HTML을 검사한 것이며, 브라우저 hydration을 실행한 결과는 아닙니다.

빌드와 응답 확인 과정에서 광고 스크립트를 요청하거나 실행하지 않았습니다.

서버에서 렌더링해도 빌드 값일 수 있습니다

이번 결과에서 주의할 부분은 서버가 반환한 HTML에도 VITE_BUILD_A와 FRESH_BUILD_A가 들어 있었다는 점입니다.

서버가 렌더링했다는 사실만으로 모든 값을 실행 시점에 읽는 것은 아닙니다.

원본 Island 코드
→ 빌드 과정에서 공개 값 치환
→ 치환된 코드로 서버 렌더링

따라서 다음 질문은 서로 다릅니다.

어디에서 렌더링하는가?
언제 설정값이 결정되는가?

Fresh의 Server-first 구조와 환경 변수의 결정 시점을 구분해야 합니다.

예를 들어 공개 표시 문구를 실행 환경에서 바꾸고 싶다면, 서버 핸들러에서 값을 읽고 필요한 문자열만 Island props로 전달하는 구성을 사용할 수 있습니다.

const message = Deno.env.get(
  "NOTES_PUBLIC_MESSAGE",
) ?? "";

return {
  data: { message },
};
<Notice message={data.message} />

이 경우 message는 브라우저에 전달되는 공개 데이터입니다. 비밀 토큰을 같은 방식으로 넘겨서는 안 됩니다.

.env 파일은 누가 읽는지도 확인합니다

Vite가 .env.production을 읽었다고 해서, 이후 Deno로 실행하는 모든 프로그램이 그 파일을 자동으로 읽는 것은 아닙니다.

이를 확인하기 위해 read-file-env.ts를 만들었습니다.

console.log(
  Deno.env.get(
    "VITE_NOTES_BADGE",
  ) ?? "unset",
);

해당 변수를 프로세스 환경에 설정하지 않은 상태에서 실행했습니다.

deno run \
  --allow-env=VITE_NOTES_BADGE \
  read-file-env.ts

결과는 다음과 같았습니다.

unset

같은 파일을 명시적으로 지정하면 결과가 바뀝니다.

deno run \
  --env-file=.env.production \
  --allow-env=VITE_NOTES_BADGE \
  read-file-env.ts
VITE_BUILD_A

Deno는 --env-file로 환경 변수 파일을 지정할 수 있습니다. Vite의 모드별 파일 로딩과 Deno 실행 시의 파일 로딩은 각각 설정해야 합니다. Deno 환경 변수 문서

여기서 읽은 것은 Deno 프로세스의 환경 값입니다. --env-file로 다른 값을 읽었다고 이미 생성된 클라이언트 번들까지 수정되지는 않습니다.

값이 없을 때와 권한이 없을 때는 다릅니다

다음 코드는 환경 변수가 없을 때 기본값을 사용합니다.

const label = Deno.env.get(
  "NOTES_SERVER_LABEL",
) ?? "unset";

하지만 읽기 권한이 없을 때까지 "unset"으로 바뀌는 것은 아닙니다.

환경 변수 접근 권한을 제외한 실행에서는 다음 오류가 발생했습니다.

NotCapable:
Requires env access to "NOTES_SERVER_LABEL"

이 경우 Deno.env.get()이 예외를 발생시키므로 ??의 기본값으로 넘어가지 않습니다.

이번 임시 프로젝트에는 12편의 오류 처리가 적용되어 있어, 요청은 500 오류 화면으로 처리되었습니다.

상황 결과
읽기 권한이 있고 값이 있음 문자열 반환
읽기 권한이 있고 값이 없음 undefined, 기본값 사용
읽기 권한이 없음 예외 발생

배포 후 설정값을 읽지 못한다면 값의 등록 여부와 실행 권한을 각각 확인해야 합니다.

10편에서 다룬 Deno 권한이 환경 변수에도 적용됩니다.

공개 접두사가 없다고 노출이 불가능한 것은 아닙니다

이번 실험에서는 가짜 토큰이 클라이언트 번들과 응답 HTML에서 발견되지 않았습니다.

하지만 이를 일반 서버 변수는 어떤 코드에서도 안전하다는 뜻으로 해석하면 안 됩니다.

서버가 읽은 값을 직접 HTML에 출력하면 독자가 볼 수 있습니다.

<p>{token}</p>

Island props로 전달해도 브라우저에 전달되는 데이터가 됩니다.

<SomeIsland token={token} />

이번 실험이 확인한 것은 다음 범위입니다.

기본 공개 변수 처리에서는
일반 서버 변수의 값이 자동으로
클라이언트 번들에 포함되지 않았다.

서버에서 읽은 이후 어디에 사용하는지는 별도 문제입니다.

비밀값은 서버에서 필요한 작업에 사용하고, 화면에는 그 작업의 결과 중 공개 가능한 정보만 전달해야 합니다.

Fresh Notes의 설정을 모두 옮길 필요는 없습니다

현재 config.ts에는 사이트 이름과 주소, 설명, 기본 이미지 경로가 있습니다.

export const SITE = {
  name: "Fresh Notes",
  url: "https://freshwebnotes.com",
  description:
    "Fresh를 중심으로 Deno, Vite, Preact와 현대 웹 아키텍처를 다루는 기술 블로그입니다.",
  ogImage: "/og/default.png",
} as const;

이 값들은 공개 설정입니다.

현재처럼 하나의 사이트를 운영하고 코드와 함께 관리하는 것이 편하다면 그대로 두어도 됩니다.

환경 변수로 옮기는 기준은 설정값이라는 사실보다, 배포 환경에 따라 달라져야 하는지입니다.

설정 선택할 수 있는 관리 방식
고정 사이트 이름과 설명 공개 설정 파일
빌드마다 다른 공개 표시값 빌드 시 공개 환경 변수
서버 실행 환경마다 다른 설정 서버 환경 변수
서버에서만 사용하는 비밀값 서버 환경 변수와 제한된 사용 경로

현재 프로젝트의 .gitignore는 .env, .env.*를 제외하고 .env.example만 예외로 둡니다.

환경 변수 파일을 Git에서 제외하는 것은 파일 관리 규칙입니다. 빌드 과정에서 공개 값으로 사용하면 그 값은 생성된 코드에 포함될 수 있습니다.

두 문제를 구분해야 합니다.

배포 설정에는 읽는 시점을 함께 기록합니다

환경 변수 이름만 적어 두면 배포할 때 다음 내용을 다시 추적해야 합니다.

빌드 작업에 넣어야 하나?
서버 실행 환경에 넣어야 하나?
바꾸면 재빌드가 필요한가?
브라우저에 보여도 되는가?

이번 실험용 설정은 다음처럼 정리할 수 있습니다.

이름 결정 시점 공개 여부 변경 반영
VITE_NOTES_BADGE 빌드 공개 재빌드
FRESH_PUBLIC_NOTES_BADGE 빌드 공개 재빌드
NOTES_SERVER_LABEL 서버 요청 처리 코드가 출력한 부분 공개 새 프로세스 환경에 반영
NOTES_PRIVATE_TOKEN 서버 요청 처리 서버 내부 사용 새 프로세스 환경에 반영

이번에 확인한 차이는 모두 사용 중인 버전의 현재 동작입니다. 향후 기능이나 예정된 변경을 가정하지 않았습니다.

공개 변수는 생성된 번들에 어떤 값이 들어갔는지 확인하고, 서버 변수는 실제 실행 프로세스가 어떤 값과 권한을 받았는지 확인하면 됩니다.

환경 변수가 반영되지 않는 문제를 만났을 때도, 파일 이름부터 바꾸기보다 값을 읽는 코드와 결정 시점을 먼저 찾으면 확인할 범위를 좁힐 수 있습니다.

#Fresh#Vite#Deno#EnvironmentVariables#Deployment