Devin.KR

Node.js · 기본

Node.js로 만드는 작은 API

Node.js 환경변수와 설정 - process.env 검증과 --env-file, 비밀값 다루기 (Node.js API 8단원)

문자열뿐인 환경변수를 한곳에서 읽어 검증하고, 잘못된 설정은 시작 전에 모두 알려 준다. 내장 --env-file, 비밀값 가리기, 쓰기 요청 토큰 확인을 붙인다.

개발자 · 원고 갱신

이 단원에서 배우는 것

같은 코드를 내 노트북, 팀 개발 서버, 운영 서버에서 돌린다. 포트, 데이터 파일 위치, 로그 수준, 비밀 토큰은 곳마다 다르다. 이 값들을 코드에 적어 두면 환경마다 코드를 고쳐야 하고, 비밀값이 git 기록에 남는다. 설정은 코드 바깥, 환경변수로 받는다.

  • process.env에서 설정을 한곳에 모아 읽고, 타입을 바꾸고, 검증한다.
  • 잘못된 설정은 서버가 뜨기 전에 모두 모아 알려 주고 종료 코드 1로 끝낸다.
  • Node.js 내장 --env-file.env 파일을 읽는다. 설치할 패키지는 없다.
  • 비밀값을 출력하지 않는 방법과, 쓰기 요청에 토큰을 요구하는 작은 인증 층을 붙인다.

문제 상황

운영 서버에 올리면서 PORT=8o80(숫자 0 대신 영문 o)이라고 잘못 적었다. 코드가 Number(process.env.PORT) || 3700이어서 서버는 오류 없이 3700번에서 떴고, 8080번을 바라보던 프록시는 연결에 실패했다. 다른 날은 DEBUG=false로 적었는데 디버그 출력이 켜졌다. 또 어떤 날은 누군가 설정을 확인하려고 console.log(process.env)를 찍었고, 그 로그가 공유 채널에 붙여 넣어져 토큰을 새로 발급해야 했다.

이 단원에서 memo-api에 더하는 파일은 lib/config.mjs, lib/auth.mjs, show-config.mjs, env-trap.mjs, .env.example이고, server.mjscall.mjs를 바꾼다.

완성 코드

환경변수 함정 확인: env-trap.mjs

// env-trap.mjs — 환경변수는 언제나 문자열이다
console.log('PORT:', typeof process.env.PORT, JSON.stringify(process.env.PORT));
console.log('PORT + 1 =', process.env.PORT + 1);

if (process.env.DEBUG) {
  console.log('DEBUG 가 켜졌다고 판단함. 실제 값:', JSON.stringify(process.env.DEBUG));
}
console.log("DEBUG === 'true' ?", process.env.DEBUG === 'true');

console.log("Number('') =", Number(''), "/ Number('37OO') =", Number('37OO'),
  "/ parseInt('3700abc') =", parseInt('3700abc', 10));

lib/config.mjs

// lib/config.mjs — 환경변수를 한곳에서 읽고, 잘못된 값은 시작할 때 모두 알려 준다
import { resolve, join } from 'node:path';

const ROOT = join(import.meta.dirname, '..');          // 프로젝트 폴더 (lib 의 부모)
const LEVELS = ['debug', 'info', 'warn', 'error'];

export class ConfigError extends Error {}

function readInt(env, name, fallback, min, max, errors) {
  const raw = env[name];
  if (raw === undefined || raw === '') return fallback;
  if (!/^\d+$/.test(raw)) {
    errors.push(`${name}=${JSON.stringify(raw)}: 숫자만 쓸 수 있습니다`);
    return fallback;
  }
  const n = Number(raw);
  if (n < min || n > max) errors.push(`${name}=${raw}: ${min}~${max} 사이여야 합니다`);
  return n;
}

export function loadConfig(env = process.env) {
  const errors = [];
  const production = env.NODE_ENV === 'production';

  const config = {
    production,
    host: env.HOST || '127.0.0.1',
    port: readInt(env, 'PORT', 3700, 1, 65535, errors),
    dataFile: resolve(ROOT, env.DATA_FILE || 'data/memos.json'),   // 상대 경로는 프로젝트 기준
    logLevel: (env.LOG_LEVEL || 'info').toLowerCase(),
    apiToken: env.API_TOKEN || null,
  };

  if (!LEVELS.includes(config.logLevel)) {
    errors.push(`LOG_LEVEL=${env.LOG_LEVEL}: ${LEVELS.join(', ')} 중 하나여야 합니다`);
  }
  if (config.apiToken !== null && config.apiToken.length < 16) {
    errors.push('API_TOKEN: 16자 이상이어야 합니다');   // 값 자체는 오류 메시지에 넣지 않는다
  }
  if (production && config.apiToken === null) {
    errors.push('API_TOKEN: NODE_ENV=production 에서는 반드시 있어야 합니다');
  }

  if (errors.length) throw new ConfigError(errors.map((e) => `- ${e}`).join('\n'));
  return Object.freeze(config);
}

// 로그나 화면에 찍어도 되는 모양으로 바꾼다. 비밀값은 길이만 남긴다
export function describeConfig(config) {
  return {
    ...config,
    dataFile: config.dataFile.startsWith(ROOT) ? '<프로젝트>' + config.dataFile.slice(ROOT.length) : config.dataFile,
    apiToken: config.apiToken ? `설정됨(${config.apiToken.length}자)` : '없음',
  };
}

show-config.mjs.env.example

// show-config.mjs — 서버를 띄우지 않고 설정만 검사해 본다
import { loadConfig, describeConfig } from './lib/config.mjs';

try {
  console.log(describeConfig(loadConfig()));
} catch (err) {
  console.error(`설정 오류:\n${err.message}`);
  process.exitCode = 1;
}
# .env.example — 저장소에는 이 "예시" 파일만 올리고, 실제 값은 .env 에 적는다
PORT=3800
LOG_LEVEL=debug
DATA_FILE=data/dev-memos.json
API_TOKEN=example-token-change-me-0000

lib/auth.mjs

// lib/auth.mjs — 앱 앞에 끼우는 "토큰 확인" 한 겹. GET 은 누구나, 쓰기는 토큰이 있어야 한다
import { timingSafeEqual } from 'node:crypto';
import { sendJson } from './http.mjs';

function same(a, b) {
  const x = Buffer.from(a);
  const y = Buffer.from(b);
  return x.length === y.length && timingSafeEqual(x, y);   // 비교 시간으로 값을 추측하지 못하게
}

export function withToken(app, token) {
  if (!token) return app;                   // 토큰을 설정하지 않았으면 그대로 통과
  const expected = `Bearer ${token}`;
  return (req, res) => {
    if (req.method === 'GET' || req.method === 'HEAD') return app(req, res);
    if (!same(req.headers.authorization ?? '', expected)) {
      return sendJson(res, 401, { error: { code: 'UNAUTHORIZED', message: '쓰기 요청에는 토큰이 필요합니다' } },
        { 'www-authenticate': 'Bearer' });
    }
    return app(req, res);
  };
}

server.mjscall.mjs

// server.mjs — 설정을 먼저 검사하고, 통과하면 서버를 띄운다
import { createServer } from 'node:http';
import { createApp } from './app.mjs';
import { createMemoStore } from './lib/store.mjs';
import { loadConfig, describeConfig } from './lib/config.mjs';
import { withToken } from './lib/auth.mjs';

let config;
try {
  config = loadConfig();
} catch (err) {
  console.error(`설정 오류로 시작하지 않습니다:\n${err.message}`);
  process.exit(1);
}
console.log('설정:', describeConfig(config));

const store = createMemoStore(config.dataFile);
const app = createApp({
  store,
  onError: (err, req) => console.error(`[500] ${req.method} ${req.url}\n${err.stack}`),
});

createServer(withToken(app, config.apiToken)).listen(config.port, config.host, () => {
  console.log(`메모 API 대기 중: http://${config.host}:${config.port}`);
});
// call.mjs — 6단원 파일에 토큰 헤더를 더했다. API_TOKEN 환경변수가 있으면 함께 보낸다
const BASE = process.env.API_BASE ?? 'http://127.0.0.1:3700';
const token = process.env.API_TOKEN;

for (const line of process.argv.slice(2)) {
  const [method, path, ...rest] = line.split(' ');
  const body = rest.join(' ') || undefined;
  const headers = body ? { 'content-type': 'application/json' } : {};
  if (token) headers.authorization = `Bearer ${token}`;
  const res = await fetch(BASE + path, { method, headers, body });
  const text = await res.text();
  const allow = res.headers.get('allow');
  console.log(`${method} ${path} -> ${res.status}${allow ? ` (allow: ${allow})` : ''}`);
  if (text) console.log(`  ${text}`);
}

줄별 해설

환경변수는 언제나 문자열이다

운영체제가 프로세스에 넘기는 환경변수는 이름과 문자열 값의 목록이다. PORT=3700은 숫자 3700이 아니라 문자열 '3700'이고, DEBUG=false는 불리언이 아니라 다섯 글자 문자열 'false'다. 빈 문자열이 아니므로 if (process.env.DEBUG)는 참이 된다. 그래서 설정은 한곳에서 읽어 알맞은 타입으로 바꾸고, 나머지 코드는 process.env를 직접 보지 않게 한다.

숫자 설정은 모양부터 확인한다

function readInt(env, name, fallback, min, max, errors) {
  const raw = env[name];
  if (raw === undefined || raw === '') return fallback;
  if (!/^\d+$/.test(raw)) {
    errors.push(`${name}=${JSON.stringify(raw)}: 숫자만 쓸 수 있습니다`);
    return fallback;
  }
  const n = Number(raw);
  if (n < min || n > max) errors.push(`${name}=${raw}: ${min}~${max} 사이여야 합니다`);
  return n;
}

Number('')는 0, parseInt('3700abc')는 3700이다. 둘 다 오류 없이 엉뚱한 값을 만든다. 문제 상황의 8o80parseInt로 읽으면 8이 된다. 그래서 먼저 정규식 /^\d+$/로 "숫자로만 이루어졌는가"를 확인하고, 그다음 범위(포트는 1~65535)를 본다. 값이 없거나 빈 문자열이면 기본값을 쓴다.

오류는 모아서, 시작하기 전에

loadConfig는 문제를 발견해도 멈추지 않고 errors에 모은 다음 마지막에 한꺼번에 던진다. 설정을 하나 고치고 재시작했더니 다음 문제가 나오는 일을 줄인다. server.mjs는 이 오류를 받으면 이유를 출력하고 process.exit(1)로 끝낸다. 잘못된 설정으로 "일단 뜨는" 서버는 첫 요청이 올 때까지 문제를 숨긴다. 배포 도구는 종료 코드 1을 보고 배포 실패를 바로 알아챈다. 이 원칙은 이 책의 실무 장인 "JSON 설정 파일을 읽고 잘못된 값은 초기에 거절하기"와 같다. 그 장은 설정을 파일에서, 이 단원은 환경변수에서 받는다.

명령 앞의 환경변수가 --env-file 값보다 우선하고, 둘 다 없으면 기본값을 쓴다. loadConfig가 모두 검사해 통과하면 얼린 설정으로 서버가 뜨고, 하나라도 틀리면 목록을 출력하고 종료 코드 1로 끝난다.

그림 8-1. 설정이 서버에 들어가기까지

반환값은 Object.freeze로 얼려 둔다. 어딘가에서 실수로 config.port = ...라고 써도 설정이 바뀌지 않는다.

메모 API 설정 항목
환경변수기본값규칙
PORT3700숫자만, 1~65535
HOST127.0.0.1-
DATA_FILEdata/memos.json상대 경로는 프로젝트 기준
LOG_LEVELinfodebug · info · warn · error
API_TOKEN없음16자 이상, 운영에서는 필수
NODE_ENV없음production이면 토큰 검사

상대 경로 설정은 프로젝트 기준으로

dataFile: resolve(ROOT, env.DATA_FILE || 'data/memos.json'),   // 상대 경로는 프로젝트 기준

3단원의 경로 함정이 설정에서도 생긴다. DATA_FILE=data/dev-memos.json을 그냥 쓰면 실행한 폴더 기준이 된다. resolve(ROOT, 값)은 값이 상대 경로면 프로젝트 폴더에 붙이고, /var/lib/memo/memos.json처럼 절대 경로면 그대로 쓴다.

운영에서만 필수인 값

NODE_ENV=production이면 API_TOKEN이 반드시 있어야 한다. 개발할 때는 토큰 없이 편하게 쓰고, 운영에 토큰 없이 올라가는 사고는 막는다. 토큰이 있는데 16자보다 짧아도 거절한다. 오류 메시지에 토큰 값 자체는 넣지 않는다. 오류 메시지는 로그로 흘러가기 때문이다.

비밀값은 길이만 보여 준다

describeConfig는 설정을 화면에 찍어도 되는 모양으로 바꾼다. 토큰은 설정됨(28자)처럼 있는지와 길이만 남긴다. 앞 몇 글자를 보여 주는 것도 피한다. 로그는 생각보다 많은 사람과 시스템이 본다. 데이터 파일 경로는 원고에 실행한 컴퓨터의 경로가 남지 않도록 프로젝트 기준으로 줄여 보여 준다.

--env-file.env 읽기

환경변수를 매번 명령 앞에 적기는 번거롭다. Node.js 20.6부터는 node --env-file=.env server.mjs로 파일의 이름=값 줄을 환경변수로 넣어 준다. 예전에는 dotenv 패키지를 설치해야 했던 일이다. 실행 결과에서 확인하겠지만, 이미 설정된 환경변수는 파일 값으로 덮어쓰지 않는다. 그래서 .env에 개발용 기본값을 두고, 필요할 때 명령 앞에서 하나만 바꿔 쓸 수 있다.

저장소에는 .env.example처럼 가짜 값이 든 예시만 올리고, 실제 값이 든 .env.gitignore에 넣는다.

설정 값을 어디에 두는가
위치저장소에 올리나적는 것
코드올린다기본값만. 비밀값은 금지
.env.example올린다이름과 가짜 값
.env안 올린다(.gitignore)내 개발용 값
서버의 서비스 설정서버에만운영 값과 토큰

앱 앞에 끼우는 토큰 확인

withToken(app, token)은 앱을 받아 "토큰을 먼저 확인하는 앱"을 돌려준다. app.mjs는 한 줄도 바뀌지 않았다. 이렇게 요청 처리 앞뒤에 한 겹씩 끼우는 함수를 프레임워크에서는 미들웨어라고 부른다. 9단원의 로그도 같은 방식으로 끼운다.

withToken은 앱을 감싸서 GET과 HEAD는 그대로 통과시키고, 쓰기 요청은 authorization 헤더의 토큰이 맞을 때만 앱으로 보낸다. 틀리면 앱까지 가지 않고 401로 답한다.

그림 8-2. 앱 앞에 끼우는 토큰 확인

조회(GET)는 누구나, 쓰기는 authorization: Bearer 토큰 헤더가 맞을 때만 통과시킨다. 틀리면 401과 함께 www-authenticate 헤더로 어떤 방식의 인증이 필요한지 알린다. 토큰 비교에 === 대신 timingSafeEqual을 쓴 것은, 문자열 비교가 앞에서부터 다른 글자를 만나는 순간 끝나서 걸린 시간으로 몇 글자가 맞았는지 추측할 여지가 있기 때문이다. timingSafeEqual은 길이가 같아야 하므로 길이부터 비교한다.

이것은 "아무나 쓰지 못하게" 하는 최소한의 장치다. 사용자별 로그인과 권한은 이 책의 범위를 넘는다.

실제 실행 결과

먼저 환경변수의 성질을 확인한다. 명령 앞에 이름=값을 적으면 그 명령에만 환경변수가 붙는다(macOS·Linux 셸 기준. 윈도우 PowerShell에서는 $env:PORT="3700"으로 먼저 설정한다).

$ PORT=3700 DEBUG=false node env-trap.mjs
PORT: string "3700"
PORT + 1 = 37001
DEBUG 가 켜졌다고 판단함. 실제 값: "false"
DEBUG === 'true' ? false
Number('') = 0 / Number('37OO') = NaN / parseInt('3700abc') = 3700

PORT + 1이 3701이 아니라 문자열 이어 붙이기로 37001이 됐다. DEBUG=false는 켜진 것으로 판단됐다. 숫자 변환 세 가지는 모두 오류 없이 틀린 값을 냈다.

$ node show-config.mjs
{
  production: false,
  host: '127.0.0.1',
  port: 3700,
  dataFile: '<프로젝트>/data/memos.json',
  logLevel: 'info',
  apiToken: '없음'
}
$ PORT=abc LOG_LEVEL=loud API_TOKEN=short node show-config.mjs
설정 오류:
- PORT="abc": 숫자만 쓸 수 있습니다
- LOG_LEVEL=loud: debug, info, warn, error 중 하나여야 합니다
- API_TOKEN: 16자 이상이어야 합니다

세 문제가 한 번에 나왔고, 토큰 값은 오류에 없다. 종료 코드는 1이다.

$ NODE_ENV=production node show-config.mjs
설정 오류:
- API_TOKEN: NODE_ENV=production 에서는 반드시 있어야 합니다
$ node --env-file=.env.example show-config.mjs
{
  production: false,
  host: '127.0.0.1',
  port: 3800,
  dataFile: '<프로젝트>/data/dev-memos.json',
  logLevel: 'debug',
  apiToken: '설정됨(28자)'
}
$ PORT=3900 node --env-file=.env.example show-config.mjs
{
  production: false,
  host: '127.0.0.1',
  port: 3900,
  dataFile: '<프로젝트>/data/dev-memos.json',
  logLevel: 'debug',
  apiToken: '설정됨(28자)'
}

파일에는 PORT=3800이 있지만 명령 앞에서 준 3900이 이겼다. 나머지 값은 파일에서 왔다. 이 동작은 Node.js 20.18에서도 같았다.

이제 토큰을 주고 서버를 띄웠다(명령: API_TOKEN=dev-token-0123456789abcd node server.mjs. 연습용 가짜 값이다).

설정: {
  production: false,
  host: '127.0.0.1',
  port: 3700,
  dataFile: '<프로젝트>/data/memos.json',
  logLevel: 'info',
  apiToken: '설정됨(24자)'
}
메모 API 대기 중: http://127.0.0.1:3700
$ node call.mjs "GET /memos" 'POST /memos {"title":"토큰 없이"}'
GET /memos -> 200
  {"items":[],"count":0}
POST /memos -> 401
  {"error":{"code":"UNAUTHORIZED","message":"쓰기 요청에는 토큰이 필요합니다"}}
$ API_TOKEN=dev-token-0123456789abcd node call.mjs 'POST /memos {"title":"토큰과 함께"}'
POST /memos -> 201
  {"id":1,"title":"토큰과 함께","done":false}

실무에서 자주 틀리는 것

1. 코드 곳곳에서 process.env를 읽는다

파일 열 곳에서 각자 process.env.PORT를 읽으면 기본값과 변환 방식이 제각각이 되고, 설정 목록을 한눈에 볼 수 없다. 설정은 한 모듈에서 읽고 검증해서 객체로 넘긴다.

2. .env를 커밋한다

한 번 커밋된 비밀값은 파일을 지워도 git 기록에 남는다. 새 저장소를 만들면 .gitignore.env부터 넣는다. 이미 올라갔다면 기록을 지우는 것보다 값을 바꾸는 것(토큰 재발급)이 먼저다.

3. 설정 전체를 로그에 찍는다

console.log(process.env)console.log(config)는 비밀값까지 찍는다. 찍어도 되는 모양으로 바꾸는 함수(describeConfig)를 거친다.

4. 잘못된 값을 기본값으로 조용히 바꾼다

Number(env.PORT) || 3700은 편해 보이지만 PORT=8o80을 3700으로 바꿔 버린다. 운영자는 설정이 먹었다고 믿는다. 값이 없을 때만 기본값을 쓰고, 값이 있는데 틀렸으면 거절한다.

연습 문제

  1. .envLOG_LEVEL=debug가 있고, 운영 서버의 서비스 설정에 LOG_LEVEL=warn이 있다. node --env-file=.env server.mjs로 띄우면 로그 수준은 무엇인가?
  2. readIntconst n = parseInt(raw, 10); if (Number.isNaN(n)) ...로 바꾸면 PORT=3700abc는 어떻게 되는가?
  3. 팀원이 실수로 실제 API_TOKEN이 든 .env를 공개 저장소에 올렸다. 해야 할 일을 순서대로 적어라.

정답과 해설

  1. warn이다. 이미 있는 환경변수는 --env-file이 덮어쓰지 않는다. 실행 결과의 PORT=3900과 같은 원리다. 운영 서버에 개발용 .env가 딸려 가도 서비스 설정이 이기지만, 서비스 설정에 없는 값은 .env의 개발용 값이 들어간다. 운영에는 .env를 두지 않거나 운영용 파일을 따로 만드는 편이 안전하다.
  2. 3700으로 통과한다. parseInt는 앞에서부터 숫자를 읽다가 숫자가 아닌 글자에서 멈추고, 그때까지 읽은 값을 돌려주기 때문이다(env-trap.mjs 실행 결과). NaN 검사만으로는 막지 못한다. 문자열 전체가 숫자인지 정규식으로 확인하는 지금 방식이 맞다.
  3. ① 토큰을 즉시 폐기하고 새로 발급해 서버 설정을 바꾼다. 공개 저장소는 올리는 순간 자동 수집기가 읽어 간다고 가정한다. ② 옛 토큰으로 들어온 요청이 있었는지 로그를 확인한다. ③ .env를 저장소에서 지우고 .gitignore에 넣는다. ④ 필요하면 git 기록에서도 지운다. 순서가 중요하다. 기록을 지우는 것부터 하면 그동안 토큰은 계속 살아 있다.

이 책의 실무 장 "JSON 설정 파일을 읽고 잘못된 값은 초기에 거절하기"를 이어서 읽으면, 설정 파일을 읽고 검증하는 같은 원칙을 파일 쪽에서 다시 볼 수 있다. 다음 단원에서는 서버가 무슨 일을 했는지 남기는 로그를 붙인다.

READER FEEDBACK

질문·오탈자·의견

내용에 관한 질문이나 오탈자, 더 나은 설명을 위한 의견을 남겨 주세요. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.