설정 관리
이 챕터에서 다루는 것
설정을 어디서 읽고, 무엇이 무엇을 이기고, 언제 검증할지를 정한다. 표준 라이브러리의
flag와 os.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뿐이고, 그것은 프로세스 전역이라 병렬 테스트끼리 서로를 망가뜨린다.
해법은 구조 하나다. 설정을 읽는 코드를 한 패키지에 모으고, 결과를 구조체 하나로 만들고, 시작할 때 전부 검증한다.
구조체 하나로 모으기
// 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에서 다룬 "필요한 것만 넘긴다"가
설정에도 적용된다.
기본값은 함수로 둔다.
// 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 clone 후 go run .이 그냥
돌아가는 프로젝트와, README의 환경 변수 열두 개를 먼저 채워야 하는 프로젝트는
합류 비용이 다르다.
time.Duration의 JSON 문제
time.Duration은 그냥 int64다. JSON으로 내보내면 나노초 정수가 된다.
{"server": {"write_timeout": 20000000000}}
사람이 읽을 수도, 쓸 수도 없다. UnmarshalText를 구현한 한 겹으로 해결한다.
// 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
}
이제 설정 파일이 읽힌다.
{
"server": {
"addr": ":9090",
"write_timeout": "20s"
},
"db": {
"max_open_conns": 50,
"max_idle_conns": 10
},
"log": {
"level": "warn"
}
}
"level": "warn"이 그냥 되는 것도 같은 원리다. slog.Level이 UnmarshalText를
이미 구현하고 있다. 표준 라이브러리 타입이 텍스트 마셜링을 지원하는지 먼저
확인해 보는 습관이 도움이 된다.
우선순위: 기본값 < 파일 < 환경 변수 < 플래그
// Load는 기본값 < 파일 < 환경 변수 < 플래그 순으로 설정을 합치고 검증한다.
//
// 플래그가 가장 세다는 것은 12-factor의 "설정은 환경 변수로"와 어긋나 보이지만,
// 실제로는 둘 다 필요하다. 환경 변수는 배포 파이프라인이 넣고, 플래그는
// 사람이 그 위에 한 번만 덮어쓰기 위해 쓴다. 컨테이너 안에서
// `./app -log.level=debug`로 한 번 띄워 보는 상황이 그것이다.
func Load(src Sources) (Config, error) {
cfg := Defaults()
구현 요령은 이미 값이 들어 있는 구조체 위에 덮어쓰는 것이다. 각 단계는 "자기가 아는 것만" 쓰고 나머지는 건드리지 않는다. JSON 디코딩이 이 방식과 잘 맞는다.
// 이미 기본값이 들어 있는 구조체 위에 덮어쓴다. JSON에 없는 필드는
// 건드려지지 않으므로, 파일에는 바꿀 것만 적으면 된다.
dec := json.NewDecoder(strings.NewReader(string(data)))
// 오타 난 키를 조용히 무시하지 않는다. "log_lvel"을 적어 두고
// "설정이 안 먹는다"고 한나절 헤매는 것을 막는다.
dec.DisallowUnknownFields()
DisallowUnknownFields는 설정 파일에서 특히 값어치가 크다. JSON의 기본 동작은 모르는
키를 조용히 버리는 것이고, 그래서 오타 난 설정은 아무 에러 없이 무시된다.
파트 9-4에서 API 요청 본문에 이 옵션을 쓸 때와 이유가 같다.
fs.Visit — 플래그 우선순위의 핵심
플래그 단계에는 함정이 하나 있다. flag 패키지는 플래그마다 기본값을 갖는데, 그 기본값을
그대로 쓰면 지정하지 않은 플래그가 환경 변수를 덮어쓴다.
// applyFlags는 명령줄 플래그를 읽는다.
//
// 핵심은 fs.Visit다. fs.VisitAll은 모든 플래그를 도는 반면 Visit은
// 실제로 지정된 것만 돈다. 그래서 "-addr을 안 줬으면 환경 변수 값을
// 그대로 둔다"가 성립한다. 이 구분을 놓치면 플래그 기본값이 항상
// 환경 변수를 덮어써서 우선순위가 무너진다.
func applyFlags(cfg *Config, args []string) error {
Visit과 VisitAll의 차이 한 글자가 우선순위 전체를 결정한다. 테스트로 못 박아 둔다.
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.Getenv와 os.ReadFile을 직접 부르지 않는다.
// 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:]))이고, 테스트에서는 맵이다.
// 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)으로
모아서 돌려준다.
// Validate는 모든 문제를 한 번에 모아서 돌려준다.
//
// 첫 에러에서 멈추면 사용자가 설정을 고치고 다시 띄우고를 반복하게 된다.
// errors.Join으로 묶으면 한 번에 다 보인다.
func (c Config) Validate() error {
var errs []error
검증에는 세 종류가 있다.
필드 하나짜리. Addr이 비었나, 타임아웃이 양수인가.
필드 사이의 관계. 개별로는 통과하지만 함께 보면 틀린 조합.
// 관계 검증. 개별 필드만 보면 통과하지만 함께 보면 틀린 조합이 있다.
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을 봤다. MaxIdleConns가
MaxOpenConns보다 큰 설정은 조용히 무시되지만, 그런 값을 적었다는 것은 두 값의 관계를
오해했다는 뜻이므로 시작할 때 알려 주는 편이 낫다. 커넥션 풀 설정이 성능에 어떻게
영향을 주는지는 12-8에서 실제로 측정한다.
환경에 따라 달라지는 것. dev에서는 허용하지만 prod에서는 금지하는 것들.
// 환경에 따라 달라지는 검증. 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번에서 네 가지 문제가 한 번에 나왔다. 그리고 모든 출력에서 password가
REDACTED다. APP_DB_PASSWORD=s3cr3t-from-vault를 넣었는데도 그렇다.
시크릿
설정 전체를 로그에 찍는 것은 좋은 습관이다. "그 서버가 어떤 설정으로 떠 있었는가"에
로그만 보고 답할 수 있게 된다. 문제는 그중에 비밀번호가 섞여 있다는 것이고, 답은
12-2의 applog.Secret이다.
// LogValue는 설정 전체를 로그에 안전하게 찍을 수 있게 한다.
//
// DB.Password는 applog.Secret이므로 자동으로 가려진다. 시작할 때
// 해석된 설정을 통째로 한 줄 찍어 두면, 나중에 "그 서버가 어떤 설정으로
// 떠 있었는가"를 로그만 보고 답할 수 있다.
func (c Config) LogValue() slog.Value {
비밀번호를 플래그로 받지 않는 것도 정책이다.
// 비밀번호를 명령줄 플래그로 받지 않는 것이 의도다.
// 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.Duration은UnmarshalText한 겹을 씌워 "20s"로 읽는다.os.Getenv/os.ReadFile을 주입받으면 테스트가 병렬로 돌 수 있다.- 검증은 시작할 때,
errors.Join으로 전부 한 번에. 환경별 규칙이 사고를 가장 많이 막는다. - 시크릿은
LogValuer타입으로 감싸고, 플래그로 받지 않는다. - Viper는 YAML·원격 설정·CLI 규약이 필요할 때. 그때도 구조체와 검증은 유지한다.
연습문제
-
APP_LOG_LEVEL=""과APP_LOG_LEVEL을 아예 지정하지 않은 것을 구분해 보자.Sources.Getenv의 시그니처를func(string) (string, bool)로 바꾸면 되는데, 그러면os.Getenv를 그대로 넘길 수 없다.os.LookupEnv의 시그니처를 확인하고OSSources를 고쳐 보자. -
Config에Redis섹션을 추가해 보자. 필드는Addr,Password(Secret),DB(int). 추가하는 데 몇 군데를 고쳐야 하는가? 기본값, 환경 변수 매핑, 플래그, 검증,LogValue— 다섯 곳이다. 이 반복을 리플렉션과 구조체 태그로 줄이는 것이caarlos0/env같은 라이브러리가 하는 일인데, 그 대가로 무엇을 잃는지 생각해 보자. (힌트: 오타 난 태그, 컴파일 타임 검사, 에러 메시지의 품질) -
12-2의
applog.Level을Config.Log.Level로 초기화하는 코드를main에 써 보자. 그다음, 설정 파일을 다시 읽어 레벨만 갱신하는Reload함수를 만들어 보자.LevelVar외에 어떤 필드를 동적으로 바꿀 수 있는가?Server.Addr은 왜 안 되는가?