본문으로 건너뛰기

관측 경계와 분산 추적 - 디자인 패턴, 시스템 수준 편 ep.13

경계를 그을 때마다 사각지대가 하나씩 생겼다. 이번 편은 그 사각지대들을 되짚는다.


이 경계는 무엇을 청구하는가

본편의 마지막이지만 회고는 아니고, 앞의 열두 경계를 관측이라는 새 렌즈로 다시 보는 편입니다.

경계를 그으면 그 너머가 보이지 않습니다. 보이지 않는 것은 부작용이 아니라 경계의 정의입니다. 모듈 경계는 내부 구현을 감추고, 네트워크 경계는 상대 프로세스를 감추고, 캐시 경계는 원본 조회를 감춥니다.

문제는 장애가 났을 때 그 감춰진 부분을 봐야 한다는 것입니다.

  • 사용자가 “결제가 안 돼요”라고 합니다. 어느 경계에서 멈췄는지 알 수 있어야 합니다.
  • 응답이 느립니다. 여섯 서비스 중 어디가 느린지 알 수 있어야 합니다.
  • 에러율이 올랐습니다. 어제 배포한 것 때문인지, 외부 API 때문인지 알 수 있어야 합니다.

이 경계가 청구하는 것은 계측 비용입니다. 로그를 남기고, 트레이스를 전파하고, 메트릭을 수집하고, 그것을 저장하고 조회할 시스템을 운영해야 합니다.

이 비용을 내지 않으면 다른 비용을 내야 합니다. 장애 시간이 길어집니다. 원인을 못 찾아 서비스를 재시작하고, 재발합니다.

관측 경계가 답하는 질문은 하나입니다. 경계 너머에서 무슨 일이 있었는지 어떻게 아는가.


이 경계에 붙어 있는 이름들

열두 개의 사각지대

앞선 편들이 만든 사각지대를 나열해 봅니다.

편경계사각지대
ep.01모듈어느 모듈에서 시간이 갔는가
ep.02의존 방향어댑터가 실제로 무엇을 반환했는가
ep.03계약스키마 검증이 언제 얼마나 실패했는가
ep.04실행서버와 클라이언트 중 어디가 느렸는가
ep.05네트워크재시도가 몇 번 있었고 브레이커가 열렸는가
ep.06시간큐에 넣은 일이 결국 처리되었는가
ep.07상태어느 사본을 읽었는가
ep.08캐시이 응답이 캐시 히트인가 미스인가
ep.09일관성Saga가 어디까지 갔고 보상은 성공했는가
ep.10신뢰인가가 거부된 요청이 늘고 있는가
ep.11배포이 사용자는 어느 버전을 봤는가
ep.12변경옛 경로를 아직 쓰는 곳이 있는가

모두 확인을 위해선 관측이 필요합니다.

열두 경계가 만든 사각지대를 보여주는 다이어그램. 좌측에 열두 개의 경계가 세로로 나열되어 있고, 각 경계 우측에는 그 경계가 가리는 영역이 회색 음영으로 표시됩니다. 각 음영 안에는 그 경계 너머에서 알 수 없게 되는 정보가 적혀 있습니다. 모듈 경계는 어느 모듈에서 시간이 갔는지, 네트워크 경계는 재시도 횟수와 브레이커 상태를, 캐시 경계는 히트인지 미스인지를 가립니다. 다이어그램 우측에는 이 사각지대들을 관통하는 하나의 선이 세로로 그려져 있고, 그 선에 correlation ID라는 라벨이 붙어 있습니다. 이 선이 모든 음영을 가로지르며 각 지점에서 정보를 수집하는 모습으로 표현되어, 관측이 경계를 관통하는 유일한 수단임을 나타냅니다.

세 가지 신호

관측 데이터는 셋으로 나뉩니다. 자주 혼동되지만 역할이 다릅니다.

로그(logs) 는 사건의 기록입니다. “무슨 일이 있었는가”에 답합니다. 상세하지만 양이 많고, 특정 요청을 찾으려면 검색이 필요합니다.

메트릭(metrics) 은 집계된 수치입니다. “지금 상태가 어떤가”에 답합니다. 가볍고 오래 보관할 수 있지만, 개별 요청은 알 수 없습니다.

트레이스(traces) 는 하나의 요청이 지나간 경로입니다. “이 요청이 어디서 시간을 썼는가”에 답합니다. 경계를 넘는 흐름을 보는 유일한 방법입니다.

세 가지를 언제 쓰는지가 명확합니다.

  • 에러율이 올랐다는 알람 → 메트릭
  • 어느 구간이 느린지 찾기 → 트레이스
  • 정확히 무슨 일이 있었는지 확인 → 로그

메트릭으로 알아채고, 트레이스로 좁히고, 로그로 확인합니다.

구조화 로깅

문자열 로그는 검색이 어렵습니다. 처음부터 구조를 갖고 남깁니다.

// 나쁨 - 파싱해야 합니다
logger.info(`user ${userId} placed order ${orderId} for ${total} won`)

// 좋음 - 필드로 질의할 수 있습니다
logger.info('order_placed', {
  userId,
  orderId,
  totalAmount: total,
  itemCount: items.length,
  paymentMethod: 'card',
})

두 번째 형태면 totalAmount > 100000 AND paymentMethod = 'card' 같은 질의가 가능합니다.

로그에 넣으면 안 되는 것도 정해 둡니다. 비밀번호, 토큰, 카드번호, 주민번호가 그 예입니다. 실수를 막으려면 마스킹 레이어를 로거에 붙입니다.

const SENSITIVE_KEYS = ['password', 'token', 'cardNumber', 'ssn']

function redact(obj: Record<string, unknown>) {
  return Object.fromEntries(
    Object.entries(obj).map(([k, v]) =>
      SENSITIVE_KEYS.some((s) => k.toLowerCase().includes(s.toLowerCase()))
        ? [k, '[REDACTED]']
        : [k, v],
    ),
  )
}

ep.10 신뢰 경계의 최소 권한이 로그에도 적용됩니다. 로그는 대개 접근 권한이 넓은 곳에 쌓입니다.

Correlation ID

경계를 넘는 흐름을 잇는 방법입니다. 요청 하나에 ID를 붙이고, 그 ID를 모든 경계 너머로 전달합니다.

// 진입점에서 생성하거나 이어받습니다
export function middleware(req: Request) {
  const traceId = req.headers.get('x-trace-id') ?? crypto.randomUUID()

  return contextStorage.run({ traceId }, () => handler(req))
}
// 나가는 호출마다 실어 보냅니다
async function callPaymentService(payload: PaymentPayload) {
  const { traceId } = contextStorage.getStore()

  return fetch(PAYMENT_URL, {
    headers: { 'x-trace-id': traceId },
    body: JSON.stringify(payload),
  })
}
// 로그에는 언제나 포함합니다
logger.info('payment_requested', { traceId, amount })

이 ID 하나로 여섯 서비스의 로그를 한 줄로 꿸 수 있습니다. 없으면 서비스마다 시간대를 맞춰가며 추측해야 합니다.

비동기 경계에서 자주 끊깁니다. ep.06 시간의 경계에서 큐에 넣은 메시지가 나중에 처리될 때, traceId가 메시지에 실려 있지 않으면 흐름이 거기서 끝납니다.

// 메시지에 traceId를 함께 넣습니다
await queue.publish('order.placed', {
  orderId: order.id,
  _meta: { traceId, publishedAt: Date.now() },
})

OpenTelemetry

계측을 표준화한 프로젝트입니다. 이전에는 벤더마다 SDK가 달라서, 관측 도구를 바꾸면 계측 코드를 다시 써야 했습니다.

OpenTelemetry는 계측과 저장소를 분리합니다. 코드는 OTel API로 쓰고, 어디로 보낼지는 설정으로 정합니다.

import { trace } from '@opentelemetry/api'

const tracer = trace.getTracer('checkout-service')

async function placeOrder(input: OrderInput) {
  return tracer.startActiveSpan('placeOrder', async (span) => {
    span.setAttribute('order.itemCount', input.items.length)

    try {
      const result = await processOrder(input)
      span.setAttribute('order.id', result.id)
      return result
    } catch (error) {
      span.recordException(error as Error)
      span.setStatus({ code: SpanStatusCode.ERROR })
      throw error
    } finally {
      span.end()
    }
  })
}

트레이스는 스팬(span) 의 트리입니다. 하나의 요청이 루트 스팬이고, 그 안의 각 작업이 자식 스팬입니다. 이 트리를 보면 어느 구간이 얼마나 걸렸는지, 무엇이 병렬로 돌았는지가 한눈에 보입니다.

correlation ID의 전파 경로와 스팬 트리 다이어그램. 상단 왼쪽은 동기 경로입니다. 브라우저가 traceId를 생성하고 x-trace-id 헤더에 실어 BFF로 보내며, BFF는 이어받아 주문 서비스, 결제 서비스, 재고 서비스 세 곳으로 전파합니다. 상단 오른쪽은 비동기 경로로, 주문 서비스가 큐에 메시지를 넣을 때 _meta에 trace ID를 포함하고 워커가 이어받는 모습이 점선으로 그려지며, 메시지에 안 넣으면 여기서 흐름이 끊긴다는 경고가 붙어 있습니다. 아래에는 수집된 트레이스가 스팬 트리로 그려집니다. 0부터 1200ms까지 300ms 간격의 시간축 위에 루트 스팬 POST /orders가 1180ms로 놓이고, 그 아래에 validateInput 18ms, reserveStock 142ms, chargePayment 742ms, createOrder 278ms가 순서대로 이어집니다. chargePayment 아래에는 타임아웃으로 끝난 attempt 1 300ms와 성공한 attempt 2 412ms가 손자 스팬으로 붙어 있고, 재시도가 있었음을 붉은 점으로 표시합니다. 그 아래 두 박스는 트리를 보면 742ms 중 300ms가 타임아웃 후 재시도였고 병목은 결제 서비스라는 해석과, 메트릭은 알람, 트레이스는 느린 구간 찾기, 로그는 확인이라는 세 신호의 역할을 설명합니다. 맨 아래에는 모든 요청을 추적하면 비용이 크므로 정상 요청은 샘플링하고 에러 요청은 100% 남기며, 요청 ID로 나중에 검색할 수 있어야 하므로 에러 트레이스는 샘플링에서 제외한다는 설명이 있습니다.

SLI와 SLO

무엇을 관측할지 정하는 틀입니다.

SLI(Service Level Indicator) 는 측정하는 지표입니다. “성공한 요청의 비율”, “95번째 백분위 응답 시간” 같은 것입니다.

SLO(Service Level Objective) 는 그 지표의 목표입니다. “성공률 99.9%”, “p95 응답 시간 300ms 이하”처럼 정합니다.

SLO를 정하면 에러 예산(error budget) 이 나옵니다. 99.9%가 목표라면 나머지 0.1% 동안은 실패해도 됩니다. 30일 기준 약 43분입니다.

이 숫자가 유용한 이유는 논쟁을 데이터로 바꾸기 때문입니다. “안정성에 더 투자해야 한다” vs “새 기능을 더 내야 한다”는 대화가, “이번 달 에러 예산을 80% 썼다”로 바뀝니다.

지표를 고를 때는 사용자가 느끼는 것을 재야 합니다.

  • 서버 응답 시간 200ms인데 사용자 체감이 3초 → 클라이언트 렌더링이 문제입니다.
  • 서버 가용성 99.99%인데 사용자는 로그인을 못 함 → 인증 서비스만 죽었습니다.

서버 지표만 보면 사용자가 겪는 것을 놓칩니다.

프론트엔드의 관측

프론트엔드는 관측이 특히 어렵습니다. 코드가 우리 서버가 아니라 사용자 기기에서 돌기 때문입니다.

Error Boundary가 놓치는 것이 많습니다.

<ErrorBoundary fallback={<ErrorScreen />}>
  <App />
</ErrorBoundary>

Error Boundary가 잡는 것은 렌더링 중의 예외뿐입니다. 놓치는 것들이 있습니다.

  • 이벤트 핸들러 안의 예외
  • setTimeout 콜백
  • 비동기 코드의 rejection
  • 서버 사이드 렌더링 중의 에러

그래서 전역 핸들러를 함께 답니다.

window.addEventListener('error', (event) => {
  reportError(event.error, { source: 'window.error' })
})

window.addEventListener('unhandledrejection', (event) => {
  reportError(event.reason, { source: 'unhandledrejection' })
})

이렇게 잡아서 보낸 에러도 그대로는 읽기 어렵습니다. 프로덕션 번들은 압축되어 있어서 스택 트레이스가 a.b.c is not a function처럼 뜻 없는 이름으로만 보입니다. 빌드할 때 만들어지는 소스맵을 관측 도구에 올려두면 원래 파일과 줄 번호로 되돌려 줍니다. 다만 소스맵을 공개 경로에 두면 원본 코드가 노출되므로, 관측 도구에만 업로드하고 공개 배포에서는 제외합니다.

RUM(Real User Monitoring) 은 실사용자의 체감을 재는 것입니다. Core Web Vitals(LCP, INP, CLS)가 대표적인 지표입니다. 합성 테스트(synthetic)는 통제된 환경에서 재고, RUM은 실제 기기와 네트워크에서 잽니다. 둘 다 필요하고, 결과가 다를 때 RUM이 진실에 가깝습니다.

안티패턴

전부 로깅: 모든 함수 입출력을 남기면 비용이 폭증하고, 정작 필요한 로그를 찾기 어려워집니다. 경계에서 남깁니다.

로그 레벨 무시: 전부 info로 남기면 레벨이 의미를 잃습니다.

샘플링 없는 트레이스: 모든 요청을 추적하면 비용이 큽니다. 정상 요청은 샘플링하고, 에러가 난 요청은 100% 남깁니다.

알람 피로: 알람이 하루 200개 오면 아무도 보지 않습니다. 사람이 지금 행동해야 하는 것만 알람으로 보냅니다. 나머지는 대시보드에 둡니다.


이 경계가 화면에 드러나는 자리

관측은 내부 도구처럼 보이지만, 에러 화면이라는 자리에서 사용자에게 드러납니다.

에러 ID의 설계

ep.10 신뢰 경계에서 “사용자에게는 요청 ID를, 로그에는 전부”라고 했습니다. 그 ID를 어떻게 만들고 보여줄지가 이 편의 몫입니다.

문제가 발생했습니다

요청을 처리하지 못했습니다. 잠시 후 다시 시도해 주세요.
문제가 계속되면 아래 번호와 함께 문의해 주세요.

요청 ID: 7f3a-2b81                              [복사]

[다시 시도]    [고객센터 문의]

설계 시 고려할 것이 있습니다.

읽을 수 있어야 합니다. UUID 전체(36자)를 전화로 불러 주기는 어렵습니다. 앞 8자 정도로 줄이거나, 혼동되는 문자(0/O, 1/l)를 뺀 문자셋을 씁니다.

// 사람이 읽기 쉬운 짧은 ID
const ALPHABET = '23456789ABCDEFGHJKMNPQRSTVWXYZ'   // 0,O,1,I,L,U 제외

function shortId(traceId: string): string {
  const hash = simpleHash(traceId)
  return Array.from({ length: 8 }, (_, i) =>
    ALPHABET[(hash >> (i * 4)) % ALPHABET.length],
  ).join('').replace(/(.{4})/, '$1-')
}

복사할 수 있어야 합니다. 사용자가 손으로 옮겨 적게 하면 오타가 납니다. 복사 버튼 하나가 문의 처리 시간을 크게 줄입니다.

추적 가능해야 합니다. 이 ID로 로그를 검색했을 때 전체 흐름이 나와야 합니다. 그러려면 에러가 난 요청은 샘플링에서 제외되어야 하고, 보관 기간이 문의가 들어오는 기간보다 길어야 합니다.

그리고 아무런 정보도 흘리지 않아야 합니다. ID 자체에 사용자 ID나 내부 정보를 인코딩하지 않습니다.

에러 화면의 정보 설계 3단계 다이어그램. 좌측에는 나쁜 예 두 가지가 배치되어 있습니다. 첫 번째는 스택 트레이스와 내부 파일 경로가 그대로 노출된 화면이고, 두 번째는 오류가 발생했다는 문장 하나만 있는 화면입니다. 전자는 공격자에게 지도를 주는 것이고 후자는 사용자에게 아무 도움이 되지 않는다는 설명이 각각 붙어 있습니다. 우측에는 좋은 예가 배치되어 있습니다. 문제 발생 안내 문장, 다시 시도 안내, 짧고 읽기 쉬운 요청 ID와 복사 버튼, 그리고 다시 시도와 고객센터 문의 두 개의 버튼으로 구성된 화면입니다. 하단에는 에러 ID 설계 시 고려할 네 가지가 정리되어 있습니다. 읽을 수 있을 것, 복사할 수 있을 것, 그 ID로 로그를 추적할 수 있을 것, 그리고 ID 자체가 내부 정보를 흘리지 않을 것입니다. 각 항목에는 구체적인 구현 방법이 함께 적혀 있습니다.

사용자가 볼 수 있어야 하는 상태

에러만이 아닙니다. 앞선 편들에서 다룬 여러 상태가 관측 가능해야 사용자에게 보여줄 수 있습니다.

시스템이 자기 상태를 알지 못하면 사용자에게 정직하게 말할 수 없습니다. 관측은 운영을 위한 도구이면서, 동시에 정직한 UI의 전제 조건입니다.

그래서 이 편을 마지막에 두었습니다. 앞의 열두 경계에서 “사용자에게 이렇게 말해야 합니다”라고 한 문장들은, 시스템이 그 사실을 알고 있을 때만 쓸 수 있습니다.

상태 페이지

장애가 났을 때 사용자가 갈 곳이 필요합니다. 상태 페이지는 그 자체가 관측의 산출물입니다.

자동 갱신되는 상태 페이지와 사람이 쓰는 공지는 다릅니다. 전자는 지표에서 나오고, 후자는 맥락을 담습니다. 둘 다 필요합니다.

  • 자동: 결제 API 응답 지연 (p95 4.2초, 정상 0.3초)
  • 사람: 결제사 장애로 승인이 지연되고 있습니다. 재시도하지 마시고 기다려 주세요. 중복 결제는 발생하지 않습니다.

두 번째 문장의 마지막 부분은 ep.06 시간의 경계의 멱등성이 있어야 쓸 수 있는 말입니다. 정직한 공지는 시스템이 그렇게 만들어져 있을 때만 가능합니다.


참고 자료


참고: 본편은 여기서 끝납니다

열세 개의 경계를 지나왔습니다. 구조·실행·데이터·수명이 있었습니다. 각 경계마다 값이 매겨져 있었고, 설계는 그 값을 알고 선을 긋는 일이었습니다.

남은 질문이 하나 있습니다. 이 열세 개의 선은 누가 그었는가.


다음 편 예고

ep.14 - 부록: 조직 경계와 콘웨이의 법칙

아키텍처 다이어그램은 조직도를 닮습니다. 시스템의 경계가 팀의 경계를 닮는 이유와, 그 사실을 알고 나서 할 수 있는 일을 다룹니다.