소울스트림 기술 실측 보고서
작성일: 2026-07-17
이 문서는 소울스트림의 실제 소스 코드, 운영 구성, 읽기 전용 데이터베이스 조회를 대조해 정리한 공개판이다. 민감한 자격 증명, 개인 식별 정보, 내부 주소, 조직을 식별할 수 있는 노드명은 싣지 않았다. 운영 노드는 중앙 노드, 원격 노드 A, 원격 노드 B로 표기한다. 파일 경로는 모두 리포지터리 루트 기준 상대 경로다.
전체 그림
소울스트림은 모델 하나를 감싼 채팅 UI가 아니라, 여러 모델 백엔드 위에 세션·지식·실행 규율을 유지하는 하니스에 가깝다.
브라우저·앱·외부 도구
↓
TypeScript 오케스트레이터
├─ 인증·API·대시보드
├─ 워커 등록과 세션 명령 라우팅
├─ 기록 조회와 실시간 이벤트 중계
└─ 보드·페이지 협업 문서 호스팅
↓ 역방향 WebSocket
soul-server-ts 워커
├─ 에이전트 프로필과 워크스페이스 선택
├─ atom·폴더·페이지·이전 세션 컨텍스트 조립
├─ Claude·Codex 등 실행 어댑터 호출
└─ 완결 이벤트를 PostgreSQL에 저장
↓
대시보드가 과거 기록과 실시간 스트림을 하나의 대화로 합성
오케스트레이터는 연결된 워커의 에이전트 목록, 지원 백엔드, 세션 소유권을 보고 새 세션을 분배한다. 이미 시작된 세션은 원래 워커로만 돌아간다. 워커는 에이전트별 규칙과 atom 지식 트리, 작업 폴더와 문서, 선행 세션의 요약을 첫 턴에 합성한다. 세션 상태와 완결된 대화 이벤트는 PostgreSQL에 남고, 과거 기록 API와 실시간 SSE는 같은 이벤트 모델로 화면에 합쳐진다.
조사 기준 소울스트림 커밋은 2b9296faed99ceba77d3d7774d0920958a7debca다. 리포지터리에는 과거 Python 구현도 남아 있으나 조사 시점의 운영 정본은 orch-server-ts와 soul-server-ts였다.
1. 오케스트레이터와 워커
역할 분리
오케스트레이터는 외부 클라이언트가 만나는 단일 진입점이다. 대시보드와 API, 노드 등록, 세션 명령 라우팅, 기록 읽기, 협업 문서 호스팅을 조립한다. 워커는 모델 프로세스를 실행하고 에이전트 레지스트리, 컨텍스트 조립, 이벤트 저장, 세션 상태 전환을 맡는다.
워커가 오케스트레이터의 /ws/node로 먼저 접속하는 역방향 연결이다. 첫 프레임은 node_register이며 노드 ID, 에이전트 목록, capabilities, 지원 백엔드, 세션 스냅샷을 전달한다. 오케스트레이터는 이 연결을 명령 통로로 보존하고, 같은 노드가 재접속하면 이전 연결을 교체한다.
근거:
orch-server-ts/src/node/ws_route.ts— 노드 WebSocket 진입점, 인증, 등록 제한 시간orch-server-ts/src/node/ws_frame_controller.ts— 첫 프레임 등록 강제와 등록 처리orch-server-ts/src/node/registry.ts— 노드 레지스트리와 재접속 교체soul-server-ts/src/upstream/adapter.ts— 역방향 연결, 등록, 명령 수신, 재접속soul-server-ts/src/upstream/registration.ts— 에이전트·capability·지원 백엔드 광고
세션 분배와 소유권
새 세션은 요청에 지정된 노드, 에이전트 프로필, 백엔드 호환성을 차례로 검사하고, 후보가 여러 개면 세션 캐시 수가 가장 적은 노드를 선택한다. 기존 세션은 다시 부하 분산하지 않는다. 소유권 캐시가 가리키는 원래 워커로만 라우팅하고, 정보가 오래됐거나 노드가 끊겼으면 오류를 낸다. 모델 백엔드의 thread와 워커 메모리 상태가 다른 노드로 우연히 이동하지 않도록 한 경계다.
근거:
orch-server-ts/src/session/session_create_node_selector.ts— 프로필·백엔드 호환과 최소 세션 수 선택orch-server-ts/src/session/session_command_router.ts— 신규 세션 선택과 기존 세션의 소유 노드 고정orch-server-ts/src/node/session_cache.ts— 노드별 세션 캐시와 연결 해제 시 stale 전환
조사 시점에는 중앙 노드의 워커 한 대가 연결돼 있었다. 이는 운영 스냅샷일 뿐 구조적 상한이 아니다. 코드는 여러 워커의 등록과 선택을 전제로 한다.
2. 세션 모델과 대화 기록
PostgreSQL의 주요 정본
| 영역 | 주요 테이블 | 저장 내용 |
|---|---|---|
| 세션 | sessions |
폴더·노드·에이전트, 상태, 최초 요청, 백엔드 세션 ID, 마지막 메시지, 호출자, 검수 상태, 선행 세션 |
| 대화·도구 기록 | events |
세션 안에서 증가하는 이벤트 ID, 이벤트 종류, JSON payload, 검색 텍스트, 중복 방지 키, 생성 시각 |
| 내비게이션 | folders, board_items |
폴더 계층과 세션·문서·런북의 보드 배치 |
| 보드 협업 | board_yjs_documents, board_yjs_updates, board_yjs_catalog_cache |
Y.Doc snapshot, 증분 업데이트, 읽기용 캐시 |
| 런북 | runbooks, runbook_sections, runbook_items, runbook_operations |
계획, 섹션, 항목 상태와 담당자, 변경 이력 |
| 페이지 | pages, blocks |
Y.Doc 기반 페이지 replica, 블록 트리, 버전과 변경 출처 |
근거:
packages/db-schema/sql/schema.sql— 세션, 이벤트, 보드, 런북, 페이지 스키마soul-server-ts/src/db/repositories/session_repository.ts— 세션 등록과 백엔드 세션 ID 저장
생성, 재개, 승계
새 세션 생성은 워커의 TaskCreation이 초기 실행 객체 생성, DB 등록, 메타데이터와 폴더 배정, session_created 발행 순서를 소유한다. 레거시 컬럼명인 claude_session_id는 실제로 특정 업체에 한정되지 않은 백엔드 session/thread ID다. 최초 엔진 이벤트에서 얻은 ID를 한 번 저장하며 다른 값으로 덮어쓰지 못한다.
같은 세션에 다시 메시지를 보내면 기존 실행을 정리한 뒤 저장된 백엔드 session ID로 새 턴을 재개한다. 승계는 재개와 다르다. 새 세션을 만들면서 predecessor_session_id로 이전 세션을 연결하고, 이전 세션의 요약이나 마지막 대화 발췌를 새 첫 컨텍스트에 넣는다.
근거:
soul-server-ts/src/task/task_creation.ts— 생성 순서의 단일 정본soul-server-ts/src/task/task_auto_resume_transition.ts— 자동 재개 상태 전환soul-server-ts/src/task/task_engine_turn_runner.ts— 저장된 백엔드 ID로 엔진 재개soul-server-ts/src/context/predecessor_summary_context.ts— 선행 세션 요약 주입
이벤트가 기록의 정본이 되는 방식
워커는 엔진 이벤트를 모두 저장하지 않는다. 생성 중인 글자 조각과 _live_only 이벤트는 실시간 스트림으로만 보내고, 완결된 사용자·어시스턴트 메시지, 도구 실행, 오류 같은 이벤트를 events에 기록한다. 이벤트 추가 함수는 세션 행을 잠가 같은 세션의 ID 할당을 직렬화하고, 중복 방지 키가 있으면 같은 이벤트의 재저장을 막는다.
오케스트레이터의 기록 API는 events를 읽는다. 웹 UI는 과거 이벤트와 라이브 SSE를 같은 처리기로 통과시켜 중복을 제거한다. 따라서 재생 가능한 대화 기록의 정본은 완결 이벤트이며, 타이핑 효과용 조각까지 모두 영구 로그인 것은 아니다.
근거:
soul-server-ts/src/db/event_persistence.ts— 저장 대상과 live-only 제외orch-server-ts/src/session/session_history_routes.ts— 기록 APIorch-server-ts/src/runtime/live_session_history_provider.ts— 이벤트 기록 조회packages/soul-ui/src/components/chat/useMessageHistoryBuffer.ts— 과거 기록과 실시간 이벤트의 합성
3. 세션 검색 알고리즘
세션 이벤트 본문 검색은 PostgreSQL 내장 전문 검색 순위를 그대로 쓰지 않는다. event_search_terms에 정규화한 토큰, 문서별 빈도, 문서 길이를 보존하고 event_search_corpus_stats의 문서 수와 평균 길이를 이용해 BM25 점수를 직접 계산한다.
현재 점수식의 상수는 k1=1.2, b=0.75다. 같은 검색어의 정확 토큰 결과가 제한 수보다 적으면, 길이 3자 이상의 한글 검색어에 대해 앞 3글자 범위 검색을 낮은 점수로 보충한다. 선택적으로 세션 ID도 찾을 수 있는데, 이 보조 경로만 ILIKE '%검색어%' 부분 일치를 쓴다.
정확한 표현은 다음과 같다.
자체 BM25 랭킹과 한글 접두어 보완 검색으로 과거 세션을 찾습니다.
websearch_to_tsquery, ts_rank, trigram similarity는 현재 event_search의 순위 계산에 사용되지 않는다. events.search_vector와 GIN 인덱스는 스키마에 남아 있지만 현재 주 검색 함수의 랭킹 경로는 별도 용어 테이블이다.
근거:
soul-server-ts/src/search/session_search.ts— 검색 결과 병합·중복 제거·점수 정렬soul-server-ts/src/db/repositories/event_repository.ts—event_search와session_id_search호출packages/db-schema/sql/schema.sql의event_search_tokenize,event_search,event_search_terms,event_search_corpus_stats- 운영 DB 함수 정의의 읽기 전용 검사: 용어 테이블·BM25 수식·한글 접두어 보완 사용,
websearch_to_tsquery·ts_rank·trigram 미사용
4. 에이전트와 워크스페이스
에이전트는 운영 agents.yaml의 프로필 한 행으로 정의된다. 핵심 필드는 ID, 표시 이름, 백엔드, 모델, 워크스페이스이며 필요에 따라 turn 제한, 도구 허용·차단 목록, permission mode, 환경 설정, MCP profile, portrait, atom context 목록을 갖는다. 워커는 YAML을 검증해 레지스트리에 올리고 오케스트레이터에 에이전트 카탈로그와 지원 백엔드를 광고한다.
실행부는 공통 EnginePort만 바라본다. 프로필의 backend에 따라 factory 경계에서 Claude, Codex, OpenAI Agents 어댑터를 만든다. 조사 시점의 연결 워커가 광고한 백엔드는 Claude와 Codex였고, 코드에는 OpenAI Agents 확장점도 존재했다.
workspace_dir는 단순한 현재 디렉터리가 아니라 에이전트의 지속 실행 환경이다. 하니스별 규칙과 스킬, 도구 구성, 로컬 상태, 작업 리포지터리, 세션 자산을 분리한다. 여러 에이전트가 같은 프로젝트를 다뤄도 Git index와 빌드 산출물이 충돌하지 않도록 각자 작업 공간과 worktree를 쓴다.
근거:
soul-server-ts/src/agent_registry.ts— 프로필 스키마와 YAML 로딩soul-server-ts/src/engine/protocol.ts— 백엔드 중립 실행 인터페이스soul-server-ts/src/runtime/worker_composition.ts— 백엔드별 어댑터 선택
MCP 노출
MCP 노출은 에이전트 프로필의 선택적 named profile과 워크스페이스별 도구 설정, 두 층으로 설계돼 있다. 조사 시점에는 named profile 목록이 비어 있었고 실제 차등 노출은 주로 워크스페이스 설정에서 관찰됐다. 구현된 확장점과 현재 운영 상태를 구분해야 한다.
근거:
soul-server-ts/src/mcp_config_service.ts— registry와 profile 해석soul-server-ts/src/engine/claude_sdk_mcp_options.ts— 워크스페이스 도구 설정과 세션 헤더
5. atom 컨텍스트와 스킬 카탈로그
각 에이전트의 atom_contexts는 노드 ID, 펼칠 깊이, 제목만 읽을지 여부의 목록이다. 첫 턴 전에 워커가 지정 subtree를 compile한다. 각 호출에는 크기와 시간 제한이 있고, 한 트리가 실패해도 그 항목만 건너뛰어 세션 시작은 계속한다.
에이전트의 장기 rules는 system prompt 앞부분에 들어간다. 폴더·페이지·세션에서 선택한 지식은 작업 context item으로 붙는다. 같은 저장소의 지식을 사용하면서도 장기 규칙과 일회 작업 자료의 우선순위를 나눈다.
스킬 카탈로그는 긴 본문을 매 세션에 모두 싣지 않는다. 시작 시에는 이름, trigger 설명, 본문 노드 ID, 선택적 스크립트 위치만 주입한다. 요청이 trigger와 맞을 때만 해당 본문 subtree를 펼쳐 절차를 실행한다. Claude와 Codex가 같은 atom 정본을 사용하면서 시작 프롬프트의 크기를 억제하는 방식이다.
근거:
soul-server-ts/src/context/atom_context.ts— subtree compile과 실패 격리soul-server-ts/src/context/context_builder.ts— 에이전트·폴더·세션 컨텍스트 조립 순서
6. 백엔드보다 위에 있는 실행 규율
백엔드 독립성은 각 업체의 API가 같다는 뜻이 아니다. 차이는 어댑터 경계에서 받아들이고 그 위의 실행 규약과 지식 정본을 공통화한다.
- 실행:
EnginePort가 요청, 재개 ID, system prompt, 도구 정책, 이벤트 스트림의 공통 표면을 제공한다. - 컨텍스트: 같은 builder가 atom, 폴더, 페이지, 선행 세션, 보드, 실행 중 세션을 조립한다.
- 규칙: 공통 rules와 스킬 카탈로그는 atom subtree로 주입된다.
- 하니스 차이: Claude와 Codex는 각자의 로컬 규칙·스킬 디렉터리를 사용하지만 장기 정본은 같은 상위 프로토콜을 따른다.
예를 들어 turn 단위 system prompt 지원 여부가 다르면 첫 턴 문자열 합성 방식은 달라질 수 있다. 같은 지침을 공유한다는 말은 물리 파일 하나를 억지로 공유한다는 뜻이 아니라, 같은 상위 규칙을 백엔드별 전달 방식으로 번역한다는 뜻이다.
근거:
soul-server-ts/src/engine/protocol.ts— 공통 실행 인터페이스와 백엔드 차이soul-server-ts/src/context/prompt_assembler.ts— 첫 턴 prompt 합성
7. Git 리포지터리와 worktree
작업용 소스 루트와 서비스 런타임 디렉터리는 분리돼 있다. 코드 작업은 기본 checkout을 직접 어지럽히지 않고 feature branch의 sibling worktree에서 수행한다.
projects/
├── soulstream/ 기본 checkout
├── soulstream--작업-slug/ feature branch worktree
└── 다른-repo/ 별도 checkout
구현, 테스트, 커밋은 worktree 안에서 하고 리뷰와 병합 뒤 worktree와 branch를 정리한다. Python은 worktree 전용 가상환경을 쓰고, Node 프로젝트는 lockfile과 node_modules 상태를 확인한 뒤 격리된 의존성 경로를 쓴다. 격리의 목적은 파일 접근 차단이 아니라 동시에 진행되는 세션의 Git index, branch, 빌드 산출물 충돌을 막는 것이다.
8. 페이지, 보드, 런북
페이지는 오케스트레이터가 Hocuspocus와 Y.Doc을 열어 처리한다. 브라우저는 페이지별 WebSocket으로 붙고, REST는 페이지와 블록 읽기·검색·변경을 제공한다. PostgreSQL의 pages와 blocks는 Y.Doc 기반 replica이며 어느 세션과 이벤트가 변경을 만들었는지 출처도 보존한다.
보드는 폴더나 런북을 container로 삼아 세션, 마크다운, 하위 폴더, asset, frame, runbook, custom view를 좌표에 배치한다. Y.Doc snapshot과 update는 PostgreSQL에 저장된다.
런북은 단순 체크리스트 JSON이 아니다. runbook, section, item, append-only operation이 별도 테이블로 있고 항목은 담당자와 상태를 가진다. 생성·수정·완료 행위는 가능한 경우 세션 ID와 이벤트 ID에 연결된다. 실행 계획과 그 안에서 파생된 세션·문서를 같은 보드에 놓을 수 있다.
근거:
orch-server-ts/src/page/page_yjs_route.ts— 페이지 WebSocketorch-server-ts/src/page/page_service.ts— Y.Doc과 페이지 변경 처리orch-server-ts/src/board/board_item_routes.ts— 폴더·런북 container APIpackages/db-schema/sql/schema.sql— 보드 Yjs, 런북 operation, 페이지 replica
9. 발행 파이프라인
글 발행 리포지터리는 소울스트림 모노리포와 분리돼 있다. 글과 자산을 기본 branch에 push하면 GitHub Actions가 Hugo Extended로 정적 사이트를 빌드하고, 생성된 결과물을 GitHub Pages artifact로 올려 배포한다. 소울스트림 서비스 배포와 글 발행은 서로 다른 파이프라인이다.
근거:
hugo.toml— 사이트와 Hugo 설정.github/workflows/deploy.yml— branch trigger, Hugo build, Pages artifact와 배포
부록 A. 운영 규모 수치
수치는 2026-07-17의 읽기 전용 실측이다. 세션·이벤트 수와 저장 용량은 21:08 KST에 다시 조회했다. 지식 카드, 에이전트 프로필, 서비스, 최근 7일 배포는 같은 날 20:38 KST의 선행 조사 스냅샷이다.
| 항목 | 실측 결과 | 해석 |
|---|---|---|
| 누적 세션 | 20,828개 | 최초 세션은 2026-03-15 생성 |
| 누적 이벤트 | 3,582,944개 | 완결 이벤트 중심의 영구 기록 |
| 지식 카드 | 21,006장 | knowledge 9,390 + structure 11,616 |
| 트리에 연결된 고유 카드 | 13,599장 | 트리에 없는 카드 7,407장 별도 |
| symlink 포함 트리 노드 | 16,889개 | 하나의 카드를 여러 위치에서 참조 가능 |
| 연결 워커의 에이전트 프로필 | 12개 | 조사 시점 연결 워커 기준 |
| 중앙 노드 서비스 정의 | 19개 | 활성 또는 기본 활성 18, 비활성 1 |
| 최근 7일 고유 배포 리비전 | 38개 | 하루 평균 5.4개 |
| 최근 7일 성공한 노드·리포 적용 | 153건 | 중앙 35, 원격 A 59, 원격 B 59 |
153건은 같은 리비전이 여러 노드와 서비스 정의에 펼쳐진 결과를 포함한다. 사람이 승인한 배포 묶음에 가까운 수치는 고유 리비전 38개다.
부록 B. 세션 기록 저장 용량
PostgreSQL의 pg_total_relation_size는 테이블 본문, TOAST, 인덱스를 합친 크기다. sessions와 events를 각각 측정해 더했으며, DB 전체는 pg_database_size로 조회했다.
| 대상 | 테이블·TOAST | 인덱스 | 합계 |
|---|---|---|---|
sessions |
0.120 GB | 0.011 GB | 0.131 GB |
events |
6.331 GB | 2.753 GB | 9.084 GB |
sessions + events |
6.451 GB | 2.764 GB | 9.215 GB |
| DB 전체 | 16.041 GB |
2진 단위로는 sessions + events가 8.582 GiB, DB 전체가 14.940 GiB다. 합산 원시 값은 9,215,164,416바이트다.
실행한 조회의 형태:
SELECT pg_total_relation_size('public.sessions'::regclass)
+ pg_total_relation_size('public.events'::regclass);
SELECT pg_database_size(current_database());
모든 데이터베이스 조사는 SELECT와 PostgreSQL 용량 함수만 사용했다.
해석할 때 유지해야 할 구분
- 조사 시점의 연결 워커 수와 다중 워커를 지원하는 설계 능력은 다른 값이다.
- 영구 기록은 완결 이벤트다. 화면에 잠깐 보이는 모든 생성 조각이 저장되는 것은 아니다.
- 같은 백엔드 thread의 재개와 새 세션으로의 요약 승계는 다른 기능이다.
- atom subtree의 시작 시 compile과 필요할 때만 펼치는 스킬 본문은 다른 경로다.
- named MCP profile은 구현된 확장점이지만 조사 시점의 운영 목록은 비어 있었다.
- 검색은 PostgreSQL 내장 전문 검색 순위가 아니라 자체 BM25가 주 경로다.
- 노드별 적용 횟수 153건과 사람이 승인한 고유 리비전 38개는 같은 배포 빈도를 다른 단위로 센 값이다.