NEXT_PUBLIC_ 환경변수를 빌드 이후에 수정할 순 없나? 함께 알아보자.

Jihan·2024년 11월 25일
5
post-thumbnail

개발을 하다보면 배포 환경에 따라 환경 변수를 다르게 설정해야 할 때가 있습니다. 이번 포스트에서는 빌드시점마다 환경변수를 번들링해 저장하는 Next.js에서, 빌드 이후에 환경변수를 주입할 수 있도록 설정하는 과정을 공유하려 합니다.

최근 회사에서 타사 폐쇄망에 자체배포하는 프로젝트를 납품하기 위해 작업하였습니다. 특징은 산출물로 소스코드를 제공하지 않고 빌드된 Docker Image를 제공한다는 것과, 그들이 앞으로 사내 배포를 하는 과정에서 백엔드 도메인 등의 환경변수를 수정할 필요가 있어 보인다는 점이었습니다.

그러니까, 컨테이너를 생성할 때 docker run -e NEXT_PUBLIC_SERVER_DOMAIN=http://127.0.0.1:8080 my-image 와 같이 환경변수를 주입하고 이 값들이 Next.js 런타임의 응답 페이지에 반영되도록 하는 것이 요구사항이었습니다.

사실상 제가 아무런 설정을 하지 않아도 Next.jsstart script는 빌드 시점과 관계 없이 실행 시점에 컨테이너의 시스템 환경변수를 설정해 줄 것이기 때문에, 원하는 바를 이룰 수 있을 것 이라 생각했습니다. 하지만 아니었죠…😭

일단 환경변수 관련해서 설정을 하기 전에 Next.js환경변수 관련 공식문서를 훑어보면서 환경변수에 대해서 함께 공부해봅시다.

환경변수(Environment Variables)란?

환경변수는 말 그대로 프로그램이 동작하는 환경을 나타내는 변수값을 의미합니다. 프로그램에게 환경이란, 그 프로그램이 가동되고 있는 하드웨어 환경에서부터 OS 환경, 컴파일러나 인터프리터 환경 등 다양한 스코프로 존재할 수 있을 것인데요.

환경변수는 이러한 프로그램이 가동되는 다양한 환경에 의존성을 갖는 값으로, 일반적으로는 OS 레벨에서 사용자가(혹은 소프트웨어 스크립트가) 설정한 다양한 변수를 의미합니다. 환경 변수가 영향을 주는 스코프는 보통 OS 전역이나, 특정 사용자, 특정 프로세스 혹은 특정 프로그램에 한정될 수도 있습니다.

터미널에서는 printenv 명령어를 통해 OS에 현재 설정되어 있는 환경변수 값들을 확인할 수 있으며, source 명령어를 통해 터미널 config와 함께 등록하거나, export 명령어를 통해 현재 실행 환경에서 환경변수를 주입할 수도 있습니다.

Next.js에서도 당연히 환경변수Next.js 런타임에서 한정해 설정할 수 있도록 지원하고 있는데요. 루트 디렉토리의 .env* 파일에서 환경변수 값을 설정해줄 수 있습니다.

# .env

# 아래와 같이 key/value의 형태로 작성해줍니다.
PRIVATE_KEY="123456"
 
# 개행을 포함한 환경변수도 지정이 가능합니다.
PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
...
Kh9NV...
...
-----END DSA PRIVATE KEY-----"
 
# 혹은 \n 개행문자를 포함하여 작성할 수도 있습니다.
PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nKh9NV...\n-----END DSA PRIVATE KEY-----\n"

코드에서 이러한 환경변수 값을 사용하기 위해서 우리는 Node.js의 process.env객체를 사용하여 이에 접근할 수 있습니다. Next.js는 런타임 시 process.env를 초기화하여, 필요한 값만 노출되도록 처리합니다. Next.js 런타임이 환경변수 스코프를 생성해 Next.js 의 특정 스코프 내에서 접근이 가능하도록 process.env 객체를 override해준다고 생각하면 좋을 것 같습니다.

export async function GET() {
  const db = await myDB.connect({
    host: process.env.DB_HOST,
    username: process.env.DB_USER,
    password: process.env.DB_PASS,
  })
  // ...
}

Next.js클라이언트 환경변수서버 환경변수

프론트엔드 개발에서 환경변수는 참 애매한 녀석입니다. 환경변수와 같이 그 환경에 대한 정보를 나타내는 값들은 보통 모든 접근자들에게 이를 보여주기엔 보안 취약점으로 적용할 여지가 있는데요. 웹 브라우저에서 특정 코드를 실행시키기 위해서는 이러한 값들이 전부 클라이언트에게 전해져야 하기 때문입니다.

우리는 .env파일을 활용해 API key 등을 명확하게 은닉했다고 생각했지만, 실제로 번들링된 HTML, JavaScript 코드 안에서도 찾아보면 그 값들이 string으로 inject되어있는 것을 확인할 수 있습니다.

vitereact로 간단히 프로젝트를 생성해서 테스트해보겠습니다.

생성한 프로젝트 내에 .env로, VITE_ prefix를 포함한 환경변수를 작성해줍니다.

프로젝트의 루트 디렉토리에 위와 같이 useEffect를 통해 환경변수를 불러오고, 이를 통해 api를 호출하는 등의 동작을 수행해보겠습니다.

빌드를 한 뒤, npm serve를 통해 정적 파일을 배포하고 이에 접속해보면, 위와 같이 번들링된 소스코드 안에서 환경변수 위치에 string값이 주입되어 있는 것을 확인할 수 있습니다. 만약 사용자가 안 좋은 마음을 먹는다면, 소스에서 위와 같이 민감한 값들을 찾아내 불필요한 요청을 마구 보내 서비스를 마비시키거나 과금을 발생시키는 등의 공격을 할 수도 있겠죠.

이를 막을 수 있는 제일 좋은 방법은, 브라우저에 이러한 민감한 값들을 아예 전송하지 않는 것입니다.

그래서 Next.js와 같은 서버 인스턴스를 포함하고 있는 프론트엔드 프레임워크에서는, 그 정보의 은닉필요성에 따라 환경변수의 공개 범위를 선택적으로 선언할 수 있도록 지원합니다. Next.js에는 두 가지 종류의 환경변수가 있는데, 바로 클라이언트 환경변수서버 환경변수입니다.

클라이언트 환경변수(Client Environment Variables)

Next.js클라이언트 환경변수브라우저에서 접근 가능한 환경변수를 정의할 수 있게 해줍니다.

Next.js에서는 서버 코드와 클라이언트 코드가 명확하게 분리되어 작성되는데요. 환경변수NEXT_PUBLIC 접두사를 포함하면 클라이언트 코드. 즉 유저 브라우저에 전해지는 코드에서도 이 환경변수에 접근할 수 있게 됩니다. 한 번 직접 설정해볼까요?

위와 같이 루트 디렉토리의 .env파일 내부에, NEXT_PUBLIC_으로 시작하는 환경변수를 추가해줍니다.

그리고 클라이언트 코드인 Home Page 코드에서 위와 같이 환경변수를 불러와 이를 통해 api를 호출하는 등의 동작을 수행해봅니다.

정적 빌드 이후에 next start 명령어로 배포된 페이지에 접근해보면, 이번에도 위와 같이 번들링된 코드 안에서 환경변수 값을 찾아낼 수 있습니다.

그러니까 클라이언트 환경변수는 유저에게 그 값이 그대로 전부 전송되며, 유저에게 공개되어도 문제되지 않는 수준이어야 합니다. 그렇다면 유저에게 전달되어서는 안되는 값들은 어떻게 환경변수로 지정할 수 있을까요? 바로 서버 환경변수로 지정하는 것입니다.

서버 환경변수(Server Environment Variables)

Next.js는 프론트엔드를 위한 백엔드 서버 인스턴스를 가집니다. 서버 인스턴스가 생성되는 환경에 따라 다른 값으로 지정해줘야하는 변수들이 있을 때, 우리는 이를 서버 환경변수로 정의할 수 있습니다. 또한 서버가 의존하고 있으면서 외부에 노출되기를 원하지 않는 값들도 보통 서버 환경변수로 정의합니다. DB 호스트 주소, 써드파티 SDK API key 등이 대표적인데요.

Next.js의 서버 인스턴스를 통해서 배포되고 있는 프론트엔드 애플리케이션은, 사용자 페이지 요청 시에 Next.js 서버 동작의 결과물로 생성된 파일을 응답으로 전달받기 때문에 Next.js 런타임에서 어떤 환경변수를 가지고 있는지 접근해서 알아낼 수 없습니다. 클라이언트 환경변수와는 다르게 서버 환경변수는 사용자에게 전달되지 않는다는 것입니다.

이러한 특성을 활용하여 프론트엔드에서 민감한 값을 핸들링해야하는 경우, 정보 은닉 목적의 Proxy 같은 형태로 Next.js의 서버 인스턴스를 활용할 수 있습니다.

이번에도 직접 설정해서, 서버 코드에서의 환경변수 동작을 확인해보겠습니다.

프로젝트 루트 디렉토리에, .env파일을 생성해준 뒤 NEXT_PUBLIC 접두사를 포함하지 않은 key/value 형태의 환경변수를 작성해줍니다.

일단 클라이언트 환경변수에 접근했을 때처럼, app/page.tsx에서 useEffect로 접근해보겠습니다.

환경변수의 값이 출력되지 않고, undefined가 출력되는 것을 확인할 수 있습니다. 이처럼 NEXT_PUBLIC_ 접두사를 포함하지 않는 환경변수는 클라이언트 환경에서 접근할 수 없습니다.

이번에는 Next.js서버 코드를 작성해봅시다. 간단하게 api/config라는 디렉토리를 생성해준 뒤, GET 엔드포인트를 하나 작성해줍니다.

그리고 배포한 환경에서 /api/config에 접근해보니, 작성한 서버 코드환경변수에 접근하고 있는 것을 확인할 수 있습니다.

그렇다면 클라이언트 환경변수서버 코드에서 접근하는 건 어떨까요? 이번에는 .envNEXT_PUBLIC_ 접두사를 붙혀 클라이언트 환경변수를 작성해줍니다.

마찬가지로 api/config라는 GET 엔드포인트에서 이에 접근해줍니다.

배포한 뒤 접속해보면, 서버 코드에서는 클라이언트 환경변수에도 접근할 수 있다는 것을 확인할 수 있습니다.

Next.js의 환경변수 동결 시점

Next.js의 환경 변수 관련 공식 문서에는 다음과 같은 내용이 있습니다.

참고: 빌드된 후에는 앱이 이러한 환경 변수의 변경 사항에 더 이상 응답하지 않습니다. 예를 들어, Heroku pipeline을 이용하여 여러 환경에 특정 환경에서 구축한 slug를 promote하거나, 단일 도커 이미지를 빌드하여 여러 환경에 배포하는 경우, NEXTPUBLIC 변수는 빌드시의 값으로 동결되므로, 프로젝트가 빌드될 때 이러한 값을 적절하게 설정해야 합니다. 런타임 환경 값에 액세스해야 하는 경우 클라이언트에 제공할 수 있는 자체적인 API를 설정해야 합니다.

Next.js는 코드 내에서 사용되는 process.env.NEXT_PUBLIC_BACKEND_URL과 같은 클라이언트 환경변수 구문들을 빌드 시점에 동결된다는 것입니다. 이는 곧 클라이언트 환경변수의 런타임 설정이 불가능하다는 것을 의미합니다

정리하자면

환경클라이언트 환경변수서버 환경변수
클라이언트 코드접근 가능접근 불가
서버 코드접근 가능접근 가능

클라이언트 환경변수는 사용자에게 전달됩니다. 그리고 빌드 시점은 항상 사용자에게 페이지가 전달되는 것보다 앞서 있기 때문에, 사용자에게 전달되는 시점에는 이미 process.env….라는 구문이 string으로 치환된 이후일 것입니다.

서버 환경변수는 사용자에게 전달되지 않습니다. Next.js 서버 인스턴스의 런타임에만 존재하며, 빌드 이후에도 process.env 구문이 그대로 살아있어, 시스템 환경변수에도 접근할 수 있습니다.

이렇게 클라이언트 환경변수와 서버 환경변수가 구별되는 특징을 갖는 이유는, Node.jsprocess.env 객체가 동작하는 OS환경에 의존하기 때문입니다. 만약 클라이언트 환경변수가 string으로 치환되지 않은 상태로 사용자에게 전달된다면, 사용자 브라우저 환경에 의존하여 그 값을 불러올 것입니다(애초에 브라우저에는 Node.jsprocess객체가 없기도 하구요.). 그렇게 되면 사용자 환경에 따라 다른 결과물을 나타내는 특이한 형태의 애플리케이션이 되겠지요. 일반적으로 프론트엔드 산출물은 그런 결과물을 원치 않습니다.

각설하고 핵심만 정리하면, 클라이언트 환경변수는 빌드 시점에 string값으로 치환되는 변수인 것입니다.

Next.js런타임 환경변수 설정하기

만약, 이미 빌드되어 있는 Next.js 산출물에 대해서 여러 환경에서 배포를 진행한다면 어떻게 될까요? 서버 환경변수시스템 환경변수에 접근하기 때문에 배포 환경에 따라 다른 값들을 불러올 수 있겠지만, 클라이언트 환경변수는 이미 string으로 치환되어 있기 때문에 배포 환경의 시스템 환경변수에는 영향을 받지 못할 것입니다. 이는 클라인트 환경변수를 런타임에 설정할 수 없음을 의미합니다.

예를들어 빌드된 프론트엔드 산출물이 바라보게 될 백엔드 도메인이 확정되지 않은 상태에서 백엔드 도메인클라이언트 환경변수로 선언되어 있다면, 우리는 백엔드 도메인이 바뀔 때마다 클라이언트 환경변수를 수정하고 새로 빌드해야할 것입니다.

이러한 문제를 해결하기 위해서 저는 빌드 시점 이후의 환경변수 값의 갱신이 클라이언트에 반영되도록 하는 방법에 대해서 찾아보았습니다. 각 방법은 우리의 문제를 해결해줄 수 있지만, 조금씩 그 적용 스코프와 필요한 코드 플레이트가 다릅니다. 상황에 따라서 필요한 방법을 적용하면 좋을 것 같습니다.

1. 서버 환경변수를 반환하는 Next.js 서버 엔드포인트 활용

첫 번째 방법은 서버 환경변수를 활용하는 것입니다. 서버 환경변수는 빌드 시점에 치환되지 않고 Next.js 서버의 런타임에 값을 불러오기 때문에 배포하는 환경에 따라 다른 값을 주입할 수 있습니다. 따라서 Next.js 서버에서 서버 환경변수를 받아오는 엔드포인트를 작성한다면, 클라이언트에서도 빌드 이후에 반영된 런타임 환경변수를 사용할 수 있게 됩니다.

// src/app/api/config/route.ts
import { NextRequest, NextResponse } from "next/server";

export async function GET(req: NextRequest) {
  const configs = {
    backendUrl: process.env["BACKEND_URL"],
  };

  return NextResponse.json(configs);
}

위와 같이 환경변수를 프로젝트 루트에 설정해주고, 이를 불러와 값으로 반환해주는 Next.js 서버 코드를 작성해줍니다.

// src/component/Config.tsx
const Config = () => {
  const [isLoading, setIsLoading] = useState<boolean>(true);
  const [configs, setConfigs] = useState<Config>();

  useEffect(() => {
    getConfig()
      .then(setConfigs)
      .finally(() => setIsLoading(false));
  }, []);

  return (
    <>
      <h1>Backend URL</h1>
      <h1>{isLoading || !configs ? "Loading..." : configs.backendUrl}</h1>
    </>
  );
};

그리고 간단하게 서버에서 값을 불러와, 화면에 띄워주는 컴포넌트를 작성해봅시다.

Next.js 서버에서 환경변수 값을 잘 받아오네요! 근데 보시다시피 (당연히)로딩이 발생합니다.

위와 같이 설정을 하게 되면 사용자가 페이지에 접근해 Next.js 서버에서 페이지 정보를 받아온 뒤, 그 페이지가 브라우저에서 한 번 더 Next.js 서버를 호출하는 구조로 동작하게 됩니다. 만약 브라우저에 페이지 정보를 전달할 때 환경변수를 직접 주입해 전달한다면, 이렇게 불필요하게 다시 한 번 Next.js 서버를 호출할 일은 없을 것 같은데요. 불필요한 호출이 일어난다는 것도 문제인데, 이것으로 인해 추가적인 로딩이 발생하는 것도 UX에 악영향을 줄 것 같습니다. 그래서 저는 Next.js 서버를 다시 호출하는 방식이 아닌 다른 방법을 찾아보았습니다.

2. Node.js의 fs 모듈을 활용해 클라이언트 window 객체에 주입

두 번째 방법은 .env 파일을 파싱해서 window객체에 주입하는 스크립트를 작성하는 방식입니다. kakao FE 기술블로그에서 발견한 방법인데, 환경 변수 URL 방식이라고 이 방식을 칭하고 있습니다. 원문에서는 배포, 개발 환경 등을 고려하여 스크립트를 꽤 복잡하게 작성하였는데, 저는 최대한 간단한 방식으로 작성해보도록 하겠습니다. 또한 제가 작성하는 스크립트는 서버 환경변수를 고려하지 않습니다.

npm install find-up
npm install dotenv --save

먼저, 가장 가까운 depth에서 원하는 파일명을 탐색하도록 도와주는 모듈인 findUp 라이브러리와, 환경변수 파일을 불러와서 JavaScript Object로 변환해주는 dotenv를 설치해줍니다.

// src/app/utils/env/index.js
import dotenv from "dotenv";
import { findUp } from "find-up";
import { realpathSync, writeFileSync } from "fs";

// .env파일 탐색
const envFilePath = await findUp(".env");
// .env파일 내의 환경변수 정보 Object로 파싱
const parsed = dotenv.config({ path: envFilePath }).parsed || {};

// 클라이언트 HTML에 주입되어 있는 스크립트 파일 주소
const scriptFilePath = `${realpathSync(process.cwd())}/public/env.js`;
// 스크립트 파일에 window._env 객체에 parsed를 할당하도록 함
writeFileSync(scriptFilePath, `window._env = ${JSON.stringify(parsed)}`);

console.log("env file parsed. create window._env object...");

그리고 CLI로 동작시킬 유틸 함수 스크립트를 작성해줍니다. 이 스크립트를 실행시키면, public/env.js 파일에 .env파일 정보들을 불러와 Object형태로 window._env에 할당하는 구문을 자동으로 작성해줍니다.

위의 스크립트를 실험해보기 위해, public폴더에 env.js 파일을 생성해줍니다.(스크립트 동작 결과로 덮어씌워지기 때문에 내용물은 상관 없습니다.)

node src/utils/env

프로젝트 루트 디렉토리의 .env에 주입시킬 환경변수들을 작성해놓고, 작성한 스크립트를 작동시켜봅니다.

원하는 대로 스크립트가 생성된 것을 확인할 수 있습니다. 이제 이 스크립트를 클라이언트에게 전달해봅시다.

// src/app/layout.tsx
// ...
export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <head>
        <script src="/env.js" async></script>
      </head>
      // ...
    </html>
  );
}

루트 레이아웃의 head 태그 안에, script태그로 동적 생성되는 public 폴더 내의 스크립트를 주입해줍니다.

// index.d.ts
interface Window {
  _env?: { [key: string]: string };
}

// 아래와 같이 환경변수의 key들을 strict하게 정의해줄 수도 있습니다.
interface Window {
  _env?: {
    BACKEND_URL: string;
    PRIVATE_KEY: string;
  };
}

window 객체의 _env property는 존재하지 않는 인터페이스이기 때문에, 타입 에러가 발생하지 않도록 루트 디렉토리에 index.d.ts 파일을 작성해줘서 Window 인터페이스를 오버라이드 해줍니다.

// src/components/Config.tsx
// ...
const Config = () => {
  const [backendUrl, setBackendUrl] = useState<string>();
  const pollingTimeout = useRef<NodeJS.Timeout>();

  const polling = () => {
    if (backendUrl) return;
    if (window._env) setBackendUrl(window._env["BACKEND_URL"]);
    else pollingTimeout.current = setTimeout(polling, 100);
  };

  useEffect(() => {
    polling();

    return () => clearTimeout(pollingTimeout.current);
  }, []);

  return (
    <>
      <h1>Backend URL</h1>
      <h1>{backendUrl ?? "Loading..."}</h1>
    </>
  );
};

public/env.js 모듈을 통해 window에 환경변수가 주입되는 시점이 React Component가 마운트되어 상태값이 초기화되었을 때보다 항상 빠르다는 확신이 없기 때문에, 간단하게 폴링 방식의 코드를 작성하여 window._env가 갱신되었는지 확인한 뒤, 화면에 출력해줍니다.

// package.json
// ...
  "scripts": {
    "dev": "node src/utils/env && next dev",
    "start": "node src/utils/env && next start",
    // ...
  },
// ...

마지막으로 dev 스크립트start 스크립트Next.js 서버 실행시마다 앞서 작성했던 .env를 파싱하는 스크립트 동작 구문을 추가해주어서, 작성해둔 public/env.js 스크립트가 서버 실행시마다 갱신될 수 있도록 설정해줍니다.

서버를 실행해보면, 무사히 환경변수가 클라이언트에게 도달하는 것을 확인할 수 있습니다.

하지만 앞서 Next.js 엔드포인트를 통해 서버 환경변수를 불러오는 방식에서 있었던 것과 같이 로딩이 발생하는 것을 확인할 수 있습니다. window객체에 대한 타입 선언을 통해 런타임 환경변수의 타입을 자연스럽게 단언해주는 부분까지는 좋았지만, 마찬가지로 UX에 영향을 줄 것으로 보입니다.

위 두 가지 방법은 초기 로딩 문제를 가집니다.

살펴봤던 것처럼, 앞선 두 가지 방법은 서버에 환경변수 목록을 받아오는 동안, 혹은 window 객체를 초기화하는 스크립트가 동작하는 동안, 클라이언트 측에서 환경변수에 접근할 수 없다는 문제점이 있습니다. 물론 이 순간은 매우 짧고, 두 번째 방법의 경우에는 네트워크 환경변수를 받아오는 데에 네트워크 의존성이 없기 때문에 크게 문제되지 않을 것 같아 보이는데요.

하지만 만약 지금 우리처럼 또 다른 서버의 도메인을 그 응답값에 포함시켜 그 도메인으로 API 호출을 해야한다면, 단순히 전역 변수 도메인값을 참조해서 호출하는 API 호출 코드를 초기 서버 환경변수 초기화 이후에 동작하도록 모두 수정해야할 수도 있습니다.

const apiBackend = axios.create({
  baseURL: process.env.NEXT_PUBLIC_BACKEND_URL ?? "",
});

export const getProfile = async () =>
  apiBackend.get("/profile").then((res) => res.data as Profile);

만약 위와 같은 코드를 통해 백엔드와 통신하고 있다고 하면, 우리가 앞서 작성한 예제에서처럼 환경변수 객체 초기화를 기다렸다가 동작하도록 이를 모두 수정해주어야할 것입니다. axios 객체를 생성한 뒤에 여러 파일에서 끌어다 쓰고 있는, 일종의 싱글톤 구조로 백엔드 API와 통신하고 있다면 proxy처럼 동작하는 폴링 객체를 선언해주어야겠지요.

let axiosInstance: AxiosInstance | null = null;

const createAxiosInstance = async (): Promise<AxiosInstance> => {
  if (axiosInstance) return axiosInstance;

  // 여기서 waitForEnv는 env 초기화를 기다리는 폴링 함수를 호출합니다.
  const env = await waitForEnv();
  axiosInstance = axios.create({
    baseURL: env.BACKEND_URL,
  });

  return axiosInstance;
};

export const getProfile = async () =>
  (await createAxiosInstance())
    .get("/profile")
    .then((res) => res.data as Profile);

아마 위와 같은 패턴이 될 것입니다. 이런 환경변수 설정 때문에 기존의 코드 베이스를 모두 고쳐주어야한다는 점도 그렇고, 애초에 초기화를 기다리는 로딩이 발생하는 게 조금 불편하네요. 이번에는 결국 제가 채택하게 된, 초기화를 기다리지 않아도 되는 방법을 한 번 알아보겠습니다.

3. 정적 빌드 산출물에서 string으로 대체된 bundled code에 직접 접근 및 수정

세 번째 방법은 빌드 결과물에서 환경변수의 key값을 쉘스크립트로 직접 찾아서 수정하는 것입니다. 꽤 무식한 접근방법 같지만 효과적이고, 확실한 방법입니다.

// src/components/Config.tsx
const Config = () => (
  <>
    <h1>Backend URL</h1>
    <h1>{process.env.NEXT_PUBLIC_BACKEND_URL}</h1>
  </>
);

일단 클라이언트 환경변수로 위와 같이 필요한 부분에서 값을 호출해줍니다.

그리고 .env파일에, 위와 같이 고유 식별 가능한, 절대 중복되지 않는 식별자를 해당 환경변수의 value값으로 지정해줍니다. 번들링 결과에서 중복되지 않을 것으로 예상되는 값을 지정해주어야 합니다.

일단 빌드를 해보면, 당연히 위와 같은 모양으로 지정해준 value가 출력되는 것을 확인할 수 있겠지요.

#!/bin/bash
# entrypoint.sh

echo "backend url setting... $NEXT_PUBLIC_BACKEND_URL"

if [ -z "$NEXT_PUBLIC_BACKEND_URL" ]; then
  echo "Error: NEXT_PUBLIC_BACKEND_URL is not set. Exiting..."
  exit 1
fi

find .next \
  \( -type d -name .git -prune \) -o -type f -print0 | \
  xargs -0 gsed -i "s#__ENV_NEXT_PUBLIC_BACKEND_URL#$NEXT_PUBLIC_BACKEND_URL#g"

echo "Backend URL replacement completed successfully."

자, 이제 위와 같이 스크립트를 작성해줍니다. Next.js 빌드 산출물인 .next 디렉토리의 모든 파일 내에서, 우리가 설정한 __ENV_NEXT_PUBLIC_BACKEND_URL이라는 값을 전부 찾아 프로세스가 가동되는 환경에서의 NEXT_PUBLIC_BACKEND_URL 환경변수로 이를 치환해주는 내용입니다.

  • 저는 macOS 환경이기 때문에, sed가 아닌 gsed를 사용했습니다.

루트 디렉토리에 entrypoint.sh라는 이름으로 스크립트를 작성해준 뒤에, chmod 755 entrypoint.sh 명령어로 실행 권한을 설정해줍니다.

NEXT_PUBLIC_BACKEND_URL=http://localhost:8080 ./entrypoint.sh

그리고 위와 같이 환경변수를 프로세스에 주입하면서 스크립트를 실행해줍니다. 동작에 성공했다는 echo 명령어가 실행되면, 다시 서버를 실행해봅니다.

바로 스크립트 결과가 반영되어있는 것을 확인할 수 있습니다. 사용자에게 페이지가 전달되기 전, 사실상 Next.js 입장에서는 정적 페이지 생성 시에 이미 값이 주입되어 있는 것이기 때문에, 앞선 방법과 같이 환경변수의 초기화를 기다려줄 필요도 없습니다.

이렇게 쉘 스크립트로 직접 환경변수를 주입해주는 방식은, 두 가지 큰 특징이 있습니다.

3-1. 안정성 문제

번들링된 코드에 직접 접근해서 값을 key로 매칭해 치환하는 방법이기 때문에, 일단 빌드 시점에 환경변수value가 번들링 이후의 코드에서도 유일할 것이 예상되어야 합니다. 만약 어디서 반복 사용될지 모르는 단순한 string값을 이 key로 사용하게 되면, 기껏 빌드한 코드들이 정상적으로 동작하지 않는 문제가 발생할 것입니다. 그리고 번들링된 값들에서 이러한 key가 유일할 것이라는 보장은 없기 때문에, 빌드 산출물에 대한 테스팅이 이루어지지 않으면 문제로 이어질 수 있는 방법입니다.

3-2. 스크립트 적용의 일회성(컨테이너 환경에 적합)

또한 이 방식은 기본적으로 일회용입니다. 예를 들어, 빌드 산출물을 A 환경에 가지고 와 이 방법을 통해 환경변수를 주입하고 배포한 뒤에, 환경변수 설정에 문제가 생겨 이를 수정하려고 하면 스크립트를 재실행하는 것으로는 복구가 불가합니다. 빌드 산출물을 가지고 오는 것부터 다시 시작해야하지요.

만약 이게 불편하다면, 치환 결과에서도 key를 유지하도록 하고 환경변수를 불러올 때 이 key값을 제거하여 반환하는 유틸성 함수를 정의하는 등 재활용이 가능하도록 설정할 수도 있어 보입니다.

우리가 도커 이미지를 통한 배포를 가정한다면, 특히 이 문제에서 자유로워질 수는 있습니다. 도커 이미지는 빌드 산출물을 안에 포함하고 있으며, 추가적인 설정을 통해 컨테이너를 생성하기 직전에 추가적인 스크립트를 실행하도록 만들 수 있는데요. 이를 통해 컨테이너가 생성될 때마다 빌드 시점의 스냅샷을 가져온 뒤 우리가 작성한 entrypoint.sh를 실행시키고 배포하는 식으로 동작시킬 수 있습니다.

도커에 런타임 환경변수 설정하기

VercelNext.js 이미지 빌드를 위한 Dockerfile을 제공합니다. Next.js Deploying 공식문서에서 제공하는 Dockerfile을 가져와, 주석을 제거하고 적당히 환경에 맞게 수정해주었습니다.

# syntax=docker/dockerfile:1

ARG NODE_VERSION=21.1.0
ARG PNPM_VERSION=9.3.0

FROM node:${NODE_VERSION}-alpine as base
WORKDIR /usr/src/app
RUN --mount=type=cache,target=/root/.npm \
  npm install -g pnpm@${PNPM_VERSION}

FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /usr/src/app
COPY package*.json tsconfig*.json pnpm-lock.yaml* ./
RUN corepack enable pnpm && pnpm i --frozen-lockfile

FROM base AS builder
WORKDIR /usr/src/app
COPY --from=deps usr/src/app/node_modules ./node_modules
COPY . .
# ------------------------- 해당 부분을 수정해줍니다.
RUN corepack enable pnpm && NEXT_PUBLIC_BACKEND_URL=__ENV_NEXT_PUBLIC_BACKEND_URL pnpm run build
# -------------------------

FROM base AS runner
WORKDIR /usr/src/app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder usr/src/app/public ./public
RUN mkdir .next
RUN chown nextjs:nodejs .next
COPY --from=builder --chown=nextjs:nodejs usr/src/app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs usr/src/app/.next/static ./.next/static
# ------------------------- 해당 부분을 추가해줍니다.
COPY --from=builder --chown=nextjs:nodejs usr/src/app/entrypoint.sh ./entrypoint.sh
# ------------------------- 
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"

# ------------------------- 해당 부분을 추가해줍니다.
ENTRYPOINT [ "/usr/src/app/entrypoint.sh" ]
# ------------------------- 
CMD ["node", "server.js"]

그리고 위와 같이 환경변수 주입을 위한 설정들을 일부 추가해줍니다. 이제 이 Dockerfile을 통해 이미지를 빌드해주면,

docker run -p 3000:3000 -e NEXT_PUBLIC_BACKEND_URL=http:/localhost:8080 my-image-name

위와 같은 명령어로 컨테이너 생성시 시스템 환경변수NEXT_PUBLIC_BACKEND_URL의 값이 주입되었을 때 서버 실행 스크립트가 동작하기 직전에 entrypoint.sh 스크립트가 빌드 산출물 안에 원하는 값을 주입해주게 됩니다.

마치며

처음에는 시스템 환경변수를 통해 간단히 해결 가능할 것 같다고 느꼈던 요구사항이었습니다. 근데 Next.js에서의 환경변수 분류에 따른 동작 방식 차이로 인해 생각했던 만큼 간단한 문제가 아니더라구요. 그리고 컨테이너 환경에서 시스템 환경변수를 Next.js 런타임이 불러오지 못하는 문제가 있었습니다.

앞서도 함께 살펴봤던 여러 테스트를 통해 클라이언트 환경변수서버 환경변수 모두 Next.js 서버 코드에서 읽어낼 수 있었는데요. 그리고 둘 모두 시스템 환경변수가 반영되었죠. 근데 이미지를 빌드하고 시스템 환경변수를 주입하면서 컨테이너를 생성하면, 그게 제대로 반영되지 않는 현상을 계속 볼 수 있었습니다.(.env 내의 환경변수는 잘 읽어오던데…)

단순하게 이 현상을 해결한다면, 시스템 환경변수 → 서버 환경변수 → Next.js 서버 → 클라이언트의 순서로 주입될 것이었고 제가 선택한 방법은 시스템 환경변수 → 클라이언트 환경변수 → 클라이언트의 순서로 주입하는 것이었어요.

아무튼, Next.js 덕분에 이 참에 환경변수에 대해서 제대로 알아본 것 같네요. 읽어주시는 분들에게도 나름 괜찮은 정보가 되었길 바라며 마치겠습니다! 긴 글 읽어주셔서 감사합니다 😊

profile
DIVIDE AND CONQUER

2개의 댓글

comment-user-thumbnail
2024년 11월 26일

흥미롭게 문제를 해결하는 과정 잘 봤습니다.! 런타임마다 클라이언트 쪽의 환경변수를 바꿔야 하는 문제가 다소 일반적인 것 같진 않지만 또 은근 흔한 문제일 거 같아서 더 쉬운 방법이 없을까 저도 검색해봤는데 https://www.npmjs.com/package/next-runtime-env 라는 라이브러리가 있더라구요. App Router도 잘 지원하네요.

서버 쪽에서 그냥 process.env 읽어서 window에 넣어주도록 하되, 별도 파일로 만드는 게 아니라 Next.js 에서 지원하는 Sciprt 태그에 strategy="beforeInteractive" 이거랑 dangerouslySetInnerHTML 요걸로 넣어주던데, 이렇게 하니까 완전 시작 Html 파일에서 이미 head 안에 들어가있어서 타이밍이나 로딩 문제도 해결될 거 같습니다! (굳이 라이브러리 안쓰고 본 방법 그대로 직접 구현해도 할법할 것 같아요) 혹시 해당 방법은 안되는지도 궁금합니다. 안정성은 확실히 올라갈 것 같아서요!

1개의 답글

관련 채용 정보