2026년에 Go 로깅 라이브러리를 고를 때 고려할 점을 정리합니다. slog, zerolog, zap, phuslu/log, Logrus, charmbracelet/log의 차이점, 성능, 활용 시점을 비교합니다.
대부분의 Go 역사에서 로깅 라이브러리를 고른다는 것은 Logrus, zap, zerolog, 그리고 구조화 로깅이 어떻게 동작해야 하는지에 대해 저마다의 API, 관용구, 의견을 들고 나온 몇몇 다른 선택지 사이에서 하나를 고르는 일을 의미했습니다.
그 시대는 대체로 끝났습니다. Go 1.21부터 log/slog가 생태계가 수렴한 표준 프런트엔드를 제공합니다. 이 변화는 많은 것을 단순하게 만들었지만, 선택 자체를 완전히 없애지는 않았습니다.
이 가이드는 오늘날 무엇을 고려할 가치가 있는지 다룹니다. 여전히 중요한 라이브러리는 무엇인지, 성능은 어떤지, 어디서 차이가 나는지, 그리고 slog만으로도 충분한 경우는 언제인지 설명합니다.
새로운 Go 프로젝트를 시작한다면, 애플리케이션 코드는 그냥 log/slog를 사용해야 합니다. 가장 빠른 선택지라서도 아니고 최고의 API를 가졌기 때문도 아니라, 표준 라이브러리에 포함되어 있고 생태계가 이를 중심으로 정렬되었으며, 실제로 필요해지면 나중에 다른 백엔드를 연결할 수 있기 때문입니다.
slog.Handler 인터페이스는 애플리케이션 코드와 그 아래의 인코딩 엔진을 분리합니다. slog의 내장 JSON 핸들러가 특정 서비스에서는 너무 느리다는 것이 드러나더라도, 로깅 구문은 건드리지 않고 초기화 코드만 바꿔 다른 백엔드로 교체할 수 있습니다.
JSONHandler를 사용하는 전형적인 설정은 다음과 같습니다:
go
1 2 3 4 5 6 7 8 9
opts := &slog.HandlerOptions{
AddSource: true,
Level: slog.LevelInfo,
}
logger := slog.New(
slog.NewJSONHandler(os.Stderr, opts),
)
slog.SetDefault(logger)
이렇게 하면 외부 의존성 없이 구조화된 JSON 출력, 소스 파일 위치 표시, 그리고 slog.LevelVar를 통한 런타임 레벨 제어를 얻을 수 있습니다. slog.LevelVar는 어떤 goroutine에서든 안전하게 갱신할 수 있습니다.
API는 세 가지 호출 방식을 지원합니다:
go
1 2 3 4 5 6 7 8 9 10 11 12 13 14
slog.Info("request", "method", "GET", "status", 200)
slog.InfoContext(ctx, "request",
slog.String("method", "GET"),
slog.Int("status", 200),
)
logger.LogAttrs(ctx, slog.LevelInfo, "request",
slog.String("method", "GET"),
slog.Int("status", 200),
)
LogValuer 인터페이스를 사용하면 타입이 로그 출력에서 어떻게 보일지도 제어할 수 있습니다. 가장 대표적인 용도는 개발자가 각 호출 지점마다 민감한 값을 반드시 정제하거나 제외해야 한다는 부담 없이 로그 마스킹을 구현하는 것입니다.
go
1 2 3 4 5
type APIKey string
func (APIKey) LogValue() slog.Value {
return slog.StringValue("REDACTED")
}
slog는 또한 OpenTelemetry 네이티브 로그로 들어가는 가장 깔끔한 경로도 제공합니다. otelslog bridge는 slog.Handler를 구현하며 로그 레코드를 OpenTelemetry Logs SDK를 통해 전달합니다.
즉, JSON 엔트리를 stdout에 쓰는 대신 Go 로그가 1급 OTel 시그널이 되어 구성해 둔 파이프라인을 통해 트레이스와 메트릭과 함께 내보내집니다:
go
1 2 3 4 5 6 7 8 9 10 11 12 13
import (
"go.opentelemetry.io/contrib/bridges/otelslog"
"go.opentelemetry.io/otel/log/global"
)
logger := otelslog.NewLogger(
"otelslog-demo",
otelslog.WithLoggerProvider(LoggerProvider),
)
slog.SetDefault(logger)
중요한 세부 사항 하나는, 이 브리지가 전달된 context.Context에서 span 컨텍스트를 읽는다는 점입니다. 즉, 컨텍스트를 받는 메서드(InfoContext(), ErrorContext() 등)를 사용해야 하고 그 컨텍스트 안에 활성 span이 있어야 합니다.
그렇게 하면 생성된 OTel 로그 레코드에 해당하는 trace ID와 span ID가 함께 담기므로, 관측성 백엔드가 로그와 그 로그가 속한 트레이스를 자동으로 연관시킬 수 있습니다.
go
1 2 3 4 5 6 7 8 9
logger.InfoContext(ctx, "processing request",
slog.String("order_id", orderID),
)
logger.Info("processing request",
slog.String("order_id", orderID),
)
slog는 많은 부분을 잘 해내지만, API에는 분명한 약점도 있습니다. 느슨한 타입의 키-값 API(slog.Info("msg", "key", val))는 로그 호출을 작성하기 가장 편한 방식이지만, 동시에 가장 실수하기 쉽습니다.
인자의 개수를 홀수로 넘기거나, 키 이름을 잘못 입력하거나, 키와 값을 실수로 바꿔 넣어도 컴파일러는 아무 불만을 제기하지 않으며 런타임에서 조용히 잘못된 출력이 만들어집니다. 타입이 있는 slog.Attr 생성자는 이를 해결하지만, 모든 로그 호출이 눈에 띄게 장황해집니다.
slog는 기본적으로 Trace나 Fatal 레벨도 포함하지 않으며, 중복 제거, 링 버퍼 로깅, 샘플링처럼 서드파티 라이브러리에서는 오래전부터 표준처럼 취급해 온 기능도 빠져 있습니다.
생태계는 이러한 공백 대부분을 이미 메웠습니다. sloglint는 잘못된 로그 호출을 잡아내고 일관된 인자 스타일을 강제해 주며, 점점 커지고 있는 커뮤니티 패키지 모음이 샘플링과 enrichment부터 로그 라우팅, 테스트까지 나머지 기능을 채워 줍니다.
Zerolog는 인코딩 벤치마크에서 여전히 최상위 성능을 유지하고 있으며, 체이닝 기반 API도 Go 생태계에서 꽤 사용하기 좋은 인터페이스 중 하나입니다. 각 메서드는 같은 *Event를 반환하므로 호출이 하나의 표현식으로 자연스럽게 이어집니다.
go
1 2 3 4 5 6 7 8 9 10 11
logger := zerolog.New(os.Stderr).
With().
Timestamp().
Caller().
Logger()
log.Info().
Str("method", "GET").
Int("status", 200).
Dur("latency", 47*time.Millisecond).
Msg("request completed")
컨텍스트 통합도 마찬가지로 깔끔합니다. 로거를 context.Context에 붙이고, 요청 수명 주기 동안 필드를 누적하고, 어디에서든 다시 꺼내 쓸 수 있습니다.
go
1 2 3 4 5 6
ctx = log.With().
Str("request_id", "abc-123").
Logger().
WithContext(ctx)
log.Ctx(ctx).Info().Msg("processing")
또한 시간 구간당 일정량의 메시지 burst를 허용한 뒤 확률적 비율로 제한하는 샘플링 시스템도 제공합니다. 이는 로그 중복 제거가 중요한 고처리량 시스템에서 유용합니다.
원시 인코딩 속도가 가장 중요한 관심사이고 zerolog의 API를 직접 사용할 의향이 있다면, 성숙한 커뮤니티를 갖춘 가장 빠른 선택지입니다.
또한 NewSlogHandler() 메서드를 통해 slog 백엔드로도 사용할 수 있어, 아래쪽에는 zerolog 인코더를 두고 위에서는 slog의 표준 API를 사용할 수 있습니다:
go
1 2 3 4 5 6 7
func main() {
zl := zerolog.New(os.Stderr).With().Timestamp().Logger()
handler := zerolog.NewSlogHandler(zl)
logger := slog.New(handler)
logger.Info("user logged in", "user", "alice", "role", "admin")
}
Zerolog API에서 주의해야 할 가장 큰 함정은 체인의 끝에서 .Msg() 또는 .Send() 호출을 잊으면 로그 엔트리가 조용히 버려진다는 점입니다. zerolog.Event 객체는 풀링되므로, 종료 메서드 누락은 메모리 누수로도 이어집니다.
또 다른 주의점은 zerolog의 slog 브리지가 네이티브 성능 및 다른 slog 백엔드에 비해 훨씬 느리다는 것입니다. 아래 벤치마크에서 이를 자세히 다루지만, zerolog의 속도가 필요하다면 네이티브 API를 사용해야 합니다.
Zap은 Uber 규모의 환경에서 수년간 운영되며 가장 널리 배포된 고성능 Go 로거입니다. 핵심 설계 선택은 하나의 패키지 안에서 두 가지 API를 제공하는 것입니다. 핫패스용 무할당 타입드 Logger와, 그 외 대부분의 용도를 위한 느슨한 타입의 SugaredLogger입니다.
go
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
logger, _ := zap.NewProduction()
defer logger.Sync()
logger.Info("request completed",
zap.String("method", "GET"),
zap.Int("status", 200),
zap.Duration("latency", 47*time.Millisecond),
)
sugar := logger.Sugar()
sugar.Infow("request completed",
"method", "GET", "status", 200,
)
zap이 차별화되는 지점은 인코딩, 출력, 레벨 필터링을 조합 가능한 조각으로 분리하는 zapcore.Core 인터페이스입니다. 이를 통해 복잡한 파이프라인도 구성할 수 있습니다.
go
1 2 3 4 5 6 7 8 9 10 11 12 13
core := zapcore.NewTee(
zapcore.NewCore(
jsonEncoder, fileOut, zap.InfoLevel,
),
zapcore.NewCore(
consoleEncoder, os.Stderr, zap.DebugLevel,
),
)
logger := zap.New(
core,
zap.AddCaller(),
zap.AddStacktrace(zap.ErrorLevel),
)
zaptest/observer 패키지는 구조화된 로그 엔트리를 캡처해 코드로 단언할 수 있게 해 줍니다. 덕분에 코드가 정확히 무엇을 어떤 조건에서 기록하는지 검증할 수 있습니다.
go
1 2 3 4 5 6 7 8 9 10
core, logs := observer.New(zap.DebugLevel)
logger := zap.New(core)
doSomething(logger)
require.Equal(t, 1,
logs.FilterField(
zap.String("event", "login"),
).Len(),
)
또한 slog adapter도 제공하므로 zap을 slog 백엔드로 사용하는 것도 간단합니다.
go
1 2 3 4
zapL, _ := zap.NewProduction()
slog.SetDefault(
slog.New(zapslog.NewHandler(zapL.Core())),
)
눈에 띄는 누락 하나는, 아마도 생태계에서 가장 확장성이 높은 로거임에도 zap은 커스텀 로그 레벨을 지원하지 않으며 내장 TRACE 레벨도 없다는 점입니다.
phuslu/log는 현재 사용 가능한 Go 로깅 라이브러리 중 가장 빠릅니다. 동시에 가장 덜 알려진 라이브러리 중 하나이기도 합니다. GitHub star 수는 약 840개로 zerolog의 12,000개, zap의 24,000개에 비해 훨씬 적습니다. 아마 log라는 이름의 라이브러리를 검색하면 찾고 있는 것 말고 거의 모든 것이 나오기 때문일 것입니다.
이 프로젝트는 zerolog에서 영감을 받아 시작했고, 이후 남아 있는 모든 할당을 체계적으로 제거해 왔습니다. API는 zerolog 사용자에게 즉시 익숙하게 느껴질 것입니다.
go
1 2 3 4
log.Info().
Str("foo", "bar").
Int("n", 42).
Msg("hello world")
zerolog와 다른 점은 구체적으로 세 가지입니다:
printf 스타일 로깅이 interface{} 인자를 사용하더라도 무할당을 달성합니다.
크기 기반 회전, 최대 백업 수, 타임스탬프 기반 파일명 생성을 기본 제공하는 강력한 FileWriter를 포함하므로 lumberjack 같은 의존성을 추가하지 않아도 됩니다:
go 1 2 3 4 5 6 7 8 9
logger := log.Logger{ Level: log.InfoLevel, Writer: &log.FileWriter{ Filename: "/var/log/app/service.log", MaxSize: 50 * 1024 * 1024, MaxBackups: 7, LocalTime: true, }, }
3. syslog, journald, Windows Event Log용 writer를 기본 제공하며, 비차단 쓰기를 위한 채널 기반 AsyncWriter도 포함합니다.
phuslu/log는 .Slog() 메서드를 통해 내장 slog 지원도 제공하므로, 서드파티 어댑터 없이 slog 백엔드로 사용할 수 있습니다.
go
1 2 3 4 5 6
slog.SetDefault((&log.Logger{
Level: log.InfoLevel,
TimeField: "time",
TimeFormat: log.TimeFormatUnixMs,
Caller: 1,
}).Slog())
가장 큰 단점은 커뮤니티 규모입니다. 예제가 더 적고, 통합도 더 적고, 물어볼 사람도 더 적고, 유지관리자도 한 명뿐입니다. 로깅처럼 중요한 요소에서는 이것이 실제로 고려해야 할 위험입니다.
또한 샘플링이 내장되어 있지 않고, OpenTelemetry 지원은 요청은 있었지만 아직 구현되지 않은 알려진 공백입니다.
Logrus는 구조화 로깅이 어떤 모습일 수 있는지를 한 세대의 Go 개발자에게 보여 준 라이브러리입니다. GitHub star 25k 이상, importing package 249k 이상을 기록하며 절대 수치로는 여전히 가장 많이 사용되는 Go 로깅 라이브러리입니다. 하지만 이 숫자는 현재의 추진력이라기보다 역사적 채택을 반영합니다.
프로젝트의 README는 분명합니다: logrus는 유지보수 모드이며, 새로운 기능 추가 계획이 없습니다. 따라서 logrus 통합이 깊고 hook 구성이 성숙한 기존 코드베이스가 있다면, 가장 좋은 선택은 slog로 마이그레이션하는 것입니다.
먼저 핫패스, 즉 가장 자주 로그를 남기는 요청 핸들러와 백그라운드 워커를 식별하고, 성능 좋은 백엔드를 둔 slog로 옮기십시오. 코드베이스의 나머지는 차례가 올 때까지 계속 logrus를 사용해도 됩니다.
새로운 코드를 logrus로 시작하는 일은 절대 피해야 합니다. 성능 격차가 너무 크고(slog보다 약 15배 느리고 zerolog보다 약 50배 느림), map[string]interface{} 아키텍처는 API를 깨뜨리지 않고는 고칠 수 없으며, Go 생태계 전반도 이미 이 방향에서 멀어지고 있습니다.
위의 라이브러리들은 모두 로그를 기계가 소비하는 프로덕션 서비스에 맞춰 최적화되어 있습니다. 직접 터미널에서 사람이 출력을 읽는 CLI 도구를 만들고 있다면, charmbracelet/log을 살펴볼 가치가 있습니다.
이 라이브러리는 Charm 팀이 만들었습니다. Bubble Tea, Lip Gloss, 그리고 Charm TUI ecosystem의 나머지 도구들로 알려진 바로 그 팀입니다. 그리고 읽기 좋은 터미널 출력을 위해 특별히 설계되었습니다. 로그에는 똑똑한 색상, 아이콘, 간격이 적용되어 한눈에 훑기 쉽습니다.
go
1 2 3 4 5 6 7 8 9 10
logger := log.NewWithOptions(os.Stderr, log.Options{
ReportTimestamp: true,
ReportCaller: true,
Level: log.DebugLevel,
})
logger.Info("starting server", "host", "localhost",
"port", 8080)
logger.Error("connection failed", "err", err,
"retries", 3)
The v2 release는 colorprofile 라이브러리를 통해 자동 색상 다운샘플링을 도입하여, 어떤 터미널에서 실행되든 출력이 그 환경에 맞게 적응하도록 만들었습니다. true-color 터미널, 기본적인 16색 SSH 세션, 파일로 파이프된 경우까지 모두 올바르게 보입니다.
또한 text, JSON, logfmt 출력 형식을 지원하고 slog.Handler도 구현하므로 slog 백엔드로 사용할 수 있습니다. 즉, CLI 애플리케이션을 여전히 *slog.Logger 기준으로 작성하면서도 코드가 Charm의 API에 묶이지 않은 채 그들의 스타일 있는 출력을 얻을 수 있습니다.
go
1 2 3 4 5 6 7 8 9 10 11 12
import (
"log/slog"
clog "github.com/charmbracelet/log"
)
handler := clog.NewWithOptions(os.Stderr, clog.Options{
ReportTimestamp: true,
Level: clog.DebugLevel,
})
slog.SetDefault(slog.New(handler))
slog.Info("using slog with charm output")
또한 셸 스크립트 로깅을 위해 Gum과 통합되고, log.With()를 통한 서브 로거를 지원하며, 표준 slog가 의도적으로 제외한 커스텀 log.Fatal 레벨도 포함합니다.
charmbracelet/log는 프로덕션 백엔드에서 zerolog나 zap을 대체하는 도구는 아닙니다. 사용자가 터미널을 바라보는 개발자이고, 출력이 CLI의 나머지 요소만큼 세련되기를 원할 때 올바른 선택입니다.
다음 결과는 프로덕션 관측성에서 지배적인 패턴을 측정합니다. 요청 범위 필드를 한 번 붙인 다음, 요청당 많은 이벤트를 기록하는 방식입니다. 모든 벤치마크는 최신 라이브러리 버전으로 16코어 머신에서 실행되었으며, 디스크 I/O가 아니라 인코딩 오버헤드만 분리해서 보기 위해 io.Discard에 기록했습니다.
| Library | ns/op | B/op | allocs/op |
|---|---|---|---|
| phuslu/log | 25.32 | 0 | 0 |
| zerolog | 25.77 | 0 | 0 |
| zap | 51.43 | 0 | 0 |
| zap (sugar) | 82.44 | 16 | 1 |
| slog | 101.00 | 0 | 0 |
| logrus | 9126 | 3078 | 55 |
| charm/log | 16786 | 9353 | 61 |
phuslu/log와 zerolog가 약 25 ns, 무할당으로 선두를 달리고, zap이 약 51 ns로 그 뒤를 잇습니다. sugared API는 가변 interface{} 인자에 값을 박싱하면서 호출당 한 번의 할당을 추가합니다. 작은 비용이지만 부하가 걸리면 꾸준히 누적됩니다.
slog의 표준 JSON 핸들러는 약 101 ns에 위치하며, 이는 커스텀 핸들러 없이 log/slog를 사용할 때 사실상 받아들이는 기준선입니다. logrus와 charm/log는 성능 등급이 완전히 다르며, 둘 다 호출당 9 µs를 넘고 할당도 많습니다. 후자는 처리량이 아니라 터미널 가독성에 최적화되어 있으므로 예상 가능한 결과입니다.
slog.Handler 인터페이스를 사용하면 표준 API를 유지하면서 인코딩 엔진만 교체할 수 있습니다. 아래 수치는 각 라이브러리를 이런 방식으로 사용할 때의 성능을 반영합니다.
| Backend | ns/op | B/op | allocs/op |
|---|---|---|---|
| phuslu/log | 37.91 | 0 | 0 |
| zap | 69.99 | 0 | 0 |
| slog (std) | 101.00 | 0 | 0 |
| zerolog | 1180 | 1442 | 16 |
약 38 ns의 phuslu/log는 가장 빠른 slog 호환 백엔드로, 표준 JSON 핸들러 대비 대략 2.7배의 처리량을 제공합니다. zap의 slog 어댑터도 네이티브 API 대비 오버헤드가 크지 않은 탄탄한 업그레이드입니다.
zerolog는 눈에 띄는 예외입니다. 네이티브에서는 약 25 ns, 무할당으로 두 번째로 빠른 라이브러리인데, 이는 누적된 컨텍스트 필드를 재사용 가능한 바이트 버퍼에 미리 직렬화해 두기 때문입니다.
하지만 이 최적화는 slog 브리지에서는 이어지지 않습니다. 이 브리지는 WithAttrs 필드를 원시 []slog.Attr로 저장하고, 매 Handle() 호출마다 다시 인코딩합니다. 그 결과 네이티브 API와 비교해 46배 느려집니다.
대부분의 서비스는 모든 애플리케이션 코드를 *slog.Logger 기준으로 작성하고, slog.NewJSONHandler()로 시작해야 합니다. 대개는 이것만으로 충분히 빠르며, 이후에는 다시 신경 쓸 일이 없습니다.
프로파일링 결과 로깅이 병목으로 드러나면, 그때 더 빠른 백엔드로 교체하십시오. 벤치마크가 보여주듯 slog 뒤에 phuslu/log를 두면 내장 JSON 핸들러보다 2~3배 빠르고, zap 어댑터도 훌륭한 업그레이드입니다.
최대 처리량이 필요하고 slog의 프런트엔드를 포기하더라도 라이브러리 전용 API를 사용할 의향이 있다면, zerolog가 속도와 커뮤니티 규모의 조합이 가장 좋습니다. 확장성과 AtomicLevel, zapcore.Core 조합, 테스트용 zaptest/observer 같은 프로덕션 도구를 중시한다면 Zap이 더 나은 선택입니다.
OpenTelemetry를 사용 중이라면, slog와 otelslog 브리지가 가장 직관적인 통합 방법을 제공합니다. 일관된 trace correlation을 얻으려면 활성 span이 있는 상태에서 로그 메서드의 Context 변형(또는 LogAttrs())을 사용해야 한다는 점만 꼭 지키십시오.
CLI 도구라면, slog의 백엔드로 charmbracelet/log를 사용해 표준 API를 유지하면서도 세련된 터미널 출력을 얻을 수 있습니다.
중요한 것은 라이브러리 자체보다 그것을 어떻게 사용하는가입니다. 잘못된 로깅 전략은 아래에 어떤 인코더가 있든 프로덕션에서는 똑같이 나쁜 결과를 만듭니다. 필드를 올바르게 구성하고, trace correlation을 연결하고, 문제가 발생했을 때 실제로 도움이 되는 관측성 플랫폼으로 로그를 보내고 있는지 확인하십시오.
OpenTelemetry를 중심으로 구축되어 로그, 트레이스, 메트릭을 별개의 도구가 아니라 연결된 시그널로 다루는 관측성 플랫폼을 찾고 있다면, Dash0를 사용해 보세요.