멀티에이전트 코딩에서 실제로 사고 난 것들
AI 코딩 에이전트를 하나만 굴릴 때는 안 나던 사고가, 여러 개를 동시에 돌리기 시작하면서 하나씩 터졌다. 실행하는 에이전트 따로, 검증하는 에이전트 따로 붙여 놓고 병렬로 굴리면 속도는 확실히 빨라진다. 대신 메인 창에서 보이지 않는 곳에서 일이 벌어진다. 서브에이전트(메인 지시를 받아 딸려서 움직이는 보조 에이전트, Subagent) 하나가 뭘 지웠는지, 검증한다던 에이전트가 실은 뭘 실행했는지, 메인은 결과만 받아 볼 뿐 과정을 못 본다.
이 글은 그렇게 실제로 사고가 난 사례를 다룬다. 어느 회사, 어느 프로젝트인지는 다 지웠다. 무슨 명령이 왜 위험했는지, 어떻게 막았는지는 그대로 남겼다. 사고 자체가 재발 방지의 재료니까.
git add -A가 지운 파일까지 커밋했다
한 작업 세션에서 여러 서브에이전트를 동시에 굴리고 있었다. 각자 맡은 파일을 고치고 정리하던 중 하나가 특정 디렉터리를 작업트리에서 지워 버렸다. 의도한 삭제가 아니라 그 서브에이전트의 작업 범위 판단 실수였다. 메인 에이전트는 이 사실을 몰랐다. 서브에이전트가 뭘 했는지는 최종 보고에만 요약되지, 파일 하나하나의 증감까지 메인 창에 실시간으로 뜨지 않는다.
문제는 그 다음이었다. 작업이 끝나고 커밋할 때 git add -A를 썼다. 이 명령은 새로 만든 파일, 고친 파일, 그리고 지워진 파일까지 전부 스테이징(커밋 대상으로 등록하는 것, Staging)한다. "손대지 말 것"으로 지정해 둔 보호 디렉터리 하나가 통째로 삭제 상태로 커밋에 실렸다. 23개 파일이 한순간에 저장소에서 사라졌다. 커밋 로그만 보면 그냥 "정리했다"는 한 줄로 보인다.
-A가 왜 위험한지 정확히 짚어야 한다. 이 플래그는 "지금 작업트리 상태를 있는 그대로 다음 커밋으로 만들겠다"는 뜻이다. 내가 지운 파일이든 남이 지운 파일이든 구분하지 않는다. 혼자 작업할 때는 이 무차별성이 편하다. 어차피 내가 지운 거니까. 그런데 서브에이전트 여럿이 같은 작업트리를 건드리는 환경에서는 이 무차별성이 그대로 위험으로 바뀐다. 누가 지웠는지 모르는 삭제까지 한 덩어리로 승인해 버린다.
복구는 됐다. 이전 커밋 해시에서 해당 경로만 git checkout <sha> -- <path>로 되살려 재커밋했다. 하지만 그 사이 시간과, "혹시 다른 것도 지워졌나" 확인하는 데 든 신경은 되돌릴 수 없었다. 이후로 커밋 방식을 바꿨다. 바뀐 파일을 git add <file1> <file2>처럼 이름으로 하나씩 지정한다. 디렉터리째 add하거나 -A를 쓰지 않는다. 그리고 스테이징 직후, 커밋을 찍기 전에 반드시 이 명령을 돌린다.
# 커밋 전 삭제 확인 루틴
git status --short
git diff --cached --name-status | grep '^D'
# 보호 경로가 삭제 목록에 없는지 별도 확인
git diff --cached --name-status | grep '보호경로키워드'
--name-status는 스테이징된 각 파일 앞에 상태 문자를 붙여 보여준다. A는 추가, M은 수정, D는 삭제다. 이 중 D로 시작하는 줄이 내가 예상한 삭제와 일치하는지, 예상 못 한 게 섞여 있지는 않은지 커밋 직전에 한 번 걸러낸다. 한 줄짜리 grep이지만 그날 이후로 같은 사고는 다시 안 났다.
검증 에이전트가 내 작업분을 날렸다
두 번째 사고는 결이 달랐다. 이번엔 검증을 맡은 서브에이전트가 문제였다. 어떤 변경이 실패를 제대로 잡아내는지 확인하려면 "이 수정을 잠깐 되돌렸을 때 테스트가 정말 실패하는가"를 봐야 한다. 소프트웨어 테스트에서 흔히 쓰는 방법인데, 문제를 정말로 고쳤는지 확인하려고 일부러 원래 버그 상태로 되돌려 테스트를 실패시켜 보는 것이다(고친 게 진짜 원인이 맞는지 뒤집어서 검증하는 절차, Fail-on-Revert).
검증 에이전트는 이 되돌리기를 git checkout src/로 처리했다. 이 명령은 지정한 경로를 마지막 커밋 상태로 강제로 되돌린다. 문제는 그 시점에 아직 커밋하지 않은 작업 중인 코드가 같은 경로에 있었다는 점이다. 커밋 안 된 변경은 git 입장에서 "기록되지 않은 것"이라 되돌리기 대상에 그대로 휩쓸린다. 작업 중이던 코드가 한순간에 사라졌다.
다행히 복원은 가능했다. 하지만 복원이 됐다는 사실이 이 사고를 가볍게 만들지는 않는다. 복원이 항상 가능하다는 보장이 없기 때문이다. 백업이 없었거나, 에디터 히스토리가 이미 밀려났거나, 애초에 그 작업이 한 번뿐인 실험적 코드였다면 그대로 증발했을 상황이다. "검증하려고 되돌렸다가 원래대로 다시 돌려놨다"는 선의가, 그 사이에 낀 미저장 작업까지는 지켜주지 못했다.
여기서 배운 건 명확하다. 검증하는 쪽에는 애초에 파일을 되돌리거나 지우는 권한을 주면 안 된다. 이후로는 서브에이전트에게 작업을 맡길 때 브리프(작업 지시서, Brief)에 이 문구를 못 박아 넣는다.
git add / git checkout / git reset / git restore / git clean 금지.
파일 직접 삭제 금지.
검증이 되돌리기 확인을 필요로 하면 파일을 직접 고쳤다가
확인 후 원래 내용으로 다시 고쳐 넣는 방식(in-place 임시 수정)만 허용.
대안은 하나 더 있다. 검증 전에 작성자 쪽 작업분을 먼저 커밋해 두는 것이다. 커밋이 되어 있으면 설령 검증 에이전트가 되돌리기를 시도해도 git 기록에 남아 있어 복원이 훨씬 쉽고 확실해진다. 두 사고를 나란히 놓고 보면 패턴이 겹친다. 하나는 메인의 광범위한 스테이징이 남의 삭제를 삼켰고, 다른 하나는 검증 에이전트의 되돌리기가 미저장 작업을 삼켰다. 둘 다 "지금 이 파일 조작이 누구 몫인지, 무엇을 건드리는지"를 메인이 실시간으로 파악하지 못한 채 일어났다.
| 사고 유형 | 가해 명령 | 피해 | 방어선 |
|---|---|---|---|
| 보호 파일 삭제 커밋 | git add -A |
서브에이전트가 지운 보호 디렉터리가 그대로 커밋됨 | 파일명 명시 add + 커밋 전 D 상태 grep |
| 미저장 작업 유실 | git checkout <path> |
검증 중 uncommitted 작업분이 통째로 사라짐 | 브리프에 git 파일조작 금지 명시 + 사전 커밋 |
| 게이트 우회(뒤에서 다룸) | vendor-token 문자열 겹침, 오래된 검증 결과 재사용 | 실제로는 한 벤더만 봤는데 두 벤더가 통과시킨 것으로 기록 | 구조적 필드 비교 + 유효기간 상한 |
| MCP 타임아웃(뒤에서 다룸) | 표준입력 상속으로 인한 무한 대기 | 같은 요청이 CLI 직접 호출에서는 약 10.8초, MCP 경유에서는 300초 뒤 타임아웃 | 자식 프로세스 표준입력을 명시적으로 차단 |
통과했다는 게이트가 사실은 뚫려 있었다
세 번째 사고는 파일이 아니라 판정 절차 자체에서 났다. 여러 AI 모델을 교차로 검증에 쓰는 구조를 짠 적이 있다. 계획을 세운 모델과 다른 모델이 그 계획을 검토하고, 테스트를 짠 모델과 다른 모델이 테스트를 검토하는 식이다. 이 관문(게이트, Gate)을 "통과해야 다음 단계로 넘어간다"는 자동화된 문지기로 만들어 뒀다. 그런데 이 문지기 자체에 구멍이 네 라운드에 걸쳐 발견됐다.
첫 번째 구멍은 기계적 검사만으로 단계를 통과시킬 수 있다는 점이었다. 테스트가 초록불이면 통과였는데, 정작 사람이 봐야 하는 주관적 판단(코드가 정말 요구사항에 맞는지 같은)은 건너뛸 수 있었다. 게다가 실패가 일정 횟수 쌓이면 자동으로 통과 처리되는 기본값(실패해도 결국 열리는 방식, Fail-open)까지 숨어 있었다. 막는 게 목적인 문에 "여러 번 두드리면 그냥 열린다"는 뒷문이 달려 있던 셈이다.
두 번째 라운드에서는 검증 결과가 정말 지금 이 파일을 보고 나온 것인지 확인하는 절차가 빠졌다는 게 드러났다. 다른 파일을 검토한 결과를 지금 파일의 통과 근거로 재사용해도 걸러지지 않았다. 게다가 오래전에 나온 검증 결과를 지금 다시 꺼내 써도 막을 방법이 없었다.
세 번째 라운드가 제일 교묘했다. 작업 이름(slug)에 특정 벤더 이름을 뜻하는 문자열이 우연히 들어가면, 시스템이 그걸 "그 벤더가 검증했다"는 신호로 잘못 읽었다. 예를 들어 작업 이름에 특정 검증사 이름이 부분 문자열로 섞여 있으면, 실제로는 한 벤더만 봤는데 이름 매칭 로직이 두 벤더 모두 확인한 것처럼 인식했다. 이름이 겹친다는 우연 하나로 이중 검증이라는 안전장치가 무력화된다.
고친 방법은 이름을 부분적으로 비교하지 않고, 정해진 위치의 정해진 조각만 정확히 일치하는지 비교하는 것이었다. 검증 결과 파일 이름을 작업이름.단계.벤더.결과처럼 구조화하고, 벤더와 단계는 그 구조에서 정확한 위치(뒤에서 두 번째, 세 번째 조각)만 떼어내 비교했다. 문자열이 어딘가에 섞여 있는지가 아니라, 정해진 자리에 정확한 값이 있는지를 본다. 오래된 결과 재사용 문제는 유효기간에 상한을 걸어 막았다. 환경변수로 유효기간을 설정할 수 있게 열어 뒀더니 그 값 자체를 무제한으로 늘릴 수 있는 허점이 남아 있었다. 결국 값이 얼마로 들어오든 최댓값을 강제로 24시간으로 묶어버리는 처리를 추가했다.
이 사고에서 얻은 제일 큰 교훈은 이거다. "게이트가 있다"는 사실과 "게이트가 실제로 막는다"는 사실은 다른 이야기다. 자동화된 관문을 만들었다고 안심하지 말고, 그 관문을 일부러 뚫어보려는 시도를 별도로 돌려봐야 한다. 실제로 이 네 라운드의 구멍들도 관문을 그냥 쓴 게 아니라, 다른 모델에게 "이 관문을 우회할 방법을 찾아라"는 역할을 따로 맡겨서 찾아낸 것들이다.
"에러가 안 나는데 응답도 안 온다"의 정체
네 번째는 원인 찾기가 유독 오래 걸린 사고다. 서로 다른 벤더 모델을 검증에 부르는 연결 통로(MCP, Model Context Protocol — 에이전트가 외부 도구·모델을 표준화된 방식으로 호출하는 연결 규격)로 특정 모델을 호출하면 정확히 300초 후에 타임아웃이 났다. 그런데 같은 모델을 터미널에서 명령줄로 직접 호출하면 약 10.8초 만에 정상 응답이 왔다. 코드 문제가 아니라 통로 문제라는 뜻인데, 정작 어디가 막혔는지는 에러 메시지 어디에도 안 나왔다.
원인은 표준입력(프로그램이 키보드 입력을 받는 통로, stdin) 상속이었다. 에이전트 실행 환경이 이 연결 통로 서버를 띄울 때, 표준입력을 이미 다른 용도(에이전트 간 통신 파이프)로 점유하고 있었다. 그 서버 내부 코드가 자식 프로세스를 실행하면서 표준입력을 어디로 연결할지 명시하지 않았다. 명시하지 않으면 자식 프로세스는 부모의 표준입력을 그대로 물려받는다. 그 결과 자식 프로세스가 "누군가 키보드로 입력해 주기를" 기다리며 무한 대기에 들어갔다. 아무도 입력을 줄 사람이 없으니 정해진 최대 대기 시간(300초)을 다 채우고서야 타임아웃으로 죽었다.
고치는 방법은 한 줄이었다. 자식 프로세스를 실행할 때 표준입력을 "아무것도 없음"으로 명시적으로 지정하면 된다. 프로그래밍에서는 이걸 흔히 /dev/null(아무 데도 안 가는 통로)로 표준입력을 막는다고 표현한다.
# 문제가 됐던 코드
subprocess.run(cmd, ...)
# 고친 코드 — 표준입력을 명시적으로 차단
subprocess.run(cmd, stdin=subprocess.DEVNULL, ...)
이 사고에서 배운 건 디버깅 순서에 관한 것이다. "권한 문제인가", "모델이 바뀌었나" 같은 그럴듯한 가설부터 먼저 시도했는데 전부 헛다리였다. 실제 원인은 훨씬 배관 쪽에 가까운 문제, 즉 프로세스가 실행되는 환경 자체의 설정이었다. 같은 요청을 두 가지 경로(직접 호출 vs 연결 통로 경유)로 나란히 실행해서 어느 쪽에서만 문제가 재현되는지 먼저 좁히는 게 더 빨랐다. 애플리케이션 로직을 아무리 들여다봐도 안 보이는 문제는, 그 로직을 감싸고 있는 실행 환경 쪽을 의심해야 한다.
비슷한 계열로 겪은 문제가 하나 더 있다. 특정 모델을 부르면 이유 없이 빈 응답만 오고 종료 코드는 정상(0)으로 찍히는 경우다. 원인은 그 벤더 쪽 사용량 한도 초과였다. 한도를 넘기면 에러를 화면에 안 뿌리고 자기네 로그 파일에만 조용히 남긴다. 명령줄 도구의 표준 출력만 보고 있으면 "왜 아무 응답도 없지"로 끝없이 헤매게 된다. 그 벤더가 제공하는 별도 로그 파일을 열어 사용량 초과 관련 문구를 찾아야 진짜 원인이 나온다. 이 경우는 설정으로 고칠 수 있는 문제가 아니었다. 사용량이 다시 채워지길 기다리거나 이용 등급을 올리는 수밖에 없었다.
한도 소진 대체가 검증을 무효로 만든 순간
마지막 사고는 앞의 네 개보다 더 미묘하다. 겉으로는 정상으로 보였기 때문이다. 여러 벤더를 교차 검증에 쓰는 구조에서, 한 벤더가 사용량 한도를 넘기면 자동으로 다른 모델로 대체해 응답을 주는 폴백(대체 경로, Fallback) 장치가 있었다. 편의를 위해 넣은 기능인데, 이게 오히려 함정이 됐다.
글을 쓴 모델과 그 글을 검증하기로 되어 있던 모델이 서로 다른 회사 제품이어야 한다는 게 이 구조의 전제였다. 그런데 검증을 맡은 벤더가 한도를 넘기자, 시스템이 조용히 같은 회사 계열의 다른 모델로 검증 요청을 대체해 버렸다. 글을 쓴 모델과 검증한 모델이 결국 같은 회사 제품이 된 것이다. 그런데도 판정 결과는 여느 때처럼 "통과"로만 찍혔다. 대체가 일어났다는 표시조차 없었다.
이 사고가 무서운 이유는, 앞의 네 사고와 달리 에러 메시지도 없고 타임아웃도 없고 삭제된 파일도 없었기 때문이다. 절차는 끝까지 정상적으로 굴러갔고 최종 판정도 "통과"로 나왔다. 다른 회사 눈으로 한 번 더 봐야 한다는 전제 자체가 조용히 깨졌는데, 그 사실을 알아채려면 실제로 응답한 모델이 무엇인지 로그를 열어 직접 확인하는 수밖에 없었다.
이후로 정한 규칙은 두 가지다. 첫째, 한도 초과로 인한 대체가 발생하면 그 사실을 판정 결과에 반드시 표시로 남긴다. 표시가 없는 통과와 표시가 있는 통과를 구분할 수 있어야 나중에 "이 판정을 믿어도 되는지"를 되짚어볼 수 있다. 둘째, 대체된 모델이 원래 검증자와 다른 회사 계열인지 확인한다. 같은 계열로 대체됐다면 그건 검증이 아니라 겉모습만 검증인 통과다. 그 경우엔 사람에게 알리고 별도 경로로 다시 검증받아야 한다.
표시된 이름과 실제로 응답한 모델이 다를 수 있다는 건 이 사고 하나로 끝나는 이야기가 아니다. 설정 화면이나 로그에 적힌 모델 이름을 그대로 믿지 말고, 실제로 무엇이 응답했는지를 확인하는 습관 자체가 여러 사고에 걸쳐 반복해서 필요했다. 이 주제는 교차 벤더로 AI 코드를 검증하는 법에서 더 자세히 다뤘다.
재발 방지 체크리스트
다섯 사고를 관통하는 공통점은 하나다. 메인 에이전트가 서브에이전트나 검증 에이전트가 실제로 무엇을 실행했는지 실시간으로 보지 못한다는 것. 그래서 방어선도 "실행되기 전에 막는다"와 "실행된 뒤 결과를 의심 없이 믿지 않는다" 두 축으로 짰다.
- 커밋 전에 항상
git diff --cached --name-status | grep '^D'로 예상 밖 삭제가 없는지 확인했는가. - 커밋은
git add -A대신 바뀐 파일을 이름으로 하나씩 지정했는가. - 검증·리뷰용 서브에이전트 지시서에 git add/checkout/reset/restore/clean 금지를 명시했는지 본다.
- 검증 전에 작성자 쪽 작업분을 먼저 커밋해 두었는가.
- 자동화된 검증 관문을 그냥 믿지 않고, 일부러 뚫어보려는 시도를 별도로 돌려봤는가.
- 검증 결과 파일의 벤더·단계 식별을 부분 문자열이 아니라 구조적 위치로 비교하는가.
- 오래된 검증 결과가 재사용되지 않도록 유효기간에 상한을 걸었는가.
- 외부 모델 연결 통로에서 원인 불명 타임아웃이 나면, 자식 프로세스의 표준입력·출력 연결부터 의심했는가.
- 빈 응답이 계속되면 명령줄 표준 출력만 보지 말고 해당 서비스의 별도 로그 파일을 확인해야 한다.
- 사용량 한도로 인한 자동 대체가 발생하면 그 사실이 결과에 표시로 남는가, 그리고 대체된 모델이 원래와 다른 회사 계열인지 확인했는가.
이 열 줄 모두 처음부터 알고 있던 규칙은 아니다. 사고가 하나씩 나고서야 하나씩 추가됐다. 여러 에이전트를 동시에 굴리는 재미는 크지만, 그 재미의 대가로 메인이 못 보는 사각지대도 그만큼 늘어난다는 걸 다섯 번의 사고를 거치며 확인했다. 자세한 재발 방지 절차는 계속 갱신 중이고, 비슷한 실전 기록은 프로그래밍 카테고리에 계속 모아둔다.
아직 댓글이 없습니다.