내부 private repo 작업을 바탕으로 썼다. 외부 접근이 불가한 저장소라 링크 없이, 익명화 중심으로 정리한다.
다른 팀이 폐기 예정으로 두었던 API 하나가 닫혔고, 우리 서비스의 제휴카드 안내 페이지가 그 즉시 에러 화면이 됐다. 사업팀 신고로 알게 됐고, 신고에서 운영 배포까지 1시간 17분이 걸렸다.
이 글은 그 세 시간의 기록이 아니다. 흥미로운 쪽은 닫히기 이틀 전부터 관측이 정확한 신호를 두 번 보냈고, 매번 사람이 그 신호를 잘못 번역했다는 사실이다. 결론을 먼저 적으면 이렇다. 관측 도구는 “호출이 남아 있다”까지 알려주지만, “이 호출은 누구 것인가”는 알려주지 않는다. 그 번역은 사람이 하고, 사람은 자기가 볼 수 있는 범위 안에서만 답한다.
등장하는 서비스는 셋이다. 원본 CMS를 새 시스템으로 이관 중인 상위 플랫폼, 그 CMS를 가져다 쓰는 우리 서비스, 그리고 같은 CMS를 쓰는 자매 서비스. 원문 기록을 링크하지 못하는 대신, 수치마다 세는 단위와 확인 방법을 병기했다.
1. 닫기 전에 트래픽은 봤다
이건 관측 실패가 아니다. 오히려 반대다.
| 시각 | 무슨 일이 있었나 | 누가 판정했나 |
|---|---|---|
| D-2 14:08 | 플래그를 켜서 새 CMS로 트래픽 전환 | 자동화 |
| D-2 14:39 | “이상 지표는 없는데 옛 API 호출이 약 40% 비율로 계속 들어온다” | 관측이 1차 신호 |
| D-2 15:05 | 같은 CMS를 쓰던 자기 팀 페이지 하나를 발견하고 경로 수정 착수 | 사람이 자기 것을 찾음 |
| D-2 18:20 | “아직도 호출이 있다” + 트레이스 링크 첨부 | 관측이 2차 신호 |
| D-2 18:27 | “우리 서비스 내부의 정합성 비교 로직이 부르는 것” | 1차 오판 |
| D-1 10:12 | “내부 호출이 대부분 맞았다. 그런데 콘텐츠 한 개가 계속 옛 API를 부른다” | 관측이 범인을 특정 |
| D-1 10:44 | “저 콘텐츠도 내부 호출만 있는 것 같다” | 2차 오판. 결정적 |
| D-day 11:21 | API 제거 배포 | 자동화 |
| D-day 13:59 | 사업팀 신고 (2시간 38분 침묵) | 사람이 화면을 보고 발견 |
D-1의 그 콘텐츠 ID가 우리 페이지가 부르던 바로 그 콘텐츠였다. 심지어 폐기를 진행하던 담당자는 “그런데 이것도 제휴카드네요”라며 같은 제휴카드 페이지가 두 번째로 걸린다는 것까지 짚었다. 관측 도구는 할 수 있는 일을 전부 했다. 남은 호출 비율을 냈고, 트레이스를 남겼고, 마지막 한 건까지 좁혀서 콘텐츠 ID를 특정했다.
마지막 확인(D-1 10:44) 뒤 제거 배포(D-day 11:21)까지 약 25시간이 있었다. 급하게 지른 것도 아니다. 절차대로, 하루 묵혀서, 확인을 받고 닫았다.
2. 틀린 것은 번역이었다
깨진 자리는 “이 트래픽은 누구 것인가”를 사람의 언어로 옮기는 단계다.
트레이스에는 호출자가 우리 서버라고 적혀 있지 않았다. 서버 대 서버 호출이고, 조직도 다르고, 게이트웨이를 거치면 출발지는 그냥 IP 하나다. 그래서 확인 방법이 “아는 사람에게 물어보기”가 됐다. 물어본 대상은 그 API를 쓰던 팀의 FE 담당이었고, 그분은 자기 코드베이스를 성실하게 뒤져서 “없다”고 답했다.
그 답은 틀리지 않았다. 자기가 볼 수 있는 범위에서는 정말로 없었으니까. 문제는 그 답이 전달되는 순간 범위 한정이 떨어져 나가고 “없음”으로 굳었다는 것이다. 침묵을 중립으로 읽으면 안 된다고 한 번 적은 적이 있는데, 이번엔 침묵이 아니라 범위가 한정된 대답이 같은 일을 했다.
두 번의 오판이 같은 모양이다. 1차는 “우리 내부 비교 로직이 부르는 것”이었고, 실제로 그런 호출이 섞여 있어서 부분적으로 맞았다. 부분적으로 맞는 답이 제일 위험하다. 남은 호출을 설명해버리기 때문에 더 찾을 이유를 없앤다.
여기서 얻은 규칙은 이렇다. 폐기 전에 “이거 쓰는 사람 있나요”를 사람에게 묻는 것은 검증이 아니라 표본 조사다. 조직 경계를 넘는 순간 그 표본에는 애초에 우리가 들어 있지 않았다.
3. 문서는 촘촘했는데 목록이 없었다
이 전환은 주먹구구가 아니었다. 폐기를 진행한 팀은 런치 리뷰 문서를 따로 썼고, 내용이 상당히 좋다. 실패 유형별 식별 기준과 우선 대응, 전체 롤백 판단 조건이 표로 정리돼 있고, 관측 대상 지표와 판정 시 주의점, 단계별 롤백 플래그가 각각 어떤 범위를 끄는지까지 적혀 있다. “지표가 0이라고 정상으로 판정하지 않는다”는 문장도 있다.
그런데 그 문서 어디에도 이 API를 호출하는 외부 소비자 목록이 없다. 데이터 정합성에 그토록 촘촘한 문서가 소비자 목록 한 줄이 없어서 뚫렸다.
이게 개인의 부주의가 아니라는 게 요점이다. 문서는 자기 시스템의 상태를 관리하도록 설계돼 있었다. 동기화 지연, 역방향 큐 적체, 발행 실패 같은 것들. 소비자는 자기 시스템의 상태가 아니라 바깥의 사실이라서, 같은 문서 구조 안에 자리가 없었다.
4. 캐시가 가린 두 번째 소비자
여기서 이야기가 한 겹 더 접힌다.
같은 API를 쓰는 페이지가 셋이었는데, 한 곳만 죽었다. 우리다. 나머지 둘은 멀쩡해 보였고, 그래서 초기 판단이 잠깐 “우리 쪽 문제인가”로 기울었다. 렌더 방식을 직접 확인해보니 이유가 갈렸다.
| 페이지 | 렌더 방식 (응답에서 직접 확인) | API 제거 시점의 반응 |
|---|---|---|
| 우리 서비스 | Pages Router, 요청마다 서버 렌더 | 즉시 에러 화면 |
| 상위 플랫폼 | Pages Router, 요청마다 서버 렌더 | 이틀 전에 미리 고쳐서 무사 |
| 자매 서비스 | App Router, 요청 시 API를 부르지 않음 | 안 터진 척 |
자매 서비스 쪽 근거는 두 가지다. 연속 두 번 요청한 응답이 19,267바이트로 길이까지 완전 동일했고, 차이는 렌더마다 바뀌는 짧은 해시 조각 두 개뿐이었다. 그리고 옛 API가 모든 ID에 404를 내던 시각에 받아둔 저장본에도 CMS 콘텐츠가 그대로 들어 있었다. 요청 시점에 API를 부르지 않는다는 뜻이다.
Next.js 14의 App Router는
서버 fetch의 기본이 캐시다.
페이지에 동적 렌더 선언이 없으면 그 페이지는 빌드 시점에 구워진다. 지금 보이는 화면은 마지막 배포 때 찍은 스냅샷이다.
(리포 접근 권한이 없어 코드로는 확정하지 못했다. 다만 빌드 타임 정적이든 데이터 캐시든
결말은 같다.)
그래서 자매 서비스의 시한폭탄은 이렇게 생겼다. 옛 API가 영구히 닫힌 뒤, 이 마이그레이션과 아무 상관없는 이유로 재빌드가 돌면 빌드 타임 fetch가 404를 받고, 에러 분기가 그대로 구워져서 조용히 배포된다. 터지는 시점이 원인에서 분리돼 있고, 터뜨린 사람은 이 전환을 모르는 사람이다.
한 줄로 정리하면 이렇다. 우리는 매 요청 호출이라 즉시 아팠고, 그래서 그날 안에 고쳤다. 자매 서비스는 정적이라 아프지 않았고, 그래서 아직 안 고쳤다. 정적 렌더링은 장애를 막아준 게 아니라 청구서를 미뤄준 것이다.
5. 그날 사람이 지휘한 것들
이 사고에서 자동화는 거의 다 제 역할을 했다. 동기화 파이프라인도, 플래그 스위치도, 트레이스도, 배포 파이프라인도, 승인 봇도, 리뷰 봇 셋도 정상이었다. 그런데 결과를 바꾼 판단 세 개는 전부 사람이 했다.
하나. “지금 배포하는 게 더 안전하다”는 역판단. 신고 한 시간 뒤, 폐기 팀이 제거 건만 임시로 되돌려서 장애가 일단 멎었다. 여기서 “급하지 않으니 정식 절차로 천천히” 쪽으로 갈 수 있었다. 그런데 그 롤백은 우리가 전환할 때까지만 유지되는 조치였다. 즉 옛 경로와 새 경로가 동시에 살아 있는 지금이, 되돌릴 안전망을 가진 유일한 창이었다. 기다리면 그 창이 닫힌다. 그래서 미루는 게 아니라 지금 나가는 게 안전한 선택이 됐다. 어떤 자동화도 이 판단을 대신해 주지 않는다.
둘. 봇의 지적을 실측으로 옮긴 결정. 리뷰 봇이 정확한 지적을 했다. “호스트와 버전이
동시에 바뀌었는데 응답 계약이 같다는 근거가 제시되지 않았다.” 봇으로서는 만점이다. 여기에
“구조가 같으니 괜찮습니다”라고 답할 수도 있었다. 대신 옛 API가 롤백으로 되살아난 그
순간을 이용해 두 응답을 직접 받아 전체 비교를 돌렸다. 결과는 차이 1건, 새 응답에만
쓰지 않는 필드 하나가 null로 추가된 것뿐이었다. 봇은 빈틈을 지적하고, 사람은 그 빈틈을
메울 방법과 그 방법이 가능한 시간대를 찾는다.
셋. 없던 모니터링을 티켓으로 만든 것. 리뷰에서 이런 지적이 나왔다. “이번 장애가 두 시간 넘게 신고로만 드러난 건 CMS 조회 실패를 아무 데도 남기지 않아서다.” 맞는 말이었다. 우리 서버 렌더 코드는 실패를 조용히 삼키고 대체 화면을 그렸다. 화면은 친절했고 로그는 없었다. 로그가 조용할 때 무엇을 놓치는지 이미 한 번 겪고도 같은 자리를 비워둔 셈이다. 핫픽스 범위는 건드리지 않고 후속 작업으로 분리해 등록했다.
6. 남길 규칙
폐기 전에 물어볼 곳은 “코드를 아는 사람”이 아니라 “호출자 목록”이다. 사람에게 묻는 건 표본 조사고, 조직 경계 밖은 그 표본에 없다. 게이트웨이 접근 로그든 트레이스의 발신 서비스 태그든, 호출자를 식별할 수 있는 형태로 남겨두지 않으면 폐기 판단은 항상 추측이 된다.
소비자 인벤토리는 문서가 아니라 등록제여야 한다. 문서에 적는 목록은 적은 날짜에만 정확하다. 우리 호출이 어느 목록에도 없었던 게 이번 사고의 뿌리인데, 그건 아무도 게을러서가 아니라 등록할 자리가 없었기 때문이다.
“안 깨졌다”와 “안전하다”를 구분한다. 정적 렌더링과 캐시는 장애를 없애지 않고 지연시킨다. 같은 의존을 가진 페이지가 여럿일 때, 지금 멀쩡한 쪽이 더 위험할 수 있다. 청구서가 언제 오는지 모르기 때문이다.
실패를 삼키는 대체 화면에는 로그를 붙인다. 사용자에게 친절한 화면과 운영자에게 침묵하는 시스템은 같은 코드에서 나온다. 대체 화면을 그리는 분기마다 한 줄은 남겨야 한다.
이번 일에서 자동화가 못한 게 딱 하나였다. 바깥을 아는 것. 트레이스는 호출이 있다고 말했고, 파이프라인은 시킨 대로 닫았고, 봇은 근거가 없다고 정확히 짚었다. 전부 자기 경계 안에서 옳았다.
일은 병렬로 굴러가도 판단은 직렬로 남는다고 적은 적이 있다. 이번엔 그 직렬 구간이 조직 사이에 걸쳐 있었을 뿐이다.
경계 밖에 누가 있는지는 여전히 사람이 알아내야 하고, 사람도 자기 경계 안에서만 답한다는 걸 아는 것 또한 사람의 몫이다. 도구가 늘수록 이 자리가 줄어들 줄 알았는데, 오히려 또렷해졌다. 지휘하는 자리는 남는다.