effidevFlutter · Cloudflare 엣지 · 클라우드 비용 최적화
한국어

Cloudflare Workers OpenTelemetry: OTLP 분산 트레이싱과 샘플링으로 비용 90% 절감

Cloudflare Workers OpenTelemetry 옵저버빌리티 분산 트레이싱 아키텍처

에지에서 옵저버빌리티가 어려운 이유

전통적인 서버 환경에서는 로그를 파일에 쓰고, APM 에이전트를 사이드카로 붙이고, 긴 요청 수명 동안 컨텍스트를 유지할 수 있다. 그런데 Cloudflare Workers는 전혀 다른 세계다.

Workers는 수명이 밀리초 단위인 격리된 V8 컨텍스트에서 실행된다. 파일 시스템이 없고, 영속적인 백그라운드 프로세스도 없다. console.log로 디버깅하다가 프로덕션에서 원인 불명의 오류를 만나면? wrangler tail로 실시간 로그를 보는 것 외에 뾰족한 수가 없었다.

그러다 2024년 말부터 Cloudflare가 Workers에 네이티브 OpenTelemetry 지원을 내장하기 시작했다. 이제 코드 한 줄 없이 wrangler.jsonc에 설정 몇 줄만 추가하면 모든 fetch 요청, D1 쿼리, KV 읽기/쓰기, Durable Object 호출의 분산 트레이스가 자동으로 수집된다.

이 글에서는 네이티브 OTel 자동 트레이싱 설정, 커스텀 스팬 추가, Axiom과 Honeycomb으로 OTLP 내보내기, 그리고 샘플링 전략으로 월 옵저버빌리티 비용을 90% 이상 절감하는 방법을 다룬다.

OpenTelemetry 핵심 개념 정리

본격적인 설정 전에 OTel의 세 가지 핵심 개념을 짚고 간다.

Trace (트레이스)
 └── Span (스팬): 하나의 작업 단위
      ├── Span: D1 쿼리 (자식 스팬)
      ├── Span: KV 읽기 (자식 스팬)
      └── Span: 외부 API 호출 (자식 스팬)

각 Span에는 다음이 포함됨:
  - 시작/종료 시각
  - 지속 시간 (duration)
  - 속성 (attributes): key-value 메타데이터
  - 이벤트 (events): 시점 기록
  - 상태 (status): OK / Error

Workers에서 트레이스는 Workers 요청 → D1 조회 → 외부 API 호출 전체를 하나의 워터폴 다이어그램으로 시각화한다.

옵저버빌리티 비용 구조 이해

설정 전에 비용 구조를 먼저 이해해야 한다. 잘못 설정하면 트레이스 비용이 Workers 실행 비용보다 커진다.

구성 요소 무료 한도 유료
Cloudflare Workers 대시보드 트레이스 월 2천만 이벤트 초과분 $0.60/백만
Axiom 데이터 수집 월 500GB 초과분 $1/GB
Grafana Cloud 트레이스 월 50GB 초과분 $0.55/GB
Honeycomb 월 2천만 이벤트 초과분 약 $1/백만

핵심 문제: Workers는 트래픽이 많으면 하루 수억 건 요청이 발생한다. 요청마다 트레이스를 수집하면 월간 이벤트가 폭발한다.

해법: Head Sampling. 전체 요청의 5%만 샘플링하면 이벤트를 95% 줄이면서도 통계적으로 의미 있는 데이터를 얻는다. 오류는 100% 캡처하도록 설정하면 디버깅 능력은 유지하면서 비용을 90%+ 절감한다.

1단계: 네이티브 자동 트레이싱 활성화

코드 변경 없이 wrangler.jsonc만 수정한다.

// wrangler.jsonc
{
  "name": "my-worker",
  "main": "src/index.ts",
  "compatibility_date": "2025-01-01",
  "compatibility_flags": ["nodejs_compat"],

  "observability": {
    "traces": {
      "enabled": true,
      // Head Sampling: 전체 요청의 5%만 샘플링
      "head_sampling_rate": 0.05,
      // Cloudflare 대시보드에도 저장 (7일 보존)
      "persist": true
    },
    "logs": {
      "enabled": true,
      "head_sampling_rate": 0.1
    }
  },

  "d1_databases": [
    {
      "binding": "DB",
      "database_name": "my-db",
      "database_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
    }
  ],
  "kv_namespaces": [
    {
      "binding": "CACHE",
      "id": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
    }
  ]
}

이 설정만으로 다음이 자동으로 트레이싱된다:

wrangler deploy를 실행하면 즉시 Cloudflare 대시보드 → Workers → Observability에서 트레이스를 볼 수 있다.

2단계: 커스텀 스팬으로 애플리케이션 로직 트레이싱

네이티브 자동 트레이싱이 플랫폼 작업을 커버하지만, 비즈니스 로직 내부는 커스텀 스팬이 필요하다.

2025년 신규: cloudflare:workers 네이티브 트레이싱 API

// src/index.ts
import { tracing } from "cloudflare:workers";
import { Hono } from "hono";
import { drizzle } from "drizzle-orm/d1";
import { users, posts } from "./db/schema";

type Env = {
  DB: D1Database;
  CACHE: KVNamespace;
};

const app = new Hono<{ Bindings: Env }>();

app.get("/api/posts/:slug", async (c) => {
  const slug = c.req.param("slug");

  // 커스텀 스팬: 전체 포스트 조회 로직을 하나의 스팬으로 묶음
  return tracing.enterSpan("get-post-by-slug", async (span) => {
    // 스팬에 비즈니스 컨텍스트 속성 추가
    span.setAttribute("post.slug", slug);
    span.setAttribute("app.feature", "blog");

    const db = drizzle(c.env.DB);

    // KV 캐시 조회 (자동으로 자식 스팬 생성)
    const cacheKey = `post:${slug}`;
    const cached = await c.env.CACHE.get(cacheKey, "json");

    if (cached) {
      span.setAttribute("cache.hit", true);
      // 스팬 이벤트: 특정 시점 기록
      span.addEvent("cache-hit", { key: cacheKey });
      return c.json(cached);
    }

    span.setAttribute("cache.hit", false);

    // D1 조회 (자동으로 자식 스팬 생성 — SQL 문장 포함)
    const post = await db
      .select()
      .from(posts)
      .where(eq(posts.slug, slug))
      .get();

    if (!post) {
      // 오류 상태 기록
      span.setStatus({ code: "ERROR", message: "Post not found" });
      return c.json({ error: "Not found" }, 404);
    }

    span.setAttribute("post.id", post.id);
    span.setAttribute("post.published", post.published);

    // KV 캐싱 (fire-and-forget)
    c.executionCtx.waitUntil(
      c.env.CACHE.put(cacheKey, JSON.stringify(post), {
        expirationTtl: 300,
      })
    );

    return c.json(post);
  });
});

export default app;

중첩 스팬: 복잡한 워크플로 트레이싱

// 여러 단계를 가진 복잡한 비즈니스 로직
async function processOrder(
  env: Env,
  ctx: ExecutionContext,
  orderId: string
) {
  return tracing.enterSpan("process-order", async (orderSpan) => {
    orderSpan.setAttribute("order.id", orderId);

    // 1단계: 재고 확인
    const inventory = await tracing.enterSpan(
      "check-inventory",
      async (span) => {
        span.setAttribute("order.id", orderId);
        const db = drizzle(env.DB);
        return db.select().from(inventoryTable)
          .where(eq(inventoryTable.orderId, orderId))
          .get();
      }
    );

    if (!inventory || inventory.stock < 1) {
      orderSpan.setStatus({
        code: "ERROR",
        message: "Out of stock",
      });
      throw new Error("Out of stock");
    }

    // 2단계: 결제 처리 (외부 API)
    const payment = await tracing.enterSpan(
      "process-payment",
      async (span) => {
        span.setAttribute("payment.provider", "stripe");
        // 아웃바운드 fetch는 자동으로 스팬 생성
        const res = await fetch("https://api.stripe.com/v1/charges", {
          method: "POST",
          // ...
        });
        span.setAttribute("payment.status", res.status);
        return res.json();
      }
    );

    orderSpan.setAttribute("payment.id", payment.id);
    orderSpan.addEvent("order-completed", {
      orderId,
      paymentId: payment.id,
    });

    return payment;
  });
}

tracing.enterSpan()은 콜백이 반환되거나 프로미스가 완료되면 자동으로 스팬을 종료한다. 수동으로 span.end()를 호출할 필요가 없다.

3단계: OTLP 외부 대시보드 연동

Cloudflare 대시보드의 7일 보존 제한을 넘어서려면 외부 OTLP 엔드포인트로 내보내야 한다.

Axiom 연동 (무료 500GB/월)

// wrangler.jsonc에 destinations 추가
{
  "observability": {
    "traces": {
      "enabled": true,
      "head_sampling_rate": 0.05,
      "persist": false,  // Cloudflare 대시보드 저장 비활성화
      "destinations": ["axiom-traces"]
    },
    "logs": {
      "enabled": true,
      "head_sampling_rate": 0.1,
      "destinations": ["axiom-logs"]
    }
  }
}

Cloudflare 대시보드에서 Destination 추가:

  1. Workers → Observability → Destinations → Add Destination
  2. Name: axiom-traces
  3. Type: OTLP
  4. Endpoint: https://api.axiom.co/v1/traces
  5. Headers: Authorization: Bearer <AXIOM_API_TOKEN>, X-Axiom-Dataset: my-workers

Honeycomb 연동

Endpoint: https://api.honeycomb.io/v1/traces
Headers:
  x-honeycomb-team: <HONEYCOMB_API_KEY>
  x-honeycomb-dataset: cloudflare-workers

Grafana Cloud 연동

Endpoint: https://otlp-gateway-prod-<region>.grafana.net/otlp/v1/traces
Headers:
  Authorization: Basic <base64(instanceId:apiToken)>

@microlabs/otel-cf-workers: 코드 레벨 커스터마이징

네이티브 OTel이 충분하지 않고 더 세밀한 제어가 필요하다면:

// src/index.ts — @microlabs/otel-cf-workers 사용
import { instrument, ResolveConfigFn } from "@microlabs/otel-cf-workers";
import { trace, context } from "@opentelemetry/api";

type Env = {
  DB: D1Database;
  OTEL_EXPORTER_OTLP_ENDPOINT: string;
  OTEL_EXPORTER_OTLP_HEADERS: string;
};

const handler = {
  async fetch(request: Request, env: Env, ctx: ExecutionContext) {
    const tracer = trace.getTracer("my-worker");

    return tracer.startActiveSpan("handle-request", async (span) => {
      try {
        span.setAttribute("http.method", request.method);
        span.setAttribute("http.url", request.url);

        const url = new URL(request.url);
        const response = await routeRequest(request, env, ctx);

        span.setAttribute("http.status_code", response.status);
        return response;
      } catch (err) {
        span.recordException(err as Error);
        span.setStatus({ code: SpanStatusCode.ERROR });
        throw err;
      } finally {
        span.end();
      }
    });
  },
};

// 환경 변수에서 OTLP 설정 읽기
const config: ResolveConfigFn = (env: Env, trigger) => ({
  exporter: {
    url: env.OTEL_EXPORTER_OTLP_ENDPOINT,
    headers: Object.fromEntries(
      new URLSearchParams(env.OTEL_EXPORTER_OTLP_HEADERS)
    ),
  },
  service: {
    name: "my-cloudflare-worker",
    version: "1.0.0",
  },
});

export default instrument(handler, config);

4단계: 샘플링 전략 — 비용 90% 절감의 핵심

샘플링은 단순히 비율을 낮추는 게 아니다. 오류는 100% 캡처하면서 정상 요청만 샘플링하는 전략이 핵심이다.

Head Sampling (헤드 샘플링)

요청 시작 시점에 트레이스할지 결정. 가장 단순하고 성능 오버헤드가 최소화.

{
  "observability": {
    "traces": {
      "enabled": true,
      "head_sampling_rate": 0.05  // 5% 샘플링
    }
  }
}

단점: 오류가 발생한 요청도 95% 확률로 드롭될 수 있다.

Tail Sampling (테일 샘플링): 오류 100% 보존

@microlabs/otel-cf-workers와 커스텀 샘플러를 사용하면 테일 샘플링 구현이 가능하다:

// src/sampling.ts — 오류는 100%, 정상은 5% 샘플링
import { Sampler, SamplingResult, SamplingDecision } from "@opentelemetry/sdk-trace-base";

export class ErrorAlwaysSampler implements Sampler {
  private baseRate: number;

  constructor(baseRate = 0.05) {
    this.baseRate = baseRate;
  }

  shouldSample(
    context: any,
    traceId: string,
    spanName: string,
    spanKind: any,
    attributes: any,
    links: any
  ): SamplingResult {
    // HTTP 오류 상태는 항상 샘플링
    const statusCode = attributes["http.status_code"];
    if (statusCode && statusCode >= 400) {
      return { decision: SamplingDecision.RECORD_AND_SAMPLED };
    }

    // 오류 속성이 있으면 항상 샘플링
    if (attributes["error"] === true) {
      return { decision: SamplingDecision.RECORD_AND_SAMPLED };
    }

    // 나머지는 baseRate 확률로 샘플링
    return {
      decision:
        Math.random() < this.baseRate
          ? SamplingDecision.RECORD_AND_SAMPLED
          : SamplingDecision.NOT_RECORD,
    };
  }

  toString() {
    return `ErrorAlwaysSampler(${this.baseRate})`;
  }
}
// instrument 설정에 커스텀 샘플러 적용
const config: ResolveConfigFn = (env: Env) => ({
  exporter: { url: env.OTEL_ENDPOINT },
  service: { name: "my-worker" },
  sampler: new ErrorAlwaysSampler(0.05),
});

샘플링 비율별 비용 시뮬레이션

월 10억 요청을 처리하는 Workers가 있다고 가정:

샘플링 비율 월간 이벤트 Axiom 비용 Honeycomb 비용
100% (샘플링 없음) 10억 건 ~$500/월 ~$1,000/월
10% 1억 건 ~$50/월 ~$100/월
5% 5천만 건 ~$25/월 ~$50/월
1% 1천만 건 무료 ~$10/월

5% 샘플링만으로 비용을 95% 줄이면서 초당 수천 건의 통계적 대표 샘플을 얻는다.

5단계: 트레이스 활용 — 실제 프로덕션 시나리오

느린 D1 쿼리 찾기

Axiom에서 다음 쿼리로 100ms 이상 걸리는 D1 쿼리를 찾는다:

// Axiom APL 쿼리
['cloudflare-workers']
| where ['span.kind'] == "client"
| where ['db.system'] == "cloudflare.d1"
| where duration > 100ms
| summarize count(), avg(duration) by ['db.statement']
| order by avg_duration desc
| limit 20

오류 패턴 분석

// 5xx 오류가 발생한 트레이스만 필터
['cloudflare-workers']
| where ['http.status_code'] >= 500
| where ['span.is_root'] == true
| project _time, ['http.url'], ['http.method'], duration, ['error.message']
| order by _time desc

P99 레이턴시 추적

// 특정 엔드포인트의 P50/P95/P99 레이턴시
['cloudflare-workers']
| where ['http.route'] == "/api/posts/:slug"
| summarize
    p50 = percentile(duration, 50),
    p95 = percentile(duration, 95),
    p99 = percentile(duration, 99)
    by bin(_time, 5m)
| order by _time desc

6단계: Durable Objects 트레이싱

Durable Objects는 Workers보다 복잡한 수명 주기를 가지므로 별도 트레이싱이 필요하다.

네이티브 자동 트레이싱 (권장)

wrangler.jsoncobservability.traces.enabled = true만 설정하면 DO 호출이 부모 Workers 트레이스의 자식 스팬으로 자동 연결된다. @microlabs/otel-cf-workers가 필요 없다.

Workers 요청 트레이스
  └── fetch handler (root span)
       ├── D1 쿼리 (자동 스팬)
       └── Durable Object 호출 (자동 자식 스팬)
            ├── DO fetch handler
            └── DO 내부 D1 쿼리

@microlabs/otel-cf-workers: DO 수동 계측

// src/rate-limiter.ts — Durable Object with OTel
import { instrumentDO } from "@microlabs/otel-cf-workers";
import { trace } from "@opentelemetry/api";

class RateLimiterBase {
  private state: DurableObjectState;

  constructor(state: DurableObjectState, env: Env) {
    this.state = state;
  }

  async fetch(request: Request): Promise<Response> {
    const tracer = trace.getTracer("rate-limiter-do");

    return tracer.startActiveSpan("rate-limit-check", async (span) => {
      const key = new URL(request.url).searchParams.get("key") ?? "global";
      span.setAttribute("rate_limit.key", key);

      const count = (await this.state.storage.get<number>(key)) ?? 0;
      const limit = 100;

      if (count >= limit) {
        span.setAttribute("rate_limit.exceeded", true);
        span.setStatus({ code: "ERROR", message: "Rate limit exceeded" });
        span.end();
        return new Response("Rate limited", { status: 429 });
      }

      await this.state.storage.put(key, count + 1);
      span.setAttribute("rate_limit.count", count + 1);
      span.end();
      return new Response("OK");
    });
  }
}

// Durable Object 클래스를 OTel로 래핑
export const RateLimiter = instrumentDO(RateLimiterBase, config);

7단계: CI/CD와 알림 통합

GitHub Actions에서 배포 후 자동 검증

# .github/workflows/deploy.yml
- name: Deploy Worker
  run: npx wrangler deploy

- name: Verify Observability (배포 후 트레이스 수집 확인)
  run: |
    sleep 30  # 트레이스 수집 대기
    # Axiom API로 최근 5분 오류 수 확인
    ERRORS=$(curl -s "https://api.axiom.co/v1/datasets/my-workers/query" \
      -H "Authorization: Bearer $AXIOM_TOKEN" \
      -d '{"apl":"[\"my-workers\"] | where status >= 500 | where _time > ago(5m) | count"}' \
      | jq '.matches[0].data._count // 0')

    if [ "$ERRORS" -gt "10" ]; then
      echo "❌ 배포 후 오류 급증: $ERRORS errors in 5min"
      exit 1
    fi
    echo "✅ 배포 후 오류 수 정상: $ERRORS"

Axiom에서 알림 설정

Axiom Monitor로 P99 레이턴시 임계값 초과 시 Slack 알림:

{
  "name": "Workers P99 Latency Alert",
  "query": {
    "apl": "['my-workers'] | where ['span.is_root'] == true | summarize p99 = percentile(duration, 99) by bin(_time, 1m) | where p99 > 2000ms"
  },
  "frequencyMinutes": 5,
  "durationMinutes": 5,
  "notifiers": ["slack-webhook"]
}

벤더 선택 가이드

상황 추천 벤더 이유
초기 스타트업, 예산 절약 Axiom 무료 500GB/월, 로그+트레이스 통합
Grafana 스택 사용 중 Grafana Cloud 기존 대시보드 통합, 무료 50GB
복잡한 쿼리/분석 필요 Honeycomb BubbleUp 이상 감지, 고급 쿼리
온프레미스 요구사항 SigNoz (자체 호스팅) 오픈소스, Kubernetes 배포
단순 로그만 필요 Cloudflare 대시보드 무료, 추가 설정 불필요

비용 최적화 체크리스트

실제 프로덕션에서 적용할 최적화 목록:

샘플링 전략:

데이터 절감:

비용 모니터링:

마이그레이션 경로: wrangler tail에서 OTel로

기존에 wrangler tailconsole.log에만 의존했다면 단계별로 전환하자:

1주차: wrangler.jsoncobservability.traces.enabled: true 추가, Cloudflare 대시보드에서 트레이스 확인
2주차: Axiom Destination 추가, 30일 보존 무료 활용
3주차: 커스텀 스팬으로 핵심 비즈니스 로직 계측
4주차: 샘플링 비율 조정, 알림 설정

기존 console.log는 지우지 않아도 된다. Workers 로그도 observability.logs.enabled: true로 함께 내보낼 수 있다.

결론

Cloudflare Workers OpenTelemetry 지원은 2024년 말 이후 급속히 성숙했다. 네이티브 자동 트레이싱으로 코드 수정 없이 모든 플랫폼 작업이 계측되고, cloudflare:workerstracing.enterSpan() API로 비즈니스 로직을 정밀하게 추가할 수 있다.

비용은 샘플링으로 완전히 통제할 수 있다:

블랙박스 에지 함수에서 완전히 관찰 가능한 분산 시스템으로 전환하는 데 걸리는 시간은 wrangler.jsonc 5줄을 추가하는 1분이다.

관련 글: Cloudflare Workflows와 Durable Execution AI 에이전트에서 Durable Objects의 상태 관리 패턴을 확인할 수 있다.