들어가며
평균 CPU는 31%, 응답시간은 평소의 절반이었어요. 그런데 서버만 계속 늘어났어요.
세 줄 요약
- 푸시 한 번에 서버가 2대에서 15대로 늘었는데, 서버는 하나도 안 힘들었어요. 응답시간은 오히려 절반이 됐고요. 15대 중 8대까지는 인프라팀이 푸시에 대비해 미리 올린 것이고, 나머지 7대를 오토스케일링이 늘렸어요.
- 페이지 한 번 여는 데 요청이 36건, 그중 34건이 그냥 빌드된 JS·CSS 파일이었어요. 그게 전부 서버까지 오고 있었어요.
- CDN 캐시를 켜서 오리진 트래픽을 96.4% 줄였어요. 대신 평균 응답시간 지표가 2.5배 나빠졌어요.
23분 동안 스케일아웃 알람이 7번 울렸어요
8월 12일 오후, 콘텐츠 푸시가 나갔어요. 평소엔 컨테이너 2개로 도는 서비스인데, 이날은 인프라팀이 푸시에 대비해 컨테이너를 8개까지 미리 올려 둔 상태였어요. 그 위에서 20분 뒤부터 오토스케일링이 움직이기 시작했어요. 아래 로그는 오토스케일링이 늘린 구간만 담고 있어요.
16:15 8 → 9 ┐
16:17 9 → 10 │
16:18 10 → 11 │ 23분 동안
16:26 11 → 12 │ 스케일아웃 알람 7번 연속
16:29 12 → 13 │
16:37 13 → 14 │
16:38 14 → 15 ┘ ← 최대 15대늘어난 건 컨테이너예요. 15개까지 갔고, 그 컨테이너들이 올라가는 실제 장비(인스턴스)도 9대에서 13대까지 따라 늘었어요. 다시 평소대로 컨테이너 2개가 되기까지 72분이 걸렸어요.
여기까지는 "트래픽 몰려서 서버 늘었네" 하고 넘어갈 일이에요. 그런데 같은 시간의 지표를 보고 손이 멈췄어요.
| 시각 | 요청 (분당) | 평균 응답시간 |
|---|---|---|
| 15:00 (평시) | 1,720 | 12.9 ms |
| 15:45 | 1,622 | 12.9 ms |
| 16:15 | 23,395 | 6.1 ms |
| 16:30 (피크) | 25,601 | 6.1 ms |
| 17:00 | 9,607 | 6.5 ms |
요청이 15배로 뛰었는데 응답시간은 절반이 됐어요.
CPU도 이상하긴 마찬가지였어요. 컨테이너가 15개까지 늘어난 구간에서도 평균 CPU는 31~35% 수준으로 여유가 있었어요.
서버가 안 힘든데 서버가 늘었어요. 그리고 서버가 늘 만큼 바쁜 순간에 응답이 더 빨라졌어요.
둘 다 말이 안 되죠. 이상하다고 넘길 수도 있었는데, 두 개가 동시에 이상한 게 걸렸어요. 하나가 고장 나면 둘이 같이 이상해지진 않아요. 보통은 둘 다 같은 원인이에요.
원인 1: 부하가 아니라 요청 개수를 세던 스케일링 정책
제일 먼저 스케일링 정책을 열었어요.
Metric ALBRequestCountPerTarget
TargetValue 3000 ← 컨테이너 1개당 분당 3,000건
Min / Max 2 / 20CPU도, 메모리도, 응답시간도 아니었어요. 요청 개수 하나였어요.
숫자를 맞춰보면 딱 떨어져요. 16시 15분, 컨테이너 8개에 분당 요청 23,395건.
23,395 ÷ 8 = 2,924 ≈ 임계치 3,000 → 스케일아웃 시작
25,601 ÷ 15 = 1,707 임계치 아래 → 16:58부터 스케일인정책은 고장 난 게 아니었어요. 시킨 대로 정확히 했어요.
버그를 찾을 게 아니었어요. 무엇을 세고 있는지를 물어야 했어요. 그래서 그 "요청 25,601건"이 뭔지 열어봤어요.
원인 2: 요청 36건 중 34건이 정적 파일
푸시가 향한 페이지를 브라우저로 열어서 네트워크 요청을 하나씩 셌어요.
| 종류 | 건수 |
|---|---|
_next/static/* (JS 30 · CSS 3 · 폰트 1) | 34건 |
| HTML 문서 | 1건 |
| 설정 API | 1건 |
| 합계 | 36건 |
36건 = _next/static 34건 + HTML · API 2건
^^^^^^^^^^^^^^^^ 94%가 그냥 빌드된 JS·CSS 파일제 브라우저에서 한 번 열어본 값이라 실제 사용자와 다를 수 있어요. 그래서 실제 사용자 브라우저에서 수집되는 RUM 데이터로도 확인했어요. 최근 하루 기준 페이지 뷰 1,159건에 리소스 요청 81,563건이었어요. 뷰 하나당 70건이고, 그중 JS·CSS·폰트가 34,423건이에요. 뷰당 약 30건. 한 번 세본 34건과 거의 같아요.
사용자마다 다르지도 않고, 서버가 계산할 것도 없는 파일이 대부분이었어요.
응답시간이 빨라진 이유도 여기 있었어요
그 25,601건은 제일 싸고 빠른 요청이었어요. 파일을 그냥 내려주기만 하는 요청이 평균을 아래로 끌어내린 거예요.
응답시간이 빨라진 것도, 서버가 늘어난 것도 원인이 같았어요. 정적 파일이 요청 수를 부풀려서 서버를 늘렸고, 같은 정적 파일이 평균 응답시간을 눌러서 서버를 안 힘들어 보이게 했어요.
임계치 3,000건은 사람으로 치면 83명이었어요
임계치를 사람 수로 바꿔보면 이렇게 돼요.
3,000 ÷ 36건 ≈ 컨테이너당 분당 83 페이지뷰분당 83명만 넘어도 서버가 늘어나요. 서버가 힘들어서가 아니라, 숫자가 커서요.
원인 3: CDN이 앞에 있는데 캐시가 꺼져 있었어요
CDN은 앞에 있었어요. 그런데 캐시 정책이 CachingDisabled였어요. 엣지(CDN이 사용자 가까이에 두는 캐시 서버)를 그냥 통과해서 34건 전부 서버까지 들어오고 있었어요.
| 오리진 요청 | CDN이 사용자에 보낸 양 | |
|---|---|---|
| 8/18 | 249만 건 | 254만 건 |
두 숫자가 거의 같아요. CDN이 사용자에게 내보낸 254만 건 중 249만 건을 다시 서버에 물어봤다는 뜻이에요.
엣지가 걸러준 게 사실상 없어요. CDN이 없는 거나 마찬가지였어요.
새 데이터보다 먼저 의심할 것: 지표의 집계 단위
이 조사에서 제일 여러 번 써먹은 방법은 새 데이터를 찾는 게 아니었어요. 이미 있던 데이터를 더 짧은 시간 간격으로 다시 보는 것이었어요.
CDN 지표는 시간 단위로만 찍혀 있었어요. 그것만 보면 나중에 CDN 캐시를 켰을 때(8월 19일) 설정 반영 시각과 트래픽이 꺾인 시각이 아홉 시간이나 벌어져 보였어요. 하마터면 "관측 도구가 부족해서 못 본다"로 결론 낼 뻔했어요.
그런데 같은 구간을 로드밸런서 지표에서 15분 단위로 다시 봤더니, 꺾인 지점이 설정 반영 시각과 3분 차였어요. 데이터가 없었던 게 아니라, 1시간 단위로 뭉쳐 보느라 안 보였던 거예요.
"볼 데이터가 없다"와 "너무 큰 단위로 뭉쳐서 봤다"는 전혀 다른 문제예요. 모니터링 도구를 새로 붙이기 전에, 지금 지표를 몇 분 단위로 보고 있는지부터 확인해보세요.
해결: 범위를 좁혀서 CDN 캐시 켜기
원인이 세 개였지만, 손댈 곳은 하나였어요.
| 원인 | 손댈 수 있나 |
|---|---|
| 스케일링 정책이 요청 개수 기준 | 임계치만 올리면 증상만 가려요 |
| 요청 36건 중 34건이 정적 파일 | 프레임워크가 만드는 거라 줄일 수 없어요 |
| CDN 캐시가 꺼져 있음 | 여기를 켜면 34건이 서버까지 안 와요 |
정적 파일을 없앨 수 없다면, 서버까지 오지 못하게 막으면 돼요. 남은 건 엣지 하나였어요.
다만 CDN 설정은 우리 손 밖이라 다른 팀과 함께 가야 했어요. 그래서 요청서에 공을 들였어요. 가장 신경 쓴 건 범위를 최소로 좁히는 것이었어요.
경로 /_next/static/* ← 딱 이 경로만
캐시 정책 CachingOptimized 또는 동등
캐시 키 쿠키 · Authorization 제외
TTL 오리진 헤더 그대로 (max-age=31536000 = 1년, immutable)바꾸지 않을 것도 같이 적었어요.
- 그 외 경로 — 지금처럼
CachingDisabled그대로 - HTML, API — 사람마다 다른 데이터라 계속 캐시 금지
캐시는 잘못 켜면 남의 데이터가 남에게 보이는 사고가 나요. 그래서 "켜도 안전한 이유"를 세 개로 나눠 증명했어요.
| 걱정 | 근거 |
|---|---|
| 사용자마다 다른 파일이 아닐까? | 로그인 안 한 상태로 만든 로컬 빌드와 운영 파일의 SHA256이 같아요 |
| 배포하면 옛 파일이 엣지에 남지 않을까? | 파일 이름에 콘텐츠 해시가 붙어서 무효화 없이도 안 남아요 |
| 옛 HTML이 옛 청크를 붙잡지 않을까? | HTML은 no-store예요 |
첫 줄이 핵심이에요. "정적 파일이니까 괜찮아요"라고 쓰지 않고, 로그인 안 한 로컬 빌드와 운영 파일의 해시를 직접 맞춰봤어요. 캐시를 켜달라는 쪽에서 상대가 가장 알고 싶은 게 그거니까요.
기대 효과도 숫자로 냈어요.
서버로 오는 요청 36건 → 약 4건 (HTML 1건 + API 호출 약 3건)
서버가 늘기 시작하는 지점 분당 83 → 약 750 페이지뷰가시적인 성과: 오리진 트래픽 96.4% 감소
8월 19일 15시 42분 44초, 설정이 반영됐어요. 로드밸런서를 15분 단위로 찍으면 그 시점이 그대로 보여요.
| 시각 | 오리진 요청 (15분) |
|---|---|
| 15:15 | 32,329 |
| 15:30 | 26,672 |
| 15:42:44 | ← 설정 반영 |
| 15:45 | 6,371 |
| 16:00 | 6,745 |
3분 만에 −76%. 설정 하나에 오리진 요청 4분의 3이 사라졌어요.
사용자가 받는 양은 그대로예요. CDN이 대신 내보내기 시작한 것뿐이에요.
| 날짜 | CDN → 사용자 | 오리진 → CDN | 엣지가 대신 처리 (전송량 기준) |
|---|---|---|---|
| 8/18 (전) | 162.1 GB / 254만 건 | 166.6 GB / 249만 건 | 0% |
| 8/23 (후) | 108.8 GB / 163만 건 | 5.95 GB / 28.5만 건 | 95% |
하루 오리진 전송량으로는 166.6GB → 5.95GB, −96.4%.
스케일아웃은 17일간 0건
요청 수 알람이 울려서 서버가 자동으로 늘어난 마지막 기록은 8월 12일 16시 38분이에요. 그 뒤 17일간 자동 증설은 0건이에요. 인프라팀이 푸시 전에 서버를 미리 올리는 사전 증설은 이 숫자와 별개로 계속 이루어지고 있어요.
| 분당 타겟당 요청 (최대) | 임계 3,000 대비 | |
|---|---|---|
| 8/18 (캐시 전) | 2,445 | 82% |
| 8/25 | 313 | 10% |
| 8/29 | 693 | 23% |
8/29가 8/25보다 두 배 넘게 높지만, 임계치의 23% 수준이라 스케일아웃까지는 아직 한참 멀어요.
개선했는데 평균 응답시간이 2.5배 늘어난 이유
| 날짜 | 평균 | p50 (가운데) | p99 (느린 쪽 1%) |
|---|---|---|---|
| 8/18 (전) | 15.7 ms | 2.9 ms | 191 ms |
| 8/23 (후) | 38.9 ms | 4.4 ms | 445 ms |
요청을 느린 순서로 줄 세웠다고 생각하면 쉬워요. p50은 딱 가운데 있는 요청이라 "보통 사람이 겪는 속도"에 가깝고, p99는 뒤에서 1%에 해당하는 요청이라 "제일 느린 사람이 겪는 속도"에 가까워요. 평균 하나만 보면 이 둘이 각각 어떻게 움직였는지가 안 보여요.
2.5배 느려졌어요. 처음엔 사고인가 했어요. 아니었어요.
캐시가 걷어간 건 JS·CSS 파일 요청이에요. 파일을 그냥 내려주는, 제일 빠르고 싼 요청. 서버가 받던 요청 전체로 보면 88%가 그런 요청이었어요. (앞의 94%는 페이지 한 번 열 때 기준이고, 이건 서버가 실제로 받은 요청 기준이에요.) 그 88%가 사라지고 남은 건 HTML 렌더링과 API 호출이에요. 원래부터 느렸던 요청들이죠.
평균이 나빠진 게 아니라, 평균에 들어가는 요청이 바뀐 거예요.
8월 12일에 응답시간이 12.9ms에서 6.1ms로 빨라졌던 것과 정확히 같은 현상이, 방향만 반대로 일어난 거예요. 값싼 요청이 평균을 눌러주고 있었던 거죠.
사용자가 겪는 속도는 나빠지지 않았어요. 오히려 엣지에서 받으니 빨라졌어요. 다만 하나는 꼭 챙겨야 해요.
캐시를 켠 다음에는 알람 임계치를 다시 잡아야 해요. 안 그러면 개선한 날 알람이 울려요.
조사하다 함께 고친 문제 두 가지
서버가 늘어난 이야기는 여기서 끝이에요. 아래 둘은 원인을 파고들다 딸려 나온 것들이에요. 시간 순서로는 오히려 앞이고요. CDN 설정을 다른 팀에 요청해두고 기다리던 8월 12일에서 19일 사이에, 우리가 바로 할 수 있어서 먼저 고쳤거든요.
성격은 서로 달라요. 파비콘은 같은 오리진 트래픽 문제의 작은 갈래였어요. 다만 요청 개수가 아니라 바이트 쪽이라, 서버가 늘어난 것과는 상관이 없었고요. 청크 로드 에러는 같은 배포에서 나온 별개의 UX 문제였어요.
279KB 파비콘, 캐시 규칙에서 '제외'되어 있었어요
정적 파일을 걷어내고 남는 몇 건을 보다가 파비콘을 열었어요. 279KB였어요. 트레이스는 더 이상했고요.
콘텐츠 상세 페이지 121건
favicon.ico 212건 ← 페이지보다 많음같은 사람이 페이지 한 번 보는 동안 파비콘을 두 번 받아 가고 있었어요.
선택지가 둘이었어요. 디자인팀에 새 파비콘을 요청하거나, 기존 파일을 손보거나.
후자로 갔어요. .ico는 여러 사이즈의 아이콘을 한 파일에 묶어두는 형식이에요. 원하는 아이콘만 빼내고 헤더에 적힌 위치 정보를 다시 계산하면 나머지는 그대로 살아 있거든요.
안을 열어보니 이랬어요.
| 사이즈 | 크기 | 비중 |
|---|---|---|
| 48×48 | 9,640 B | 3% |
| 32×32 | 4,264 B | 1% |
| 16×16 | 1,128 B | 0% |
| 256×256 | 270,376 B | 95% ← |
256×256은 Windows 바로가기용이에요. 브라우저 탭은 16×16만 써요. 게다가 4개 전부 BMP 무압축이었어요. 256 × 256 × 4바이트 = 262,144. 계산이 딱 맞아요.
이걸 빼서 285,478 → 15,086 bytes, 94.7% 감소. 이미지를 다시 저장한 게 아니라 잘라내기만 한 거라 화질이 그대로예요.
그래도 "손실이 없다"를 믿지 않고, 남긴 3개의 픽셀 데이터를 바이트 하나하나 원본과 맞춰봤어요.
16x16: 동일 / 32x32: 동일 / 48x48: 동일
file → MS Windows icon resource - 3 icons, 16x16, 32x32PNG 재인코딩은 일부러 안 했어요. 5KB까지 더 줄일 수 있는데, 변환 중 알파 채널이 깨질 위험이 있었거든요. 10KB 아끼려고 아이콘을 깨뜨릴 이유는 없어요.
캐시 규칙을 "제외"에서 "명시"로
121 : 212가 나온 이유는 캐시 헤더였어요.
source: '/((?!_next/static|_next/image|favicon.ico).*)'
// ^^^^^^^^^^^ 제외되어 있음제외는 "캐시하라"가 아니에요. "여기서는 아무 말 안 하겠다"는 뜻이에요.
아무 말 안 하면 프레임워크 기본값(max-age=0, must-revalidate)이 들어와요. ETag도 Last-Modified도 없었어요. 이 둘이 있으면 브라우저가 "나 이거 갖고 있는데 바뀌었어?"라고 묻고, 서버는 "안 바뀌었어"(304) 한 줄만 보내고 끝낼 수 있어요. 그걸 못 하니 매번 279KB를 통째로 다시 받았어요.
그래서 제외를 빼고 명시로 바꿨어요.
{
// 파비콘은 URL에 콘텐츠 해시가 붙어 서빙되므로 장기 캐시가 안전
source: '/favicon.ico',
headers: [{ key: 'Cache-Control', value: 'public, max-age=86400' }],
},"장기 캐시가 안전하다"도 실측했어요. 파일을 바꾸면 URL도 정말 같이 바뀌는지 직접 확인해봤어요.
변경 전: favicon.0ur~hhr_0vq~d.ico
변경 후: favicon.07ewdv7fu3ozd.ico ← 해시가 바뀜캐시를 어떻게 갈아치울지 확인한 다음에 캐시를 켰어요.
프레임워크가 자기 헤더로 덮어쓸까 봐 걱정했어요. 빌드 결과물엔 실제로 max-age=0이 남아 있었거든요. 그런데 응답을 찍어보니 우리 설정이 이기더라고요. 문서를 추측하는 것보다 응답을 한 번 찍어보는 게 빨랐어요.
정규식 앵커 $ 한 글자
파비콘을 트레이스에서 빼두는 코드는 이미 있었어요. 그런데 실제 경로가 이랬어요.
/favicon.ico?favicon.0ur~hhr_0vq~d.ico
^^^^^^^^^^^^^^^^^^^^^^^^^ 쿼리스트링메타데이터 파일에 콘텐츠 해시가 쿼리로 붙는데, 정규식이 /\/favicon\.ico$/ — $로 끝나 있었어요. ?가 붙으니 매칭이 안 됐어요.
- /\/favicon\.ico$/
+ /\/favicon\.ico(\?|$)/이 한 글자를 고치고 나서야 212건이 보였어요. 트레이스에서 빼두려던 코드가 동작하지 않은 덕분에 파비콘 문제를 발견한 셈이에요.
여기서 진짜 무서운 건 너무 많이 막는 것이에요. 느슨하게 풀면 엉뚱한 요청까지 트레이스에서 사라지고, 그러면 장애가 나도 볼 게 없어요. 그래서 반대 방향도 테스트했어요. 파비콘(쿼리 포함)은 차단, 일반 페이지·API는 추적 유지.
"막고 싶은 게 막혔나"만 보면 절반이에요.
배포할 때마다 나던 흰 화면, 청크 로드 에러
그 주에만 배포가 여덟 번 나갔는데, 배포할 때마다 화면이 하얘진다는 얘기가 있었어요. 원인은 이랬어요.
사용자: 화면 열어둠
↓
배포 나감 → 청크 파일 이름 전부 바뀜 (콘텐츠 해시)
↓
사용자: 스크롤 → 지연 로드 청크 요청
↓
404 → ChunkLoadError → 하얀 화면Next.js error.tsx가 주는 reset()은 컴포넌트를 다시 렌더할 뿐이에요. 그런데 청크는 오리진에서 사라진 파일이에요. 다시 렌더하면 사라진 파일을 또 요청하고, 또 실패해요.
// 청크 실패는 reset()이 사라진 청크를 다시 요청해 복구되지 않는다.
const handleAction = isChunkError ? () => window.location.reload() : reset브라우저를 통째로 새로고침해야 새 HTML을 받고, 새 HTML이 새 청크 이름을 가리켜요. 그래서 문구도 바꿨어요.
❌ 오류가 발생했어요 / 다시 시도
✅ 새 버전이 배포됐어요
새로고침하면 이어서 이용할 수 있어요. [ 새로고침 ]에러 화면이 아니라 안내 화면이에요. 사용자 잘못도, 서버 장애도 아니니까요.
번들러·브라우저마다 다른 에러 메시지
error.name === 'ChunkLoadError' 하나로는 부족했어요.
const CHUNK_ERROR_MESSAGE_PATTERNS = [
/Failed to load chunk/i, // Turbopack
/Loading chunk \S+ failed/i, // webpack
/Loading CSS chunk \S+ failed/i, // webpack (CSS)
/Failed to fetch dynamically imported module/i, // Chrome / Edge
/error loading dynamically imported module/i, // Firefox
/Importing a module script failed/i, // Safari
] as const여기서도 위험한 건 너무 넓게 잡는 것이에요. 패턴이 헐거우면 그냥 인터넷이 끊긴 사람한테까지 "새 버전이 배포됐어요"라고 안내하게 돼요. 그래서 패턴은 좁게 유지하고, 프레임워크가 페이지를 통째로 다시 열어서 스스로 복구하는 경우는 아예 뺐어요.
// 프레임워크가 자체 복구하는 경로라 새로고침 안내 대상이 아니다.
const NEXT_SELF_RECOVERED_PATTERN = /Failed to fetch RSC payload/i34개 페이지를 감싼 HOC
처음엔 라우트 그룹 5곳에 공통 error.tsx를 깔았어요. 그런데 페이지마다 자기 error.tsx가 있으면 그게 먼저 잡아요. 그래서 두 번째로 34개 페이지를 다 돌았어요.
화면 안에서 분기하면 훅이 조건부 호출되니까, 바깥에서 감싸는 HOC로 갔어요.
export const withChunkErrorFallback = (ErrorComponent, componentName) => (props) => {
if (isChunkLoadError(props.error)) {
return <AppErrorFallback {...props} componentName={componentName} />
}
return <ErrorComponent {...props} /> // 그 외 에러는 기존 화면 그대로
}청크 에러만 가로채고 나머지는 건드리지 않는 것. 34개를 한 번에 바꾸면서 지킨 유일한 규칙이었어요.
실제로 얼마나 발생하던 문제였나
RUM에 남은 청크 로드 에러는 3주 동안 10건, 세션 8개예요. 그중 하나가 8월 12일 16시 51분, 푸시가 향한 바로 그 페이지에서 났어요.
많은 숫자는 아니에요. 그리고 고친 뒤에도 계속 잡혀요. 당연한 게, 이 수정은 에러를 없애는 게 아니라 에러 다음에 오는 화면을 바꾸는 거니까요. 흰 화면 대신 안내가 뜨고, 새로고침하면 이어져요.
숫자가 작은 데는 이유가 하나 더 있어요. 이 에러는 에러 화면이 RUM으로 리포트하는 페이지에서만 집계돼요. 측정 코드가 안 붙은 페이지에서 난 건 애초에 세어지지 않아요. 10건은 최대치가 아니라 최소치예요.
총 정리
1. 값이 아니라 '무엇의 평균인지'를 먼저 보기
같은 함정에 3주 동안 세 번 빠졌어요.
8/12 12.9ms → 6.1ms 좋아졌는데, 사고 신호였고
8/19 15.7ms → 38.9ms 나빠졌는데, 개선의 결과였고
8/26 ??? 세 번째세 번째는 이랬어요. AI 정비 상담 기능을 붙였어요. 붙인 날 저녁부터 서버 평균 응답시간이 올라갔어요.
| 구간 | 일 평균 응답시간 |
|---|---|
| 기능 도입 전 8일간 | 199 ~ 271 ms |
| 도입 당일 | 241 ms |
| 다음 날 | 328 ms |
| 그다음 날 | 287 ms |
표에 있는 건 평균뿐인데, p50과 p99를 따로 떼어 보니 가운데 있는 요청(p50)은 그대로였고 느린 쪽 1%(p99)만 올라갔어요. 일부 요청만 아주 느려졌다는 뜻이에요.
후보 두 개를 먼저 지웠어요. 며칠 전에 서버 렌더링으로 바꾼 페이지가 의심스러웠는데, 그 라우트는 열흘 내내 39~55ms로 일정했어요. 백엔드가 느려졌나 싶어 서버가 외부로 보내는 API 호출도 봤는데 43~50ms로 역시 일정했고요. 둘 다 아니었어요.
남은 건 새로 생긴 엔드포인트 하나였어요.
하루 호출량 272 → 612 → 661건
건당 평균 10.2초 ← 스트리밍 LLM 응답
요청 비중 전체의 0.31%
시간 비중 전체 서버 시간의 10.9%이 하나를 평균에서 빼봤어요.
241ms → 227ms
328ms → 295ms
287ms → 257ms ← 전부 원래 범위(199~271ms) 안팎요청 300건 중 1건이 전체 평균을 30ms 올린 거예요. 그리고 그 1건은 고장 난 게 아니라, 원래 10초 걸리는 게 정상인 요청이었어요.
세 번 다 값이 바뀐 게 아니라 평균에 들어가는 요청이 바뀐 거였어요. 정적 파일이 들어와서, 나가서, 그리고 10초짜리 요청이 새로 생겨서.
트래픽 구성이 바뀌는 작업을 할 땐 배포 전에 알람 임계치를 같이 계획하세요. 특히 응답시간이 기존보다 수십 배 긴 엔드포인트를 새로 붙일 때는요.
2. "제외"와 "캐시하라"의 차이
캐시 규칙에서 경로를 빼는 건 캐시를 켜는 게 아니라 아무 말도 안 하는 것이에요. 그러면 프레임워크 기본값이 들어와요.
그리고 캐시를 켤 땐 "파일이 바뀌면 새 걸 어떻게 받게 하지?"에 먼저 답하세요. max-age=86400이 안전한 이유는 하루가 짧아서가 아니라, 파일이 바뀌면 URL이 바뀌기 때문이에요.
3. 쿼리스트링을 모르는 정규식 앵커 $
/foo$/는 /foo?bar를 못 잡아요. blocklist·allowlist·라우팅 매처 어디서든 같은 함정이에요.
이 버그는 에러를 내지 않아요. 조용히 안 걸릴 뿐이죠. 옆줄이 (\?|$)로 되어 있으면, 누가 이미 같은 데 빠져봤다는 신호예요.
4. 캐시를 남에게 켜달라 할 때 필요한 증명
"정적 파일이니까 괜찮아요"는 근거가 아니에요. 로그인 안 한 로컬 빌드와 운영 파일의 SHA256을 맞춰본 것이 근거예요.
그리고 바꾸지 않을 것을 같이 적으세요. 범위가 좁을수록 설득이 쉽고 사고 위험도 낮아요.
대신 좁힌 만큼 남는다는 것도 알고 있어야 해요. 우리는 _next/static 하나만 켰어요. 그래서 파비콘은 브라우저 캐시까지만 듣고 첫 방문자는 여전히 오리진까지 오고, 목록 페이지의 이미지 12장도 아직 엣지를 그냥 통과해요. 범위를 좁힌 건 실수가 아니라 선택이고, 남은 건 다음 차례예요.