서로 간섭하지 않으면서 병렬 에이전트 세션 실행하기
요약
여러 코딩 에이전트 세션을 동시에 실행할 때 발생하는 데이터 손상(Corruption)과 작업 충돌(Collision) 문제를 해결하는 설계 방식을 다룹니다. 세션별 독립 디렉터리 구조를 통해 데이터 오염을 방지하고, 존재 레이어(presence layer)를 통해 세션 간 상호 인지를 구현하는 전략을 제안합니다.
핵심 포인트
- 세션별 고유 ID 디렉터리 사용으로 데이터 손상 방지
- 공유 파일 최소화 및 읽기 전용 병합 뷰 활용
- 에이전트 간 작업 중복을 막기 위한 존재 레이어 도입 필요성
원래 olund.dev에 게시됨.
저는 보통 여러 개의 코딩 에이전트(coding-agent) 세션을 동시에 실행합니다. 때로는 서로 다른 프로젝트에서, 종종 같은 저장소(repo)에서요: 한 세션은 리팩토링(refactor)을 깊이 진행하고, 하나는 문서를 작성하며, 또 다른 하나는 버그를 조사하죠. 어떤 에이전트 하네스(agent harness)도 기본적으로 이것을 안전하게 만들어주지 못합니다. 두 가지 실패 모드가 빠르게 나타납니다:
- 손상(Corruption): 두 세션이 같은 상태 파일에 쓰기를 하고, 하나가 다른 것을 덮어씁니다(clobbers).
- 충돌(Collision): 서로에게 눈먼 두 세션이 같은 작업을 선택하거나 같은 파일을 수정하고, 그 사실을 diff 시점에 발견하게 됩니다.
이것들은 '에이전트 간 충돌'이라는 하나의 문제처럼 느껴지지만, 구조적으로는 다르고 제 기억 시스템에서는 구조적으로 다른 해결책들이 있었습니다. 손상(Corruption) 해결책은 런타임 구성 요소가 전혀 없습니다. 충돌(Collision) 해결책에는 잠금(locks)도 없습니다. 이 게시물은 그 두 가지와 제가 의도적으로 구축하지 않은 것들에 관한 내용입니다.
손상(Corruption): 조정이 아닌 구조로 해결하다
제 에이전트 세션 각각은 디스크에 작업 메모리(working memory)를 유지합니다: 스크래치패드, 결정 로그(decision log), 열린 질문들. 프로젝트 브랜치당 하나의 파일이라는 순진한 레이아웃은 두 개의 활성 세션만으로도 즉시 무너집니다. 그리고 고전적인 답변은 데몬을 통한 잠금(locking) 또는 직렬화된 쓰기입니다.
이 둘 모두를 필요로 하지 않는 레이아웃은 다음과 같습니다: 모든 세션이 자신의 세션 ID로 키가 지정된 자체 디렉터리에만 쓰기를 합니다. 서브 에이전트(Subagents)는 접미사(suffix)와 함께 ID를 상속받기 때문에, 심지어 한 세션의 워커들조차도 부모와 충돌할 수 없습니다. 두 개의 라이터가 같은 파일을 건드리는 경우는 절대 없는데, 이는 잠금이 막아서가 아니라 공유 파일 자체가 존재하지 않기 때문입니다. 세션 시작 시, 물질화(materialize) 단계는 모든 세션의 항목들을 하나의 읽기 전용 병합된 뷰로 접어내어, 새로운 세션이 여전히 브랜치에서 발생한 모든 것을 읽을 수 있게 합니다—이는 스냅샷이 정의상 오래되었기 때문에 역사적 참조(historical reference)로 표시됩니다.
정확히 단 하나의 예외가 존재하며, 이는 핵심적인 역할을 합니다. 브랜치당 단 하나의 current-task.md 파일이 존재하며, 모든 세션이 이를 공유하고 마지막에 작성한 사람이 승리(last writer wins)하는 방식입니다. 이 파일은 "현재 이 브랜치가 무엇에 관한 것인가"라는 질문에 답하며, 바로 공유되기 때문에 유용합니다. 이 과정에서 도출된 설계 규칙은 다음과 같습니다. 기본적으로는 아무것도 공유하지 않되, 공유할 때는 단 하나의 작은 파일만을 공유하며, 그 파일의 목적 자체가 유일한 만남의 장소가 되도록 하는 것입니다.
이 방식은 데이터 오염(corruption) 문제를 완전히 해결했습니다. 하지만 충돌(collision) 문제에는 아무런 도움이 되지 않았습니다. 자신의 디렉토리 내에서 안전하게 작업 중인 세션이라 할지라도, 한 시간 전에 다른 세션이 완료한 작업을 아무렇지 않게 다시 수행할 수 있기 때문입니다.
충돌: 세션 간의 상호 인지 필요성
데이터 오염이 사라진 후 발생한 실제 고통은, 저장소(repo)에 두 번째 세션을 눈먼 상태로 실행하는 것이었습니다. 두 번째 세션은 첫 번째 세션이 존재한다는 사실은 물론, 그 세션이 어떤 파일들을 건드리고 있는지조차 알지 못했습니다. 저의 해결책은 **존재 레이어(presence layer)**입니다. 이는 세션 시작, 도구 사용(tool use), 세션 종료 시 이미 실행되고 있던 라이프사이클 훅(lifecycle hooks)에 의해 완전히 유지되는, 로컬 머신 기반의 활성 세션 레지스트리(registry)입니다.
각 레코드에는 브랜치, 세션이 건드린 파일들, 선택 사항인 한 줄짜리 의도(intent), 그리고 최신성 타임스탬프(freshness timestamp)가 포함됩니다. 새로운 세션이 시작될 때, 해당 세션의 컨텍스트(context)에는 다음과 같은 블록이 주입됩니다.
현재 이 브랜치에서 3개의 다른 세션이 실행 중입니다 - 동일한 파일을 편집하는 것을 피하세요:
- 6679ade6 - channel.md, production-readiness.md 수정 중 (5분 전 활성)
- 30b9890c - audit.rs, audit.test.ts 수정 중 (52분 전 활성)
...
이것이 기능의 대부분입니다. 지배적인 충돌 사례는 두 세션이 동일한 밀리초(millisecond)에 하나의 파일을 차지하려고 경쟁하는 상황이 아니었습니다. 그것은 바로 제가 세션 A가 무엇을 하고 있었는지 기억하지 못한 채 세션 B를 시작하는 상황이었습니다. 실행 시점의 인지 능력과 더불어, 에이전트가 세션 중간에 실행할 수 있는 온디맨드(on-demand) "여기 또 누가 있나요?" 쿼리를 통해 거의 모든 문제를 해결할 수 있습니다.
실제로 설계에 세심한 주의를 기울인 부분은 신뢰성(trust)입니다. 존재 레지스트리(presence registry)는 에이전트가 이를 믿을 수 있을 때만 유용합니다. 정리(cleanup) 없이 종료된 세션의 기록인 유령 피어(phantom peers)는 블록을 에이전트가 무시하게 되는 노이즈로 만들어 버리며, 이 경우 해당 기능은 없는 것보다 못하게 됩니다. 세션 종료 훅(session-end hooks)은 본질적으로 신뢰할 수 없기 때문에(크래시, 강제 종료, 배터리 방전 등), 생존 여부(liveness)는 OS를 대상으로 읽기(read) 시점에 확인합니다. 즉, 기록된 PID(process ID)가 존재하는지, 그리고 해당 프로세스의 시작 시간(start-time)이 기록된 내용과 일치하는지를 확인하는 것입니다. 두 번째 확인 작업은 보기보다 훨씬 중요합니다. PID는 재사용될 수 있습니다. 시작 시간 일치 여부를 확인하지 않으면, 재사용된 PID로 인해 종료된 세션이 영원히 살아있는 것으로 읽히게 되며, 이는 바로 이 레이어가 방지하고자 하는 실패 사례와 정확히 일치합니다.
의도적인 잠금(lock) 미사용
이 상황에서의 본능적인 설계는 상호 배제(mutual exclusion)입니다. 즉, 세션이 편집 중인 잠금 파일(lock files)을 생성하여 다른 세션의 접근을 거부하는 방식입니다. 저는 이 방식을 거부했으며, 그 이유는 다음과 같이 일반화할 수 있습니다.
- 문제는 배제가 아니라 인지(awareness)였습니다. 세션들은 파일에 대해 싸우고 있었던 것이 아니라, 서로의 존재를 몰랐을 뿐입니다. 세션들에게 정보를 제공하는 것이 실제 실패를 해결합니다. 세션을 차단하는 것은 대부분 가상적인 다른 문제를 해결할 뿐입니다.
- 잠금은 루프 내의 인간(human in the loop)에게 적대적입니다. 이것들은 저 자신의 병렬 세션들입니다. 강력한 잠금(hard lock)이 걸려 있다면, 제가 한 시간 전에 첫 번째 세션이 건드렸던 파일에 대해 두 번째 세션이 접근하려 할 때, 제 리포지토리가 저에게 안 된다고 말하는 셈이 됩니다.
- 권고(advisory) 방식은 우아하게 성능이 저하되지만, 잠금 방식은 재앙적으로 저하됩니다. 오래된 존재 기록은 경고 메시지 한 줄을 낭비할 뿐입니다. 하지만 오래된 잠금은 누군가 그것을 찾아낼 때까지 작업을 차단합니다.
따라서 이 레이어 전체는 권고(advisory) 방식입니다. 정보를 제공할 뿐, 절대 차단하지 않습니다. 몇 주가 지난 지금, 저는 단 한 번도 잠금 기능이 있었으면 좋겠다고 생각한 적이 없습니다.
저는 더 무거운 대안인, 모든 세션이 무언가에 손을 대기 전에 공식적으로 작업을 점유하는 내구성이 있는 작업 큐 (durable task queue) 방식도 거부했습니다. 큐는 "이 작업의 상태가 어떠한가"라는 질문에 답을 주지만, 이는 실제 질문이긴 해도 다른 종류의 질문입니다. 실시간 충돌 방지 (collision avoidance)를 위해서는 점유된 작업 (claimed task) 방식이 너무 거칠면서도 ("누군가 리팩토링 중이다"라는 정보는 그들이 지금 정확히 어떤 파일을 편집하고 있는지 알려주지 않음), 동시에 너무 과한 절차적 요식 행위입니다 (모든 세션이 드물게 발생하는 이벤트를 방지하기 위해 매번 점유 단계를 거쳐야 함). 반면 존재 여부 (Presence)는 더 세밀하며 비용도 들지 않습니다. 훅 (hooks)은 어차피 실행되니까요.
인간적인 측면: 어떤 세션이 나를 필요로 하는가
세션 간의 조정은 이야기의 절반일 뿐입니다. 나머지 절반은 _나 자신_을 조정하는 것입니다. 세 개의 프로젝트에 걸쳐 네 개의 세션이 돌아가고 있을 때, 비용이 많이 드는 질문은 "에이전트들이 충돌하고 있는가"에서 "내가 다른 터미널을 쳐다보고 있는 동안 어떤 에이전트가 내 입력을 기다리고 있는가"로 바뀝니다.
Presence 기록은 이를 위해 working 또는 waiting이라는 상태 필드를 가집니다. 턴 경계 (turn boundaries)가 이를 구동합니다. 프롬프트를 제출하면 세션이 working 상태로 표시되고, 에이전트가 자신의 턴을 마치면 waiting 상태로 표시됩니다. 여기서 미묘한 점은 도구 활동 (tool activity)만으로는 이를 알 수 없다는 것입니다. 생각 중이거나 파일을 읽고 있는 세션은 실제로 바쁘지만, 모든 활동 지표상으로는 유휴 (idle) 상태로 보입니다. 오직 턴 경계만이 진실을 말해줍니다. 워크벤치 뷰 (workbench view)는 프로젝트별로 세션을 렌더링하며, 기다리고 있는 대상에게 마커를 표시하고, 백그라운드로 돌아가던 세션이 유휴 상태가 되면 알림을 보냅니다.
처리량 (throughput)에 미치는 효과는 충돌 수정보다 더 큽니다. 병렬 세션은 이미 _안전(safe)_했습니다. 하지만 이 기능은 병렬 세션을 가치 있게(worth it) 만들었습니다. 왜냐하면 에이전트의 유휴 시간 — 완료됨, 대기 중, 인지되지 않음 — 이야말로 병렬성이 실제로 누수되는 지점이었기 때문입니다.
전이 가능한 것들
세부 사항은 저의 것이지만, 그 형태는 어떤 멀티 세션 에이전트 설정에도 적용 가능합니다:
전이 가능한 것들
세부 사항은 저의 것이지만, 그 형태는 어떤 멀티 세션 에이전트 설정에도 적용 가능합니다:
- 작성자별로 상태를 분할하고, 공유 파일은 의도적인 예외로 처리하세요. 구축을 통해 해결한 손상 문제는 다시 발생하지 않습니다. 추가하는 락(Lock)은 영원히 정확해야 합니다.
- 시작 시점에 인지도를 주입하세요. 가장 지배적인 충돌은 경주를 하는 것이 아니라 눈을 감고 시작하는 것입니다. 세션 시작 시에
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기