로깅
Go의 로깅은 log 패키지로 시작해서 Go 1.21부터 표준 라이브러리에 포함된 log/slog로 넘어간다. pino나 winston 같은 서드파티를 고를 필요 없이 구조화 로깅이 표준에 있다.
log 패키지 — 기본 로깅
log 패키지는 Go의 가장 기본적인 로깅 도구다:
package main
import "log"
func main() {
log.Println("server started")
log.Printf("listening on port %d", 8080)
}
출력:
2026/03/15 10:30:00 server started
2026/03/15 10:30:00 listening on port 8080
log.Println은 타임스탬프를 자동으로 붙인다. fmt.Println과의 차이가 이것이다. fmt는 순수 출력이고, log는 타임스탬프 + stderr 출력이다.
console.log와 비슷하지만, 타임스탬프를 자동으로 붙이고 stderr로 출력한다는 점이 다르다.
log.Fatal과 log.Panic
log.Fatal은 로그를 출력한 뒤 os.Exit(1)을 호출한다. log.Panic은 로그를 출력한 뒤 panic을 호출한다:
log.Fatal("database connection failed")
// 로그 출력 후 os.Exit(1) — defer도 실행되지 않는다
log.Panic("unexpected state")
// 로그 출력 후 panic — defer는 실행된다
log.Fatal은 main 함수의 초기화 단계에서만 사용하는 것이 관례다. 서버가 시작 전에 실패하는 경우(DB 연결 불가, 설정 파일 누락 등)에 적합하다. 요청 처리 중에는 사용하지 않는다.
커스텀 Logger
log.New로 출력 대상과 prefix를 지정한 Logger를 만들 수 있다:
package main
import (
"log"
"os"
)
func main() {
errorLog := log.New(os.Stderr, "ERROR: ", log.Ldate|log.Ltime|log.Lshortfile)
errorLog.Println("something went wrong")
// ERROR: 2026/03/15 10:30:00 main.go:11: something went wrong
}
log.Lshortfile은 파일명과 줄 번호를 추가한다. 하지만 이것이 log 패키지의 한계이기도 하다. 로그 레벨 구분이 없고, 구조화된 필드를 추가할 수 없다. prefix로 "ERROR: "를 붙이는 것은 문자열 조작이지 레벨 시스템이 아니다.
log/slog — 구조화 로깅
Go 1.21에서 log/slog 패키지가 표준 라이브러리에 추가되었다. pino와 같은 역할을 서드파티 없이 수행한다.
package main
import "log/slog"
func main() {
slog.Info("server started", "port", 8080, "env", "production")
}
출력:
2026/03/15 10:30:00 INFO server started port=8080 env=production
slog.Info의 첫 번째 인자는 메시지, 나머지는 key-value 쌍이다. 이것이 구조화 로깅의 핵심이다. 메시지와 데이터가 분리되어 있어 로그 수집 시스템에서 파싱하기 쉽다.
pino에서 key-value를 객체로 전달하는 것과 같은 개념이다. slog는 가변 인자로 전달한다는 점만 다르다.
로그 레벨
slog는 네 가지 레벨을 제공한다:
slog.Debug("cache miss", "key", "user:123")
slog.Info("request received", "method", "GET", "path", "/api/users")
slog.Warn("slow query", "duration", "2.3s", "query", "SELECT ...")
slog.Error("failed to connect", "err", err, "host", "db.example.com")
기본 레벨은 Info다. Debug 레벨 로그는 기본적으로 출력되지 않는다. 레벨을 변경하려면 Handler를 설정해야 한다:
handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelDebug,
})
logger := slog.New(handler)
logger.Debug("this is now visible")
런타임 레벨 변경
slog.LevelVar를 사용하면 프로그램 실행 중에 레벨을 변경할 수 있다:
var logLevel slog.LevelVar
logLevel.Set(slog.LevelInfo)
handler := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: &logLevel,
})
slog.SetDefault(slog.New(handler))
slog.Debug("not visible")
logLevel.Set(slog.LevelDebug) // 런타임에 변경
slog.Debug("now visible")
디버깅이 필요할 때 서버를 재시작하지 않고 로그 레벨을 변경할 수 있다. HTTP 엔드포인트로 노출하면 운영 중에도 제어 가능하다.
Handler: TextHandler와 JSONHandler
slog의 출력 형식은 Handler가 결정한다. 두 가지 내장 Handler가 있다.
TextHandler
handler := slog.NewTextHandler(os.Stdout, nil)
logger := slog.New(handler)
logger.Info("user login", "user_id", 42, "ip", "192.168.1.1")
출력:
time=2026-03-15T10:30:00.000+09:00 level=INFO msg="user login" user_id=42 ip=192.168.1.1
key=value 형식이다. 사람이 읽기 쉽다. 개발 환경에 적합하다.
JSONHandler
handler := slog.NewJSONHandler(os.Stdout, nil)
logger := slog.New(handler)
logger.Info("user login", "user_id", 42, "ip", "192.168.1.1")
출력:
{"time":"2026-03-15T10:30:00.000+09:00","level":"INFO","msg":"user login","user_id":42,"ip":"192.168.1.1"}
JSON 형식이다. 로그 수집 시스템(Datadog, Elasticsearch, CloudWatch)이 파싱하기 좋다. 프로덕션 환경에 적합하다.
pino의 기본 출력이 JSON인 것과 반대로, slog는 기본이 텍스트이고 프로덕션에서 JSONHandler로 전환하는 방향이다.
slog.With — child logger
pino의 child()처럼 기본 필드를 추가한 로거를 만들 때 With()를 사용한다:
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
// request 처리용 로거 — 모든 로그에 request_id가 포함된다
reqLogger := logger.With("request_id", "abc-123", "method", "POST")
reqLogger.Info("processing request", "path", "/api/users")
reqLogger.Info("request completed", "status", 200)
출력:
{"time":"...","level":"INFO","msg":"processing request","request_id":"abc-123","method":"POST","path":"/api/users"}
{"time":"...","level":"INFO","msg":"request completed","request_id":"abc-123","method":"POST","status":200}
With()는 새 Logger를 반환한다. 원본은 변경되지 않는다.
slog.Group — 필드 그룹화
관련 필드를 그룹으로 묶을 수 있다:
logger.Info("request",
slog.Group("user",
slog.String("id", "u-123"),
slog.String("role", "admin"),
),
slog.Group("request",
slog.String("method", "GET"),
slog.String("path", "/api/data"),
),
)
JSONHandler 출력:
{"time":"...","level":"INFO","msg":"request","user":{"id":"u-123","role":"admin"},"request":{"method":"GET","path":"/api/data"}}
중첩된 JSON 구조가 만들어진다. 로그 분석 시 필드 충돌을 방지하고, 관련 데이터를 논리적으로 묶는다.
타입 안전한 속성
key-value를 가변 인자로 전달하면 key와 value의 타입이 보장되지 않는다. slog.Attr를 사용하면 타입 안전하게 속성을 지정할 수 있다:
slog.Info("user created",
slog.String("name", "Alice"),
slog.Int("age", 30),
slog.Bool("verified", true),
slog.Duration("latency", 150*time.Millisecond),
slog.Time("created_at", time.Now()),
)
slog.String, slog.Int 등은 slog.Attr를 반환한다. 가변 인자 방식("key", value)보다 IDE 지원이 좋고, key 없이 value만 전달하는 실수를 방지한다.
LogAttrs — 성능 최적화
slog.Info 등의 편의 함수는 내부에서 any 타입 변환이 발생한다. 고성능이 필요한 경로에서는 LogAttrs를 사용한다:
logger.LogAttrs(ctx, slog.LevelInfo, "request handled",
slog.String("method", r.Method),
slog.String("path", r.URL.Path),
slog.Int("status", statusCode),
slog.Duration("duration", elapsed),
)
LogAttrs는 모든 인자가 slog.Attr 타입이므로 any로의 boxing이 발생하지 않는다. 대부분의 경우 차이는 미미하지만, 초당 수만 건의 로그를 출력하는 hot path에서는 의미 있다.
context에서 로거 전달
22편에서 다룬 context를 활용하여 요청 범위의 로거를 전달하는 패턴이다. 미들웨어에서 request ID 등의 필드를 추가한 로거를 context에 저장하고, 하위 함수에서 꺼내 쓴다:
type loggerKey struct{}
func WithLogger(ctx context.Context, logger *slog.Logger) context.Context {
return context.WithValue(ctx, loggerKey{}, logger)
}
func FromContext(ctx context.Context) *slog.Logger {
if logger, ok := ctx.Value(loggerKey{}).(*slog.Logger); ok {
return logger
}
return slog.Default()
}
미들웨어에서 로거를 context에 주입한다:
func loggingMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
logger := slog.Default().With(
"request_id", r.Header.Get("X-Request-ID"),
"method", r.Method,
"path", r.URL.Path,
)
ctx := WithLogger(r.Context(), logger)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
핸들러와 서비스 함수에서 context로부터 로거를 꺼낸다:
func handler(w http.ResponseWriter, r *http.Request) {
logger := FromContext(r.Context())
logger.Info("handling request")
result, err := processOrder(r.Context(), "order-123")
if err != nil {
logger.Error("failed to process order", "err", err)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
logger.Info("order processed", "result", result)
}
func processOrder(ctx context.Context, orderID string) (string, error) {
logger := FromContext(ctx)
logger.Info("processing order", "order_id", orderID)
// 비즈니스 로직
return "done", nil
}
processOrder는 로거를 인자로 받지 않지만, context에서 꺼내면 request_id, method, path가 이미 포함된 로거를 사용할 수 있다. 모든 로그가 동일한 request_id를 가지므로 요청 단위로 로그를 추적할 수 있다.
pino의 child logger를 req 객체에 붙이는 것과 같은 패턴이다. Go에서는 context가 그 역할을 한다.
커스텀 Handler 작성
slog.Handler interface를 구현하면 로그 출력을 완전히 제어할 수 있다:
type Handler interface {
Enabled(context.Context, Level) bool
Handle(context.Context, Record) error
WithAttrs(attrs []Attr) Handler
WithGroup(name string) Handler
}
개발 환경에서 사람이 읽기 쉬운 컬러 출력을 만드는 예시:
type PrettyHandler struct {
w io.Writer
level slog.Level
attrs []slog.Attr
}
func NewPrettyHandler(w io.Writer, level slog.Level) *PrettyHandler {
return &PrettyHandler{w: w, level: level}
}
func (h *PrettyHandler) Enabled(_ context.Context, level slog.Level) bool {
return level >= h.level
}
func (h *PrettyHandler) Handle(_ context.Context, r slog.Record) error {
levelStr := r.Level.String()
switch r.Level {
case slog.LevelDebug:
levelStr = "\033[36mDEBUG\033[0m" // cyan
case slog.LevelInfo:
levelStr = "\033[32mINFO\033[0m" // green
case slog.LevelWarn:
levelStr = "\033[33mWARN\033[0m" // yellow
case slog.LevelError:
levelStr = "\033[31mERROR\033[0m" // red
}
fmt.Fprintf(h.w, "%s [%s] %s", r.Time.Format("15:04:05"), levelStr, r.Message)
r.Attrs(func(a slog.Attr) bool {
fmt.Fprintf(h.w, " %s=%v", a.Key, a.Value)
return true
})
for _, a := range h.attrs {
fmt.Fprintf(h.w, " %s=%v", a.Key, a.Value)
}
fmt.Fprintln(h.w)
return nil
}
func (h *PrettyHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &PrettyHandler{w: h.w, level: h.level, attrs: append(h.attrs, attrs...)}
}
func (h *PrettyHandler) WithGroup(name string) slog.Handler {
// 단순화를 위해 그룹은 무시
return h
}
사용:
func main() {
handler := NewPrettyHandler(os.Stdout, slog.LevelDebug)
slog.SetDefault(slog.New(handler))
slog.Info("server started", "port", 8080)
slog.Debug("cache initialized", "size", 1024)
slog.Warn("deprecated API called", "endpoint", "/v1/users")
slog.Error("connection lost", "host", "db.example.com")
}
출력 (터미널에서 컬러로 표시):
10:30:00 [INFO] server started port=8080
10:30:00 [DEBUG] cache initialized size=1024
10:30:00 [WARN] deprecated API called endpoint=/v1/users
10:30:00 [ERROR] connection lost host=db.example.com
실제 프로덕션에서는 직접 Handler를 작성하기보다 기존 Handler를 래핑하는 방식이 더 일반적이다. 기본 JSONHandler로 충분하고, 출력 대상만 바꾸면 되는 경우가 대부분이다.
라이브러리를 고르고, 설정을 맞추고, transport를 구성하는 과정이 slog.NewJSONHandler 한 줄로 대체된다. 서드파티 의존성 없이 프로덕션 수준의 구조화 로깅을 바로 시작할 수 있다.
| ← JSON과 직렬화 | 데이터베이스 → |