OpenTelemetry를 이용한 Go 웹 애플리케이션의 데이터베이스 및 HTTP 클라이언트 호출 추적
James Reed
Infrastructure Engineer · Leapcell

Go 웹 애플리케이션의 데이터베이스 및 HTTP 클라이언트 호출 추적
오늘날의 복잡한 분산 시스템에서는 요청 흐름을 이해하고 성능 병목 현상을 정확히 찾아내는 것이 매우 중요합니다. 데이터베이스 및 외부 HTTP 서비스와 자주 상호 작용하는 Go 웹 애플리케이션도 예외는 아닙니다. 적절한 가시성 없이는 문제를 디버깅하는 것이 시간이 많이 걸리고 좌절스러운 일이 될 수 있으며, 평균 해결 시간(MTTR) 증가 및 고객 불만으로 이어질 수 있습니다. 이것이 분산 추적이 필요한 이유입니다. 애플리케이션을 계측함으로써 다양한 구성 요소를 통과하는 요청의 전체 수명 주기에 대한 귀중한 통찰력을 얻을 수 있습니다. 이 기사에서는 데이터베이스 쿼리 및 HTTP 클라이언트 호출을 추적하기 위해 OpenTelemetry를 Go 웹 애플리케이션에 수동으로 통합하는 실제 측면에 대해 자세히 알아보고 애플리케이션 동작을 세밀하게 살펴봅니다.
분산 추적의 핵심 이해
코드를 살펴보기 전에 추적 노력을 뒷받침할 핵심 OpenTelemetry 개념에 대한 공통적인 이해를 확립해 보겠습니다.
- Trace: Trace는 분산 시스템 내에서 단일의 종단 간 트랜잭션 또는 요청을 나타냅니다. 이는 전체 작업을 포괄적으로 설명하는 인과적으로 관련된 Span 모음입니다.
- Span: Span은 Trace 내의 단일 원자 작업입니다. 수신 HTTP 요청, 데이터베이스 쿼리 또는 나가는 API 호출과 같은 작업 단위를 나타냅니다. 각 Span에는 이름, 시작 시간 및 종료 시간이 있으며, 컨텍스트 정보를 제공하는 속성이 있습니다. Span은 부모-자식 관계를 형성하며 중첩될 수 있습니다.
- Tracer: Tracer는 Span을 생성하는 데 사용되는 인터페이스입니다. 일반적으로
TracerProvider
에서 가져옵니다. - TracerProvider:
TracerProvider
는Tracer
인스턴스를 관리하고 구성합니다. Span Processor 및 Exporter를 포함하여 추적 파이프라인을 설정하는 역할을 합니다. - Span Processor:
SpanProcessor
는 내보내기 전에 Span을 처리할 수 있는 인터페이스입니다. 샘플링, 추가 속성으로 Span 강화 또는 Span 버퍼링과 같은 작업을 수행할 수 있습니다. - Exporter:
Exporter
는 수집된 원격 분석 데이터(Span, 메트릭, 로그)를 Jaeger, Zipkin 또는 OpenTelemetry Collector와 같은 백엔드 시스템으로 보냅니다. - Context Propagation: Trace 컨텍스트(Trace ID 및 Span ID 포함)가 서비스 경계를 넘어 전달되는 메커니즘입니다. 이는 완전한 Trace를 형성하기 위해 Span을 연결하는 데 중요합니다. HTTP에서는 일반적으로 요청 헤더를 통해 수행됩니다.
추적의 원리는 각 중요한 작업에 대해 새 Span을 생성하고 컨텍스트 전파를 사용하여 부모 Span에 연결하는 것입니다. 이는 요청 흐름의 상세한 시간순 기록을 제공하는 Span의 방향성 비순환 그래프(DAG)를 형성합니다.
데이터베이스 및 HTTP 클라이언트 추적을 위한 OpenTelemetry 수동 통합
우리의 목표는 데이터베이스(예: PostgreSQL) 및 나가는 HTTP 요청에 대한 호출을 추적하기 위해 Go 웹 애플리케이션을 수동으로 계측하는 것입니다. 로컬 OpenTelemetry Collector로 추적을 내보내 추적 백엔드(예: Jaeger)로 전달할 수 있는 기본 OpenTelemetry 파이프라인을 설정합니다.
시연 목적으로 Docker Compose를 사용하여 로컬에서 OpenTelemetry Collector 및 Jaeger를 설정하는 것부터 시작하겠습니다.
# docker-compose.yaml version: '3.8' services: jaeger: image: jaegertracing/all-in-one:1.35 ports: - "6831:6831/udp" # Agent UDP - "16686:16686" # UI - "14268:14268" # Collector HTTP environment: COLLECTOR_ZIPKIN_HOST_PORT: 9411 COLLECTOR_OTLP_ENABLED: true otel-collector: image: otel/opentelemetry-collector:0.86.0 command: ["--config=/etc/otel-collector-config.yaml"] volumes: - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml ports: - "4317:4317" # OTLP gRPC receiver - "4318:4318" # OTLP HTTP receiver depends_on: - jaeger
그리고 OpenTelemetry Collector 구성:
# otel-collector-config.yaml receivers: otlp: protocols: grpc: http: exporters: jaeger: endpoint: jaeger:14250 tls: insecure: true service: pipelines: traces: receivers: [otlp] exporters: [jaeger]
docker-compose up -d
로 이들을 실행합니다. 그런 다음 http://localhost:16686
에서 Jaeger UI에 액세스할 수 있습니다.
이제 Go 애플리케이션을 작성해 보겠습니다. 이 예제에서는 PostgreSQL 데이터베이스에서 데이터를 가져온 다음 외부 HTTP 호출을 수행하여 요청을 처리하는 간단한 HTTP 서버를 특징으로 합니다.
먼저 OpenTelemetry TracerProvider
를 초기화하고 Span을 내보내도록 구성해야 합니다.
package main import ( "context" "fmt" "log" "net/http" "time" "github.com/gin-gonic/gin" "github.com/jackc/pgx/v5" "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/exporters/otlp/otlptrace" "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc" "go.opentelemetry.io/otel/sdk/resource" sdktrace "go.opentelemetry.io/otel/sdk/trace" semconv "go.opentelemetry.io/otel/semconv/v1.21.0" "go.opentelemetry.io/otel/trace" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) var tracer = otel.Tracer("my-go-web-app") // InitTracerProvider initializes the OpenTelemetry TracerProvider func InitTracerProvider() *sdktrace.TracerProvider { ctx := context.Background() // Configure the OTLP exporter to send traces to the collector conn, err := grpc.DialContext(ctx, "otel-collector:4317", grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithBlock(), ) if err != nil { log.Fatalf("failed to dial gRPC: %v", err) } exporter, err := otlptrace.New( ctx, otlptracegrpc.WithGRPCConn(conn), ) if err != nil { log.Fatalf("failed to create OTLP trace exporter: %v", err) } // Create a new trace processor that will export spans bsp := sdktrace.NewBatchSpanProcessor(exporter) // Define the resource that identifies this service res, err := resource.New(ctx, resource.WithAttributes( semconv.ServiceNameKey.String("go-web-app"), semconv.ServiceVersionKey.String("1.0.0"), ), ) if err != nil { log.Fatalf("failed to create resource: %v", err) } // Create a new TracerProvider tp := sdktrace.NewTracerProvider( sdktrace.WithBatchSpanProcessor(bsp), sdktrace.WithResource(res), ) // Register the global TracerProvider otel.SetTracerProvider(tp) return tp } // simulateDBQuery simulates a database query. func simulateDBQuery(ctx context.Context, userID string) (string, error) { // Create a new span for the database query ctx, span := tracer.Start(ctx, "db.get_user_data", trace.WithSpanKind(trace.SpanKindClient), trace.WithAttributes( attribute.String("db.system", "postgresql"), attribute.String("db.statement", "SELECT * FROM users WHERE id = $1"), attribute.String("db.user_id", userID), attribute.String("database.connection_string", "user=postgres password=password dbname=test host=localhost port=5432 sslmode=disable"), ), ) defer span.End() // Simulate database connection and query execution conn, err := pgx.Connect(ctx, "postgresql://postgres:password@localhost:5433/test?sslmode=disable") if err != nil { span.RecordError(err) span.SetStatus(trace.StatusError, "Failed to connect to database") return "", fmt.Errorf("failed to connect to database: %w", err) } defer conn.Close(ctx) // Simulate a query delay time.Sleep(100 * time.Millisecond) var username string err = conn.QueryRow(ctx, "SELECT username FROM users WHERE id = $1", userID).Scan(&username) if err != nil { span.RecordError(err) if err == pgx.ErrNoRows { span.SetStatus(trace.StatusError, "User not found") return "", fmt.Errorf("user %s not found", userID) } span.SetStatus(trace.StatusError, "Failed to query database") return "", fmt.Errorf("failed to query database: %w", err) } span.SetStatus(trace.StatusOK, "Successfully retrieved user data") return username, nil } // makeExternalAPIRequest simulates an HTTP client call to an external service. func makeExternalAPIRequest(ctx context.Context, endpoint string) (string, error) { // Create a new span for the HTTP client call ctx, span := tracer.Start(ctx, fmt.Sprintf("http.client.get_%s", endpoint), trace.WithSpanKind(trace.SpanKindClient), trace.WithAttributes( attribute.String("http.method", "GET"), attribute.String("http.url", endpoint), ), ) defer span.End() req, err := http.NewRequestWithContext(ctx, "GET", endpoint, nil) if err != nil { span.RecordError(err) span.SetStatus(trace.StatusError, "Failed to create HTTP request") return "", fmt.Errorf("failed to create HTTP request: %w", err) } // Propagate trace context to the outgoing request otel.GetTextMapPropagator().Inject(ctx, otel.HeaderCarrier(req.Header)) client := http.DefaultClient resp, err := client.Do(req) if err != nil { span.RecordError(err) span.SetStatus(trace.StatusError, "Failed to make HTTP request") return "", fmt.Errorf("failed to make HTTP request: %w", err) } defer resp.Body.Close() span.SetAttributes( attribute.Int("http.status_code", resp.StatusCode), ) if resp.StatusCode != http.StatusOK { span.SetStatus(trace.StatusError, fmt.Sprintf("HTTP request failed with status: %d", resp.StatusCode)) return "", fmt.Errorf("external API call failed with status: %d", resp.StatusCode) } // Simulate processing the response time.Sleep(50 * time.Millisecond) span.SetStatus(trace.StatusOK, "Successfully made external API request") return "External API response for: " + endpoint, nil } func main() { // Initialize OpenTelemetry TracerProvider tp := InitTracerProvider() defer func() { if err := tp.Shutdown(context.Background()); err != nil { log.Printf("Error shutting down tracer provider: %v", err) } }() // Database setup (for demonstration) // You need to have a PostgreSQL instance running, // e.g., with a Docker container: // docker run --name some-postgres -p 5433:5432 -e POSTGRES_PASSWORD=password -d postgres // Then connect and create a 'users' table: // CREATE TABLE users (id VARCHAR(255) PRIMARY KEY, username VARCHAR(255)); // INSERT INTO users (id, username) VALUES ('123', 'john_doe'); // INSERT INTO users (id, username) VALUES ('456', 'jane_doe'); router := gin.Default() router.Use(otelgin.Middleware("my-go-web-app")) // Use Gin OpenTelemetry middleware for incoming requests router.GET("/user/:id", func(c *gin.Context) { ctx := c.Request.Context() // Get context from Gin, which already contains the trace context userID := c.Param("id") // Trace database call username, err := simulateDBQuery(ctx, userID) if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } // Trace an external HTTP client call externalAPIResponse, err := makeExternalAPIRequest(ctx, "http://jsonplaceholder.typicode.com/todos/1") if err != nil { c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) return } c.JSON(http.StatusOK, gin.H{ "message": fmt.Sprintf("User %s found: %s", userID, username), "external_api_result": externalAPIResponse, }) }) log.Fatal(router.Run(":8080")) // Listen and serve on 0.0.0.0:8080 }
이 예제를 실행하려면 다음 Go 모듈이 필요합니다.
go get github.com/gin-gonic/gin go get go.opentelemetry.io/otel go get go.opentelemetry.io/otel/attribute go get go.opentelemetry.io/otel/exporters/otlp/otlptrace go get go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc go get go.opentelemetry.io/otel/sdk/resource go get go.opentelemetry.io/otel/sdk/trace go get go.opentelemetry.io/otel/semconv/v1.21.0 go get google.golang.org/grpc go get go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin go get github.com/jackc/pgx/v5
코드 설명:
-
InitTracerProvider()
:- 이 함수는 OpenTelemetry 추적 파이프라인을 설정합니다.
- OpenTelemetry Collector(
otel-collector:4317
)에 연결하기 위해 gRPC 클라이언트를 생성합니다. otlptracegrpc.New()
는 gRPC를 통해 Span을 전송하는 OTLP 추적 내보내기를 생성합니다.sdktrace.NewBatchSpanProcessor()
는 Span을 버퍼링하고 일괄 처리로 전송하여 성능을 향상시킵니다.- 추적 백엔드에서 애플리케이션을 식별하기 위해
ServiceNameKey
및ServiceVersionKey
로resource
가 정의됩니다. sdktrace.NewTracerProvider()
는 구성된 Processor 및 리소스를 사용하여TracerProvider
를 생성합니다.otel.SetTracerProvider(tp)
는 이 Provider를 전역으로 등록하여 애플리케이션의 다른 부분이Tracer
를 얻을 수 있도록 합니다.
-
main()
함수:- 시작 시
InitTracerProvider()
를 호출하고 종료 시tp.Shutdown()
이 호출되어 버퍼링된 모든 Span이 플러시되도록 합니다. - Gin 통합:
router.Use(otelgin.Middleware("my-go-web-app"))
는 OpenTelemetry Gin 미들웨어를 활용합니다. 이 미들웨어는 각 들어오는 HTTP 요청에 대한 루트 Span을 자동으로 생성하고, 들어오는 헤더에서 추적 컨텍스트를 추출하고(있는 경우), GinRequest.Context()
에 컨텍스트를 주입합니다. 이는 HTTP 요청 간의 컨텍스트 전파에 중요합니다. tracer
변수는otel.Tracer("my-go-web-app")
를 사용하여 얻으며, 전역적으로 구성된TracerProvider
를 사용합니다.
- 시작 시
-
simulateDBQuery()
함수:tracer.Start(ctx, "db.get_user_data", ...)
는 데이터베이스 작업에 대해 새 Span을 수동으로 생성합니다.trace.WithSpanKind(trace.SpanKindClient)
는 이 Span이 클라이언트 측 호출(데이터베이스의 클라이언트 역할을 하는 당사 애플리케이션)을 나타냄을 나타냅니다.trace.WithAttributes(...)
는 Span에 의미론적 규칙 및 사용자 지정 속성을 추가하여 데이터베이스 호출에 대한 풍부한 컨텍스트(시스템, 문, 사용자 ID)를 제공합니다. 이는 Jaeger에서 추적을 필터링하고 분석하는 데 중요합니다.defer span.End()
는 Span이 항상 종료되도록 하여 기간을 기록합니다.- 오류 처리는 오류를 나타내기 위해
span.RecordError(err)
및span.SetStatus(trace.StatusError, ...)
를 포함합니다.
-
makeExternalAPIRequest()
함수:- 데이터베이스 함수와 마찬가지로 HTTP 클라이언트 호출에 대해 새 Span이 생성됩니다.
otel.GetTextMapPropagator().Inject(ctx, otel.HeaderCarrier(req.Header))
는 분산 컨텍스트 전파를 위한 중요한 단계입니다. 현재 추적 컨텍스트를ctx
에서 가져와 나가는 HTTP 요청 헤더(예:traceparent
)에 주입합니다. 외부 서비스도 OpenTelemetry로 계측된 경우 이 컨텍스트를 추출하고 추적을 계속하여 단일 종단 간 추적을 형성합니다.http.method
및http.url
과 같은 속성은 HTTP 요청에 대한 세부 정보를 제공합니다. 응답의 HTTP 상태 코드도 속성으로 추가됩니다.
추적 관찰
docker-compose up -d
를 실행한 다음 Go 애플리케이션을 시작한 후:
- 애플리케이션에 요청합니다:
curl http://localhost:8080/user/123
. http://localhost:16686
에서 Jaeger UI를 엽니다.- 서비스 드롭다운에서 "go-web-app"을 선택하고 "Find Traces"를 클릭합니다.
이와 유사한 추적이 표시됩니다.
- GET /user/
(Gin HTTP Server Span - otelgin.Middleware
에서)- db.get_user_data (당사가 수동으로 생성한 데이터베이스 Span)
- http.client.get_http://jsonplaceholder.typicode.com/todos/1 (당사가 수동으로 생성한 HTTP 클라이언트 Span)
각 Span에는 우리가 설정한 것을 포함한 상세한 속성이 있어 요청 중에 발생한 일에 대한 명확한 그림을 제공합니다. 각 작업의 기간을 검사하고 잠재적인 병목 현상을 식별할 수 있습니다.
결론
Go 웹 애플리케이션에서 데이터베이스 및 HTTP 클라이언트 호출 추적을 위해 OpenTelemetry를 수동으로 통합하면 시스템 동작에 대한 세밀한 가시성을 제공합니다. Trace, Span 및 컨텍스트 전파와 같은 핵심 개념을 이해하고 OpenTelemetry SDK를 사용하여 신중하게 계측하면 강력한 가시성 프레임워크를 구축할 수 있습니다. 이 접근 방식은 개발자가 성능 문제를 효율적으로 진단하고 분산 시스템 내에서의 복잡한 상호 작용을 이해할 수 있도록 하여 궁극적으로 더 안정적이고 성능이 뛰어난 애플리케이션으로 이어집니다. OpenTelemetry를 채택하는 것은 포괄적인 시스템 가시성을 달성하기 위한 중요한 단계입니다.