XGEN 15일 무료 체험 — 설치 없이 브라우저에서 바로 시작하세요체험 신청
PlateerAI Labs
·Reading Time | 5 min
작성 | 김진수

설치는 됐는데 같은 실행이라고 부를 수 없었습니다(3편)

워크플로우를 wheel로 묶어 다른 환경에 설치했습니다. 도구를 호출하자 제품 안에서만 성립하던 전제가 드러났습니다. 포장 형식을 두 번 갈아엎으며 무엇을 고정해야 실행이 옮겨가는지 확인한 과정입니다.

설치는 됐는데 같은 실행이라고 부를 수 없었습니다(3편) — 커버 일러스트Series · 하네스 개발기시리즈 3번째 글 · 전체 10편 보기

엔진과 제품을 나누고 나니 하네스를 제품 밖에서도 쓸 수 있을 것 같았습니다. 워크플로우를 wheel로 묶어 빈 환경에 설치했더니 import까지는 통과했고, 도구를 호출하는 순간 제품 안에서만 성립하던 전제가 전부 드러났습니다. 포장 형식을 Python에서 npm으로 갈아엎었다가 한 달 만에 되돌아오면서, 옮겨야 할 것이 코드가 아니라 실행 결정이었다는 것을 확인한 과정입니다.


첫 wheel은 코드를 옮겼지 실행 환경을 옮기지는 못했습니다

4월 20일 만든 첫 컴파일러는 캔버스 그래프와 하네스 설정을 스냅샷으로 만들고, 그것을 담은 Python wheel을 생성했습니다.

빈 환경에서 pip install이 됐고 import도 통과했습니다. 코드를 복사해 옮기던 것보다 분명히 한 걸음 나아갔습니다.

도구를 실제로 호출하자 멈췄습니다.

제품 프로세스 안에서는 설정 서비스가 모델 이름과 반복 횟수를 채워주고, 도구 레지스트리가 이름을 실행 함수에 연결하고, 데이터베이스가 세션을 이어줍니다. 스냅샷에 노드 식별자가 남아 있어도 독립 실행기에는 그 노드를 불러올 클래스도, 호출할 경로도 없었습니다.

패키지는 설치됐는데 같은 실행이라고 부를 수가 없었습니다.

반대 방향도 검토했습니다. 제품 패키지와 설정 서비스를 통째로 wheel에 넣으면 실행은 바로 됩니다. 그러면 독립 산출물이 제품의 축소 복제본이 되고, 2편에서 끊어낸 의존성이 배포 파일 안에서 그대로 되살아납니다.

두 선택지가 다 막히고 나서야 저희가 문제를 잘못 잡고 있었다는 것이 보였습니다. 컴파일의 목표는 지금 코드를 잘 포장하는 것이 아니라, 다른 런타임이 같은 실행 결정을 복원할 수 있는 규격을 만드는 것이었습니다.

무언가를 다른 환경으로 옮길 때 "설치되는가"를 완료 조건으로 두면 대체로 여기서 걸립니다. 설치는 파일의 문제이고, 실행은 그 파일이 무엇을 알고 있는가의 문제입니다.

무엇이 고정돼 있는지 몰라서 무엇을 얼려야 할지도 몰랐습니다

규격을 만들려면 먼저 하네스에서 무엇이 안 변하는지 알아야 했습니다.

일반 캔버스 워크플로우는 저장 시점에 노드와 간선으로 된 방향성 비순환 그래프입니다. 워크플로우마다 그래프 모양이 달라지고, 실행기는 그때마다 간선에서 실행 순서를 계산합니다. 라우터와 반복, 하위 워크플로우가 들어가면 분기 의미와 노드 구현, 포트 매핑까지 알아야 합니다.

캔버스 그래프를 그대로 도구로 감싸려면 제품 실행기와 노드 레지스트리를 통째로 외부에 옮겨야 하는 이유입니다.

하네스는 반대쪽이 고정돼 있습니다. 입력 정리, 대화 이력, 프롬프트, 도구 공개, 정책, 컨텍스트, 도구 실행, 판정, 결과 정리의 순서는 모든 실행에서 같습니다. 달라지는 것은 각 단계에서 쓸 전략과 도구, 판정 기준, 반복 상한입니다.

캔버스 워크플로우   그래프 모양이 매번 다르다      → 실행 순서를 매번 계산
하네스             단계 순서가 항상 같다          → 단계 안의 선택만 다르다

여기서 '정적'이라는 말을 조심해서 써야 합니다. 답이나 도구 호출 순서를 미리 정해둔다는 뜻이 아닙니다. 실행 제어의 골격과 단계별 입출력 규약이 고정돼 있다는 뜻입니다. 어떤 도구를 언제 부를지는 실행 중에 모델이 정합니다.

이 차이 덕분에 스냅샷에 하네스 설정, 도구 정의, 의존성, 외부 입력 이름을 동결하면 외부 런타임이 임의 그래프를 다시 해석하지 않아도 됐습니다. 생성된 MCP 서버는 인터페이스를 하나만 공개합니다.

run_workflow(input)
  → 고정된 하네스 상태 머신 실행
  → final_output 반환

복잡한 것을 외부에 공개할 때 흔히 기능을 줄이는 쪽을 봅니다. 실제로 봐야 할 것은 그 복잡함 중에 무엇이 이미 고정돼 있는가입니다. 고정된 부분이 있으면 그것을 계약으로 삼아 나머지 복잡함을 그대로 둔 채 공개할 수 있습니다.

npm으로 갈아엎자 규격은 보였는데 실행이 안 따라왔습니다

4월 29일 Python wheel 생성 경로를 걷어내고 npm 패키지 안의 spec.json을 기준으로 실행하는 방향으로 바꿨습니다.

Python 코드를 생성하는 데 집중하면 다른 런타임에서 계약을 읽기 어렵고, 패키지 안에 무엇이 보존됐는지 확인하기도 힘들었기 때문입니다. spec.json에는 그래프뿐 아니라 단계와 전략, 품질 문턱, 반복 상한, 입출력 스키마, 필요한 엔진 호환 범위가 들어갑니다.

이 전환은 목적을 이뤘습니다. 무엇을 동결해야 하는지가 파일 하나를 열어보면 드러났습니다.

그런데 실행이 따라오지 않았습니다. Python 환경에서 이미 쓰고 있던 공급자와 도구 어댑터를 다시 쓸 수 없었고, spec.json에 적힌 도구가 실제로 호출 가능한지 보장하는 계층도 없었습니다.

규격은 무엇을 옮겨야 하는지 보여줬지만, 옮기는 일까지 해주지는 않았습니다.

그래서 5월 중순 Python 컴파일러를 되살렸습니다. 처음으로 돌아간 것은 아닙니다. npm 전환에서 얻은 실행 규격을 Python 산출물에도 적용했습니다. 포장 형식은 둘이 됐지만 동결해야 할 의미는 하나로 맞췄습니다.

1차   Python wheel        코드는 옮겼으나 실행 전제가 남았다
2차   npm + spec.json     계약은 드러났으나 실행 경로가 없었다
3차   Python + 같은 규격   드러난 계약을 실제 호출까지 연결했다

기술 선택을 되돌리는 일은 실패로 기록되기 쉽습니다. 그런데 되돌아온 자리가 출발점과 같은 자리인지는 따로 봐야 합니다. 저희가 5월에 만든 Python 컴파일러는 4월의 것과 산출물 형식만 같고 담고 있는 것이 달랐습니다.

다섯 개의 선택지인 줄 알았는데 네 개의 층이었습니다

컴파일 화면에는 wheel, PyPI, npm, stdio MCP, 원격 MCP가 함께 나타납니다.

처음에는 이 다섯을 하나의 목록으로 놓고 "무엇을 고를 것인가"를 물었습니다. 그러니 왜 여러 개를 동시에 지원해야 하는지 설명이 안 됐습니다.

이들은 같은 층의 대안이 아니었습니다. 서로 다른 질문에 답하고 있었습니다.

선택지 결정하는 것
산출물 형식 Python wheel, npm tarball 어떤 런타임이 패키지를 설치하고 실행하는가
전달 경로 wheel 직접 전달, PyPI 등록, npm registry 산출물을 어디서 받아 버전별로 보관하는가
실행 진입점 Python API, 명령행, MCP 서버 설치한 워크플로우를 누가 어떤 형태로 호출하는가
MCP 전송 표준 입출력, Streamable HTTP 실행 중인 서버와 클라이언트가 어떤 연결로 대화하는가

PyPI는 패키지를 배포하고 버전을 고정하는 저장소이고, stdio는 설치된 프로세스가 표준 입출력으로 메시지를 주고받는 전송 방식입니다. 대체 관계가 아니라 설치 이전의 유통 문제설치 이후의 통신 문제입니다.

그래서 둘을 함께 쓸 수 있습니다.

PyPI에 워크플로우 패키지 등록
  → pip install
  → python -m <workflow>.mcp
  → 로컬 클라이언트와 stdio로 MCP 통신

PyPI가 없으면 wheel 파일을 사용자마다 전달하고 의존성과 버전을 따로 관리해야 합니다. stdio가 없으면 로컬 개발 도구가 설치된 패키지를 별도 서버 없이 자식 프로세스로 실행하기 어렵습니다.

이름이 한 화면에 같이 나온다는 이유로 대안 관계로 읽는 일이 자주 생깁니다. 두 기술이 함께 등장하면 먼저 물어야 할 것은 어느 쪽이 나은가가 아니라 둘이 같은 층에 있는가입니다.

같은 PyPI라는 이름이 두 가지를 가리키고 있었습니다

PyPI는 이 구조에서 두 번 등장합니다.

xgen-harness는 상태 머신과 도구 실행기를 제공하는 엔진 패키지입니다. 컴파일된 워크플로우 패키지는 동결한 설정과 도구 정의를 담고, 자신을 만든 엔진 버전을 정확히 의존합니다.

엔진만 설치한다고 특정 업무 도구가 생기지 않습니다. 워크플로우 패키지를 설치하면 필요한 엔진 버전이 함께 해석됩니다.

wheel 직접 전달과 PyPI 등록도 결과물의 의미는 같습니다. 검증이나 폐쇄망 반입처럼 파일을 직접 통제해야 하면 wheel을 전달하고, 여러 사용자가 같은 이름과 버전으로 설치해야 하면 PyPI를 씁니다. PyPI에 올린 버전은 같은 번호로 덮어쓸 수 없으므로 수정된 실행 계약은 새 버전으로 나가야 합니다.

실행 위치가 다르면 책임지는 사람도 달랐습니다

stdio에서는 클라이언트가 MCP 서버 프로세스를 직접 시작합니다. 포트를 열지 않고 환경 변수도 로컬 프로세스에 주면 되니 개인 개발 도구와 단일 사용자 실행에 단순합니다. 대신 클라이언트마다 프로세스가 생기고, 수명과 업데이트를 각 설치 환경이 관리해야 합니다.

원격 Streamable HTTP에서는 플랫폼이나 중앙 MCP 실행 서비스가 패키지를 실행하고 여러 클라이언트가 URL로 호출합니다. 중앙에서 새 버전을 배포하고 인증과 실행 기록을 관리하기 쉽지만, 서버 수명주기와 네트워크, 인증 같은 운영 책임이 붙습니다. 로컬 프로세스를 직접 시작할 수 없는 웹 기반 자동화 도구에는 이 경로가 필요했습니다.

원격 MCP를 직접 지원하지 않고 stdio 설정만 받는 클라이언트를 위해서는 런처를 두고, 런처가 stdio 요청을 원격 HTTP로 중계하게 했습니다.

로컬 실행:  client ──stdio──> 설치된 Python·Node 워크플로우 프로세스
원격 실행:  client ──HTTP───> 플랫폼 게이트웨이 ──> 중앙 워크플로우 실행
중계 실행:  client ──stdio──> npx launcher ──HTTP──> 플랫폼 게이트웨이

겉으로는 세 경우 모두 MCP 도구 하나로 보입니다. 실제 실행 위치와 인증 책임은 셋 다 다릅니다. 같은 도구를 쓰는 두 사용자가 서로 다른 운영 부담을 지고 있을 수 있다는 뜻입니다.

Python API와 명령행도 별도 엔진이 아닙니다. Python API는 다른 애플리케이션 프로세스 안에 워크플로우를 포함할 때, 명령행은 셸과 배치 작업에서 한 번 실행할 때, MCP는 도구 호출 규약이 필요한 외부 클라이언트에서 씁니다. 세 진입점이 같은 파이프라인 구성과 스냅샷을 읽어야 호출 표면이 달라도 실행 의미가 유지됩니다.

무엇을 얼릴지는 두 질문으로 갈렸습니다

동결 대상을 정하는 기준은 두 가지였습니다. 값이 달라질 때 에이전트의 의사결정이 바뀌는가, 그리고 다른 환경으로 복사해도 안전한가입니다.

동결: 단계·전이·판정 기준·도구 규격·입출력 스키마
주입: 자격 정보·환경별 주소·실행 시점의 세션

설정의 ${VAR} 참조는 컴파일할 때 변수 이름만 수집하고 실제 값은 실행 시점에 읽습니다.

너무 적게 얼리면 같은 산출물이 환경마다 다른 에이전트가 됩니다. 너무 많이 얼리면 비밀값이 새거나 운영 주소가 개발 패키지에 박힙니다.

컴파일러는 파일 생성기라기보다 이 경계를 검사하는 도구에 가까워졌습니다.

도구 설명은 문서가 아니라 호출 가능한 소스여야 했습니다

여기까지 하고도 독립 실행이 안 되는 구간이 남았습니다.

모델에게 도구 이름과 JSON 스키마를 보여주면 모델은 올바른 인자를 만듭니다. 그런데 산출물은 그 요청을 어디로 보낼지 몰랐습니다.

도구 정의를 모델이 읽는 설명으로만 다루고 있었던 것입니다. 제품 안에서는 도구 레지스트리가 그 간격을 메워주고 있었고, 밖으로 나오자 그 자리가 비었습니다.

5월 중순 이후 도구를 호출 종류와 호출 규격으로 정규화했습니다. HTTP 도구에는 메서드와 URL 템플릿, 헤더와 본문 매핑이 필요합니다. RAG 도구는 검색 서비스 연결과 결과 형식을, MCP 도구는 서버 실행 또는 중계 방식을 알아야 합니다. 이 정의를 동결된 도구 소스에 담아 제품 레지스트리 없이도 이름 검색부터 실제 호출까지 이어지게 했습니다.

호출 가능성만 담지는 않았습니다. HTTP 도구가 로컬·메타데이터 주소로 향하지 못하게 막고, 오류 메시지에 자격 정보가 섞이지 않게 했습니다. 제품이 대신 지켜주던 안전 경계도 계약에 포함해야 했습니다.

이 대목이 독립 배포를 준비하는 조직에서 가장 늦게 발견되는 부분입니다. 기능은 옮겼는데 그 기능을 감싸고 있던 보호가 안 따라옵니다. 보호는 대체로 코드가 아니라 환경에 있기 때문입니다.

하위 워크플로우와 캔버스 그래프가 마지막 경계였습니다

단일 도구를 동결한 뒤에는 워크플로우 자체를 도구로 부르는 경우가 남았습니다. 상위 에이전트가 하위 에이전트를 고르면 하위 설정과 도구도 같이 얼려야 합니다. 이름만 남기면 독립 환경에서 다시 제품 레지스트리를 찾습니다.

5월 말 선택된 도구와 하위 파이프라인을 재귀적으로 수집해 산출물에 넣었습니다. 하위 워크플로우가 또 다른 워크플로우를 부를 수 있으므로 깊이 제한과 순환 검사가 필요했습니다.

캔버스 그래프는 더 까다로웠습니다. 제품의 노드 클래스를 import하는 대신 노드와 간선, 포트 매핑을 실행 가능한 그래프 규격으로 바꾸고 독립 인터프리터가 읽게 했습니다. 이 단계에 와서야 단일 도구, 하위 파이프라인, 캔버스 워크플로우가 같은 산출물 모델 안에서 돌아갔습니다.

Python 산출물과 Node 산출물은 같은 코드를 쓰지 않습니다. 같은 스냅샷과 도구 정의를 입력으로 받고, 같은 이름이 같은 호출 의미를 갖게 맞췄을 뿐입니다. 두 런타임을 지원하는 대가로 스냅샷 동등성 회귀 테스트와 엔진 버전 고정을 계속 관리해야 합니다.

검증은 제품 밖에서 해야 의미가 있었습니다

검증을 제품 프로세스 안에서 하면 설정 서비스와 도구 레지스트리가 빠진 정보를 우연히 채워줍니다. 첫 wheel이 통과했던 이유도 그것이었습니다.

그래서 순서를 바꿨습니다. 스냅샷을 저장했다 다시 읽어 단계와 설정, 외부 입력 선언이 보존되는지 먼저 확인합니다. 그다음 생성된 wheel과 npm tarball을 빈 환경에 설치하고 명령행과 MCP 서버를 시작합니다.

도구 목록 조회에서 끝내지 않았습니다. HTTP·RAG·MCP 대표 도구와 하위 워크플로우를 실제로 호출했습니다. 캔버스 실행과 독립 실행을 비교할 때는 최종 문장이 아니라 사용한 도구, 적용한 판정 기준, 종료 이유를 맞춰봤습니다.

왕복처럼 보였지만 같은 자리로 돌아온 것은 아니었습니다

한 달 사이에 wheel을 만들었다가 npm으로 갈아탔다가 다시 Python으로 돌아왔습니다. 이력만 보면 제자리걸음입니다.

각 단계에서 알아낸 것이 달랐습니다. 첫 wheel은 코드를 옮기는 것으로는 실행이 옮겨가지 않는다는 것을 보여줬습니다. npm 규격은 무엇을 옮겨야 하는지를 파일 하나로 드러냈습니다. 돌아온 Python 컴파일러는 그 계약을 실제 도구 호출까지 연결했습니다.

저희가 두 번 갈아엎은 것은 포장 형식이었고, 그 사이에 만든 것은 실행 계약이었습니다. 포장은 두 개가 됐고 계약은 하나로 남았습니다.

여기까지 오면 워크플로우 하나가 외부에서 호출 가능한 도구가 됩니다. 그런데 그 도구가 내놓은 답을 받아들일지 말지는 아직 아무도 정하지 않고 있었습니다. 다음 편에서는 동결된 실행 안에서 답을 만드는 역할과 그 답을 채택하는 역할을 왜 따로 두었는지 다룹니다.


이전 편 → 저장소를 나눴는데 의존성은 그대로였습니다 (2편)

다음 편 → 답을 만든 모델에게 채점까지 맡기고 있었습니다 (4편)

#하네스#MCP#패키징#실행 계약
블로그 목록으로