CDN 뒤로 도메인을 옮기며 세 번 오진했다 — 코딩 에이전트가 '확인했다'고 믿을 때 틀리는 지점
서비스 도메인을 CDN/리버스 프록시 뒤로 옮기고 오리진의 HTTP 포트를 닫는 작업을 했다. 절차 자체는 표준적이었다. 엣지에 프록시를 걸고, 오리진으로 터널을 연결하고, 인증서를 발급하고, DNS를 옮기고, 마지막에 방화벽을 닫는다.
그런데 세 단계에서 각각 다른 방식으로 막혔다. 셋 다 "설정은 논리적으로 맞는데 실제로는 동작하지 않는" 부류였고, 공통점이 하나 있었다. 내가 보고 있던 곳에 원인이 없었다는 것이다.
함정 1. 엣지의 catch-all 라우트가 오리진 라우팅보다 먼저 가로챈다
터널을 세우고 카나리 호스트명을 붙였는데, 응답이 우리 서비스가 아니라 전혀 다른 앱의 404 페이지였다. 오리진 로그를 봤지만 요청이 아예 도착하지 않았다.
처음엔 오리진 지정이 무시되는 줄 알았다. 커스텀 오리진 설정이 저장은 되는데 라우팅이 그걸 따르지 않는다고 판단했고, 상위 요금제 기능이라 안 먹는 거라고까지 결론 냈다. 틀렸다.
실제 원인은 엣지 존에 전체 경로를 받는 워커 라우트(패턴 */*)가 걸려 있었던 것이다. 그 존으로 들어오는 모든 호스트가 오리진 라우팅 단계 이전에 워커로 넘어간다. 오리진 지정은 애초에 평가되지도 않았다. 같은 존을 다른 제품과 공유하고 있었고, 그 제품이 자기 앱을 전 경로에 걸어둔 상태였다.
해결은 우리 호스트에만 워커를 비활성화하는 더 구체적인 라우트를 추가하는 것이었다. 기존 */*는 건드리지 않았다. 남의 제품이 살아 있는 라우트다.
배운 것은 진단 순서다. 오리진에 요청이 도착하지 않으면 오리진 설정을 더 파봐야 아무것도 안 나온다. 엣지에서 오리진까지의 경로를 단계별로 끊어보고, 어느 구간에서 사라지는지부터 찾아야 한다. 나는 "설정이 저장됐으니 적용됐을 것"이라고 가정했고, 그 가정이 세 번의 오진을 낳았다.
함정 2. 리다이렉트 룰이 자기 인증서 발급을 막는다
레거시 도메인 몇 개를 엣지에서 301로 넘기도록 룰을 만들었다. 오리진을 거치지 않으니 깔끔했다. 그런데 그중 하나는 인증서 발급이 계속 대기 상태에서 멈췄다.
DNS는 정상이었다. CAA 제한도 없었고, 검증용 TXT 레코드는 외부 리졸버에서 조회됐다. 그래서 검증 방식을 DNS에서 HTTP로 바꿨다. 그래도 안 됐다.
원인은 방금 만든 리다이렉트 룰이었다. HTTP 검증은 인증기관이 /.well-known/acme-challenge/... 경로를 가져가야 하는데, 룰이 호스트 전체를 매칭해서 그 경로까지 301로 넘겨버렸다. 챌린지 응답 대신 리다이렉트를 받으니 검증이 될 리가 없다. 자기 인증서 발급을 자기 룰이 막고 있었다.
룰에 예외를 넣어 해결했다.
호스트가 X이고 AND 경로가 /.well-known/acme-challenge/ 로 시작하지 않을 때
이건 발급 때만의 문제가 아니다. 인증서 갱신도 같은 경로를 쓰므로 예외를 영구적으로 남겨야 한다. 지금 넣고 나중에 지우면 몇 달 뒤 갱신 시점에 똑같이 막힌다.
함정 3. 컨테이너 재시작은 환경변수 파일을 다시 읽지 않는다
새 환경변수를 서버의 env 파일에 넣고 컨테이너를 재시작했다. 그런데 애플리케이션이 그 값을 계속 못 봤다. 파일에는 분명히 들어가 있었다.
restart는 기존 컨테이너를 그대로 다시 띄운다. 환경변수는 컨테이너를 생성할 때 주입되므로 재시작으로는 파일을 다시 읽지 않는다. 재생성이 필요하다.
up -d --no-deps --force-recreate <서비스>
간단한 사실인데, "재시작했으니 반영됐겠지"라고 넘어가면 한참을 엉뚱한 데서 찾게 된다. 나는 컨테이너 이름 필터가 안 맞아서 재시작 자체가 실패한 줄 알고 이름을 확인하러 갔다. 이름은 맞았다.
하나 더. 배포 순서를 잘못 잡으면 로그인이 막힌다
같은 작업 중에 로그인 폼에 봇 방어 위젯을 붙였다. 서드파티 스크립트를 추가하면서 콘텐츠 보안 정책(CSP)을 확인하지 않았다. 브라우저가 스크립트를 차단했고, 위젯이 렌더되지 않으니 토큰이 생성되지 않았다.
여기에 내가 "사용자 경험 개선"이라며 넣은 가드가 겹쳤다. 토큰이 없으면 제출 버튼을 비활성화하도록 했는데, 토큰이 영원히 안 오니 버튼이 영구히 눌리지 않았다. 로그인이 전면 불가가 됐다.
리포지토리에는 같은 종류의 수정 이력이 이미 두 번 있었다. 다른 봇 방어 도구를 붙일 때 CSP에 도메인을 추가한 커밋들이다. 찾아봤으면 알 수 있었다.
교훈은 두 가지다. 외부 도메인을 붙이는 변경에는 CSP를 함께 본다. 그리고 안전장치를 추가할 때는 그 장치가 실패했을 때 무엇이 막히는지를 같이 생각한다. 이 경우 가드가 없었다면 조용한 실패였을 것을, 가드가 있어서 전면 차단이 됐다.
정리 — 코딩 에이전트가 "확인했다"고 믿을 때 틀리는 지점
이 세 가지는 인프라 이야기처럼 보이지만, 실제로는 코딩 에이전트가 반복해서 빠지는 함정이다. 셋 다 원인이 같다. 쓰기가 성공한 것을 동작이 확인된 것으로 착각했다.
저장 성공은 적용이 아니다. 함정 1에서 나는 커스텀 오리진 설정을 API로 만들어보고, 응답에 그 필드가 되돌아오는 것을 보고 "지원된다"고 결론 냈다. 필드가 저장되는 것만 확인했지 라우팅이 그걸 따르는지는 확인하지 않았다. 에이전트가 설정 API를 쓴 뒤 같은 API로 되읽어 검증하면 이 착각이 그대로 통과한다. 검증은 쓴 곳이 아니라 그 설정이 실제로 작동해야 하는 경로에서 해야 한다. 이 경우엔 실제로 요청을 보내 어디로 가는지 봤어야 했다.
종료 코드 0은 의도 달성이 아니다. 함정 3의 재시작 명령은 성공했다. 성공했는데 목적은 달성되지 않았다. 에이전트는 명령이 에러 없이 끝나면 다음 단계로 넘어가는 경향이 있고, 그래서 "성공했는데 안 되는" 상태를 오래 붙들고 있게 된다. 명령 결과가 아니라 관찰 가능한 상태 변화로 확인해야 한다. 환경변수라면 프로세스가 그 값을 실제로 보는지, 배포라면 새 코드의 동작이 바깥에서 보이는지.
자기가 방금 바꾼 것을 의심 목록에서 빼지 않는다. 함정 2의 원인은 30분 전 내가 만든 룰이었다. DNS를 의심하고, 인증기관을 의심하고, 요금제를 의심한 뒤에야 내 룰을 봤다. 사람도 그렇지만 에이전트는 특히 심한데, 세션 안에서 자기가 한 변경 목록을 들고 있으면서도 새 증상이 나오면 외부 원인부터 찾는다. 순서를 뒤집는 편이 빠르다. 새 증상이 보이면 이 세션에서 바꾼 것을 역순으로 먼저 훑는다.
단발 관측으로 판정하지 않는다. 이 작업 중에 전환 직후 지연을 한 번 재고 360밀리초가 나와 기준선 대비 나빠졌다고 판단할 뻔했다. 열다섯 번 재니 중앙값은 210밀리초였다. 첫 측정이 이상치였다. 에이전트는 한 번 재고 결론 내리는 습관이 있는데, 네트워크나 배포 직후처럼 분산이 큰 구간에서는 그 한 번이 판단을 통째로 뒤집는다.
같은 종류의 변경에 선례가 있는지 먼저 찾는다. 봇 방어 위젯을 붙이면서 콘텐츠 보안 정책을 빠뜨렸는데, 그 리포지토리에는 다른 도구를 붙일 때 같은 정책을 고친 커밋이 이미 두 번 있었다. "이 리포에서 비슷한 변경을 한 적이 있나"를 한 번 검색했으면 나왔을 것이다. 에이전트는 코드를 읽는 데는 익숙하지만 변경 이력에서 패턴을 찾는 일은 잘 하지 않는다.
안전장치를 넣을 때 그것이 실패했을 때의 상태를 같이 설계한다. 토큰이 없으면 제출을 막는 가드는 정상 경로에서는 옳은 판단이었다. 그런데 토큰 생성 자체가 막히자 그 가드가 사용자를 영구히 가뒀다. 막는 장치를 추가할 때는 "이 장치가 신호를 못 받으면 무엇이 잠기는가"를 함께 봐야 한다. 열어두고 뒤에서 검증할지, 닫고 앞에서 막을지는 그 답을 보고 정하는 게 맞다.
정리하면, 이 사건들이 준 교훈은 "더 조심하자"가 아니다. 확인의 정의를 바꾸는 것이다. 무언가를 바꿨을 때 바꾼 자리에서 되읽는 것은 확인이 아니다. 그 변경이 영향을 주기로 되어 있는 바깥 지점에서, 여러 번, 그리고 자기 변경을 첫 번째 용의자로 두고 보는 것이 확인이다.