모든 명령어를 위한 정밀한 라우팅: HagiCode 프리셋 태스크의 멀티 스킬 (Multi-Skill) 지원 실무 구현
요약
HagiCode의 프리셋 태스크 시스템에서 각 명령어가 독립적인 스킬 요구사항을 가질 수 있도록 리팩토링한 실무 사례를 다룹니다. 기존의 프리셋 단위 스킬 선언 방식에서 벗어나, 명령어별로 스킬을 독립적으로 선언하고 UI에 시각화하는 구현 방법을 설명합니다.
핵심 포인트
- 프리셋 내 명령어별 독립적 스킬 선언 지원
- 데이터 불일치를 방지하기 위한 설계 최적화
- 스킬 바인딩의 시각적 구현(배지, 요약 등)
- HagiCode 프로젝트의 실무 리팩토링 경험 공유
모든 명령어를 위한 정밀한 라우팅: HagiCode 프리셋 태스크의 멀티 스킬 (Multi-Skill) 지원 실무 구현
여러 명령어가 포함된 프리셋이 있지만, 모두 동일한 스킬 요구사항을 공유해야 하나요? 이번 리팩토링을 통해 각 명령어가 의존하는 스킬을 독립적으로 선언할 수 있게 되었으며, 패널에서 배지, 요약, 원클릭 설치 등 이러한 바인딩(binding)을 하나의 매끄러운 흐름으로 시각화합니다.
배경 (Background)
먼저 맥락을 설명하며 시작하겠습니다.
HagiCode의 프리셋 태스크(preset task)는 플러그인 기반의 미니 도구 시스템입니다. 사용자는 명령어를 수동으로 입력할 필요 없이, 시각적 패널의 몇 가지 필드를 채우고 클릭하기만 하면 자동화된 태스크 세션을 생성할 수 있습니다. 각 프리셋은 본질적으로 다음과 같은 구조를 가진 디렉토리입니다:
manifest.json: 프리셋 식별 정보panel.json: 시각적 패널 양식 정의commands.json: 실제로 실행할 명령어 목록task-preset.json또는prompts.json: 태스크 파라미터 및 스킬 요구사항
이 시스템은 사용하기에 매우 편리하지만, 곧 어색한 한계에 부딪혔습니다.
초기 버전에서는 스킬을 프리셋 레벨의 requirements 배열에서만 선언할 수 있었습니다. 이것이 무엇을 의미할까요? 동일한 프리셋 내의 모든 명령어가 동일한 스킬 요구사항을 공유한다는 뜻입니다. 별것 아닌 것처럼 들릴 수도 있지만, 실제로는 다음과 같은 상황을 마주하게 됩니다:
하나의 프리셋에 다섯 개의 명령어가 있는데, 첫 번째 명령어는 last30days 스킬을 사용하고 싶어 하고, 세 번째 명령어는 ui-master를 사용하고 싶어 하며, 나머지 세 개는 아무런 스킬도 필요하지 않은 경우입니다. 기존 설계에서는 이것이 불가능했습니다. 서로 다른 명령어가 서로 다른 스킬로 라우팅(routing)되기를 원한다면, 이러한 명령어들을 강제로 여러 개의 프리셋으로 나누어야 했고, 이로 인해 설정이 즉시 비대해졌습니다.
이것이 바로 제안서 extend-preset-task-multiple-skills-support가 해결하고자 하는 문제입니다. 각 명령어가 의존하는 스킬을 독립적으로 선언하게 하고, UI에서 이러한 바인딩을 시각화하는 것입니다.
HagiCode에 대하여
이 글에서 공유하는 솔루션은 HagiCode 프로젝트에서의 실무 경험에서 비롯되었습니다. HagiCode는 AI 코드 어시스턴트 (AI code assistant) 프로젝트이며, 프리셋 태스크 (preset task) 시스템은 사용자를 위한 빠른 실행 진입점 (quick action entry point) 역할을 합니다. 아래에서 논의되는 모든 변경 사항은 실제 시행착오와 최적화 과정을 거쳐 정제되었습니다. 결국 "독서는 실습을 대신할 수 없다"는 말처럼 말이죠. 프로젝트 소스 코드는 HagiCode-org/site에 있으며, 관심 있는 독자분들은 먼저 Star를 눌러주셔도 좋습니다.
문제 정의: 왜 매핑 테이블 (Mapping Table)이 아닌가?
본론으로 들어가기 전, 가장 직관적인 해결책은 다음과 같습니다. "command ID → skill" 관계를 별도로 저장하기 위해 또 다른 commandSkillMappings 매핑 테이블을 만드는 것입니다. 이는 관심사의 분리 (separation of concerns) 측면에서 깔끔해 보입니다.
하지만 자세히 검토해 보면, 이 방식은 유지되지 않습니다.
commands.json의 각 명령어 (command)에는 이미 ID가 있으며, 이 ID를 매핑 테이블에 복사해야 합니다. 두 개의 파일에 동일한 ID가 존재하게 되는데, 누군가 명령어를 수정하면서 매핑 테이블을 동기화하는 것을 잊는 순간 데이터 불일치 (data drift)가 발생합니다. 이러한 "분리를 위한 분리" 식의 설계는 그것이 가져다주는 약간의 깔끔함보다 훨씬 더 큰 유지보수 비용을 초래합니다. 결국 번거로움만 가중시킬 뿐입니다.
따라서 우리는 궁극적으로 더 직접적인 경로를 선택했습니다. 명령어 정의 (command definition)에 선택적 skill 필드를 직접 포함하는 것입니다. 명령어가 스스로 어떤 스킬에 바인딩되는지를 직접 선언하고 로컬에서 관리함으로써, 데이터가 끊어지는 일이 없도록 했습니다.
이 결정의 이면에는 별도로 짚고 넘어갈 만한 더 중요한 설계 원칙이 있습니다.
핵심 원칙 1: 두 계층의 데이터 책임 분리 (Two-Layer Data Responsibility Separation)
이것이 이번 리팩터링 (refactor) 전체에서 가장 중요한 통찰입니다.
많은 사람의 첫 번째 반응은 이렇습니다. "명령어에 이제 skill이 있으니, 요구사항 체크 (requirement check, 스킬 게이트키퍼 검사)를 할 때 각 명령어의 skill 필드를 스캔해야 하지 않을까요?"
아닙니다.
우리는 이를 의도적으로 두 개의 계층으로 나누었습니다:
commands.json의skill필드: 오직 바인딩 (binding)을 선언하는 역할만 수행합니다. 이는 시스템에 "이 명령어가 어떤 스킬에 바인딩되는가"를 알려주며, 프롬프트 서문 (prompt preambles) 렌더링 및 UI 표시용으로 사용됩니다.task-preset.json의requirements배열: 이것이 권위 있는 열거 (authoritative enumeration)입니다. 프리셋이 실행되기 위해 어떤 스킬들을 충족해야 하는지를 결정하는 실제 게이트키퍼 (gatekeeper) 역할을 합니다.
다시 말해, skill은 "무엇을 바인딩하고 무엇을 렌더링할 것인가"에 답하는 반면, requirements는 "실제로 실행이 허용되는가"에 답합니다. 이 둘은 별개의 것이므로 서로 섞어서는 안 됩니다.
이러한 분리의 이점은 체크 로직이 자연스럽게 단순해진다는 것입니다. 게이트키퍼가 항상 프리셋 레벨의 requirements를 기반으로 하고 CacheKey에 의해 중복 제거되기 때문에, 동일한 스킬에 바인딩된 여러 명령어가 있더라도 반복적인 호출 없이 단 한 번만 조사(probing)됩니다. 명령어 레벨의 스킬은 추가적인 조사 오버헤드 (probing overhead)를 발생시키지 않습니다.
이 원칙은 우리가 매핑 테이블 (mapping table) 방식을 거부한 근본적인 이유이기도 합니다. 매핑 테이블은 사람들에게 "바인딩이 곧 게이트키핑"이라는 오해를 불러일으켜, 두 계층의 책임을 다시 하나로 뒤섞어 버릴 것이기 때문입니다. 한마디로, 스스로를 곤란하게 만드는 꼴이 됩니다.
핵심 두 번째: 명령어 정의는 어떻게 생겼는가?
리팩토링된 명령어 정의는 기존 구조에 선택 사항인 skill 필드를 단순히 추가한 형태입니다. last30days 번들 프리셋을 예로 들면, 해당 commands.json은 대략 다음과 같습니다:
{
"$schema": "../../schemas/commands.schema.json",
"version": "1.1",
...
주의해야 할 몇 가지 사항:
version이1.1로 업그레이드되었으며, 이에 대응하는 스키마 (schema)에도 선택적skill필드가 추가되었습니다.- 첫 번째 명령어인
research는last30days스킬에 바인딩되며, 실행 시 이 스킬로 라우팅 (route)됩니다. - 두 번째 명령어인
summarize는 어떤 스킬에도 바인딩되지 않으며, 기본 경로를 따르는 일반적인 지침입니다. - 여기서 주목할 점은 명령어 내에 어떠한 요구사항 (requirement)도 작성되지 않았다는 것입니다. 실제 게이트키퍼는
task-preset.json의requirements에 있습니다:
{
"requirements": [
{
...
research 명령에 의해 바인딩된 last30days는 반드시 이 requirements에 나타나야 합니다. 그렇지 않으면 문제가 발생하며, 이는 바로 다음 섹션에서 논의할 엄격한 제약 조건(hard constraint)입니다. 억지로 강제할 수는 없습니다.
핵심 3: 로딩 중 교차 검증 (Cross-Validation During Loading)
데이터 내에 바인딩을 선언하는 것만으로는 충분하지 않습니다. 명령어가 특정 기술(skill)에 바인딩되어 있지만, requirements에는 선언되지 않아 발생하는 "고아 바인딩 (orphan bindings)"이 프로덕션 환경으로 넘어가는 것을 방지할 안전망이 필요합니다.
이 안전망이 바로 ValidateCommandSkills입니다. 이 프로세스는 프리셋 패키지 로딩 중에 한 번 실행되며, 각 명령어의 skill을 하나씩 확인하여 프리셋 레벨의 requirements에서 상응하는 항목을 찾을 수 있는지 검사합니다. 만약 찾을 수 없다면, 해당 패키지를 비정상(illegal)으로 판단하고 프리셋 전체를 즉시 비활성화하며, 진단 코드 command-skill-not-in-requirements를 발생시킵니다.
왜 해당 명령어만 건너뛰지 않고 패키지 전체를 비활성화할까요? 프리셋은 하나의 완성된 단위이며, 명령어들은 종종 의존성(한 명령어의 출력이 다음 명령어의 입력이 됨)을 갖기 때문입니다. 만약 하나를 조용히 건너뛰면, 뒤따르는 명령어들은 빈 입력을 받게 되어 동작이 완전히 예측 불가능해집니다. 결국, 사람의 마음을 읽을 수 없듯이 코드의 의도도 읽을 수 없습니다. 작업이 중간에 미스터리하게 탈선하도록 두는 것보다 사용자가 명시적인 에러를 보게 하는 것이 낫습니다. 이 지점은 결코 소홀히 다뤄져서는 안 됩니다.
이 검증은 로딩 단계에서 완료됩니다. 즉, 문제가 사용자가 실제로 "실행 (run)"을 클릭하여 폭발할 때까지 끌려가는 것이 아니라, 프리셋이 등록되는 순간 발견된다는 것을 의미합니다. 사용자 경험(UX) 측면에서는 늦은 에러보다 빠른 에러가 언제나 더 좋습니다.
핵심 4: 프롬프트 서문 (Prompt Preambles)의 멱등적 결합 (Idempotent Splicing)
다음은 실행 체인에서 가장 미묘한 연결 고리입니다.
명령어가 last30days와 같은 스킬 (Skill)에 바인딩될 때, 시스템은 실제 실행 전에 이 스킬 정보를 명령어 앞에 "결합 (splice)"하여 실행기 (Executor)로 전달할 완전한 단일 행 명령어를 형성해야 합니다. 이 프로세스는 CombineCommandSkillPrelude에 의해 처리됩니다.
구체적인 예를 들어보겠습니다. research 명령어의 프롬프트 (Prompt)는 "调研一下最近30天大家对 {topic} 的真实讨论"이고, 바인딩된 스킬은 last30days이므로, 실행기에 전달되는 최종 명령어는 대략 다음과 같습니다:
/last30days 调研一下最近30天大家对 {topic} 的真实讨论
즉, 프롬프트 앞에 /last30days 서문 (Preamble)이 추가됩니다. 실행기가 이 서문을 확인하면, 먼저 last30days 스킬로 컨텍스트 (Context)를 전환해야 함을 인지합니다.
여기에 함정이 하나 있습니다: 바로 멱등성 (Idempotency)입니다.
왜 멱등성을 강조할까요? 어떤 시나리오에서는 프롬프트 자체에 이미 이 스킬 서문이 포함되어 있을 수 있기 때문입니다 (예를 들어, 사용자가 수동으로 일부를 작성했거나 다른 곳에서 복사해 온 경우). 만약 시스템이 어리석게 이를 다시 결합한다면, /last30days /last30days 调研...와 같이 되어 실행기가 오류를 발생시키거나 비정상적으로 동작하게 됩니다.
따라서 CombineCommandSkillPrelude는 결합하기 전에 이를 감지합니다. 만약 접두사 (Prefix)가 이미 존재한다면 다시 추가하지 않습니다. 이 단계는 사소해 보일 수 있지만, 매우 미묘한 버그 부류를 차단할 수 있습니다.
이 모든 서문 주입 (Preamble injection) 로직은 프리셋 정의 계층 (PresetTaskCatalogProvider 내의 BuildCommandPrelude)에서 완료되며, SessionsController 측의 세션 생성 코드는 전혀 변경할 필요가 없다는 점을 언급할 가치가 있습니다. 이것이 바로 관심사의 분리 (Separation of concerns)가 가져다주는 이점입니다. 실행 진입점은 안정적으로 유지되면서, 스킬 라우팅 (Skill routing)의 복잡성은 정의 계층 내에 갇히게 됩니다.
핵심 5요소: 프론트엔드에서 바인딩을 표시하는 방법
백엔드에서 데이터 모델과 실행 체인을 정리했으므로, 마지막 단계는 사용자가 인터페이스에서 이 바인딩을 "볼" 수 있게 하는 것입니다. 결국 사용자가 기능을 인지할 수 없다면, 그것은 본질적으로 완성되지 않은 것이나 다름없습니다.
프론트엔드는 세 가지 작업을 수행했습니다.
첫째, 명령어 선택기(command selector)에 배지(badge)를 추가합니다. 명령어 선택기(command-picker)에서 각 스킬(skill)에 바인딩된 명령어 옆에 작은 배지가 나타나며, 해당 명령어가 어떤 스킬에 의존하는지 표시합니다. 사용자는 어떤 명령어가 "스킬 활성화(skill-enabled)" 상태인지, 그리고 어떤 것이 일반 명령어인지 한눈에 파악할 수 있습니다.
둘째, 요구사항 확인 요약 블록(requirement-check summary block)입니다. 패널에는 현재 프리셋(preset)이 충족해야 하는 모든 스킬 요구사항(skill requirements)과 각 명령어가 어떤 스킬에 바인딩되는지를 나열하는 전용 요약 영역이 있습니다. 이 블록의 데이터는 commandSkillsByRequirementKey 매핑에서 가져옵니다. 이는 바인딩된 요구사항 키(requirement key)별로 명령어를 그룹화하고 집계하여, 사용자가 "요구사항(requirements)"과 "실제 바인딩(actual bindings)"이 일치하는지 쉽게 비교할 수 있도록 합니다. 호랑이를 그리려다 개를 그리는 것과 같은 상황을 방지하기 위해, 집계 로직은 화려하기보다는 직관적이고 단순해야 합니다.
셋째, 실패 시 원클릭 설치 딥링크(one-click installation deep link)입니다. 요구사항 확인 과정에서 스킬이 설치되지 않았음을 발견하더라도, 사용자가 설치 항목을 찾기 위해 문서를 뒤질 필요가 없습니다. 인터페이스가 직접 딥링크(deep link) 버튼을 제공하며, 클릭 한 번으로 해당 설치 프로세스로 바로 이동할 수 있습니다. 이 단계는 "문제를 발견하는 것"과 "문제를 해결하는 것" 사이의 거리를 가능한 최단 거리로 압축합니다.
프론트엔드 타입(types) 또한 절제되어 있습니다. 명령어 타입(command type)에 skill?: string을 추가하고 정규화(|| undefined)를 수행함으로써, 빈 문자열이나 기타 경계값(boundary values)이 후속 판단 로직에서 문제를 일으키는 것을 방지합니다.
실습: 리팩터링을 완료하기 위한 5단계
앞서 언급한 흩어진 포인트들을 하나로 엮으면, 전체 리팩터링(refactor)은 실제로는 단 다섯 단계로 이루어집니다:
- 스키마 확장 (Extend schema):
commands.schema.json에 선택적skill필드를 추가하고, 버전 번호를1.1로 업그레이드합니다. - 파싱 및 검증 (Parse + validate):
NormalizeCommands가 명령어 정의를 파싱하는 역할을 담당하며,ValidateCommandSkills가 교차 검증 (cross-validation)을 수행합니다. 명령어 스킬 (command skills)은 프리셋 레벨의 요구 사항 (preset-level requirements)에서 찾아낼 수 있어야 합니다. - 프리앰블 주입 (Inject preamble):
BuildCommandPrelude가 실행 전 명령어 앞에/skill프리앰블 (preamble)을 멱등적 (idempotently)으로 결합합니다.SessionsController를 수정할 필요는 없습니다. - 번들된 프리셋 마이그레이션 (Migrate bundled preset):
last30days및ui-master내장 프리셋의commands.json을 수정하여 해당 명령어에skill필드를 추가합니다. 마이그레이션은commands.json만 수정하며, 다른 파일은 건드리지 않습니다. - 프론트엔드 시각화 (Frontend visualization): 타입 (types)에 필드를 추가하고, 커맨드 피커 (command-picker)에 배지 (badges)를 추가하며, 요구 사항 확인 (requirement-check)에 요약 블록을 추가합니다. 또한 실패 시 원클릭 설치를 위한 딥 링크 (deep link)를 제공합니다.
실무에서 주의해야 할 몇 가지 사항을 별도로 나열합니다:
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기