
내가 더 이상 스키마 마이그레이션을 하지 않게 된 이유
요약
EvalBench 벤치마크 시스템의 데이터 구조를 개선하여 스키마 마이그레이션의 번거로움을 해결한 사례를 다룹니다. 각 스위트마다 다른 메트릭을 개별 컬럼이 아닌 JSON 형태의 개방형 컬럼으로 통합하여 확장성을 확보했습니다.
핵심 포인트
- 새로운 벤치마크 추가 시 발생하는 스키마 및 프론트엔드 수정 비용 절감
- 데이터의 공통 속성과 가변 속성을 분리하여 설계
- JSON 컬럼을 활용한 개방형 데이터 구조(Open Schema) 도입
- 유지보수 효율성을 높이는 데이터 모델링 전략
EvalBench에 벤치마크 스위트 (benchmark suite)를 추가할 때마다, 저는 데이터베이스를 건드리곤 했습니다. 새로운 스위트가 생기면 새로운 컬럼이 필요했고, 마이그레이션 (migration)을 수행해야 했으며, 집계 쿼리 (aggregation query)를 변경해야 했고, 대개 새로운 메트릭 (metric)이 존재한다는 것을 알 수 있도록 프론트엔드 (frontend)도 수정해야 했습니다. 세 번째 스위트에 이르렀을 때, 이 패턴이 명백히 잘못되었다는 것을 깨달았습니다. 스위트들은 태스크 (task) 수준에서 공통점이 거의 없었지만, 각 태스크에 대해 저장하는 정보는 항상 동일한 몇 가지 사실과 숫자 꾸러미였습니다.
그래서 저는 이 모든 것을 하나의 로우 (row) 형태로 통합했습니다. 이제 스위트를 추가하는 작업은 세 개의 파일만 수정하면 되며, 그 파일 중 어느 것도 스키마 (schema), 애그리게이터 (aggregator), 또는 대시보드 (dashboard)가 아닙니다.
세 가지 스위트가 실제로 서로 다른 점
EvalBench는 여러 제공자 (providers)를 대상으로 세 가지 스위트를 실행합니다. 구조화된 출력 (Structured-output) 신뢰성 검사는 모델이 스키마 (schema)에 따라 유효한 JSON을 반환하는지, 그리고 몇 번의 재시도 (retries)가 필요한지를 확인합니다. 지연 시간 (Latency) 및 비용 (cost)은 쌍별 판정 점수 (pairwise judge scoring)를 실행합니다. RAG는 교체 가능한 청킹 (chunking) 전략에 따라 recall@k, nDCG@k, 그리고 충실도 (faithfulness)를 측정합니다.
이들은 공유하는 태스크 유형 (task type)이 전혀 없습니다. 구조화된 태스크 (structured task)는 JSON 스키마 (schema)와 예상 값을 가집니다. RAG 태스크 (RAG task)는 코퍼스 (corpus)와 관련 문서 ID 세트를 가집니다. 점수 산정 코드 (scoring code)도 완전히 다릅니다. 각 스위트가 자체 저장 방식을 정의하도록 두면, 세 개의 테이블, 세 개의 집계 경로, 그리고 세 가지 특수 사례를 처리해야 하는 프론트엔드를 갖게 됩니다. 새로운 스위트가 추가될 때마다 모든 작업량이 4분의 1씩 늘어나는 셈입니다.
하지만 점수 산정 후 태스크별로 저장되는 내용을 살펴보면, 그 차이점은 사라집니다. 여러분은 항상 해당 데이터가 어떤 실행 (run), 스위트 (suite), 도메인 (domain)에 속하는지, 어떤 모델이 생성했는지, 시간이 얼마나 걸렸는지, 비용은 얼마인지, 모델이 거부했는지 여부를 알 수 있습니다. 그리고 그 뒤에 스위트별 특정 숫자들을 붙입니다. 구조화된 출력은 first_attempt_valid와 retries_to_valid를 내보냅니다. RAG는 recall_at_k와 faithfulness를 내보냅니다. 키 (key)는 다르지만, 타입 (type)은 동일합니다: 바로 부동 소수점 (float)입니다.
하나의 레코드, 하나의 오픈 컬럼
이 관찰이 설계의 핵심입니다. 저장되는 레코드 (record)는 단 하나의 필드를 제외하고는 고정되어 있습니다:
class MetricRecord(BaseModel):
id: str
run_id: str
...
metrics 위의 모든 것은 앞으로 존재하게 될 모든 스위트 (suite) 간에 공유됩니다. metrics 딕셔너리 (dict)는 개방된 부분입니다. Structured는 스키마 유효성 (schema-validity) 수치로 이를 채우고, RAG는 검색 (retrieval) 수치로 이를 채우며, 저장 계층 (storage layer)은 그것이 무엇인지 상관하지 않습니다. 이는 metrics를 위한 JSON 컬럼을 가진 하나의 테이블이므로, 새로운 스위트가 추가될 때 해당 딕셔너리에 새로운 키 (key)를 추가할 뿐 스키마 (schema)에는 전혀 손을 대지 않습니다.
스위트 인터페이스 (suite interface)는 의도적으로 작게 설계되었습니다. 스위트는 자신의 메트릭 키 (metric keys)와 이를 표시하는 방법을 선언한 다음, 세 가지 메서드 (methods)를 구현합니다:
class Suite(ABC):
name: str
metric_keys: list[str]
...
evaluate는 일반적인 dict[str, float]를 반환합니다. 그 반환 값은 있는 그대로 metrics 컬럼이 됩니다. 러너 (runner)가 래핑 (wrapping)을 수행합니다:
metrics = suite.evaluate(task, raw_output, judge)
# ...
return MetricRecord(
...
스위트 작성자는 MetricRecord를 결코 직접 생성하지 않습니다. 그들은 숫자 딕셔너리를 반환하고, 러너가 공유 컬럼들을 부착합니다. 이러한 분리가 스위트가 자신만의 저장 방식을 만들어내는 것을 방지합니다.
세 개의 파일
스위트를 추가한다는 것은 스위트 클래스 (suite class)를 작성하고, 이를 등록하며, 태스크 데이터 (task data)를 넣는 것을 의미합니다. 레지스트리 (registry)는 명시적이며, 자동 검색 (auto-discovery)은 없습니다:
register_suite(StructuredSuite())
register_suite(LatencyCostSuite())
register_suite(RagSuite())
여기에 한 줄만 더 추가하면 스위트가 활성화됩니다. 대시보드 (dashboard)가 이를 인식하는 이유는 API가 각 스위트의 display_metrics 리스트를 제공하기 때문이며, 따라서 프론트엔드 (frontend)는 스위트가 선언한 레이블 (labels)과 포맷 (formats)을 그대로 렌더링합니다. 프론트엔드 변경도, 새로운 엔드포인트 (endpoint)도 필요 없습니다. 세 개의 파일은 스위트, 하나의 등록 라인, 그리고 태스크 데이터입니다. 스키마, 어그리게이터 (aggregator), 그리고 UI는 건드리지 않은 채 유지됩니다.
"단순한 float 딕셔너리"가 나를 물었을 상황
오픈 메트릭스 백 (open metrics bag)은 어그리게이터 (aggregator)가 그 데이터를 바탕으로 신뢰 구간 (confidence intervals)을 계산해야 하는 시점 전까지는 매우 편리합니다. 스키마 유효성 (schema-validity)과 같은 비율 (proportion) 데이터는 윌슨 구간 (Wilson interval)을 필요로 합니다. 재시도 (retries)와 같은 카운트 (count) 데이터는 0 미만으로 내려갈 수 없는 구간을 필요로 합니다. 지연 시간 꼬리 (latency tail) 데이터는 순서 통계 구간 (order-statistic interval)을 필요로 합니다. 만약 어그리게이터가 숫자 자체만 보고 타입을 추측한다면, 잘못된 추측을 하게 됩니다. 제가 이를 아는 이유는 이미 한 번 그런 일이 있었기 때문입니다. 재시도 횟수 (retry count)가 음수가 될 수 없는 수치에 대해 -0.035에서 0.285 사이의 신뢰 구간을 게시한 적이 있습니다.
해결책은 display_metrics가 support 필드를 포함하도록 하고, 어그리게이터가 이를 기준으로 라우팅 (routing)하는 것입니다.
display_metrics = [
{
"key": "schema_valid",
...
support는 통계적 주장 (statistical claim)입니다. format은 표시 (display)에 관한 사항입니다. 이전의 버그는 표시용 문자열인 format을 기반으로 구간 계산을 라우팅했기 때문에 발생했습니다. 이제 어그리게이터는 추측하기를 거부합니다.
def _support_by_metric(suite: Suite) -> dict[str, str]:
declared: dict[str, str] = {}
for metadata in suite.display_metrics:
...
만약 새로운 스위트 (suite)가 메트릭 키 (metric key)를 추가하면서 그 지원 범위 (support)를 선언하는 것을 잊는다면, 시스템은 조용히 기본값을 사용하는 대신 에러를 발생시킵니다. 메타데이터가 누락되었을 때 조용히 기본값을 사용하는 방식이 바로 처음에 음수 구간이 배포되었던 바로 그 방식이었기 때문입니다. 따라서 오픈 딕셔너리 (open dict)의 자유로움은 엄격한 요구사항에 의해 제한됩니다: 당신이 내보내는 모든 키에 대해, 반드시 분류 (classify)도 함께 수행해야 합니다.
계약 테스트 (contract test)가 등록된 모든 스위트에 대해 동일한 사항을 강제하므로, 이 요구사항은 제가 일일이 기억해야 하는 관례가 아닙니다. 각 스위트가 선언한 표시 키 (display keys)는 반드시 해당 스위트의 메트릭 키 (metric keys)의 부분 집합이어야 하며, 각 키는 유효한 support를 선언해야 합니다. 오픈 컬럼 (open column)은 자유롭습니다. 하지만 분류는 선택 사항이 아닙니다.
내가 다르게 했을 방식
내가 포기한 한 가지는 타입이 지정된 메트릭 컬럼 (typed metrics column)입니다. metrics: dict[str, float]는 메트릭 키의 오타가 타입 체커 (type checker)에 의해 실수 시점에 발견되는 것이 아니라, 애그리게이터 (aggregator)나 테스트에 의해 런타임 (runtime)에 발견됨을 의미합니다. 개인용 평가 (eval) 플랫폼의 경우, 스위트 (suite)의 개수가 적고 계약 테스트 (contract test)가 이를 커버하기 때문에 그러한 트레이드오프 (tradeoff)는 괜찮습니다. 만약 이것이 다른 사람들이 스위트를 작성하는 공유 라이브러리였다면, 잘못된 키가 정의 시점에 실패하도록 각 스위트마다 타입이 지정된 열거형 (enum)을 메트릭 키로 사용하고 싶었을 것입니다. 오픈 딕셔너리 (open dict)는 나에게 추가적인 스위트 (additive suites)를 가능하게 해주었습니다. 그 대가로 아직은 필요하지 않다고 판단한 컴파일 타임 (compile-time) 보장을 포기했습니다.
내가 계속해서 되새기는 교훈은 다음과 같습니다: 모든 프로듀서 (producer)가 이미 동의하는 로우 형태 (row shape)를 찾고, 그들이 동의하지 않는 부분은 하나의 오픈 필드 (open field) 뒤로 숨기며, 컨슈머 (consumer)가 절대 추측할 필요가 없도록 그 필드에 대해 충분히 명시하도록 강제하는 것입니다. 그 버그는 나에게 마지막 부분을 가르쳐 주었습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기