티스토리 뷰

반응형

백엔드 프로젝트의 두 번째 뼈대, 환경변수와 설정 분리부터 제대로 하자 — FastAPI · Spring Boot · Node.js

지난 글에서는 프로젝트 구조를 먼저 잡았습니다.
솔직히 그 글은 약간 “집 짓기 전에 땅 고르는 이야기”에 가까웠어요.

이번 글부터는 조금 더 현실적인 얘기로 들어가 보겠습니다.
백엔드 프로젝트를 처음 만들면 대부분 이런 식으로 시작하죠.

DB_URL = "postgresql://user:password@localhost:5432/mydb"
SECRET_KEY = "my-secret-key"
DEBUG = True

처음엔 편해요.
정말 편합니다.
근데 이 방식은 프로젝트가 조금만 커져도 바로 발목을 잡아요.

  • 로컬에서 쓰던 DB 주소가 운영에 그대로 들어가고
  • 비밀번호가 깃에 올라가고
  • 테스트 환경과 개발 환경이 뒤섞이고
  • “왜 서버에선 되는데 내 로컬에선 안 되지?”가 반복됩니다

저는 이 구간을 정말 많이 봤어요.
특히 주니어 때는 “일단 되게 만드는 것”에 집중하다 보니, 설정을 코드에 박아 넣는 습관이 생기기 쉽더라고요.
근데 이건 나중에 거의 반드시 비용으로 돌아옵니다.

그래서 이번 글에서는 환경변수와 설정 분리를 다룹니다.
그리고 이번에도 똑같이, FastAPI / Spring Boot / Node.js(Express) 세 가지로 같은 개념을 나란히 맞춰서 구현해보겠습니다.

FastAPI 공식 문서는 Pydantic Settings를 이용해 설정을 다루고 .env 파일을 사용할 수 있다고 안내합니다. 또 @lru_cache를 이용하면 요청마다 dotenv를 반복해서 읽지 않도록 할 수 있다고 설명합니다. Spring Boot는 설정을 외부화해서 같은 코드로 서로 다른 환경에서 동작하게 하는 것을 핵심 기능으로 설명하고 있고, Node.js 쪽에서는 dotenv 패키지가 .env 값을 process.env로 로딩하는 가장 널리 쓰이는 방식입니다. (FastAPI)


왜 설정을 코드 밖으로 빼야 할까?

이건 그냥 “깔끔해서”가 아닙니다.

설정을 분리하면 좋은 점이 아주 명확해요.

  • 개발 / 테스트 / 운영 환경을 나눌 수 있다
  • 민감한 값(DB 비밀번호, JWT 시크릿 등)을 코드에 넣지 않아도 된다
  • 배포 환경에서 값만 바꿔도 같은 코드를 재사용할 수 있다
  • Docker, CI/CD, 클라우드 배포로 넘어가기 쉬워진다

Spring Boot 공식 문서도 바로 이 지점을 강조합니다. 설정은 properties, YAML, 환경변수, 커맨드라인 인자 같은 외부 소스로 관리할 수 있고, 같은 애플리케이션 코드를 다른 환경에서 재사용할 수 있어야 한다고 설명합니다. (Home)

저는 이걸 **“설정을 바꾸기 위해 코드를 수정하지 않는 상태”**라고 표현하는 편입니다.
이 감각이 생기면 백엔드가 훨씬 덜 불안해져요.


이번 글에서 만들 공통 기준

이번 글에서는 세 가지 스택 모두 아래 기준으로 맞추겠습니다.

  1. 앱 이름
  2. 실행 포트
  3. 디버그 여부
  4. DB URL
  5. JWT 시크릿 같은 민감값
  6. 개발/운영 환경 구분

즉, 앞으로 모든 프로젝트에서 자주 보게 될 설정 항목들을 가장 단순한 형태로 먼저 넣어보는 거예요.


공통 폴더 구조에 config를 추가하자

지난 글의 구조에 이제 config 성격이 명확히 들어옵니다.

project-root/
├── app-or-src/
│   ├── main / Application
│   ├── api / controller / routes
│   ├── service
│   ├── domain / model / dto
│   ├── repository
│   ├── config
│   └── common
├── .env
├── .env.example
├── tests
└── README.md

여기서 중요한 파일은 두 개예요.

  • .env : 실제 로컬 실행값
  • .env.example : 어떤 환경변수가 필요한지 공유하는 템플릿

이 .env.example 파일 진짜 중요합니다.
협업할 때 “뭐 넣어야 실행돼요?” 질문을 줄여줘요.
생각보다 팀 생산성에 꽤 큰 차이를 만듭니다.


1) FastAPI에서 환경변수와 설정 분리하기

반응형

FastAPI 공식 문서는 설정 처리에 Pydantic Settings 사용을 권장하고, .env 파일과 캐싱 패턴까지 함께 보여줍니다. Pydantic Settings는 환경변수나 secret 파일에서 설정 클래스를 로딩하는 데 특화되어 있습니다. (FastAPI)

추천 구조

fastapi-backend/
├── app/
│   ├── api/
│   │   └── health.py
│   ├── core/
│   │   └── config.py
│   ├── schemas/
│   │   └── health.py
│   ├── services/
│   │   └── health_service.py
│   └── main.py
├── .env
├── .env.example
└── requirements.txt

requirements.txt

fastapi
uvicorn[standard]
pydantic
pydantic-settings

.env.example

APP_NAME=backend-series-fastapi
APP_ENV=local
APP_PORT=8000
DEBUG=true
DATABASE_URL=postgresql://user:password@localhost:5432/app_db
JWT_SECRET=change-me

.env

APP_NAME=backend-series-fastapi
APP_ENV=local
APP_PORT=8000
DEBUG=true
DATABASE_URL=postgresql://user:password@localhost:5432/app_db
JWT_SECRET=my-local-secret

app/core/config.py

from functools import lru_cache

from pydantic_settings import BaseSettings, SettingsConfigDict


class Settings(BaseSettings):
    app_name: str = "backend-series-fastapi"
    app_env: str = "local"
    app_port: int = 8000
    debug: bool = True
    database_url: str
    jwt_secret: str

    model_config = SettingsConfigDict(
        env_file=".env",
        env_file_encoding="utf-8",
        case_sensitive=False,
    )


@lru_cache
def get_settings() -> Settings:
    return Settings()

app/schemas/health.py

from pydantic import BaseModel


class HealthResponse(BaseModel):
    status: str
    framework: str
    environment: str

app/services/health_service.py

from app.core.config import Settings
from app.schemas.health import HealthResponse


class HealthService:
    @staticmethod
    def check(settings: Settings) -> HealthResponse:
        return HealthResponse(
            status="ok",
            framework="fastapi",
            environment=settings.app_env,
        )

app/api/health.py

from fastapi import APIRouter, Depends

from app.core.config import Settings, get_settings
from app.schemas.health import HealthResponse
from app.services.health_service import HealthService

router = APIRouter(prefix="/api/health", tags=["health"])


@router.get("", response_model=HealthResponse)
def health_check(
    settings: Settings = Depends(get_settings),
) -> HealthResponse:
    return HealthService.check(settings)

app/main.py

from fastapi import FastAPI

from app.api.health import router as health_router
from app.core.config import get_settings

settings = get_settings()

app = FastAPI(
    title=settings.app_name,
    debug=settings.debug,
)

app.include_router(health_router)

실행

python -m venv .venv
source .venv/bin/activate
pip install -r requirements.txt
uvicorn app.main:app --reload --port 8000

확인

curl http://127.0.0.1:8000/api/health

예상 응답:

{
  "status": "ok",
  "framework": "fastapi",
  "environment": "local"
}

FastAPI에서 이 방식이 좋은 이유

FastAPI 쪽은 여기서 포인트가 두 개예요.

첫째, 설정을 한 클래스에 모을 수 있다는 점.
둘째, 의존성 주입처럼 사용할 수 있다는 점입니다.

공식 문서도 BaseSettings와 .env, 그리고 @lru_cache 조합을 보여주는데, 이 패턴은 테스트나 운영 전환 시 꽤 안정적입니다. (FastAPI)

개인적으로 FastAPI는 너무 빨리 만들 수 있어서 설정도 대충 넘어가기 쉬운데요.
초반부터 core/config.py를 만들어두면 나중에 DB 세션, CORS, 인증 설정까지 훨씬 자연스럽게 붙습니다.


2) Spring Boot에서 환경변수와 설정 분리하기

Spring Boot는 아예 “Externalized Configuration”을 핵심 기능으로 다룹니다. properties, YAML, 환경변수, 커맨드라인 인자 등 다양한 소스에서 설정을 읽고, 프로필(profiles)로 환경별 분기를 할 수 있습니다. (Home)

추천 구조

springboot-backend/
├── src/main/java/com/example/backend/
│   ├── BackendApplication.java
│   ├── controller/
│   │   └── HealthController.java
│   ├── dto/
│   │   └── HealthResponse.java
│   ├── service/
│   │   └── HealthService.java
│   └── config/
│       └── AppProperties.java
├── src/main/resources/
│   ├── application.yml
│   └── application-local.yml
└── build.gradle

build.gradle

plugins {
    id 'java'
    id 'org.springframework.boot' version '4.0.3'
    id 'io.spring.dependency-management' version '1.1.7'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(21)
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
    useJUnitPlatform()
}

src/main/resources/application.yml

spring:
  application:
    name: backend-series-spring

  profiles:
    active: local

server:
  port: ${APP_PORT:8080}

app:
  env: ${APP_ENV:local}
  debug: ${APP_DEBUG:true}
  database-url: ${DATABASE_URL:postgresql://user:password@localhost:5432/app_db}
  jwt-secret: ${JWT_SECRET:change-me}

src/main/resources/application-local.yml

app:
  env: local
  debug: true

src/main/java/com/example/backend/config/AppProperties.java

package com.example.backend.config;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "app")
public record AppProperties(
        String env,
        boolean debug,
        String databaseUrl,
        String jwtSecret
) {
}

src/main/java/com/example/backend/BackendApplication.java

package com.example.backend;

import com.example.backend.config.AppProperties;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;

@SpringBootApplication
@EnableConfigurationProperties(AppProperties.class)
public class BackendApplication {
    public static void main(String[] args) {
        SpringApplication.run(BackendApplication.class, args);
    }
}

src/main/java/com/example/backend/dto/HealthResponse.java

package com.example.backend.dto;

public record HealthResponse(
        String status,
        String framework,
        String environment
) {
}

src/main/java/com/example/backend/service/HealthService.java

package com.example.backend.service;

import com.example.backend.config.AppProperties;
import com.example.backend.dto.HealthResponse;
import org.springframework.stereotype.Service;

@Service
public class HealthService {

    private final AppProperties appProperties;

    public HealthService(AppProperties appProperties) {
        this.appProperties = appProperties;
    }

    public HealthResponse check() {
        return new HealthResponse(
                "ok",
                "spring-boot",
                appProperties.env()
        );
    }
}

src/main/java/com/example/backend/controller/HealthController.java

package com.example.backend.controller;

import com.example.backend.dto.HealthResponse;
import com.example.backend.service.HealthService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class HealthController {

    private final HealthService healthService;

    public HealthController(HealthService healthService) {
        this.healthService = healthService;
    }

    @GetMapping("/api/health")
    public HealthResponse healthCheck() {
        return healthService.check();
    }
}

실행

./gradlew bootRun

또는 환경변수 덮어쓰기:

APP_ENV=prod APP_PORT=9090 JWT_SECRET=my-secret ./gradlew bootRun

확인

curl http://127.0.0.1:8080/api/health

예상 응답:

{
  "status": "ok",
  "framework": "spring-boot",
  "environment": "local"
}

Spring Boot에서 이 방식이 좋은 이유

Spring Boot는 솔직히 설정 쪽이 정말 강합니다.
처음엔 application.yml 문법이 낯설 수 있는데, 익숙해지면 “아 이건 환경 분리용으로 만든 프레임워크구나” 하는 느낌이 와요.

특히 좋은 점은 이거예요.

  • YAML 기반으로 구조화된 설정 가능
  • 환경변수로 오버라이드 가능
  • local / dev / prod 프로필 분리 가능
  • @ConfigurationProperties로 타입 안정성 확보 가능

Spring 문서도 설정을 구조화된 객체에 바인딩하는 방식을 공식적으로 다루고 있습니다. (Home)

저는 Spring Boot에서 @Value를 여기저기 뿌리는 것보다,
지금처럼 AppProperties 하나로 묶는 습관이 훨씬 오래 간다고 봐요.


3) Node.js(Express)에서 환경변수와 설정 분리하기

Node.js/Express에서는 .env와 process.env 조합이 가장 널리 쓰입니다. npm의 dotenv 패키지는 .env 파일을 로딩해 환경변수를 process.env에 넣어주는 방식으로 설명합니다. (npmjs.com)

추천 구조

node-backend/
├── src/
│   ├── config/
│   │   └── env.js
│   ├── dto/
│   │   └── health-response.js
│   ├── routes/
│   │   └── health.route.js
│   ├── services/
│   │   └── health.service.js
│   └── server.js
├── .env
├── .env.example
└── package.json

package.json

{
  "name": "backend-series-node",
  "version": "1.0.0",
  "type": "module",
  "scripts": {
    "dev": "node src/server.js"
  },
  "dependencies": {
    "dotenv": "^17.2.3",
    "express": "^5.1.0"
  }
}

.env.example

APP_NAME=backend-series-node
APP_ENV=local
APP_PORT=3000
DEBUG=true
DATABASE_URL=postgresql://user:password@localhost:5432/app_db
JWT_SECRET=change-me

.env

APP_NAME=backend-series-node
APP_ENV=local
APP_PORT=3000
DEBUG=true
DATABASE_URL=postgresql://user:password@localhost:5432/app_db
JWT_SECRET=my-local-secret

src/config/env.js

import dotenv from "dotenv";

dotenv.config();

function requireEnv(name, defaultValue = undefined) {
  const value = process.env[name] ?? defaultValue;

  if (value === undefined || value === null || value === "") {
    throw new Error(`Missing required environment variable: ${name}`);
  }

  return value;
}

export const env = {
  appName: requireEnv("APP_NAME", "backend-series-node"),
  appEnv: requireEnv("APP_ENV", "local"),
  appPort: Number(requireEnv("APP_PORT", "3000")),
  debug: requireEnv("DEBUG", "true") === "true",
  databaseUrl: requireEnv("DATABASE_URL"),
  jwtSecret: requireEnv("JWT_SECRET"),
};

src/dto/health-response.js

export function createHealthResponse(environment) {
  return {
    status: "ok",
    framework: "nodejs-express",
    environment,
  };
}

src/services/health.service.js

import { createHealthResponse } from "../dto/health-response.js";

export function checkHealth(environment) {
  return createHealthResponse(environment);
}

src/routes/health.route.js

import { Router } from "express";
import { env } from "../config/env.js";
import { checkHealth } from "../services/health.service.js";

const router = Router();

router.get("/api/health", (req, res) => {
  res.json(checkHealth(env.appEnv));
});

export default router;

src/server.js

import express from "express";
import healthRouter from "./routes/health.route.js";
import { env } from "./config/env.js";

const app = express();

app.use(express.json());
app.use(healthRouter);

app.listen(env.appPort, () => {
  console.log(`${env.appName} running on http://localhost:${env.appPort}`);
});

실행

npm install
npm run dev

확인

curl http://127.0.0.1:3000/api/health

예상 응답:

{
  "status": "ok",
  "framework": "nodejs-express",
  "environment": "local"
}

Node.js에서 이 방식이 중요한 이유

Node.js는 진짜로 유연합니다.
근데 그래서 더 위험해요.

process.env.DB_URL을 여기저기서 바로 꺼내 쓰기 시작하면, 나중에 설정 구조가 금방 무너집니다.
저는 그래서 Node.js일수록 더 빨리 config/env.js 같은 진입점을 하나 만들어두는 편이에요.

이 방식의 장점은 명확합니다.

  • 필수 환경변수 누락을 앱 시작 시 바로 알 수 있다
  • 문자열 → 숫자/불리언 변환을 한 곳에서 처리한다
  • 테스트할 때 교체가 쉽다
  • 나중에 Joi/Zod 검증으로 확장하기 좋다

dotenv 패키지 자체도 .env에서 값을 읽어 process.env로 로딩하는 기본 역할에 충실합니다. (npmjs.com)


세 스택을 비교해보면

같은 목적을 달성하고 있지만 느낌은 다릅니다.

FastAPI

  • Pydantic Settings 기반으로 설정 모델을 만들기 좋음
  • 타입 기반 검증이 자연스러움
  • .env와 캐싱 패턴이 깔끔함 (FastAPI)

Spring Boot

  • 외부 설정 기능 자체가 매우 강력함
  • YAML, 환경변수, 프로필 구조가 성숙해 있음
  • 장기 운영과 환경 분리에 특히 강함 (Home)

Node.js

  • 가장 단순하게 시작 가능
  • 대신 규칙을 안 만들면 금방 흩어짐
  • config 모듈을 일찍 만드는 게 정말 중요함 (npmjs.com)

실무에서 꼭 같이 해두면 좋은 것

여기서 진짜 중요한 팁 몇 개만 더 적어둘게요.

1. .env는 커밋하지 말자

.gitignore에 꼭 넣으세요.

.env
.env.*
!.env.example

운영 비밀키가 올라가면, 그건 그냥 사고입니다.
정말로요.


2. .env.example는 반드시 만들자

팀원이 새로 프로젝트를 받았을 때
무슨 값이 필요한지 한 번에 알 수 있어야 합니다.


3. “설정값 검증”을 앱 시작 시점에 하자

앱이 뜬 다음 요청 들어와서야 JWT_SECRET 없음이 터지면 너무 늦어요.
부팅 시점에 바로 죽는 게 오히려 낫습니다.


4. DB URL, 토큰 키, 외부 API 키는 코드에 박지 말자

이건 습관 문제예요.
처음부터 안 박는 습관을 들이면 나중에 훨씬 편합니다.


5. dev / prod 차이를 명확히 하자

예를 들면 이런 차이요.

  • 로그 레벨
  • 디버그 여부
  • CORS 허용 범위
  • DB 주소
  • 외부 API endpoint
  • 캐시 사용 여부

이걸 코드 분기문으로 처리하기 시작하면 나중에 정말 지칩니다.
설정으로 빼야 합니다.


이번 글 핵심 정리

이번 글의 핵심은 단순합니다.

백엔드 코드는 기능을 구현하고, 설정은 환경이 결정한다.

좋은 백엔드는
코드를 조금 더 잘 짜는 것만으로 만들어지지 않습니다.
운영 환경에 맞게 안전하게 바뀔 수 있어야 해요.

오늘 만든 구조를 다시 요약하면 이렇습니다.

  • .env와 .env.example를 분리한다
  • 설정 전용 파일(config)을 만든다
  • 앱 시작 시 설정을 로딩하고 검증한다
  • 헬스 체크 응답에도 현재 환경을 포함해본다
  • dev / prod 전환을 고려한 구조로 시작한다

이 단계까지 오면 이제 프로젝트가 조금 “진짜 서비스”처럼 보이기 시작합니다.


다음 글 예고

다음 글에서는 이제 진짜 백엔드다운 주제로 넘어가겠습니다.

공통 응답 포맷과 전역 예외 처리를 다룰 거예요.

이걸 넣기 시작하면

  • 성공 응답 형식 통일
  • 에러 메시지 통일
  • validation 에러 처리
  • 예상 가능한 비즈니스 예외 분리

이런 것들이 가능해집니다.

개인적으로는 이 단계부터 “혼자 만든 API”와 “운영 가능한 API”의 차이가 꽤 벌어진다고 느낍니다.


출처

  • FastAPI 공식 문서 — Settings and Environment Variables (FastAPI)
  • Pydantic 공식 문서 — Settings Management (Pydantic)
  • Spring Boot 공식 문서 — Externalized Configuration (Home)
  • Spring Boot 공식 문서 — Properties and Configuration (Home)
  • npm 공식 페이지 — dotenv (npmjs.com)


백엔드개발, FastAPI, SpringBoot, Nodejs, Express, 환경변수, 설정분리, dotenv, applicationyml, 백엔드시리즈

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2026/04   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30
글 보관함
반응형