TTFB 6ms 서버에 캐시 레이어는 필요 없었다. FilesMatch가 프록시 응답에 안 먹는다는 걸 몰랐을 뿐
Redis를 넣으려다 재보고 안 넣었다 — 진짜 범인은 캐시 헤더였다 개인 서버에 사이트를 열 개쯤 올려두고 쓴다. 전부 Node로 돌리고 앞에는 Apache를 리버스 프록시로 세워뒀다. 어느 날 문득 "이거 좀 더 빠르게 못 하나" 싶었다. 제일 먼저 떠오른 건 Redis였다. 캐시 레이어를 하나 두면 뭔가 빨라질 것 같았다. 로드밸런서도 잠깐 생각했다. 그런데 넣기 전에 일단 재보기로 했다. 결론부터 말하면 Redis는 안 넣었다. 넣을 자리가 없었다. 진짜 원인은 완전히 다른 데 있었고, 고치는 데는 설정 파일 하나면 됐다. 일단 재보자 먼저 서버가 실제로 느린지부터 봤다. $ curl -o /dev/null -w "%{time_starttransfer}\n" https://내도메인/ 0.006 TTFB 6ms. 사이트 열 개를 다 재봐도 3~6ms 사이였다. 서버가 응답을 시작하기까지 걸리는 시간이 이 정도면 여기서 더 줄일 게 없다. DB도 봤다. 가동 201시간, 총 쿼리 146,608건 (초당 0.2) 슬로우 쿼리 0건 InnoDB 버퍼풀 적중률 99.98% 초당 0.2쿼리. 슬로우 쿼리는 하나도 없고, 버퍼풀 적중률이 99.98%라 디스크를 거의 안 읽고 메모리에서 다 처리하고 있었다. load average는 0.18이었다. Redis는 "DB가 느려서 결과를 메모리에 들고 있어야 할 때" 쓰는 물건이다. 그런데 MySQL이 이미 메모리에서 다 처리하고 있고 부하 자체가 없었다. 여기에 Redis를 붙이면 프로세스 하나, 관리 포인트 하나, 캐시 무효화 버그 가능성 하나가 늘어날 뿐이다. 체감 속도는 0ms 빨라진다. 로드밸런서도 마찬가지였다. 로드밸런서는 백엔드 서버가 여러 대일 때 트래픽을 나눠주는 물건인데, 우리 집 서버는 한 대다. 분산할 대상이 없는데 홉만 하나 늘고 새 단일 장애점이 생긴다. 그 한 대가 죽으면 로드밸런서가 보낼 곳도 없다. 그럼 뭐가 느린가 서버가 아니면 브라우저 쪽이다. 자산이 어떻게 내려가는지 봤다. $ curl -I https://내도메인/assets/react-dom-0e8399ab.js cache-control: public, max-age=0, max-age=2592000 etag: W/"1fb72-1a084ad10cc" 이상하다. max-age가 두 번 들어가 있다. 그것도 0이 먼저. 중복 지시어를 만나면 브라우저는 대개 첫 번째 값을 따른다. 즉 파일명에 해시가 박힌 빌드 산출물인데도 매번 재검증을 하고 있었다. ETag 덕에 304로 끝나긴 하지만 왕복 자체는 그대로 발생한다. 다른 사이트도 다 봤더니 열 개 전부 같은 증상이었다. favicon은 이랬다. cache-control: public, max-age=0, max-age=31536000 제일 이상했던 건 사이트 하나였다. 이쪽은 Node 앱이 캐시 헤더를 제대로 보내고 있었는데도 이렇게 나왔다. cache-control: public, max-age=31536000, immutable, max-age=2592000 앱이 보낸 올바른 값 뒤에 뭔가가 30일을 덧붙이고 있었다. 앱을 고쳐도 소용이 없다는 뜻이다. 범인은 Apache다. 설정은 제대로 써놨는데 Apache 설정을 열어봤다. 캐시 설정은 이미 잘 되어 있었다. IfModule mod_headers.c FilesMatch "\.(css|js)
quot; Header set Cache-Control "public, max-age=2592000, immutable" /FilesMatch /IfModule 보다시피 immutable 까지 붙여놨다. 그런데 실제 응답 어디에도 immutable 이 없다. 이게 힌트였다. Header set 이 실행됐다면 immutable 은 반드시 붙어야 한다. 없다는 건 이 블록이 아예 실행되지 않았다 는 뜻이다. FilesMatch는 프록시 응답에 적용되지 않는다 이유는 vhost 설정에 있었다. ProxyPass / http://localhost:5015/ ProxyPassReverse / http://localhost:5015/ 모든 사이트가 Node로 프록시된다. Apache가 직접 서빙하는 파일이 하나도 없다. FilesMatch 는 이름 그대로 "파일"에 매칭되는 지시어다. 프록시 응답은 Apache 입장에서 그냥 다른 서버가 보내준 바이트 스트림이지 로컬 파일이 아니다. 매칭될 파일이 없으니 블록이 통째로 죽는다. 내 서버에서는 사이트 열 개가 전부 프록시라, 저 mod_headers 블록이 단 한 번도 동작한 적이 없었다. 그럼 30일은 누가 붙였나 설정 위쪽에 이런 게 같이 있었다. IfModule mod_expires.c ExpiresActive On ExpiresByType application/javascript "access plus 1 month" /IfModule 1 month = 2592000초. 아까 그 숫자다. mod_expires 는 Content-Type 기준으로 동작한다. 파일이 아니라 응답 헤더를 보기 때문에 프록시 응답에도 정상적으로 먹는다. 문제는 동작 방식이다. mod_expires 는 기존 Cache-Control 을 교체하지 않고 max-age 를 덧붙인다. 그래서 이런 일이 벌어진 거였다. Express (express.static 기본값) → public, max-age=0 mod_expires 가 덧붙임 → public, max-age=0, max-age=2592000 mod_headers 는 실행 안 됨 → (immutable 없음) 정리하면 의도한 설정은 안 먹고, 의도하지 않은 설정만 먹은 상태였다. 둘 다 조용해서 몇 달 동안 몰랐다. expr= 로 바꾸기 mod_expires 를 걷어내고 mod_headers 의 expr= 조건만 쓰기로 했다. expr= 은 URL이나 Content-Type을 보기 때문에 프록시 응답과 로컬 파일 양쪽에서 다 동작한다. 그리고 Header set 은 백엔드가 보낸 값을 덧붙이지 않고 교체한다. IfModule mod_headers.c # 1) 해시 파일명 자산 — 영구 캐시 Header set Cache-Control "public, max-age=31536000, immutable" \ "expr=%{REQUEST_URI} =~ m#^/assets/.+\.(js|mjs|css|woff2?|png|svg|ico)$#" # 2) 서비스워커 — 캐시 금지 Header set Cache-Control "no-cache, must-revalidate" \ "expr=%{REQUEST_URI} =~ m#^/(sw\.js|registerSW\.js|[^/]*\.webmanifest)$#" # 3) 루트의 비해시 이미지 — 하루 Header set Cache-Control "public, max-age=86400" \ "expr=%{REQUEST_URI} =~ m#^/[^/]*\.(png|jpe?g|svg|ico)$# ! %{resp:Cache-Control} =~ m#max-age=[1-9]#" # 4) HTML — 항상 재검증 Header set Cache-Control "no-cache, must-revalidate" \ "expr=%{CONTENT_TYPE} =~ m#^text/html# ! %{resp:Cache-Control} =~ m#max-age=[1-9]#" /IfModule 규칙을 짜면서 걸린 게 몇 개 있었다. 서비스워커에 1년을 걸면 안 된다 처음엔 .js 전부에 1년 immutable 을 걸려고 했다. 그러다 dist 폴더를 열어보고 멈췄다. dist/ ├── assets/ ← 전부 해시 파일명 (index-ae0ed0e9.js) ├── sw.js ← 해시 없음 └── registerSW.js ← 해시 없음 PWA로 만든 사이트 두 개에 서비스워커가 있었다. 이건 파일명이 고정이라 URL이 그대로 유지된다. 여기에 1년 immutable 을 걸면 새로 배포해도 브라우저가 영영 옛 서비스워커를 쓴다. 캐시를 켜려다 배포를 막을 뻔했다. 그래서 캐시 규칙을 /assets/ 경로로 한정했다. Vite는 빌드 산출물을 전부 거기에 해시 이름으로 떨어뜨리니까, 경로만으로 "내용이 바뀌면 이름도 바뀌는 파일"을 정확히 고를 수 있다. 서비스워커는 별도로 no-cache 를 명시했다. HTML은 경로로 못 잡는다 원래 설정에는 이런 것도 있었다. FilesMatch "\.(html|json)
quot; 이건 프록시 문제를 빼고 봐도 안 되는 규칙이다. SPA의 진입 경로는 / 나 /dashboard 처럼 확장자가 없다. \.html$ 로는 절대 안 잡힌다. 그래서 HTML만은 경로가 아니라 Content-Type 으로 판정하게 했다. 이러면 확장자가 없는 라우트도 전부 걸린다. HTML을 no-cache 로 두는 건 중요하다. index.html이 캐시되면 브라우저가 새 배포의 자산 해시를 못 읽어서 사용자가 옛 화면에 갇힌다. 자산을 1년 캐시하는 대신 index.html은 매번 확인하는 게 이 조합의 핵심이다. 앱이 정한 캐시는 건드리지 않기 배포하고 검증하다가 하나를 밟았다. 코인 사이트의 약관 페이지가 이랬다. // app.js app.get('/privacy', (req, res) = { res.setHeader('Cache-Control', 'public, max-age=3600'); ... }); 정적인 약관 페이지라 앱이 일부러 1시간 캐시를 걸어둔 건데, 내 HTML 규칙이 이걸 no-cache 로 덮어버렸다. 앱의 결정을 무시한 셈이다. 그래서 3번과 4번 규칙에 조건을 하나 붙였다. ! %{resp:Cache-Control} =~ m#max-age=[1-9]# "앱이 이미 유효한 max-age를 지정했으면 건드리지 않는다"는 뜻이다. express.static 이 index.html에 붙이는 기본값은 max-age=0 이라 [1-9] 에 안 걸리고, 그래서 SPA의 index.html은 의도대로 교체된다. API 응답(JSON)은 아예 규칙 대상에서 뺐다. 결과 사이트 열 개를 재방문 기준으로 다시 재봤다. 요청 수 전송량 전 → 175건 1572KB 후 → 11건 50KB (94% 감소) (97% 감소) 남은 11건은 사이트별 index.html 10건과 서비스워커 1건이다. 이건 매번 확인해야 정상인 것들이라, 결국 자산 요청은 0건 이 됐다. 헤더도 이렇게 정리됐다. /assets/*.js → public, max-age=31536000, immutable / → no-cache, must-revalidate /sw.js → no-cache, must-revalidate /privacy → public, max-age=3600 (앱이 정한 값 그대로) 남는 생각 Redis를 넣었으면 어땠을까. 아마 잘 돌았을 거고, 나는 뭔가 개선했다고 생각했을 거다. 그리고 브라우저는 여전히 매 방문마다 자산 열아홉 개를 다시 확인하고 있었을 거다. 재보길 잘했다. TTFB 3~6ms에 슬로우 쿼리 0건인 서버에 캐시 레이어를 얹는 건 그냥 관리 포인트만 늘리는 일이었다. 그리고 "설정을 써놨다"와 "설정이 동작한다"는 다르다. FilesMatch 블록은 몇 달 동안 파일에 얌전히 적혀 있었고, 문법 오류도 없었고, apache2ctl configtest 도 통과했다. 그냥 실행이 안 됐을 뿐이다. 조용히 실패하는 설정은 로그도 안 남긴다. Node를 nginx나 Apache 뒤에 두고 쓰는 사람이라면 한 번 확인해볼 만하다. 명령어 한 줄이면 된다. curl -I https://내사이트/assets/아무거나.js | grep -i cache-control max-age 가 두 번 나오면 캐시가 안 되고 있는 거다.