blog logo
Published on

Runtime 환경 변수 설정으로 빌드 프로세스 개선하기


기존 빌드 프로세스 방식

현재 사내에서 Next.js를 통해 각 환경(alpha, beta, prod 등)마다 환경 변수를 만들어서 빌드 타임에 해당하는 환경 변수들을 주입하는 방식으로 개발을 하고 있다. 환경을 분리하고 환경마다 필요한 환경 변수를 실행시키는데에 큰 문제가 없지만 다른 두 가지 문제가 있었다.

가장 먼저 환경별로 빌드 프로세스를 만들어야 하는 불편함이 존재했다. 현재 사내에서 하나의 환경이 추가될 때마다 아래와 같은 프로세스를 거치게 되는데, 아래 프로세스를 만들고 각 환경마다 빌드를 해주어야 한다면 매우 귀찮을 것이다.

.env.{환경} 파일 생성 -> dockerfile 생성 -> {환경} 빌드 명령어 추가 -> {환경} 빌드

두번째로는 환경별로 빌드를 해야하며 빌드 결과물들은 해당 환경에 종속될 수 밖에 없는 구조를 가지고 있다. 종속된 구조는 API 정보, 로깅 레벨 등 동일한 코드를 다른 환경(alpha -> prod)에 배포해야 한다면 반드시 다시 빌드를 해야한다. 이유는 Next.js에서 기본으로 제공하는 환경 변수 방식은 반드시 빌드를 진행해야 하기 때문이다. 즉, 서버를 띄우는 런타임에는 환경 변수를 경험할 수 없다.

런타임에 환경 변수를 경험할 수 없는 뜻은 각 환경 별로 빌드를 할 경우 빌드 타이밍에 환경 마다 결과가 달라질 수 있다는 뜻이다. 이러한 결과를 보게 된다면 빌드에 대한 신뢰가 깨질 것이다.

그래서 Next.js에서는 런타임에 환경 변수를 주입할 수 있도록 Runtime Configuration을 제공한다. 하지만 이 방법은 SSG(Static Site Generator)에서는 동작하지 않고 SSR(Server-Side Rendering)에서만 동작하기 때문에 현재 프로세스를 개선하는 방향에서는 좋지 않다.

그래서 기존에는 어떻게 설정했었고, 현재 개선 방향과 어떻게 개선했는지 기록해 보려고 한다.



기존 CRA 환경 분리 설정

먼저 환경이 alpha, beta, prod 등 세 가지가 있다고 가정하자. 그러면 먼저 dotenv 파일을 만들어 주었다.

donenv 파일 생성 이미지

그 다음 env-cmd 라이브러리를 사용해서 각 환경마다 실행시키고 싶은 dotenv 파일을 강제로 우선 순위를 가져와서 사용했다.

$ npm i -D env-cmd

그 다음 실행 명령어를 추가할 때 -f 옵션을 통해 강제로 우선 순위를 가져왔다.

// package.json

"scripts": {
    "beta": "env-cmd -f .env.development next dev",
    "alpha": "env-cmd -f .env.alpha next dev",
    "prod": "env-cmd -f .env.prod next dev",
    "build:beta": "env-cmd -f .env.beta next build",
    "build:alpha": "env-cmd -f .env.alpha next build",
    "build:prod": "env-cmd -f .env.production next build",
    "start": "next start",
  }

그리고 환경과 NODE_ENV도 구분을 지어야 하는 상황이 온다. 만약 아래 이미지처럼 환경을 만들어야 한다면 어떻게 해야할까?

5가지 환경에 대해 각 환경 이름과 NODE_ENV 모드

먼저 NODE_ENV는 노드를 실행할 때의 모드를 말하는데,

  • 개발 모드(development)
  • 배포 모드(production)
  • 테스트 모드(test)

노드 실행 모드는 총 세 가지로 나뉜다. 그리고 각 모드에 대한 실행 명령어는 다음과 같다.

  • development: next dev 로 실행될 때
  • production: next build 로 애플리케이션이 빌드될 때
  • test: jest 등을 통해 애플리케이션이 테스트 중일 때

1. 환경: alpha, NODE_ENV: development 설정 예시

스크립트 실행 명령어와 도커파일을 다음과 같이 설정한다.

// package.json

"scripts": {
    "alpha": "env-cmd -f .env.alpha next dev",
  }
// Dockerfile-alpha

// ...생략
RUN npm install

COPY . .
RUN rm -rf ./.git

EXPOSE 3000

CMD ["npm", "run", "alpha"]

2. 환경: beta, NODE_ENV: production 설정 예시

로컬에서 개발할 때는 npm run beta로 실행한다. 그러면 development 모드로 실행이 된다. 그리고 빌드 후 어플리케이션의 상황을 테스트해보고 싶을 때 build && start 를 실행해서 production 모드일 때 화면을 테스트해 볼 수 있다.

// package.json

"scripts": {
    "beta": "env-cmd -f .env.beta next dev", // 로컬에서 개발할 때
    "build:beta": "env-cmd -f .env.beta next build",
    "start": "next start",
  }
// Dockerfile-alpha

// ...생략
RUN npm install

COPY . .
RUN rm -rf ./.git

RUN npm run build:beta

EXPOSE 3000

CMD ["npm", "run", "start"]



런타임 환경 변수 설정으로 빌드 프로세스 개선

사실 위의 설정들만으로도 충분히 프로젝트 내에서 환경을 분리하고 각 환경마다 환경 변수들을 관리할 수 있다. 하지만 각 환경마다 빌드를 개별적으로 진행해야 하는 번거로움이 따른다. 그리고 환경 변수가 여러개일 경우 웹팩은 번들링 과정에서 모든 환경을 다 돌면서 문자열로 치환을 하는데, 그러다보니 이 때 불 필요한 과정들을 거치게 된다.

지금까지 진행해 온 빌드 프로세스는 다음과 같다.

기존 빌드 프로세스 도식화

현재 빌드 프로세스를 개선하고자 하는 방향은 아래 이미지와 같다.

개선하고자 하는 빌드 프로세스 도식화

각 환경마다 빌드 프로세스를 계속 만드는 것이 아닌, 하나의 빌드만으로 여러개의 빌드 프로세스를 관리하는 방식이다. 그래서 환경 변수를 런타임에 생성함으로써 환경 변수를 하나로 통합하는 과정을 만드려고 한다.


1. 환경별로 dotenv 파일 파싱

먼저 각 dotenv 파일(ex. env.alpha, env.beta 등)을 파싱하는 함수를 만든다.

// src/utils/cli.mjs

import { findUp } from "find-up";
import { config } from "dotenv";

export async function parseDotenv(appEnv) {
  // dotenv 파싱
  const envFilePath = await findUp(`.env.${appEnv}`);
  const parsedEnv = config({ path: envFilePath }).parsed || {};

  return parsedEnv;
}

필요한 라이브러리 설치

$ npm i findUp
$ npm i dotenv

2. 파싱 된 환경 변수를 클라이언트에서 사용할 수 있도록 설정

// src/utils/cli.mjs

import { realpathSync, writeFileSync, copyFileSync } from "fs";

export function writeEnv(parsedEnv) {
  // 파싱 된 내용을 /public/__ENV.js에 출력
  const scriptFilePath = `${realpathSync(process.cwd())}/public/__ENV.js`;

  writeFileSync(scriptFilePath, `window.__ENV = ${JSON.stringify(parsedEnv)}`);
}

필요한 라이브러리 설치

$ npm i fs

만약 next.js를 사용하고 있다면 아래 설정도 해주어야 한다.

// next.config.js

const nextConfig = {
  // ... 생략
  webpack: (config) => {
    config.resolve.fallback = { fs: false };
    return config;
  },
};

module.exports = nextConfig;

3. 환경 변수를 서버에서도 사용할 수 있도록, .env.${환경}을 .env 파일에 복사

// src/utils/cli.mjs

import { realpathSync, writeFileSync, copyFileSync } from "fs";

export async function copyEnv(appEnv) {
  // 파싱 대상 파일은 '.env'파일로 복사
  const envFilePath = await findUp(`.env.${appEnv}`);
  const dotenvFilePath = `${realpathSync(process.cwd())}/.env`;

  copyFileSync(envFilePath, dotenvFilePath);
}

4. 스크립트 실행 명령어 파일 작성

// src/utils/cli.mjs

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, beta, prod)",
      });
    },
    async function handler(args) {
      const appEnv = args.e || args.env || "dev";

      const parsedEnv = await parseDotenv(appEnv); // dotenv 파싱
      writeEnv(parsedEnv); // 환경 변수 스크립트 파일 생성
      await copyEnv(appEnv); // .env 파일 복사

      return parsedEnv;
    },
  )
  .parse();

필요한 라이브러리 설치

$ npm i yargs

이번 빌드 프로세스를 개선하면서 yargs라는 라이브러리를 처음 알게 되었다. 커맨드라인을 만들어주는 라이브러리인데 스크립트를 실행할 때 수행할 함수들과 문구들을 넣어줄 수 있다.


5. 설정한 환경 변수 사용

아래와 같이 스크립트를 추가해서 클라이언트에서도 사용할 수 있게 한다.

// src/index.js

import Script from "next/script";

export default function Home() {
  console.log("환경 변수: ", process.env.NEXT_PUBLIC_ENV_KEY);

  return (
    <>
      <Script src="/__ENV.js" />
    </>
  );
}

이제 클라이언트에서는 window.__env 객체를 통해 환경 변수에 접근할 수 있고, 서버에서는 process.env 객체를 통해 환경 변수에 접근할 수 있게 되었다.


6. 실행 명령어 추가

// package.json

"scripts": {
    "alpha": "node ./utils/cli.mjs next-env --env=${APP_ENV:-alpha} && next dev",
    "beta": "node ./utils/cli.mjs next-env --env=${APP_ENV:-beta} && next dev",
    "prod": "node ./utils/cli.mjs next-env --env=${APP_ENV:-prod} && next dev"
    "build": next build,
    "start": next start,
}

이제 환경에 따라 각 환경 변수는 env 파일로 복사를 하게 된다.

해당 환경의 dotenv 변수를 env 파일로 복사

그러면 이제 npm run build를 통해 .env 파일을 실행하면서 해당 환경에 대한 환경 변수를 사용하게 된다. 이로써 각 환경별로 빌드를 할 필요가 없게 되며, 빌드 환경에 종속되지 않게 된다.


7. 도커 파일 작성

// Dockerfile-beta

// ...생략

RUN npm run build

EXPOSE 3000

CMD ["npm", "run", "start"]

npm run {환경} 명령어를 실행하고 개발 후 별도의 빌드 명령어 없이 한 번의 npm run build로 해당 환경을 빌드할 수 있다.




글을 마치며

이번 블로그 글은 카카오 기술 블로그에 있는 빌드 프로세스 개선하기 글을 많이 참고하며 공부를 했다. 현재 사내에서 배포 환경이 계속 추가되고 있고, 배포 환경이 늘어날 때마다 각 환경에 독립적인 환경을 가질 수 있는 방법이 무엇이 있을까? 찾다가 발견하게 된 글이다. 이전에는 env-cmd 라이브러리를 통해 dotenv 파일 우선 순위를 강제로 변경해서 환경을 관리했다. 그래서 각 환경마다 독립적으로 사용할 수 있는 환경 변수를 적용하면서 무리없이 배포할 수 있는 환경을 갖추었다.

하지만 각 환경이 늘어날 때마다 해당 환경에 대한 파일들을 계속 만들어가야 했고, 또 각각 빌드를 해주어야 하는 불편함이 생겼다. 그래서 이를 해결해 줄 수 있는 가장 좋은 해결책이 런타임에 빌드 프로세스를 만드는 것이라고 생각했다. 충분히 적용해 볼 만한 내용이었고, 프로젝트에 적용하기에도 어려움이 없어서 그대로 사용해보고 블로그에 기록하게 되었다.

빌드 프로세스를 개선하면서 한 가지 어려움이 있었는데, 바로 commonJS 방식과 ESM(ECMAScript Modules)의 방식이 잘 적용되지 않았던 문제이다. 개선 코드에서 findUP이라는 라이브러리를 설치하고 사용해야 하는데, 어찌된 이유인지 commonJS 방식으로 가져올 수가 없었다. 그래서 package.json에 type: module을 추가해줌으로써 import/export로 해당 모듈을 불러왔다. 하지만 .js 확장자에 type: module이기 때문에 ES 모듈로 취급하기 때문에 CommonJs 스크립트로 처리하려면 확장자를 바꾸라는 에러 메시지가 터미널에 등장했다... 그리고 친절하게도 이 next.config.js Loading Error도 같이 에러 문구를 제공해주었다.

그래서 에러 안내 페이지에 나와있는 것처럼 .mjs 확장자로 변경했더니 해결해 줄 수 있었다! mjs 확장자를 사용함으로써 CommonJs, ESM 방식 모두 한 파일 내에 공존하게 하는 방식이다. 현재 이게 옳은 방식인지는 정확히 판단이 안되지만 확장자로 인해 문제가 발생하고 있지 않기 때문에 그대로 적용했다.

환경 변수를 동적으로 가져옴으로써 각 한경마다 빌드 > 배포를 하지 않는 이 방식은 정말 편리한 것 같다. 사내 컨플루언스에도 정리를 하고 공유를 한 상태이다. 환경이 계속 늘어나고, 많아진다면 충분히 적용해 볼만한 내용이라고 생각한다.




github code


Reference