TypeScript 네이티브 관측성 구축하기: Async Context 및 실행 흐름
요약
TypeScript 환경에서 AI 에이전트의 실행 흐름을 추적하기 위한 관측성(Observability) 구축 방법을 다룹니다. AsyncLocalStorage를 활용하여 비동기 컨텍스트를 관리하고, 단순 완료 순서가 아닌 인과 관계를 나타내는 실행 트리 구조를 설계하는 핵심 메커니즘을 설명합니다.
핵심 포인트
- 단순 타임스탬프 목록이 아닌 인과 관계를 나타내는 트리 구조의 트레이싱 필요성
- Node.js의 AsyncLocalStorage를 활용한 비동기 컨텍스트 전파 방법
- 병렬 실행 시 경합을 방지하기 위한 불변(Immutable) 컨텍스트 설계
- 스팬(Span)의 시작과 종료 이벤트를 분리한 이벤트 모델 정의
유용한 에이전트 트레이스(agent trace)는 단순히 타임스탬프의 목록이 아닙니다. 그것은 인과 관계를 나타내는 트리(causal tree)입니다.
TypeScript 에이전트가 문서를 병렬로 검색하고, 모델을 호출하며, 도구(tool)를 재시도하고, 캐시된 데이터로 폴백(fallback)할 때, 각 작업에는 트레이스 ID(trace ID), 고유한 스팬 ID(span ID), 그리고 올바른 부모 스팬(parent span)이 필요합니다. 이러한 관계가 없다면, 완료 순서(completion order)를 실행 구조(execution structure)로 오해하기 쉽습니다.
이 글에서는 불변의 비동기 컨텍스트(immutable async context), 부모-자식 스팬(parent-child spans), 신뢰할 수 있는 마무리(finalization), 그리고 플러그형 싱크(pluggable sink)와 같은 핵심 메커니즘을 보여주기 위해 작은 Node.js 트레이서(tracer)를 구축합니다. 이는 의도적으로 프로덕션급 관측성(observability) 라이브러리보다 작게 설계되었지만, 그 디자인은 최소한의 예제에서 흔히 발견되는 몇 가지 실수들을 피하고 있습니다.
완료 순서는 인과 관계가 아니다
세 개의 도구가 병렬로 실행된다고 가정해 봅시다:
80 ms search_tickets 완료
100 ms load_account 완료
120 ms search_docs 완료
이 타임스탬프들은 완료 순서를 설명합니다. 실행 트리(execution tree)는 왜 해당 작업들이 존재했는지를 설명합니다:
research_agent
└─ parallel_retrieval
├─ search_docs
...
두 관점 모두 유용하지만, 에이전트의 결정과 그 자식 도구들 사이의 관계를 보존하는 것은 오직 트리뿐입니다.
일반적인 JavaScript 호출 스택(call stack)은 해당 트리 역할을 할 수 없습니다. 비동기 작업은 나중에 재개되거나, 동시에 실행되거나, 작업을 예약한 함수보다 더 오래 지속될 수 있습니다. 따라서 트레이싱(tracing)에는 명시적인 논리적 컨텍스트(logical context)가 필요합니다.
우리에게 필요한 컨텍스트
각 비동기 분기(asynchronous branch)에는 두 가지 값이 필요합니다:
type TraceContext = {
traceId: string;
parentSpanId: string | null;
...
새로운 스팬이 시작될 때, 현재 컨텍스트를 읽어 parentSpanId를 기록하고, 자체적인 spanId를 생성하며, 해당 스팬을 부모로 하는 새로운 컨텍스트 내부에서 자식 작업을 실행합니다.
Node.js에서는 AsyncLocalStorage가 전파 프리미티브(propagation primitive)를 제공합니다. 이는 모든 애플리케이션 함수에 트레이스 파라미터를 추가하지 않고도 일반적인 비동기 리소스를 통해 값을 전달합니다.
하나의 공유된 컨텍스트 객체를 변형(mutate)하지 마세요. 병렬로 실행되는 형제(siblings) 프로세스들이 현재 스팬(span)을 교체하기 위해 경합(race)을 벌이게 됩니다. 대신, 모든 중첩된 스팬(nested span)에 대해 새로운 컨텍스트 값을 생성하세요.
작은 이벤트 모델 정의하기
소비자(consumer)가 완료되지 않은 스팬(span)을 식별할 수 있도록 별도의 시작(start) 및 종료(end) 이벤트를 사용하세요.
type SpanKind = 'run' | 'model' | 'tool' | 'retrieval' | 'decision';
type SpanStatus = 'ok' | 'error';
...
스키마는 예외 메시지(exception message)나 스택(stack) 대신 제어된 에러 카테고리를 저장합니다. 프로덕션 설계에서는 메타데이터(metadata) 또한 작업별로 특화되어야 하지만, 이 예제에서는 가독성을 위해 평면적인 레코드(flat record) 형식을 유지합니다.
싱크(Sink) 뒤에 영속성 유지하기
트레이서(tracer)가 전역 이벤트 배열을 소유해서는 안 됩니다. 장시간 실행되는 프로세스는 모든 트레이스(trace)를 메모리에 유지하게 되며, 테스트 시 관련 없는 실행에서 발생한 이벤트를 실수로 읽을 수 있습니다.
대신 싱크 계약(sink contract)을 사용하세요:
export interface TraceSink {
enqueue(event: SpanEvent): boolean;
flush(options?: { timeoutMs?: number }): Promise<void>;
...
enqueue()는 의도적으로 동기적(synchronous)이며 예외를 던지지(non-throwing) 않습니다. 이 메서드는 이벤트를 제한된 버퍼(bounded buffer)에 배치하며, 이벤트를 수락할 수 없을 때 false를 반환합니다. 싱크(sink)는 애플리케이션의 크리티컬 패스(critical path) 외부에서 배치(batching) 및 영속성(persistence)을 처리합니다.
프로덕션용 싱크(sink)는 수락됨(accepted), 누락됨(dropped), 재시도됨(retried), 실패함(failed) 이벤트 수를 노출해야 합니다. “트레이싱이 요청을 중단시켜서는 안 된다(Tracing must not crash the request)”라는 원칙이 “트레이싱이 소리 없이 실패해도 된다(tracing may fail silently)”로 변질되어서는 안 됩니다.
트레이서(Tracer) 구축하기
아래의 트레이서는 AsyncLocalStorage를 사용하여 컨텍스트(context)의 범위를 지정하고, finally 블록에서 하나의 종료 이벤트를 방출하며, 싱크(sink)의 실패를 애플리케이션의 실패와 분리하여 유지합니다.
import { AsyncLocalStorage } from 'node:async_hooks';
import { randomUUID } from 'node:crypto';
...
애플리케이션 예외(exception)는 변경 없이 다시 던져집니다(rethrown). 싱크(sink) 문제는 애플리케이션 작업을 분류하는 try 블록에 절대 진입하지 않으므로, 관측성(observability)의 실패가 성공적인 모델 호출을 실패한 모델 스팬(model span)으로 만들 수 없습니다.
메타데이터 콜백(metadata callback)은 스팬(span)이 종료될 때 실행되며, 이는 토큰 사용량(token usage)이나 결과 수(result counts)를 시작 시점에 알 수 없을 때 유용합니다. 콜백은 결정론적(deterministic)이어야 하며 페이로드(payload)를 포함하지 않도록 유지하세요.
병렬 작업 추적 (Trace Parallel Work)
아래의 세 가지 작업은 완료되는 시점이 서로 다름에도 불구하고 모두 parallel_retrieval 스팬을 부모로 상속받습니다.
await tracer.run('research_agent', async () => {
return tracer.span('parallel_retrieval', 'decision', async () => {
const [documents, tickets, account] = await Promise.all([
...
span()을 호출할 때마다 각자의 프로미스 체인(promise chain)을 위한 새로운 불변 컨텍스트(immutable context)가 생성됩니다. 형제 브랜치(sibling branches)들은 공유된 현재 스팬(current-span) 값을 절대 변경(mutate)하지 않습니다.
재시도 가시화하기 (Make Retries Visible)
재시도(retry)는 덮어쓰는 시도 횟수(attempt count)가 아니라 자식 스팬(child span)이어야 합니다. 그래야 모든 시도의 지속 시간(duration)과 에러 카테고리(error category)를 보존할 수 있습니다.
await tracer.span('load_pricing', 'tool', async () => {
try {
return await tracer.span('attempt_1', 'tool', () => {
...
load_pricing
├─ attempt_1 error: timeout
├─ attempt_2 error: dependency
...
실제 애플리케이션 코드에서는 재시도(retry)나 폴백(fallback)을 트리거해야 하는 에러만 포착(catch)하세요. 인증(authentication), 유효성 검사(validation), 그리고 취소(cancellation) 에러는 보통 별도의 처리가 필요합니다.
함수 타입 보존하기 (Preserve Function Types)
래퍼(wrapper)는 인자(argument)와 결과(result) 타입을 유지해야 합니다.
type AsyncFn<Args extends unknown[], Result> = (
...args: Args
) => Promise<Result>;
...
this에 의존하는 메서드, 오버로딩된 함수(overloaded functions), 그리고 스트림(streams)은 특화된 래퍼가 필요합니다. 이러한 어댑터(adapters)들을 any를 사용하여 시그니처(signature)를 지워버리는 대신 명시적으로 유지하세요.
분리된 작업 및 스트리밍 작업 명시적 처리 (Handle Detached and Streaming Work Explicitly)
비동기 컨텍스트(Async context)는 "이 작업이 어떤 트레이스(trace)에 속하는가?"라는 질문에 답합니다. 트레이스가 얼마나 오랫동안 열려 있어야 하는지를 결정하지는 않습니다.
await를 하지 않은 백그라운드 작업(background task)과 같이 분리된 작업(detached work)은 루트 스팬(root span)이 종료된 후에도 계속될 수 있습니다. 스트리밍 작업(streaming work)은 라우트 핸들러(route handler)가 응답을 반환한 후에도 계속될 수 있습니다. 두 경우 모두 명시적인 라이프사이클 정책(lifecycle policy)이 필요합니다.
- 요청의 성공 기준(success criteria)의 일부인 작업(Await work)을 기다립니다.
- 내구성이 있는 백그라운드 작업(durable background job)을 위해 새로운 연결된 트레이스(linked trace)를 시작합니다.
- 완료, 오류 또는 취소 시 스트리밍 스팬(streaming span)을 종료합니다.
- 모든 이벤트 직후가 아니라, 제어된 라이프사이클 경계(lifecycle boundaries)에서 플러시(Flush)합니다.
- 서버리스 런타임(serverless runtimes)이 응답을 보낸 후에도 계속 실행될 것이라고 가정하지 마십시오.
컨텍스트 전파(Context propagation)와 라이프사이클 관리(lifecycle management)는 서로 관련되어 있지만, 동일한 문제는 아닙니다.
오버헤드 제어 및 백프레셔 (Control Overhead and Backpressure)
모든 헬퍼 함수(helper function)가 아닌 의미 있는 경계를 트레이스(Trace)하십시오. 에이전트 실행(Agent runs), 검색(retrieval), 모델 호출(model calls), 도구(tools), 정책 결정(policy decisions), 재시도(retries) 및 폴백(fallbacks)은 보통 충분한 구조를 제공합니다.
유계 큐(bounded queue)를 사용하십시오. 싱크(sink)가 이벤트 생성자(event producer)보다 느릴 경우, 다음과 같은 정책을 선택하고 문서화하십시오: 최신 항목 드롭(drop newest), 오래된 항목 드롭(drop oldest), 제한된 백프레셔(backpressure) 적용, 또는 해당 실행에 대한 트레이싱 비활성화. 유계되지 않은 버퍼(unbounded buffer)가 프로세스를 점유하도록 절대 방치하지 마십시오.
샘플링(Sampling)은 일반적으로 트레이스(trace) 수준에서 수행되어야 합니다. 자식 스팬(child spans)을 독립적으로 샘플링하면 끊어진 트리(broken trees)가 생성됩니다. 선택된 실행에 대해서는 완전한 트레이스를 유지하고, 문서화된 정책을 통해 오류나 높은 지연 시간의 이상치(outliers)를 항상 보존하는 것을 고려하십시오.
실행 모델 테스트 (Test the Execution Model)
트레이서(tracer)는 동시성(concurrency) 및 실패 테스트를 통과해야만 올바른 것으로 간주됩니다:
- 두 개의 트레이스를 동시에 실행하고 스팬 ID(span IDs)가 절대 섞이지 않음을 확인합니다.
- 세 개의
Promise.all()형제(siblings)를 시작하고 예상된 부모(parent)를 공유하는지 확인합니다. - 애플리케이션 작업에서 오류를 발생시키고(throw) 원래의 오류가 호출자(caller)에게 도달하는지 확인합니다.
- 싱크(sink)가 거부(reject)하거나 오류를 던지도록 강제하고 애플리케이션 결과가 변하지 않는지 확인합니다.
- 스트림(stream)을 취소하고 스팬이 취소 상태(cancellation status)와 함께 한 번만 종료되는지 확인합니다.
- 싱크를 플러시(Flush)하고 시작된 모든 스팬이 정확히 하나의 종료 이벤트(end event)를 갖는지 확인합니다.
- 병렬 워커(parallel workers)에서 테스트를 실행하고 격리(isolation)를 확인합니다.
또한 방출된 데이터 정책(emitted data policy)을 테스트하십시오. 프롬프트(prompts), 출력(outputs), 도구 인자(tool arguments) 및 검색된 콘텐츠(retrieved content)가 없더라도 실행 트리(execution trees)는 가치가 있습니다.
OpenTelemetry와의 관계 (Relationship to OpenTelemetry)
이러한 개념들은 분산 트레이싱 (Distributed Tracing)에 자연스럽게 매핑됩니다. 즉, 하나의 실행 (run)은 트레이스 (trace)가 되고, 하나의 작업 (operation)은 스팬 (span)이 되며, 메타데이터는 속성 (attributes)이 되고, 컨텍스트 전파 (context propagation)는 부모 관계를 보존합니다. 실제 프로덕션 구현에서는 이 이벤트 모델을 OpenTelemetry 또는 다른 백엔드로 변환할 수 있습니다.
먼저 작은 규모의 버전을 구축하는 것도 여전히 유용합니다. 이를 통해 불변 컨텍스트 (immutable context), 스팬당 하나의 부모, 정확히 한 번의 종료 (finalization), 제한된 지속성 (bounded persistence), 그리고 싱크 (sink)의 상태와 무관한 애플리케이션 동작과 같은 불변량 (invariants)을 가시화할 수 있습니다.
마지막 생각 (Final Thought)
TypeScript 에이전트 트레이싱 (agent tracing)에서 어려운 부분은 ID를 생성하는 것이 아닙니다. 비동기 작업이 분기되고, 순서에 상관없이 완료되며, 재시도(retry)되고, 스트리밍(stream)되며, 때로는 원래의 요청보다 더 오래 지속되는 동안 인과 구조 (causal structure)를 보존하는 것이 핵심입니다.
AsyncLocalStorage는 강력한 Node.js 기반을 제공하지만, 신뢰할 수 있는 관측성 (observability)을 위해서는 라이프사이클 규칙, 싱크 격리 (sink isolation), 백프레셔 (backpressure), 그리고 동시성 테스트 (concurrency tests)도 필요합니다. 이러한 요소들이 갖춰지면, 흩어져 있던 이벤트들은 개발자가 신뢰할 수 있는 실행 트리 (execution tree)가 됩니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기