본문 바로가기

AI

Claude Code slack 환경에서 message injection 개선하기

#ClaudeCode #무인기동 #Slack #managed-settings #MCP #트러블슈팅

새벽 지하철역, 자동 개찰구 다섯 중 하나만 닫혀 있어 역무원이 손으로 열어 주고 있다
다섯 개찰구 중 넷은 저절로 열린다. 하나만 사람이 손을 대야 한다.

"WARNING: Loading development channels." Slack 봇을 띄울 때마다 이 문구가 화면 한가운데 떠 있었다. 아래에는 선택지가 둘뿐이다. 1번 로컬 개발용으로 쓰는 중입니다, 2번 나가기. 사람이 1번을 누르기 전까지 세션은 그 자리에서 멈춰 있다.
같은 PC에서 Discord 봇은 작업 스케줄러에 올라가 사람 없이 떴다. Slack 봇만 매번 손이 필요했다. 두 세션은 같은 CLI, 같은 권한 모드, 같은 계정으로 도는데 한쪽만 사람을 불렀다. 왜 한쪽만인가. Claude Code 무인 기동을 가로막은 이 확인창을 없애는걸로 결정하기 까지 긴시간이 걸렸고, 그중 대부분은 잘못된 확신 하나를 붙들고 있던 시간이다.

🔍 증상 — 한쪽만 사람이 필요했다

확인창의 전문은 이렇다.

WARNING: Loading development channels
--dangerously-load-development-channels is for local
      channel development only.
Please use --channels to run a list of approved channels.
1. I am using this for local development
2. Exit

Claude Code는 외부 메신저를 세션에 붙이는 통로를 채널이라고 부른다. 채널이 붙으면 Slack이나 Discord에 올라온 메시지가 세션 안으로 실시간으로 들어오고, 봇이 그 자리에서 답한다. 우리는 봇 여섯이 이 방식으로 돈다.
이 확인창은 경고만 찍고 지나가지 않는다. 입력을 기다리며 멈춘다. 작업 스케줄러는 사람이 없는 자리에서 프로그램을 실행하는 장치이므로, 여기서 멈추면 그대로 끝이다. 그래서 Slack 세션은 스케줄러에 올릴 수 없었고, 재부팅이나 세션 종료가 있을 때마다 사람이 직접 띄워야 했다.

🧭 첫 오진 — 이름이 비슷한 두 플래그

처음에 붙잡은 것은 --dangerously-skip-permissions였다. 권한 확인을 건너뛰는 플래그이고, 이름에 위험이라는 말이 들어가며, 무엇보다 확인창을 띄울 법한 물건이다. 그럴듯했다.

아니었다. 두 런처를 나란히 놓자 다른 칸이 하나뿐이었다.

항목 Discord (무인 기동 성공) Slack (사람 필요)
권한 모드 --permission-mode bypassPermissions --permission-mode bypassPermissions
모델·작업 디렉터리 동일 동일
채널 로드 --channels plugin:discord@claude-plugins-official --dangerously-load-development-channels server:slack-channel
확인창 없음 매 기동

권한 모드는 양쪽이 이미 같았다. Discord는 그 상태로 잘 떴다. 그러니 권한 모드는 애초에 변수가 아니었다. 범인은 이름이 비슷한 다른 플래그, --dangerously-load-development-channels였다.

두 대상이 같은 조건에서 갈릴 때는, 원인을 짐작하기 전에 다른 점을 전부 적는 것이 먼저다. 이 표를 만드는 데 몇 분이 걸렸고, 그 표가 몇 주치 추측을 대신했다.

🔒 왜 끄지 못했나

범인을 알고도 바로 풀리지 않았다. 확인창을 끄는 방법이 없었기 때문이다. 다이얼로그에는 수락과 종료 두 갈래뿐이고, 한 번 수락해도 저장되는 값이 없어 다음 기동에 또 묻는다. 이를 끄는 환경변수도, settings.json 키도 존재하지 않는다. 플래그 도움말 자체가 "Shows a confirmation dialog at startup"이라고 적어 둔다. 숨은 동작이 아니라 명세된 동작이다.

그렇다면 확인창이 권하는 대로 --channels로 가면 될 일이다. 그렇게 해 봤고, 채널이 조용히 안 붙었다.

Channel notifications
      skipped: server slack-channel is not on the
      approved channels allowlist
      (use --dangerously-load-development-channels
      for local dev)

여기서 구조가 드러난다. 승인 목록은 플러그인만 받는다. Claude Code의 채널 정의에는 두 종류가 있다. 마켓플레이스에서 설치한 플러그인과, 직접 등록한 MCP 서버다. 로더는 정의 종류가 플러그인일 때만 승인 목록을 들여다보고, 그 외 서버는 개발용 표시가 없으면 건너뛴다. 우리 Slack 브리지는 MCP 서버로 등록돼 있었으므로, 위험 플래그 말고는 로드될 경로가 아예 없었다.

채널 로드 판정 경로 다이어그램: 정의 종류가 플러그인이면 승인 목록을 보고, MCP 서버면 승인 경로 자체가 없어 위험 플래그와 확인창으로 빠진다
승인 목록은 플러그인만 본다. 서버 정의에는 통과할 문 자체가 없었다.

이 진단은 정확했다. 그리고 정확한 진단이 다음 단계를 막았다.

🧱 84일을 막은 문장

같은 문제로 열린 GitHub 이슈가 이미 있었다. anthropics/claude-code#42486은 2026년 4월 2일에 열렸고 지금도 열려 있다. 제목이 그대로 우리 문제다 — 확인창을 건너뛸 수 있게 해 달라. 본문에 이런 문장이 있다. "There's no way to persist this choice or skip it via a flag." 그 사용자 역시 server: 타입은 승인 목록에 올릴 수 없어 위험 플래그가 유일한 선택지라고 적었다. 우리가 따로 알아낸 결론에 바깥에서도 도달해 있었다.

문제는 그 옆의 다른 이슈다. #47767은 개인 사용자도 승인 목록을 확장하게 해 달라는 요청이었고, 중복으로 닫혔다. 작성자는 Claude Code 코드를 직접 뜯어 근거를 붙였다.

let K = T7();
let _ = K === "team" || K === "enterprise";
let A = _ ? T8("policySettings") : void 0;

사용자 등급을 읽어서 팀이나 기업일 때만 정책 설정을 가져온다. 개인 계정은 아예 다른 경로로 빠진다. 작성자의 결론은 명확했다. 개인 결제 사용자에게는 등가의 경로가 없다.
우리는 그 결론을 정설로 받았다. 코드가 인용돼 있었고, 논리에 빈틈이 없었고, 우리 실험 결과와도 어긋나지 않았다. 그래서 이 길은 닫힌 것으로 정리하고 손을 뗐다. 그 상태가 2026년 5월 13일부터 8월 4일까지, 84일간 우리 표준이었다. Slack 봇은 매번 사람이 띄웠다.

여기가 반전이다. 그 등급 검사는 우리가 설치한 버전에 없었다. 같은 함수가 인자를 하나만 받고, 관리 설정에 값이 있으면 등급을 보지 않고 그냥 쓴다. 남이 뜯은 코드는 그 사람이 설치한 버전의 코드다.

이것을 확인하는 데 필요한 작업은 우리가 설치한 파일을 우리가 직접 읽는 것이었다. 84일 중 어느 날에라도 할 수 있었던 일이다. 그리고 확인의 최종 근거는 코드 판독이 아니라 동작이다. 개인 계정인 이 호스트에서 관리 설정이 실제로 먹는다.

🔧 어떻게 풀었나

방향이 정해지면 작업은 짧다. 승인 목록이 플러그인만 받는다면, 브리지를 플러그인으로 만들면 된다. Slack 브리지를 로컬 마켓플레이스 플러그인으로 포장하고, 관리 설정의 승인 목록에 올리고, 런처를 --channels로 바꾼다.

관리 설정 — 호스트당 한 번

Windows는 C:\Program Files\ClaudeCode\managed-settings.json, macOS는 /Library/Application Support/ClaudeCode/managed-settings.json이다. 관리자 권한이 필요하다.

{
  "channelsEnabled": true,
  "allowedChannelPlugins": [
    {"marketplace": "claude-plugins-official",
      "plugin": "discord"},
    {"marketplace": "claude-plugins-official",
      "plugin": "telegram"},
    {"marketplace": "claude-plugins-official",
      "plugin": "fakechat"},
    {"marketplace": "claude-plugins-official",
      "plugin": "imessage"},
    {"marketplace": "my-local-marketplace",
      "plugin": "my-channel"}
  ]
}

두 가지를 빠뜨리면 안 된다. channelsEnabled: true가 없으면 정책 파일이 존재한다는 이유만으로 그 호스트의 채널 기능이 통째로 막힌다. 그리고 이 목록은 기본 목록에 더해지는 것이 아니라 대체한다. 기존 항목 중 하나라도 빠뜨리면 그 채널이 호스트 전체에서 취소된다.

브리지 코드 — 신원을 추측하지 않게

플러그인 형태에는 함정이 하나 딸려 온다. MCP 서버로 등록할 때는 봇마다 설정 파일에 환경변수를 따로 적어 서로를 갈랐다. 플러그인 정의는 모든 세션이 공유하고, 그런 칸이 없다. 그대로 옮기면 한 PC의 봇 다섯이 같은 상태 디렉터리, 즉 같은 토큰과 같은 신원으로 뭉개진다.
그래서 브리지가 프로젝트 안의 선언 파일을 읽도록 고쳤다. 순서는 환경변수, 그다음 프로젝트의 선언 파일이고, 둘 다 없으면 기본값으로 떨어지지 않고 죽는다. 조용히 남과 신원을 공유하는 브리지는 진단이 안 되지만, 안 뜨는 브리지는 진단이 된다. 커밋 셋이 6분 안에 들어갔다.

커밋 시각 (KST) 내용
14da691d 08-05 07:30 프로젝트별 상태 디렉터리 해석, 신원 미선언 시 기동 거부 + 테스트
65942fef 08-05 07:33 로컬 마켓플레이스 정의를 저장소에 편입
7b2554f2 08-05 07:36 기동 시 해석된 상태 디렉터리를 스스로 로그에 찍기

런처 — 한 줄

# 전
claude --permission-mode bypassPermissions \
      --dangerously-load-development-channels \
      server:slack-channel

# 후
claude --permission-mode bypassPermissions \
      --channels \
      plugin:slack-channel@slack-channel-local

런처에는 같은 값을 쓰는 자리가 하나 더 있다. 중복 실행을 막는 싱글턴 가드가 명령줄 문자열로 자기 세션을 알아보는데, 이 값을 같이 안 바꾸면 가드가 자기를 못 알아보고 중복 방지가 조용히 풀린다.

🕳️ 밟은 함정 다섯

중복 브리지메시지 절반이 사라짐. 에러 없음플러그인을 깔고 기존 MCP 서버 항목을 안 지우면 같은 서버가 둘 뜬다. Slack Socket Mode는 인바운드를 연결들에 나눠 준다
유저 스코프 설치Slack과 무관한 세션이 Slack 소켓을 뭄채널 플러그인을 user scope로 깔면 그 호스트의 모든 세션이 브리지를 띄운다
툴 개명훅이 통과시킴. 종료 코드 0, 무출력플러그인 형태는 툴 이름을 바꾼다. 훅 설정의 matcher만 넓히면 부족하고, 훅 스크립트가 들고 있는 정확일치 목록도 같이 고쳐야 한다
신원 뭉개짐봇 다섯이 한 토큰으로플러그인 정의는 공유되고 환경변수 칸이 없다
런처 자기 살해종료 코드만 남고 출력 없음싱글턴 가드가 자기를 실행한 부모까지 후보로 잡는다. 사람이 아이콘으로 누를 때는 부모가 탐색기라 안 드러나고, 자동화로 띄울 때만 나온다

Discord 게이트웨이는 같은 상황에서 이벤트를 모든 연결에 복제해 보낸다. 그래서 중복 연결이 무해하다. Slack에서 같은 실수가 절반 유실로 나타나는 것은 이 비대칭 때문이고, Discord에서 다져진 감각이 Slack에서 깨지는 지점이 정확히 여기다.

📐 안 뜬 창을 어떻게 증명하나

"세션이 떴다"로는 부족하다. 확인할 것은 확인창이 안 떴다는 것, 그리고 각 브리지가 제 신원을 잡았다는 것이다. 그런데 Windows에는 화면을 들여다볼 마땅한 방법이 없다. 터미널 멀티플렉서가 없고, 우회 도구는 실제 tty를 요구하며, 출력만 받는 모드는 채널을 붙이지 않는다.
여기서 막힌 성질이 그대로 증거가 된다. 확인창이 뜨면 세션이 거기서 멈추므로 MCP 서버가 시작되지 않는다. 따라서 브리지 프로세스가 살아 있다는 사실 자체가 확인창이 뜨지 않았다는 증거다. 화면을 못 보는 대신 프로세스 목록으로 판정한다.

두 경로 비교 다이어그램: 확인창이 뜨면 세션이 멈춰 MCP 서버가 시작되지 않고, 확인창이 없으면 브리지 프로세스가 존재한다
두 경로가 겹치지 않는다. 그래서 브리지 프로세스의 존재가 화면을 대신한다.

신원은 브리지가 기동할 때 스스로 찍게 만들었다. 프로세스는 상태 디렉터리 핸들을 계속 붙들지 않으므로 열린 파일로는 알아낼 수 없고, 접근 시각도 갱신되지 않는다. 그래서 세 번째 커밋이 로그 한 줄을 추가한 것이다.
정책 파일이 실제로 읽히는지도 문구로 갈린다. 거부 메시지가 "is not on the approved channels allowlist"이면 관리 설정이 안 먹고 기본 목록을 쓰는 중이고, "is not on your org's approved channels list"이면 관리 설정이 읽히고 있다. 같은 거부인데 뜻이 반대다.

이 글을 쓰는 동안 PC가 재부팅됐다. 확인해 보니 Slack 세션 다섯이 모두 --channels로 떠 있었고 사람이 누른 것은 없었다. CLI 버전은 전환 당시의 2.1.221이 아니라 그 뒤로 올라간 2.1.228이다. 개인 계정에서는 안 된다던 경로가 버전이 바뀌고도 그대로 동작한다.

⚖️ 반론 — 이 방법이 옳은가

이 해법에는 정당한 반대가 여럿 있고, 그중 몇은 아직 유효하다.
첫째, 우리가 쓴 것은 공식 기능이 아니다. 관리 설정은 조직 관리자를 위한 정책 파일이고, 정작 필요한 기능 — MCP 서버 형태의 채널을 승인 목록에 올리는 것 — 은 지금도 없다. #42486은 열린 채다. 우리가 한 것은 플러그인이라는 다른 형태로 갈아입혀 기존 관문을 통과한 것이지, 없던 문을 연 것이 아니다. 다음 버전에서 등급 검사가 다시 들어오면 이 구성은 그날로 멈춘다.
둘째, 폭발 반경이 커졌다. 확인창은 성가셨지만 한 세션만 붙잡았다. 관리 설정 파일은 호스트 전체에 걸리고, 목록을 대체하며, 키 하나가 빠지면 채널 기능이 통째로 죽는다. 성가신 것을 없애면서 조용히 전부를 끊을 수 있는 스위치를 들여놓은 셈이다.
셋째, 더 간단한 길이 있었다. 런처가 확인창에 엔터를 자동으로 넣어 주면 코드도 정책 파일도 필요 없다. 채택하지 않았다. 확인창은 플랫폼이 "이건 개발용이다"라고 말하는 자리인데, 자동 입력은 그 말에 자동으로 예라고 답하게 만든다. 무엇을 켰는지가 시스템 어디에도 남지 않고, 나중에 이 설정을 물려받는 사람은 위험 플래그가 켜져 있다는 사실조차 모른다. 승인 목록은 반대다. 무엇을 허용했는지가 파일에 적혀 남는다.
넷째, 플러그인 형태 자체가 비용을 만든다. 툴 이름이 바뀌면서 훅이 조용히 무력화됐고, 그 침묵은 로그로 구분되지 않는다. 확인창을 없앤 대가로 더 알아채기 어려운 고장 하나를 들여왔다.
그럼에도 바꾼 이유는 하나다. 확인창이 있는 한 Slack 봇은 사람이 깨어 있을 때만 존재할 수 있었다.

✅ 정리

기술적인 결론은 짧다. Claude Code의 채널 승인 목록은 플러그인만 받으므로, MCP 서버로 만든 브리지는 로컬 마켓플레이스 플러그인으로 포장한 뒤 managed-settings.json에 올리면 확인창 없이 무인 기동한다. 옮길 때는 기존 MCP 서버 항목을 지우고, 봇마다 신원 선언 파일을 두고, 런처의 싱글턴 가드 문자열을 같이 고친다.

남는 것은 그보다 일반적인 세 가지다.

  • 같은 조건에서 결과가 갈리면, 짐작하기 전에 다른 점을 전부 적는다. 이름이 비슷하다는 이유로 엉뚱한 플래그를 오래 팠다. 표 한 장이 그 추측을 대신했다.
  • 남이 뜯은 코드는 그 사람의 버전이다. 인용된 코드가 정확해도, 그것이 내 호스트에 설치된 파일이라는 보장은 어디에도 없다. 84일 중 대부분은 이 확인 하나를 하지 않아서 흘러갔다.
  • 볼 수 없는 것은 남는 흔적으로 판정한다. 확인창을 화면으로 볼 수 없었지만, 확인창이 뜨면 브리지가 시작되지 않는다는 성질이 그 자리를 대신했다. 관측할 수 없을 때는 관측 가능한 결과를 찾는다.

📚 참고 자료

반응형