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

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

엔진을 별도 모듈로 옮겼지만 실행하려면 제품의 설정 객체와 데이터베이스 세션, 캔버스 노드를 먼저 불러와야 했습니다. 독립을 선언한 사흘 뒤부터 누수를 잡으며 배운, 경계가 닫혔는지 확인하는 방법입니다.

저장소를 나눴는데 의존성은 그대로였습니다(2편) — 커버 일러스트Series · 하네스 개발기시리즈 2번째 글 · 전체 10편 보기

엔진을 별도 저장소로 떼어내고 나서 저희는 분리가 끝났다고 생각했습니다. 그런데 제품 패키지가 없는 환경에 올리자 import부터 실패했습니다. 의존성은 코드가 어느 폴더에 있는지가 아니라 무엇을 알아야 실행되는지로 정해진다는 것을, 독립을 선언하고 사흘 뒤부터 누수를 잡으면서 배웠습니다.


폴더를 옮겼다고 의존성이 옮겨가지는 않았습니다

1편에서 제품 실행기에 판정을 끼우는 대신 독립 상태 머신을 만들기로 한 이유는 변경 범위를 줄이기 위해서였습니다. Python으로 옮긴 실행기를 XGEN Agentic AI Platform(이하 XGEN)의 워크플로우에 연결하자, 같은 결합이 다른 모습으로 돌아왔습니다.

엔진 코드는 분명히 별도 모듈에 있었습니다. 그런데 실행하려면 제품의 설정 객체와 데이터베이스 세션, 캔버스 노드 클래스를 먼저 불러와야 했습니다.

제품 안에서만 돌릴 때는 이 결합이 오히려 편합니다. 설정 서비스에서 모델 이름을 바로 읽고, 노드 레지스트리에서 도구를 찾고, 제품 이벤트 형식으로 로그를 내보내면 됩니다.

같은 엔진을 제품 없는 환경에 올리자 실행이 시작되기도 전에 멈췄습니다. 캔버스 스키마가 바뀌면 범용 상태 머신도 같이 고쳐야 했습니다.

저장소가 나뉜 것과 의존성이 나뉜 것은 다른 일이었습니다. 저희는 파일의 위치를 옮겨놓고 경계를 만들었다고 부르고 있었습니다.

그래서 기준을 한 문장으로 다시 적었습니다. 제품은 하네스를 알아도 되지만, 하네스의 실행 원리는 제품을 몰라야 한다.

모듈을 쪼개는 리팩터링을 계획하는 조직이라면 완료 조건을 먼저 정해두는 편이 좋습니다. "파일을 옮겼다"는 관측할 수 없지만, "제품 패키지 없이 import가 통과한다"는 관측할 수 있습니다.

제품 객체를 인자로 넘겼더니 결합만 눈에 안 보이게 됐습니다

첫 시도는 전역 import를 걷어내고 엔진 함수에 제품 객체를 인자로 넘기는 것이었습니다. import 목록은 깨끗해졌습니다.

그런데 엔진은 여전히 그 객체의 필드와 생명주기를 알고 있었습니다. 테스트를 하려면 거대한 가짜 제품 객체를 만들어야 했고, 제품 모델의 필드 하나가 바뀌면 엔진이 깨졌습니다.

전역 import를 인자로 바꾼 것은 결합을 없앤 것이 아니라 결합을 눈에 덜 띄게 만든 것이었습니다. 컴파일 시점의 의존이 실행 시점의 의존으로 자리만 옮겼습니다.

코어가 실제로 필요로 한 것은 제품 객체가 아니라 몇 가지 동작이었습니다. 4월에 먼저 끊은 경계가 이것입니다.

DatabaseService → 실행에 필요한 영속 데이터를 읽고 쓴다
ConfigService   → 제품 설정을 실행 설정으로 제공한다
MCPService      → 등록된 MCP 연결을 조회한다
DocumentService → 문서와 검색 자원을 제공한다
ToolSource      → 도구를 찾고 정해진 입력으로 호출한다

구현이 제품 데이터베이스인지 메모리 저장소인지, 캔버스 노드인지 외부 MCP 서버인지는 코어가 정하지 않습니다. 이 경계를 세우고 나서야 제품 환경 없이 가짜 모델과 도구만으로 상태 전이 전체를 돌릴 수 있었습니다.

이후 모델 공급자와 세션, 실행 이벤트가 늘면서 계약도 함께 늘었습니다. 처음부터 전부 그린 것은 아니고, 실행 기능이 추가될 때마다 같은 원칙으로 하나씩 붙였습니다.

작은 인터페이스의 값어치는 테스트 편의가 아니었습니다. 제품이 제공해야 할 최소 계약이 목록으로 보이게 됐다는 점입니다. 엔진이 새 제품 필드를 요구하기 시작하면 그것이 인터페이스 변경으로 드러나고, 제품 내부 객체가 슬며시 코어로 들어오는 것도 막힙니다.

정책을 코어에서 걷어내려다 절반만 걷어냈습니다

인터페이스를 나눠도 경계가 완성되지는 않았습니다. 특정 모델 이름, 조직 전용 도구, 검색 별칭, 기본 컬렉션 같은 값이 코어에 남아 있으면 제품 정책이 다른 모습으로 계속 삽니다.

여기서 저희가 처음에 잘못 나눈 것은 "정책은 전부 제품으로"였습니다. 그렇게 밀어내자 이번에는 정책을 언제 적용해야 하는지를 제품이 알아야 했고, 제품이 상태 머신의 내부 순서를 다시 알게 됐습니다.

경계는 정책의 유무가 아니라 정책의 두 부분에 있었습니다.

코어가 소유   언제 볼 것인가   실행 전 입력 / 모델 응답 뒤 / 도구 호출 직전 / 회차 경계
제품이 소유   무엇을 볼 것인가  무엇을 차단할지 · 어느 도구를 노출할지 · 한도를 얼마로 둘지

검사 지점은 상태 머신의 일부이고, 검사 내용은 제품의 것입니다. 이렇게 갈라놓자 새 제품 요구가 생겨도 상태 머신에 조건문을 넣지 않고 연결 계층의 변환과 정책 구현만 바꾸면 됐습니다.

확장도 같은 방식으로 처리했습니다. 공급자와 도구 종류가 적을 때는 코어에 분기를 넣는 것이 빠르지만, 제품별 분기가 늘면 코어 릴리스가 모든 통합 변경의 병목이 됩니다. 그래서 공급자, 도구 소스, 정책 검사, 평가 전략, 세션 저장소를 등록 지점으로 받게 했습니다.

다만 등록 지점을 만드는 일은 조건문을 지우는 일보다 컸습니다. 같은 이름의 구현이 충돌하면 무엇을 고를지, 플러그인을 불러오지 못하면 실행을 중단할지 그 기능만 끌지, 입력 스키마와 오류 형식은 어디까지 공개 계약인지를 전부 정해야 했습니다. 확장 지점은 분기를 없애는 대신 발견·선택·실패의 규칙을 새로 요구합니다.

캔버스를 아는 코드는 지우는 게 아니라 옮길 자리가 필요했습니다

가장 많은 제품 의존성은 캔버스 실행 경로에 있었습니다. 노드와 포트, 사용자 권한, 스트리밍 이벤트는 XGEN에서 중요한 정보이지만 범용 엔진의 개념은 아닙니다.

이 코드는 지울 수 있는 종류가 아니었습니다. 누군가는 캔버스의 노드를 하네스 설정으로 바꿔야 하고, 연결된 노드를 도구 목록으로 만들어야 하고, 엔진 이벤트를 제품의 스트리밍 응답으로 옮겨야 합니다.

4월 말 이 책임을 harness_bridge라는 별도 계층으로 모았습니다. 2천 줄가량의 XGEN 특화 코드가 엔진에서 이식 계층으로 옮겨갔습니다.

캔버스·제품 API → harness_bridge → 하네스 공개 계약 → 상태 머신

이때 지킨 규칙이 하나 있습니다. bridge는 상태 머신의 내부 필드를 직접 고치는 우회로가 아닙니다. 모든 제품 값은 공개된 설정과 인터페이스를 통해서만 들어갑니다. 그래야 캔버스에서 실행한 경로와 독립 실행 경로가 같은 단계와 전이를 씁니다.

경계를 만들 때 "이 코드를 없애자"로 접근하면 대체로 막힙니다. 그 코드가 하는 일은 실제로 필요하기 때문입니다. 없앨 코드와 옮길 코드를 먼저 갈라야 논의가 진행됩니다.

독립을 선언하고 사흘 뒤부터 누수를 잡았습니다

4월 24일 엔진에서 XGEN 전용 어댑터를 삭제했습니다. 커밋 메시지에 "엔진 독립성 완결"이라고 적었습니다. 그날 기준으로 코어에는 제품을 가리키는 import가 하나도 없었습니다.

이틀 뒤부터 사흘간, 제품 값이 엔진까지 도달하지 못하는 문제를 연달아 잡았습니다. 그중 하나는 캔버스에서 도구를 붙였는데 실행 시점에 도구 목록이 비어 있는 증상이었습니다. 엔진은 도구가 없다고 판단하고 정상적으로 그 경로를 탔습니다.

import가 사라졌다고 값이 흐르는 것은 아니었습니다. 경계를 만들면서 제품과 엔진 사이에 변환 계층이 하나 생겼고, 그 변환에서 조용히 떨어지는 값들이 있었습니다. 이 기간에만 관련 패치가 스무 개 넘게 나갔습니다.

그러니까 4월 24일에 저희가 완결한 것은 독립이 아니라 독립의 컴파일 조건이었습니다. 실제 독립은 그 뒤로 실행을 관측하면서 확인해야 하는 것이었습니다.

선언할 수 있는 것   코어에 제품 import가 없다        → 정적으로 확인 가능
확인해야 하는 것    제품 값이 엔진까지 실제로 닿는다   → 실행을 봐야 알 수 있다

의존성 제거를 완료 보고하는 자리에서 이 구분이 자주 사라집니다. import 그래프가 깨끗한 것과 기능이 온전한 것은 서로 다른 관측입니다. 전자는 하루면 끝나고 후자는 그다음 주에 나타납니다.

그래서 검증도 세 자리에서 따로 했습니다

누수를 겪고 나서 검증을 세 경계로 나눴습니다.

코어 테스트는 제품 패키지와 데이터베이스가 없는 환경에서 시작합니다. 가짜 모델과 도구, 메모리 저장소만으로 도구 회차와 품질 재시도, 종료 조건이 동작해야 합니다. 여기서 제품 import가 하나라도 필요하면 경계가 아직 닫히지 않은 것입니다.

bridge 테스트는 반대 방향을 봅니다. 캔버스 값이 하네스 설정으로 변환되는지, 연결된 노드가 표준 도구 호출로 이어지는지, 엔진 이벤트가 스트리밍 응답에 담기는지를 확인합니다. 앞의 누수는 전부 이 구간에서 났습니다.

독립 패키지는 제품 의존성이 없는 깨끗한 설치 환경에서 import와 실행을 따로 통과시켰습니다.

세 개의 배포 경로는 중복이 아니라 서로 다른 책임이었습니다

두 달 뒤 실행 코드를 xgen-sdk 안으로 옮겼습니다. XGEN 서비스들이 이미 그 배포 단위에 의존하고 있어서, SDK 한 패키지를 올리면 하네스 실행 API와 XGEN용 구현을 함께 받을 수 있게 하려는 것이었습니다.

이때 독립 배포용 xgen-harness 패키지를 정리하고 싶은 마음이 있었습니다. 같은 엔진이 두 곳에 있으면 관리 비용이 두 배니까요.

없앨 수 없었습니다. 제품 밖에서 엔진만 쓰는 소비자가 있었고, 이미 특정 엔진 버전을 고정해둔 컴파일 산출물이 있었기 때문입니다. 그 산출물들은 SDK를 알지 못합니다.

xgen-harness      제품 밖 실행 · 기존 컴파일 산출물의 버전 호환
xgen_sdk.harness  XGEN 서비스가 소비하는 배포 단위
harness_bridge    캔버스 · 권한 · 스트리밍의 XGEN 전용 변환

harness_bridge는 둘 중 어느 쪽으로도 흡수하지 않았습니다. XGEN에서만 의미가 있는 변환까지 SDK 엔진에 넣으면 앞에서 끊은 의존성이 그대로 되살아납니다.

세 경로는 같은 것을 세 번 만든 결과가 아니라 배포 책임이 셋으로 갈린 결과입니다. 하나로 합치려는 시도는 그중 하나의 소비자를 버리는 결정과 같습니다.

경계는 그린 것이 아니라 관측한 것이었습니다

이 작업에서 저희가 처음에 하려던 일은 코드를 잘 나누는 것이었습니다. 실제로 한 일은 무엇을 봐야 나뉘었다고 말할 수 있는지 정하는 일이었습니다.

폴더를 옮기는 것으로는 부족했고, 전역 import를 인자로 바꾸는 것으로도 부족했고, 어댑터를 삭제하는 것으로도 부족했습니다. 매번 저희는 나눴다고 생각했고 매번 다른 경로로 결합이 남아 있었습니다.

경계는 선언으로 생기지 않고, 그 경계가 깨졌을 때 실패하는 검사를 만들었을 때 생깁니다. 제품 패키지 없는 환경의 코어 테스트, 변환 계층의 값 전달 테스트, 깨끗한 설치 환경의 import 테스트가 그 검사입니다.

여기까지가 실행 중인 프로세스 안에서의 경계입니다. 다음 편에서는 이 경계를 프로세스 밖으로 내보내는 문제를 다룹니다. 다른 환경에 설치할 산출물 안에 어떤 실행 계약을 담아야, 그것을 받은 쪽이 XGEN을 몰라도 실행할 수 있는지입니다.


이전 편 → 재시도 횟수로는 실행을 설명할 수 없었습니다 (1편)

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

#하네스#아키텍처#의존성 설계
블로그 목록으로