요청 하나가
어느 파일을 어떤 순서로 지나는가
노드는 파일이고 선은 요청이 옮겨 가는 순간이다. 선마다 그 이동이 일어나는 조건이 적혀 있다. 범례에서 흐름 하나를 고르면 그 경로만 남는다.
읽는 법
선의 색과 모양이 함께 흐름을 구분한다. 색만으로 구분하지 않는 이유는 색각 이상에서도 읽히게 하기 위해서다. 아래 버튼을 누르면 그 흐름만 남고, 선마다 조건이 글자로 나타난다. 다시 누르면 전체로 돌아온다.
전체 보기에서는 선이 겹치므로 글자를 숨겨 둔다. 선 위에 마우스를 올리면 그 선 하나의 조건만 볼 수 있다. 화면이 좁으면 그림을 가로로 밀어서 본다.
노드를 누르면 그 파일의 해설 문서로 간다. 우리 파일 12개가 모두 연결돼 있고, 브라우저와 추론 서버는 우리 코드가 아니라 눌리지 않는다.
| 표시 | 뜻 |
|---|---|
| 흰 상자 | be/ 안의 우리 파일 · 눌러서 해설 문서로 이동 |
| 회색 상자 | 우리 코드가 아닌 것 — 브라우저와 macOS 추론 서버 |
| 점선 테두리 상자 | 아직 이 흐름에 참여하지 않는 파일 · 눌러서 이동 가능 |
연결도
왼쪽 위가 브라우저, 오른쪽 끝이 macOS에서 도는 TurboFieldfareServer다. 가운데 세 열이 be/app/ 안이고, 아래쪽 낮은 줄은 요청이 아니라 서버가 시작할 때 한 번만 도는 조립 경로다.
흐름별로 읽기
■ 서버 시작 시 1회 · 조립
요청과 무관하게 프로세스가 뜰 때 한 번만 일어난다. main.py가 설정을 읽고, HTTP client 두 개를 만들어 Adapter에 넣고, router.py가 모아 둔 규칙을 application에 복사한다.
이 경로가 끝난 뒤에야 나머지 다섯 흐름이 가능해진다. main.py는 /healthz라는 글자를 모른다 — 묶음 하나만 받아 붙일 뿐이다.
■ GET /healthz
가장 짧다. 엔진을 부르지 않는다. 추론 서버가 꺼져 있어도 200이 나오며, 그것이 이 endpoint의 존재 이유다. FastAPI 프로세스가 HTTP에 답할 수 있다는 사실만 증명한다.
■ GET /readyz
Depends를 거쳐 Adapter를 받아 probe 전용 연결(최대 2개)로 추론 서버를 두 번 두드린다. 생성 요청이 연결을 다 쓰고 있어도 상태 확인은 자기 연결로 답한다.
실패하면 원인이 무엇이든 503 하나다. 예/아니오 질문이기 때문이다.
■ POST /api/v1/chat · 성공
가장 긴 경로다. 검증 → 번역 → 생성 → 되돌아오기까지 여덟 번 옮겨 간다. 중요한 분기는 두 번째 선이다 — 공백 메시지나 상한 초과는 schemas/chat.py에서 422로 끝나고 엔진까지 가지 않는다. 8GB 환경에서 30초짜리 생성을 낭비하지 않기 위해서다.
■ 실패 · 오류를 status 로
연결 실패·시간 초과·429·비200이면 Adapter가 InferenceError 하나를 던진다. 같은 예외를 두 route가 다르게 해석한다 — 채팅은 매핑표로 여섯 가지 status로 나누고, readyz는 전부 503이다. 이 갈림이 route 계층이 하는 일이다.
■ smoke.py · 웹 없이 관찰
브라우저를 거치지 않는 유일한 경로다. create_app()으로 lifespan만 빌려 client를 얻은 뒤 generate()를 직접 부른다. HTTP 계층을 뺀 채 추론 연결만 확인할 때 쓴다.
■ 아직 연결되지 않음
fe/src/App.tsx의 전송 버튼은 지금도 화면에만 문장을 추가한다. 브라우저에서 POST /api/v1/chat으로 가는 선은 9단계에서 생긴다. 서버 쪽은 이미 준비돼 있다.
전체 이동 목록
그림의 모든 선을 표로 옮긴 것이다. 그림을 보기 어려운 환경에서도 같은 내용을 읽을 수 있고, 검색도 된다. 범례에서 흐름을 고르면 이 표도 함께 강조된다.
| 흐름 | 출발 | 도착 | 언제 이 선을 탄다 |
|---|---|---|---|
| ● 서버 시작 시 1회 · 조립 | app/main.py | core/config.py | create_app() 이 APP_* 환경변수를 읽어 Settings 를 만든다 |
| ● 서버 시작 시 1회 · 조립 | app/main.py | api/router.py | include_router(api_router) 로 규칙을 app 에 복사한다 |
| ● 서버 시작 시 1회 · 조립 | app/main.py | engines/turbofieldfare.py | lifespan startup 에서 생성용·probe용 client 2개를 만들어 Adapter 에 주입한다 |
| ● 서버 시작 시 1회 · 조립 | api/router.py | api/routes/system.py | import 시 접두사 없이 붙는다 → /healthz · /readyz |
| ● 서버 시작 시 1회 · 조립 | api/router.py | api/routes/chat.py | import 시 prefix=/api/v1 로 붙는다 → /chat |
| ● GET /healthz | 브라우저 | api/routes/system.py | 브라우저가 GET /healthz 를 보내면 |
| ● GET /healthz | api/routes/system.py | schemas/system.py | 엔진을 부르지 않고 HealthResponse 로 직렬화한다 |
| ● GET /readyz | 브라우저 | api/routes/system.py | 브라우저가 GET /readyz 를 보내면 |
| ● GET /readyz | api/routes/system.py | api/dependencies.py | Depends(get_inference_engine) 이 실행된다 |
| ● GET /readyz | api/dependencies.py | engines/turbofieldfare.py | app.state 의 Adapter 를 돌려주고 check_ready() 를 부른다 |
| ● GET /readyz | engines/turbofieldfare.py | TurboFieldfareServer | probe client(최대 2연결)로 GET /health 와 GET /v1/models 를 부른다 |
| ● POST /api/v1/chat · 성공 | 브라우저 | api/routes/chat.py | 브라우저가 POST /api/v1/chat 을 보내면 |
| ● POST /api/v1/chat · 성공 | api/routes/chat.py | schemas/chat.py | 본문을 검증한다 · 공백이나 상한 위반이면 여기서 422 로 끝난다 |
| ● POST /api/v1/chat · 성공 | api/routes/chat.py | engines/base.py | 통과하면 ChatMessage 를 Message 로 바꾸고 옵션 128/0.2 를 서버가 붙인다 |
| ● POST /api/v1/chat · 성공 | engines/base.py | engines/turbofieldfare.py | GenerationRequest 로 engine.generate() 를 부른다 |
| ● POST /api/v1/chat · 성공 | engines/turbofieldfare.py | TurboFieldfareServer | 생성 client(최대 4연결)로 POST /v1/chat/completions 를 stream=false 로 부른다 |
| ● POST /api/v1/chat · 성공 | TurboFieldfareServer | engines/turbofieldfare.py | 응답 JSON 을 wire model 로 재검증해 GenerationResult 로 만든다 |
| ● POST /api/v1/chat · 성공 | engines/turbofieldfare.py | api/routes/chat.py | GenerationResult 를 route 로 돌려준다 |
| ● POST /api/v1/chat · 성공 | api/routes/chat.py | 브라우저 | ChatResponse 를 200 으로 내보낸다 |
| ● 실패 · 오류를 status 로 | engines/turbofieldfare.py | api/routes/chat.py | 연결 실패·시간 초과·429·비200 이면 InferenceError(code) 를 던진다 |
| ● 실패 · 오류를 status 로 | api/routes/chat.py | 브라우저 | 매핑표로 400·413·429·502·503·504 와 {error_code} 를 내보낸다 |
| ● 실패 · 오류를 status 로 | api/routes/system.py | 브라우저 | readyz 는 실패가 무엇이든 503 not_ready 하나로 답한다 |
| ● smoke.py · 웹 없이 관찰 | engines/smoke.py | app/main.py | create_app() 으로 lifespan 을 빌려 client 를 얻는다 |
| ● smoke.py · 웹 없이 관찰 | engines/smoke.py | engines/turbofieldfare.py | 웹 port 없이 generate() 를 직접 불러 실제 생성을 관찰한다 |
| ● 아직 연결되지 않음 | fe/src/App.tsx | 브라우저 | 9단계에서 연결 예정 · 지금 전송 버튼은 화면에만 문장을 추가한다 |
직접 확인하는 방법
그림이 맞는지 실제로 확인할 수 있다. 두 서버를 띄운 뒤 요청을 보내면서 어느 흐름을 탔는지 응답과 로그로 구분한다.
# 터미널 1 — 추론 서버
cd turbo-fieldfare
.build/release/TurboFieldfareServer --model scratch/gemma4.gturbo
# 터미널 2 — FastAPI
cd be && .venv/bin/uvicorn app.main:app --port 8000
# 시작 로그에 service_started 가 뜨면 파란 조립 경로가 끝난 것이다
# 터미널 3 — 흐름별로 하나씩
curl -s http://127.0.0.1:8000/healthz # 초록 · 엔진을 안 부른다
curl -s http://127.0.0.1:8000/readyz # 노랑 · probe 연결을 쓴다
curl -s -X POST http://127.0.0.1:8000/api/v1/chat \
-H 'content-type: application/json' \
-d '{"messages":[{"role":"user","content":"MoE가 뭐야?"}]}' # 진초록
curl -s -X POST http://127.0.0.1:8000/api/v1/chat \
-H 'content-type: application/json' \
-d '{"messages":[{"role":"user","content":" "}]}' # 422 · 두 번째 선에서 끝
cd be && .venv/bin/python -m app.engines.smoke # 보라 · 웹 없이
추론 서버를 끈 채 같은 요청을 보내면 빨간 경로를 볼 수 있다. 채팅은 503 {"error_code":"unavailable"}, /healthz는 여전히 200이다.
이 그림이 보여주지 않는 것
| 항목 | 왜 없나 |
|---|---|
| import 관계 | 요청이 옮겨 가는 순간만 그렸다. schemas/chat.py가 base.py의 오류 코드를 가져다 쓰는 것 같은 참조는 선이 아니다 |
| streaming | 아직 계약 자체가 없다. 4단계에서 조각 단위 경로가 생긴다 |
| 대화 저장 | 5~7단계. database 노드가 추가될 자리다 |
| 인증 | 6단계. 브라우저와 route 사이에 검사 한 겹이 더 생긴다 |
| 동시 요청 | 선은 요청 하나의 경로다. 생성 4·probe 2라는 연결 수 제한은 그림에 없다 |
색을 이렇게 고른 이유
여섯 색을 눈으로 고르지 않고 검증기로 계산해서 정했다. 8개 후보에서 6개를 뽑는 28가지 조합을 전부 돌려, 모든 쌍이 구분되는 조합만 남겼다.
[PASS] Lightness band all 6 inside L 0.43–0.77
[PASS] Chroma floor all 6 >= 0.1
[WARN] CVD separation worst #e34948↔#1baf7a ΔE 6.9 (deutan)
[PASS] Normal-vision floor worst #008300↔#1baf7a ΔE 15.6
[WARN] Contrast vs surface #1baf7a 2.82 · #eda100 2.17 — 라벨 또는 표 필요
→ ALL CHECKS PASS
경고 두 개가 붙는 조건이 있고, 이 페이지가 그것을 지킨다.
| 경고 | 요구 조건 | 이 페이지가 한 것 |
|---|---|---|
| CVD ΔE 6.9 (6–8 구간) | 색 외의 보조 부호화가 있어야 함 | 흐름마다 선 모양을 다르게 하고 범례에도 그 모양을 보여준다 |
| 대비 3:1 미만 2개 | 보이는 라벨 또는 표가 있어야 함 | 선마다 글자 라벨이 있고 전체 이동 목록 표를 함께 싣는다 |
그래서 색을 전혀 구분하지 못해도 이 그림을 읽을 수 있다. 선 모양이 다르고, 라벨이 있고, 같은 내용이 표에 있다.