내부 서비스의 장애 대응 경험을 바탕으로 썼다. 조직과 개인을 특정할 수 있는 이름, 링크, 번호와 수치는 제거했다.
TL;DR
장애를 만든 사람과 장애를 해결한 사람은 다를 수 있다. 하지만 대응 과정이 제대로 기록되지 않으면 가장 많이 설명하고 움직인 사람이 문제의 주체처럼 보이기도 한다.
이번 일을 겪으며 문제를 해결하는 것만큼 다음 세 가지가 중요하다는 것을 배웠다.
- 원인은 사람이 아니라 변경 단위와 실행 경로로 설명한다.
- 발견, 판단, 복구, 검증의 책임을 구분해서 기록한다.
- 겸손을 이유로 사실관계까지 생략하지 않는다.
시작은 한 페이지의 오류였다
정기 배포 뒤 특정 페이지에 직접 진입하면 화면이 정상적으로 열리지 않는 문제가 발견됐다. 서비스 전체가 멈춘 것은 아니었지만 고객이 사용하는 실제 경로에서 발생한 명백한 회귀였다.
우선 이전 버전으로 되돌려 고객 영향을 멈췄다. 그다음 변경 내역과 런타임 흐름을 따라가며 원인을 좁혔다.
문제의 기술적 구조는 다음과 같았다.
SSR 요청
-> page props
-> 공통 page shell
-> hydration boundary
-> query cache
-> 화면 렌더링
공통 인프라를 교체하는 과정에서 서버가 준비한 데이터가 중간 계층으로 전달되지 않았다. 빌드와 타입 검사는 통과했고 일반적인 이동 경로도 얼핏 정상처럼 보였다. 하지만 주소로 바로 진입하는 경로에서는 캐시가 비어 있었고, 하위 컴포넌트가 데이터가 존재한다고 가정한 채 값을 읽으면서 크래시가 발생했다.
수정 자체는 작았다. 빠진 연결을 복구하고, 스테이징과 운영 환경에서 직접 진입과 페이지 이동을 다시 검증했다. 어려웠던 부분은 코드 한 줄보다 그 앞뒤의 일이었다.
코드를 읽기 전에 원인 후보가 퍼졌다
장애가 발생하면 빠르게 후보를 세우는 일은 필요하다. 문제는 가설과 사실이 구분되지 않을 때 생긴다.
원인이 확인되기 전 여러 작업이 대화 속에서 후보처럼 나열됐다. 그중에는 내가 진행한 작업도 있었다. 하지만 실제 코드를 따라가 보니 장애는 다른 공통 변경에서 발생한 데이터 전달 누락과 연결돼 있었다.
추정이 틀릴 수 있다는 사실 자체는 문제가 아니다. 장애 대응은 불완전한 정보에서 시작하기 때문이다. 다만 코드를 확인하지 않은 추정이 사람과 작업의 책임처럼 전달되면, 그것을 바로잡는 설명 비용은 실제 대응자에게 돌아간다.
나는 원인을 찾고, 수정하고, 배포하고, 검증했다. 장애 기록을 보완하고 상황을 설명하는 일도 맡았다. 문제를 해결하는 데 집중하다 보니 내가 무엇을 했고 무엇을 하지 않았는지는 굳이 강조하지 않았다.
그런데 침묵한다고 해서 기록이 중립적으로 남는 것은 아니었다.
정보가 빠진 자리는 사람들이 가장 자주 본 이름과 메시지로 채워졌다. 장애를 가장 많이 설명한 사람이 장애와 가장 가까운 사람처럼 보였다. 수습을 위해 자발적으로 메운 빈칸이 어느 순간 원래부터 내 책임이었던 것처럼 굳어질 수도 있었다.
내가 화가 났던 이유
처음에는 단순히 실수를 만든 사람이 미웠다.
하지만 하루를 되짚어 보니 감정의 중심은 버그 하나가 아니었다. 내가 선택하지 않은 책임까지 자연스럽게 흡수해야 했던 상황이 더 힘들었다.
- 장애를 일으킨 변경은 내 것이 아니었지만 원인은 내가 찾아야 했다.
- 복구와 배포 검증은 내가 맡아야 했다.
- 대응 역할은 나뉘지 않았고, 결국 여러 역할의 결과를 내가 정리해야 했다.
- 사실과 다른 추정도 내가 코드를 근거로 정정해야 했다.
해결할 수 있다는 이유로 모든 책임까지 가져가야 하는 것은 아니다. 문제를 잘 푸는 사람에게 설명, 조율, 기록이 계속 몰리면 개인의 역량이 팀의 운영 부채를 가리는 구조가 된다.
이 지점을 말하지 않고 계속 묵묵히 처리하면 주변에서는 상황이 원래 그렇게 설계된 줄 알 수 있다. 나 역시 그것을 내 역할로 받아들이게 된다.
사람 대신 변경 단위로 설명하기
그렇다고 책임을 분명히 하는 일이 누군가를 공개적으로 지목하는 방식이어야 하는 것은 아니다.
장애 원인은 다음처럼 설명할 수 있다.
잘못된 설명
- 누가 만든 작업에서 장애가 났다.
더 나은 설명
- 공통 데이터 계층 전환 변경에서 SSR 데이터 전달이 누락됐다.
- 직접 진입 경로의 런타임 검증이 배포 전 체크에서 빠졌다.
- 해당 연결을 복구한 뒤 대표 경로를 다시 검증했다.
사람 이름을 빼도 변경 범위, 실패 조건, 발견되지 않은 이유와 수정 내용은 충분히 명확하게 남길 수 있다.
반대로 대응 기여까지 익명화할 필요는 없다. 누가 최초로 발견했고, 누가 롤백을 판단했으며, 누가 원인을 분석하고 운영 검증을 마쳤는지는 운영 기록이다. 이것은 공을 주장하기 위한 장식이 아니라 다음 장애에서 같은 판단을 더 빨리 하기 위한 정보다.
원인은 변경 단위로 설명하고, 대응은 역할 단위로 기록한다. 이번에 세운 가장 중요한 원칙이다.
기술적으로 배운 것
이번 장애는 타입이 맞고 데이터가 생성됐다는 사실만으로 화면까지 안전하게 전달됐다고 볼 수 없다는 것을 보여줬다.
서버에서 만든 데이터는 여러 경계를 지난다.
server-side props
-> page props
-> application shell
-> hydration boundary
-> component query
각 단계의 타입이 맞더라도 중간 연결 하나가 빠지면 최종 컴포넌트는 빈 데이터로 실행될 수 있다. 특히 공통 Provider나 App Shell처럼 여러 애플리케이션이 공유하는 구조를 바꿀 때는 diff의 크기보다 대표 실행 경로를 기준으로 검증해야 한다.
앞으로 공통 인프라 변경에서는 최소한 다음 경로를 별도로 확인하려 한다.
- 서버 렌더링으로 직접 진입
- 클라이언트 라우팅으로 진입
- 데이터가 정상적으로 hydration 되는 경로
- 데이터가 없거나 요청이 실패하는 경로
- 오류, 빈 상태, 존재하지 않는 페이지 경로
통합 QA를 통과했다는 사실도 특정 런타임 경로의 안전을 보장하지 않는다. 테스트했다는 문장보다 어떤 경로를 어떤 방식으로 확인했는지가 중요하다.
기록 방식을 바꾸기로 했다
다음부터 장애 대응 기록에는 최소한 네 가지를 분리해 남기려고 한다.
| 구분 | 남길 내용 |
|---|---|
| 발생 | 어떤 변경과 실행 경로에서 문제가 드러났는가 |
| 판단 | 어떤 근거로 롤백 또는 수정을 선택했는가 |
| 대응 | 발견, 분석, 수정, 배포, 검증을 누가 맡았는가 |
| 예방 | 어떤 테스트와 운영 규칙을 추가할 것인가 |
그리고 원인이 확정되기 전에는 사람 이름이나 작업명을 사실처럼 전달하지 않는다. 가설이 필요하다면 가설이라고 표시하고, 반드시 코드와 실행 결과로 확인한다.
나 자신에게도 한 가지 규칙을 추가했다.
문제를 조용히 해결하더라도, 사실관계까지 조용히 만들지는 않는다.
내가 하지 않은 일은 하지 않았다고 말하고, 내가 수행한 대응은 객관적으로 남긴다. 감정을 쏟아내거나 다른 사람을 공격하지 않아도 책임의 경계는 분명하게 설명할 수 있다.
마치며
장애 대응에서 가장 어려운 일은 버그를 고치는 것만이 아니었다. 불완전한 정보 속에서 사실을 확인하고, 서비스는 빠르게 복구하면서도 책임에 대한 잘못된 서사가 생기지 않도록 기록하는 일이었다.
묵묵히 일하는 태도는 여전히 가치가 있다. 다만 묵묵함이 사실의 생략을 의미해서는 안 된다.
겸손은 내가 한 일을 숨기는 것이 아니다. 다른 사람을 과장해서 탓하지 않으면서도, 실제로 일어난 일과 내가 맡은 일을 정확하게 남기는 것이다.
한 줄 결: 문제를 해결한 기록이 없다면, 침묵은 때때로 해결한 사람에게 문제의 책임까지 남긴다.