2026-01-05
이 글에서는 Go 애플리케이션의 설정 관리를 체계적으로 다룹니다. 데이터베이스 비밀번호를 소스코드에 하드코딩하는 순간 그 코드는 공개 저장소에 올라가서는 안 되는 코드가 됩니다. 환경변수 기반 설정 관리는 단순히 “보안 좋은 습관”이 아니라 12-Factor App 원칙에 따른 현대적인 서버 설계의 기본입니다. os.Getenv와 os.LookupEnv의 차이, godotenv의 동작 방식, 타입 안전한 Config 구조체 설계, 다중 환경 분리, Docker와 Kubernetes에서의 비밀 관리, 그리고 테스트에서의 환경변수 제어까지 — 실무에 바로 적용할 수 있는 패턴을 모두 담았습니다.
두 함수의 차이는 환경변수가 존재하지 않는 경우와 빈 문자열로 설정된 경우를 구분할 수 있느냐입니다.
// 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가 빈 문자열로 설정되었습니다")
}
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_")
github.com/joho/godotenv는 .env 파일을 읽어 os.Setenv로 등록해주는 라이브러리입니다.
go get github.com/joho/godotenv
# .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}
// godotenv.Load: 이미 설정된 환경변수는 덮어쓰지 않음 (OS 환경변수 우선)
// → 프로덕션에서 .env보다 실제 OS 환경변수가 우선되어 안전
err := godotenv.Load(".env")
// godotenv.Overload: .env가 기존 값을 덮어씀 (테스트용)
err := godotenv.Overload(".env.test")
🛠️ 프로덕션 서버에서는
godotenv.Load를 사용해야 합니다. CI/CD 파이프라인이나 Docker에서 주입한 실제 환경변수가 있을 때.env파일이 그것을 덮어쓰지 않도록 보장합니다.
func init() {
// 파일이 없으면 무시 (프로덕션에서 .env 없이 동작)
if err := godotenv.Load(); err != nil {
// 파일이 없는 경우는 정상 — OS 환경변수로 동작
if !os.IsNotExist(err) {
slog.Warn(".env 파일 로딩 실패", "error", err)
}
}
}
매번 os.Getenv를 호출하는 대신, 애플리케이션 시작 시 한 번에 설정을 읽어 타입이 있는 구조체에 저장합니다. 이후 코드는 문자열 변환 없이 직접 사용합니다.
// 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
}
// 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
}
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
}
개발(development), 스테이징(staging), 프로덕션(production) 환경별로 다른 설정이 필요합니다.
.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()
}
# 실제 비밀값이 포함된 파일
.env
.env.development
.env.staging
.env.production
.env.test
# 템플릿은 Git에 포함
# .env.example ← 주석 처리하지 않음
# 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
# 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로 읽을 수 있어, 환경변수 노출 없이 더 안전하게 관리할 수 있습니다.
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)
}
}
// 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)
}
}
// ❌ 절대 금지 — 공개 저장소에 올라가면 보안 사고
db, _ := sql.Open("postgres", "host=prod.db.example.com password=SuperSecret123!")
// ✅ 환경변수 사용
db, _ := sql.Open("postgres", cfg.Database.DSN())
// ❌ 프로덕션에서 .env 없으면 서버 시작 불가
err := godotenv.Load()
if err != nil {
log.Fatal(err) // .env 파일 없는 환경에서 죽음
}
// ✅ .env 파일은 선택사항, OS 환경변수로도 동작해야 함
_ = godotenv.Load() // 실패 무시 (파일 없어도 OK)
// ❌ 전역 상태 — 테스트 격리 불가, 경쟁 조건 위험
var GlobalConfig *Config
// ✅ 의존성 주입 — 함수/구조체 인자로 전달
func NewServer(cfg *Config, db *sql.DB) *Server {
return &Server{cfg: cfg, db: db}
}
# 실수로 커밋된 .env 파일 이력 제거
git filter-branch --force --index-filter \
'git rm --cached --ignore-unmatch .env' \
--prune-empty --tag-name-filter cat -- --all
# 이미 노출된 비밀값은 반드시 교체해야 합니다
os.LookupEnv: 미설정과 빈 문자열을 구분해야 할 때 사용godotenv.Load: OS 환경변수 우선, .env는 보완. godotenv.Overload는 테스트용validate() 메서드로 필수값 검증DSN(), Addr() 메서드: 설정 조합 로직을 Config 구조체가 책임.env, .env.development, .env.production 계층 구조.gitignore: .env* 제외, .env.example은 포함env_file + environment로 민감값과 공통값 분리Secret으로 민감값 관리, ConfigMap으로 일반 설정t.Setenv: 테스트 중 환경변수 임시 설정, 종료 시 자동 복원log.Fatal(godotenv.Load()), 전역 Config, Git 커밋다음 글에서는 Go 테스트 코드 작성을 다룹니다. testing 패키지, 테이블 드리븐 테스트, Mock 패턴, 커버리지 측정까지 살펴보겠습니다.