![[실전 성능 13] 캐시를 층층이 쌓기](https://img.thenullpage.com/posts/5767/5767_1_92232c.webp)
지난 편에서 서버 렌더링으로 알맹이를 빨리 보여주는 법을 다뤘다. 그 과정에서 캐시가 여러 번 등장했다. 이번 편은 그 캐시를 하나의 기법이 아니라 여러 층으로 쌓은 계층으로 정리한다. 그리고 캐시가 편리한 만큼 반드시 따라오는 무효화와 보안 함정을 정면으로 다룬다. 내가 실제로 사고를 겪으며 배운 부분이다.
1. 미리 계산해두는 캐시
가장 안쪽 층은 무거운 계산을 미리 해두는 캐시였다. 사이트에는 인기 글 목록이나 통계처럼 만드는 데 조회가 많이 드는 부분이 있었다. 이걸 사용자가 요청할 때마다 매번 계산하면 부담이 컸다. 그래서 나는 한가할 때 미리 계산해서 그 결과를 저장해두고, 요청이 오면 저장된 것을 그대로 내보냈다.
이 방식의 효과는 극적이었다. 예전에는 그 목록을 만들려고 많은 데이터를 훑어야 했는데, 미리 계산해두니 요청 시에는 저장된 결과 한 줄만 읽으면 됐다. 무거운 조회가 요청 경로에서 사라진 것이다. 사용자는 빠르게 응답받고 서버는 조회 부담을 덜었다. 계산을 요청 시점에서 미리 시점으로 옮기는 원리의 가장 강력한 적용이었다.
미리 계산은 정기적으로 갱신했다. 콘텐츠가 바뀌면 미리 계산해둔 결과도 낡으니, 일정 주기로 다시 계산해 갱신했다. 얼마나 자주 갱신할지는 그 데이터가 얼마나 신선해야 하는지에 따라 정했다. 인기 목록은 몇 시간에 한 번 갱신해도 괜찮았고, 자주 바뀌어야 하는 건 더 짧게 잡았다. 신선도와 부담 사이의 균형이었다. 지나치게 자주 갱신하면 미리 계산의 의미가 없어지고 너무 뜸하게 갱신하면 낡은 정보가 오래 남으니, 각 데이터가 얼마나 신선해야 하는지를 콘텐츠 성격에 따라 개별로 정했다.
이 층의 캐시는 사용자 요청과 무관하게 배경에서 미리 준비된다는 점이 특징이었다. 사용자가 오든 안 오든 정해진 주기로 갱신되니, 어떤 사용자가 오더라도 이미 준비된 결과를 즉시 받았다. 첫 사용자도 기다리지 않았다. 요청이 몰려도 계산은 이미 끝나 있어 부담이 없었다. 배경 준비가 요청 순간의 부담을 없앤 것이다.
2. 엣지에서 응답을 재사용하는 캐시
그다음 층은 앞 편에서 다룬 엣지 캐시였다. 완성된 응답을 사용자 가까운 지점에 저장해두고 재사용하는 층이다. 미리 계산이 무거운 데이터를 준비하는 안쪽 층이라면, 엣지 캐시는 완성된 응답 자체를 사용자 근처에 두는 바깥층이었다. 두 층이 함께 작동하면 조회도 없고 거리도 짧았다.
이 두 층은 역할이 달라서 겹쳐 쓸 때 효과가 컸다. 안쪽 층이 조회 부담을 없애고, 바깥층이 왕복 거리를 줄였다. 어느 한 층만으로는 반쪽이었다. 미리 계산만 하고 엣지 캐시가 없으면 여전히 멀리서 응답을 만들어야 했고, 엣지 캐시만 있고 미리 계산이 없으면 캐시가 없을 때 무거운 조회가 일어났다. 두 층이 서로를 보완했다.
엣지 캐시는 짧은 유효 기간으로 운영했다. 이 층은 완성된 응답을 통째로 재사용하니, 오래 두면 낡은 화면을 보여줄 위험이 컸다. 그래서 짧게 잡아서, 콘텐츠가 바뀌어도 금방 새 응답으로 교체되게 했다. 짧아도 그 순간 몰리는 요청을 흡수하는 효과는 충분했다. 트래픽이 몰릴수록 짧은 캐시도 큰 몫을 했다.
브라우저 캐시까지 더하면 가장 바깥층이 됐다. 사용자 브라우저에 자원을 저장해두면 재방문 시 다운로드 없이 재사용했다. 미리 계산, 엣지 캐시, 브라우저 캐시가 안쪽부터 바깥까지 층을 이루니, 각 층이 서로 다른 상황의 요청을 걸러냈다. 어떤 요청은 브라우저에서, 어떤 요청은 엣지에서, 어떤 요청은 미리 계산으로 빠르게 처리됐다.
3. 무효화라는 어려운 문제
캐시의 진짜 어려움은 언제 비우느냐였다. 콘텐츠가 바뀌면 캐시된 옛 내용을 비우고 새로 채워야 하는데, 이 시점을 놓치면 사용자가 낡은 내용을 봤다. 반대로 너무 자주 비우면 캐시의 이점이 사라졌다. 캐시를 채우는 것보다 적절히 비우는 것이 훨씬 어려운 문제였다. 나는 여기서 여러 번 헤맸다.
핵심 원칙은 캐시를 채우는 곳과 비우는 곳을 반드시 짝으로 두는 것이었다. 콘텐츠를 바꾸는 코드가 있으면, 그와 관련된 캐시를 비우는 코드가 반드시 함께 있어야 했다. 한쪽만 있으면 캐시가 낡은 채로 남았다. 나는 콘텐츠를 수정하는 모든 경로에서 관련 캐시를 함께 비우도록 짝을 맞췄다. 짝이 안 맞으면 반드시 문제가 생겼다.
그런데 무엇이 관련 캐시인지 파악하기가 까다로웠다. 하나의 콘텐츠가 여러 화면에 나타나면, 그 콘텐츠가 바뀔 때 관련된 여러 캐시를 다 비워야 했다. 하나라도 놓치면 그 화면만 낡은 채로 남았다. 나는 콘텐츠와 그것이 나타나는 화면들의 관계를 정리해두고, 바뀔 때 관련된 모든 캐시를 빠짐없이 비웠다. 관계를 명확히 아는 것이 무효화의 전제였다.
어떤 캐시는 정확히 비우기 어려워서 아예 짧게 유지하는 쪽을 택했다. 관련 화면을 다 추적하기 힘든 경우, 무효화에 매달리기보다 유효 기간을 짧게 잡아 자연히 갱신되게 했다. 완벽한 무효화 대신 짧은 수명으로 낡음을 감수하는 절충이었다. 어느 정도의 낡음이 허용되는 데이터라면 이 방식이 단순하고 안전했다.
4. 캐시가 부른 보안 사고
캐시에서 가장 크게 데인 건 보안이었다. 캐시는 응답을 여러 사용자가 공유하게 하는데, 이때 개인화된 응답이 섞이면 심각한 노출이 일어났다. 나는 캐시 설정을 잘못해서 로그인 사용자용 응답이 공유될 뻔한 사고를 겪었다. 다른 사람에게 남의 개인 화면이 나갈 수 있는 상황이었다. 성능을 좇다 안전을 위협한 것이다.
원인은 응답이 캐시돼도 되는지 판단하는 조건이 허술했던 데 있었다. 공개 콘텐츠와 개인화된 콘텐츠를 명확히 구분하지 않으면, 개인 정보가 담긴 응답이 공유 캐시로 흘러들 수 있었다. 나는 이 사고 뒤로 캐시 허용 조건을 엄격히 다시 짰다. 로그인 흔적이 없고, 개인화되지 않았고, 확실히 공개된 응답일 때만 캐시하도록 못 박았다.
또한 캐시를 구분하는 기준을 명확히 했다. 같은 주소라도 사용자에 따라 다른 응답이 나갈 수 있으면, 그것들이 뒤섞이지 않도록 구분 기준을 정확히 세워야 했다. 이 기준이 어긋나면 엉뚱한 사용자에게 엉뚱한 응답이 나갔다. 나는 캐시를 설계할 때 성능보다 이 구분의 정확성을 먼저 확인했다. 빠른 것보다 안전한 것이 먼저였다.
이 경험으로 나는 캐시를 다룰 때 항상 최악을 상상하게 됐다. 이 응답이 엉뚱한 사람에게 가면 어떻게 되는지 먼저 따지고, 조금이라도 위험하면 캐시하지 않았다. 성능 이득이 아무리 커도 개인 정보 노출의 위험과는 바꾸지 않았다. 캐시는 강력한 만큼 신중해야 하는 도구였다. 편리함 뒤의 위험을 늘 의식했다.
5. 캐시는 설계다
캐시를 층층이 쌓으며 얻은 결론은 캐시가 단순한 기법이 아니라 설계라는 점이었다. 무엇을, 어느 층에, 얼마나 오래, 어떻게 비울지, 그리고 무엇을 절대 캐시하지 않을지를 함께 정해야 했다. 이 결정들이 맞물려야 캐시가 성능을 높이면서도 안전하고 신선했다. 하나만 어긋나도 낡음이나 노출로 이어졌다.
잘 설계된 캐시 계층은 사이트를 여러 상황에서 빠르고 안정적으로 만들었다. 트래픽이 몰려도 각 층이 요청을 흡수해 서버가 무너지지 않았고, 사용자는 어디서든 빠르게 응답받았다. 비용도 아꼈다. 캐시가 조회와 계산을 대신 흡수하니 자원 소모가 줄었다. 성능과 안정성과 비용을 한꺼번에 챙기는 핵심 장치였다.
다만 그 대가로 복잡성이 늘었다. 층이 많아질수록 무효화와 정합성을 챙길 곳이 늘었고, 실수의 여지도 커졌다. 나는 꼭 필요한 만큼만 층을 쌓고, 각 층의 규칙을 명확히 문서로 정리해뒀다. 복잡한 캐시일수록 규칙을 분명히 해야 나중에 헷갈리지 않았다. 관리 가능한 선을 지키는 것도 설계의 일부였다. 성능을 위해 층을 무한정 쌓다가 정작 무효화를 놓쳐 낡은 화면이나 노출 사고를 내면 이득보다 손해가 컸으니, 나는 감당할 수 있는 만큼만 쌓는 절제를 캐시 설계의 원칙으로 삼았다.
이렇게 사람 사용자를 위한 성능 최적화를 두루 다뤘다. 그런데 내 사이트의 트래픽에는 사람만 있는 게 아니었다. 사람보다 훨씬 많은 봇이 끊임없이 드나들며 성능과 비용에 부담을 줬다. 다음 편에서는 이 봇 트래픽이 만드는 문제와, 내가 그것을 어떻게 다스렸는지를 다루겠다. 예상보다 훨씬 큰 문제였다.