서비스 학습
SOURCE · be/app/schemas/system.py · 2026-09-06

상태 endpoint가 돌려줄 JSON을
미리 못 박아 둔다

Route 함수는 dict를 그냥 반환할 수도 있다. 이 파일은 그러지 않기로 한 선택이며, 그 대가로 오타·누락·잘못된 값이 배포가 아니라 실행 즉시 드러난다.

이 파일을 만든 이유

/healthz{"status": "ok"}를 돌려준다고 하자. dict로 직접 만들면 "stauts"라고 오타를 내도 Python은 아무 말 없이 그 dict를 JSON으로 내보낸다. 오류는 이 API를 소비하는 쪽 — Kubernetes probe나 monitoring — 에서야 드러난다.

Pydantic model로 정의하면 필드 이름과 허용 값이 객체를 만드는 순간 검증된다. 동시에 이 class가 /openapi.json의 schema가 되어 /docs 화면과 client 코드 생성의 근거가 된다. 문서와 검증을 한 곳에서 얻는 것이 목적이다.

전체 구조에서의 위치

요청GET /healthz · /readyz브라우저·orchestrator
처리api/routes/system.pyengine 확인, 헤더 설정
계약schemas/system.py여기 — 반환 형태를 강제
응답JSON필드 이름·값·순서가 고정

engines/base.pyGenerationRequest·ModelInfoFastAPI와 추론 engine 사이의 계약이라면, 이 파일은 FastAPI와 바깥 client 사이의 계약이다. 방향이 반대다.

전체 코드

from typing import Literal

from pydantic import BaseModel, ConfigDict

from app.engines.base import InferenceErrorCode


class HealthResponse(BaseModel):
    """Stable JSON contract returned by the liveness endpoint."""

    model_config = ConfigDict(frozen=True)

    status: Literal["ok"]
    service: str
    version: str
    environment: Literal["development", "test", "production"]


class ReadinessResponse(BaseModel):
    """Dependency reachability, not a generation or queue-capacity guarantee."""

    model_config = ConfigDict(frozen=True)

    status: Literal["ready", "not_ready"]
    dependency: Literal["inference"] = "inference"
    error_code: InferenceErrorCode | None = None

import 해설

from typing import Literal

Literal은 “이 값들 중 하나”를 뜻하는 type이다. str이라고만 쓰면 status에 어떤 문자열이든 들어간다. Literal["ok"]라고 쓰면 "ok" 외의 값은 Pydantic이 거부한다. 순수 Python 함수의 인수라면 Literal은 정적 검사기에만 힌트를 주지만, Pydantic field에서는 실행 시점 검증으로 이어진다.

from pydantic import BaseModel, ConfigDict

BaseModel을 상속하면 그 class는 필드 검증·JSON 직렬화·schema 생성 능력을 얻는다. ConfigDict는 그 동작을 조절하는 설정 객체다.

from app.engines.base import InferenceErrorCode

추론 실패 코드 목록을 다시 적지 않고 가져온다. engine 쪽에 context_too_long 같은 코드가 추가되면 이 응답 schema도 자동으로 따라간다. 두 곳에 각각 적으면 반드시 어긋난다.

HealthResponse — 프로세스가 살아 있는가

class HealthResponse(BaseModel):
    """Stable JSON contract returned by the liveness endpoint."""

    model_config = ConfigDict(frozen=True)

    status: Literal["ok"]
    service: str
    version: str
    environment: Literal["development", "test", "production"]
model_config = ConfigDict(frozen=True)

frozen은 만들어진 뒤 필드 재할당을 막는다. 응답 객체는 만들어서 그대로 내보내는 값이므로 중간에 바뀔 이유가 없다. 실수로 route에서 response.status = "degraded"라고 쓰면 그 자리에서 예외가 난다. 다만 이는 깊은 동결이 아니다 — 필드가 list나 dict라면 그 내용물은 여전히 바뀔 수 있다. 이 model은 문자열만 쓰므로 문제되지 않는다.

status: Literal["ok"]

값이 하나뿐인 Literal이다. 즉 이 endpoint는 성공 응답만 이 형태로 만든다. 프로세스가 죽으면 애초에 응답 자체가 없으므로 "down" 같은 값이 필요 없다. “살아 있다고 대답할 수 있다는 사실” 자체가 답이다.

service·version: str

값의 종류를 미리 알 수 없으므로 자유 문자열이다. request.app.state.settings에서 읽어 채운다. 여러 container가 떠 있을 때 어떤 build가 응답했는지 구분하는 용도다.

environment: Literal[...]

Settings의 같은 이름 필드와 동일한 세 값으로 제한된다. production container가 실수로 development 설정을 들고 떴는지 응답만 보고 알 수 있다.

ReadinessResponse — 의존성에 닿는가

class ReadinessResponse(BaseModel):
    """Dependency reachability, not a generation or queue-capacity guarantee."""

    model_config = ConfigDict(frozen=True)

    status: Literal["ready", "not_ready"]
    dependency: Literal["inference"] = "inference"
    error_code: InferenceErrorCode | None = None
status: Literal["ready", "not_ready"]

이번에는 값이 둘이다. /readyz실패해도 응답을 돌려주기 때문이다. 추론 서버가 꺼져 있어도 FastAPI 자신은 살아 있으므로 503과 함께 not_ready를 답할 수 있다.

dependency: Literal["inference"] = "inference"

기본값이 있으므로 route에서 넘기지 않아도 채워진다. 지금은 확인하는 의존성이 추론 서버 하나뿐이라 값도 하나다. PostgreSQL이 추가되는 5단계에서 이 Literal"database"가 늘어나는 것이 예정된 확장 지점이다. 필드를 미리 둔 이유는 그때 응답 형태가 통째로 바뀌지 않게 하기 위해서다.

error_code: InferenceErrorCode | None = None

X | None은 “값이 있거나 없거나”다. 성공하면 None, 실패하면 "unavailable"·"timeout"·"model_mismatch" 같은 정해진 코드가 들어간다. 상세 메시지가 아니라 코드만 내보내는 것이 핵심이다. 추론 서버가 돌려준 원문에는 prompt 일부나 내부 경로가 섞일 수 있으므로 그대로 노출하지 않는다.

실제 값으로 따라가기

두 endpoint가 만들어내는 JSON을 실제 값으로 확인한다.

GET /healthz  →  200
{"status": "ok", "service": "local-moe-backend", "version": "0.1.0",
 "environment": "development"}

GET /readyz   →  200
{"status": "ready", "dependency": "inference", "error_code": null}
GET /healthz  →  200        ← FastAPI 자신은 여전히 살아 있다
{"status": "ok", ...}

GET /readyz   →  503
{"status": "not_ready", "dependency": "inference",
 "error_code": "unavailable"}

error_code가 Python의 None일 때 JSON에서는 null로 나간다. 필드가 사라지는 것이 아니라 항상 존재하고 값이 null이다. 소비하는 쪽이 키 존재 여부를 검사하지 않아도 된다.

검증이 실제로 작동하는지 관찰하기

cd be
.venv/bin/python -c "
from app.schemas.system import HealthResponse, ReadinessResponse
try:
    HealthResponse(status='degraded', service='a', version='b', environment='test')
except Exception as error:
    print('거부됨:', type(error).__name__)

ok = ReadinessResponse(status='ready')
print('기본값 채워짐:', ok.model_dump())
try:
    ok.status = 'not_ready'
except Exception as error:
    print('frozen:', type(error).__name__)
"
거부됨: ValidationError
기본값 채워짐: {'status': 'ready', 'dependency': 'inference', 'error_code': None}
frozen: ValidationError

세 가지가 한 번에 증명된다. Literal이 허용되지 않은 값을 막고, 기본값이 자동으로 채워지고, frozen이 사후 변경을 막는다.

자주 발생하는 오류

증상원인진단
ResponseValidationError 500route가 schema와 다른 값을 반환응답 model과 return 값을 대조한다. 클라이언트 잘못이 아니라 서버 버그다
필드가 응답에서 사라짐response_model에 없는 키를 dict로 반환FastAPI는 model에 선언된 필드만 내보낸다. 추가하려면 schema를 먼저 고친다
error_code가 늘 nullroute가 예외를 삼키고 ready를 반환routes/system.pyexcept InferenceError 구간을 확인한다
ImportError: InferenceErrorCodeengines/base.py에서 이름이 바뀜응답 schema가 engine 계약을 참조하므로 함께 고쳐야 한다

설계 선택과 대안

dict를 그대로 반환하면 안 되는가?

동작은 한다. 잃는 것은 세 가지다. 첫째 오타를 잡아주지 않는다. 둘째 /openapi.json에 응답 형태가 실리지 않아 /docs가 비어 보인다. 셋째 test_health.py가 검사하는 components.schemas.HealthResponse가 존재하지 않는다.

왜 두 model을 하나로 합치지 않는가?

두 endpoint는 질문이 다르다. liveness는 “이 프로세스가 응답할 수 있는가”, readiness는 “의존성에 닿는가”이다. 합치면 /healthz 응답에도 error_code 같은 무의미한 필드가 붙고, orchestrator가 두 신호를 구분하지 못한다.

왜 실패 메시지를 그대로 전달하지 않는가?

이 응답은 인증 없이 접근 가능한 운영 endpoint다. 추론 서버의 원문 오류에는 모델 경로·prompt 조각이 섞일 수 있어 그대로 노출하면 정보가 새어 나간다. 코드만 내보내고 자세한 내용은 서버 log에 남긴다.

이전 단계와 다음 파일

이 계약을 실제로 채우는 코드는 api/routes/system.py에 있다. error_code의 값 목록이 어디서 오는지는 engines/base.py에서, 그 코드를 실제로 만들어내는 분류 로직은 turbofieldfare.py에서 읽는다. 이 schema가 응답으로 나가는지 검사하는 테스트는 test_health.py다.

← 이전상태 route 구현다음 →브라우저 계약