플랫폼을 모르는 하네스 엔진: 코드 결합을 끊고 설정 진실원본을 하나로 모은 과정
공용 실행 코어 안에 있던 하네스를 독립 엔드포인트로 옮기고, 엔진이 알던 플랫폼 코드 2,251줄을 Protocol과 entry_points 뒤의 브리지 계층으로 넘겼습니다. 경로마다 달랐던 설정 조회는 우선순위 체인과 공용 매핑으로 모았고, 배포 이원화를 없애려 엔진을 SDK로 흡수한 뒤 다시 생긴 사본 분기까지 기록했습니다.
Key Results
- 2,251줄
- 엔진에서 제거한 플랫폼 특화 코드v0.22.0(2026-04-24), 플랫폼 쪽 브리지 계층으로 2,040 LOC 이관
- 44/44
- 도구 노드 로드노드 레지스트리 조회 키·필드 매칭 수정 후, 수정 전 44개 중 39개 누락
- 161파일
- SDK로 흡수한 엔진 소스전부 상대 import라 문자열 동적 import 4곳만 수정, SDK 경로에서 엔진 테스트 225건 통과
XGEN의 하네스 엔진은 여러 입구로 실행됩니다. 캔버스의 하네스 노드, 저장된 워크플로우, 하네스 전용 SSE 엔드포인트, MCP 도구 호출, 그리고 플랫폼 밖에서 실행되는 컴파일 산출물입니다. 어느 입구로 들어와도 같은 설정이면 같은 행동을 해야 합니다. 엔진이 특정 플랫폼의 코드나 설정 저장소를 직접 알고 있으면 이 조건이 성립하지 않습니다.
2026년 4월부터 7월까지 이 경계를 세 번 다시 그었습니다. 처음에는 코드였습니다. 엔진이 플랫폼 어댑터를 품고 있었습니다. 다음은 설정이었습니다. 코드를 끊은 뒤에도 "설정을 어디서 읽는가"가 경로마다 달라 값이 조용히 빠졌습니다. 마지막은 배포였습니다. 엔진과 SDK가 따로 배포되면서 엔진 수정이 SDK 사용자에게 도달하지 못했습니다.
이 글은 세 경계를 어떤 증상으로 발견했고 어떤 기준으로 나눴는지, 그리고 7월 감사에서 다시 드러난 사본 분기까지를 다룹니다.
공용 실행 코어 안의 분기에서 시작했습니다
초기 하네스는 플랫폼의 공용 실행 코어 안에 분기로 들어가 있었습니다. 실행 코어에 하네스 설정이 있는지 검사하는 분기와 하네스 실행 함수가 레거시 워크플로우 실행기와 같은 경로를 공유했습니다. 4월 17일 팀 방침에 따라 메인 코드베이스에서 하네스 관련 코드를 모두 제거했습니다. 레거시 실행기는 손대지 않았습니다.
같은 날 이 방침의 본질을 "레거시 실행 경로를 공유하는 통합"이 문제라는 것으로 해석하고, 하네스를 레거시와 공유하지 않는 독립 스트리밍 엔드포인트로 다시 연결했습니다. 기존 실행 엔드포인트는 전혀 건드리지 않았습니다. 그 전주 검증 미들웨어를 실행기에 넣었다가 되돌린 것과 같은 교훈이었습니다. 하네스는 기존 경로에 들어가지 말고 옆에 따로 서야 했습니다.
엔진 쪽 추상화는 이미 일부 있었습니다. 4월 16일 DB·설정·MCP·문서 서비스를 Protocol로 추상화한 ServiceProvider가 들어왔고, 독립 실행용 Null 구현과 플랫폼 연동용 구현으로 나뉘었습니다. 같은 날 서비스 URL을 중앙에서 관리하는 레지스트리, 환경변수 쓰기 대신 contextvars로 실행 컨텍스트를 격리하는 ExecutionContext, entry_points 플러그인 로딩도 들어왔습니다. 하지만 엔진 저장소 안에는 여전히 플랫폼 전용 연동 디렉터리와 어댑터가 있었습니다.
엔진에서 2,251줄을 잘라 브리지 계층으로 옮겼습니다
4월 24일 엔진 v0.22.0에서 플랫폼 연동 디렉터리와 플랫폼 어댑터를 삭제했습니다. 2,251줄입니다. 같은 날 플랫폼 저장소에 브리지 계층을 만들고 2,040 LOC를 옮겼습니다. 플랫폼 어댑터, 서비스 클라이언트, 노드 어댑터, 워크플로우 변환, 스트리밍 변환, 노드 제어 정책이 이 계층으로 갔습니다. 같은 코드가 저장소를 건너 소유권을 바꾼 것입니다. 이관하면서 플랫폼의 엔진 의존성 핀은 그대로 두어, 두 저장소가 각자 릴리즈할 수 있게 순서를 설계했습니다.
| 선택지 | 장점 | 단점 | 판단 |
|---|---|---|---|
| 엔진 안에 플랫폼 어댑터 유지 | 연동이 빠름 | 엔진이 플랫폼을 알아 컴파일 산출물·외부 배포에서 동작을 보장할 수 없음 | 기각 |
| 실행 시 클러스터로 요청 전달(프록시) | 플랫폼 환경을 그대로 재현 | 산출물이 클러스터 없이 동작하지 않음 | 5월 npm 엔진에서 도입 당일 철회 |
| Protocol·entry_points로 절단, 플랫폼 코드는 브리지로 | 엔진은 플랫폼을 모르고 플랫폼이 기능을 주입 | 브리지에서 값 전달을 따로 검증해야 함 | 채택 |
절단 이후 엔진이 아는 것은 계약뿐입니다. 플랫폼 노드는 4개 필드의 dataclass로 등록하면 duck typing으로 감지되는 ExternalNodeRef Protocol로 받습니다. 플랫폼은 entry_points로 배포 대상, 옵션 소스, 오류 패턴을 주입합니다. 도구는 같은 날 v0.25에서 ToolSource를 유일한 공급 채널로 승격하면서, 단계 파라미터에 하드코딩돼 있던 4종(MCP 세션, 커스텀 도구, 노드 태그, CLI 스킬)을 "도구 소스 ID → 도구 이름 목록"의 단일 맵으로 바꿨습니다.
이 커밋에는 검증에 관한 기록도 하나 남았습니다. 앞선 내부 감사가 죽은 코드라고 지목한 이벤트들이 실제 코드에서는 14건 발생하고 있었습니다. 감사 결과를 그대로 따르지 않고 실제 코드로 확인한 뒤 유지했습니다.
독립을 선언한 이틀 뒤 값이 조용히 빠지기 시작했습니다
4월 26일 운영 환경에서 하네스를 직접 호출해 점검하자, 엔진에 플랫폼 import가 하나도 없는데도 값이 엔진까지 도달하지 않는 문제가 연달아 나왔습니다.
- MCP 클라이언트가 요청 헤더를 0개 보내 내부 MCP 서버가 401과 빈 도구 목록을 반환했습니다. 이틀 전 v0.25에서 요청 헤더를 하위 호출로 전파하는 contextvar를 도입했는데, MCP 클라이언트에만 적용되지 않았습니다. 결과는 도구 0개였습니다.
- 플랫폼의 노드 레지스트리 로더가
nodes키를 찾았지만 실제 파일의 최상위 키는node_registry였습니다. 게다가 categoryId만 허용 목록과 비교해 functionId 쪽에 분류된 노드를 놓쳤습니다. 도구 노드 44개 중 39개가 빠졌습니다. - provider가 base_url을 프로세스 환경변수에서만 읽어, Redis에서 갱신한 엔드포인트가 반영되지 않았습니다. 같은 계열로 api_key 조회 우선순위와 DAG 노드의 환경변수 덮어쓰기에 대한 안전망도 이어서 고쳤습니다.
세 건은 모두 "값을 어디서 어떻게 읽는가"의 문제였습니다. 첫 번째는 새로 도입한 전파 메커니즘이 모든 호출 지점에 적용되지 않은 경우, 두 번째는 설정 파일의 키와 필드를 잘못 읽은 경우, 세 번째 계열은 설정의 진실원본(Redis, 하네스 설정)과 프로세스 환경변수가 어긋난 경우였습니다. import 그래프가 깨끗하다는 것은 정적으로 확인할 수 있지만, 값이 실제로 엔진까지 도달하는지는 실행을 봐야만 알 수 있었습니다.
설정을 어디서 읽는지 한 곳에서 정했습니다
이 문제의식은 5월에 구조로 정리됐습니다. 5월 14일 v1.10에서 하네스 설정 해석을 5단계 우선순위 체인으로 명시했습니다. base_url이나 api_key 같은 값이 어느 출처에서 오는지 코드로 드러나게 하려는 것이었습니다.
HarnessConfig.resolve
code → env → file → cluster origin → SDK default
(앞 단계에 값이 있으면 그 값을 쓰고, 없으면 다음 단계로)플랫폼 쪽은 하네스 v2 전환을 거치며 설정을 세 단계로 구성하는 공용 매핑을 하나로 두고, 캔버스 실행, 저장된 워크플로우 실행, 컴파일이 모두 이 매핑을 공유하게 했습니다. 7월 아키텍처 문서 기준의 구성은 다음과 같습니다.
1. 노드 파라미터
2. 연결된 노드 역매핑: 도구 노드 → 도구 목록, RAG 노드 → 컨텍스트 + rag_top_k, 스키마 → response_format
3. 플랫폼 설정 폴백: 노드 값 → Redis 영속 설정 → 환경변수진입 경로도 정리했습니다. 캔버스 노드, 저장된 워크플로우, 하네스 SSE, MCP 도구 호출의 네 경로가 모두 같은 어댑터로 수렴합니다. 경로마다 설정을 다르게 만들 수 있는 자리를 없애는 것이 목표였습니다.
단일 매핑이 왜 필요했는지 보여 주는 사례가 6월에 나왔습니다. 노드의 "검색당 문서 수" 파라미터가 아무 효과가 없었습니다. 그 값은 컨텍스트 단계(s06)의 파라미터로 주입됐는데, 엔진의 도구 단계는 자기 단계 파라미터만 읽었습니다. 무엇을 넣어도 4로 고정됐습니다. RAG 본문 전달 방식 파라미터도 s06에 주입되면 읽히지 않았고, 설령 읽혀도 엔진이 폐기된 설정값을 정규화하는 과정에서 원문 전체(eager) 값이 스니펫(progressive)으로 바뀌는 이중 사문 상태였습니다. 검색당 문서 수 파라미터는 삭제하고 연결된 검색 노드의 top_k를 하네스 설정 최상위로 전파하는 경로로, 전달 방식 파라미터는 엔진이 실제로 읽는 도구 단계 파라미터로 다시 연결했습니다.
6월의 연결 실패: 진실원본을 잘못 짚었던 몇 시간
6월 29일 로컬 스택의 캔버스에서 하네스 노드를 provider=vllm으로 실행하면 항상 All connection attempts failed가 났습니다. 몇 시간 동안의 디버깅에서 두 번 잘못 짚었습니다.
하나는 엔진의 빌트인 provider가 os.environ만 읽는다는 진단이었습니다. 이 진단에 따라 서버 시작 시점, 노드, loopback 엔드포인트 세 곳에 vLLM 설정을 환경변수로 복사하는 코드를 넣었지만 연결은 여전히 실패했습니다. 다른 하나는 워크플로우 컨테이너 DB의 vLLM 설정 행을 고친 것으로, 이것도 효과가 없었습니다.
어댑터의 설정 조회 코드를 따라가자 provider의 model과 base_url은 워크플로우 DB가 아니라 코어 서비스의 설정 저장소에서 왔습니다. 어댑터가 설정 클라이언트로 코어 서비스에 값을 요청하고, 코어 서비스는 Redis와 DB로 된 자체 설정 저장소를 갖고 있었습니다. 거기에는 더 이상 응답하지 않는 로컬 호스트 엔드포인트와 이전 Qwen3 4B 모델 이름이 남아 있었습니다.
수정은 설정 UI가 쓰는 정규 경로였습니다. 코어 서비스의 설정 갱신 API로 base_url과 모델 이름을 고치자 Redis와 DB에 함께 반영됐고, 재시작 없이 바로 연결됐습니다. 로그에 provider=vllm, model=Qwen3.6-27B로 실행된 것이 찍혔고, 캔버스 채팅에서 검색을 먼저 시도하는 동작까지 확인했습니다.
그다음 오진에 근거해 넣었던 환경변수 복사 코드 세 블록을 모두 삭제했습니다. 삭제 후 재시작하고 다시 실행해도 연결이 유지됐습니다. 복사 코드가 처음부터 필요 없었다는 것을 실제로 확인한 것입니다. 우선순위 체인이 명시된 시스템에서 증상을 우회하는 코드를 남기면, 그 코드가 다음 진단의 잡음이 됩니다.
배포 경계: 엔진을 SDK로 흡수하고 다시 생긴 분기
6월 중순에는 배포 경계가 문제가 됐습니다. 엔진은 독립 저장소와 PyPI 패키지로, 플랫폼 서비스들이 쓰는 SDK는 별도 패키지로 배포됐습니다. 처음에는 SDK가 엔진을 의존성으로 가져오는 facade를 만들었습니다. 의존 방향은 SDK에서 엔진으로만 두고, 엔진의 인터페이스 모듈에는 SDK를 import하지 않는다는 보장을 명시했습니다. 리뷰에서는 "의존성만 넣은 것은 편입이 아니다"라는 피드백을 받았습니다. 그래서 1.16.1에서 provider 키를 SDK 설정에서 먼저 찾고 없으면 환경변수로 폴백하는 설정 연결, 엔진의 세션 저장소 Protocol을 SDK DB로 구현한 세션 저장소, 엔진 이벤트를 SDK 로거로 보내는 로깅 연결, 앱 객체에서 바로 하네스를 만드는 메서드까지 4개 층으로 SDK 인프라에 연결했습니다. 그 뒤 최종 결정은 엔진 소스 전체를 SDK 안으로 흡수(vendoring)하는 것이었습니다. 엔진 수정이 SDK 릴리즈로 나가지 못하는 배포 이원화를 없애야 했기 때문입니다.
| 선택지 | 장점 | 단점 | 판단 |
|---|---|---|---|
| SDK가 엔진 패키지를 의존 | 엔진 독립 유지 | 엔진 수정이 SDK 릴리즈와 따로 움직임 | 리뷰에서 기각 |
| 엔진 소스를 SDK에 흡수 | 하네스 수정이 곧 SDK 릴리즈 | 독립 엔진과 사본이 두 벌 | 채택 |
| 독립 엔진 즉시 폐기 | 사본이 하나 | 기존 소비자에 영향 | 보류, 레거시로 유지 |
161개 파일을 옮겼습니다. 엔진 내부가 전부 상대 import였기 때문에 트리를 옮기는 것만으로 동작했습니다. 예외는 단계 모듈 경로를 문자열로 만들어 동적 import하던 4곳이었습니다. 정적 import가 아니어서 grep으로 절대 import를 찾을 때 걸리지 않았고, 패키지 위치에서 경로를 파생하도록 고쳤습니다. entry_points 그룹명은 외부 플러그인 계약이므로 그대로 두고 등록 대상 경로만 바꿨습니다. 같은 작업에서 LangChain 어댑터를 삭제했고, 플랫폼 실행 경로를 감사해 디스패치가 0건이던 멀티에이전트 오케스트레이터 패키지도 삭제했습니다. 이름이 비슷해 함께 묶일 뻔했던 capabilities는 실제로 도구 단계의 핵심 경로에서 쓰이고 있어 유지했습니다.
결과와 남은 과제
| 지표 | 이전 | 이후 | 측정 조건 |
|---|---|---|---|
| 엔진 안의 플랫폼 특화 코드 | 2,251줄 | 0, 브리지로 2,040 LOC 이관 | v0.22.0, 2026-04-24 |
| 도구 노드 로드 | 44개 중 39개 누락 | 44/44 | 레지스트리 키·필드 매칭 수정 후 |
| 캔버스 vLLM 연결 | 항상 ConnectError | 즉시 연결, 복사 코드 제거 후에도 유지 | 로컬 스택 |
| SDK 흡수 | 별도 패키지 의존 | 161파일, 엔진 테스트 225 passed | SDK 경로로 치환한 엔진 테스트 |
| SDK wheel 의존성 | 해당 없음 | LangChain 0, 구 엔진 패키지 0 | 로컬 wheel 빌드 |
import xgen_sdk만으로는 하네스가 로드되지 않는 것도 확인해, 기존 SDK 사용자에게는 영향이 없었습니다. 흡수 검증에 쓴 엔진 테스트 15개 파일은 SDK 저장소에 테스트 파일을 두지 않는다는 규칙에 따라 검증 후 제거했습니다.
독립 엔진이 SDK보다 앞서 나가면 차이를 확인해 그 부분만 옮기는 동기화 방식도 정했습니다. 같은 날 독립 엔진이 자가 단조 기능으로 1.23.0까지 진행한 것을 반영할 때, 변경분은 LLM 호출 파일 하나와 새 패키지 9개 파일뿐이어서 그것만 옮기고 SDK 쪽에서 삭제한 부분은 보존했습니다. 이때 judge의 보조 호출 토큰 상한 설정이 "키는 있는데 값이 None"인 상태라 기본값 폴백이 적용되지 않고 int(None)으로 실패하던 잠복 결함도 고쳤습니다. judge가 조용히 실패하면 평가 실패로 재시도가 발생하므로 비용과 직결되는 결함이었습니다.
경계는 한 번 그은 뒤에도 다시 흐트러졌습니다. 7월 13일 엔진 로직은 SDK를 먼저 고치고 검증한다는 운영 규칙을 정했지만, 7월 15일 구조 감사에서 SDK와 독립 엔진 저장소의 공통 상대경로 145개 중 38개의 내용이 달랐습니다. SDK에서 삭제한 DAG·멀티에이전트 오케스트레이션은 독립 엔진에만 남아 있었고, SDK 쪽에는 하네스 테스트 소스가 없었습니다. SDK를 유일한 정본으로 두고 독립 엔진 저장소를 얇은 호환 어댑터로 줄이는 계획을 세워 구현에 들어갔지만, 이 작업은 로컬 스택 검증까지만 진행했고 커밋과 배포는 하지 않았습니다.
돌아보면 세 번의 경계는 각각 다른 검사가 필요했습니다. 코드 경계는 import 그래프로, 설정 경계는 값이 실제로 엔진까지 도달하는지로, 배포 경계는 두 사본의 내용 비교로 확인해야 했습니다. 앞의 검사를 통과했다고 다음 경계가 닫힌 것은 아니었습니다.