에이전트 10개가 동시에 같은 타입 체크를 실행했습니다. 해결책은 디렉터리였습니다.

같은 체크아웃에 코딩 에이전트 17개, 동시에 도는 tsc 10개, load average 37에 남은 RAM 87 MB. 90초짜리 타입 체크가 7분 36이 걸렸습니다. 무엇을 측정했는지, 왜 머신이 계산을 하고 있지 않았는지, 그리고 문제를 해결한 작은 공유 잠금을 정리했습니다. 어떤 저장소에도 그대로 옮길 수 있습니다.

9월 7일, 개발 머신이 제대로 응답하지 않기 시작했습니다. 크래시도 아니고 멈춤도 아닙니다. 그냥 모든 것이 열 배 오래 걸렸습니다. 코드와 아무 상관 없는 것들까지 포함해서.

뻔한 용의자들은 전부 틀렸습니다. 노트북은 과열되지 않았습니다. 기록된 스로틀링은 없었고 배터리는 30.6 C였습니다. CPU를 먹어치우는 폭주 프로세스도 없었습니다. 배포된 것도 없었습니다. 유일하게 평소와 달랐던 것은 같은 저장소 안에 에이전트 CLI 17개가 살아 있었다는 점인데, 우리에게는 평범한 근무일입니다.

추측이 아니라 측정한, 실제로 벌어지고 있던 일은 이렇습니다.

측정

16 GB, 8코어 머신, 켠 지 다섯 시간 반, 에이전트 17개가 작업 중.

측정한 것
살아 있는 에이전트 CLI17
동시에 도는 tsc --noEmit , 2분 안에 확인3, 그다음 10
Load average37에서 41
남은 RAM / 메모리 압축기87 MB / 7.2 GB
데스크톱 앱 타입 체크 한 번, 포화된 머신CPU 26초에 대해 벽시계 7분 36
같은 타입 체크, 한산한 머신33초

결정적인 줄은 끝에서 두 번째입니다. CPU 26초가 7분 반에 걸쳐 퍼져 있다면 사용률 10퍼센트입니다. 타입 체크는 계산하고 있지 않았습니다. 메모리를 기다리고 있었습니다.

그리고 그 프로세스 중 하나는 도중에 운영체제에 죽임을 당했습니다. 죽은 tsc 는 0이 아닌 코드와 빈 출력으로 종료되는데, 이건 진짜 타입 오류와 구분되지 않습니다. 그러니까 머신은 느리기만 한 게 아니라, 아무도 믿을 수 없는 판정을 내놓고 있었습니다.

아무도 잘못한 사람이 없습니다

여기가 잠깐 머물러볼 만한 대목입니다. 이 고장이 다가오는 걸 알아채기 어렵게 만드는 게 바로 이것이기 때문입니다.

그 에이전트들은 모두 규칙을 따르고 있었습니다. 각자 TypeScript를 편집했습니다. 각자 손을 넘기기 전에 타입을 확인하라는 지시를 받았습니다. 각자 tsc --noEmit 을 실행했습니다. 아무도 다른 에이전트를 볼 수 없었습니다. 어떤 에이전트가 「지금 비싼 일을 하고 있으니 기다려」라고 적어둘 공용 칠판 같은 건 없습니다.

그다음엔 스스로를 키웁니다. 머신이 포화됐으니 타입 체크가 느려집니다. 그걸 지켜보던 에이전트는 멈췄다고 판단합니다. 그래서 죽이고 하나를 더 띄웁니다. 이 반사는 혼자 있을 때는 옳고 무리 안에서는 치명적입니다. 그리고 이것은 한 달 전에 에이전트들이 멈춘 검색 프로세스를 뒤에 남겼을 때 기록했던 것과 같은 고장 계열입니다. Process Guard 는 이미 시작된 것을 찾아내는 안전망이고, 여기서 하는 일은 시작 자체를 막는 것입니다.

우리가 택하지 않은 세 가지 답

에이전트를 줄인다. 증상을 반으로 나누고 버그는 남깁니다. 부하가 걸린 머신에서 동시에 도는 타입 체크 두 개는 여전히 하나보다 느리고, 대수를 줄이는 것은 일을 빠르게 만들어주는 바로 그것으로 문제 값을 치르는 셈입니다.

마지막에 타입 체크 한 번. 솔깃하지만, 성능과는 아무 상관 없는 이유로 틀렸습니다. 티켓 10개가 지난 뒤에 발견된 타입 오류는 고아입니다. 그걸 쓴 에이전트는 닫혔고 컨텍스트는 사라졌으며, 한 줄을 고치려고 사람이 주제 전체를 다시 열어야 합니다. 우리는 검증을 미루고 싶지 않았습니다.

증분 컴파일. 시험해보고 버렸습니다. --noEmit 모드에서는 이득이 의심스럽고, 동시에 도는 프로세스들이 공유된 .tsbuildinfo 를 망가뜨립니다. 문제의 절반을, 나머지 절반을 더 나쁘게 만들면서 해결하는 방법입니다.

대신 한 일, 체크 한 번을 함께 쓰기

규칙은 「덜 자주 확인한다」가 아닙니다. 프로젝트마다, 모두를 위해, 한 번에 타입 체크 하나입니다. 래퍼 스크립트가 N번의 확인을 한 번으로 바꾸고 세 가지 경우에 답합니다.

  1. 지난 실행 이후 바뀐 게 없으니 그 결과를 돌려준다.
  2. 실행이 이미 진행 중이니 그것을 기다렸다가 결과를 받는다.
  3. 그 외에는 잠금을 잡고 머신에서 유일한 tsc 가 된다.

에이전트 입장에서는 달라진 게 없습니다. yarn typecheck 을 치고 타입 오류를 받습니다. 예전보다 더 오래 기다리는 일도 없습니다. 자기가 기다리는 실행은 자기 것보다 먼저 시작된 실행이기 때문입니다. 머신은 열 번치 대신 한 번치를 지불합니다.

이게 전부입니다. 흥미로운 건 여기 필요한 두 장치가 생각보다 훨씬 작다는 점입니다.

잠금은 디렉터리입니다

파일도, 데이터베이스도, 데몬도 아닙니다. 디렉터리입니다.

try {
  mkdirSync(lockDir);       // 성공: 잠금은 우리 것
} catch (err) {
  if (err.code === 'EEXIST') { /* 누군가 쥐고 있다, 기다린다 */ }
}

mkdir 은 디렉터리를 만들거나 EEXIST 로 실패하거나이고, macOS와 Windows와 Linux에서 의존성도 네이티브 호출도 없이 원자적으로 그렇게 합니다. 파일을 쓰고 나서 존재하는지 확인하는 것은 두 연산이 되고, 두 연산이 바로 두 번째 에이전트가 사이로 미끄러져 들어오는 지점입니다.

디렉터리 안에는 pid와 호스트 이름과 시작 시각을 담은 owner.json 을 떨어뜨립니다. 그 파일은 진단용이고 죽은 잠금을 감지하기 위한 것입니다. 배제를 하는 건 절대 그 파일이 아닙니다.

죽은 잠금은 두 경우에 자동으로 회수됩니다. 소유 프로세스가 사라진 경우, 이것은 호스트 이름이 일치할 때만 확인합니다. pid는 머신이 달라지면 아무 의미가 없기 때문입니다. 다른 하나는 잠금이 15분보다 오래된 경우입니다.

버그 하나만큼의 값을 치른 함정. mkdirowner.json 쓰기 사이에는 소유자를 읽을 수 없는 창이 있습니다. 그 창에서 잠금을 죽었다고 선언하는 것은 방금 그것을 잡은 프로세스에게서 훔치는 일이고, 이 파일이 막으려고 존재하는 바로 그 경쟁입니다. 그래서 읽을 수 있는 소유자가 없을 때는 디렉터리의 나이로 판단하지, 없는 파일로 판단하지 않습니다.

지문은 날짜와 개수입니다

첫 번째 경우는 지난 실행 이후 뭔가 바뀌었는지 알아야 합니다. 뻔한 답은 소스 파일을 해시하는 것입니다. 우리는 그렇게 하지 않습니다.

지문은 tsconfig의 include 에서 끌어낸 루트들과 tsconfig 자체에 대한 <가장 최근 mtime>:<파일 개수> 입니다.

2300개 파일에서 모든 바이트를 읽는 비용은 그렇게 아끼는 확인보다 비쌉니다. 날짜만으로는 삭제를 놓칩니다. 개수만으로는 편집을 놓칩니다. 둘을 합치면 양쪽을 덮습니다. 받아들인 거짓 음성은 같은 밀리초 안에 들어와 개수를 그대로 두는 편집 두 개이고, 그 최악의 경우도 몇 초 묵은 캐시 결과일 뿐 조용한 타입 오류는 아닙니다. 막아서는 검증은 여전히 빌드 쪽에 남아 있기 때문입니다.

적힌 규칙으로는 부족해서 훅을 붙였습니다

지시는 첫날부터 AGENTS.md 에 있었습니다. tsc 를 직접 실행하지 말고 항상 공유 스크립트를 쓸 것. 그것으로는 부족했고, 왜 그런지 솔직히 말할 가치가 있습니다.

편집 뒤에 타입을 확인하는 건 깊이 밴 반사입니다. 압박을 받으면 에이전트는 지시를 다시 읽지 않고 npx tsc --noEmit 을 칩니다. 그리고 잠금이 막으려고 존재하는 그 떼거리를 다시 만들어내는 데는 규칙을 건너뛴 에이전트 하나면 충분합니다. 지시는 협상이 됩니다. 훅은 안 됩니다.

그래서 Bash 도구에 PreToolUse 훅을 걸어, 직접 실행하는 tsc 를 거부하고 거부 메시지에서 올바른 명령을 짚어주게 했습니다.

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          { "type": "command", "command": "node \"$CLAUDE_PROJECT_DIR/scripts/hooks/block-direct-tsc.mjs\"" }
        ]
      }
    ]
  }
}

훅은 도구 호출을 JSON으로 stdin에서 읽고, 거부할 때는 사유를 stderr에 내며 2로 종료하고, 통과시킬 때는 0으로 종료합니다. 쓸모 있는 훅과 성가신 훅을 가르는 것은 두 가지 디테일입니다.

문자열 아무 데나가 아니라 명령 자리에 있는 tsc 에 맞춥니다. 세 글자를 아무 데서나 찾으면 grep -rn tsc AGENTS.md 까지 거부하게 됩니다. 그래서 패턴은 tsc 가 줄 맨 앞이나 ; , && , || , | , ( 뒤에 오기를 요구하고, 그 앞에 패키지 실행기와 경로가 붙는 것은 허용합니다. tsc --version 도 통과시킵니다. 정보를 주는 옵션을 거부할 이유가 없습니다.

예상하지 못했던 두 번째 것도 거부합니다. 어떤 에이전트가 잠금 뒤에서 기다리다가, 한참 뒤에 죽었을 거라고 판단하고, 스스로 막힌 걸 풀려고 잠금 디렉터리를 지우는 걸 봤습니다. 그건 살아 있는 프로세스 옆에서 두 번째 무거운 프로세스를 시작하는 일이고, 잠금이 지키는 모든 것을 완벽하게 우회하는 방법입니다. 그래서 잠금이나 캐시 디렉터리를 지우는 것도 거부하고, 죽은 잠금은 알아서 회수된다고 설명합니다.

그 두 번째 거부는 미리 앉아서는 절대 못 썼을 것입니다. 막힌 에이전트가 실제로 무엇을 하는지 지켜본 데서 나왔고, 그건 그들이 무엇을 할지 상상하는 것보다 나은 보호 난간의 출처입니다.

어디서 멈추는가

훅은 Claude Code 전용입니다. 플릿에서 같이 돌아가는 다른 에이전트 CLI들은 적힌 규칙만 봅니다. 이건 알려진 구멍이고 받아들였습니다. 모든 CLI가 읽는 훅 표준을 기다리는 동안, 플릿의 대부분을 덮는 보호 난간이 난간 하나 없는 것을 이깁니다.

공유 스크립트 자체는 제공자 중립적입니다. 그냥 명령이기 때문입니다. yarn typecheck 을 실행할 수 있는 CLI라면 강제되든 아니든 잠금의 덕을 봅니다.

여기서 가져갈 것

타입 체크는 우리에게 가장 시끄러운 사례였지 특별한 사례가 아니었습니다. 이 패턴은 비싸고, 짧은 창 안에서 멱등이며, 같은 좋은 이유로 모든 에이전트가 시작하는 어떤 명령에나 적용됩니다. 의존성 설치, 전체 테스트 스위트 실행, 프로덕션 빌드, 고정 포트에서 개발 서버 띄우기.

이 순서대로 세 가지 질문, 그러면 설계는 끝입니다.

  1. 최근 결과를 재사용할 수 있나.
  2. 이미 진행 중인 실행에 합류할 수 있나.
  3. 그렇지 않다면, 내가 혼자서 그것을 시작하는 사람인가.

여러 에이전트가 네 머신을 함께 쓴다면, 측정할 가치가 있는 건 몇 개가 돌고 있느냐가 아닙니다. 그중 몇 개가 같은 1분 안에 같은 명령을 시작하느냐입니다. 네 머신이 실제로 느끼는 건 그 숫자이고, 그걸 들여다보기 전까지 너는 계속 열을 탓하게 됩니다.

한 저장소에서 여러 에이전트를 서로 밟지 않게 돌리는 큰 그림은 3에서 8개의 코딩 에이전트를 병렬로 실행하면서 추적을 잃지 않는 방법 에 있고, 실제로 시작된 프로세스들을 위한 안전망은 Process Guard 입니다. 공유 스크립트도 훅도 둘 다 AgentsRoom 저장소 안에 있습니다. 바로 그날 오후에 그 17개의 에이전트가 작업하던 곳입니다.

AgentsRoom 다운로드

모든 AI 에이전트를, 모든 프로젝트에서, 하나의 창으로 실행하세요.

무료AgentsRoom 다운로드

컴패니언 앱: 이동 중에도 에이전트를 모니터링

Claude, Codex, Antigravity CLI 또는 다른 AI 공급자를 사용하세요.

확장 프로그램 설치
Chrome Web Store

버그와 요청을 공개 백로그로 바로 보내세요.

AgentsRoom의 실제 모습.

멀티 프로젝트
멀티 프로바이더
멀티 에이전트
실시간 상태
파일 diff & 커밋
모바일 앱
라이브 프리뷰
에이전트 팀
브라우저 자동화
백로그 기반 개발
프롬프트 라이브러리
스킬 라이브러리
모든 기능 보기

계속 읽기