파라미터를 하나씩 조이다 두 번 뚫리고, 조이기를 그만뒀다
형제 경로에 모양 검사가 없는 걸 고치러 갔다가 그게 핵심이 아니라는 걸 봤다. 캐시 키가 RequestURI 전체라 같은 응답을 가리키는 표기가 여럿이면 항목도 여럿이었다. 적중률을 포기하고 총량 상한 64MB를 걸었고, 그 뒤 리뷰가 상한 자체의 구멍을 셋 더 찾았다
공시 요약 서비스 백엔드에는 공개 GET 응답을 몇 분 물고 있는 메모리 캐시가 있다. 그날 고치러 간 건 형제 경로 하나에 빠져 있던 날짜 모양 검사뿐이었다. 붙이기 전에 재 보니, 바이트까지 똑같은 응답을 돌려주는 요청들이 캐시에 따로따로 쌓이고 있었다.
하려던 것
공시 요약 서비스 백엔드에는 공개 GET 응답을 몇 분 물고 있는 메모리 캐시가 있다. 같은 요청이 몰릴 때 DB까지 안 가게 하려고 둔 것이다.
그날 고치러 간 건 작은 거였다. 하루 집계 경로에는 날짜 모양 검사가 있는데 형제 경로인 ?hasSummary=true&rceptDt= 쪽에는 없었다. 거기도 같은 검사를 붙이면 되는 일로 보였다.
어떻게 시켰나
붙이기 전에 재 보라고 시켰다. 파라미터를 하나 조일 때마다 항목이 얼마나 줄어드는지 숫자로 보고 싶었다. 로컬 서버를 띄우고 변형 요청을 넣으면서 캐시 항목 수와 응답 크기를 같이 찍게 했다.
그 표가 계획을 엎었다.
?hasSummary=true&limit=20 / 21 / 99999 / 020 → 전부 동일한 12,562B, 항목 4개
?hasSummary=true&limit=20&zzz=1 / zzz=2 → 동일 12,562B, 항목 2개
?hasSummary=true&limit=20&rceptDt=lolwat → 3B, 별개 항목
limit은 핸들러가 어차피 20으로 깎는다. 그런데도 네 개의 표기가 네 개의 항목이 됐다. 존재하지도 않는 zzz를 붙여도 항목이 하나 더 생겼다.
어디서 막혔나
캐시 코드를 열어 보니 두 줄이 서로 다른 걸 보고 있었다.
// 룰 매칭은 decoded 경로로
r.URL.Path
// 키는 raw 문자열 전체로
key := r.URL.RequestURI()
매칭은 의미로 하고 저장은 표기로 했다. 그러면 같은 응답을 가리키는 표기의 수만큼 항목이 생긴다. 표기의 가짓수는 사실상 무한하다.
그러니까 내가 고치러 간 rceptDt 검사는 핵심이 아니었다. 비싼 쪽은 12.5KB짜리 목록을 만들어 내는 limit과 미지 파라미터였고, rceptDt=lolwat은 3바이트짜리 빈 배열이었다. 제일 싼 구멍을 막으러 간 셈이다.
더 나쁜 건 이 방식의 수명이었다. 파라미터를 하나씩 조이는 방어는 새 파라미터가 생길 때마다 다시 뚫린다. 그리고 이미 두 번 그랬다. 직전 PR에서만 두 번이다. 한 번은 같은 값을 여러 표기로 쓸 수 있어서였고, 한 번은 검증이 값의 일부만 보고 통과시켜서였다.
세 번째 방어를 같은 방식으로 짜는 건 세 번째로 뚫리겠다는 뜻이었다.
어떻게 풀었나
조이기를 그만두고 담는 쪽에 상한을 걸었다. 저장소 총량 64MB. 넘으면 만료된 걸 먼저 쓸고, 그래도 넘으면 임의 항목을 버려서 4분의 3까지 내린다. 회계는 원자적 카운터로 하고 만료 삭제와 덮어쓰기에서 더하고 빼는 짝을 맞춘다.
LRU를 안 쓴 이유는 커밋에 이렇게 적었다.
담기는 게 전부 공개 GET이고 TTL이 1~10분이라 밀린 항목은 다음 요청에 다시 채워진다. 여기서 지키려는 건 적중률이 아니라 메모리 상한이다.
바꿔 든 위험도 같이 적었다. 이제 잡음이 정상 항목을 밀어낼 수 있고 그동안 정상 요청은 DB로 간다. 그래도 서비스가 죽는 것보다 느려지는 게 낫다.
그리고 그 위험 평가를 그날 안에 한 번 정정했다. 한 IP로는 이 상한에 못 닿는다. 읽기 레이트리밋이 절반 이상을 막아서 정상 상태가 45MB 언저리다. 잡음 100건을 넣으면 56건이 429로 튕긴다. 상한이 실제로 무는 건 여러 IP이거나 큰 응답이 섞일 때다. 그래도 두는 이유는 상한이 없으면 그 45MB가 IP 수에 비례해 늘어나기 때문이다.
여기까지가 내가 짠 것이고, 진짜는 그다음이었다. 리뷰가 상한 자체에서 P1을 셋 찾았다.
첫째, 담기려는 항목이 상한 밖에 있었다. 크기를 먼저 더하고 나서 축출을 부르는데, 축출은 아직 저장소에 없는 그 항목을 지울 수 없다. 응답 하나가 혼자 상한을 넘으면 저장소를 통째로 비우고도 그게 들어간다. 단일 항목 상한 1MB를 따로 뒀다. 근거는 정상 최대가 사이트맵 하루치 210KB라 다섯 배 여유이고, 총량의 64분의 1이라 동시에 여러 개가 겹쳐도 초과폭이 작다는 것이었다.
그 전제를 캐다가 진짜 문제가 나왔다. 날짜 범위 조회 분기에 LIMIT이 없었다. 공개 익명 경로인데 범위를 비워서 부르면 테이블 전체가 한 응답으로 나온다. 로컬에서 62,024행이 17.7MB였고 서버 RSS가 141MB까지 갔다. 운영 341,593행 기준이면 97MB고, 그게 캐시에 1분 상주한다. 형제 분기는 전부 상한이 있었다. 여기만 없었다. 기본 100, 상한 1,000으로 막으니 17.7MB가 30KB가 되고 RSS 141MB가 24MB가 됐다.
둘째, 지운 것과 뺀 것이 달랐다. 만료된 같은 키를 두 요청이 함께 읽고 하나가 새 값을 넣으면, 늦은 쪽이 그 새 값을 지우면서 회계에서는 자기가 읽은 옛 값 크기를 뺀다. 쌓이면 상한이 서서히 무의미해진다. 내가 본 값일 때만 지우는 연산으로 바꿨다. 이건 테스트로 재현했다. 고루틴 64개가 300회씩, TTL을 1마이크로초로 벌려서 세 번 중 세 번 드리프트가 났다.
셋째, 하나는 묶었는데 합을 안 묶었다. 서로 다른 키의 미스가 동시에 저장 구간에 들어오면 각자 축출을 마치고 각자 넣어서 합이 상한을 넘을 수 있다. 순서를 뒤집고 그 구간만 직렬화했다. 넣고 나서 재면 축출이 방금 넣은 것도 지울 수 있고, 캐시라서 그래도 상관없다.
이 셋째 건에서 에이전트가 한 게 마음에 들었다. 고쳐 놓고 커밋에 이렇게 적었다.
정직하게: 이 결함은 테스트로 재현하지 못했다. 고치기 전 코드로도 테스트가 통과한다.
논리적으로는 성립하는데 공개 API로는 그 순서를 구성할 수 없었다는 것이다. 그래서 고친 이유는 테스트가 잡아서가 아니라 불변식이 구조적으로 성립하게 하려는 것이고, 이 테스트는 재현 테스트가 아니라 회귀 가드라고 주석에 적어 뒀다.
상한 자체의 검증도 솔직하게 남았다. 단위 테스트에서는 900건 약 90MB를 밀어 넣어 572개 56.1MB로 잘리는 걸 확인했다. 하지만 로컬 서버 종단 간으로는 상한을 재현하지 못했다. 레이트리밋이 절반 이상을 막아서 6,000건을 넣어도 실제 적재가 33MB였다. 그래서 상한의 근거는 미들웨어 단위 테스트라고 커밋에 못 박았다.
배운 점
- 고치러 간 것과 고쳐야 할 것은 다를 수 있다. 모양 검사를 붙이러 갔다가 키 설계가 문제라는 걸 봤다. 붙이기 전에 재 본 덕이다.
- 같은 방어를 세 번째 짜고 있으면 방어가 아니라 설계가 틀린 것이다. 파라미터 조이기는 두 번 뚫렸고 세 번째도 뚫릴 예정이었다.
- 매칭과 저장이 다른 걸 보면 안 된다. 룰은 의미로 고르고 키는 표기로 만들면 항목이 표기 수만큼 생긴다.
- 지키려는 게 적중률인지 상한인지 먼저 정한다. 정하고 나면 LRU를 안 쓰는 게 태만이 아니라 선택이 된다.
- 상한을 걸면 상한 자체가 새 표면이 된다. 담기 전 항목, 회계 짝, 동시 삽입의 합. 셋 다 리뷰가 찾았다.
- 재현 못 한 결함은 재현 못 했다고 적는다. 그래야 다음 사람이 그 테스트를 재현 테스트로 착각하지 않는다.