- 교시: 1교시 강의 (09:00~09:50) · 2교시 [Lab 01](../../Last3Mon/72/LAB-01-Q-MEASURE-BOTTLENECK.md) 실습 5개 (10:00~10:50)
- 이 단계가 끝나면: 고치기 전에 응답 시간을 p50 · p95로 재고, 워크플로우가 단계별 시간을 결과에 남기게 해 병목을 숫자로 찾고, 시간 예산을 정해 넘었는지 기록할 수 있다.
- 숫자: 모두 강사 노트북 실측입니다 (65일차 n8n 2.42.6, OpenAI `gpt-4.1-mini`). 10/18은 수업 준비, 10/24는 실습을 5개로 늘린 뒤 다시 잰 값입니다. 출력 전부는 수업 뒤 공개되는 `Last3Mon/72/answers/LAB-TEST-RESULT.md`에 있습니다.
- 실습 가리키기: 이 문서에서 "Lab 01 예제 1"은 2교시 [Lab 01 문서](../../Last3Mon/72/LAB-01-Q-MEASURE-BOTTLENECK.md)의 그 절입니다. 파일 경로(`cases/` · `requests/` · `my-work/` · `reference/`)는 실습 폴더 `~/aio-01d4-stu/Last3Mon/72` 기준입니다.
```mermaid
flowchart LR
A["1. 재기 전에<br/>고치지 않기"] --> B["2. 시간을 말하는 말<br/>p50 · p95 · max"] --> C["3. 첫 호출"] --> D["4. 어디서 재나"] --> E["5. 단계별 시간"]
| 바꾸기 | 그 단계를 줄이거나 건너뛸 수 있나? | 2단계 (규칙 · 캐시 · 검사 코드) |
| 다시 재기 | 빨라졌나? **답은 그대로인가?** | `bench` + 원래 테스트 |
짐작과 실측이 다른 예 (오늘 실습):
| 짐작 | 실측 (10/24) | 그래서 |
| --- | --- | --- |
| "사내 데이터 API가 느릴 것" | FAQ · 접수 조회를 합쳐 **18~43ms** | 데이터 API를 빠르게 해도 거의 그대로 |
| "IT와 상관없는 질문은 금방 끝날 것" | 점심 메뉴 질문도 **2.48초** (LLM 세 번) | 건너뛸 단계가 있다 (2단계) |
| "push 하면 바로 평소 속도" | push 직후 첫 호출 **4.4초** (10/18 6.5초) | 첫 호출은 따로 적는다 |
## 2. 시간을 말하는 말: 평균 · p50 · p95 · max
### 일반 설명
응답 시간은 거의 늘 **한쪽으로 긴 꼬리**를 가집니다. 대부분은 비슷하게 빠르고, 가끔 아주 느린 것이 섞입니다. 까닭은 여러 가지입니다: 네트워크가 잠깐 막힘, 서버가 다른 일로 바쁨(대기열), 메모리 정리(GC), LLM이 평소보다 긴 답을 씀.
- 이런 분포에서 **평균**은 꼬리에 끌려가 "보통"도 "느린 쪽"도 말해 주지 못합니다. 그래서 업계는 **백분위(percentile)** 로 말합니다: p50(중앙값), p90 · p95 · p99(느린 쪽).
- 꼬리가 중요한 까닭: 한 화면이 서비스를 여러 번 부르면 그중 하나만 느려도 화면이 느립니다. 예를 들어 한 번 부를 때 1%가 느린 서비스를 100번 부르는 화면은 1 − 0.99¹⁰⁰ ≈ **63%** 가 느린 호출을 하나 이상 만납니다 (계산 예). 큰 서비스가 p99 · p99.9까지 보는 까닭입니다.
- 백분위를 세는 방법은 도구마다 조금 다릅니다 (가장 가까운 자리를 고르는 nearest-rank, 두 자리 사이를 나누는 보간). 오늘은 nearest-rank: 작은 것부터 줄 세우고 **올림(비율 × 개수)번째**. 숫자를 비교할 때는 같은 도구 · 같은 방법으로 잰 것끼리.
- 개수가 적으면 백분위가 흔들립니다. p95를 믿으려면 적어도 수십 번, p99는 수백 번이 필요합니다.
### 오늘 실습에서
같은 요청을 여러 번 부르면 시간이 매번 조금씩 다릅니다. 그 목록을 한 숫자로 말하는 방법이 여럿입니다.
| 말 | 뜻 | 계산 (시간을 작은 것부터 줄 세움) |
| --- | --- | --- |
| 평균 | 다 더해서 개수로 나눔 | 한두 번 아주 느리면 크게 끌려감 |
| **p50** (가운데) | "보통 이 정도" | 줄의 가운데 |
| **p95** (느린 쪽) | 100번 중 95번은 이보다 빠름 | 앞에서 95% 자리 |
| 평균 | 2.18초 | 합 21.8 ÷ 10 — 한 번의 9.8초 때문에 "보통 2초"처럼 보임 |
| p50 | 1.3초 | 가운데 (5번째) |
| p95 | 9.8초 | 10개 중 95% 자리 = 10번째 (자리 = 올림(0.95 × 10)) |
| max | 9.8초 | |
- 사용자 열 명 중 아홉은 1.6초 안에 받았고 한 명은 10초를 기다렸습니다. 평균 2초라는 말은 **아무에게도 맞지 않습니다.**
- 사용자가 "느리다"고 기억하는 것은 느린 쪽입니다. 그래서 p50과 p95를 **함께** 말합니다.
- 개수가 적으면 p95와 max가 같아집니다 (5번이면 p95 = 5번째 = max). 5번은 "대강", 20번 이상이면 p95가 의미를 갖기 시작합니다. 오늘 실습은 시간 때문에 5번입니다.
오늘 기준 워크플로우 (`base-helpdesk`, VPN 질문, push 직후 첫 호출 뒤 5번):
| 날 | 첫 호출 | p50 | p95 | 실패 |
| --- | --- | --- | --- | --- |
| 10/18 | 6.5초 | 2.61초 | 2.83초 | 0 |
| 10/23 | 6.3초 | 3.14초 | 3.22초 | 0 |
| 10/24 | 4.4초 | 2.69초 | 2.98초 | 0 |
같은 워크플로우 · 같은 질문인데 날마다 0.5초쯤 다릅니다 (OpenAI 쪽 응답 시간, 네트워크). 그래서 **바꾸기 전 · 후를 같은 날, 같은 조건으로** 잽니다. 어제 잰 숫자와 오늘 바꾼 숫자를 비교하면 안 됩니다.
## 3. 첫 호출 (콜드 스타트)
### 일반 설명
**콜드 스타트(cold start)** 는 "처음 한 번"만 드는 준비 비용입니다. 서버리스 함수(AWS Lambda 등)가 오랜만에 불릴 때, 자바 같은 언어가 처음 실행한 코드를 최적화(JIT)하기 전, 데이터베이스 연결 풀이 비어 있을 때 모두 같은 일이 생깁니다.
| 처음에만 드는 일 | 예 |
| --- | --- |
| 연결 만들기 | TCP 연결 + TLS 인증 주고받기 (왕복 몇 번), 그 뒤로는 연결을 다시 씀 |
| 불러오기 · 초기화 | 프로그램 · 설정 · 라이브러리를 메모리에 올림 |
| 비어 있는 캐시 | 처음에는 모든 것을 새로 계산 |
- 다루는 법은 셋입니다: **따로 적기**(평소 숫자와 섞지 않음), **미리 불러 두기**(워밍업), **아예 줄이기**(늘 켜 두기, 연결 재사용).
- 운영에서는 배포 직후, 서버가 늘어날 때(오토스케일), 밤새 아무도 안 쓴 뒤 아침 첫 요청이 콜드 스타트를 만납니다.
### 오늘 실습에서
n8n이 다시 켜진 뒤(오늘은 `push`할 때마다) 첫 호출은 눈에 띄게 느립니다.
| 까닭 | 설명 |
| --- | --- |
| 연결 열기 | OpenAI · 데이터 API와 처음 연결 (TLS 등) |
| 준비 | 워크플로우 · 노드를 처음 불러오고 Code 노드 실행기(runner)를 깨움 |
| 다루는 법 | 어디서 |
| --- | --- |
| 첫 호출을 **따로 적는다** | Lab 01 예제 1에서 `my-work/PERF.md` 1절 표의 "첫 호출" 칸 (`PERF.md`는 Lab 01을 시작할 때 `templates/PERF-REPORT-TEMPLATE.md`를 복사해 만든 내 보고서) |
| 재기 전에 한 번 불러 둔다 (워밍업) | 4교시 Lab 02 과제 1의 테스트 `cases/lab02-challenge.json`: 맨 위 `before`에 워밍업 호출이 있음 (과제 1에서 열어 봄) |
| 운영에서는 배포 직후 한 번 불러 둔다 | 74일차 배포에서 다시 |
> 수업 준비 때 첫 호출 때문에 시간 기대(`max_seconds`)가 있는 케이스가 흔들렸습니다. 테스트에 워밍업을 넣은 까닭입니다.
## 4. 어디서 재나
### 일반 설명
재는 자리는 크게 둘입니다.
| 재는 자리 | 무엇을 아나 | 예 |
| --- | --- | --- |
| **밖에서** (블랙박스) | 사용자가 실제로 기다린 시간, 성공 · 실패 | 부하 도구(k6 · Locust), 합성 점검(synthetic monitoring), 실제 사용자 측정(RUM) |
| **안에서** (화이트박스) | 그 시간이 어느 단계에서 쓰였나 | 로그, 단계별 시간, 추적(trace) |
업계에서 말하는 **관측 가능성(observability)** 의 세 기둥이 이것입니다.
- **지표(metrics)**: 모아서 센 숫자 (p95, 실패율, 분당 요청 수). 추세 · 알림에 씀.
- **로그(logs)**: 일이 하나 일어날 때마다 남기는 기록. 무슨 일이 있었는지 찾아볼 때.
W -.->|timings 로 보임| T["classify · data · answer · review"]
```
- 밖에서 잰 시간(`bench`)과 안에서 잰 `total_ms`는 조금 다릅니다. 밖은 웹훅을 받고 돌려주는 시간까지 포함합니다. 오늘 VPN 한 번: `call` 2.7초, `total_ms` 2698ms (10/24) — 거의 같습니다.
## 5. 단계별 시간을 결과에 남기기
### 일반 설명
코드에 시간을 재는 줄을 넣는 일을 **계측(instrumentation)** 이라고 합니다. 원리는 간단합니다: 단계 앞뒤에서 시각을 읽고 차이를 남깁니다.
- 시각은 같은 시계로 읽어야 뺄 수 있습니다. 컴퓨터 시계는 시간 맞추기(NTP)로 가끔 앞뒤로 튈 수 있어서, 정밀하게 잴 때는 늘 앞으로만 가는 시계(monotonic: 자바스크립트 `performance.now()`, 파이썬 `time.monotonic()`)를 씁니다. 오늘처럼 ms 단위로 단계를 비교할 때는 `Date.now()`로 충분합니다.
- 계측에도 시간이 듭니다. 아주 짧은 단계를 아주 많이 재면 재는 비용이 커집니다. 오늘은 Code 노드 몇 개라 수 ms입니다.
- 업계 도구는 이것을 표준으로 만든 것입니다: **OpenTelemetry**는 단계(span)마다 시작 · 끝 시각과 이름을 남기고, 모아서 한 요청의 폭포 그림(waterfall)으로 보여 줍니다. 오늘 `timings`는 그 아주 작은 판입니다.
- 시간과 함께 **어느 길로 왔는지**(지나간 단계, 부른 외부 서비스 수)도 남깁니다. 시간은 길에 따라 다르기 때문입니다.
### 오늘 실습에서
단계가 끝날 때마다 Code 노드에서 `Date.now()`(지금 시각, ms)를 남기고, 마지막에 앞 시각과의 차이를 계산합니다.
(단계 칸은 ms, `uv run tools/flow.py call my-helpdesk <요청> -v` 한 번의 값. LLM 몫 = (classify + answer + review) ÷ total)
- **세 종류 모두 LLM이 98% 안팎.** 데이터 API는 20~40ms입니다.
- 점심 메뉴 질문은 데이터가 하나도 없는데도 LLM을 세 번 부르고 "찾지 못했습니다"류의 답을 씁니다. 2단계에서 줄일 수 있는 첫 후보입니다.
- 접수 번호 질문은 10/24 Lab 01 예제 2의 테스트(`cases/lab01.json`)에서 한 번 실패했습니다: 검토 LLM이 "처리 중"을 "승인 대기 중"으로 바꿔 썼습니다. **검토 LLM이 사실을 바꿔 쓸 수 있다**는 것도 4교시 Lab 02 과제 1(검토 LLM을 검사 코드로 바꾸기)의 근거입니다.
## 7. 어림 계산: 어디를 고치면 얼마나 빨라지나
### 일반 설명
어디를 고칠지 정하기 전에 "고치면 얼마나 줄어드나"를 계산해 봅니다. 이것을 정리한 것이 **암달의 법칙(Amdahl's law)** 입니다.
> 전체에서 비율 p를 차지하는 부분을 s배 빠르게 하면, 전체는 1 ÷ ((1 − p) + p ÷ s)배 빨라진다.
| 고치는 부분의 비율 p | 그 부분을 | 전체 |
| --- | --- | --- |
| 1% | 10배 빠르게 | 약 1.01배 (거의 그대로) |
| 50% | 2배 빠르게 | 약 1.33배 |
| 98% | 2배 빠르게 | 약 1.96배 |
| 98% | 없앰 | 약 50배 (남은 2%만) |
- 작은 부분을 아무리 빠르게 해도 전체는 거의 그대로입니다. **큰 덩어리부터.**
- 어림은 방향을 정하는 데 쓰고, 고친 뒤에는 반드시 다시 잽니다. 단계끼리 영향을 주기도 하기 때문입니다 (예: 앞 단계가 빨라지면 뒤 단계가 콜드 스타트를 떠안음).
### 오늘 실습에서
전체 2.7초(VPN) 중 각 단계를 **없앤다면** 얼마가 되는지 어림합니다.
| 없애는 것 | 줄어드는 시간 | 남는 시간 | 줄어드는 비율 |
| --- | --- | --- | --- |
| 데이터 조회 (18ms) | 0.02초 | 2.68초 | 0.7% |
| 분류 LLM (616ms) | 0.62초 | 2.08초 | 23% |
| 검토 LLM (920ms) | 0.92초 | 1.78초 | 34% |
| 분류 + 검토 | 1.54초 | 1.16초 | 57% |
- 데이터 조회를 열 배 빠르게 해도 사용자는 모릅니다. **큰 덩어리부터.**
- 2단계 실측과 맞춰 봅니다: 10/24 캐시 없이 VPN p50이 Lab 02 예제 2까지(규칙 + 캐시, LLM 2번) 2.22초 → Lab 02 과제 1에서 검토를 검사로 바꾼 뒤(LLM 1번) 1.12초. 어림한 "검토를 빼면 약 1초 줄어듦"과 맞습니다.
- 어림은 **방향**을 정하는 데 씁니다. 결과는 반드시 다시 잽니다.
## 8. 가장 느린 단계 (2교시 Lab 01 과제 1)
### 일반 설명
차례로 실행되는 흐름에서는 가장 오래 걸린 단계가 곧 가장 큰 병목입니다. 단계가 **동시에** 실행되는 흐름이라면, 전체 시간은 가장 긴 줄기(**임계 경로**, critical path)가 정합니다. 임계 경로 밖의 단계를 빠르게 해도 전체는 줄지 않습니다.
- 매번 사람이 화면을 열어 찾는 대신, 결과에 "가장 느린 단계"를 **계산해 남기면** 많은 요청을 모아 셀 수 있습니다 ("오늘 느린 요청의 70%는 답 단계").
- 가장 느린 단계는 실행마다 바뀔 수 있습니다. 비슷한 단계가 둘이면 1등이 자주 바뀌므로, 한 번이 아니라 **여러 번 모아서** 봅니다.
### 오늘 실습에서
`timings`에서 `total_ms`를 뺀 칸 중 가장 큰 칸의 이름을 `slowest_step`으로 결과에 남깁니다. 매번 Executions를 열지 않아도 결과만 보고 어디가 느린지 압니다.
10/24 Lab 01 과제 1 정답으로 과제 1 테스트 `cases/lab01-challenge.json`의 네 번 (ms):
- 답 LLM과 검토 LLM이 비슷해서 실행마다 1등이 바뀝니다. push 직후 첫 호출에서는 분류(첫 LLM)가 1등이었습니다 (첫 호출의 준비 시간이 첫 LLM 단계에 붙음). 그래서 테스트는 `classify` · `answer` · `review` 중 하나면 통과로 봅니다.
-`slowest_step`은 LLM이 아니라 **Code가 계산**합니다. 숫자가 있으면 판단하지 않고 셉니다.
- 데이터 조회를 `faq_ms` · `ticket_ms`로 나눈 까닭: 3단계에서 "어느 쪽이 멈췄나"를 볼 때 둘을 따로 봐야 합니다.
## 9. 시간 예산 (2교시 Lab 01 과제 2)
### 일반 설명
"빠르다"는 기준이 있어야 말할 수 있습니다. 업계에서 쓰는 말은 셋입니다.
| 말 | 뜻 | 예 |
| --- | --- | --- |
| **SLI** (Service Level Indicator, 서비스 수준 지표) | 서비스가 잘 되고 있는지 **재는 숫자**. 무엇을 재나 | "3초 안에 답한 요청의 비율" |
| **SLO** (Service Level Objective, 서비스 수준 목표) | 그 지표가 지켜야 할 **목표 값**. 팀이 스스로 정함 | "한 달 동안 95% 이상" |
| **SLA** (Service Level Agreement, 서비스 수준 협약) | 고객과 맺은 **계약**. 목표를 못 지키면 어떻게 되나 (보상) | "95% 밑이면 요금 일부 돌려줌" |
- **오류 예산(error budget)** = 1 − SLO. 목표가 95%면 5%까지는 늦어도 됩니다. 예산이 남으면 새 기능을 내고, 다 쓰면 안정화에 집중합니다 (구글 SRE의 방식).
- **시간 예산 나누기**: 전체 목표(예: 3초)를 단계마다 나눠 줍니다 (분류 0.5초, 데이터 0.2초, 답 2초 …). 각 단계는 자기 몫을 넘었는지로 봅니다.
- 목표는 **사용자 쪽에서** 정합니다. "지금 2.7초니까 목표 3초"가 아니라 "사용자가 3초 넘으면 답답해하니 3초"입니다.
- 넘은 요청을 버리지 않고 **기록해서 모으는** 것이 먼저입니다. 모아야 어디를 고칠지 보입니다.
### 오늘 실습에서
"빠르다 · 느리다"는 기준이 있어야 말할 수 있습니다. 그 기준이 **시간 예산**(목표 시간)입니다.
| 말 | 뜻 | 오늘 |
| --- | --- | --- |
| 시간 예산 (`budget_ms`) | 한 요청이 이 안에 끝나야 함 | 기본 3000ms, 요청 본문으로 바꿀 수 있음 |
| 넘었나 (`over_budget`) | `total_ms > budget_ms` | 결과에 남김 (답은 그대로 보냄) |
| LLM 시간 (`llm_ms`) | 분류 · 답 · 검토의 합 | 넘었을 때 LLM 탓인지 바로 보임 |
| 목표 수준 (SLO) | "요청의 95%가 3초 안" 같은 약속 | 오늘은 씨앗만 (79일차 운영 대시보드) |
- 예산은 **사용자 쪽에서** 정합니다 ("3초 넘으면 느리다"). 워크플로우를 만든 사람이 "우리 워크플로우는 2.7초니까 예산 3초"라고 정하는 것이 아닙니다.
- 넘었다고 답을 멈추지 않습니다. 이미 거의 다 만든 답을 버리면 사용자는 더 오래 기다립니다. **기록하고, 모아서, 고칠 곳을 정합니다.**
-`llm_ms`를 `timings` 안에 넣으면 늘 가장 커서 `slowest_step`이 망가집니다. 그래서 결과 맨 바깥에 둡니다.
**세어 보기 (예시).** 하루 200건 중 `over_budget: true`가 24건이면 3초 안에 답한 비율은 (200 − 24) ÷ 200 = 88%. 목표가 95%라면 모자랍니다. 넘은 24건의 `slowest_step`을 세어 보면 무엇부터 고칠지 보입니다.
10/24 Lab 01 과제 2 정답으로 과제 2 테스트 `cases/lab01-challenge2.json` (VPN · 접수, 캐시 없이). Lab 01 과제 2에서 각자 `PERF.md` 2-2절에 같은 표를 채웁니다:
| 예산 | `total_ms` | `llm_ms` | `over_budget` |
| --- | --- | --- | --- |
| 60000 | 3357 (push 직후) | 3295 | false |
| 500 | 2609 | 2563 | true |
| 주지 않음 → 3000 | 3023 | 2973 | **true** |
| 주지 않음 → 3000 (접수 현황) | 2904 | 2858 | false |
- 기본 예산 3초에서 VPN이 3023ms로 **23ms 넘었습니다.** 지금 워크플로우는 3초 근처라 실행마다 `true` · `false`가 바뀝니다. 그래서 과제 2 테스트는 기본값일 때 `budget_ms: 3000`만 보고, 4교시 Lab 02에서 LLM을 줄이면 넉넉히 들어옵니다.
-`llm_ms`가 `total_ms`의 98%: 넘은 까닭이 LLM이라는 것이 결과만 봐도 보입니다.
**요청문(프롬프트)이란.** 실습에서 CLI 코딩 에이전트(codex · claude · agy)에게 보내는 글입니다. 에이전트는 이 글만 보고 n8n 워크플로우 JSON을 만들거나 고칩니다. 이 절은 이 단계에서 배운 것을 **요청문(프롬프트)에 어떤 칸으로 적을지** 실습 전에 정리하는 자리입니다. 실습 문서의 `▶ 빈칸 채우기 · 요청문(프롬프트) 뼈대`가 이 칸을 실제 문장으로 만든 것이고, 빈칸 ①~④는 그 문장 속 정확한 값입니다.
| 칸 | 에이전트에게 알려 주는 것 | 빠지면 생기는 일 |
| --- | --- | --- |
| 무엇을 | 만들거나 바꿀 기능 | 에이전트가 짐작해서 다른 것을 만듦 |
| 어디에 | 고칠 파일 · 노드 · 자리 | 엉뚱한 노드를 고치거나 새 파일을 만듦 |
| 어떤 모양으로 | 결과 JSON의 칸 이름 · 값 · 응답 코드 | 테스트가 보는 칸 이름과 달라 실패 |
| 그대로 · 하지 말 것 | 건드리면 안 되는 것 | 다른 곳까지 바뀌어 무엇 때문인지 모르게 됨 |
| 확인 | 통과해야 할 테스트 파일 | "다 됐다"를 판단할 기준이 없음 |
67~71일차의 다섯 가지(① 파일 ② 입력 ③ 처리 ④ 결과 모양 ⑤ 기대 결과)와 같은 뜻입니다. 파일 · 입력이 "어디에", 처리가 "무엇을", 결과 모양이 "어떤 모양으로", 기대 결과가 "확인"입니다. 72일차부터는 **이미 있는 워크플로우를 고치는** 요청이 많아 "그대로"가 따로 한 칸이 됐습니다.