달력이 멈추지 않았다
필터를 유지하려다 서버를 도배할 뻔한 이야기.
달력이 멈추지 않았다
데스크탑 달력에 필터를 붙인 지 얼마 되지 않았습니다. 키워드, 색깔, 상태로 일정을 좁혀 볼 수 있는 기능입니다. 다만 한 가지 불편이 남아 있었습니다. 다른 탭에 갔다가 달력으로 돌아오면 필터가 초기화됐습니다. 매번 다시 고르는 게 번거로웠습니다.
“필터 유지하자.” 그렇게 시작했습니다. 두 줄짜리 작업이라고 생각했습니다.
두 줄
상태를 영속화하는 흔한 방법이 있습니다. 컴포넌트 안에 두지 말고 바깥의 공유 저장소에 두는 것. Jotai라는 라이브러리를 쓰고 있는데, atom 하나 만들고 컴포넌트가 거기서 읽고 쓰면 끝입니다. 페이지를 떠나도 atom은 살아 있으니 다시 들어왔을 때 그대로 보입니다.
코드를 두 줄 바꾸고 타입 체크가 통과했습니다. 화면이 새로고침됐습니다.
달력이 멈추지 않았습니다.
일정이 깜빡였다
화면에서 일정들이 계속 반짝거리고 있었습니다. 나타났다 사라지길 반복합니다. 서버 로그를 보니 같은 요청이 끊임없이 들어오고 있었습니다. 같은 날짜 범위, 같은 필터, 같은 응답. 초당 몇 번씩.
원인은 명확했습니다. 어딘가에서 데이터를 가져오는 코드가 무한히 반복 실행되고 있는 겁니다. 달력은 이런 폭주를 막으려고 가드를 하나 두고 있었습니다. “필터가 변경됐을 때만 캐시를 비우고 다시 불러와라.” 필터가 그대로면 아무것도 안 합니다.
가드가 제대로 작동하려면 “필터가 변경됐는지”를 확인할 기준이 필요합니다. 보통은 객체의 정체성을 비교합니다. 같은 객체면 그대로, 다른 객체면 변경. 새로 짠 코드도 필터 값이 그대로면 매번 같은 객체를 돌려주도록 만들었습니다. 안정적인 참조. 이론상 문제가 없어야 합니다.
그런데 무한히 돕니다. 그러니까 어떤 식으로든 매번 다른 것으로 인식되고 있다는 뜻입니다.
모르겠다
가설을 몇 개 떠올렸습니다. atom 구독이 어떤 식으로 effect 체인을 흔들고 있을지도 모릅니다. 타입 임포트가 만드는 순환 참조가 모듈 평가 순서에 미묘한 영향을 줬을지도 모릅니다. 가드 자체가 너무 약해서 한 번이라도 흔들리면 도미노로 연쇄 반응을 일으켰을지도 모릅니다.
어느 게 맞는지는 정확히 모르겠습니다. 추측만 있고 검증을 못 했습니다.
이런 상황에서 추측을 사실처럼 적어서 패치하기 시작하면 위험합니다. 운 좋게 멈추더라도 다음번에 비슷한 증상이 나오면 또 같은 자리를 헤맵니다. 그래서 방향을 바꿨습니다. 정확한 원인 규명 대신, 이미 작동한다고 검증된 패턴으로 다시 짜기로 했습니다.
처음 코드는 atom을 직접 컴포넌트 상태로 썼습니다. 새 코드는 atom을 단순 저장소로만 씁니다. 컴포넌트는 마운트할 때 atom에서 한 번 읽어오고, 자기 안에 평범한 상태로 보관합니다. 변경되면 자기 상태도 바꾸고 atom에도 한 번 더 적습니다. atom을 구독하지 않습니다. 이전에 잘 돌아가던 모든 메커니즘이 그대로 보존됩니다.
새로고침. 멈췄습니다. 다른 탭에 갔다 와도 필터는 그대로 있었습니다. 두 가지 다 됐습니다.
진짜 문제는 따로 있었다
해결됐다는 안도감 직후, 다른 생각이 떠올랐습니다.
서버가 그 모든 요청을 받았다는 게 더 문제 아닌가.
같은 사용자가 1초에 같은 요청을 수십 번 보낸다면 어떤 시스템도 그걸 정상 사용으로 봐서는 안 됩니다. 오늘은 우연한 클라이언트 버그였습니다. 내일은 누군가가 의도적으로 부하를 걸 수도 있습니다. 모레는 다른 어떤 경로에서 비슷한 가드 실수가 생길 수도 있습니다.
서버가 항상 무방비로 받기만 하면, 클라이언트의 모든 작은 실수가 그대로 서버 부하가 됩니다. 한 명이 그러면 한 명만큼이지만, 그게 코드에 박혀 배포되면 모든 사용자가 동시에 같은 부하를 만듭니다.
서버에 한도를 걸다
API에 속도 제한을 도입했습니다. 한 IP가 10초 안에 180개, 1분 안에 1200개 이상의 요청을 보내면 다음 요청은 429로 거절됩니다. 정상적인 페이지 로드나 빠른 탭 전환 정도는 충분히 통과하지만, 오늘 같은 무한 반복은 결국 막힙니다.
수치는 추측입니다. 실제로 사람이 어디까지 쓰는지 측정한 적이 없으니까요. 너무 엄격하면 정상 사용자가 막히고, 너무 느슨하면 폭주를 못 잡습니다. 그래서 한 가지를 같이 넣었습니다.
429가 발생할 때마다 로그에 IP와 경로를 남기도록 했습니다. 그것도 다른 잡다한 정보 없이 에러만 파일에 쌓이도록 따로 핸들러를 만들었습니다. 한동안 운영하면서 로그를 보면, 진짜 사용자가 어디서 한도에 걸리는지 보일 겁니다. 그걸 보고 숫자를 조정하면 됩니다.
추측에서 시작해서 측정으로 옮겨가는 길을 미리 깔아 두는 것. 이게 핵심이었습니다.
두 겹의 방어
오늘 작업의 진짜 결과는 필터가 유지되는 것도, rate limit이 들어간 것도 아니었습니다. 시스템이 두 겹의 방어를 갖게 됐다는 점입니다.
클라이언트는 서버에 폭주하지 않으려고 노력하고, 서버는 폭주가 와도 받지 않으려고 노력합니다. 두 층 모두 완벽할 수는 없습니다. 클라이언트는 언제든 새로운 버그를 만들 수 있고, 서버의 숫자도 처음엔 추측에서 출발합니다. 그래도 두 층이 동시에 무너지지 않는 한 시스템 전체는 견딥니다.
오늘 깨달은 게 하나 더 있습니다. 원인을 모르겠으면 모르겠다고 말하는 게 낫습니다. 그러고 나서 안전하게 작동하는 길을 택하는 것. 그게 추측을 사실처럼 다루는 것보다 항상 빠르게 끝납니다.