GeekNews의 캐시 구조를 다시 설계하며 배운 것들. 서버를 키우기 전에 요청을 줄이고, 콘텐츠의 시간에 맞춰 캐시하는 방법을 정리했습니다.
작은 서버로 큰 트래픽을 버티는 법
GeekNews에는 첫 화면처럼 많은 사람이 반복해서 보는 페이지도 있고, 7년 동안 쌓인 수만 개의 글처럼 하루에 한두 명만 찾아오는 페이지도 있습니다. 완전 정적인 건 아니어서, 로그인한 사용자는 투표와 즐겨찾기 상태를 봐야 하고, 새 댓글은 가능한 한 빨리 보여야 하지만, 모든 독자에게 실시간으로 반영될 필요가 있는지도 따져봐야 합니다.
트래픽을 종류별로 구분해 보면, 구글 검색을 통한 지속적인 트래픽도 있고, 슬랙 봇을 통해서도 꽤 많은 순간적인 트래픽이 쏟아지고, AI 봇들이 늘어나서 마구 크롤링하기도 하는데요. 그러다 보면 서버가 버티지 못하고 죽는 경우가 생기는데, 운영자 입장에선 에러를 없애기 위해 서버를 키우고 싶은 순간이 자주 옵니다. 특히 요즘 AI 에이전트와 서버 상황에 대해 얘기하다 보면, 우리가 마치 무한한 자원을 가지고 있는 것처럼 큰 인스턴스로 옮기면 다 해결된다고 얘기합니다.
하지만 매달 늘어나는 고정비는 기술 부채만큼이나 부담스럽습니다. 그래서 던지는 질문:
이 요청은 정말 매번 웹 서버와 데이터베이스까지 도달해야 할까?
이 글은 그 질문에서 시작해 GeekNews의 구조를 다시 설계한 기록입니다. 비슷한 규모의 서비스를 운영하는 분들이 자신의 서비스에 적용해 볼 수 있는 판단 기준을 중심으로 정리해 봤습니다.
캐시는 속도보다 일을 줄이는 기술입니다
캐시는 흔히 뭔가를 빠르게 하는 기술로 설명됩니다. 운영자의 관점에서는 조금 다릅니다. 같은 결과를 만들기 위해 서버가 반복하는 일을 없애는 기술이라고나 할까요.
GeekNews의 공개 페이지 하나를 만들려면 애플리케이션을 실행하고, 여러 데이터를 읽고, HTML을 조합해야 합니다. 응답 한 번은 가벼워 보여도 같은 요청이 수십만 번 반복되면 작은 낭비가 큰 비용이 됩니다. 반대로 CDN이나 웹 서버가 이미 만든 결과를 돌려줄 수 있다면 애플리케이션과 데이터베이스는 아예 일을 하지 않아도 됩니다.
그래서 확인해야 하는 지표는 평균 응답 시간만이 아닙니다.
- 어떤 URL이 원점 서버까지 가장 많이 들어오는가
- 같은 HTML을 만들기 위해 애플리케이션이 몇 번 실행되는가
- 페이지 종류와 글의 나이에 따라 캐시 적중률이 어떻게 다른가
- 최신 페이지를 보여주기 위해 감수하는 캐시 미스가 실제로 가치가 있는가
성능 개선의 목표를 “더 빨리 계산하기”가 아니라 “계산하지 않기” 로 바꾸면 관점이 좀 달라집니다.
개인화와 공개 HTML 분리
캐시를 어렵게 만드는 대표적인 요소는 로그인입니다. 로그인한 사용자에게는 닉네임, 투표 여부, 즐겨찾기 상태가 보여야 합니다. 이 정보를 서버가 HTML에 직접 넣으면 사람마다 결과가 달라져 같은 페이지를 함께 캐시하기 어렵습니다.
GeekNews도 처음에는 로그인 쿠키가 있으면 공개 페이지 캐시를 우회하는 구조였습니다. 방문자가 늘수록 로그인 사용자의 요청은 계속 애플리케이션까지 들어왔습니다.
이를 다음과 같이 나눴습니다.
- 제목, 요약, 댓글처럼 모두에게 같은 내용은 공개 HTML로 만듭니다.
- 공개 HTML을 만들 때는 세션을 열지 않습니다.
- 로그인 상태, 투표, 즐겨찾기 같은 작은 데이터만 페이지가 열린 뒤 별도 API로 가져옵니다.
이러면 익명 사용자와 로그인 사용자가 같은 공개 HTML을 공유할 수 있습니다. 개인화 API의 응답은 작고 캐시하지 않지만, 훨씬 무거운 본문 HTML은 CDN과 웹 서버에서 재사용됩니다.
물론, 이 방식에는 화면 일부가 잠깐 늦게 그려지는 문제가 있습니다. 그래서 브라우저에 최근 개인화 상태를 짧게 보관해 먼저 표시하고, 뒤에서 서버 값으로 맞춥니다. 사용자가 투표한 직후에는 브라우저의 값도 함께 갱신합니다. 로그인 여부에 따라 달라지던 메뉴바 오른쪽 영역도 공통화했습니다. 항상 보이던 사용자 아이디와 카르마 대신 사람 모양 아이콘만 두고, 클릭하면 개인화된 사용자 메뉴를 팝업으로 보여줍니다.
페이지 전체가 개인화되어 보이더라도 실제로 사람마다 다른 데이터는 아주 적을 수 있습니다. 그 작은 부분 때문에 전체 HTML 캐시를 포기할 필요는 없습니다. 디자인과 정보 구성을 이에 맞게 정리하면 서버의 일을 크게 줄일 수 있습니다.
캐시는 한 겹이 아니라 역할별로 나눴습니다
GeekNews의 공개 요청은 대략 네 가지 캐시 장치를 거칩니다. 모두가 직렬로 이어진 것은 아니고, Nginx에서 정적 HTML을 찾지 못한 요청만 마이크로캐시와 애플리케이션으로 들어갑니다.
| 계층 | 하는 일 |
|---|---|
| CDN | 사용자와 가까운 곳에서 공개 HTML을 반환하고 원점 요청을 줄임 |
| Nginx 정적 HTML | 오래된 글은 애플리케이션을 거치지 않고 압축된 HTML로 바로 반환 |
| Nginx 마이크로캐시 | 짧은 시간에 같은 요청이 몰릴 때 애플리케이션 실행을 막음 |
| 애플리케이션 캐시 | 애플리케이션까지 들어온 요청의 조회와 조합 비용을 줄임 |
각 층은 다른 문제를 해결합니다. CDN 적중률이 낮아도 Nginx가 받아내면 데이터베이스까지 가지 않을 수 있습니다. 반대로 원점의 캐시가 잘 동작해도 CDN이 매번 원점에 확인한다면 네트워크와 요청 비용은 계속 발생합니다.
오래된 토픽은 조금 더 적극적으로 처리했습니다. 등록된 지 30일이 넘은 글의 첫 페이지는 압축된 HTML 파일로 만들어두고, Nginx가 애플리케이션보다 먼저 찾습니다. 파일이 없을 때만 애플리케이션이 페이지를 만들고 이후 요청에서 재사용합니다. 오랫동안 요청되지 않은 파일은 정리하지만, 다음 방문 때 다시 만들 수 있으므로 전체를 영구 보관할 필요도 없습니다.
모든 페이지를 미리 생성하지 않고 실제로 읽히는 오래된 글만 느긋하게 스태틱화한 셈입니다.
모든 페이지에 같은 TTL을 주지 않았습니다
캐시 설정에서 가장 쉬운 실수는 사이트 전체에 하나의 만료 시간을 적용하는 것입니다. 하지만 첫 화면과 몇 년 전 글은 시간이 흐르는 방식이 다릅니다.
GeekNews는 뉴스 서비스이다 보니 콘텐츠의 성격에 따라 정책을 나눴습니다.
- 첫 화면, 새 글 목록, 등록 후 하루가 지나지 않은 토픽과 댓글 목록은 수십 초 단위로 짧게 캐시합니다.
- 토픽 첫 페이지는 등록 후 1-3일이면 1분, 3-30일이면 1시간 캐시합니다.
- 페이지가 넘어간 목록이나 자주 바뀌지 않는 공개 화면은 더 길게 캐시합니다.
- 등록 후 30일이 지난 토픽의 첫 페이지는 CDN에서 아주 길게 캐시합니다.
- 날짜별 아카이브도 조회 시점에서 7일 미만은 3분, 7~30일은 1시간, 30일 이상은 24시간 등으로 나눕니다.
- 사이트맵은 미리 정적으로 만들고, 오래된 아카이브는 조회 결과를 파일로 보관해 데이터베이스를 다시 읽지 않도록 합니다.
이 절의 적중률은 CloudFront 요청 로그를 기준으로 계산했습니다. 짧은 캐시도 효과가 있습니다. 첫 화면처럼 짧은 시간에 요청이 집중되는 URL은 15초만 캐시해도 적중률이 70% 후반까지 나왔습니다. 반면 오래된 글은 다른 패턴을 보였습니다. 같은 글을 다시 찾는 간격이 길어서 1시간 캐시로는 충분하지 않았습니다.
토픽을 등록 후 경과 시간으로 나눠보니 1-3일 토픽의 캐시 적중률은 38.0%, 3-30일 토픽은 10.6%에 불과했습니다. 모든 30일 미만 토픽에 동일한 15초 TTL을 적용한 결과였습니다. 댓글이 비교적 활발한 1-3일 구간은 1분으로 조심스럽게 늘리고, 변경 가능성이 낮아진 3~30일 구간은 1시간으로 조정하는 등 계속 조정하고 있습니다.
운영 로그를 살펴보니, 매일 읽히는 30일 이상 토픽 1만 6천여 개 가운데 약 절반은 하루에 한 번만 조회됐습니다. 하루 한 번 조회되는 글의 캐시 적중률은 22.5%였지만, 하루 50회 이상 조회되는 글은 96.5%였습니다. 즉, 롱테일 콘텐츠의 재방문 간격보다 TTL이 짧았던 것입니다. 최신성을 조금 늦추는 비용은 작고, 원점 요청을 줄이는 효과는 크다고 판단해 오래된 토픽의 CDN 캐시를 1시간에서 24시간으로 늘리자, 58~59%에서 정체되어 있던 30일 이상 토픽의 캐시 적중률은 변경 후 79.0%로 올라갔습니다.
날짜별 아카이브도 같은 방식으로 확인했습니다. 30일 이상 지난 날짜가 전체 아카이브 요청의 약 81%를 차지했지만, 캐시 적중률은 0.8%에 불과했습니다. 날짜마다 URL이 달라 같은 페이지를 다시 찾는 경우가 거의 없었기 때문입니다. 그래서 최근 7일은 3분, 7~30일은 1시간, 30일 이상은 24시간으로 나눴습니다. 사실 수치보다 중요한 것은 판단 과정입니다. TTL은 “얼마나 오래 캐시해도 될까”만으로 정하지 않고, “같은 URL이 얼마 만에 다시 요청되는가”와 함께 봐야 합니다.
CDN은 지점별로 캐시가 나뉘므로, 실제 분석에서는 URL만이 아니라 쿼리, 헤더, 쿠키 등으로 구성된 캐시 키와 CDN 지점도 함께 봤습니다.
캐시 무효화는 적게 할수록 안전합니다
캐시를 오래 유지하면 자연스럽게 “내용이 바뀌면 어떻게 할 것인가”라는 문제가 생깁니다. 처음에는 댓글이나 글이 바뀔 때마다 CDN 캐시를 바로 지우는 것이 정확해 보이지만, 무효화 범위가 넓어지고 이벤트가 자주 발생하면 비용과 실패 지점이 늘어납니다. 여러 변경이 동시에 일어날 때 무효화 요청 자체가 밀릴 수도 있습니다. 무엇보다 캐시를 어렵게 만드는 원인은 오래된 데이터보다 어떤 변경이 어떤 캐시를 지워야 하는지 추적하는 복잡성인 경우가 많았습니다.
GeekNews는 다음 원칙으로 단순화했습니다.
- 글과 댓글이 변경되면 애플리케이션 캐시와 원점 마이크로캐시의 정확한 키, 해당 정적 HTML 파일만 즉시 지웁니다.
- CDN은 기본적으로 TTL이 끝날 때 자연스럽게 갱신합니다.
- 투표처럼 자주 일어나고 본문 전체를 다시 만들 필요가 없는 동작은 CDN 캐시를 지우지 않습니다.
- 삭제나 민감정보처럼 즉시 반영해야 하는 예외에 한해서만 즉시 해당 URL을 무효화합니다.
댓글 작성자에게는 별도의 짧은 우회 경로를 제공합니다. 작성 직후 한 번은 캐시되지 않은 최신 화면을 보여주고, 주소는 다시 원래 URL로 정리합니다. 다른 독자에게는 오래된 글의 새 댓글이 다소 늦게 보일 수 있다는 절충이 필요합니다. 모든 변경을 모든 사람에게 즉시 보여주는 것은 가능하지만 공짜가 아닙니다. 서비스에서 정말 즉시성이 필요한 변경과 그렇지 않은 변경을 구분하면 캐시 구조가 훨씬 단순해집니다.
캐시 판단에는 Google Analytics가 아니라 CDN 로그를 사용했습니다
방문자 분석 도구는 사용자가 무엇을 봤는지 이해하는 데 유용하지만, CDN이 요청을 어디서 처리했는지는 충분히 알려주지 않습니다. 캐시를 개선하려면 CDN의 요청 로그가 필요합니다. 그렇다고 페이지가 열릴 때마다 데이터베이스에 조회수를 기록하는 쓰기 작업을 추가하면 측정을 위해 서비스가 느려지는 역설이 생깁니다.
그래서 CDN 접근 로그를 모아 별도의 장비에서 배치로 처리했습니다. 로컬 자원을 활용해 EC2 비용을 늘리지 않는 구조로 설계했습니다. 이번 로그 집계 작업에서는 오래된 노트북 하나로도 작은 EC2 인스턴스보다 높은 처리량을 낼 수 있었습니다. 원본 요청을 서비스 데이터베이스에 그대로 넣지 않고, 날짜/페이지 종류/기기/캐시 결과/토픽 연령 같은 작은 집계로 압축한 뒤 전송합니다. 이렇게 만든 지표가 있으면 전체 적중률뿐 아니라 다음 질문들까지 답할 수 있습니다.
- 첫 화면과 토픽 중 어디의 미스가 원점 부하에 더 큰가
- 최근 글과 오래된 글의 적중률 차이는 무엇인가
- 조회량이 적은 URL 때문에 전체 적중률이 낮아 보이는 것은 아닌가
- 정책을 바꾼 뒤 실제로 적중률이 올랐는가
측정도 제품의 요청 경로 밖으로 밀어내고, 원본 데이터보다 의사결정에 필요한 집계만 남기는 것이 비용과 운영 복잡성을 함께 줄여주었습니다.
전체 적중률 하나만 보면 잘못된 결론에 도달합니다
한동안 GeekNews의 전체 CloudFront Hit와 Miss는 거의 반반이었습니다. 숫자만 보면 캐시가 제대로 작동하지 않는 것처럼 보입니다. 하지만 페이지 종류별로 나누자 이야기가 달라졌습니다.
첫 화면과 새 글 목록은 짧은 TTL에도 요청이 집중되어 70% 후반의 적중률을 보였습니다. 토픽은 URL이 수만 개라 전체 적중률이 낮았지만, 30일 이상 된 토픽만 보면 24시간 캐시 적용 후 79.0%까지 올라갔습니다. 사용자 프로필처럼 URL 종류가 많고 각 페이지의 방문이 드문 화면은 적중률이 낮아도 전체 원점 비용에 미치는 영향이 작았습니다.
따라서 캐시 개선의 우선순위는 단순히 적중률이 낮은 순서가 아닙니다.
절감 효과 = 요청량 × 캐시 미스율 × 원점 처리 비용
단, 서비스가 허용할 수 있는 최신성 지연 안에서 적용합니다.
절감 효과와 최신성 위험을 함께 봐야 합니다. 적중률이 10%인 작은 페이지보다, 적중률이 50%인 대형 경로를 개선하는 편이 훨씬 큰 효과를 낼 수 있습니다. 반대로 조회가 많더라도 최신성이 핵심인 화면에 무리하게 긴 캐시를 적용하면 사용자 신뢰를 잃을 수 있습니다.
작은 서비스에서 먼저 해볼 순서
비슷한 고민을 하는 분이라면 처음부터 여러 계층을 모두 만들 필요는 없습니다. 다음 순서로 확인해보는 것을 권합니다.
- 비싼 공개 GET을 찾습니다. 접근 로그와 애플리케이션 처리 시간을 함께 보고, 요청량과 계산 비용이 모두 큰 경로부터 고릅니다.
- 세션이 정말 필요한지 확인합니다. 공개 HTML에 사용자별 정보가 조금 섞였다는 이유로 전체 캐시를 포기하고 있지 않은지 봅니다.
- 15초짜리 마이크로캐시부터 시작합니다. 짧아 보여도 요청이 몰리는 화면에는 큰 효과가 있습니다.
- 캐시 키를 정규화합니다. 같은 내용을 보여주는 쿼리 순서, 기본 페이지 번호, 불필요한 파라미터가 서로 다른 캐시를 만들지 않게 합니다.
- 변경 빈도에 따라 URL을 나눕니다. 최근 글, 오래된 글, 아카이브가 같은 TTL을 가질 이유는 없습니다.
- 무효화 범위를 정확하게 만듭니다. 전체 삭제보다 해당 URL의 원점 캐시만 지우고, CDN은 짧은 TTL로 시작합니다.
- 전체 적중률을 페이지 종류와 콘텐츠 연령으로 쪼갭니다. 평균은 문제의 위치를 감춥니다.
특히 캐시를 붙인 뒤에는 익명 사용자와 로그인 사용자의 HTML이 같은지, 응답에 의도하지 않은 세션 쿠키가 생기지 않는지, 글 작성과 삭제 직후 화면이 어떻게 보이는지를 함께 테스트해야 합니다. E2E 테스트를 잘 구축하는 것도 도움이 됩니다.
서버를 키우지 않는 것도 하나의 제품 결정입니다
이번 작업에서 특별한 알고리듬을 만든 것은 아닙니다. CDN, Nginx, 정적 파일, 배치 처리처럼 오래된 도구들을 서비스의 성격에 맞게 조합했을 뿐입니다.
중요했던 것은 최신 기술을 더하는 일이 아니라, 어떤 일을 서비스 요청 순간에 하지 않아도 되는지 계속 찾아내는 것이었습니다. 공개 HTML에서 세션을 빼고, 오래된 글은 애플리케이션을 거치지 않고 반환하고, 분석 작업을 별도 장비로 옮기고, 무효화를 줄였습니다.
작은 서버를 유지하는 것은 단순히 인프라 비용을 아끼는 일이 아닙니다. 고정비가 낮으면 서비스를 더 오래 운영할 수 있고, 복잡한 관리 시스템을 덜 만들 수 있으며, 사용자를 위한 기능에 더 많은 시간을 쓸 수 있습니다.
GeekNews는 앞으로도 트래픽이 늘 때마다 서버 크기부터 올리기보다 같은 질문을 먼저 해보려 합니다.
이 계산을 더 빠르게 할 방법보다, 아예 하지 않을 방법은 없을까?