[실전 서버리스 10] 캐시 계층 쌓기, DB 읽기 90% 줄이기

앞 편들에서 D1의 읽기가 곧 비용이라고 했다. 그럼 근본적으로 읽기를 줄이는 방법은 뭘까. 답은 캐시다. 같은 데이터를 매번 데이터베이스에서 읽지 말고, 한 번 읽은 걸 저장해뒀다가 재사용하는 것이다. 나는 캐시 계층을 쌓아 데이터베이스 읽기를 극적으로 줄였다. 이번 편은 그 실전 이야기다.


이건 앞서 다룬 rows read 최적화의 연장선이자, 더 근본적인 해법이다. 인덱스로 한 번 읽는 비용을 줄인다면, 캐시는 읽는 횟수 자체를 줄인다.

1. 왜 캐시가 필요한가

커뮤니티의 글 목록 같은 건 수많은 사용자와 봇이 계속 조회한다. 그런데 그 목록은 잠깐 사이에 크게 바뀌지 않는다. 몇 초 동안은 거의 같은 내용이다. 그런데도 매 요청마다 데이터베이스에서 새로 읽으면, 같은 내용을 반복해서 읽느라 비용과 시간을 낭비한다.


캐시는 이 낭비를 없앤다. 한 번 데이터베이스에서 읽은 목록을 잠깐 저장해두고, 그 짧은 시간 안에 오는 요청들은 저장된 걸 돌려준다. 데이터베이스를 다시 읽지 않으니 그만큼 비용과 시간이 절약된다. 자주 조회되고 자주 안 바뀌는 데이터일수록 캐시 효과가 크다.


내 사이트에서 글 목록 조회가 특히 문제였다. 봇들이 이 목록을 끊임없이 긁어서, 앞 편에서 다룬 것처럼 데이터베이스 읽기가 폭증했다. 이 목록을 캐시하면 봇이 아무리 긁어도 데이터베이스는 잠깐에 한 번만 읽힌다. 봇 트래픽의 대부분을 캐시가 흡수하는 것이다.


캐시가 필요한 이유를 한마디로 하면 반복의 낭비를 없애는 것이다. 자주 조회되지만 자주 안 바뀌는 데이터를 매번 새로 읽는 건 순전한 낭비다. 한 번 읽어 저장해두고 그 짧은 시간 재사용하면 그 낭비가 사라진다. 특히 봇처럼 같은 걸 끊임없이 조회하는 트래픽에 캐시가 결정적이다.


한 가지 더, 캐시는 사용자 경험도 개선한다. 데이터베이스를 안 거치고 캐시에서 바로 응답하니 훨씬 빠르다. 비용을 아끼는 동시에 페이지가 빨라지는 것이다. 앞서 다룬 페이지 속도와 직결되니, 캐시는 비용과 성능과 SEO를 한꺼번에 개선하는 셈이다. 한 작업이 여러 이득을 낳는 좋은 예다.

2. 여러 층의 캐시

캐시는 한 층이 아니라 여러 층으로 쌓을 수 있다. 나는 크게 두 층을 뒀다. 첫 번째 층은 각 엣지 위치의 로컬 캐시다. 사용자와 가장 가까운 곳에서 응답을 캐시해, 그 위치로 오는 요청은 데이터베이스는커녕 다른 곳도 안 거치고 바로 응답한다.


다만 이 로컬 캐시는 엣지 위치마다 따로다. 전 세계에 흩어진 각 위치가 자기만의 캐시를 갖는다. 그래서 봇이 여러 위치에서 오면 각 위치에서 처음엔 캐시가 없어 데이터베이스를 읽게 된다. 이 빈틈을 메우는 게 두 번째 층이다.


두 번째 층은 여러 위치가 공유하는 글로벌 캐시다. 데이터베이스에 캐시 전용 공간을 두고, 목록 응답을 거기에 저장한다. 어느 위치에서 이걸 채우면, 다른 위치들도 그걸 읽어 쓴다. 이러면 봇이 아무리 여러 위치에서 와도 데이터베이스 원본은 잠깐에 한 번만 읽힌다. 이 두 층으로 데이터베이스 읽기가 극적으로 줄었다.


여러 층의 캐시가 각자 다른 빈틈을 메운다는 게 중요하다. 엣지별 로컬 캐시는 가장 빠르지만 위치마다 따로라 빈틈이 있고, 글로벌 캐시가 그 빈틈을 메운다. 두 층이 겹쳐서 어느 위치에서 와도 대부분 캐시로 처리되게 한다. 한 층으로 안 되는 걸 여러 층을 쌓아 촘촘하게 막는 것이다.


조금 더 배경을 주면, 캐시는 앞 편의 인덱스와 역할이 다르다. 인덱스는 한 번 읽는 비용을 줄이고, 캐시는 읽는 횟수 자체를 줄인다. 둘은 경쟁이 아니라 보완이라, 함께 쓰면 효과가 곱해진다. 인덱스로 가볍게 읽고, 그마저도 캐시로 자주 건너뛰면, 데이터베이스 부담이 극적으로 준다.

3. 캐시 유효 시간

캐시의 핵심은 얼마나 오래 저장할지, 즉 유효 시간이다. 너무 길면 데이터가 바뀌었는데도 옛것을 보여주고, 너무 짧으면 캐시 효과가 적다. 이 둘 사이의 균형을 찾아야 한다. 나는 목록 캐시를 짧게, 몇십 초 수준으로 뒀다.


몇십 초라는 게 짧아 보여도 효과가 크다. 봇이 초당 여러 번 긁어도, 그 몇십 초 동안은 캐시된 하나만 돌려주면 되기 때문이다. 초당 수십 번의 요청이 몇십 초에 한 번의 데이터베이스 읽기로 줄어든다. 사용자 입장에서 몇십 초 전 목록과 지금 목록은 거의 차이가 없으니, 이 정도 지연은 문제가 안 된다.


중요한 건 캐시 유효 시간을 데이터의 성격에 맞추는 것이다. 자주 바뀌고 최신성이 중요한 건 짧게, 거의 안 바뀌는 건 길게. 글 목록은 새 글이 계속 올라오니 짧게 두고, 잘 안 바뀌는 설정 같은 건 길게 둘 수 있다. 데이터마다 알맞은 유효 시간을 정하는 게 캐시 설계의 요령이다.


캐시 유효 시간의 균형이 캐시 설계의 핵심이다. 너무 길면 낡은 데이터를, 너무 짧으면 캐시 효과를 잃는다. 목록처럼 몇십 초 차이가 무의미한 데이터는 짧게 둬도 봇 트래픽을 충분히 흡수한다. 데이터마다 최신성이 얼마나 중요한지를 보고 유효 시간을 정하는 감각이 필요하다.


또 하나, 캐시 계층을 쌓을 때 각 층의 역할을 문서로 정리해두면 좋다. 어느 층이 무엇을 얼마나 캐시하는지가 복잡해질 수 있어서, 정리해두지 않으면 나중에 캐시 문제를 디버깅할 때 헤맨다. 캐시는 강력한 만큼 얽히기 쉬우니, 그 구조를 명확히 기록해두는 게 나중의 자신을 돕는 일이다.

4. 캐시가 오염되지 않게

캐시에는 함정이 있다. 잘못된 데이터가 캐시되면 그 유효 시간 동안 계속 잘못된 걸 보여준다. 그래서 무엇을 캐시할지 걸러야 한다. 나는 빈 응답이나 오류 응답은 캐시하지 않도록 했다. 정상적인 데이터만 캐시하고, 이상한 응답은 캐시에 넣지 않는 것이다.


또 하나 조심할 게 캐시 키의 폭증이다. 캐시는 보통 요청의 특징을 키로 삼아 저장하는데, 봇이 임의의 값을 마구 넣어 요청하면 캐시 키가 무한히 늘어난다. 앞서 SEO 시리즈에서 다룬 봇이 이상한 페이지 번호를 긁는 문제가 여기서도 나온다. 그래서 캐시 키로 삼을 값을 정해진 것만 허용하도록 걸러야 한다.


이 걸러내기가 캐시를 건강하게 유지한다. 정상 데이터만, 정해진 키로만 캐시하면, 캐시가 오염되거나 폭증하지 않는다. 캐시를 쌓는 것만큼 캐시를 깨끗하게 관리하는 것도 중요하다. 잘못 관리된 캐시는 오히려 문제를 키운다.


캐시 오염과 폭증을 막는 게 캐시 관리의 숨은 절반이다. 잘못된 데이터가 캐시되면 그 시간 내내 잘못된 걸 보여주고, 봇이 임의 값을 넣으면 캐시 키가 무한히 는다. 그래서 정상 데이터만, 정해진 키로만 캐시하도록 걸러야 한다. 쌓는 것만큼 깨끗하게 유지하는 게 중요하다.


조금 더 실무적으로, 캐시를 도입할 때 무엇을 캐시할지 고르는 안목이 중요하다. 모든 걸 캐시하려 들면 관리가 복잡하고 오염 위험도 커진다. 자주 조회되고 자주 안 바뀌는, 캐시 효과가 확실한 것부터 캐시한다. 목록 조회 같은 게 대표적이다. 효과 큰 것부터 골라 캐시하는 게 현명하다.

5. 캐시 효과 측정

캐시를 쌓았으면 효과를 측정해야 한다. 나는 응답에 이게 캐시에서 나온 건지 데이터베이스에서 나온 건지 표시하는 헤더를 붙였다. 이걸로 얼마나 많은 요청이 캐시로 처리되는지, 즉 캐시 적중률을 확인했다. 적중률이 높을수록 데이터베이스 읽기가 적다는 뜻이다.


측정해보니 봇 트래픽의 대부분이 캐시로 처리되고 있었다. 데이터베이스 원본 읽기는 극히 일부로 줄었다. 결과적으로 데이터베이스 읽기가 90퍼센트 가까이 줄었다. 인덱스로 각 읽기를 가볍게 하고, 캐시로 읽는 횟수를 줄이니, 두 최적화가 곱해져 큰 효과가 났다.


정리하면 캐시는 자주 조회되고 자주 안 바뀌는 데이터를 저장해 재사용함으로써 데이터베이스 읽기를 근본적으로 줄이고, 여러 층으로 쌓되 유효 시간과 오염 방지를 관리하며, 효과를 측정한다. 다음 편에서는 이 캐시로도 안 막히는 특수한 공격, 봇이 존재하지 않는 페이지를 무한히 긁는 문제와 동적 페이지 상한 방어를 다룬다.


캐시 효과를 측정하는 습관이 최적화를 완성한다. 응답에 캐시 여부를 표시하는 헤더를 붙여 적중률을 확인하면, 캐시가 실제로 얼마나 일하는지 보인다. 적중률이 낮으면 유효 시간이나 키 설계를 손봐야 한다는 신호다. 측정 없이 캐시를 쌓으면 효과가 있는지 없는지도 모른 채 방치된다.


다음 편에서는 이 캐시로도 안 막히는 특수한 문제, 봇이 존재하지 않는 페이지를 무한히 긁는 상황과 그 방어를 다룬다. 캐시가 만능이 아니라는 걸, 그리고 문제마다 맞는 방어가 따로 있다는 걸 보여주는 사례다.