
Claude Code 에이전트 군단을 Discord를 통해 서로 대화하게 만들기
요약
Claude Code 세션에 Discord 봇 정체성을 부여하여 멀티 프로젝트를 관리하고, 봇 간의 상호작용을 시도하는 실험적 방법을 소개합니다. 플러그인 내부 코드의 제약으로 인해 봇이 다른 봇의 멘션을 인식하지 못하는 기술적 한계와 원인을 분석합니다.
핵심 포인트
- Claude Code 세션에 Discord 봇 토큰을 부여해 원격 대화 가능
- 각 봇은 독립적인 상태 디렉토리(State directory)가 필요함
- 플러그인 코드 내 'msg.author.bot' 체크로 인해 봇 간 대화 불가
- Discord 플러그인의 접근 제어 로직에 따른 기술적 장벽 확인
저는 각각 자신만의 Claude Code 세션과 축적된 컨텍스트(Context)를 가진 여러 개의 장기 프로젝트를 운영하고 있습니다. 스택도 다르고, 데이터베이스도 다르며, 서로 겹치는 부분도 없습니다. 이 세션들은 컨텍스트를 공유하지 않기 때문에 유용하지만, 이는 곧 각 세션이 자신만의 터미널 창에서 실행되어야 하며 제가 책상 앞에 있을 때만 대화할 수 있다는 것을 의미합니다.
그래서 저는 각 세션에 Discord 봇(Bot) 정체성을 부여했습니다. 이제는 줄을 서 있는 동안에도 .NET 코드베이스를 알고 있는 세션에게 결제 관련 예외 케이스(Edge case)에 대해 물어볼 수 있습니다.
그 작업은 저녁 한 끼 정도의 시간이 걸렸습니다. 그다음에는 그들이 서로 대화하게 만들려고 시도했는데, 그 작업은 훨씬 더 오래 걸렸으며 제가 직접 작성한 보안 버그를 발견하게 되었습니다.
구조 (The shape of it)
Claude Code의 Discord 채널 플러그인을 사용하면 세션이 Discord 메시지를 보내고 받을 수 있습니다. 프로젝트당 하나의 세션을 실행하고, 각 세션에 고유한 봇 토큰(Bot token)을 부여하면 각 프로젝트는 주소 지정이 가능한 정체성을 갖게 됩니다.
한 가지 명확하지 않은 부분은, 플러그인이 환경 변수(Environment variable)로부터 상태 디렉토리(State directory)를 읽는다는 점입니다. 따라서 모든 봇은 자신만의 디렉토리가 필요합니다.
$env:DISCORD_STATE_DIR = "$env:USERPROFILE\.claude\channels\bot-a"
claude
각 상태 디렉토리에는 access.json 파일이 포함되어 있습니다. 이 파일은 누가, 어떤 채널에서 이 봇과 대화할 수 있는지, 그리고 멘션(Mention)이 필요한지 여부를 결정합니다.
{
"dmPolicy": "allowlist",
"allowFrom": ["<my-user-id>"],
...
groups에 포함되지 않은 채널은 모델이 확인하기도 전에 삭제됩니다. 봇이 분명히 초대받았음에도 채널에서 조용히 가만히 있다면 가장 먼저 확인해야 할 사항입니다.
장벽: 봇은 봇을 트리거할 수 없다
한 채널에 네 개의 봇이 있다면, 당연히 다음 단계는 하나가 다른 봇들을 조정하게 만드는 것입니다. 제 봇은 다른 봇을 직접 언급하는 메시지를 게시했습니다. 하지만 아무 일도 일어나지 않았습니다. 오류도, 거부도, 로그 라인(Log line)도 없었습니다. 언급된 봇은 마치 아무 말도 듣지 못한 것처럼 그냥 계속 할 일을 했습니다.
저는 권한 문제라고 가정하고 Discord 개발자 포털(Developer portal)에서 한참을 보냈습니다. 문제는 그것이 아니었습니다. 상황을 종결시킨 통제된 비교 실험은 다음과 같습니다:
| 시간 | 작성자 | 언급 | 응답 |
|---|---|---|---|
| 10:28:06 | bot | <@bot-b> | 없음 |
| 10:33:14 | me (human) | <@bot-b> | 36초 만에 응답 |
동일한 채널, 동일한 설정, 동일한 언급(mention) 텍스트입니다. 오직 작성자만 달랐습니다.
원인은 플러그인의 server.ts에 있는 네 줄의 코드입니다:
client.on('messageCreate', msg => {
if (msg.author.bot) return
handleInbound(msg).catch(...)
...
Discord는 게이트웨이 이벤트(gateway event)를 완벽하게 전달합니다. 하지만 플러그인은 접근 제어(access control)를 수행하기 _전에, 그리고 모델에 도달하기 _전에 이를 폐기해 버립니다. 어떤 권한 부여로도 이를 바꿀 수 없으며, 이에 대한 설정도 존재하지 않습니다. 서버는 정확히 세 개의 환경 변수(environment variables)를 읽지만, 그 중 어느 것도 이 부분에 관여하지 않습니다.

이 점은 잠시 곱씹어 볼 가치가 있습니다. 왜냐하면 이는 통합(integration) 디버깅에 관한 일반적인 교훈을 주기 때문입니다: 이벤트를 전달하는 계층(layer)과 그 이벤트에 반응하는 계층은 서로 다른 계층이며, 설정(configuration)은 오직 그중 하나만을 수정할 수 있습니다.
해당 코드가 핵심적인 이유 (load-bearing)
당연한 해결책은 가드(guard) 코드를 삭제하는 것입니다. 하지만 그러지 마십시오.
그 단 한 줄의 코드는 두 가지 역할을 수행하고 있습니다. 다른 봇들을 차단하는 역할과, 봇이 _자기 자신_에게 반응하는 것을 막는 역할입니다. 대체 코드 없이 이를 삭제하면, 자기 자신의 메시지를 들을 수 있게 된 모든 봇은 API 속도로 영원히 자신의 응답에 다시 답하게 될 것입니다.
이는 가설이 아닙니다. 이 제약 사항을 이해하기 전, 저는 네 개의 봇을 동시에 언급하는 메시지를 하나 보냈습니다. 그중 세 개가 6초 이내에 응답했습니다. 만약 그들이 서로의 소리를 들을 수 있었다면, 그 과정은 멈추지 않았을 것입니다.
따라서 대체 코드는 이 두 가지 관심사(concerns)를 분리해야 합니다:
// 무조건적이며, 설정 불가능함
if (msg.author.id === client.user?.id) return
...그리고 _다른 봇들_에 대한 결정은 논리적으로 추론 가능한 다른 곳으로 옮겨야 합니다.
패치 (The patch)
저는 access.json에 allowBots: string[]를 추가했으며, 기본값은 []로 설정했습니다. 이는 정확히 이전의 동작 방식과 동일하므로, 설정되지 않은 봇은 변경 사항이 없습니다.
기능 자체보다 더 중요했던 두 가지 세부 사항이 있습니다:
이것은 이벤트 핸들러(event handler)가 아니라 gate()에서 확인됩니다. gate()는 메시지가 올 때마다 access.json을 다시 읽기 때문에, 아무것도 재시작하지 않고도 네 개의 세션이 실행되는 동안 허용 목록(allowlist)을 편집할 수 있습니다.
중앙에서 강제되는 것은 아무것도 없습니다. 각 봇의 자체 설정에 자신을 방해할 수 있는 대상이 나열됩니다. 글로벌 레지스트리(global registry)가 없으므로, 파일 하나가 손상된다고 해서 모든 것이 한꺼번에 열리는 일은 발생하지 않습니다.
토폴로지 (Topology): 메시(mesh)가 아닌 허브 앤 스포크 (hub and spoke)
저의 첫 번째 설계에서는 서버에 토큰 버킷(token-bucket) 속도 제한기(rate limiter)를 두었습니다. 네 개의 에이전트가 메시(mesh) 구조로 연결되면 루프가 이차 함수적으로 증가하기 때문에 안전장치가 필요했기 때문입니다.
그 후 저는 대신 토폴로지(topology)를 변경했고, 문제의 대부분이 사라졌습니다.
하나의 에이전트가 허브(hub) 역할을 합니다. 스포크(spoke)들은 allowBots에 허브만 나열하며, 허브는 모든 스포크를 나열합니다. 스포크들은 서로를 깨울 수 없습니다. 가능한 유일한 사이클은 허브 ↔ 하나의 스포크뿐이며, 구조적으로 허브가 모든 경로에 존재합니다. 따라서 허브에 있는 루프 차단기(loop-breaker)가 전체 시스템을 커버하며, 폭주하는 프로세스를 막기 위해 속도 제한기(rate limiter)에만 의존할 필요가 없습니다.
그럼에도 불구하고 저렴한 보험으로서 서버 측 제한기를 유지했습니다. 프롬프트 수준의 규칙은 합리화되어 무시될 수 있지만, 서버 코드는 그럴 수 없습니다. 하지만 제한기는 더 이상 핵심적인 지지 구조(load-bearing)가 아니게 되었으며, 이것이 안전장치(safeguard)와 희망(hope)의 차이입니다.
정확히 짚고 넘어갈 만한 속성 하나는 다음과 같습니다: 격리는 가시성(visibility)이 아니라 호출(invocation) 단계에서 이루어집니다. 스포크들은 깨어 있는 동안 언제든 서로의 메시지를 읽을 수 있습니다. 단지 서로를 깨울 수 없을 뿐입니다. 이것이 우리가 얻는 속성이며, 처음 들리는 것보다는 규모가 작습니다.
내 패치를 검토하며 발견한 버그
이 부분은 제가 다른 누군가가 꼭 배웠으면 하는 대목입니다.
이 플러그인에는 권한 승인 프롬프트(permission prompts)를 가로채는 인터셉터(intercept)가 있습니다. 도구(tool)에 대한 승인이 필요할 때, 터미널로 돌아가는 대신 Discord에서 y <request_id>라고 답장할 수 있습니다. 이와 관련된 코드에는 실질적으로 _gate()를 통과한 모든 것은 allowFrom에 포함되어 있으므로, 이 발신자는 사용자이다_라는 취지의 주석이 달려 있었습니다.
그 코드가 작성되었을 당시에는 그것이 사실이었습니다. 하지만 제가 allowBots를 변경하면서 그것은 거짓이 되었습니다. 이제는 봇이 allowFrom에 나타나지 않고도 gate()를 통과할 수 있기 때문입니다.
이는 에이전트가 저를 대신하여 도구 권한을 승인했을 수도 있음을 의미합니다.
이를 악용하려면 5자리의 request_id가 필요한데, 이는 오직 허용 목록(allowlist)에 있는 DM(Direct Message)으로만 브로드캐스트되므로, 이것은 완전히 열린 문이라기보다는 심층 방어(defence in depth)의 문제였습니다. 수정 방법은 두 줄이면 충분했습니다. 버튼 핸들러(button handler)가 항상 해왔던 방식대로, allowFrom을 명시적으로 확인하고 봇 작성자를 즉시 제외하는 것입니다.
실수의 형태가 흥미로운 부분입니다. 해당 패치는 고립되어서는 옳았으나 결합되었을 때는 틀렸습니다. 왜냐하면 백 줄 떨어진 곳에 주석으로 기록된 가정을 무효화했기 때문입니다. 어떤 타입 체커(type checker)도 이를 잡아낼 수 없습니다. 이를 잡아낸 유일한 방법은 작업을 다 마쳤다고 생각한 후 파일의 나머지 부분을 읽어본 것뿐이었습니다.
저에게 몇 시간을 허비하게 만든 네 가지 일
BOM(Byte Order Mark)이 모든 봇의 설정을 조용히 초기화했습니다. Windows PowerShell 5.1에서 Set-Content -Encoding utf8는 UTF-8 BOM을 작성합니다. JSON.parse는 이에 대해 오류를 발생시키며, 파싱할 수 없는 JSON에 대한 서버의 응답은 파일을 access.json.corrupt-<epoch>로 이름을 변경하고 기본값으로 시작하는 것입니다. 즉, 허용 목록(allowlist)도, 채널 등록도 없는 상태가 됩니다. 봇은 계속 실행되지만 단순히 아무것도 듣지 못하게 됩니다. 네 가지 문제 모두 10분 간격으로 발생했습니다.
값(value)이 아니라 바이트(byte)를 확인하세요. 중요한 파서를 제외한 모든 JSON 파서는 BOM을 허용하기 때문입니다:
Get-ChildItem "$env:USERPROFILE\.claude\channels\*\access.json" |
ForEach-Object { (Get-Content $_.FullName -AsByteStream -TotalCount 3) -join ' ' }
239 187 191은 BOM입니다. 모든 PowerShell 버전에서 BOM이 없는 [System.IO.File]::WriteAllText를 사용하여 이 파일들을 작성하세요.
포크(forked)된 플러그인은 승인된 채널(approved-channels) 허용 목록(allowlist)에 없습니다. 패치된 포크 버전을 설치하자마자, 외부로 나가는 메시지는 완벽하게 작동했지만 들어오는 알림은 세션에 도달하기 전에 드롭(drop)되었습니다. 봇은 고장 난 것이 아니라 응답이 없는 것처럼 보이며, 설정 파일의 어떤 부분에서도 이를 나타내지 않습니다. 지문(fingerprint)은 _'답장 영역(replies land), 아무것도 도착하지 않음'_이며, 그 해답은 MCP 로그의 로그 라인과 포크를 명시적으로 지정하는 실행 플래그(launch flag)에 있습니다.
에이전트는 자신의 사용자 ID(user ID)를 절대 전달받지 못합니다. 에이전트는 송신자의 ID는 볼 수 있지만, 자신의 ID는 결코 볼 수 없습니다. 따라서 멘션(mention)이 자신을 향한 것인지 확인할 수 없습니다. 제가 네 개의 봇을 언급하며 "당신이 오케스트레이터(orchestrator)입니다"라고 메시지를 보냈을 때, 각 봇은 어떤 절(clause)이 자신의 것인지 추측해야 했습니다. 세 개는 잘못 추측했습니다. 그중 하나는 채널 기록에서 자신의 정체성을 추론하여, 다른 봇의 이름으로 메시지에 서명하기 시작했습니다.
해결책은 코드가 아니라 산문(prose)입니다. 즉, 봇을 이름으로 부르고, 역할을 할당할 때 ID를 명시적으로 언급하십시오. ID만으로는 수신자가 확인할 수 없습니다.
침묵은 양방향 모두에서 모호합니다. 바쁜 에이전트와 전달되지 않은 메시지는 동일해 보입니다. 당신의 보고를 무시한 에이전트와 보고에 따라 조용히 행동한 에이전트도 마찬가지입니다. 저는 일주일 사이에 두 가지 실패 모드를 모두 경험했습니다. 하나는 메시지가 도착하지 않아 거절로 읽힌 경우였고, 다른 하나는 오케스트레이터가 수정을 인지하지 못한 채 조용히 실행하여 송신자 입장에서는 무시당한 것과 구별할 수 없었던 경우였습니다.
이러한 시스템을 구축한다면, 공유 상태(shared state)를 변경하는 모든 사항에 대해 확인(acknowledgement)을 규칙으로 만드십시오. 메시지 하나가 더 들 뿐이지만, 혼란의 범주 전체를 제거해 줍니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기