웹훅이 49일간 안 닿고 있었는데, 실패 알림은 한 통도 안 왔습니다
개인 비서 에이전트에 내부 프로젝트 관리 도구의 MCP 서버를 다시 등록하다가 「버전이 예전 것 같다」는 신호를 받았습니다. 확인해 보니 그 MCP 서버 사본이 두 달 가까이 50커밋 뒤처져 있었습니다.
여기서 의심이 옆으로 번졌습니다. 그 도구의 AI 서버는 배포되고 있는지 확인하러 들어갔더니 CI 잡의 마지막 빌드가 49일 전이었습니다. 그 49일 사이에 push 는 계속 있었습니다. CI 잡에 push 트리거 옵션도 설정돼 있었습니다.
실패 알림은 한 통도 오지 않았습니다. 빌드가 실패한 게 아니라 빌드가 시작조차 되지 않았기 때문입니다. MCP 서버 버전이라는 우연한 신호가 없었다면 이 49일은 더 길어졌을 겁니다.
트리거가 설정돼 있다는 것과 닿는다는 것은 다른 말입니다
배포는 push → 웹훅 → CI 잡 → 서버 반영의 사슬입니다. 이 사슬에서 소리를 내는 구간은 CI 잡 안쪽뿐입니다. 빌드가 돌다 실패하면 알림이 오고, 배포 스크립트가 죽어도 알림이 옵니다.
그런데 웹훅이 CI 서버에 닿지 않으면 CI 잡은 시작되지 않습니다. 시작되지 않은 잡은 실패도 하지 않습니다. 알림 체계 입장에서는 아무 일도 일어나지 않았습니다.
이번에 끊긴 곳이 정확히 그 구간이었습니다. 웹훅 대상이 CI 서버의 IP 와 포트로 직결돼 있었습니다. 그 포트는 방화벽 정책상 공인망에서 직접 닿을 수 없는 포트였습니다. 저장소 쪽에서 웹훅을 아무리 보내도 CI 서버에는 도착하지 않는 주소였습니다.
트리거 체크박스는 켜져 있었고, 잡 정의도 스크립트도 다 맞았습니다. 네트워크 경로 한 칸이 막혀 있어서 전부 무의미했습니다.

같은 CI 서버를 향한 웹훅을 전부 열어 봤습니다. 10개가 전부 같은 상태였습니다. 같은 서버에 얹힌 다른 프로젝트들의 웹훅까지 한 손으로 만든 것이라 같은 실수가 무리로 있었습니다. 10개를 전부 도메인 경유로 바꿨습니다.
IP:포트 직결은 방화벽 정책과 몰래 결합합니다. 정책이 조여지는 날 조용히 끊어지는 쪽은 항상 직결입니다. 내부망 전용 통신처럼 외부 DNS 를 태울 이유가 없는 자리라면 직결이 더 단순합니다. 이번 웹훅은 저장소 서비스가 공인망에서 쏘는 것이라 그 경우가 아니었습니다.
웹훅을 고쳤더니 다른 이유로 세 번 더 실패했습니다
웹훅이 닿기 시작하자 첫 빌드가 바로 실패했습니다. 49일 동안 한 번도 안 돌았기 때문에 그 사이 쌓인 문제가 한꺼번에 튀어나왔습니다. 시간 순으로 네 겹이 차례로 벗겨졌습니다.
겹 | 실패 | 원인 | 처리 |
|---|---|---|---|
1 | 도달 실패 | 웹훅 대상이 IP:포트 직결. 방화벽 정책상 외부에서 안 닿음 | 도메인 경유로 교체. 같은 서버 대상 10개 일괄 |
2 | 의존성 해석 실패 | 모노레포 안에서만 성립하는 로컬 상대경로 의존이 패키지 설정에 남아 있음 | 실사용 0인 기능이라 코드와 테스트째 제거 |
3 | 인증 실패 | 상대경로를 git 의존성으로 바꿨더니 프라이빗 저장소라 CI 가 받아오지 못함 | 위임 구조를 걷어내고 자체 HTTP 클라이언트로 원복 |
4 | 권한 잔재 | 서버 가상환경 안에 root 소유 캐시가 남아 CI 계정으로 못 지움 | 가상환경 삭제 후 재빌드 |
모노레포 안에서는 옆 폴더를 상대경로로 가리키는 의존이 잘 돌아갑니다. 그런데 이 저장소는 쪼개져서 따로 배포됩니다. 배포본에는 옆 폴더가 없으니 의존성 동기화가 항상 실패합니다. 로컬에서는 통과하고 배포에서만 깨지는 구조라 커밋 시점에는 아무도 못 잡습니다.
그 의존이 필요한 기능은 원격 마운트였습니다. 실사용을 확인해 보니 0이었습니다. 포트가 노출된 적이 없고 원격에서 붙는 클라이언트도 없었습니다. 고치지 않고 코드와 테스트를 통째로 제거했습니다. 배포를 막고 있는 코드가 아무도 쓰지 않는 코드라면 수리 대상이 아닙니다.
세 번째 겹은 두 번째를 고치는 과정에서 생겼습니다. 상대경로를 git 의존성으로 바꿔 봤습니다. 프라이빗 저장소라 CI 가 인증 없이 받아오지 못했습니다.
인증을 CI 에 심는 대신 뒤로 물러났습니다. 그 의존성 때문에 만들었던 위임 구조를 걷어내고 자체 HTTP 클라이언트로 원복했습니다. 의존성 항목 자체를 지웠습니다. 이유가 사라졌으니 구조도 같이 치웠습니다.
마지막 겹은 49일 전의 흔적이었습니다. 서버 가상환경 안에 root 소유 캐시가 남아 있었습니다. CI 계정 권한으로는 지울 수 없어서 동기화가 또 막혔습니다. 가상환경을 통째로 지우고 다시 만들었습니다.
검증은 CI 스크립트와 같은 순서를 서버에서 직접 실행하는 것으로 했습니다. 헬스체크 200 이 돌아왔고 54개 패키지가 동기화됐습니다.

각 단계는 「고쳤다, 다시 돌렸다, 다른 이유로 실패했다」의 반복이었습니다. 오래 멈춘 파이프라인은 한 번에 안 살아납니다. 멈춘 기간만큼 미검증 변경이 쌓여 있어서 복구는 단발 수정이 아니라 실패를 여러 겹 벗기는 작업이 됩니다.
빌드가 시작조차 안 되면 알림 대상이 없습니다
배포는 살아났습니다. 그런데 더 중요한 산출물은 「왜 49일 동안 몰랐나」의 답입니다. 실패 알림 기반 관측은 「실패했다」만 잡습니다. 「아무 일도 일어나지 않았다」는 이벤트가 아니라서 이벤트 기반으로는 원리적으로 못 잡습니다.
웹훅이 안 닿는 실패는 CI 서버 입장에서 요청이 온 적이 없는 일입니다. 저장소 입장에서는 보냈으니 끝난 일입니다. 양쪽 어디에도 실패가 기록되지 않습니다.
침묵을 잡으려면 다른 축이 필요합니다. 마지막 성공 시각에 기한을 거는 감시입니다. 이 저장소에 push 가 있었는데 N일 넘게 배포가 없으면 알림을 보내는 식입니다. 실패를 기다리지 않고 성공이 없는 날을 셉니다.
서버 다운을 잡을 때 같은 원리로 10분 카운트를 붙인 적이 있습니다. 배포 파이프라인도 같은 구조였는데 거기에는 그 감시가 없었습니다.

이 감시가 만능은 아닙니다. 배포 주기가 불규칙한 저장소에 기한을 걸면 오탐이 잦아지고, 알림 피로가 쌓이면 결국 무시됩니다. 저장소별로 정상 간격을 다르게 잡아야 실효가 있습니다.
팀 규모가 있으면 이렇게까지 안 묻힙니다. 매일 여러 명이 배포 결과를 기다리면 하루 이틀 안에 들통납니다. 49일이 가능했던 건 그 서비스에 새 변경이 자주 없었고 확인하는 사람이 한 명뿐이었기 때문입니다.
「실사용 0이면 제거」에도 조건이 붙습니다. 이번에는 노출 포트가 없고 클라이언트도 없다는 걸 확인한 뒤였습니다. 사용량 계측 없이 「안 쓰는 것 같다」로 지우면 조용한 소비자를 죽입니다.
배포 파이프라인 점검 항목
웹훅 대상이 IP:포트 직결인지, 도메인 경유인지. 직결이면 방화벽 정책이 바뀌는 날 조용히 끊깁니다
트리거를 「설정했다」가 아니라 「한 번 도달시켰다」로 검증했는지. 빈 커밋을 push 해서 CI 잡이 실제로 시작되는지 봅니다
마지막 성공 배포 시각에 기한이 걸려 있는지. push 는 있는데 N일 넘게 배포가 없으면 알림이 오는지
하나가 잘못돼 있으면 같은 손으로 만든 나머지도 훑었는지. 이번엔 같은 서버를 향한 10개가 전부 같았습니다
모노레포에서 쪼개 배포하는 저장소에 로컬 경로 의존이 남아 있는지. 로컬에선 통과하고 배포에서만 깨집니다
경위와 안티패턴은 사내 표준 문서에 2회 개정으로 남겼습니다. 다음 사람이 같은 경로를 다시 만들지 않게 하기 위해서입니다. 여기서 다음 사람은 대개 몇 달 뒤의 저 자신입니다.
배포 파이프라인은 「설정돼 있음」이 아니라 「실제로 도달함」으로만 살아 있다고 말할 수 있습니다. 설정은 화면에서 보이고 도달은 로그에서만 보입니다. 그 로그가 없다는 사실까지 신호로 읽는 장치가 없으면 다음 49일도 같은 모습으로 지나갑니다.
연작 「에러 없이 조용히 틀린다」
이 글은 다섯 편 중 3편입니다. 에러가 한 번도 안 났는데 실제로는 일이 안 되고 있던 사례를 이어서 다룹니다.