Observability 및 Alerting을 위한 Go의 slog 기반 Structured Logging

추적 정보와 연결된 쿼리 가능한 JSON 로그

Page content

로그는 시스템이 파국적인 상황에 처해 있을 때에도 여전히 사용할 수 있는 디버깅 인터페이스입니다. 문제는 평문(plain text) 로그는 시간이 지나면 관리하기 어렵다는 점입니다. 필터링, 집계, 알림이 필요해지자마자 문장을 파싱하기 시작하게 됩니다.

better logging을 위한 노트북과 Go 마스코트가 있는 작업 공간

구조화된 로깅은 이에 대한 해법입니다. 이는 각 로그 라인을 안정적인 필드를 가진 작은 이벤트로 변환하여, 도구들이 신뢰할 수 있게 검색하고 집계할 수 있게 합니다. 로그가 더 넓은 스택에서 메트릭, 대시보드, 알림과 어떻게 연결되는지에 대해서는 관찰 가능성: 모니터링, 메트릭, Prometheus 및 Grafana 가이드를 참조하십시오.

구조화된 로깅이란 무엇이며 왜 확장 가능한가

구조화된 로깅이란 기록이 단순한 문자열이 아니라, 메시지와 타입이 지정된 키-값 속성의 조합인 로깅 방식입니다. 이 아이디어는 가장 좋은 의미에서 지루하지만 확실합니다: 로그가 기계가 읽을 수 있는 형식이 되면, 사고 대응은 더 이상 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 치트시트를 참조하십시오.

모델의 핵심 부분은 다음과 같습니다:

Record

레코드(Record)는 발생한 사건입니다. slog 용어로, 시간, 레벨, 메시지 및 속성 세트를 포함합니다. Info 및 Error와 같은 메서드를 통해 레코드를 생성하거나, 레벨을 명시적으로 제공하려면 Log를 사용할 수 있습니다.

Attributes

속성(Attributes)은 로그를 쿼리 가능하게 만드는 키-값 쌍입니다. 동일한 개념을 세 가지 다른 키(user, userId, uid) 아래에 로그하면 세 가지 다른 데이터셋이 생성됩니다. 진정한 가치는 일관된 키에서 숨어 있습니다.

Handler

핸들러(Handler)는 레코드가 바이트로 변환되는 방식입니다. 내장 TextHandler는 key=value 출력을 작성하고, JSONHandler는 줄로 구분된 JSON을 작성합니다. 핸들러는 또한 검열(Redaction), 키 이름 변경, 출력 라우팅이 주로 이루어지는 곳입니다.

하나의 저평가된 기능은 slog가 기존 코드 앞에 배치될 수 있다는 점입니다. 기본 slog 로거를 설정하면 최상위 slog 함수가 이를 사용하며, 고전적인 log 패키지도 이를 통해 리디렉션될 수 있습니다. 이로 인해 점진적인 마이그레이션이 가능해집니다.

Groups

그룹(Groups)은 “모든 서브시스템이 id를 사용한다"는 문제를 해결합니다. 요청(request.method, request.path)에 대한 속성 세트를 그룹화하거나, WithGroup을 사용하여 전체 서브시스템에 네임스페이스를 지정하여 키가 충돌하지 않도록 할 수 있습니다.

프로덕션 환경에 적합한 slog 설정

다음 설정은 일반적인 목표들을 달성합니다. 예제는 작은 logx 패키지를 사용하며, 이러한 패키지가 실제 모듈에서 일반적으로 어디에 위치해야 하는지는 Go 프로젝트 구조: 관행 및 패턴을 참조하십시오.

  • 한 줄에 하나의 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 (또는 subsystem)
  • event (발생한 사건의 안정적인 이름)
  • request_id (요청이 존재할 때)
  • trace_id 및 span_id (추적이 존재할 때)
  • error (문자열) 및 error_kind (안정적인 버킷)

패턴을 주목하십시오: 이러한 필드는 개발자의 호기심이 아니라 운영 관련 질문에 답합니다.

시맨틱 규칙은 저렴한 일관성 해킹입니다

이미 OpenTelemetry를 사용한다면, 그 시맨틱 규칙은 텔레메트리 신호 전반에 걸친 속성에 대한 표준 어휘를 제공합니다. OpenTelemetry를 통해 로그를 내보내지 않더라도, 속성 이름을 차용하면 “서비스 B에서 이 필드를 무엇이라고 불렀지?“라는 세금(부담)을 줄일 수 있습니다.

높은 카디널리티와 로그가 비싸지는 이유

높은 카디널리티(High cardinality)는 “고유한 값이 너무 많다"는 것을 의미합니다. 이는 JSON 페이로드 내부에서는 괜찮지만, 백엔드가 일부 필드를 인덱싱된 라벨이나 스트림 키로 처리할 때 고통스러워집니다. 사용자 ID, IP 주소, 임의의 요청 토큰 및 전체 URL은 조합을 폭발시켜버립니다.

실용적인 결과는 단순합니다: 라벨과 인덱스 키는 지루하게 유지하십시오(서비스, 환경, 지역)고, 높은 카디널리티 필드는 쿼리 시 필터링을 위해 구조화된 페이로드 내부에 유지하십시오.

요청 ID 및 추적을 통한 상관관계 분석

상관관계(Correlation)는 로그가 단순한 텍스트를 넘어 텔레메트리처럼 행동하기 시작하는 지점입니다.

가장 마찰력이 낮은 상관관계 키로서의 Request ID

요청 ID는 들어오는 요청과 그로 인해 발생하는 모든 것 사이의 가장 간단한 브리지입니다. 분산 추적이 없어도 작동하며, 추적이 샘플링되더라도 여전히 유용합니다. 요청 ID 및 기타 메타데이터가 컨텍스트에 어떻게 저장되고 검색되어야 하는지에 대한 전체적인 그림, 즉 타입화된 키 패턴 및 미들웨어 예제를 포함하여 — Go context.Context Done Right를 참조하십시오.

일반적인 패턴은 컨텍스트에 요청별 로거를 첨부하는 것입니다:

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를 컨텍스트에서 추출할 수 있게 합니다.

다음 미들웨어 예제는 사용 가능한 경우 request_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

많은 플랫폼은 창(window) 동안 일치하는 기록을 세어서 로그 기반 메트릭을 파생할 수 있습니다. 구조화된 로그는 텍스트 매칭이 아닌 필드 값에 기반하므로 이러한 계산을 견고하게 만듭니다. 이러한 신호를 시각화하고 탐색할 준비가 되었을 때, Ubuntu에서 Grafana 설치 및 사용: 완전 가이드는 공통 로그 및 메트릭 백엔드를 가리킬 수 있는 전체 Grafana 설정을 안내합니다.

여기서 로그 레벨의 중요성이 나타나기 시작합니다. 디버그 로그는 종종 가치 있지만, 비용과 노이즈가 숨어 있는 곳이기도 합니다. 동적 레벨(LevelVar)을 사용하면 시스템은 기본적으로 조용하게 유지되면서도 필요할 때 표적화된 세부 사항을 허용할 수 있습니다.

마무리 생각

Go에서의 구조화된 로깅은 더 이상 라이브러리 논쟁이 아닙니다. 흥미로운 부분은 로그 기록이 일관되고, 상관관계가 있으며, 저장 비용이 합리적인지 여부입니다.

로그에 event, request_id, trace_id와 같은 안정적인 필드가 포함되어 있으면, 그들은 “누군가 작성한 문자열"에서 멈추고 운영할 수 있는 데이터셋이 됩니다.

참고 사항

Go 팀은 Go 1.21에서 log/slog를 도입했으며, 구조화된 로그가 키-값 쌍을 사용하여 신뢰할 수 있게 파싱, 필터링, 검색 및 분석될 수 있도록 한다는 점을 강조했습니다. 또한 생태계 전반에 걸쳐 공통 프레임워크를 제공하는 동기 사항도 언급했습니다.

log/slog 패키지 문서에는 기록 모델(시간, 레벨, 메시지, 키-값 쌍)과 내장 핸들러(TextHandler는 key=value용, JSONHandler는 줄로 구분된 JSON용)가 정의되어 있으며, 고전적인 log 패키지와 SetDefault 통합이 문서화되어 있습니다.

분산 상관관계의 경우, W3C Trace Context 사양은 traceparent 및 tracestate 전파를 표준화하며, OpenTelemetry는 그 SpanContext가 W3C Trace Context를 준수하고 TraceId 및 SpanId를 노출하여 스팬이 존재할 때 로그-추적 상관관계를 간단하게 만듭니다.

로그 저장 비용 및 성능과 관련하여, Grafana Loki 문서에서는 제한적이고 정적인 라벨을 강력히 권장하며, 높은 카디널리티 라벨이 너무 많은 스트림과 거대한 인덱스를 생성한다는 점을 경고합니다. 이는 라벨로 무엇을 만들지, 인덱싱되지 않은 JSON 필드로 무엇을 유지할지 결정할 때 직접적으로 관련이 있습니다.

구독하기

시스템, 인프라, AI 엔지니어링에 관한 새 글을 받아보세요.