관측 가능성과 알림을 위한 Go의 slog를 활용한 구조화된 로깅
트레이스에 연결되는 쿼리 가능한 JSON 로그
로그는 시스템이 화재 상태일 때도 여전히 사용할 수 있는 디버깅 인터페이스입니다. 문제는 평문 텍스트 로그는 시간이 지날수록 관리하기 어려워진다는 점입니다. 필터링, 집계, 알림이 필요해지자마자 문장을 파싱하게 됩니다.

구조화된 로깅은 이에 대한 해답입니다. 이는 각 로그 라인을 안정적인 필드를 가진 작은 이벤트로 변환하여 도구가 신뢰할 수 있게 검색하고 집계할 수 있게 합니다. 로그가 더 넓은 스택에서 지표, 대시보드, 알림과 어떻게 연결되는지에 대해서는 Observability: Monitoring, Metrics, Prometheus & Grafana Guide 를 참고하세요.
구조화된 로깅이란 무엇이며 왜 확장 가능한가
구조화된 로깅은 기록이 단순히 문자열이 아니라 메시지 plus 유형화 된 키 - 값 속성인 로깅 방식입니다. 아이디어는 가장 좋은 방식으로 지루합니다: 로그가 기계에서 읽을 수 있게 되면, 사고 대응은 더 이상 grep 대결이 아닙니다.
간단한 비교:
평문 텍스트 (인간 중심, 도구 비호환)
failed to charge card user=42 amount=19.99 ms=842 err=timeout
구조화된 데이터 (도구 중심, 여전히 가독성 있음)
{"msg":"failed to charge card","user_id":42,"amount":19.99,"duration_ms":842,"error":"timeout"}
프로덕션 환경에서는 로그를 프로세스에서 방출하는 이벤트 스트림으로 생각하는 것이 도움이 되며, 라우팅과 스토리지는 애플리케이션 외부에 존재한다고 생각하는 것이 좋습니다. 이러한 사고방식은 라인당 하나의 이벤트를 작성하고 이벤트를 전송하고 재처리하기 쉽게 유지하도록 유도합니다.
공유 로깅 프론트엔드로서의 Go 의 Slog
Go 는 오래전부터 고전적인 log 패키지를 가지고 있었지만, 현대적인 서비스는 레벨과 필드가 필요합니다. log/slog 패키지 (Go 1.21 이상) 는 구조화된 로깅을 표준 라이브러리에 도입하고 로그 기록을 위한 공통 형식 (시간, 레벨, 메시지, 속성) 을 공식화합니다. 이 가이드와 함께 간결한 언어 및 명령어 복습을 원하시면 Go Cheatsheet 를 참고하세요.
모델의 핵심 요소는 다음과 같습니다:
기록 (Record)
기록은 발생한 사건입니다. slog 용어로는 시간, 레벨, 메시지 및 속성 세트를 포함합니다. Info 와 Error 와 같은 메서드나 레벨을 명시적으로 지정할 때 Log 를 통해 기록을 생성합니다.
속성 (Attributes)
속성은 로그를 쿼리 가능하게 하는 키 - 값 쌍입니다. 같은 개념을 세 가지 다른 키 (user, userId, uid) 로 기록하면 세 가지 다른 데이터셋이 생깁니다. 일관된 키가 진정한 가치가 숨어 있는 곳입니다.
핸들러 (Handler)
핸들러는 기록이 바이트가 되는 방식입니다. 내장된 TextHandler 는 키=값 형식으로 출력하고, JSONHandler 는 줄 구분된 JSON 을 작성합니다. 핸들러는 또한 적출 (redaction), 키 이름 변경, 출력 라우팅이 발생하는 곳입니다.
과소평가되는 기능 중 하나는 slog 가 기존 코드 앞에 위치할 수 있다는 점입니다. 기본 slog 로거를 설정하면 최상위 slog 함수가 이를 사용하며, 고전적인 log 패키지도 이를로 리디렉션될 수 있습니다. 이는 점진적인 마이그레이션을 가능하게 합니다.
그룹 (Groups)
그룹은 “각 하위 시스템이 id 를 사용한다"는 문제를 해결합니다. 요청에 대한 속성 집합 (request.method, request.path) 을 그룹화하거나 WithGroup 으로 전체 하위 시스템을 네임스페이스하여 키 충돌을 방지할 수 있습니다.
프로덕션 환경에 적합한 slog 설정
다음 설정은 일반적인 목표를 달성합니다. 예제는 작은 logx 패키지를 사용하며, 실제 모듈에서 이와 같은 패키지가 일반적으로 어디에 위치하는지에 대해서는 Go Project Structure: Practices & Patterns 을 참고하세요.
- 라인당 하나의 JSON 이벤트
- 수집을 위해 stdout 으로 로그 작성
- 일관된 서비스 메타데이터 한 번 첨부
- 요청 및 추적 ID 를 위한 컨텍스트 인식 로깅
- 민감한 키를 위한 중앙 집중식 적출
package logx
import (
"log/slog"
"os"
)
var level slog.LevelVar // defaults to INFO
func New() *slog.Logger {
opts := &slog.HandlerOptions{
Level: &level, // can be changed at runtime
AddSource: true, // include file and line when available
ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr {
// Centralised redaction: consistent and hard to bypass by accident.
switch a.Key {
case "password", "token", "authorization", "api_key":
return slog.String(a.Key, "[redacted]")
}
return a
},
}
h := slog.NewJSONHandler(os.Stdout, opts)
return slog.New(h).With(
"service", os.Getenv("SERVICE_NAME"),
"env", os.Getenv("ENV"),
"version", os.Getenv("VERSION"),
)
}
func SetLevel(l slog.Level) { level.Set(l) }
작은 세부 사항이지만 큰 결과를 가져옵니다: 내장된 JSON 핸들러는 표준 키 (time, level, msg, source) 를 사용합니다. 로그 백엔드가 다른 스키마를 기대할 때, ReplaceAttr 는 호출 지점을 다시 작성하지 않고 키를 정규화할 수 있는 안전판 역할을 합니다.
스키마가 로거보다 중요합니다
대부분의 “구조화된 로깅” 실패는 스키마 실패입니다.
필수적으로 유지해야 하는 필드
모든 로그 백엔드는 타임스탬프, 레벨, 메시지를 저장합니다. 실제로 유용한 애플리케이션 스키마는 종종 안정적인 필드 세트를 추가합니다:
- service, env, version
- component (또는 하위 시스템)
- event (발생한 일에 대한 안정적인 이름)
- request_id (요청이 존재할 때)
- trace_id 와 span_id (추적이 존재할 때)
- error (문자열) 와 error_kind (안정적인 버킷)
패턴을 주목하세요: 이 필드들은 개발자의 호기심이 아니라 운영상의 질문에 답합니다.
시맨틱 컨벤션은 저렴한 일관성 해킹 방법입니다
이미 OpenTelemetry 를 사용 중이라면, 시맨틱 컨벤션은 관측 신호 전반에 걸쳐 속성을 위한 표준 어휘를 제공합니다. OpenTelemetry 를 통해 로그를 내보내지 않더라도, 속성 이름을 차용하면 “서비스 B 에서 이 필드를 무엇이라고 불렀나"라는 부담을 줄일 수 있습니다.
높은 카디널리티와 로그 비용 상승의 이유
높은 카디널리티는 “유니크 값이 너무 많다"는 것을 의미합니다. JSON 페이로드 내에서는 괜찮지만, 백엔드가 일부 필드를 인덱싱된 레이블이나 스트림 키로 취급할 때 고통스러워집니다. 사용자 ID, IP 주소, 무작위 요청 토큰, 전체 URL 은 조합을 폭발시키는 경향이 있습니다.
실용적인 결과는 간단합니다: 레이블과 인덱스 키는 지루하게 유지하세요 (service, environment, region), 그리고 높은 카디널리티 필드는 쿼리 시 필터링을 위해 구조화된 페이로드 안에 두세요.
요청 ID 와 추적을 통한 상관관계 (Correlation)
상관관계는 로그가 단순한 텍스트를 벗어나 관측 데이터처럼 행동하기 시작하는 지점입니다.
요청 ID 를 가장 낮은 마찰의 상관관계 키로 활용
요청 ID 는 들어오는 요청과 그로 인해 발생하는 모든 것 사이의 가장 간단한 다리입니다. 분산 추적 없이도 작동하며, 추적이 샘플링될 때도 여전히 유용합니다.
일반적인 패턴은 컨텍스트에 요청별 로거를 첨부하는 것입니다:
package logx
import (
"context"
"log/slog"
)
type ctxKey struct{}
func WithLogger(ctx context.Context, l *slog.Logger) context.Context {
return context.WithValue(ctx, ctxKey{}, l)
}
func FromContext(ctx context.Context) *slog.Logger {
if l, ok := ctx.Value(ctxKey{}).(*slog.Logger); ok && l != nil {
return l
}
return slog.Default()
}
W3C Trace Context 와 OpenTelemetry 를 활용한 추적 상관관계
W3C Trace Context 는 추적 정체성을 전파하는 표준 방식 (HTTP 의 경우 traceparent 와 tracestate 를 통해) 을 정의합니다. OpenTelemetry 는 이를 기반으로 컨텍스트에서 추적 ID 와 스패너 ID 를 추출할 수 있게 합니다.
이 미들웨어 예제는 사용 가능한 경우 요청 ID 와 추적 식별자 모두를 기록합니다:
package middleware
import (
"crypto/rand"
"encoding/hex"
"net/http"
"go.opentelemetry.io/otel/trace"
"log/slog"
"example.com/project/logx"
)
func requestID() string {
var b [16]byte
_, _ = rand.Read(b[:])
return hex.EncodeToString(b[:])
}
func WithRequestLogger(base *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
rid := r.Header.Get("X-Request-Id")
if rid == "" {
rid = requestID()
}
l := base.With(
"request_id", rid,
"method", r.Method,
"path", r.URL.Path,
)
if sc := trace.SpanContextFromContext(r.Context()); sc.IsValid() {
l = l.With(
"trace_id", sc.TraceID().String(),
"span_id", sc.SpanID().String(),
)
}
ctx := logx.WithLogger(r.Context(), l)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
상관관계 필드가 존재하면, 로그 라인은 다른 데이터에 대한 인덱스가 됩니다. 실제 사고 발생 시 그 차이는 미묘하지 않습니다.
구조화된 로그를 모니터링 및 알림 신호로 전환
로그는 “무엇이 일어났는가"라는 질문에 답하는 데 뛰어납니다. 알림은 보통 “얼마나 자주, 얼마나 심각한가"에 관한 것입니다.
실용적인 접근 방식은 특정 로그 이벤트를 카운터로 취급하는 것입니다:
- event=payment_failed
- event=db_timeout
- event=cache_miss
많은 플랫폼은 윈도우 내 매칭 기록을 세어 로그 기반 지표를 파생할 수 있습니다. 구조화된 로그는 취약한 텍스트 매칭이 아닌 필드 값에 기반하므로 이 카운팅을 견고하게 만듭니다. 이러한 신호를 시각화하고 탐색할 준비가 되었을 때, Install and Use Grafana on Ubuntu: Complete Guide 는 일반적인 로그 및 지표 백엔드를 대상으로 할 수 있는 전체 Grafana 설정을 안내합니다.
여기서 로그 레벨이 중요해지기 시작합니다. 디버그 로그는 종종 가치 있지만, 비용과 노이즈가 숨어있는 곳이기도 합니다. 동적 레벨 (LevelVar) 을 사용하면 시스템은 기본적으로 조용히 유지되지만, 필요할 때 표적화된 세부 정보를 허용합니다.
마무리 생각
Go 의 구조화된 로깅은 더 이상 라이브러리 논쟁이 아닙니다. 흥미로운 부분은 로그 기록이 일관되고 상관관계가 있으며 저장 비용이 저렴한지 여부입니다.
로그가 event, request_id, trace_id 와 같은 안정적인 필드를 포함할 때, 그들은 “누군가가 쓴 문자열"에서 벗어나 운영 가능한 데이터셋이 됩니다.
참고사항
Go 팀은 Go 1.21 에서 log/slog 를 도입하고, 구조화된 로그가 키 - 값 쌍을 사용하여 신뢰할 수 있게 파싱, 필터링, 검색, 분석될 수 있음을 강조했으며, 생태계 전반에서 공유되는 공통 프레임워크를 제공하는 동기를 언급했습니다.
log/slog 패키지 문서에서는 기록 모델 (시간, 레벨, 메시지, 키 - 값 쌍) 과 내장 핸들러 (키=값용 TextHandler 와 줄 구분 JSON 용 JSONHandler) 를 정의하고 고전적인 log 패키지와의 SetDefault 통합을 문서화합니다.
분산 상관관계에 대해, W3C Trace Context 사양은 traceparent 와 tracestate 전파를 표준화하며, OpenTelemetry 는 그 SpanContext 가 W3C Trace Context 를 준수하고 TraceId 와 SpanId 를 노출하여 스패너가 존재할 때 로그 - 추적 상관관계를 직관적으로 만듭니다.
로그 저장 비용 및 성능에 대해, Grafana Loki 문서는 제한적이고 정적인 레이블을 강력히 권장하며, 높은 카디널리티 레이블이 너무 많은 스트림과 거대한 인덱스를 생성할 것에 대해 경고하며, 이는 레이블이 될 것과 인덱싱되지 않은 JSON 필드로 남을 것을 결정할 때 직접적으로 관련이 있습니다.