1. Viper 소개

golang viper

Go 어플리케이션에서 구성 솔루션이 필요한 이유 이해

신뢰성 있고 유지보수가 용이한 소프트웨어를 구축하기 위해, 개발자들은 구성을 응용프로그램 로직에서 분리해야 합니다. 이를 통해 코드베이스를 변경하지 않고 응용프로그램의 동작을 조정할 수 있습니다. 구성 솔루션은 이러한 분리를 용이하게 하고 구성 데이터를 외부화하는 것을 용이하게 합니다.

특히 Go 어플리케이션은 복잡도가 증가하고 개발, 스테이징, 프로덕션 등 다양한 배포 환경에 직면할 때 이러한 시스템에서 큰 이점을 얻을 수 있습니다. 이러한 환경마다 데이터베이스 연결, API 키, 포트 번호 등의 설정이 다를 수 있습니다. 이러한 값을 하드코딩하는 것은 문제가 될 수 있으며, 다양한 설정을 유지하기 위해 여러 코드 경로를 유지하고 민감한 데이터 노출의 위험을 증가시킵니다.

Viper와 같은 구성 솔루션은 다양한 구성 요구와 형식을 지원하는 통합된 접근 방식을 제공하여 이러한 문제를 해결합니다.

Viper의 개요 및 구성 관리에서의 역할

Viper는 모든 구성 요구를 위한 표준 솔루션이 되기 위해 목표로 하는 Go 어플리케이션용 종합적인 구성 라이브러리입니다. Viper는 구성을 환경에 저장하여 실행 환경 간에 이식성을 달성하도록 권장되는 Twelve-Factor App 방법론에 부합합니다.

Viper는 다음과 같은 방법으로 구성을 관리하는데 중추적인 역할을 합니다:

  • JSON, TOML, YAML, HCL 등 다양한 형식의 구성 파일을 읽고 언마샬링합니다.
  • 환경 변수를 사용하여 구성 값을 덮어씌움으로써 외부 구성 원칙을 준수합니다.
  • 커맨드 라인 플래그를 바인딩하고 읽어서 실행 중에 구성 옵션을 동적으로 설정할 수 있도록 합니다.
  • 응용프로그램 내에서 외부로부터 제공되지 않은 구성 옵션에 대한 기본값을 설정할 수 있도록 합니다.
  • 구성 파일의 변경을 감시하고 라이브 리로딩을 지원하여 유연성을 제공하고 구성 변경으로 인한 다운타임을 감소시킵니다.

2. 설치 및 설정

Go 모듈을 사용하여 Viper 설치

Viper를 Go 프로젝트에 추가하기 위해서는 프로젝트가 이미 의존성 관리를 위해 Go 모듈을 사용하고 있어야 합니다. 이미 Go 프로젝트가 있다면, 아마도 프로젝트의 루트에 go.mod 파일이 있을 것입니다. 그렇지 않은 경우 다음 명령어를 실행하여 Go 모듈을 초기화할 수 있습니다:

go mod init <module-name>

<module-name>을 프로젝트의 이름 또는 경로로 대체합니다. 프로젝트에서 Go 모듈을 초기화한 후, 다음 명령어를 사용하여 Viper를 의존성으로 추가할 수 있습니다:

go get github.com/spf13/viper

이 명령어는 Viper 패키지를 가져오고 그 버전을 go.mod 파일에 기록합니다.

Go 프로젝트에서 Viper 초기화

Go 프로젝트 내에서 Viper를 사용하기 위해서는 먼저 패키지를 임포트하고 새로운 Viper 인스턴스를 생성하거나 미리 정의된 싱글톤을 사용해야 합니다. 아래는 두 가지 방법을 보여줍니다:

package main

import (
	"fmt"
	"github.com/spf13/viper"
)

func main() {
	// 미리 구성되어 사용 가능한 Viper 싱글턴 사용
	viper.SetDefault("serviceName", "내 멋진 서비스")

	// 또는, 새로운 Viper 인스턴스 생성
	myViper := viper.New()
	myViper.SetDefault("serviceName", "내 새로운 서비스")

	// 싱글턴을 사용하여 구성 값을 가져오기
	serviceName := viper.GetString("serviceName")
	fmt.Println("서비스명:", serviceName)

	// 새로운 인스턴스를 사용하여 구성 값을 가져오기
newServiceName := myViper.GetString("serviceName")
	fmt.Println("새로운 서비스명:", newServiceName)
}

위 코드에서 SetDefault는 구성 키에 대한 기본값을 정의합니다. GetString 메서드는 값을 가져옵니다. 이 코드를 실행하면, 싱글턴 인스턴스와 새로운 인스턴스를 사용하여 구성한 두 서비스 이름을 출력합니다.

3. 구성 파일 읽고 쓰기

구성 파일을 다루는 것은 Viper의 핵심 기능입니다. 이를 통해 응용프로그램은 코드를 다시 컴파일하지 않고 구성을 업데이트할 수 있습니다. 아래에서는 다양한 구성 형식을 설정하고 이러한 파일에서 읽고 쓰는 방법을 살펴보겠습니다.

설정 형식 설정하기 (JSON, TOML, YAML, HCL 등)

Viper는 JSON, TOML, YAML, HCL 등과 같은 여러 설정 형식을 지원합니다. 먼저 구성 파일의 이름과 유형을 설정해야 합니다. Viper가 찾아야 하는 구성 파일의 이름과 유형을 설정해야 합니다:

v := viper.New()

v.SetConfigName("app")  // 파일 확장자를 제외한 구성 파일 이름
v.SetConfigType("yaml") // 또는 "json", "toml", "yml", "hcl" 등

// 구성 파일 검색 경로. 구성 파일의 위치가 다양한 경우 여러 경로를 추가합니다.
v.AddConfigPath("$HOME/.appconfig") // 전형적인 UNIX 사용자 구성 위치
v.AddConfigPath("/etc/appconfig/")  // UNIX 시스템 전역 설정 경로
v.AddConfigPath(".")                // 작업 디렉토리

구성 파일에서 읽기 및 쓰기

Viper 인스턴스가 구성 파일을 어디서 찾고 무엇을 찾아야 하는지 알고 있는 경우, 구성을 읽도록 요청할 수 있습니다:

if err := v.ReadInConfig(); err != nil {
    if _, ok := err.(viper.ConfigFileNotFoundError); ok {
        // 구성 파일을 찾을 수 없음; 원하는 경우 무시하거나 다르게 처리합니다.
        log.Printf("구성 파일을 찾을 수 없습니다. 기본값 또는 환경 변수를 사용합니다.")
    } else {
        // 구성 파일을 찾았지만 다른 오류가 발생했습니다
        log.Fatalf("구성 파일을 읽는 중 오류 발생, %s", err)
    }
}

구성 파일에 수정 사항을 다시 쓰거나 새로 생성하는 경우, Viper는 여러 메서드를 제공합니다. 현재 구성을 파일에 쓰는 방법은 다음과 같습니다:

err := v.WriteConfig() // 현재 구성을 `v.SetConfigName` 및 `v.AddConfigPath`로 설정된 미리 정의된 경로에 쓰기
if err != nil {
    log.Fatalf("구성 파일을 쓰는 중 오류 발생, %s", err)
}

기본 구성 값 설정하기

기본 값은 구성 파일이나 환경 변수에 키가 설정되지 않은 경우에 대비하여 대체 값을 제공합니다:

v.SetDefault("ContentDir", "content")
v.SetDefault("LogLevel", "debug")
v.SetDefault("Database.Port", 5432)

// 기본 값에 대한 복잡한 데이터 구조
viper.SetDefault("Taxonomies", map[string]string{
    "tag":       "tags",
    "category":  "categories",
})

4. 환경 변수와 플래그 관리

Viper는 구성 파일뿐만 아니라 환경 변수와 명령줄 플래그도 관리할 수 있으며, 특히 환경별 설정을 처리할 때 유용합니다.

환경 변수와 플래그를 Viper에 바인딩하기

환경 변수를 바인딩하기:

v.AutomaticEnv() // Viper의 키와 일치하는 환경 변수 키를 자동으로 검색합니다.

v.SetEnvPrefix("APP") // 다른 환경 변수와 구분하기 위한 환경 변수 접두사
v.BindEnv("port")     // PORT 환경 변수를 바인딩합니다 (예: APP_PORT)

// 애플리케이션에서 키에 대한 여러 환경 변수명을 매칭하는 것도 가능합니다
v.BindEnv("database_url", "DB_URL") // 이것은 Viper에 대해 "database_url" 구성 키에 "DB_URL" 환경 변수 값을 사용하도록 지시합니다

pflag를 사용하여 플래그를 바인딩하기:

var port int

// pflag를 사용하여 플래그 정의하기
pflag.IntVarP(&port, "port", "p", 808, "애플리케이션의 포트")

// 플래그를 Viper 키에 바인딩하기
pflag.Parse()
if err := v.BindPFlag("port", pflag.Lookup("port")); err != nil {
    log.Fatalf("키에 플래그를 바인딩하는 중 오류 발생, %s", err)
}

환경별 구성 처리하기

애플리케이션은 종종 다양한 환경(개발, 스테이징, 프로덕션 등)에서 다르게 작동해야 합니다. Viper는 환경 변수로부터 구성을 사용하여 구성 파일의 설정을 재정의할 수 있도록 해줍니다:

v.SetConfigName("config") // 기본 구성 파일 이름

// 구성은 환경 변수에 의해 재정의될 수 있습니다
// 접두어 "APP"을 가진 환경 변수 및 키 이외의 나머지 부분을 대문자로 설정합니다
v.SetEnvPrefix("APP")
v.AutomaticEnv()

// 프로덕션 환경에서는 기본 포트를 재정의하기 위해 APP_PORT 환경 변수를 사용할 수 있습니다
fmt.Println(v.GetString("port")) // 출력은 APP_PORT의 값이 설정되어 있으면 그 값을, 그렇지 않으면 구성 파일 또는 기본값의 값을 보여줍니다

필요한 경우 Viper에 의해 로드된 구성에 따라 애플리케이션 코드 내에서 환경 간의 차이를 처리해야 합니다.

5. 원격 키/값 저장소 지원

Viper는 etcd, Consul 또는 Firestore와 같은 원격 키/값 저장소를 사용하여 애플리케이션 구성을 효과적으로 관리할 수 있는 강력한 지원을 제공합니다. 이를 통해 구성을 중앙 집중화하고 분산 시스템 전체에 동적으로 업데이트할 수 있습니다. 추가로, Viper는 민감한 구성을 암호화하여 안전하게 처리할 수 있도록 지원합니다.

Viper를 원격 키/값 저장소 (etcd, Consul, Firestore 등)와 통합하기

원격 키/값 저장소를 사용하여 Viper를 시작하려면 Go 애플리케이션에서 viper/remote 패키지를 빈 임포트해야 합니다:

import _ "github.com/spf13/viper/remote"

etcd와 통합하는 예제를 살펴봅시다:

import (
    "log"

    "github.com/spf13/viper"
    _ "github.com/spf13/viper/remote"
)

func initRemoteConfig() {
    viper.SetConfigType("json") // 원격 구성 파일의 유형을 설정합니다
    viper.AddRemoteProvider("etcd", "http://127...1:4001", "/config/myapp.json")
  
    err := viper.ReadRemoteConfig() // 원격 구성을 읽으려고 시도합니다
    if err != nil {
        log.Fatalf("원격 구성을 읽는 데 실패했습니다: %v", err)
    }
  
    log.Println("원격 구성을 성공적으로 읽었습니다")
}

func main() {
    initRemoteConfig()
    // 여기에 애플리케이션 로직을 작성합니다
}

이 예제에서 Viper는 http://127...1:4001에 실행 중인 etcd 서버에 연결하고 /config/myapp.json에 위치한 구성을 읽습니다. Consul과 같은 다른 저장소를 사용할 때는 "etcd""consul"로 대체하고, 제공자별 매개변수를 이에 맞게 조정하시면 됩니다.

암호화된 구성 관리

API 키 또는 데이터베이스 자격 증명과 같은 민감한 구성은 평문으로 저장해서는 안 됩니다. Viper를 사용하면 암호화된 구성을 키/값 저장소에 저장하고 애플리케이션에서 복호화할 수 있습니다.

이 기능을 사용하려면 암호화된 설정이 키/값 저장소에 저장되어 있는지 확인하고 Viper의 AddSecureRemoteProvider를 활용해야 합니다. etcd를 이와 함께 사용하는 예제는 다음과 같습니다:

import (
    "log"

    "github.com/spf13/viper"
    _ "github.com/spf13/viper/remote"
)

func initSecureRemoteConfig() {
    const secretKeyring = "/path/to/secret/keyring.gpg" // 키링 파일의 경로를 설정합니다
  
    viper.SetConfigType("json")
    viper.AddSecureRemoteProvider("etcd", "http://127...1:4001", "/config/myapp.json", secretKeyring)
  
    err := viper.ReadRemoteConfig()
    if err != nil {
        log.Fatalf("원격 구성을 읽을 수 없습니다: %v", err)
    }

    log.Println("원격 구성을 성공적으로 읽고 복호화했습니다")
}

func main() {
    initSecureRemoteConfig()
    // 여기에 애플리케이션 로직을 작성합니다
}

위 예제에서는 AddSecureRemoteProvider를 사용하며, 복호화에 필요한 키가 포함된 GPG 키링의 경로를 지정합니다.

6. 구성 변경 감지 및 처리

Viper의 강력한 기능 중 하나는 애플리케이션을 다시 시작하지 않고도 실시간으로 구성 변경을 감지하고 처리할 수 있는 능력입니다.

구성 변경 감지 및 구성 재읽기

Viper는 fsnotify 패키지를 사용하여 구성 파일의 변경사항을 감시합니다. 구성 파일이 변경될 때마다 이벤트를 트리거하는 감시자를 설정할 수 있습니다:

import (
    "log"

    "github.com/fsnotify/fsnotify"
    "github.com/spf13/viper"
)

func watchConfig() {
    viper.WatchConfig()
    viper.OnConfigChange(func(e fsnotify.Event) {
        log.Printf("구성 파일이 변경되었습니다: %s", e.Name)
        // 필요에 따라 업데이트된 구성을 읽거나, 서비스를 다시 초기화하거나 변수를 업데이트하는 등의 작업을 수행할 수 있습니다
    })
}

func main() {
    viper.SetConfigName("myapp")
    viper.AddConfigPath(".")
    err := viper.ReadInConfig()
    if err != nil {
        log.Fatalf("구성 파일을 읽는 중 오류가 발생했습니다, %s", err)
    }

    watchConfig()
    // 여기에 애플리케이션 로직을 작성합니다
}

실행 중인 애플리케이션 구성 업데이트 트리거

구동 중인 애플리케이션에서 신호, 시간 기반 작업 또는 API 요청과 같은 다양한 트리거에 대응하여 구성을 업데이트할 수 있습니다. Viper의 구성을 다시 읽어 애플리케이션의 내부 상태를 갱신할 수 있도록 애플리케이션을 구성할 수 있습니다.

import (
    "os"
    "os/signal"
    "syscall"
    "time"
    "log"

    "github.com/spf13/viper"
)

func setupSignalHandler() {
    signalChannel := make(chan os.Signal, 1)
    signal.Notify(signalChannel, syscall.SIGHUP) // SIGHUP 신호 수신 대기

    go func() {
        for {
            sig := <-signalChannel
            if sig == syscall.SIGHUP {
                log.Println("SIGHUP 신호를 받았습니다. 구성을 다시 불러오는 중...")
                err := viper.ReadInConfig() // 구성 다시 불러오기
                if err != nil {
                    log.Printf("구성 다시 불러오기 오류: %s", err)
                } else {
                    log.Println("구성이 성공적으로 다시 불러와졌습니다.")
                    // 새 구성을 기반으로 애플리케이션을 다시 구성합니다
                }
            }
        }
    }()
}

func main() {
    viper.SetConfigName("myapp")
    viper.AddConfigPath(".")
    err := viper.ReadInConfig()
    if err != nil {
        log.Fatalf("구성 파일 읽기 오류, %s", err)
    }

    setupSignalHandler()
    for {
        // 애플리케이션의 주 로직
        time.Sleep(10 * time.Second) // 일부 작업 시뮬레이션
    }
}

이 예제에서는 SIGHUP 신호를 수신하기 위한 핸들러를 설정하고 있습니다. 해당 신호를 받게 되면 Viper가 구성 파일을 다시 불러오고, 애플리케이션은 필요에 따라 구성 또는 상태를 업데이트해야 합니다.

언제나 동적으로 구성을 업데이트할 수 있는지 테스트하여, 애플리케이션이 유연하게 대응할 수 있는지 확인하는 것을 잊지 마십시오.