잘 돌아가는 것을 걷어냈다 — Plane·Buzz 셀프호스팅 27일
TL;DR
- Linear 대신 Plane, Slack 대신 Buzz를 홈랩 K3s에 셀프호스팅했다가 27일 만에 둘 다 걷어냈다
- 세우는 건 됐다. 비용은 내가 통제하지 못하는 것과 닿는 자리마다 붙었다 — Cloudflare Access, CA 번들, CoreDNS
- 프록시 뒤의 앱은 자기 공개 주소를 모른다. 리다이렉트와 이미지 업로드가 각각 깨졌는데 뿌리는 하나였다
- 가장 비쌌던 건 실패가 실패처럼 안 보이는 것 — 2주간 체크돼 있던 미등록 MCP, 파일에만 쌓인 보고, 증상 없는 죽은 hook
- 알림 하나 배선에 사흘. 같은 걸 Slack + GitHub으로 하면 채널이 레포를 구독하고 끝이다
Slack 대신 Buzz, Linear 대신 Plane을 셀프호스팅했다. 27일 만에 둘 다 지웠다. 고장 나서가 아니다 — 알림 하나를 배선하는 데 사흘이 걸렸고, 그동안 보고는 조용히 실패하고 있었다. Claude Code와 함께한 도입기, 그리고 철수기.
시작: 2026-07-31
첫 커밋 메시지가 그대로 남아 있다.
feat: Buzz+Plane 에이전트 개발 환경 허브 레포 초기 구성
목적은 분명했다. 여러 프로젝트에서 Claude Code 세션을 돌리는데, 이슈 트래킹과 에이전트 간 소통을 자기 인프라 위에 두고 싶었다. Plane은 Linear 대신, Buzz는 Slack 대신.
조사할 때부터 성숙도 차이는 알고 있었다. Plane은 Django 어플라이언스에 공식 MCP 서버가 있었고, Buzz는 Rust + Nostr에 열린 이슈가 496개였다. 그래도 Buzz를 고른 이유가 있었다 — buzz-cli와 buzz-dev-mcp로 Claude Code를 공식 지원했다. 에이전트가 채널에 보고하는 구조를 그리고 있었으니 그게 컸다.
27일 뒤, 커밋 155개를 쌓고 둘 다 지웠다.
이 글은 그 27일에 밟은 것들을 순서대로 적은 기록이다.
1. 차트가 있다고 차트를 쓸 수 있는 건 아니다
로컬 docker compose로 Plane을 잘 쓰고 있었고, 이제 홈랩 K3s로 옮길 차례였다.
조건은 이랬다. PostgreSQL은 클러스터 밖 별도 서버, Redis는 클러스터 안 공유 인스턴스를 DB 번호로 나눠 쓰고, 외부 노출은 Cloudflare Tunnel + Access(이메일 OTP). TLS는 Cloudflare 엣지에서 끝나고 안쪽은 평문 HTTP로 흐른다. 다른 앱들이 전부 그 구조였다.
Plane은 공식 Helm 차트(plane-ce)가 있다. 매니페스트를 직접 쓰는 것보다 낫다고 판단했다. 업스트림이 검증한 서비스 구성을 벗어나면 업그레이드 경로와 공식 문서를 못 쓰니까.
그 판단은 맞았다. 다만 “차트가 있다”와 “차트를 그대로 쓸 수 있다”는 다른 얘기였다. 세 군데를 우회해야 했다.
entryPoints가 템플릿에 박혀 있었다
차트는 Traefik IngressRoute를 만든다. 렌더 결과가 이랬다.
spec:
entryPoints:
- websecure # 443
...
tls:
secretName: plane-ssl-cert
우리 구조에서는 web(80)이어야 한다. 안쪽이 평문이니까. values를 뒤졌는데 노출된 traefik 키는 ingress.traefik.maxRequestBodyBytes 하나뿐이었다. entryPoints와 tls는 템플릿에 하드코딩돼 있었다.
표준 Ingress로 우회하면 되나 싶어 조건문을 봤다.
{{- if and .Values.ingress.enabled .Values.ingress.appHost
(not (hasPrefix "traefik" .Values.ingress.ingressClass)) }}
ingressClass가 traefik이면 IngressRoute가 나가고, nginx 같은 값으로 바꾸면 표준 Ingress가 나오는데 그때는 ingressClassName: "nginx"로 렌더돼 우리 Traefik이 무시한다. 표준 Ingress + ingressClassName: traefik 조합은 차트로 만들 수 없었다.
post-renderer를 택했다. 차트 원본은 그대로 두고 kustomize로 두 군데만 고친다.
patches:
- target: {kind: IngressRoute, name: plane-ingress}
patch: |
- op: replace
path: /spec/entryPoints/0
value: web
- op: remove
path: /spec/tls
자체 IngressRoute를 쓰지 않은 이유가 있다. 차트가 만드는 라우트는 경로가 일곱 개다(/spaces /god-mode /api /auth /live /uploads /). 복제해두면 업스트림이 경로를 추가했을 때 조용히 깨진다. post-renderer는 그 변경을 자동으로 따라온다.
helm 4는 실행 파일 경로를 안 받는다
post-renderer를 붙였더니 이랬다.
Error: invalid argument "./deploy/k8s/plane/post-render.sh" for "--post-renderer" flag:
plugin: {Name:./deploy/k8s/plane/post-render.sh Type:postrenderer/v1} not found
절대경로로 바꿔도 같았다. helm 4에서 --post-renderer는 플러그인 이름만 받는다. helm 3과 달라진 점이다. 레포에 플러그인 디렉토리를 두고 ~/Library/helm/plugins/에 심링크하는 방식으로 우회했다.
# plugin.yaml
apiVersion: v1
type: postrenderer/v1
name: plane-patch
runtime: subprocess
runtimeConfig:
platformCommand:
- command: ${HELM_PLUGIN_DIR}/post-render.sh
차트가 시크릿을 values로 받는다
values 기본값을 보고 잠깐 멈췄다. env.secret_key에 차트에 박힌 고정 문자열이 들어 있고, RabbitMQ는 plane/plane, MinIO는 admin/password였다.
secret_key는 Django의 SECRET_KEY다. 차트에 하드코딩된 공개 문자열을 그대로 쓰면 안 된다. 그렇다고 values 파일에 진짜 값을 적을 수도 없었다 — 그 파일은 git에 들어간다.
차트가 external_secrets.*_existingSecret을 지원하는 걸 발견했다. 그러면 차트는 Secret을 만들지 않고 우리가 만든 걸 참조한다. 그래서 Secret은 클러스터 안에서 조립하기로 했다. 스크립트가 인프라 쪽에서 만들어준 Secret에서 DB·Redis 접속 정보를 읽고, 나머지는 그 자리에서 만든다. 값은 클러스터에만 남고 레포에는 이름만 남는다.
멱등하게 짜는 게 중요했다. 다시 돌렸을 때 SECRET_KEY가 바뀌면 기존 세션이 전부 무효화되니까.
if exists "${RELEASE}-app-secrets"; then
echo "keep (SECRET_KEY 유지 — 바꾸면 기존 세션·토큰이 무효화된다)"
else
...
fi
렌더 결과를 검사해 확인했다. 차트 기본 secret_key 문자열 0건, kind: Secret 리소스 0건, 이미지 태그는 전부 핀 고정. 그제야 배포했다. 마이그레이션에 6분이 걸렸고 파드 아홉 개가 떴다.
2. 앱은 자기 주소를 모른다
배포가 끝나고 나서 두 번 더 막혔다.
”Get started” 버튼이 죽은 주소로 보냈다
브라우저로 열었다. “Welcome to Plane”이 떴다. Get started를 눌렀다.
https://plane.example.com:3000/god-mode/
ERR_ADDRESS_UNREACHABLE
포트 3000이 URL에 붙어 있었다. 3000은 클러스터 안에서만 열린 포트다. WEB_URL 설정을 확인했다. http://plane.example.com — 포트가 없다. 정상이다. 그런데 브라우저는 :3000으로 갔다.
경로별로 찔러봤다.
/god-mode 301 → http://plane.example.com:3000/god-mode/
/spaces 301 → /spaces/
/spaces는 상대 경로로 리다이렉트하는데 /god-mode만 절대 URL을 뱉었다. admin 앱(Next.js)이 끝 슬래시를 붙이려고 301을 내면서 자기가 듣고 있는 포트를 그대로 절대 URL에 쓴 것이다. 프록시 뒤에 있다는 걸 모른다.
빌드 인자를 확인해봤다.
ARG VITE_ADMIN_BASE_URL=""
ARG VITE_ADMIN_BASE_PATH="/god-mode"
빌드 타임 값이라 런타임 환경변수로 못 고친다. 앱을 고칠 수 없으면 앞단에서 고치는 수밖에 없다. Traefik 미들웨어로 그 301을 가로챘다.
spec:
redirectRegex:
regex: "^https?://([^/:]+)(:[0-9]+)?/god-mode$"
replacement: "https://${1}/god-mode/"
permanent: true
여기서 한 번 더 걸렸다. 고쳤는데도 브라우저는 계속 :3000으로 갔다. 301은 영구 리다이렉트라 브라우저가 캐시한다. 요청을 아예 안 보내니 미들웨어가 가로챌 기회가 없었다. 시크릿 창으로 열자 정상이었다.
커버 이미지가 안 올라갔다
프로젝트를 만들려는데 이번엔 Error! Failed to upload cover image가 떴다. 서버 로그를 봤다.
POST /api/assets/v2/workspaces/homelab/ 200
200이다. 서버는 정상적으로 응답했다. 실패는 그다음, 브라우저가 받은 주소로 직접 올리는 단계였다. Plane의 스토리지 코드를 열었다.
if os.environ.get("USE_MINIO") == "1":
if os.environ.get("MINIO_ENDPOINT_SSL") == "1":
endpoint_protocol = "https"
else:
endpoint_protocol = request.scheme if request else "http"
...
endpoint_url=(f"{endpoint_protocol}://{request.get_host()}" ...)
호스트는 요청 Host 헤더에서 가져온다. 그래서 plane.example.com는 맞게 나왔다. 문제는 스킴이었다. MINIO_ENDPOINT_SSL이 1이 아니면 요청 스킴을 쓰는데, Django에 SECURE_PROXY_SSL_HEADER가 설정돼 있지 않아 X-Forwarded-Proto를 무시한다. Cloudflare가 TLS를 끝낸 뒤 Traefik에는 평문으로 들어오니, Django가 보는 스킴은 http다.
https 페이지가 http 주소로 업로드하려다 브라우저에 차단된 것이다. mixed content. minio_endpoint_ssl: true 한 줄로 끝났다.
두 문제는 사실 하나였다
버튼이 깨진 것과 이미지가 안 올라간 것. 증상도 다르고 언어도 다르고(Next.js와 Django) 고친 위치도 달랐다(Traefik 미들웨어와 values 한 줄). 그런데 원인은 같다. 앱이 자기 공개 주소를 만들어야 하는 순간, 자기가 프록시 뒤에 있다는 걸 몰랐다.
- Next.js는 자기가 듣는 포트를 썼다 — 외부에 열린 포트가 아닌데
- Django는 자기가 받은 스킴을 썼다 — 엣지에서 이미 https로 종료됐는데
“TLS를 엣지에서 끝내고 안쪽은 평문”이라는 구조는 흔하다. 그 구조에서 앱은 자기가 어떤 주소로 불렸는지 알 수 없다. 알려주는 방법은 X-Forwarded-* 헤더뿐인데, 앱이 그걸 읽어줘야 성립한다. 안 읽으면 앱은 자기 내부 사정으로 주소를 조립하고, 그 주소는 밖에서 통하지 않는다.
그래서 이런 스택을 올릴 때 “앱이 자기 URL을 만들어 내보내는 지점”을 미리 세어보는 게 낫다. 리다이렉트, presigned URL, 이메일 링크, webhook 콜백, OAuth redirect_uri. 전부 같은 함정을 밟을 수 있는 자리다. 우리는 그중 둘을 사후에 발견했다.
3. 계속 붙던 것
셀프호스팅의 비용은 세우는 데 있지 않았다. 내가 통제하지 못하는 것과 닿는 자리마다 붙었다.
| 벽 | 무엇이었나 |
|---|---|
| Cloudflare Access | 브라우저가 아닌 것을 막는다. service token을 발급받고 헤더를 넣어줄 로컬 프록시를 세웠다 |
| CA 번들 | 회사 장비에서 MCP가 TLS 검증에 걸렸다 |
host-gateway | mac에서 되던 것이 Linux에서 안 됐다 |
buzz-cli 이미지 | 장비를 옮기니 이미지가 없었다 |
| CoreDNS | 클러스터 안에서 buzz.example.com가 공인 IP로 풀려 자기 클러스터 서비스에 못 닿았다. 끝내 안 풀렸다 |
마지막 것이 상징적이다. Plane이 Buzz에 webhook을 보내는데, 둘 다 같은 클러스터 안에 있는데, 요청이 밖으로 나갔다가 Cloudflare Access에 막혀 돌아왔다. Plane 로그에는 sent successfully라고 찍혔다 — 무언가 응답을 받긴 했으니까.
host-gateway 건은 더 얄궂다. mac에서 host.docker.internal로 잘 붙던 것이 Ubuntu로 옮기니 172.17.0.1(docker0)로 풀렸다. 그런데 공용 DB는 127.0.0.1에만 바인딩돼 있었다. 같은 compose 파일, 같은 명령, 다른 결과.
어느 것도 Plane이나 Buzz의 결함이 아니다. 자기 인프라 위에 올린다는 선택이 데려온 것들이다.
4. 조용히 죽는다
가장 비쌌던 건 벽이 아니라 실패가 실패처럼 안 보이는 것이었다.
체크박스가 2주간 거짓말을 했다
어느 날 세션에 “plane MCP로 프로젝트 조회해줘”를 시켰다. 도구가 없었다. TODO에는 이렇게 적혀 있었다.
- [x] API 키 발급 → 글로벌 ~/.claude/mcp.json에 plane 등록
- [ ] 세션 재시작 후 plane MCP 도구 검증 — 성공하면 Phase 1 종료
키가 만료됐나 싶어 REST API로 직접 찔러봤다. GET /api/v1/workspaces/.../projects/ → HTTP 200. 프로젝트도 멀쩡히 돌아왔다. stdio로 MCP 서버를 직접 띄워 핸드셰이크를 걸어보니 Plane MCP Server (stdio) 3.2.0, 도구 28개를 정상적으로 뱉었다. 전부 정상인데 Claude Code에서만 도구가 안 보였다.
원인은 허무했다. ~/.claude/mcp.json은 Claude Code가 읽지 않는 파일이다. 유저 스코프 MCP 설정은 ~/.claude.json의 mcpServers에 들어간다. 2주 전 그 체크박스가 찍힌 순간부터 단 한 번도 등록된 적이 없었다. 고치는 건 claude mcp add plane --scope user ... 한 줄이었다.
왜 2주 동안 몰랐나. 완료 판정을 파일 작성으로 했기 때문이다. JSON은 잘 썼고, 그 안의 값도 다 옳았고, API 키는 REST로 200까지 확인했다.
- 검증한 것: 키가 유효한가 →
curl200 - 검증했어야 할 것: Claude Code가 이 서버를 아는가 →
claude mcp list
전자가 통과해도 후자는 아무것도 보장하지 않는데, 통과의 느낌이 후자까지 덮었다.
두 번째 요인은 검증을 미룰 수밖에 없는 구조였다. MCP 서버는 세션 시작 시 로드되므로 등록한 그 세션에서는 확인할 수 없다. “다음 세션에서 확인”이 TODO로 남았고, 그 사이 등록 항목에는 이미 [x]가 찍혀 있었다. 확인 항목은 열려 있었지만 등록 항목이 닫혀 있어서 이후 세션들이 그 앞을 다시 보지 않았다.
설정을 “썼다”와 도구가 “붙었다”는 다른 사건이다. 완료 판정은 그 설정을 소비하는 쪽에 물어서 받아야 한다.
배선된 적 없는 CD를 배선된 걸로 읽고 있었다
같은 무렵, 레포의 CLAUDE.md에 이렇게 적혀 있었다.
feature/* → develop(로컬 compose로 검증) → main(머지 시 kube prod CD — Phase 4에서 배선)
“Phase 4에서 배선”이라는 단서가 붙어 있긴 했다. 그런데 이 문장은 시간이 지나면서 **“main에 머지하면 배포된다”**로 읽히기 시작한다. 실제로 나는 그렇게 읽고 있었다. CD는 배선된 적이 없었다. GitHub Actions에 워크플로우 파일 자체가 없었다.
계획을 적을 때 “아직 아니다”를 함께 적어두지 않으면, 계획은 시간이 지나 사실로 위장한다.
보고가 며칠간 파일에만 쌓였다
buzz-report.sh는 세션이 끝날 때 채널에 보고를 보낸다. 전송이 안 되면 로컬 파일에 적고 exit 0으로 조용히 끝난다 — 보고 실패가 세션을 깨뜨리면 안 되니까.
그 배려가 실패를 숨겼다. 장비를 옮긴 뒤 buzz-cli 이미지가 없어서, 모든 세션의 보고가 며칠간 파일에만 쌓였다. 다른 프로젝트 세션이 보낸 테스트 메시지가 fallback 파일에서 발견되고 나서야 알았다.
더 얄궂은 건, 그 fallback 파일조차 우연히 남았다는 것이다. 원래는 빈 출력이 파서로 들어가 트레이스백이 stdout으로 나가고 있었다. 호출자가 그걸 보고서로 삼을 자리였다.
죽은 hook은 증상조차 없다
다른 레포에서 Plane 작업을 감지해 알림을 쏘는 hook을 걸어뒀다. 그 레포가 Plane을 떠나자 매처가 다시는 안 걸리게 됐다. async: true라 실패해도 조용하다.
증상이 없는 고장은 발견될 방법이 없다.
셋 다 같은 모양이다. 셀프호스팅한 접착제는 깨질 때 소리를 안 낸다.
5. 알림 하나에 사흘
Plane 이슈가 생기면 Buzz 채널에 알리는 것. 그거 하나였다.
08-21에 배선했다. YAML 워크플로우를 쓰고, 채널 UUID를 받고, relay에 등록하고, Plane 웹 UI에 webhook을 걸었다. 실제 도달이 확인된 것은 08-24였다.
그 사이에 밟은 것들:
--yaml이 파일 경로가 아니라 YAML 내용을 받는다는 것 (--help는<YAML>이라고만 적어뒀다)- step id에 하이픈을 못 쓴다는 것 (구조체 주석엔 없고
validate()안에만 있었다) - YAML에서
on:을 따옴표 없이 쓰면 불리언으로 파싱된다는 것 webhook_secret을 터미널에 찍어서 회전해야 했던 것
마지막 건은 따로 적을 값어치가 있다. 마스킹을 하긴 했다 — 키 이름에 secret·key·token이 들어가면 가리도록. 그런데 응답이 이렇게 생겼다.
{
"accepted": true,
"message": "response:{\"webhook_secret\":\"...\"}"
}
시크릿이 message라는 무해한 이름 안에 JSON 문자열로 들어 있었다. 마스킹이 값이 아니라 이름을 봤고, 중첩 구조가 문자열로 평평해지는 순간 그 가정이 깨졌다.
그리고 그 고생 끝에도 알림에 이슈 제목을 못 넣었다. relay가 webhook 본문의 최상위 키만 변수로 주는데 Plane은 제목을 data 안에 넣기 때문이다.
같은 것을 Slack + GitHub으로 하면 채널이 레포를 구독하고 끝이다. 우리 쪽 배선이 0이다.
6. 자를 먼저 봐야 했다
하루에 측정 도구가 네 번 고장 난 날이 있다. 08-21이다.
| 무엇 | 왜 틀렸나 | 어떻게 보였나 |
|---|---|---|
| 셸 변수로 넘긴 플래그 | zsh가 단어 분리를 안 한다 | 빈 출력 |
| 프록시 응답 | gzip을 두 번 풀었다 | 파싱 오류 |
| Access 검증 | urllib이 302를 따라갔다 | 200 |
| WebSocket 검증 | curl이 HTTP/2로 붙었다 | 200 |
세 번째가 위험했다. Access 헤더 없이 요청했는데 200이 나와서, **“인프라 담당이 정책을 잘못 붙여 서비스가 인터넷에 열렸다”**고 읽을 뻔했다. curl로 같은 요청을 하니 302였다. Access는 멀쩡했다.
넷 다 “실패”처럼 안 보였다. 빈 문자열, 파싱 오류, 200, 200이었다.
잰 값이 이상하면 대상이 아니라 자를 먼저 본다.
그리고 하나 더. 다른 세션이 “이 스크립트가 종료 코드를 안 본다”고 알려왔을 때 코드를 안 읽고 “고칠 값어치가 있다”고 동의했다. 읽어보니 보고 있었다. 한 사람의 미확인 발언이 두 세션을 왕복하며 사실이 될 뻔했다.
동의도 주장이다. 반박할 때만 근거를 찾고 동의할 때는 안 찾는 비대칭이 있었다.
같은 함정을 매니페스트 배치에서도 밟을 뻔했다. 홈랩 인프라 레포를 열어보니 ansible/k8s/<앱 이름>/에 앱 매니페스트가 통째로 들어 있어서 “여기가 관행이구나” 했는데, 다른 앱 레포들을 훑어보니 그림이 정반대였다. 다섯 중 넷이 앱 레포에 매니페스트를 두고 있었고 그 하나만 초기 방식으로 남아 있었다. 가장 먼저 눈에 띈 사례가 하필 예외였다.
n=1로 규칙을 만들면 안 된다는 건 누구나 안다. 문제는 그 하나가 그럴듯할 때 확인을 건너뛰게 된다는 점이다.
7. 전제가 무너졌다
2026-08-26, 다른 레포 세션에게서 메시지가 왔다.
Plane CE → GitHub Issues로 이관했어. 이슈 10건·코드 0줄 시점이라 가장 싼 때였고, 결정적이었던 건
Closes #n한 줄로 “티켓 1개 = PR 1개”가 공짜로 강제된다는 점이야.
우리가 규약 문서로 만들려던 것을 GitHub이 기본으로 준다는 얘기였다.
확인해보니 Plane 워크스페이스에 프로젝트가 둘, 작업 항목이 10건 남아 있었다. 그쪽은 “사용처 0”이라고 알려왔지만 데이터가 남아 있는지는 안 봤던 것이다. 이관 표시는 10건 중 1건에만 붙어 있었다. 물어봤고, 답은 “옮겼는데 표시를 안 단 것”이었고, GitHub 이슈와 제목 10/10을 양쪽이 각각 대조하고 나서 지웠다.
여기서 상대가 남긴 문장이 좋았다.
“안 쓴다”와 “데이터를 치웠다”는 다르다. 떠난 시스템에 우리 데이터가 남아 있으면 남을 못 움직이게 한다.
그리고 Buzz는 사용자가 Slack으로 정했다. 두 소비자가 다 떠났다.
8. 숫자
지우기 전에 세어봤다.
Buzz 총 이벤트 222건 · 8채널
08-20 82건 ← 배선하던 날들
08-21 75건
08-24 37건
08-25 26건
08-26 1건
세팅하던 주에 몰려 있고 줄어드는 중이었다. 그중 상당수가 에이전트 세션 보고이고 사람이 주고받은 대화가 아니다.
디스크는 더 극적이다.
| PVC | 할당 | 실제 |
|---|---|---|
| Buzz MinIO | 20Gi | 3.1M |
| Plane RabbitMQ | 1Gi | 3.0M |
| Plane MinIO | 20Gi | 644K |
| Buzz git-data | 5Gi | 28K (lost+found뿐) |
46Gi를 잡아두고 6.7MB를 썼다. 마지막 줄이 특히 그렇다 — git 저장소 기능을 위해 5Gi를 예약해두고 한 번도 안 썼다.
9. 걷어내며
철수에도 순서가 있었다.
먼저 덤프를 떴다. Plane 110테이블, Buzz 54테이블. 지우기 전에, 레포 밖에. 그다음 프로젝트를 지웠고, 레포에서 스택 정의 37개 파일, 4371줄을 걷었다.
클러스터와 공용 DB는 아직 안 건드렸다. 공용 PostgreSQL에는 남의 DB가 10개 넘게 있다. 지울 것은 plane·buzz 두 개뿐이고, 그 서버는 다른 레포 소관이라 요청서로 넘긴다.
시크릿 파일 5개는 안 지웠다. 되돌릴 일이 생기면 필요해서, 문서에 「읽는 쪽 없음 — 정리 대상」으로 표시만 했다.
그리고 걷어내면서도 한 번 미끄러졌다. develop에 직접 커밋했다. push 전에 알아채고 feature 브랜치로 옮겼는데, 같은 실수를 이틀 전에도 했다. 그때는 앞 명령의 git checkout 실패를 출력에서 안 읽고 넘어간 것이었다.
10. 남긴 것
스택을 걷어도 값어치가 남는 것들이 있었다. ADR로 옮겼다.
- relay는 webhook 본문의 최상위 키만 변수로 준다 — 중첩 JSON을 보내는 발신자는 다 같은 벽을 만난다
RELAY_URL의 scheme이 서명 검증 URL을 정한다 — 클라이언트와 같이 바꿔야 한다- iOS는 평문
ws://를 막는다 — 모바일을 붙이려면 TLS가 선행이다 command -v로 준비 여부를 판단하면 안 된다 — Docker로 격리 실행되는 도구는 PATH에 없다
그리고 하나 더 건졌다. Plane 때문에 적어둔 “IngressRoute가 entryPoints: [web]뿐이라 https가 안 된다” 는 줄이, 아이폰에서 Buzz를 쓰려던 마지막 시도에서 그대로 다시 걸렸다.
항목을 닫을 때 “왜 막혔나”만 남겨도 값어치가 있다.
무엇을 배웠나
세우는 비용과 유지하는 비용은 다른 곡선이다. 세우는 건 27일이면 됐다. 유지 비용은 내가 통제하지 못하는 것과 닿는 자리마다 계속 붙었고, 그 자리는 줄지 않았다.
셀프호스팅한 접착제는 조용히 깨진다. 체크박스, fallback, 죽은 hook, 없는 이미지. 넷 다 증상이 없었다. 관리형 서비스가 주는 것 중에 **“고장 나면 시끄럽게 고장 난다”**가 있다.
프록시 뒤의 앱은 자기 주소를 모른다. 리다이렉트든 presigned URL이든, 앱이 URL을 만들어 밖으로 내보내는 자리는 전부 같은 함정이다. X-Forwarded-*를 앱이 읽어주지 않으면 앞단에서 고치는 수밖에 없다.
소비자가 없는 인프라는 남을 붙잡는다. 우리 Plane에 남의 작업 항목이 남아 있어서, 그쪽이 떠났다고 말한 뒤에도 우리가 못 움직였다.
접는 판단은 실패가 아니다. 이관은 이슈 10건·코드 0줄 시점에 이뤄졌다. 가장 싼 때였다. 27일 만에 접을 수 있었던 건 27일밖에 안 썼기 때문이다.
덧: 그래서 Claude Code는
이 27일 내내 Claude Code와 같이 했다. 좋았던 것과 아니었던 것이 갈린다.
좋았던 것 — 벤더링, 매니페스트 작성, 덤프 뜨고 격리 컨테이너에 복원해 마이그레이션 리허설하기 같은 것들. 특히 철수 작업이 그랬다. 무엇이 딸려 있는지 세고, 남의 DB를 안 건드리게 범위를 좁히고, 지우기 전에 백업을 뜨는 순서를 지키는 일.
아니었던 것 — 위 목록 대부분이 내가 아니라 에이전트가 밟은 것이다. ssh -G로 검증했다고 회신하고, just buzz-up이 통과했으니 고쳐졌다고 하고, urllib이 302를 따라간 걸 모르고 “인프라가 뚫렸다”고 할 뻔했다.
패턴이 하나로 모인다. “확인했다”는 느낌이 실제 확인과 자주 어긋났다. 그래서 이 레포에는 규칙이 하나 생겼다 — 주장 옆에 확인 방법을 적는다. 문서가 안 낡게 만들 수는 없어도, 낡았는지 확인하는 비용은 낮출 수 있다.
이 글은 Claude Code와 작업하며 남긴 worklog·postmortem·ADR을 바탕으로 정리했다.