|
| 1 | +# BaseComponent |
| 2 | + |
| 3 | +`BaseComponent`는 **define-by-run(순수 파이썬 제어)** 철학을 유지하면서도, 컴포넌트 실행을 **관측 가능(observable)** 하게 만들기 위한 **선택적(opt-in) 표준 레이어**입니다. |
| 4 | + |
| 5 | +* 파이프라인은 `step(run: RunContext) -> RunContext` 형태의 **그냥 함수/콜러블**만으로도 충분히 동작합니다. |
| 6 | +* `BaseComponent`는 그 위에 **추적(hooks), 에러 표준화, 이름/형식 통일**을 얹어주는 역할을 합니다. |
| 7 | + |
| 8 | +즉, **필수는 아니지만**, 라이브러리/팀 단위 개발에서 “운영 가능한 형태”로 만들고 싶을 때 유용합니다. |
| 9 | + |
| 10 | +--- |
| 11 | + |
| 12 | +## 왜 필요한가? |
| 13 | + |
| 14 | +### 1) 관측성(Tracing)을 “그래프 엔진 없이” 얻기 위해 |
| 15 | + |
| 16 | +Lang2SQL은 LangGraph 같은 그래프 엔진을 강제하지 않습니다. 대신: |
| 17 | + |
| 18 | +* 사용자는 Python `if/for/while`로 제어한다. |
| 19 | +* 라이브러리는 관측성은 **hook 이벤트**로 제공한다. |
| 20 | + |
| 21 | +`BaseComponent`는 각 컴포넌트 실행의 `start/end/error`를 이벤트로 남깁니다. |
| 22 | + |
| 23 | +### 2) 에러를 “도메인 친화적으로” 정리하기 위해 |
| 24 | + |
| 25 | +현실에서는 `ValueError`, `KeyError`, 외부 라이브러리 예외 등이 섞여서 올라옵니다. |
| 26 | + |
| 27 | +`BaseComponent`는: |
| 28 | + |
| 29 | +* `Lang2SQLError`(ValidationError, IntegrationMissingError 등)는 **그대로 유지** |
| 30 | +* 그 외 예외는 `ComponentError`로 **표준 래핑**(+ 원인 예외를 `cause`로 보존) |
| 31 | + |
| 32 | +→ 사용자/운영자 관점에서 “어디서 터졌는지”가 분명해집니다. |
| 33 | + |
| 34 | +### 3) “컴포넌트 단위 표준”을 만들기 위해 |
| 35 | + |
| 36 | +라이브러리 제공 컴포넌트를 모두 BaseComponent 기반으로 만들면: |
| 37 | + |
| 38 | +* 로그/트레이스의 포맷이 통일 |
| 39 | +* 테스트/디버깅 경험이 일정 |
| 40 | +* 문서/타입 힌트가 일관 |
| 41 | + |
| 42 | +--- |
| 43 | + |
| 44 | +## 철학: Define-by-run + Minimal core |
| 45 | + |
| 46 | +Lang2SQL의 기본 철학은 아래 2개입니다. |
| 47 | + |
| 48 | +1. **제어는 파이썬으로** |
| 49 | + 루프/분기/재시도/서브플로우 호출은 “프레임워크 DSL”이 아니라 Python으로 표현합니다. |
| 50 | + |
| 51 | +2. **상태는 RunContext 하나로** |
| 52 | + 파이프라인이 커져도, step 간 연결이 깨지지 않도록 `RunContext`를 I/O로 둡니다. |
| 53 | + |
| 54 | +`BaseComponent`는 이 철학을 해치지 않습니다. |
| 55 | +컴포넌트의 실행을 감싸서 이벤트만 남길 뿐, 그래프/스키마/실행 모델을 강제하지 않습니다. |
| 56 | + |
| 57 | +--- |
| 58 | + |
| 59 | +## BaseComponent가 제공하는 API |
| 60 | + |
| 61 | +### 생성자 |
| 62 | + |
| 63 | +```python |
| 64 | +BaseComponent(name: str | None = None, hook: TraceHook | None = None) |
| 65 | +``` |
| 66 | + |
| 67 | +* `name`: 이벤트에 찍힐 컴포넌트 이름 (기본값: 클래스명) |
| 68 | +* `hook`: 이벤트 수신자. 기본값은 `NullHook()` (아무것도 하지 않음) |
| 69 | + |
| 70 | +### 구현해야 하는 것: `run()` |
| 71 | + |
| 72 | +```python |
| 73 | +class MyComp(BaseComponent): |
| 74 | + def run(self, run: RunContext) -> RunContext: |
| 75 | + ... |
| 76 | + return run |
| 77 | +``` |
| 78 | + |
| 79 | +### 실행: `__call__` |
| 80 | + |
| 81 | +`comp(run)`을 호출하면 내부적으로 아래를 자동 수행합니다. |
| 82 | + |
| 83 | +* `component.run start 이벤트 발행` |
| 84 | +* `self.run(...)` 실행 |
| 85 | +* 성공 시 `end 이벤트` + `duration_ms` |
| 86 | +* 실패 시 `error 이벤트` |
| 87 | + |
| 88 | + * 도메인 예외(`Lang2SQLError`)는 그대로 raise |
| 89 | + * 그 외 예외는 `ComponentError`로 래핑해서 raise |
| 90 | + |
| 91 | +--- |
| 92 | + |
| 93 | +## 권장 규약: RunContext in → RunContext out |
| 94 | + |
| 95 | +Lang2SQL의 기본 step 규약은 단순합니다. |
| 96 | + |
| 97 | +> **RunContext를 받으면 RunContext를 반환한다.** |
| 98 | +> (`return run`을 습관처럼) |
| 99 | +
|
| 100 | +왜냐하면 “None 반환”은 인간이 보기엔 자연스럽지만, 팀/사용자 관점에서는 실수를 만들기 쉽습니다. |
| 101 | + |
| 102 | +* `return None`은 “의도적”인지 “실수(반환 누락)”인지 구분이 안 됨 |
| 103 | +* Flow/컴포넌트 조합에서 결과가 조용히 깨지기 쉬움 |
| 104 | + |
| 105 | +그래서 Lang2SQL은 **fail-fast** 스타일을 권장합니다. |
| 106 | + |
| 107 | +--- |
| 108 | + |
| 109 | +## 언제 BaseComponent를 쓰는가? |
| 110 | + |
| 111 | +### ✅ BaseComponent를 쓰는 게 좋은 경우 |
| 112 | + |
| 113 | +* 라이브러리 기본 제공 컴포넌트( retriever/builder/generator/validator ) |
| 114 | +* 팀/제품 환경에서 **관측성(트레이싱)이 필요한 경우** |
| 115 | +* 예외 표준화가 중요한 경우(운영/테스트/디버깅) |
| 116 | + |
| 117 | +### ✅ BaseComponent 없이 함수로 두는 게 좋은 경우 |
| 118 | + |
| 119 | +* `policy`, `eval`, metric 계산처럼 **순수 함수 성격**이 강한 로직 |
| 120 | +* “유저가 빠르게 붙여 넣어 쓰는” 초경량 커스텀 로직 |
| 121 | +* 실행 단위가 너무 작아 이벤트가 과도해지는 경우 |
| 122 | + |
| 123 | +즉, **핵심 파이프라인 축**은 BaseComponent로 잡고, |
| 124 | +그 외의 작은 로직은 함수로 두는 혼합형이 가장 자연스럽습니다. |
| 125 | + |
| 126 | +--- |
| 127 | + |
| 128 | +## FunctionalComponent: “함수도 트레이싱하고 싶다” |
| 129 | + |
| 130 | +유저에게 “클래스 상속 + run 메서드 작성”이 부담인 경우가 많습니다. |
| 131 | +그래서 **함수/콜러블을 그대로 유지하면서**도 트레이싱을 얻고 싶다면 래퍼를 제공합니다. |
| 132 | + |
| 133 | +### 예시: FunctionalComponent |
| 134 | + |
| 135 | +```python |
| 136 | +from __future__ import annotations |
| 137 | +from typing import Callable, Any, Optional |
| 138 | + |
| 139 | +from .base import BaseComponent |
| 140 | +from .context import RunContext |
| 141 | + |
| 142 | +class FunctionalComponent(BaseComponent): |
| 143 | + """ |
| 144 | + Wrap a callable(run: RunContext) -> RunContext into a BaseComponent, |
| 145 | + so it becomes traceable and error-normalized. |
| 146 | + """ |
| 147 | + |
| 148 | + def __init__( |
| 149 | + self, |
| 150 | + fn: Callable[[RunContext], RunContext], |
| 151 | + *, |
| 152 | + name: str | None = None, |
| 153 | + hook=None, |
| 154 | + ) -> None: |
| 155 | + super().__init__(name=name or getattr(fn, "__name__", "FunctionalComponent"), hook=hook) |
| 156 | + self._fn = fn |
| 157 | + |
| 158 | + def run(self, run: RunContext) -> RunContext: |
| 159 | + return self._fn(run) |
| 160 | +``` |
| 161 | + |
| 162 | +### 사용 예 |
| 163 | + |
| 164 | +```python |
| 165 | +def my_retriever(run: RunContext) -> RunContext: |
| 166 | + run.schema_selected = ... |
| 167 | + return run |
| 168 | + |
| 169 | +retriever = FunctionalComponent(my_retriever, name="MyRetriever", hook=hook) |
| 170 | +``` |
| 171 | + |
| 172 | +> 이 방식의 장점: 유저는 “함수 스타일” 그대로 유지하면서, 운영/디버깅을 위한 트레이싱을 얻게 됩니다. |
| 173 | +
|
| 174 | +--- |
| 175 | + |
| 176 | +## 훅(Tracing) 시스템이 뭐고, 왜 필요한가? |
| 177 | + |
| 178 | +### Hook이란? |
| 179 | + |
| 180 | +컴포넌트/플로우 실행 시점에 **이벤트(Event)** 를 받는 인터페이스입니다. |
| 181 | + |
| 182 | +* `start/end/error` 시점 기록 |
| 183 | +* 소요 시간(duration_ms) |
| 184 | +* 입력/출력 요약(input_summary/output_summary) |
| 185 | +* 필요하면 `data`에 구조화된 값을 추가 |
| 186 | + |
| 187 | +### 어디서 확인하나? |
| 188 | + |
| 189 | +가장 쉬운 건 `MemoryHook`입니다. |
| 190 | + |
| 191 | +```python |
| 192 | +from lang2sql.core.hooks import MemoryHook |
| 193 | +hook = MemoryHook() |
| 194 | + |
| 195 | +flow = BaselineFlow(steps=[...], hook=hook) # 또는 컴포넌트마다 hook 주입 |
| 196 | +out = flow.run_query("지난달 매출") |
| 197 | + |
| 198 | +# 이벤트 확인 |
| 199 | +for e in hook.events: |
| 200 | + print(e.phase, e.component, e.duration_ms, e.error) |
| 201 | +``` |
| 202 | + |
| 203 | +### 운영용 관측성은 어디서 제어하나? |
| 204 | + |
| 205 | +운영에서는 `MemoryHook` 대신 다음이 일반적입니다. |
| 206 | + |
| 207 | +* 로그로 흘리는 Hook (stdout / JSON log) |
| 208 | +* APM/Tracing으로 보내는 Hook (OpenTelemetry span 등) |
| 209 | +* 필터링 Hook (특정 컴포넌트만 샘플링) |
| 210 | + |
| 211 | +핵심은: **관측성은 hook 구현체에서 제어**하고, 파이프라인/컴포넌트 코드는 최대한 “비즈니스 로직”만 갖도록 분리합니다. |
| 212 | + |
| 213 | +--- |
| 214 | + |
| 215 | +## 중첩(서브플로우/래핑)하면 트레이싱이 깨지나? |
| 216 | + |
| 217 | +“깨진다”기보다는 **이벤트가 더 많이 찍힙니다.** |
| 218 | + |
| 219 | +* `flow_b` 안에 `flow_a`를 step으로 넣으면 |
| 220 | + |
| 221 | + * `flow_b` 이벤트 2개(시작/끝) |
| 222 | + * `flow_a` 이벤트 2개(시작/끝) |
| 223 | + * `a1/a2` 컴포넌트 이벤트도 각각 찍힘(컴포넌트가 BaseComponent라면) |
| 224 | + |
| 225 | +이게 싫다면 두 가지 선택지가 있습니다. |
| 226 | + |
| 227 | +1. **상위 레벨(Flow)만 트레이싱하고 내부는 함수로 둔다** |
| 228 | +2. **Hook에서 필터링/샘플링한다** (예: component 이름 prefix로 제외) |
| 229 | + |
| 230 | +추가 문법 없이 해결하려면 2번이 가장 현실적입니다. |
| 231 | + |
| 232 | +--- |
| 233 | + |
| 234 | +## 베스트 프랙티스 |
| 235 | + |
| 236 | +### 1) 구성(config)은 `__init__`에, 요청별 상태는 `RunContext`에 |
| 237 | + |
| 238 | +```python |
| 239 | +class Retriever(BaseComponent): |
| 240 | + def __init__(self, catalog, top_k=8, ...): |
| 241 | + self.catalog = catalog # 고정 설정 |
| 242 | + self.top_k = top_k |
| 243 | + |
| 244 | + def run(self, run: RunContext) -> RunContext: |
| 245 | + # 요청마다 달라지는 값은 run에서 읽고 run에 쓴다 |
| 246 | + ... |
| 247 | + return run |
| 248 | +``` |
| 249 | + |
| 250 | +### 2) RunContext가 들어오면 무조건 `return run` |
| 251 | + |
| 252 | +* 가독성(계약이 분명) |
| 253 | +* 실수 방지(fail-fast) |
| 254 | +* flow 합성 시 안정 |
| 255 | + |
| 256 | +### 3) “작은 로직(policy/eval)은 그냥 함수” |
| 257 | + |
| 258 | +* BaseComponent로 감싸는 건 선택 |
| 259 | +* 운영에서 꼭 추적이 필요할 때만 FunctionalComponent로 감싼다 |
| 260 | + |
| 261 | +--- |
| 262 | + |
| 263 | +## FAQ |
| 264 | + |
| 265 | +### Q. “그냥 함수만 써도 되는데 왜 굳이 BaseComponent?” |
| 266 | + |
| 267 | +A. **운영/디버깅/협업에서** 차이가 큽니다. |
| 268 | +문제 났을 때 “어디서, 어떤 입력으로, 얼마나 걸리다, 어떤 에러로” 터졌는지 자동으로 남는 게 핵심 가치입니다. |
| 269 | + |
| 270 | +### Q. “BaseComponent를 유저가 직접 써야 하나?” |
| 271 | + |
| 272 | +A. 필수 아닙니다. |
| 273 | +초급 유저는 **SequentialFlow + 프리셋 컴포넌트**만으로 충분히 쓰게 하고, |
| 274 | +고급/운영 유저에게 BaseComponent/Hook을 제공하는 구성이 가장 자연스럽습니다. |
| 275 | + |
| 276 | +### Q. “policy는 RunContext를 몰라도 되는데?” |
| 277 | + |
| 278 | +A. 맞습니다. `policy(metrics) -> action` 같은 건 순수 함수로 두는 걸 권장합니다. |
| 279 | +필요하면 `FunctionalComponent(policy_fn)`처럼 감싸서 추적만 추가할 수 있습니다. |
| 280 | + |
| 281 | +--- |
0 commit comments