시행착오
환경변수가 빌드 시점과 런타임 시점에서 어떻게 동작하는지에 대해 알아가는 시간을 조금 가져보았다. 사실 일관성 있게 동작하기를 바랐지만, 안타깝게도 이놈의 Next14는 애초에 런타임 환경변수 동작에 대해서 자세히 설명하지도 않고, 한다는 소리는 런타임 환경 변수 주입 시에는 서버 사이드를 권장한다는 것이었다. 아니 그럼 클라이언트 사이드는!!!
// Next14 runtime Environment 관련
By default, environment variables are only available on the server. To expose an environment variable to the browser, it must be prefixed with NEXT_PUBLIC_. However, these public environment variables will be inlined into the JavaScript bundle during next build.
To read runtime environment variables, we recommend using getServerSideProps or incrementally adopting the App Router.
자, 하지만 여기서 자세히 봐야 할 것이 있다. 환경 변수가 빌드 시점에 고정되고 이를 캐시하는 것은 정확히 말하면 html에 직접적으로 노출이 되는 부분이지 API와 같이 서버에서 처리되는 부분이 아니라는 것이다. 클라이언트 사이드 랜더링 페이지라고 하더라도, 이러한 서버쪽 처리에 대해서는 .env에서 동적으로 환경변수를 가져오고, 때문에 변경된 .env를 인식하여 사용할 수 있다. 하지만 만약 직접적인 노출이 필요한 환경변수일 경우, next가 빌드될 때 같이 빌드에 포함되어버리기 때문에 런타임 동안에는 변경되지 않는 것이다.
조금 복잡할 수 있으니 챗지피티를 고문해 내용을 다시 정리해보자.
서버 사이드 환경 변수
- 변경 적용: 서버 재시작 시 새로운 .env 파일의 환경 변수가 반영됩니다.
- 접근 방식: 서버 코드에서는 process.env를 통해 환경 변수를 동적으로 읽어올 수 있습니다.
- 적용 예: API 호출, 서버사이드 렌더링(SSR) 등
클라이언트 사이드 환경 변수
- 변경 적용: 클라이언트 사이드에서는 빌드 시점에 .env 파일의 환경 변수가 고정됩니다.
- 접근 방식: 빌드 후 .env 파일을 변경해도, 클라이언트 사이드 컴포넌트에서는 새로운 값이 반영되지 않습니다.
- 적용 예: 브라우저에서 직접 사용되는 환경 변수, 예를 들어 publicRuntimeConfig
지난 시간에 카카오 디벨로퍼 블로그에서 확인했던 내용은, 두 번째 케이스에 대한 내용이라고 볼 수 있다. 이미지를 불러올 때의 url을 환경변수로 넣어주는 방식으로 사용하다 보니, 이 부분에 대해서 next가 빌드 시점에 캐시되어버리기 떄문이다. 하지만 현재 우리는 그러한 부분을 사용하지 않고 간단하게 상황에 따라 .env를 develop 환경과 prod 환경에 맞게 교체만 진행해주면 될 것 같다.
주의사항
next14에서는 기본적으로 development와 production 에 대해서 환경변수 우선순위가 존재한다.
next build 시 우선순위
- .env.production.local
- .env.local
- .env.production
- .env
next dev시 우선순위
- .env.development.local
- .env.local
- .env.development
- .env
때문에 .env.development와 같이 예약어로 .env를 등록하게 되면 이를 인식해버린다. .env.develop와 같이 인식되지 않는 .env를 사용해야 한다. (이걸 모르고 왜 자꾸 .env.production이 덮어쓰기 되나 삽질을 하고 있었다.)
코드 작성하기
먼저, 필자가 필요한 건 그저 상황에 따라 .env가 production용으로 대체되거나 development로 대체되는 것이지만, 혹시 카카오 디벨로퍼에서 사용하는 것과 같이 client에 직접 노출되어야 하는 환경변수를 사용할 경우에도 정상적으로 동작할 수 있는 코드가 필요한 사람들을 위해 해당 부분들도 같이 넣어둘 예정이다.
copyEnv.js
import { findUp } from "find-up";
import fs from "fs";
export default async function copyEnv(appEnv) {
// 파싱 대상 파일은 '.env'파일로 복사
const envFilePath = await findUp(`.env.${appEnv}`);
const dotenvFilePath = `${fs.realpathSync(process.cwd())}/.env`;
fs.copyFileSync(envFilePath, dotenvFilePath);
}
이 파일은 APP_ENV를 런타임에 받아 해당 .env.APP_ENV와 동일한 이름의 .env를 찾고 해당 파일의 .env로 복사하는 역할을 한다.
env.js
import { env as envL } from "next-runtime-env";
export default function env(envName) {
if (!envName) {
return undefined;
}
if (typeof window !== "undefined" && window.__ENV) {
console.log("found");
return window.__ENV[envName];
} else {
console.log("not found");
const envVar = envL(envName);
return envVar;
}
}
추후 클라이언트에서 환경변수가 필요할 때 불러오는 역할을 하는 env이다.
parsedotenv.js
import Dotenv from "dotenv";
import { findUp } from "find-up";
export default async function parseDotenv(appEnv) {
// dotenv 파싱
const envFilePath = await findUp(`.env.${appEnv}`);
const parsedEnv = Dotenv.config({ path: envFilePath }).parsed || {};
return parsedEnv;
}
우리가 사용할 예정인 .env 파일의 내용을 return 한다.
writeEnv.js
import fs from "fs";
export default function writeEnv(parsedEnv) {
// 파싱 된 내용을 /public/__ENV.js에 출력
const scriptFilePath = `${fs.realpathSync(process.cwd())}/public/__ENV.js`;
fs.writeFileSync(
scriptFilePath,
`window.__ENV = ${JSON.stringify(parsedEnv)}`,
);
}
.env의 내용을 public/__ENV.js에 복사하여 클라이언트 사이드에서 동적으로 가져와 사용할 수 있도록 한다. (환경변수 URL 방식)
cli.js
import yargs from "yargs/yargs";
import { hideBin } from "yargs/helpers";
import parseDotenv from "./parsedotenv.js";
import writeEnv from "./writeEnv.js";
import copyEnv from "./copyEnv.js";
yargs(hideBin(process.argv))
.command(
"next-env",
"Create Next.js runtime environment js",
function builder(y) {
return y.option("env", {
alias: "e",
type: "string",
description: "Environment name(ex: alpha, dev, staging, real)",
});
},
async function handler(args) {
const appEnv = args.e || args.env || "develop";
const parsedEnv = await parseDotenv(appEnv); // dotenv 파싱
writeEnv(parsedEnv); // 환경 변수 스크립트 파일 생성
await copyEnv(appEnv); // .env 파일 복사
return parsedEnv;
},
)
.parse();
"dev": "node ./src/utils/cli.mjs next-env --env=${APP_ENV:-develop} && next dev",
"test": "jest",
"build": "next build",
"start": "node ./src/utils/cli.mjs next-env --env=${APP_ENV:-develop} && next start",
"serve": "node ./src/utils/cli.mjs next-env -e ${APP_ENV:-develop} && node server.js",
package.json에서도 위와 같이 runtime에 환경변수를 받을 수 있도록 수정하자.
위의 파일들을 사용해서 next start가 동작하기 전에 위의 cli가 동작할 수 있도록 하기 위한 script를 작성한다. 여기서 public/__ENV를 만들어주는 것은 writeEnv.js 파일이다. 해당 파일을 사용할 경우, public에서 사용할 예정인 환경변수에 대해서는 명확히 정의할 필요가 있다. public/__ENV에 들어가는 내용에 대해서는 보호가 되지 않기 때문이다. 중요 키나 토큰을 넣지 않도록 조심하자.
동작 확인
간단하게 page.tsx에서 정상적으로 동작하는지를 확인해보자.
import env from "../utils/env.js";
export default function Home() {
console.log("test", process.env.TEST_ENV);
const envVar = env("TEST_ENV");
return (
<main className={styles.main}>
<div className={styles.description}>
<div>
<h1>this is env</h1>
<h2>{envVar}</h2>
</div>
</div>
미리 만들어둔 env를 import 하고 TEST_ENV에 해당하는 환경변수를 사용할 수 있게 했다. console.log와 같은 경우에는 env로 가져올 필요 없이 .env를 사용할 수 있게 하여 process.env.TEST_ENV 그대로 사용할 수 있게 했다. 이제 .env.develop와 .env.prod를 만들자.
// develop
TEST_ENV=thisisDevelop
NEXT_PUBLIC_TEST_ENV=thisisNextDevelop
SECOND_SECRET=secondsecret
// prod
TEST_ENV=thisisProd
NEXT_PUBLIC_TEST_ENV=thisisNextProd
SECOND_SECRET=secondsecretprod
여기까지 세팅했으니, 이제 APP_ENV=develop npm run dev를 실행해보자.
위와 같이 __ENV.js와 .env가 생성되면 성공이다. 내부에는 .env.develop가 복사되어 들어있다.
접속할 경우 정상적으로 console.log에 환경변수가 찍혀나온다.
env로 __ENV.js에서 가져온 환경변수도 제대로 노출되는 것을 확인할 수 있다. 이번에는 prod로 바꿔보자.
console 창에 찍혀 나오는 부분은 제대로 .env 환경변수를 읽었다는 것이다.
화면에 나오는 부분은 우리가 작성한 env.js의 동작에 따라 window 혹은 next-runtime-env 라이브러리를 통해 가져왔다는 것이다.
정상적으로 prod에 해당하는 환경변수를 가져와 사용하는 것을 볼 수 있다!