본문으로 건너뛰기

설정 관리

이 챕터에서 다루는 것

설정을 어디서 읽고, 무엇이 무엇을 이기고, 언제 검증할지를 정한다. 표준 라이브러리의 flagos.Getenv만으로 충분한 구조를 만들고, 그 위에 Viper 같은 도구를 얹을 기준선을 정한다.

12-2에서 만든 applog.Secret이 여기서 비밀번호를 담는 타입으로 쓰인다.

문제 — 설정을 읽는 코드가 흩어지면

// handler.go
timeout, _ := strconv.Atoi(os.Getenv("TIMEOUT"))

// db.go
dsn := os.Getenv("DATABASE_URL")

// worker.go
if os.Getenv("DEBUG") == "1" { ... }

각각은 두 줄짜리라 문제로 보이지 않는다. 문제는 이 코드가 답하지 못하는 질문들이다.

  • "이 프로그램은 무슨 환경 변수를 읽는가?" grep 말고는 답이 없고, grep은 오타를 못 잡는다.
  • "TIMEOUT을 안 주면 어떻게 되는가?" strconv.Atoi("")는 0을 준다. 타임아웃 0초는 즉시 실패다. 그것도 조용히.
  • "이 설정이 맞게 들어갔나?" 세 시간 뒤 첫 결제 요청에서 알게 된다.
  • "테스트에서 어떻게 바꾸지?" os.Setenv뿐이고, 그것은 프로세스 전역이라 병렬 테스트끼리 서로를 망가뜨린다.

해법은 구조 하나다. 설정을 읽는 코드를 한 패키지에 모으고, 결과를 구조체 하나로 만들고, 시작할 때 전부 검증한다.

구조체 하나로 모으기

examples/12-production/03-configuration/config/config.go
// Config는 프로그램 전체 설정이다.
type Config struct {
Env string `json:"env"`
Server Server `json:"server"`
DB DB `json:"db"`
Log Log `json:"log"`
}

중첩 구조체로 나눈 것은 취향이 아니다. Server를 통째로 http.Server 조립 함수에 넘기면, 그 함수는 DB 설정을 볼 수 없다. 파트 6에서 다룬 "필요한 것만 넘긴다"가 설정에도 적용된다.

기본값은 함수로 둔다.

examples/12-production/03-configuration/config/config.go
// Defaults는 아무것도 주지 않았을 때의 값이다.
//
// 개발 환경에서 그대로 뜨는 값이어야 한다. 기본값만으로 프로그램이
// 실행되지 않으면 새로 합류한 사람이 첫날을 설정 파일과 싸우며 보낸다.
func Defaults() Config {
return Config{
Env: "dev",
Server: Server{
Addr: ":8080",
ReadTimeout: Duration(5 * time.Second),
WriteTimeout: Duration(10 * time.Second),
ShutdownGrace: Duration(15 * time.Second),
},

"기본값만으로 뜬다"는 목표는 생각보다 중요하다. git clonego run .이 그냥 돌아가는 프로젝트와, README의 환경 변수 열두 개를 먼저 채워야 하는 프로젝트는 합류 비용이 다르다.

time.Duration의 JSON 문제

time.Duration은 그냥 int64다. JSON으로 내보내면 나노초 정수가 된다.

{"server": {"write_timeout": 20000000000}}

사람이 읽을 수도, 쓸 수도 없다. UnmarshalText를 구현한 한 겹으로 해결한다.

examples/12-production/03-configuration/config/config.go
// Duration은 "5s"처럼 사람이 읽는 문자열로 오가는 time.Duration이다.
//
// time.Duration을 그대로 쓰면 JSON에서 정수 나노초가 된다. 설정 파일에
// "write_timeout": 20000000000이라고 적힌 것을 사람이 읽을 수는 없다.
// encoding/json은 UnmarshalText를 구현한 타입을 JSON 문자열로 다루므로,
// 이 한 겹으로 "20s"라고 쓸 수 있게 된다.
type Duration time.Duration

// D는 표준 time.Duration으로 돌려준다.
func (d Duration) D() time.Duration { return time.Duration(d) }

// String은 "5s" 형태다.
func (d Duration) String() string { return time.Duration(d).String() }

// MarshalText는 설정을 다시 파일로 쓸 때 쓰인다.
func (d Duration) MarshalText() ([]byte, error) { return []byte(d.String()), nil }

// UnmarshalText는 "5s", "200ms", "1h30m"을 받는다.
func (d *Duration) UnmarshalText(b []byte) error {
v, err := time.ParseDuration(string(b))
if err != nil {
return err
}
*d = Duration(v)
return nil
}

이제 설정 파일이 읽힌다.

examples/12-production/03-configuration/config.json
{
"server": {
"addr": ":9090",
"write_timeout": "20s"
},
"db": {
"max_open_conns": 50,
"max_idle_conns": 10
},
"log": {
"level": "warn"
}
}

"level": "warn"이 그냥 되는 것도 같은 원리다. slog.LevelUnmarshalText를 이미 구현하고 있다. 표준 라이브러리 타입이 텍스트 마셜링을 지원하는지 먼저 확인해 보는 습관이 도움이 된다.

우선순위: 기본값 < 파일 < 환경 변수 < 플래그

examples/12-production/03-configuration/config/config.go
// Load는 기본값 < 파일 < 환경 변수 < 플래그 순으로 설정을 합치고 검증한다.
//
// 플래그가 가장 세다는 것은 12-factor의 "설정은 환경 변수로"와 어긋나 보이지만,
// 실제로는 둘 다 필요하다. 환경 변수는 배포 파이프라인이 넣고, 플래그는
// 사람이 그 위에 한 번만 덮어쓰기 위해 쓴다. 컨테이너 안에서
// `./app -log.level=debug`로 한 번 띄워 보는 상황이 그것이다.
func Load(src Sources) (Config, error) {
cfg := Defaults()

구현 요령은 이미 값이 들어 있는 구조체 위에 덮어쓰는 것이다. 각 단계는 "자기가 아는 것만" 쓰고 나머지는 건드리지 않는다. JSON 디코딩이 이 방식과 잘 맞는다.

examples/12-production/03-configuration/config/config.go
// 이미 기본값이 들어 있는 구조체 위에 덮어쓴다. JSON에 없는 필드는
// 건드려지지 않으므로, 파일에는 바꿀 것만 적으면 된다.
dec := json.NewDecoder(strings.NewReader(string(data)))
// 오타 난 키를 조용히 무시하지 않는다. "log_lvel"을 적어 두고
// "설정이 안 먹는다"고 한나절 헤매는 것을 막는다.
dec.DisallowUnknownFields()

DisallowUnknownFields는 설정 파일에서 특히 값어치가 크다. JSON의 기본 동작은 모르는 키를 조용히 버리는 것이고, 그래서 오타 난 설정은 아무 에러 없이 무시된다. 파트 9-4에서 API 요청 본문에 이 옵션을 쓸 때와 이유가 같다.

fs.Visit — 플래그 우선순위의 핵심

플래그 단계에는 함정이 하나 있다. flag 패키지는 플래그마다 기본값을 갖는데, 그 기본값을 그대로 쓰면 지정하지 않은 플래그가 환경 변수를 덮어쓴다.

examples/12-production/03-configuration/config/config.go
// applyFlags는 명령줄 플래그를 읽는다.
//
// 핵심은 fs.Visit다. fs.VisitAll은 모든 플래그를 도는 반면 Visit은
// 실제로 지정된 것만 돈다. 그래서 "-addr을 안 줬으면 환경 변수 값을
// 그대로 둔다"가 성립한다. 이 구분을 놓치면 플래그 기본값이 항상
// 환경 변수를 덮어써서 우선순위가 무너진다.
func applyFlags(cfg *Config, args []string) error {

VisitVisitAll의 차이 한 글자가 우선순위 전체를 결정한다. 테스트로 못 박아 둔다.

examples/12-production/03-configuration/config/config_test.go
func TestUnsetFlagDoesNotOverrideEnv(t *testing.T) {
// fs.Visit 대신 fs.VisitAll을 쓰면 이 테스트가 깨진다.
// 지정하지 않은 -addr의 기본값이 환경 변수를 덮어쓰기 때문이다.
t.Parallel()

cfg, err := config.Load(src(
[]string{"-log.json"},
map[string]string{"APP_SERVER_ADDR": ":7070"},
"",
))
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.Server.Addr != ":7070" {
t.Fatalf("Addr = %q, 환경 변수 값이 남아 있어야 한다", cfg.Server.Addr)
}
if !cfg.Log.JSON {
t.Fatal("지정한 -log.json은 적용돼야 한다")
}
}

:::note 설정 파일 경로는 닭과 달걀 설정 파일 경로 자체는 설정 파일에 적을 수 없다. 그래서 -config만 따로, 다른 플래그를 등록하기 전에 찾아야 한다. flag.FlagSet은 모르는 플래그를 만나면 에러를 내므로 -config 하나만 등록한 FlagSet으로는 나머지 인자에서 걸린다. 인자 슬라이스를 손으로 한 번 훑는 편이 간단하다. :::

전역 상태를 주입으로 바꾸기

os.Getenvos.ReadFile을 직접 부르지 않는다.

examples/12-production/03-configuration/config/config.go
// Sources는 설정을 어디서 읽을지 주입받는 자리다.
//
// os.Getenv와 os.ReadFile을 직접 부르지 않고 함수로 받는 이유는
// 테스트 때문이다. 전역 환경 변수를 건드리는 테스트는 병렬 실행에서
// 서로를 망가뜨린다. 함수로 받으면 테스트마다 자기 환경을 갖는다.
type Sources struct {
Args []string
Getenv func(string) string
ReadFile func(string) ([]byte, error)
}

// OSSources는 진짜 프로세스 환경을 쓰는 Sources다.
func OSSources(args []string) Sources {
return Sources{Args: args, Getenv: os.Getenv, ReadFile: os.ReadFile}
}

main에서는 config.Load(config.OSSources(os.Args[1:]))이고, 테스트에서는 맵이다.

examples/12-production/03-configuration/config/config_test.go
// src는 테스트마다 자기만의 환경을 만든다.
// t.Setenv를 쓰지 않으므로 t.Parallel과 함께 써도 안전하다.
func src(args []string, env map[string]string, file string) config.Sources {
return config.Sources{
Args: args,
Getenv: func(k string) string { return env[k] },
ReadFile: func(string) ([]byte, error) {
if file == "" {
return nil, errors.New("파일 없음")
}
return []byte(file), nil
},
}
}

t.Setenv는 Go 1.17에 추가된 편리한 함수지만, t.Parallel과 함께 쓸 수 없다. 환경 변수가 프로세스 전역이기 때문이고, testing이 이 조합을 감지해 테스트를 실패시킨다. 설정 소스를 주입으로 바꾸면 그 제약이 사라진다. 이것은 설정만의 이야기가 아니라, Go에서 main을 테스트 가능하게 만드는 일반적인 패턴이다 — 프로세스 전역에 의존하는 것들 (os.Args, 환경 변수, os.Stdin/Stdout, 시계)을 전부 인자로 받는 함수를 하나 만들고, main은 그것을 진짜 값으로 한 번 부르는 껍데기가 된다. 11-7에서 task.Clock을 주입한 것과 같은 논리다.

검증은 시작할 때, 한 번에 전부

첫 문제에서 멈추면 "고치고 다시 띄우고"를 반복하게 된다. errors.Join(파트 4-7)으로 모아서 돌려준다.

examples/12-production/03-configuration/config/config.go
// Validate는 모든 문제를 한 번에 모아서 돌려준다.
//
// 첫 에러에서 멈추면 사용자가 설정을 고치고 다시 띄우고를 반복하게 된다.
// errors.Join으로 묶으면 한 번에 다 보인다.
func (c Config) Validate() error {
var errs []error

검증에는 세 종류가 있다.

필드 하나짜리. Addr이 비었나, 타임아웃이 양수인가.

필드 사이의 관계. 개별로는 통과하지만 함께 보면 틀린 조합.

examples/12-production/03-configuration/config/config.go
// 관계 검증. 개별 필드만 보면 통과하지만 함께 보면 틀린 조합이 있다.
if c.DB.MaxIdleConns > c.DB.MaxOpenConns {
errs = append(errs, fmt.Errorf("config: db.max_idle_conns(%d)가 max_open_conns(%d)보다 크다",
c.DB.MaxIdleConns, c.DB.MaxOpenConns))
}

11-2에서 db.Stats()WaitCount/WaitDuration을 봤다. MaxIdleConnsMaxOpenConns보다 큰 설정은 조용히 무시되지만, 그런 값을 적었다는 것은 두 값의 관계를 오해했다는 뜻이므로 시작할 때 알려 주는 편이 낫다. 커넥션 풀 설정이 성능에 어떻게 영향을 주는지는 12-8에서 실제로 측정한다.

환경에 따라 달라지는 것. dev에서는 허용하지만 prod에서는 금지하는 것들.

examples/12-production/03-configuration/config/config.go
// 환경에 따라 달라지는 검증. prod에서만 강제하는 것들이 있다.
if c.Env == "prod" {
if !c.Log.JSON {
errs = append(errs, errors.New("config: prod에서는 log.json이 켜져 있어야 한다"))
}
if c.DB.Password == "" {
errs = append(errs, errors.New("config: prod에서는 db.password가 필요하다"))
}
if strings.HasPrefix(c.DB.DSN, "file:") {
errs = append(errs, fmt.Errorf("config: prod에서 파일 DB는 쓸 수 없다 (%s)", c.DB.DSN))
}
}

이 세 번째 종류가 실제 사고를 가장 많이 막는다. "스테이징 DSN을 그대로 두고 prod에 배포"는 흔한 사고이고, 여기서 걸린다.

돌려 본 결과

== 1. 기본값만 ==
{"level":"INFO","msg":"설정 완료","config":{"env":"dev","server":{"addr":":8080","read_timeout":"5s","write_timeout":"10s","shutdown_grace":"15s"},"db":{"dsn":"file:app.db","password":"REDACTED","max_open_conns":25,"max_idle_conns":5,"conn_max_lifetime":"30m0s"},"log":{"level":"INFO","json":false}}}

== 2. + 설정 파일 (addr, write_timeout, 커넥션 수, 레벨) ==
{"level":"INFO","msg":"설정 완료","config":{"env":"dev","server":{"addr":":9090","read_timeout":"5s","write_timeout":"20s","shutdown_grace":"15s"},"db":{"dsn":"file:app.db","password":"REDACTED","max_open_conns":50,"max_idle_conns":10,"conn_max_lifetime":"30m0s"},"log":{"level":"WARN","json":false}}}

== 3. + 환경 변수 (파일의 addr과 레벨을 이긴다) ==
{"level":"INFO","msg":"설정 완료","config":{"env":"dev","server":{"addr":":7070","read_timeout":"5s","write_timeout":"20s","shutdown_grace":"15s"},"db":{"dsn":"file:app.db","password":"REDACTED","max_open_conns":50,"max_idle_conns":10,"conn_max_lifetime":"30m0s"},"log":{"level":"DEBUG","json":false}}}

== 4. + 플래그 (환경 변수도 이긴다) ==
{"level":"INFO","msg":"설정 완료","config":{"env":"dev","server":{"addr":":6060","read_timeout":"5s","write_timeout":"20s","shutdown_grace":"15s"},"db":{"dsn":"file:app.db","password":"REDACTED","max_open_conns":50,"max_idle_conns":10,"conn_max_lifetime":"30m0s"},"log":{"level":"DEBUG","json":true}}}

== 5. 검증 실패는 한 번에 모아서 ==
config: db.max_idle_conns(100)가 max_open_conns(25)보다 크다
config: prod에서는 log.json이 켜져 있어야 한다
config: prod에서는 db.password가 필요하다
config: prod에서 파일 DB는 쓸 수 없다 (file:app.db)

== 6. 값이 형식에 안 맞을 때 ==
config: APP_SERVER_READ_TIMEOUT="5 seconds": time: unknown unit " seconds" in duration "5 seconds"
config: APP_DB_MAX_OPEN_CONNS="many": strconv.Atoi: parsing "many": invalid syntax

1~4번에서 addr:8080:9090:7070:6060으로 계단을 올라간다. write_timeout은 파일에서 20s를 받은 뒤 아무도 건드리지 않아 끝까지 남았다.

5번에서 네 가지 문제가 한 번에 나왔다. 그리고 모든 출력에서 passwordREDACTED다. APP_DB_PASSWORD=s3cr3t-from-vault를 넣었는데도 그렇다.

시크릿

설정 전체를 로그에 찍는 것은 좋은 습관이다. "그 서버가 어떤 설정으로 떠 있었는가"에 로그만 보고 답할 수 있게 된다. 문제는 그중에 비밀번호가 섞여 있다는 것이고, 답은 12-2의 applog.Secret이다.

examples/12-production/03-configuration/config/config.go
// LogValue는 설정 전체를 로그에 안전하게 찍을 수 있게 한다.
//
// DB.Password는 applog.Secret이므로 자동으로 가려진다. 시작할 때
// 해석된 설정을 통째로 한 줄 찍어 두면, 나중에 "그 서버가 어떤 설정으로
// 떠 있었는가"를 로그만 보고 답할 수 있다.
func (c Config) LogValue() slog.Value {

비밀번호를 플래그로 받지 않는 것도 정책이다.

examples/12-production/03-configuration/config/config.go
// 비밀번호를 명령줄 플래그로 받지 않는 것이 의도다.
// ps aux에 그대로 보이기 때문이다. 환경 변수나 파일로만 받는다.

시크릿 취급의 기준선은 이렇다.

방법평가
소스 코드에 하드코딩절대 금지. git 히스토리에서 영원히 지워지지 않는다
명령줄 플래그금지. ps aux와 셸 히스토리에 남는다
환경 변수실용적인 최소선. 단 자식 프로세스에 상속되고 크래시 덤프에 실린다
파일(권한 0600)환경 변수보다 낫다. 쿠버네티스 시크릿 마운트가 이 형태다
시크릿 관리 서비스Vault, AWS Secrets Manager 등. 회전이 필요하면 사실상 필수

환경 변수가 "그럭저럭"인 이유를 알아 둘 만하다. 자식 프로세스가 환경을 통째로 물려받으므로 exec.Command로 부른 도구가 그것을 로그에 남길 수 있고, 크래시 덤프에도 프로세스 환경이 포함된다. 그래도 대부분의 서비스에서 실용적인 출발점이고, 12-factor가 권하는 것도 이것이다.

Viper를 언제 도입하는가

지금까지 만든 것은 표준 라이브러리만 쓴다. Viper는 훨씬 많은 것을 해 준다 — YAML/TOML/HCL, 원격 설정 저장소(etcd, Consul), 파일 변경 감시와 자동 리로드, 대소문자 무시 키.

기준은 이렇게 잡는다.

표준 라이브러리로 충분한 경우. 설정 항목이 수십 개 이하, 형식은 JSON이나 환경 변수, 재시작으로 설정을 반영해도 되는 서비스. 대부분의 웹 서비스가 여기 속한다.

Viper를 고려할 경우. YAML이 조직 표준이라 협상 불가능할 때. 설정을 원격 저장소에서 받아야 할 때. CLI 도구라서 ~/.config/tool/config.yaml, ./config.yaml, 환경 변수를 표준적인 규칙으로 찾아 줘야 할 때. Cobra로 CLI를 만들고 있다면 궁합이 좋다.

Viper를 쓰더라도 유지할 것. 결과를 구조체 하나로 만들고, 시작할 때 검증하고, 읽는 코드를 한 패키지에 가둔다. viper.GetString("db.dsn")을 서비스 곳곳에서 부르는 것은 os.Getenv를 흩어 놓은 것과 똑같은 문제이고, 오타를 컴파일러가 못 잡는다는 점에서 오히려 나쁘다.

:::tip 자동 리로드는 생각보다 어렵다 "설정 파일을 고치면 즉시 반영"은 매력적으로 들리지만, 지금 그 값을 읽고 있는 코드와 경쟁 상태가 된다. 12-2의 slog.LevelVar처럼 원자적으로 읽고 쓸 수 있는 값만 동적으로 바꾸고, 나머지는 재시작으로 반영하는 것이 안전하다. 재시작이 부담스럽다면 그것은 설정 문제가 아니라 무중단 배포 문제이고, 12-8에서 다룬다. :::

흔한 실수

strconv.Atoi(os.Getenv(...))의 에러를 버린다. 값이 없거나 잘못됐을 때 0이 되고, 0은 대개 "무제한" 아니면 "즉시 실패"라는 극단적인 의미를 갖는다.

빈 문자열과 "설정하지 않음"을 구분하지 않는다. os.Getenv는 둘 다 ""를 준다. APP_PREFIX=""로 "접두사 없음"을 표현하고 싶다면 os.LookupEnv를 써야 한다.

설정 구조체를 전역 변수로 둔다. var Cfg Config는 편하지만, 테스트가 서로를 간섭하고 "누가 언제 이 값을 바꿨나"를 추적할 수 없게 된다. 필요한 계층에 필요한 부분만 넘긴다.

민감한 값을 String()이 있는 타입으로 감싸지 않는다. 12-2에서 다뤘다.

설정 파일을 저장소에 커밋한다. config.json은 예제라서 커밋돼 있다. 실제 서비스에서는 config.example.json만 커밋하고 진짜 파일은 .gitignore에 넣는다.

환경별로 설정 파일을 통째로 복사한다. config.dev.json, config.prod.json, config.staging.json이 90% 같은 내용이면, 하나를 고칠 때 나머지를 빠뜨린다. 공통 파일 하나 + 환경 변수 덮어쓰기가 낫다.

정리

  • 설정을 읽는 코드는 한 패키지에. 결과는 구조체 하나.
  • 우선순위는 기본값 < 파일 < 환경 변수 < 플래그. 각 단계는 자기가 아는 것만 덮어쓴다.
  • fs.Visit은 지정된 플래그만 돈다. VisitAll을 쓰면 우선순위가 무너진다.
  • DisallowUnknownFields로 오타 난 설정 키를 잡는다.
  • time.DurationUnmarshalText 한 겹을 씌워 "20s"로 읽는다.
  • os.Getenv/os.ReadFile을 주입받으면 테스트가 병렬로 돌 수 있다.
  • 검증은 시작할 때, errors.Join으로 전부 한 번에. 환경별 규칙이 사고를 가장 많이 막는다.
  • 시크릿은 LogValuer 타입으로 감싸고, 플래그로 받지 않는다.
  • Viper는 YAML·원격 설정·CLI 규약이 필요할 때. 그때도 구조체와 검증은 유지한다.

연습문제

  1. APP_LOG_LEVEL=""APP_LOG_LEVEL을 아예 지정하지 않은 것을 구분해 보자. Sources.Getenv의 시그니처를 func(string) (string, bool)로 바꾸면 되는데, 그러면 os.Getenv를 그대로 넘길 수 없다. os.LookupEnv의 시그니처를 확인하고 OSSources를 고쳐 보자.

  2. ConfigRedis 섹션을 추가해 보자. 필드는 Addr, Password(Secret), DB(int). 추가하는 데 몇 군데를 고쳐야 하는가? 기본값, 환경 변수 매핑, 플래그, 검증, LogValue — 다섯 곳이다. 이 반복을 리플렉션과 구조체 태그로 줄이는 것이 caarlos0/env 같은 라이브러리가 하는 일인데, 그 대가로 무엇을 잃는지 생각해 보자. (힌트: 오타 난 태그, 컴파일 타임 검사, 에러 메시지의 품질)

  3. 12-2의 applog.LevelConfig.Log.Level로 초기화하는 코드를 main에 써 보자. 그다음, 설정 파일을 다시 읽어 레벨만 갱신하는 Reload 함수를 만들어 보자. LevelVar 외에 어떤 필드를 동적으로 바꿀 수 있는가? Server.Addr은 왜 안 되는가?