Go 환경변수와 설정 관리

Go 환경변수와 설정 관리: .env 파일부터 Config 구조체 설계까지

이 글에서는 Go 애플리케이션의 설정 관리를 체계적으로 다룹니다. 데이터베이스 비밀번호를 소스코드에 하드코딩하는 순간 그 코드는 공개 저장소에 올라가서는 안 되는 코드가 됩니다. 환경변수 기반 설정 관리는 단순히 “보안 좋은 습관”이 아니라 12-Factor App 원칙에 따른 현대적인 서버 설계의 기본입니다. os.Getenvos.LookupEnv의 차이, godotenv의 동작 방식, 타입 안전한 Config 구조체 설계, 다중 환경 분리, Docker와 Kubernetes에서의 비밀 관리, 그리고 테스트에서의 환경변수 제어까지 — 실무에 바로 적용할 수 있는 패턴을 모두 담았습니다.


1. os.Getenv vs os.LookupEnv: 빈 문자열 함정

두 함수의 차이는 환경변수가 존재하지 않는 경우빈 문자열로 설정된 경우를 구분할 수 있느냐입니다.

// os.Getenv: 없으면 "" 반환 — 빈 값과 미설정을 구분 불가
host := os.Getenv("DB_HOST")
if host == "" {
    // 환경변수가 없는 건지, ""로 설정된 건지 알 수 없음
    host = "localhost"
}

// os.LookupEnv: (값, 존재여부) 반환 — 명확한 구분 가능
host, ok := os.LookupEnv("DB_HOST")
if !ok {
    host = "localhost" // 명시적으로 "설정 안 됨" 처리
} else if host == "" {
    return fmt.Errorf("DB_HOST가 빈 문자열로 설정되었습니다")
}

1-1. 모든 환경변수 목록 조회

os.Environ()은 현재 프로세스의 모든 환경변수를 KEY=VALUE 형태의 슬라이스로 반환합니다.

// 특정 접두사를 가진 환경변수만 추출
func getEnvsByPrefix(prefix string) map[string]string {
    result := make(map[string]string)
    for _, env := range os.Environ() {
        key, value, found := strings.Cut(env, "=")
        if found && strings.HasPrefix(key, prefix) {
            result[key] = value
        }
    }
    return result
}

// 사용: "APP_" 접두사 환경변수 모두 읽기
appEnvs := getEnvsByPrefix("APP_")

2. godotenv: .env 파일 로딩

github.com/joho/godotenv.env 파일을 읽어 os.Setenv로 등록해주는 라이브러리입니다.

go get github.com/joho/godotenv

2-1. .env 파일 형식

# .env — 주석, 따옴표, 여러 줄 값 지원
DB_HOST=localhost
DB_PORT=5432
DB_PASSWORD="p@ssw0rd!123"  # 특수문자 포함 시 따옴표
APP_NAME='My Go Server'

# 여러 줄 값 (따옴표로 감싸기)
PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEA...
-----END RSA PRIVATE KEY-----"

# 다른 환경변수 참조
BASE_URL=https://${DOMAIN}:${PORT}

2-2. Load vs Overload

// godotenv.Load: 이미 설정된 환경변수는 덮어쓰지 않음 (OS 환경변수 우선)
// → 프로덕션에서 .env보다 실제 OS 환경변수가 우선되어 안전
err := godotenv.Load(".env")

// godotenv.Overload: .env가 기존 값을 덮어씀 (테스트용)
err := godotenv.Overload(".env.test")

🛠️ 프로덕션 서버에서는 godotenv.Load를 사용해야 합니다. CI/CD 파이프라인이나 Docker에서 주입한 실제 환경변수가 있을 때 .env 파일이 그것을 덮어쓰지 않도록 보장합니다.

2-3. .env 파일 없어도 동작하는 로딩 패턴

func init() {
    // 파일이 없으면 무시 (프로덕션에서 .env 없이 동작)
    if err := godotenv.Load(); err != nil {
        // 파일이 없는 경우는 정상 — OS 환경변수로 동작
        if !os.IsNotExist(err) {
            slog.Warn(".env 파일 로딩 실패", "error", err)
        }
    }
}

3. Config 구조체 패턴: 타입 안전 설정 관리

매번 os.Getenv를 호출하는 대신, 애플리케이션 시작 시 한 번에 설정을 읽어 타입이 있는 구조체에 저장합니다. 이후 코드는 문자열 변환 없이 직접 사용합니다.

3-1. Config 구조체 정의

// internal/config/config.go

type Config struct {
    App      AppConfig
    Database DatabaseConfig
    Server   ServerConfig
    Auth     AuthConfig
}

type AppConfig struct {
    Env   string // "development" | "production" | "test"
    Debug bool
    Name  string
}

type DatabaseConfig struct {
    Host            string
    Port            int
    User            string
    Password        string
    Name            string
    MaxOpenConns    int
    MaxIdleConns    int
    ConnMaxLifetime time.Duration
    SSLMode         string
}

// DSN 생성 메서드 — Config 구조체가 책임
func (d DatabaseConfig) DSN() string {
    return fmt.Sprintf(
        "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s",
        d.Host, d.Port, d.User, d.Password, d.Name, d.SSLMode,
    )
}

type ServerConfig struct {
    Host            string
    Port            int
    ReadTimeout     time.Duration
    WriteTimeout    time.Duration
    ShutdownTimeout time.Duration
}

// Addr 메서드 — ":8080" 형태 반환
func (s ServerConfig) Addr() string {
    return fmt.Sprintf("%s:%d", s.Host, s.Port)
}

type AuthConfig struct {
    JWTSecret     string
    TokenExpiry   time.Duration
    RefreshExpiry time.Duration
}

3-2. 환경변수 로딩 및 검증

// Load: 환경변수를 읽어 Config 반환. 필수값 누락 시 에러
func Load() (*Config, error) {
    cfg := &Config{
        App: AppConfig{
            Env:   getEnv("APP_ENV", "development"),
            Debug: getBoolEnv("APP_DEBUG", false),
            Name:  getEnv("APP_NAME", "Go Server"),
        },
        Database: DatabaseConfig{
            Host:            getEnv("DB_HOST", "localhost"),
            Port:            getIntEnv("DB_PORT", 5432),
            User:            getEnv("DB_USER", "postgres"),
            Password:        os.Getenv("DB_PASSWORD"),
            Name:            getEnv("DB_NAME", "app"),
            MaxOpenConns:    getIntEnv("DB_MAX_OPEN_CONNS", 25),
            MaxIdleConns:    getIntEnv("DB_MAX_IDLE_CONNS", 5),
            ConnMaxLifetime: getDurationEnv("DB_CONN_MAX_LIFETIME", 5*time.Minute),
            SSLMode:         getEnv("DB_SSL_MODE", "disable"),
        },
        Server: ServerConfig{
            Host:            getEnv("SERVER_HOST", "0.0.0.0"),
            Port:            getIntEnv("SERVER_PORT", 8080),
            ReadTimeout:     getDurationEnv("SERVER_READ_TIMEOUT", 10*time.Second),
            WriteTimeout:    getDurationEnv("SERVER_WRITE_TIMEOUT", 10*time.Second),
            ShutdownTimeout: getDurationEnv("SERVER_SHUTDOWN_TIMEOUT", 30*time.Second),
        },
        Auth: AuthConfig{
            JWTSecret:     os.Getenv("JWT_SECRET"),
            TokenExpiry:   getDurationEnv("JWT_TOKEN_EXPIRY", 15*time.Minute),
            RefreshExpiry: getDurationEnv("JWT_REFRESH_EXPIRY", 7*24*time.Hour),
        },
    }

    return cfg, cfg.validate()
}

// validate: 필수값 및 유효성 검사
func (c *Config) validate() error {
    var errs []string

    if c.Database.Password == "" {
        errs = append(errs, "DB_PASSWORD는 필수입니다")
    }
    if c.Auth.JWTSecret == "" {
        errs = append(errs, "JWT_SECRET는 필수입니다")
    }
    if len(c.Auth.JWTSecret) < 32 {
        errs = append(errs, "JWT_SECRET는 32자 이상이어야 합니다")
    }
    if c.Server.Port < 1 || c.Server.Port > 65535 {
        errs = append(errs, fmt.Sprintf("SERVER_PORT 범위 오류: %d", c.Server.Port))
    }

    if len(errs) > 0 {
        return fmt.Errorf("설정 검증 실패:\n  - %s", strings.Join(errs, "\n  - "))
    }
    return nil
}

3-3. 헬퍼 함수들

func getEnv(key, defaultVal string) string {
    if v := os.Getenv(key); v != "" {
        return v
    }
    return defaultVal
}

func getIntEnv(key string, defaultVal int) int {
    if v := os.Getenv(key); v != "" {
        if n, err := strconv.Atoi(v); err == nil {
            return n
        }
        slog.Warn("환경변수 정수 변환 실패, 기본값 사용", "key", key, "value", v)
    }
    return defaultVal
}

func getBoolEnv(key string, defaultVal bool) bool {
    if v := os.Getenv(key); v != "" {
        if b, err := strconv.ParseBool(v); err == nil {
            return b
        }
        slog.Warn("환경변수 불린 변환 실패, 기본값 사용", "key", key, "value", v)
    }
    return defaultVal
}

func getDurationEnv(key string, defaultVal time.Duration) time.Duration {
    if v := os.Getenv(key); v != "" {
        if d, err := time.ParseDuration(v); err == nil {
            return d
        }
        slog.Warn("환경변수 Duration 변환 실패, 기본값 사용", "key", key, "value", v)
    }
    return defaultVal
}

4. 다중 환경 분리

개발(development), 스테이징(staging), 프로덕션(production) 환경별로 다른 설정이 필요합니다.

4-1. 환경별 .env 파일 구조

.env                # 공통 기본값 (선택)
.env.development    # 개발 환경
.env.staging        # 스테이징 환경
.env.production     # 프로덕션 환경 (절대 Git에 커밋 금지)
.env.test           # 테스트 환경
.env.example        # 템플릿 (Git에 커밋)
// APP_ENV 환경변수에 따라 적절한 .env 파일 로드
func LoadForEnv() (*Config, error) {
    appEnv := os.Getenv("APP_ENV")
    if appEnv == "" {
        appEnv = "development"
    }

    // 기본 .env 먼저 로드 (공통 설정)
    _ = godotenv.Load(".env")

    // 환경별 .env 파일로 덮어씀
    envFile := fmt.Sprintf(".env.%s", appEnv)
    if _, err := os.Stat(envFile); err == nil {
        if err := godotenv.Overload(envFile); err != nil {
            return nil, fmt.Errorf("%s 로드 실패: %w", envFile, err)
        }
    }

    return Load()
}

4-2. .gitignore 설정

# 실제 비밀값이 포함된 파일
.env
.env.development
.env.staging
.env.production
.env.test

# 템플릿은 Git에 포함
# .env.example ← 주석 처리하지 않음

5. Docker와 Kubernetes에서의 환경변수

5-1. Docker Compose

# docker-compose.yml
services:
  app:
    build: .
    env_file:
      - .env              # 공통 설정
      - .env.production   # 환경별 설정 (서버에 존재)
    environment:
      # docker-compose.yml 자체의 환경변수로 민감값 주입
      - DB_PASSWORD=${DB_PASSWORD}
      - JWT_SECRET=${JWT_SECRET}

  db:
    image: postgres:16
    environment:
      POSTGRES_USER: ${DB_USER}
      POSTGRES_PASSWORD: ${DB_PASSWORD}
      POSTGRES_DB: ${DB_NAME}
# 운영 서버에서 실행 (쉘 환경변수로 주입)
export DB_PASSWORD="prod-secret-password"
export JWT_SECRET="prod-jwt-secret-32chars-minimum!"
docker-compose up -d

5-2. Kubernetes Secret

# k8s/secret.yaml (절대 Git에 커밋 금지)
apiVersion: v1
kind: Secret
metadata:
  name: app-secrets
type: Opaque
stringData:
  DB_PASSWORD: "prod-secret-password"
  JWT_SECRET: "prod-jwt-secret-32chars-minimum!"
# k8s/deployment.yaml
spec:
  containers:
  - name: app
    image: myapp:latest
    envFrom:
    - secretRef:
        name: app-secrets     # Secret의 모든 키를 환경변수로
    - configMapRef:
        name: app-config      # ConfigMap의 모든 키를 환경변수로

🛠️ Kubernetes에서는 Secret을 파일로 마운트하는 방법도 있습니다. /run/secrets/db-password 파일로 마운트하면 os.ReadFile로 읽을 수 있어, 환경변수 노출 없이 더 안전하게 관리할 수 있습니다.


6. 테스트에서 환경변수 제어: t.Setenv

Go 1.17부터 t.Setenv가 추가되어 테스트 중 환경변수를 임시로 설정하고, 테스트가 끝나면 자동으로 원상복구됩니다.

// config_test.go

func TestLoad_MissingRequired(t *testing.T) {
    // t.Setenv: 테스트 종료 시 자동 복원 (defer 불필요)
    t.Setenv("DB_PASSWORD", "")
    t.Setenv("JWT_SECRET", "")

    _, err := Load()
    if err == nil {
        t.Fatal("필수값 누락 시 에러가 발생해야 합니다")
    }
}

func TestLoad_ValidConfig(t *testing.T) {
    t.Setenv("DB_HOST", "test-db")
    t.Setenv("DB_PORT", "5432")
    t.Setenv("DB_USER", "testuser")
    t.Setenv("DB_PASSWORD", "testpassword")
    t.Setenv("DB_NAME", "testdb")
    t.Setenv("JWT_SECRET", "test-jwt-secret-32chars-minimum!")
    t.Setenv("SERVER_PORT", "9090")

    cfg, err := Load()
    if err != nil {
        t.Fatalf("설정 로드 실패: %v", err)
    }

    if cfg.Database.Host != "test-db" {
        t.Errorf("DB 호스트 불일치: got %q, want %q", cfg.Database.Host, "test-db")
    }
    if cfg.Server.Port != 9090 {
        t.Errorf("서버 포트 불일치: got %d, want %d", cfg.Server.Port, 9090)
    }
}

func TestDatabaseConfig_DSN(t *testing.T) {
    cfg := DatabaseConfig{
        Host: "localhost", Port: 5432,
        User: "user", Password: "pass",
        Name: "db", SSLMode: "disable",
    }
    want := "host=localhost port=5432 user=user password=pass dbname=db sslmode=disable"
    if got := cfg.DSN(); got != want {
        t.Errorf("DSN 불일치\ngot:  %s\nwant: %s", got, want)
    }
}

7. main.go에서의 통합 패턴

// main.go
func main() {
    // 1) 설정 로드 — 애플리케이션 진입점에서 한 번만
    cfg, err := config.LoadForEnv()
    if err != nil {
        // 설정 실패는 즉시 종료 — slog보다 먼저 실패할 수 있음
        fmt.Fprintf(os.Stderr, "설정 로드 실패: %v\n", err)
        os.Exit(1)
    }

    // 2) 구조화 로거 초기화
    var logLevel slog.Level
    if cfg.App.Debug {
        logLevel = slog.LevelDebug
    } else {
        logLevel = slog.LevelInfo
    }
    logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
        Level: logLevel,
    }))
    slog.SetDefault(logger)

    slog.Info("설정 로드 완료",
        "env", cfg.App.Env,
        "debug", cfg.App.Debug,
        "server_addr", cfg.Server.Addr(),
        "db_host", cfg.Database.Host,
    )

    // 3) DB 연결
    db, err := sql.Open("postgres", cfg.Database.DSN())
    if err != nil {
        slog.Error("DB 연결 실패", "error", err)
        os.Exit(1)
    }
    db.SetMaxOpenConns(cfg.Database.MaxOpenConns)
    db.SetMaxIdleConns(cfg.Database.MaxIdleConns)
    db.SetConnMaxLifetime(cfg.Database.ConnMaxLifetime)
    defer db.Close()

    // 4) 서버 시작
    srv := &http.Server{
        Addr:         cfg.Server.Addr(),
        ReadTimeout:  cfg.Server.ReadTimeout,
        WriteTimeout: cfg.Server.WriteTimeout,
        Handler:      setupRoutes(cfg, db),
    }

    slog.Info("서버 시작", "addr", srv.Addr)
    if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
        slog.Error("서버 오류", "error", err)
        os.Exit(1)
    }
}

8. 설정 관리 안티패턴

안티패턴 1: 소스코드에 하드코딩

// ❌ 절대 금지 — 공개 저장소에 올라가면 보안 사고
db, _ := sql.Open("postgres", "host=prod.db.example.com password=SuperSecret123!")

// ✅ 환경변수 사용
db, _ := sql.Open("postgres", cfg.Database.DSN())

안티패턴 2: godotenv.Load() 실패를 치명적 오류로 처리

// ❌ 프로덕션에서 .env 없으면 서버 시작 불가
err := godotenv.Load()
if err != nil {
    log.Fatal(err) // .env 파일 없는 환경에서 죽음
}

// ✅ .env 파일은 선택사항, OS 환경변수로도 동작해야 함
_ = godotenv.Load() // 실패 무시 (파일 없어도 OK)

안티패턴 3: 전역 변수로 Config 노출

// ❌ 전역 상태 — 테스트 격리 불가, 경쟁 조건 위험
var GlobalConfig *Config

// ✅ 의존성 주입 — 함수/구조체 인자로 전달
func NewServer(cfg *Config, db *sql.DB) *Server {
    return &Server{cfg: cfg, db: db}
}

안티패턴 4: .env 파일을 Git에 커밋

# 실수로 커밋된 .env 파일 이력 제거
git filter-branch --force --index-filter \
  'git rm --cached --ignore-unmatch .env' \
  --prune-empty --tag-name-filter cat -- --all

# 이미 노출된 비밀값은 반드시 교체해야 합니다

핵심 요약

다음 글에서는 Go 테스트 코드 작성을 다룹니다. testing 패키지, 테이블 드리븐 테스트, Mock 패턴, 커버리지 측정까지 살펴보겠습니다.