티켓 예약 시스템으로 배우는 “요구사항 + 관측성”
예약 생성과 조회, 단 두 개의 유스케이스로 풀어보는 기능/비기능 요구사항과 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] 소요 시간 확정
}
}
핵심은 비즈니스 로직(기능)과 관측성 코드(비기능)가 한 함수 안에 공존한다는 점입니다. lockSeat과 repo.save는 기능, span/logger/metrics는 비기능입니다.
1-4. 이 코드에서 3축이 각각 어떻게 찍히나
같은 한 번의 예약 생성이 세 가지 다른 관점으로 기록됩니다. 이게 Observability의 3대 축입니다.
- Logs (무슨 일이) —
"reservation requested","reservation failed"같은 이벤트 기록. 사후에 “그 요청 때 어떤 좌석이었지?”를 찾을 때 본다. - Metrics (얼마나/몇 번) —
reservation.created/reservation.failed카운터. 집계해서 “분당 예약 수”, “실패율” 같은 추세를 본다. - Traces (어떤 경로로) —
reservation.createspan 아래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. 둘의 비율이 캐시 히트율이고, 이게 떨어지면 조회 성능(비기능 요구사항)이 위협받습니다. - Traces —
findOnespan에cache.hit속성을 달면, 느린 조회가 캐시 미스 → DB 직행 때문인지 trace만 보고 판별합니다.
생성 트레이스에서는 좌석 점유(동시성) 가 주인공이었다면, 조회 트레이스에서는 캐시 히트 여부 가 주인공입니다. 유스케이스마다 관측해야 할 것이 다르다는 게 핵심입니다.
마무리 — Monitoring과 Observability, 그리고 성숙도
두 유스케이스를 관통하는 큰 그림을 한 장으로 정리합니다.
| 질문 | 답하는 것 |
|---|---|
| 시스템이 무엇을 하나? | 기능요구사항 (예약 생성·조회) |
| 얼마나 잘 하나? | 비기능요구사항 (성능·동시성·보안·관측성) |
| 문제가 있다는 사실을 아는가? | Monitoring (임계치 알림) |
| 왜 그런지 파고들 수 있는가? | Observability (Logs·Metrics·Traces) |
그리고 관측성은 한 번에 완성되지 않고 성숙도(maturity) 로 자랍니다.
- Monitoring — 정해둔 지표만 감시
- Observability — 임의의 질문을 사후에 던질 수 있음
- Correlation — 메트릭 알람 → 해당 trace → 그 trace의 log로 한 번에 점프
- Proactive — 이상 징후를 미리 탐지·예측
이 작은 두 유스케이스에서도, “예약을 만들고 조회한다”는 기능 위에 성능·동시성·관측성이라는 비기능이 얹히고, Logs·Metrics·Traces가 각 계층(infra/app/business)에서 함께 흐른다는 걸 봤습니다. 기능은 시스템을 동작하게 만들고, 비기능과 관측성은 그 시스템을 신뢰할 수 있게 만듭니다.