티켓 예약 시스템의 관측성(Observability)

티켓 예약 시스템으로 배우는 “요구사항 + 관측성”

예약 생성과 조회, 단 두 개의 유스케이스로 풀어보는 기능/비기능 요구사항과 Observability 코드는 TypeScript + NestJS 기준이며, 관측성 계측은 라이브러리에 얽매이지 않는 의사코드 수준으로 표현합니다.


0. 들어가며 — “되게 만들기”는 절반일 뿐

티켓 예약 시스템을 만든다고 하면 보통 이렇게 생각합니다.

“예약을 생성하고, 조회할 수 있으면 되는 거 아니야?”

맞습니다. 그건 기능요구사항(Functional Requirement) 입니다. 시스템이 무엇을 하는가죠. 하지만 실제 운영에 들어가면 질문이 바뀝니다.

  • 인기 콘서트 오픈 순간 1만 명이 같은 좌석을 누르면?
  • “예약이 안 돼요”라는 문의가 왔는데, 우리 잘못인지 결제사 잘못인지 어떻게 알지?
  • 조회가 평소보다 느려진 걸 우리가 먼저 알 수 있을까, 고객이 먼저 알까?

이 질문들의 답이 비기능요구사항(Non-functional Requirement) 이고, 그중에서도 “지금 시스템 안에서 무슨 일이 벌어지는지 알 수 있는가”가 관측성(Observability) 입니다.

이 글에서는 예약 생성예약 조회 딱 두 개의 유스케이스만 가지고, 기능과 비기능 요구사항이 어떻게 맞물리고, 코드에 관측성이 어떻게 스며드는지 보겠습니다.


Part 1. 유스케이스 ① 예약 생성 (POST /reservations)

1-1. 기능요구사항 — 시스템이 무엇을 하는가

  • 사용자는 특정 공연(event)의 특정 좌석(seat)을 예약할 수 있다
  • 이미 점유된 좌석은 중복 예약될 수 없다
  • 예약이 성공하면 예약 ID와 상태(CONFIRMED)를 돌려준다

→ 전부 됨/안 됨으로 판정되는 동작입니다.

1-2. 비기능요구사항 — 얼마나 잘 하는가

분류 요구사항 예시
성능 예약 생성 p95 응답시간 300ms 이하
동시성 같은 좌석에 대한 동시 요청에서 단 1건만 성공
보안 인증된 사용자만, 본인 명의로만 예약
관측성 예약 실패율·소요 시간을 실시간으로 관측, 실패 요청은 원인 추적 가능

💡 구분 팁: “좌석을 예약한다”에서 부사를 빼도 기능이 성립하면 기능요구사항, “중복 없이“, “300ms 안에” 같은 수식이 핵심이면 비기능요구사항입니다.

1-3. 코드 조각 — 생성 로직에 관측성 심기

// reservation.service.ts (NestJS)
async create(dto: CreateReservationDto) {
  const span = tracer.startSpan('reservation.create');           // [Trace] 루트 span 시작
  logger.info('reservation requested', {                          // [Log] 무슨 일이 일어났나
    eventId: dto.eventId, seatId: dto.seatId, userId: dto.userId,
  });

  try {
    const seat = await this.lockSeat(dto.seatId);                 // 좌석 점유 (자식 span)
    const reservation = await this.repo.save(seat, dto.userId);   // DB 저장 (자식 span)

    metrics.increment('reservation.created');                     // [Metric] 비즈니스 지표
    return reservation;
  } catch (e) {
    metrics.increment('reservation.failed');                      // [Metric] 실패 카운트
    logger.error('reservation failed', { seatId: dto.seatId, err: e.message });
    span.recordException(e);                                      // [Trace] 어느 span에서 터졌나
    throw e;
  } finally {
    span.end();                                                   // [Trace] 소요 시간 확정
  }
}

핵심은 비즈니스 로직(기능)과 관측성 코드(비기능)가 한 함수 안에 공존한다는 점입니다. lockSeatrepo.save는 기능, span/logger/metrics는 비기능입니다.

1-4. 이 코드에서 3축이 각각 어떻게 찍히나

같은 한 번의 예약 생성이 세 가지 다른 관점으로 기록됩니다. 이게 Observability의 3대 축입니다.

  • Logs (무슨 일이)"reservation requested", "reservation failed" 같은 이벤트 기록. 사후에 “그 요청 때 어떤 좌석이었지?”를 찾을 때 본다.
  • Metrics (얼마나/몇 번)reservation.created / reservation.failed 카운터. 집계해서 “분당 예약 수”, “실패율” 같은 추세를 본다.
  • Traces (어떤 경로로)reservation.create span 아래 lockSeat, repo.save가 자식으로 매달려 전체 흐름과 각 단계 소요 시간을 본다.

1-5. Tracing 흐름 — 예약 생성 요청이 거치는 길

예약 생성은 보통 여러 단계(혹은 여러 서비스)를 거칩니다. 하나의 Trace ID 아래 각 단계가 Span으로 기록됩니다.

[Trace ID: rsv-abc-123]  총 280ms
│
├─ Span: reservation.create            (280ms)
│   ├─ Span: lockSeat (좌석 점유)        (200ms)  ← 동시성 처리로 가장 오래 걸림
│   └─ Span: repo.save (DB 저장)         (60ms)
│
└─ Span: payment 호출 (외부 PG, 비동기)   (별도 trace로 전파)

“예약 생성이 느려요”라는 문의가 오면, 이 트레이스를 열어 280ms 중 200ms가 좌석 점유 단계임을 한눈에 봅니다. 즉, 느림의 원인이 DB가 아니라 동시성 잠금(lock)이라는 걸 추측이 아니라 데이터로 짚어냅니다.

1-6. Monitoring 포인트 — 계층별로 하나씩

Monitoring은 미리 정한 지표가 임계치를 넘는지 감시하는 것입니다. 계층(infra/app/business)별로 잡아봅니다.

  • Infra: 예약 서버 CPU > 80% 5분 지속 → 알림
  • Application: 예약 생성 HTTP 5xx 비율 > 1% → 알림
  • Business: reservation.failed 비율 > 5% → 결제사/재고 장애 의심 알림

📌 Monitoring vs Observability: 위 알림이 “실패율이 5%를 넘었다!”고 알려주면(Monitoring), 그 느린/실패한 요청의 trace와 log로 들어가 왜 그런지 파고드는 것(Observability) 입니다.


Part 2. 유스케이스 ② 예약 조회 (GET /reservations/:id)

2-1. 기능요구사항

  • 사용자는 예약 ID로 예약 상세를 조회할 수 있다
  • 존재하지 않는 예약 ID는 404를 반환한다
  • 본인 예약만 조회할 수 있다

2-2. 비기능요구사항 — 조회는 성능·캐싱 중심

생성과 달리 조회는 읽기가 압도적으로 많고 반복적입니다. 그래서 비기능의 무게중심이 다릅니다.

분류 요구사항 예시
성능 조회 p95 응답시간 100ms 이하
확장성 캐시를 통해 DB 부하 없이 읽기 트래픽 흡수
관측성 캐시 히트율을 관측해 캐시 효율 저하를 조기 감지

2-3. 코드 조각 — 조회 로직에 관측성 심기

// reservation.service.ts (NestJS)
async findOne(id: string) {
  const span = tracer.startSpan('reservation.findOne');          // [Trace]

  const cached = await this.cache.get(id);                       // 캐시 조회 (자식 span)
  if (cached) {
    metrics.increment('reservation.cache_hit');                  // [Metric] 캐시 히트
    span.setAttribute('cache.hit', true);                        // [Trace] 속성 기록
    span.end();
    return cached;
  }

  metrics.increment('reservation.cache_miss');                   // [Metric] 캐시 미스
  const found = await this.repo.findById(id);                    // DB 조회 (자식 span)
  if (!found) {
    span.end();
    throw new NotFoundException();                               // 기능요구사항: 없으면 404
  }

  await this.cache.set(id, found);
  span.end();
  return found;
}

2-4. 3축 적용 + 생성과 무엇이 다른가

같은 조회 한 번이 또 세 관점으로 남습니다 — 다만 관심사가 바뀝니다.

  • Logs — 조회는 양이 많아 매 건 info 로그를 남기면 비용이 큽니다. 보통 404·권한 오류 같은 이상 케이스만 로그로 남깁니다.
  • Metrics — 핵심은 cache_hit / cache_miss. 둘의 비율이 캐시 히트율이고, 이게 떨어지면 조회 성능(비기능 요구사항)이 위협받습니다.
  • TracesfindOne span에 cache.hit 속성을 달면, 느린 조회가 캐시 미스 → DB 직행 때문인지 trace만 보고 판별합니다.

생성 트레이스에서는 좌석 점유(동시성) 가 주인공이었다면, 조회 트레이스에서는 캐시 히트 여부 가 주인공입니다. 유스케이스마다 관측해야 할 것이 다르다는 게 핵심입니다.


마무리 — Monitoring과 Observability, 그리고 성숙도

두 유스케이스를 관통하는 큰 그림을 한 장으로 정리합니다.

질문 답하는 것
시스템이 무엇을 하나? 기능요구사항 (예약 생성·조회)
얼마나 잘 하나? 비기능요구사항 (성능·동시성·보안·관측성)
문제가 있다는 사실을 아는가? Monitoring (임계치 알림)
그런지 파고들 수 있는가? Observability (Logs·Metrics·Traces)

그리고 관측성은 한 번에 완성되지 않고 성숙도(maturity) 로 자랍니다.

  1. Monitoring — 정해둔 지표만 감시
  2. Observability — 임의의 질문을 사후에 던질 수 있음
  3. Correlation — 메트릭 알람 → 해당 trace → 그 trace의 log로 한 번에 점프
  4. Proactive — 이상 징후를 미리 탐지·예측

이 작은 두 유스케이스에서도, “예약을 만들고 조회한다”는 기능 위에 성능·동시성·관측성이라는 비기능이 얹히고, Logs·Metrics·Traces가 각 계층(infra/app/business)에서 함께 흐른다는 걸 봤습니다. 기능은 시스템을 동작하게 만들고, 비기능과 관측성은 그 시스템을 신뢰할 수 있게 만듭니다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다