go-lib는 하나의 프로덕트를 만들기 위한 Go 기반 프레임워크다. 로거, HTTP 클라이언트/서버, 데이터베이스, Kafka 같은 표준 인프라를 config 번들과 컨테이너로 미리 배선해 두었기 때문에, 애플리케이션 개발자는 api와 service만 작성하면 아주 쉽게 동작하는 서비스를 얻는다.
흐름은 네 단계로 요약된다.
- config —
config.Config를 임베드한 aggregate를 정의하고, phase별 YAML을 읽어 검증한다 (pkg/config,pkg/phase). - service — 비즈니스 로직. 팩토리(
NewXxxService)의 인자로 필요한 인프라 (*sql.DB,client.Client,*log.Logger)와 config가 타입으로 주입된다. - api (또는 consumer) — HTTP/Kafka 표면.
Route(gin.IRouter)/Consume(consumer.Consumer)만 구현하면 라우터·핸들러가 자동 배선된다. - run — aggregate를
container.New(cfg)에 넘기고 팩토리 함수를 등록한 뒤Run()을 호출한다. 컨테이너가 enabled된 인프라만 골라 배선하고, service를 열고, api를 서버에 마운트한 다음 시그널이 올 때까지 서비스를 유지한다.
import _ "modernc.org/sqlite" // 드라이버는 애플리케이션이 선택해 blank-import
cfg, err := appconfig.Load()
if err != nil { panic(err) }
err = container.New(cfg).
Service(todo.NewService). // NewService(*sql.DB) *Service
Api(todo.NewApi). // NewApi(*Service) *Api — Route로 라우팅
Run() // Ctrl+C / SIGTERM 까지 블로킹, 이후 정리각 인프라의 enabled 플래그는 기본이 false라, config에서 켠 것만 배선된다
(server/db/log만 켜고 Kafka는 생략하는 식). 동작하는 레퍼런스는 cmd/ +
internal/의 todo 데모이며, 그 모양을 그대로 복사하면 된다. 아래는 각 패키지
설명이다.
pkg/log # zerolog 기반 로거
pkg/http/client # imroc/req 기반 글로벌 HTTP 클라이언트
pkg/http/server # gin 기반 HTTP 서버
pkg/db # 드라이버 비의존 database/sql 풀 + 트랜잭션 헬퍼
pkg/kafka/producer # sarama 기반 Kafka 프로듀서
pkg/kafka/consumer # sarama 기반 Kafka 컨슈머 그룹
pkg/config # config 읽기/검증/렌더링 + 임베드 가능한 서비스 번들
pkg/container # config.Config 번들을 gontainer로 배선
pkg/phase # local|dev|live 배포 phase enum
zerolog 위에 얹은 작은 구조적 로거. zerolog는 internal/logbench 벤치마크에서
가장 빠른 백엔드였기 때문에 선택했고, 이후 독립적으로 재구현했다.
| 백엔드 | ns/op | B/op | allocs/op |
|---|---|---|---|
| zerolog | 155 | 128 | 1 |
| zap | 376 | 640 | 2 |
| slog | 486 | 136 | 2 |
| logrus | 1284 | 2388 | 35 |
패키지 레벨 헬퍼는 기본 로거를 통해 로깅하며, Open(또는 NewContainer)이 그
기본 로거를 설정된 로거로 교체한다.
log.Open(log.Config{Level: log.DebugLevel, Format: log.ConsoleFormat})
log.Info("service started", "port", 8080)imroc/req 기반의 글로벌 HTTP 클라이언트. 한 번
Open(또는 NewContainer)하고 나면, 어디서든 패키지 레벨 NewRequest()로
요청을 보낼 수 있다.
// 시작 시 한 번
client.Open(client.Config{
BaseURL: "https://api.example.com",
Retry: 3, // 재시도 간 상한 있는 지수 백오프(지터 포함)
RetryBackoffMin: 100 * time.Millisecond,
RetryBackoffMax: 2 * time.Second,
Log: true, // pkg/log를 통한 요청/응답 로깅
})
// 실패 관찰 (재시도 후의 전송 에러 및 5xx/429)
client.OnFailure(func(resp *req.Response, err error) {
// 메트릭 기록, 알림 등
})
// 이후 어디서든
resp, err := client.NewRequest().SetSuccessResult(&out).Get("/health")재시도는 req의 상한 있는 지수 백오프(지터 포함)를 사용하며, 전송 에러와 서버측
에러 상태(5xx, 429)에서 발동한다. OnFailure(또는 Config.FailureHandlers)로
등록한 실패 핸들러는 재시도가 소진된 뒤 실패한 요청당 한 번 실행된다. Log가
설정되면 각 요청/응답이 pkg/log를 통해 로깅되고(에러 상태는 error 레벨), req
자체의 전송 레벨 로그도 pkg/log로 라우팅되어 로거를 우회하는 것이 없다.
gin 기반의 HTTP 서버. New(cfg, middlewares...)는 Recovery와 선택적 요청 로거 뒤에 사용자의 미들웨어를 얹어
빌드한다. Run은 인터럽트/종료 시그널이 올 때까지 서비스한 뒤 graceful하게
종료하므로, 장기 실행 엔트리포인트로 동작한다. 라우트는 Engine()에 등록하고,
GET /health 라이브니스 엔드포인트가 기본 내장되어 있다. NewContainer는 여전히
백그라운드 고루틴 팩토리 형태도 제공한다.
srv := server.New(server.Config{
Addr: ":8080",
Mode: "release", // gin 모드: debug | release | test
Log: true, // pkg/log를 통한 요청 로깅
}, myMiddleware)
srv.Engine().GET("/ping", func(c *gin.Context) { c.String(200, "pong") })
if err := srv.Run(); err != nil { /* ... */ } // Ctrl+C / SIGTERM 까지 블로킹Mode는 기본이 release라 zero Config는 :8080에서 조용한 서버가 된다. 모든
로깅은 pkg/log를 거친다: 요청 라인(Log 설정 시, 5xx는 error 레벨)과 gin 자체
출력이 모두 그리로 라우팅된다. 요청 로거는 레벨을 먼저 확인해, 로거가 버릴 라인은
아예 만들지 않는다.
드라이버 비의존을 유지하는 얇은 database/sql
래퍼: pkg/db는 데이터베이스 드라이버를 절대 import하지 않는다. 프로그램이 원하는
드라이버를 blank-import하면 — 그 드라이버의 init()이 실행되어 자신을 등록하고 —
Config.Driver에 그 이름을 적는다:
import _ "github.com/jackc/pgx/v5/stdlib" // 라이브러리가 아니라 당신의 main에서
cfg := db.Config{Driver: "pgx", DSN: "postgres://user:pass@host:5432/app"}
conn, err := db.New(cfg) // *sql.DB덕분에 go-lib는 어떤 드라이버도 끌어오지 않고, 당신이 쓰는 드라이버 하나만 컴파일된다. 완전한 제어를 위한 두 가지 override가 있다:
Config.Connector(driver.Connector,yaml:"-")는Driver/DSN을 override한다 — 임의의 드라이버나 커스텀/래핑된 커넥터를 가져올 수 있다.log.Config.Output이OutputPath를 override하는 것과 같은 모양이다.- **
db.NewWith(conn *sql.DB)**는 당신이 직접 만든 풀을 설치한다 (커스텀 드라이버, testcontainers, mock 등).
풀은 구성 가능한 *sql.DB로도, db.DB() 뒤의 프로세스 글로벌 기본값으로도
노출되며, 트랜잭션 헬퍼가 함께 있다:
// 관리형 트랜잭션: nil이면 커밋, error/panic이면 롤백.
err := db.Tx(ctx, conn, nil, func(tx *sql.Tx) error {
_, err := tx.ExecContext(ctx, "insert into ...", args...)
return err
})
// 또는 글로벌 풀에서 수동으로 시작.
tx, err := db.WriteTx(ctx) // 읽기 전용은 db.ReadTx(ctx)
// TX는 *sql.DB와 *sql.Tx가 공유하는 쿼리 표면이라, 리포지토리 메서드가
// 풀이든 열린 트랜잭션이든 받을 수 있다.
func (r *Repo) Find(ctx context.Context, q db.TX, id int) (Row, error) { /* ... */ }sarama 기반 Kafka로, 프로듀서와 컨슈머
그룹으로 나뉜다. 각각은 자체 Config(Enabled로 게이트, 기본 false)와
gontainer 모양의 NewContainer 팩토리를 가진 자족적 인프라 패키지라,
pkg/container가 config 번들에서 배선한다. sarama 자체 로깅도 pkg/log로
라우팅된다.
producer는 동기적으로 발행하며, 구성 가능한 Producer로도 producer.Send 뒤의
프로세스 글로벌 기본값으로도 노출된다:
if err := producer.InitProducer(producer.Config{
Brokers: []string{"localhost:9092"},
}); err != nil { /* ... */ }
partition, offset, err := producer.Send("topic", key, value)consumer는 컨슈머 그룹이다: 토픽마다 Handler 하나를 Consume으로 등록한 뒤
Open하면 Close까지 백그라운드에서 실행된다. nil을 반환하는 핸들러는 메시지를
소비 완료(커밋) 처리하고, non-nil 에러는 미커밋으로 남겨 재전달되게 하며 로깅된다.
핸들러는 모두 Open 전에 등록해야 한다 — 컨테이너를 쓰면 이 호출들을 직접 하지
않는다(아래 참고).
c, err := consumer.NewConsumer(consumer.Config{
Brokers: []string{"localhost:9092"},
GroupID: "orders",
Offset: "oldest", // 또는 "newest" (기본값)
})
if err != nil { /* ... */ }
c.Consume("orders.created", func(m *consumer.Message) error {
var order Order
return m.Bind(&order) // nil → 커밋, error → 재전달
})
if err := c.Open(); err != nil { /* ... */ }
defer c.Close()모든 서비스가 같은 config 구조체 보일러플레이트를 반복한다. pkg/config가 그걸
모아, 프로젝트별 config 패키지를 얇게 유지한다.
세 개의 제네릭 헬퍼가 어떤 config든 읽기와 검증을 커버한다:
Read[T](docs ...[]byte) (T, error)는 YAML을 새T로 언마샬하며, 뒤의 문서를 앞 문서 위에 겹친다 (예:Read[Config](base, secrets)). 원시[]byte를 받고 — phase enum이나embed.FS가 아니다 — 바이트를 어디서 가져올지는 호출자가 정해 넘긴다.Valid(cfg)는validate구조체 태그를 검증한다(go-playground/validator). 임의의 구조체를 받아 전체 aggregate(임베드된 서브 config 포함)를 한 번에 검증한다.Read는 일부러 검증을 건너뛰므로, 런타임 전용 필드를 먼저 채운 뒤Valid를 호출할 수 있다.String(cfg)는 시작 로그 라인용으로 config를 컴팩트 JSON으로 렌더링한다.
config.Config는 표준 서브 config들 — 로거, HTTP 클라이언트, HTTP 서버,
데이터베이스, Kafka 프로듀서/컨슈머 — 의 임베드 가능한 번들이다. aggregate에
임베드해 전부 실어 나른다. Valid()와 String()은 그 위의 메서드로도 쓸 수 있다
(패키지 레벨 함수의 리시버 형태). pkg/container가 이 번들을 gontainer 옵션으로
바꾼다.
type Config struct {
config.Config `yaml:",inline"` // Log, Client, Server, DB, Producer, Consumer
Billing Billing `yaml:"billing"` // 기능 자체의 config (선택)
}
cfg, err := config.Read[Config](data) // data: 임베드/읽어들인 []byte
if err != nil { /* ... */ }
if err := config.Valid(cfg); err != nil { /* ... */ }container.New(cfg)는 config 번들과 당신의 service·api를 실행 가능한
gontainer로 바꾸는 빌더를 시작한다.
전체 aggregate config를 넘겨라 — New가 임베드된 config.Config를 리플렉션
으로 찾으므로 직접 꺼낼 필요가 없다. enabled된 각 서브 config(로거, HTTP
클라이언트, HTTP 서버, 데이터베이스, Kafka 프로듀서/컨슈머)가 자동 배선된다 —
Enabled 플래그는 기본이 false라, config가 enabled: true로 켠 것만 배선된다.
당신은 팩토리 함수(NewXxxService / NewXxxApi / NewXxxConsumer)를
등록하고, gontainer가 그 의존성을 주입한다 — New가 aggregate에서 등록한 config
필드도 포함해서:
err := container.New(cfg). // cfg는 config.Config를 임베드; New가 집어냄
Use(myMiddleware). // 서버용 gin 미들웨어
Service(todo.NewService). // NewService(*sql.DB) *Service
Api(todo.NewApi). // NewApi(*Service) *Api
Consumer(orders.NewConsumer). // 선택: NewConsumer(deps...) *Consumer
Run() // 블로킹: Ctrl+C / SIGTERM 까지 서비스세 가지 역할:
- service — 팩토리
func NewXxxService(deps...) (*Xxx[, func() error][, error]). gontainer가 deps를 주입해 빌드하고, 반환된 cleanupfunc() error는 종료 시 호출된다. 빌드된 service가Opener(Open() error)를 구현하면, 모든 service가 빌드된 뒤Open이 한 번 호출된다. - api — 팩토리
func NewXxxApi(deps...) *Xxx로, 결과가Api(Route(gin.IRouter))를 구현한다. 서버가 서비스하기 전에 빌드된 각 api의Route가 엔진과 함께 호출되어, api가 자신의 라우트를 마운트한다 — 별도 라우터 배선이 없다. - consumer — 팩토리
func NewXxxConsumer(deps...) *Xxx로, 결과가Consumer(Consume(consumer.Consumer))를 구현한다. Kafka 컨슈머가 enabled면 빌드된 각 consumer의Consume이 그것과 함께 호출되어 — 토픽 핸들러를 바인딩하고 — 열리기 전에 실행된다. 컨슈머는 있지만 서버가 없으면,Run이 시그널 자체에서 블로킹해 프로세스를 유지한다.
New는 aggregate config의 모든 필드를 service로 등록한다(임베드된 번들을
평탄화), 그래서 팩토리가 기능 config, log.Config 등을 타입으로 주입받는다.
Use는 gin 미들웨어를 추가하고, Option(...)은 원시 gontainer 옵션용 escape
hatch, Build()는 gontainer를 직접 돌리기 위한 옵션을 반환한다. 서버가 enabled면
Run은 시그널까지 서비스한 뒤 graceful하게 종료한다.
서비스가 도는 배포 phase — Local, Dev, Live — 를 맨 환경 문자열 대신 타입
있는 enum으로 표현한다. zero 값은 Local이라, 미설정 환경이 가장 안전한 phase다.
String()은 정규 이름을 반환하며, 이는 per-phase config 파일 stem이기도 하다
(local.yml, dev.yml, live.yml).
p, err := phase.FromEnv() // APP_ENV를 읽음; 미설정 → Local, 오타 → error
if err != nil { /* ... */ }
docs, err := appconfig.Bytes(p) // p.String()+".yml"New는 이름을 파싱하며 별칭(development→dev, prod/production→live)을 받고
인식 불가한 것은 거부하므로, 오타난 phase는 시작 시 빠르게 실패한다.