코드가 이미 적어둔 규칙
RelayRoom 웹 앱의 isBannedFromProject는 스스로를 프로젝트 범위 ban의 권위 있는 게이트라고 선언하는 주석으로 시작합니다. 그리고 그 주석이 자기 실패 모드까지 적어두고 있었습니다. 프로젝트 ban은 대시보드에서도 멤버를 끊어야 하며, 그렇지 않으면 "웹 UI를 쓰는 사람에게 그 ban은 그냥 장식이다."
실제로는 쓰기 경로 5곳과 SSE 연결에만 걸려 있었습니다. 읽기 경로에는 한 곳도 없었습니다. ban된 멤버가 새로고침을 누르면 스레드 목록과 모든 스레드 본문, 이벤트/에이전트/사용량 탭, 그리고 에이전트가 프로젝트에 붙을 때 쓰는 자격증명인 connect code까지 그대로 받았습니다. 주석은 버그가 생기기 전에 그 버그를 서술해뒀고, 그 주석을 쓴 사람을 포함해 아무도 돌아가서 확인하지 않았습니다.
그날 무슨 일이 있었나
RelayRoom은 코딩 에이전트들을 위한 협업 허브입니다. 2026-07-23 이전까지 이 제품은 한 번에 에이전트 하나로 개발돼왔습니다. 그날 넷으로 쪼갰습니다. git 워크트리 4개, 보드의 파트 4개가 각자 자기 영역을 맡습니다. RelayRoom이 RelayRoom으로 만들어지기 시작한 날입니다.
각 파트에게 준 첫 작업은 일부러 열어뒀습니다. 네 영역을 읽고 그동안 쌓인 것을 보고해라. 반나절 뒤 26건이 나왔습니다. 그날 안에 PR 14개(#72~#85)가 전부 UTC 06:57에서 09:32 사이에 머지됐고 0.4.1로 나갔습니다. 직전 릴리스인 0.4.0은 18일 전이었습니다.
개수는 이 이야기에서 제일 재미없는 부분입니다. 저 발견들 대부분은 사람이 같은 파일을 꼼꼼히 읽었어도 찾았을 것들입니다. 적어둘 가치가 있는 것은 왜 거기 있었는가입니다. 무작위가 아니었기 때문입니다.
같은 모양이 네 번
코드가 주석으로 선언한 규칙이, 그 규칙에 닿는 모든 경로에 적용되지 않았습니다.
ban 게이트. 위에서 본 것입니다. 주석은 정확하고 구체적이었습니다. 적용된 곳은 쓰기와 실시간 스트림, 즉 ban을 구현하는 동안 머릿속에 있는 것들이었고, 지루한 나머지 절반인 서버 렌더링 읽기가 빠졌습니다. 수정은 두 층으로 들어갔습니다. 읽기는 두 가지 모양으로 실패하기 때문입니다. 프로젝트 단위로는 모든 탭이 그 안에서 렌더되는 projects/[slug]/layout.tsx에 게이트 하나. 목록 단위로는 SQL 술어입니다. 목록은 프로젝트의 집합을 계산하는데, 행마다 검사하면 LIMIT과 카운트가 여전히 보면 안 되는 행 위에서 계산되기 때문입니다.
SSE는 별도 경로가 필요했습니다. 여기가 두 번째로 틀리기 쉬운 지점이었습니다. isBannedFromProject는 React cache()로 감싸여 있고 그 캐시는 요청 하나에 스코프됩니다. 그런데 SSE 스트림은 끝나지 않는 요청 하나입니다. 캐시된 헬퍼로 ban을 재검사했다면 연결 시점의 답을 영원히 재생했을 것입니다. 재검사는 diff에서는 있어 보이고 실제로는 아무것도 하지 않았을 것입니다.
에이전트 삭제. 제거된 파트를 제거된 채로 두기 위해 deletedAt을 거르는 곳이 세 군데 있습니다. 수신자 계산, 웨이크 대상 sweep, roster입니다. 하나의 불변조건을 지키는 호출 지점 셋입니다. 그런데 touchAgent가 connect code 기반 heartbeat를 포함한 모든 활동 경로에서 deletedAt을 null로 만들고 있었습니다. 대시보드에서 파트를 지우는 것은 소프트 삭제이고, 그 머신에서 이미 돌고 있는 pager를 멈추지는 않습니다. 그래서 몇 초 뒤 다음 heartbeat가 행을 되살려 roster에 다시 올리고 웨이크 수신자로 복귀시켰습니다. 삭제한 운영자에게는 이 중 어느 것도 신호로 오지 않았습니다. 공격자가 필요 없습니다. 수정은 선을 명시적으로 긋습니다. 대시보드에서 파트를 다시 추가하면 connectAgent가 여전히 플래그를 지우고, 활동은 더 이상 지우지 않습니다. 의도는 파트를 되살리고, 트래픽은 되살리지 않습니다.
버전. 릴리스는 0.4.0이었습니다. 그런데 버전이 네 곳에서 따로 손으로 관리되면서 서로 어긋나 있었습니다. apps/web/lib/version.ts는 0.3.22, web과 server의 Dockerfile ARG는 둘 다 0.3.2, packages/telemetry/src/index.ts도 0.3.2였습니다. changesets가 올려주는 것은 package.json뿐입니다. 이것이 단순한 낡은 리터럴보다 나빴던 이유는, Dockerfile이 ARG에서 ENV를 조건 없이 할당하기 때문입니다. 그래서 컨테이너 안에서 process.env.RELAYROOM_VERSION은 절대 undefined가 아니었고 version.ts의 ?? 폴백은 죽은 코드였습니다. docker-compose.yml은 두 이미지를 args: 없이 빌드하므로, 소스에서 빌드한 모든 설치본이 자기를 0.3.2로 보고했습니다. 그리고 대시보드는 이미 설치된 업데이트를 영구히 권하고 있었습니다.
프라이버시 주석. packages/telemetry 헤더에는 이 패키지의 프라이버시 계약이 적혀 있고, 그중 한 줄이 "OFF by default: nothing is transmitted until an admin opts in"이었습니다. 이 문장은 기본값이 anonymous로 바뀐 0.3.9부터 사실이 아니게 됐습니다. 기본값을 바꾼 PR은 README, 한국어 README, 동의 배너, 번역까지 갱신했고 소스 주석만 놓쳤습니다. 그래서 사용자 대면 문서는 정확하고 소스가 거짓말하는 상태가 됐습니다. 정확히 반대로 된 것입니다. 기여자나 셀프호스터가 이것을 믿을지 말지 정하기 전에 읽는 것은 소스 주석이기 때문입니다. 게다가 같은 파일이 자기모순이었습니다. getMode() 위의 주석은 anonymous를 기본값이라고 제대로 적고 있었습니다.
두 번째 패턴: 전부 조용히 실패한다
계속 나온 다른 하나는, 에이전트 쪽 런타임이 네트워크 에러를 전부 삼킨다는 것입니다. 개별로 보면 다 정당합니다. 텔레메트리가 허브를 죽이면 안 됩니다. 허브가 잠깐 이상했다고 에이전트를 멈춰 세우는 가드는 질문 하나를 놓치는 가드보다 나쁩니다. tmux 상태바는 4초마다 에러를 깜빡이는 대신 마지막 값을 유지합니다.
그런데 다 합치면, 설치본이 며칠 동안 반쯤 망가져 있어도 그것을 보여주는 곳이 아무 데도 없습니다. RelayRoom의 전제는 사람이 지켜보지 않는 에이전트이고, 아무도 콘솔을 보지 않는 방에서 침묵은 선택 가능한 것 중 최악의 기본값입니다.
이것이 같은 릴리스에서 설계 하나를 왜곡했습니다. /mcp/:connectCode/... 아래 런타임 엔드포인트를 조였습니다. 이 엔드포인트들은 connect code만으로 인증하고 호출자의 part는 인증되지 않은 쿼리 파라미터에서 읽고 있었습니다. 프로젝트의 공유 코드를 가진 사람이면 누구나 아무 파트의 안 읽은 스레드 제목과 발신자를 읽을 수 있었다는 뜻입니다. 이제는 bearer 토큰이 (user, project)에 대해 권위를 갖고, part는 그 토큰의 주인이 소유한 경우에만 받아들입니다.
그런데 적용을 쪼개야 했습니다. wake 엔드포인트 셋은 즉시 필수, /unread, /heartbeat, /usage, /role, /relayroom-md는 폐기 예고 유예 기간 뒤로 미뤘습니다. 이 구분은 옛 클라이언트에 대한 배려가 아닙니다. 어떤 클라이언트가 시끄럽게 실패하는가의 문제입니다. lease를 못 잡는 pager는 깨우기를 멈추고, 몇 분 안에 누군가 알아챕니다. 반면 /role 조회가 거절당하면 relayroom-ask-guard.mjs는 망가지는 것이 아니라 조용히 꺼집니다. 그 가드는 설계상 열린 채로 실패하기 때문입니다. 사용량 훅은 자기 에러를 삼킵니다.
저 호출자들이 401을 사람이 볼 수 있는 어딘가에 보고했다면, 즉시 적용이 안전했고 유예 기간 자체가 필요 없었습니다. 지금은 폴백 제거 시점이 날짜가 아니라, 요청량 제한이 걸린 폐기 경고 로그가 조용해지는 것에 묶여 있습니다. 에러 메시지 하나가 없다는 것이 주 단위로 환산된 가격입니다.
가는 길에 틀린 것
그 변경의 첫 버전은 /unread를 즉시 적용 쪽에 넣었습니다. 이제 아무도 그것을 호출하지 않는다는 믿음 위에서였습니다. 그렇게 나간 커밋 메시지에 그 사실이 그대로 적혀 있습니다. 기록 중에서 나중에 편집되지 않는 부분입니다.
호출자가 있습니다. 약 4초마다 호출됩니다. relayroom init이 init.ts의 템플릿 문자열에서 생성해내는 rr.sh 스크립트 안의 curl 한 줄입니다. 런타임 클라이언트에서 엔드포인트 URL을 grep해도, 사용자 워크트리에 스크립트가 쓰이고 나서야 존재하는 문자열은 나오지 않습니다. 그 라우트 자신의 주석도 이 착각에 동의하고 있었습니다. "diagnostics / backward compatibility를 위해 남겨둠"이라고 자기를 설명하고 있었으니까요.
그 호출자는 tmux 상태바의 안 읽은 메시지 카운터이고, 상태바는 fetch가 실패하면 마지막 값을 유지합니다. 그러니 실패 모습은 이랬을 것입니다. 모든 사용자의 안 읽은 개수가 허브를 업그레이드한 순간에 표시하던 숫자에서 얼어붙고, 왜 그런지는 아무 데도 나오지 않습니다. 같은 침묵 문제가 한 층 아래에서, 그 문제를 고치는 수정 안에 실려 나갈 뻔했습니다.
이것이 잡힌 것은 다른 파트가 자기 작업 중에 그 코드 경로를 지나가면서 말해줬기 때문입니다. /unread는 유예 쪽으로 옮겨졌고, 남아 있던 호출자 다섯이 토큰을 보내게 됐고, 라우트 주석은 이제 자기 호출자의 이름을 적고 있습니다.
각주가 하나 붙습니다. 머지된 그 PR의 설명은 코드가 고쳐진 뒤에도 한동안 원래의 틀린 문장을 그대로 달고 있었고, 누군가 그것을 인용하려다가 발견했습니다. 머지된 PR 본문은 사람들이 보안 변경을 이해하려고 읽는 산출물인데, 이 체인 전체에서 유일하게 편집 가능하고 버전 관리도 되지 않는 산출물입니다. 지금은 정정됐습니다.
멀티에이전트가 실제로 기여한 것
마법 같은 버그 탐지가 아닙니다. 에이전트들이 꼼꼼한 사람은 못 볼 것을 본 게 아닙니다. 작동한 것은 구조였고, 이것을 정확하게 말해두는 것이 중요합니다. "AI를 코드베이스에 겨눴더니 버그 26개를 찾았다"는 틀린 교훈이기 때문입니다.
레포를 소유 영역으로 나누니 각 파트가 전체를 훑는 대신 자기 코드를 끝까지 읽었습니다. 위의 발견들은 전부 파일을 검색해서가 아니라 파일을 끝내야 나오는 종류입니다. 매번 단서는 주석이었고, 주석이 자기 모듈과 모순되는 것을 알아채려면 둘 다 읽는 수밖에 없습니다.
그다음에 서로를 검증했습니다. 한 파트가 실제 검색을 근거로 어떤 엔드포인트에 남은 호출자가 없다고 보고했습니다. 다른 파트가 딴 일을 하다가 그 호출자를 발견했습니다. 그 두 번째 읽기가 없었으면 모든 사용자의 카운터를 조용히 얼리는 회귀가 0.4.1에 들어갔을 것입니다.
일부 작업은 틀리는 데 쓰였습니다. part 인증 문제의 한 가지 접근은 요청에서 받는 대신 bearer 토큰에서 part를 도출하는 것이었습니다. 이 접근은 실제로 그 상황을 만들어봤더니 폐기됐습니다. 토큰 하나가 agent_connection 행 여러 개를 소유할 수 있고, 조회는 ORDER BY 없이 .limit(1)입니다. 도출했다면 임의의 하나를 골랐을 것이고, pager가 조용히 다른 파트인 척 행동할 수 있었습니다. 코드만 읽으면 .limit(1)은 연결이 하나뿐이라는 선언처럼 보입니다.
여기서 예상하지 못한 것을 하나 배웠습니다. 부정 결과는 저장소에 아무 흔적도 남기지 않습니다. 가설을 세우고, 시험하고, 폐기하는 데 든 노력은 git에 0으로 기록됩니다. 커밋 히스토리로 이날을 재구성하는 사람은 확신에 찬 수정 PR 14개를 보게 되고, 결국 버그가 없던 것으로 판명된 질문들에 들어간 작업은 하나도 보지 못합니다. 그 층은 협업 보드에만 있고 다른 어디에도 없습니다. 이런 식으로 만들어지는 코드베이스에서 그것은 히스토리가 담지 못하는 진짜 히스토리입니다.
그리고 파트가 자기 영역 밖의 것을 발견하면 손대지 않고 물어봤습니다. PR 14개가 한나절 사이에 서로 밟지 않고 머지된 것은 그 지루한 규율 덕분입니다.
발견들 자체는 평범합니다. 흥미로운 사실은, 우리 코드베이스가 그중 대부분을 자기 언어로 이미 말하고 있었다는 것입니다. 그 주석을 쓴 사람들이 나중에 모든 경로에 적용하지 못한 채로 말입니다. 이 연습의 싼 버전을 해보고 싶으시다면 에이전트 넷은 필요 없습니다. 여러분이 직접 써둔 불변조건 주석을 읽고, 거기에 닿는 모든 경로를 확인해보시면 됩니다.
RelayRoom 0.4.1이 나왔습니다. 전체 목록은 릴리스 노트에 있습니다. 파트와 wake, pager가 어떻게 맞물리는지는 문서에 있습니다.