경계를 그을 때마다 사각지대가 하나씩 생겼다. 이번 편은 그 사각지대들을 되짚는다.
이 경계는 무엇을 청구하는가
본편의 마지막이지만 회고는 아니고, 앞의 열두 경계를 관측이라는 새 렌즈로 다시 보는 편입니다.
경계를 그으면 그 너머가 보이지 않습니다. 보이지 않는 것은 부작용이 아니라 경계의 정의입니다. 모듈 경계는 내부 구현을 감추고, 네트워크 경계는 상대 프로세스를 감추고, 캐시 경계는 원본 조회를 감춥니다.
문제는 장애가 났을 때 그 감춰진 부분을 봐야 한다는 것입니다.
- 사용자가 “결제가 안 돼요”라고 합니다. 어느 경계에서 멈췄는지 알 수 있어야 합니다.
- 응답이 느립니다. 여섯 서비스 중 어디가 느린지 알 수 있어야 합니다.
- 에러율이 올랐습니다. 어제 배포한 것 때문인지, 외부 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 | 변경 | 옛 경로를 아직 쓰는 곳이 있는가 |
모두 확인을 위해선 관측이 필요합니다.
세 가지 신호
관측 데이터는 셋으로 나뉩니다. 자주 혼동되지만 역할이 다릅니다.
로그(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) 의 트리입니다. 하나의 요청이 루트 스팬이고, 그 안의 각 작업이 자식 스팬입니다. 이 트리를 보면 어느 구간이 얼마나 걸렸는지, 무엇이 병렬로 돌았는지가 한눈에 보입니다.
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나 내부 정보를 인코딩하지 않습니다.
사용자가 볼 수 있어야 하는 상태
에러만이 아닙니다. 앞선 편들에서 다룬 여러 상태가 관측 가능해야 사용자에게 보여줄 수 있습니다.
- ep.05 네트워크 경계 브레이커가 열려 있다 → “약 30초 후 다시 시도해 주세요”
- ep.06 시간의 경계 큐에서 처리 중이다 → “변환 중입니다”
- ep.08 캐시 경계 사본이 낡았다 → “마지막 갱신 5분 전”
- ep.09 일관성 경계 보상이 진행 중이다 → “환불을 처리하고 있습니다”
시스템이 자기 상태를 알지 못하면 사용자에게 정직하게 말할 수 없습니다. 관측은 운영을 위한 도구이면서, 동시에 정직한 UI의 전제 조건입니다.
그래서 이 편을 마지막에 두었습니다. 앞의 열두 경계에서 “사용자에게 이렇게 말해야 합니다”라고 한 문장들은, 시스템이 그 사실을 알고 있을 때만 쓸 수 있습니다.
상태 페이지
장애가 났을 때 사용자가 갈 곳이 필요합니다. 상태 페이지는 그 자체가 관측의 산출물입니다.
자동 갱신되는 상태 페이지와 사람이 쓰는 공지는 다릅니다. 전자는 지표에서 나오고, 후자는 맥락을 담습니다. 둘 다 필요합니다.
- 자동: 결제 API 응답 지연 (p95 4.2초, 정상 0.3초)
- 사람: 결제사 장애로 승인이 지연되고 있습니다. 재시도하지 마시고 기다려 주세요. 중복 결제는 발생하지 않습니다.
두 번째 문장의 마지막 부분은 ep.06 시간의 경계의 멱등성이 있어야 쓸 수 있는 말입니다. 정직한 공지는 시스템이 그렇게 만들어져 있을 때만 가능합니다.
참고 자료
- OpenTelemetry. https://opentelemetry.io/docs/
- Beyer, B. et al. (2016). Site Reliability Engineering. O’Reilly.: SLI·SLO·에러 예산
- Majors, C., Fong-Jones, L., & Miranda, G. (2022). Observability Engineering. O’Reilly.
- web.dev. Core Web Vitals. https://web.dev/articles/vitals
- W3C. Trace Context. https://www.w3.org/TR/trace-context/
참고: 본편은 여기서 끝납니다
열세 개의 경계를 지나왔습니다. 구조·실행·데이터·수명이 있었습니다. 각 경계마다 값이 매겨져 있었고, 설계는 그 값을 알고 선을 긋는 일이었습니다.
남은 질문이 하나 있습니다. 이 열세 개의 선은 누가 그었는가.
다음 편 예고
아키텍처 다이어그램은 조직도를 닮습니다. 시스템의 경계가 팀의 경계를 닮는 이유와, 그 사실을 알고 나서 할 수 있는 일을 다룹니다.