목록으로

아키텍처

새로운 프론트엔드 인프라 아키텍쳐를 고민하며

새로운 프론트엔드 인프라 아키텍쳐를 고민하며

1. 새 프로젝트를 맡게 되었습니다#

이전 글 말미에서 지금의 SSR을 ISR + On Demand Revalidation으로 바꾸고 앞단에 CloudFront를 붙여보겠다고 적었었는데요.

그러던 차에 회사에서 새 프로젝트를 맡게 되었습니다. 기존 서비스를 고치는 게 아니라 인프라를 처음부터 설계해야 하는 일이었지요.

새로 만들 서비스는 커뮤니티 성격이 강한 서비스인데요. 콘텐츠 성격을 보면 이전 글에서 다뤘던 질문상세 페이지와 거의 비슷했습니다.

  • 메인 콘텐츠는 유저가 작성한 텍스트
  • 한 번 쓰이고 나면 수정 빈도가 낮음
  • 검색 유입이 주된 유입 경로라 비회원 조회 비중이 높을 것으로 예상

마지막 항목은 막연한 기대는 아닌데요. 기존에 운영하던 커뮤니티 성격의 서비스에서 비회원 비율이 압도적으로 높았습니다. 검색엔진을 통한 자연유입 비중이 컸기 때문이지요. 성격이 비슷한 서비스를 새로 만드는 것이다보니 비슷한 곡선을 그릴 것으로 보고 설계를 시작했습니다.

그러니 목표도 똑같습니다. 렌더 비용을 줄이는 것이지요.

SSR은 요청이 들어올 때마다 서버가 페이지를 새로 그리는 방식이다보니, 구조상 트래픽이 늘면 서버 비용이 그대로 따라 올라갑니다. 콘텐츠가 거의 바뀌지 않는데도 매번 같은 결과를 다시 그리고 있다면 그건 그냥 낭비인 것이고요.

그래서 앞단에 CDN을 두고 대부분의 요청을 엣지에서 끊어내자는 방향은 처음부터 정해져 있었습니다. 문제는 그 다음이었는데요. 막상 설계에 들어가니 결정해야 할 게 생각보다 많았습니다.

  1. CDN과 오리진을 어떤 조합으로 둘 것인가
  2. 페이지를 어떻게 그려서 어디에 둘 것인가
  3. 오리진은 어떻게 보호할 것인가
  4. 캐시는 어떻게 갱신할 것인가
  5. 개인화 데이터는 어떻게 할 것인가

그리고 결론부터 말씀드리면, 이전 글에서 예고했던 ISR 조합과는 다른 결론이 나왔습니다. 왜 그렇게 됐는지를 순서대로 적어보려고 합니다.

다만 미리 말씀드릴 부분이 있는데요. 이 글은 아직 운영 데이터가 없는 설계 단계의 기록입니다. 이전 글처럼 개선 전후 수치를 들고 오는 글이 아니라, 무엇을 근거로 이렇게 정했는지를 남겨두는 글에 가깝습니다. 숫자는 서비스가 뜨고 나서 다시 적어보려고 합니다.


2. CDN과 오리진을 어떤 조합으로 둘 것인가#

1) 출발점은 요구사항이었습니다#

먼저 밝혀둘 게 있는데, CDN 벤더는 제가 고른 게 아닙니다. CTO님 쪽에서 Cloudflare를 요구하셨고, 이유는 CloudFront와 비교했을 때 비용 차원에 이점이 있다고 하셨습니다.

그래서 제가 판단해야 할 건 "Cloudflare를 쓸 것인가"가 아니라 **"Cloudflare를 쓴다면 어디까지 옮길 것인가"**였습니다. 애플리케이션은 EKS 위의 Next.js 파드로 띄운다는 것까지는 정해져 있었으니, 그 앞뒤를 어디서 끊느냐의 문제였지요.

그래서 익숙한 AWS 단일 구성을 기준선으로 놓고 비교를 시작했습니다.

2) 기준선 — 전부 AWS로 가는 구성#

CloudFront + EKS 구성. 모든 구성 요소가 한 벤더 안에 들어온다

가장 단순한 구성입니다. 장점이 꽤 명확한데요.

  • IAM·보안그룹으로 통합 접근제어
  • CloudFront–ALB처럼 네이티브 통합이 이미 갖춰져 있음
  • 단일 벤더라 장애 대응·과금·설정 창구가 하나

다만 단점도 같은 자리에서 나옵니다.

  • CloudFront 데이터 전송 비용이 트래픽 커질수록 부담
  • AWS 종속성 심화
  • WAF·봇 방어 등은 별도 서비스로 붙여야 함

3) CDN만 Cloudflare로 빼는 구성#

Cloudflare CDN + EKS 구성. CDN만 떼어내고 애플리케이션은 VPC 안에 그대로 둔다

이 구성의 포인트는 옮기는 게 CDN 하나뿐이라는 점입니다. 애플리케이션이 도는 곳도, 데이터가 사는 곳도 전부 VPC 안에 그대로 남고 맨 앞 레이어만 바뀌는 것이지요.

그러면서 기준선의 단점으로 적었던 두 가지 — 전송 비용과 별도로 붙여야 하는 보안 계층 — 는 CDN 레이어에서 해소됩니다. CTO님이 말씀하신 비용 이점도 결국 첫 번째 항목에 해당하는 이야기이고요.

대가는 세 가지였습니다.

  • 벤더 두 곳 운영 (장애 대응·과금·설정 창구가 이원화)
  • CDN → 오리진 인터넷 구간에 오리진 보호 설정이 필수
  • CloudFront–ALB 같은 네이티브 통합의 편의는 포기

4) 무엇을 택했나#

Cloudflare로 CDN만 빼는 구성으로 갔고, 채택 기준은 사실상 하나였습니다. 비용이지요.

두 CDN은 과금 구조 자체가 다릅니다.

  • CloudFront — 전송량(GB)과 요청 수에 따라 붙는 변동비. 게다가 단가가 엣지 리전별로 다름
  • Cloudflare — 플랜당 고정비. HTTP/S 대역폭은 전 플랜 무과금

구조가 이렇게 다르다보니 감으로는 비교가 안 돼서, 목표 트래픽을 놓고 직접 계산해봤습니다. 가정은 이렇게 잡았는데요.

  • 1,000만 페이지뷰
  • 페이지당 평균 전송량 500KB (텍스트 위주 + 정적 에셋, 재방문은 브라우저 캐시 감안) → 월 약 5TB
  • 페이지당 평균 요청 10건 → 월 약 1억 건

여기에 2026년 기준 공개 요금을 대입해보면요. CloudFront는 아시아(한국·일본·싱가포르 등) 엣지 기준 GB당 $0.120이고, HTTPS 요청은 1만 건당 $0.0125입니다. 영구 무료 티어로 월 1TB 전송과 1,000만 요청이 빠지고요.

  • 전송 — (5,120GB − 1,024GB) × $0.120 = 약 $492
  • 요청 — (1억 − 1,000만) ÷ 10,000 × $0.0125 = 약 $113
  • 합계 — 월 약 $600 (환율 1,400원 기준 84만원 남짓)

반면 Cloudflare는 Business 플랜이 월 $250(연간 결제 시 $200)이고, 대역폭은 얼마가 나가든 추가 과금이 없습니다. 5TB를 쓰든 50TB를 쓰든 청구서는 그대로인 것이지요.

월 $600 대 월 $250. 대략 2.4배 차이인데, CloudFront에 보안 번들 할인(최대 30%)을 붙여도 $420 선이라 순서는 바뀌지 않았습니다.

계산해보고 나서야 알았는데, 여기서 제일 크게 작용한 변수는 우리 사용자가 한국에 있다는 점이었습니다. CloudFront는 엣지 리전별로 단가가 다른데 아시아 엣지는 GB당 $0.120으로, 미국·유럽의 $0.085보다 41% 비쌉니다. Price Class를 낮춰 단가를 깎는 방법이 있긴 하지만 그건 사용자가 실제로 미국·유럽에 있을 때 얘기고요. 하필 우리 서비스가 가장 비싼 구간에 해당했던 셈입니다.

다만 정직하게 덧붙이면, 트래픽이 작을 때는 CloudFront가 더 쌉니다. 무료 티어 1TB 안에서 놀면 CloudFront는 $0인데 Cloudflare는 Pro만 해도 $25니까요. 손익분기를 계산해보면 Pro 기준으로는 월 1.2TB(약 240만 PV), Business 기준으로는 월 3.1TB(약 620만 PV) 근처였습니다. 목표로 잡은 1,000만 PV는 그 선을 이미 넘어서는 지점이었고요.

그리고 금액보다 더 크게 봤던 건 변동비냐 고정비냐였습니다. 커뮤니티 서비스는 글 하나가 터지면 트래픽이 몇 배로 뛰는 일이 흔한데, CloudFront에서는 그 스파이크가 그대로 다음 달 청구서에 실립니다. Cloudflare에서는 실리지 않고요. 예산을 잡는 입장에서는 이 차이가 꽤 컸습니다ㅎㅎ

한 가지 주의할 점도 적어둡니다. Cloudflare의 무제한 대역폭은 일반적인 웹 콘텐츠를 전제로 한 것이라, 약관상 비디오나 대용량 바이너리를 불균형하게 전송하는 경우는 범위 밖입니다. 텍스트 위주 커뮤니티라면 문제될 게 없지만, 나중에 영상 같은 대용량 콘텐츠를 붙이게 되면 그때는 별도로 논의해야 할 부분으로 보입니다.

참고로 위 숫자는 전부 공개된 리스트 가격 기준이고, 실제 계약 조건과는 다를 수 있다는 점은 감안해주시면 좋겠습니다.


3. 페이지를 어떻게 그려서 어디에 둘 것인가#

여기가 이번 설계에서 가장 오래 고민한 부분입니다. 그리고 이전 글의 계획에서 방향이 갈라진 지점이기도 하고요.

1) 두 갈래의 선택지#

CDN을 앞에 두기로 한 이상, 엣지에 올릴 HTML을 누가 언제 그리느냐를 정해야 했습니다. 크게 두 갈래였는데요.

  • 저장소를 끼우는 방식 — 정적 페이지를 S3 / R2에 보관해두고 CDN이 그걸 가져다 쓰는, ISR + Custom Cache Handler 조합
  • 저장소 없이 가는 방식 — SSR로 실시간 렌더한 결과를 CDN에 곧바로 캐시

중요한 건 두 방식이 엣지 히트 시에는 완전히 동일하다는 것입니다. 캐시에 있으면 어느 쪽이든 CDN이 즉시 응답하고 끝이니까요. 차이는 오직 엣지 미스 이후에만 생깁니다.

미스 이후의 처리. 저장소가 있으면 렌더를 건너뛸 수 있지만, 그러려면 계층이 하나 더 필요하다

저장소를 끼우는 쪽은 분명히 매력적입니다. 이미 그려둔 결과가 있으면 렌더 단계를 통째로 건너뛰다보니, 렌더 발생 횟수에 상한이 생기는 셈이니까요. 최초 생성과 데이터 변경 시에만 그리면 되고 그 외에는 읽기만 하면 됩니다.

2) 그럼에도 저장소를 끼우지 않은 이유#

그런데 저장소를 끼우는 순간 갱신 경로가 두 갈래가 됩니다.

  1. 저장소에서 해당 페이지를 제거하고
  2. CDN에서도 그 페이지 캐시를 초기화해야 함

호출 횟수만 놓고 보면 별것 아닙니다. 무효화 함수 안에 두 줄 적으면 되는 일이지요. 진짜 비용은 둘 중 하나만 성공했을 때 발생합니다. 두 계층이 어긋난 채로 서빙되기 시작하고, 장애가 나면 "지금 이 페이지가 어느 층에서 stale인지"를 사람이 추론해야 하는데요. 운영 중에 이걸 매번 따지는 그림이 잘 그려지지 않았습니다.

반면 저장소를 걷어내면 무효화 지점이 CDN 한 곳뿐입니다. 그런데 이게 단순히 손이 덜 간다는 얘기가 아닌데요. 틀릴 수 있는 상태의 개수가 줄어듭니다.

저장소가 있으면 갱신이 실패했을 때 시스템이 잘못된 상태로 굳어버립니다. 저장소에는 옛 페이지가 남아 있는데 CDN은 비어 있거나, 그 반대이거나요. 어느 쪽이든 사람이 들어가서 맞춰줘야 풀리는 상태입니다. 반면 저장소가 없으면 무효화가 실패했을 때 나올 수 있는 결과가 하나뿐인데요. 캐시가 안 지워져서 옛 페이지가 조금 더 서빙되거나, 지워져서 미스가 나거나 둘 중 하나입니다.

그리고 그 미스라는 게 이미 정상 경로입니다. 수정·삭제로 캐시가 지워졌다면 오리진이 페이지를 새로 그리는 건 예외 상황이 아니라 설계상 당연히 일어나야 할 일이니까요. 애초에 CDN이 있든 없든 SSR 서버는 요청이 오면 그리는 게 본업이고, 캐시는 그 본업을 덜어주는 장치일 뿐입니다. 캐시가 비면 본업으로 돌아가는 것뿐이지요.

그러니 이 구조에서는 무효화가 실패해도 다음 요청에서 알아서 정상으로 돌아옵니다. 두 계층을 맞춰주러 사람이 들어갈 일이 없습니다. 운영 중에 뭔가 이상할 때 던져야 할 질문도 "이 페이지 캐시가 살아 있나 없나" 하나로 끝나고, "저장소와 CDN 중 어디가 어긋났나"를 따질 일이 없고요.

사실 이게 이번 선택의 가장 큰 이유였습니다.

여기에 하나 더 있었는데요. 이번 구성은 원래 CloudFront + SSR로 구상했던 그림을 Cloudflare로 옮긴 것입니다. 저장소는 원래 없던 요소이다보니, 옮기는 김에 캐시 아키텍처까지 같이 바꾸면 문제가 생겼을 때 원인이 둘로 갈립니다. 한 번에 하나만 바꾸자는 판단도 작용했습니다.

3) 무엇을 포기했는지도 적어둡니다#

다만 이 선택이 공짜는 아닙니다. 정리해보면 서버 자원 측면에서는 오히려 불리한 쪽을 고른 것으로 보였는데요.

저장소 없이 가면 이렇게 됩니다.

  • 렌더는 엣지 미스가 날 때마다 발생 — 트래픽에 비례하고 상한이 없음
  • 무효화 지점은 CDN 한 곳
  • 롱테일 페이지는 축출되면 데이터가 같아도 다시 그림

저장소를 끼웠다면 이랬을 겁니다.

  • 렌더는 최초 생성과 데이터 변경 시에만 — 페이지 수와 변경 횟수에 묶여 상한이 있음
  • 무효화 지점은 저장소와 CDN 두 곳
  • 롱테일 페이지도 저장소에서 읽기만 하면 됨

즉 이번 결정은 렌더 비용의 상한을 포기하는 대신 무효화를 한 뎁스로 끌어내린 교환인 셈입니다.

그리고 이 교환이 성립하려면 전제가 하나 필요한데요. 엣지 히트율이 충분히 높아야 한다는 것입니다. 히트율이 높으면 렌더는 어차피 드물게만 발생하니 상한이 없어도 무해하지만, 히트율이 떨어지면 포기한 상한이 곧바로 비용으로 돌아오기 때문이지요.

그럼에도 이쪽을 고른 건, 운영에서 사람이 개입해야 하는 상황을 없애는 쪽의 값어치를 더 크게 봤기 때문입니다. 렌더 비용은 결국 파드를 늘리면 해결되는 문제인데요. 두 계층이 어긋난 채로 서빙되는 상황은 돈으로 해결되지 않습니다. 장애 대응 중에 "지금 어느 층이 잘못됐지"를 따지고 앉아 있을 시간을 생각하면 더 그렇고요.

물론 이 판단이 틀렸다고 나올 수도 있습니다. 다행히 cacheHandler를 나중에 붙이는 건 CDN 레이어도 무효화 경로도 건드리지 않는 더하기 작업이다보니, 지표가 아니라고 말해주면 그때 붙이면 됩니다.


4. 오리진은 어떻게 보호할 것인가#

CDN을 Cloudflare로 빼는 순간 생긴 숙제입니다. CDN에서 오리진으로 가는 구간이 인터넷을 타게 되다보니 오리진을 그냥 열어둘 수는 없는데요.

후보는 두 가지였습니다.

  • Cloudflare Tunnel — 오리진에 cloudflared를 띄워 아웃바운드 전용 연결만 맺는 방식
  • ALB + IP allowlist — 공개 엔드포인트는 두되 Cloudflare 대역만 통과시키는 방식

이 영역은 제 챕터 밖이라 인프라 개발자분께 여쭤보고 후자로 정해졌다는 답을 받았는데요. 보안 모델만 놓고 보면 Tunnel 쪽이 더 깔끔해 보였던 터라, 왜 그렇게 결정됐을지를 제 나름대로 정리해봤습니다. (실제 근거와 다를 수 있습니다)

1️⃣ 우회 경로가 아예 사라진다

Tunnel은 공개 IP가 없는 구조다보니 오리진에 도달하는 경로가 Cloudflare 하나뿐이 됩니다. CDN 벤더에 장애가 나거나 긴급히 우회해야 할 때 DNS를 돌릴 대상 자체가 없는 것이지요. 벤더를 두 곳 쓰기로 한 선택의 대비책이 오히려 사라지는 셈이라, 이게 가장 크지 않았을까 싶습니다.

2️⃣ Cloudflare를 안 거치는 트래픽은 여전히 남는다

Tunnel은 인그레스 한 경로만 덮습니다. VPC 내부 서비스 간 호출, 헬스체크, CI/CD 스모크 테스트, 모니터링 — 이것들은 굳이 Cloudflare를 통과할 이유가 없지요. 결국 ALB는 두게 되고, 그러면 인그레스 경로가 둘로 갈라져서 보안 검토와 트러블슈팅이 오히려 늘어납니다.

3️⃣ AWS 쪽 통합을 포기해야 한다

AWS WAF, 타깃 그룹 헬스체크, ALB 액세스 로그, AWS Load Balancer Controller 기반 Ingress 구성까지 전부 다시 짜야 합니다. 모니터링 지표도 ALB는 기존 도구에 그대로 들어오는데 cloudflared는 별도 계측을 붙여야 하고요.

4️⃣ 용량에 하드 리밋이 있다

터널 하나당 replica와 연결 수에 상한이 있고, 처리량은 cloudflared 호스트의 포트 수에 묶입니다. ALB는 알아서 스케일하지만 이쪽은 용량 계획 대상이 되는 것이지요.

다만 IP allowlist만으로는 부족하다는 점은 짚고 넘어가야 할 것 같습니다. Cloudflare의 IP 대역은 공개되어 있고 모든 Cloudflare 고객이 공유하다보니, allowlist만 걸어두면 누군가 우리 오리진 호스트명을 알고 자기 Cloudflare 계정에 오리진으로 등록해도 그대로 통과하기 때문인데요. mTLS나 최소한 공유 시크릿 헤더 검증을 함께 두는 방향으로 논의해보려고 합니다.


5. 캐시는 어떻게 갱신할 것인가#

무효화 지점을 CDN 한 곳으로 만들었으니, 이제 그 한 곳을 잘 쏘는 게 중요해집니다.

방식은 Cloudflare가 제공하는 Cache-Tag 기반으로 정했습니다. 응답 헤더에 태그를 실어두고 콘텐츠가 바뀌면 해당 태그를 퍼지하는 방식인데요. 예전에는 Enterprise 전용이었지만 지금은 전 플랜에서 태그 퍼지를 쓸 수 있습니다.

Cache-Tag 무효화 경로. 태그 입도가 곧 재렌더 범위가 된다

여기서 주의할 게 하나 있는데요. 퍼지된 태그가 붙은 콘텐츠는 강제로 MISS 처리됩니다. 저희는 오리진에 영속 캐시를 두지 않기로 했다보니, 이 MISS는 곧바로 EKS의 렌더로 이어지지요.

태그 하나에 몇 개의 페이지가 묶여 있느냐가 퍼지 1회당 재렌더될 페이지 수가 됩니다. 말로만 하면 잘 와닿지 않으니 예시를 들어보면요. 칼럼 데이터 하나가 이렇게 세 페이지에 걸쳐 있다고 해보겠습니다.

  • A 페이지 — 질문 상세. 질문·답변·댓글 데이터에 더해 관련 전문가 칼럼 요약이 곁들여져 있음
  • B 페이지 — 그 전문가 칼럼의 상세 페이지
  • C 페이지 — 그 칼럼이 포함된 전문가 칼럼 리스트 페이지

셋 모두 id=2인 칼럼 데이터를 참조하고 있으니, 응답 헤더에도 column:2 태그를 달게 됩니다. 그러면 이 칼럼의 제목 한 줄만 수정되어도 column:2 퍼지가 발동하고, A·B·C 세 페이지의 캐시가 한꺼번에 날아갑니다. 이후 들어오는 요청은 셋 다 미스가 나서 오리진으로 향하게 되고요.

칼럼 제목 한 줄만 바뀌어도 column:2를 단 페이지는 모두 함께 날아간다

여기서 짚어볼 게 두 가지 있는데요.

먼저 A 페이지는 다소 억울하게 다시 그려집니다. 질문도 답변도 댓글도 그대로인데, 곁들여둔 칼럼 요약의 제목 한 줄 때문에 페이지 전체를 새로 렌더하게 되는 것이지요. 퍼지는 페이지 단위로 걸리다보니 "칼럼 요약 부분만 갱신"이라는 선택지가 없습니다.

그리고 C 페이지가 더 문제입니다. 리스트 페이지는 한 화면에 칼럼을 여러 개 얹다보니 column:2뿐 아니라 column:7, column:11 … 을 전부 달게 됩니다. 즉 목록에 올라온 칼럼 중 아무거나 하나만 바뀌어도 매번 퍼지 대상이 되는 것인데요. 하필 리스트 페이지는 그려야 할 게 많아 렌더 단가도 높은 편이라, 가장 비싼 페이지가 가장 자주 지워지는 그림이 됩니다.

그래서 태그를 columns처럼 엔티티 종류 단위로 굵게 잡는 건 애초에 선택지가 될 수 없습니다. 칼럼 아무거나 하나만 바뀌어도 칼럼을 참조하는 모든 페이지가 날아갈 테니까요. column:2처럼 id 단위까지 내려가는 게 최소 조건인 셈인데, 거기까지 내려가도 팬아웃은 그대로 남습니다. 렌더 비용을 아끼려고 시작한 설계인데 여기서 다 까먹을 수도 있는 부분이라, 태그 스키마 설계가 생각보다 중요한 작업으로 보였습니다.

반대로 태그를 너무 잘게 쪼개면 퍼지 요청 수가 늘어나 rate limit에 먼저 걸립니다. 요청당 처리 가능한 태그 수에도 상한이 있다보니, 변경 이벤트마다 개별 퍼지를 쏘는 게 아니라 짧은 윈도우로 태그를 모아 배치로 보내는 큐가 사실상 필수일 것으로 보입니다.


6. 개인화 데이터는 어떻게 할 것인가#

사실 이 부분은 이전 글에서 이미 한 번 제안했던 내용인데요. 당시엔 타 챕터와의 협업이 필요한 작업이었고 비즈니스 업무에 밀려 공수가 나지 않아 다음을 기약했었습니다. 이번엔 처음부터 설계하는 자리이다보니 아예 이 구조로 시작하기로 했습니다.

원칙은 단순합니다. 캐시 대상은 비회원 기준 shell 페이지뿐입니다.

여기서 shell이라고 부르는 건 CDN에 캐시되는 페이지 그 자체입니다. 유저 데이터가 입혀지지 않아 누가 열어도 똑같이 보이는 페이지라고 생각하시면 되는데요. 구체적으로는 이렇게 나눴습니다.

  • 글의 제목·본문, 좋아요 수·조회수처럼 회원·비회원 공통 데이터는 SSR로 미리 렌더
  • 좋아요/싫어요를 내가 눌렀는지 여부처럼 유저마다 달라지는 데이터만 별도 API로 분리
  • 클라이언트에서 쿠키 기반으로 토큰을 판단한 뒤 그 API를 호출해 UI에 덮어씌움
비회원은 엣지에서 끝나고, 회원만 백엔드 API를 한 번 더 탄다

이 구조의 이점은 두 군데에서 나옵니다.

먼저 캐시 사본이 하나로 유지됩니다. A 유저 정보로 렌더된 페이지가 CDN에 캐시되었다가 B 유저에게 서빙되면 곤란한데요. 애초에 shell에 개인 정보가 들어가지 않으니 이 문제가 생길 여지가 없고, 캐시 키에 쿠키를 넣을 일도 없습니다.

그리고 비회원은 개인화 API를 아예 트리거하지 않습니다. 비회원 입장에서는 어차피 좋아요 여부라는 게 존재하지 않다보니, 불필요한 데이터를 받지 않게 되는 것이지요. 검색 유입 기반이라 비회원 비중이 높을 것으로 보는 서비스에서는 이 차이가 꽤 클 것으로 보입니다.

그리고 한 가지 더 짚어둘 게 있는데요. 이 개인화 API가 향하는 곳은 Next.js 파드가 아니라 백엔드 API 서버입니다. 앞에서 계속 "오리진"이라고 불렀던 렌더 서버와는 아예 다른 계층인 것이지요.

그러다보니 회원이 늘어 개인화 호출이 늘어도 그 부하가 렌더를 담당하는 EKS 파드에 쌓이지 않습니다. 문서 트래픽과 개인화 트래픽이 서로의 용량을 잠식하지 않는 셈인데요. 프론트 쪽 파드는 엣지 미스만 처리하면 되고, 개인화 호출 뒤에서 어떤 캐시 전략을 쓸지는 백엔드 챕터에서 준비해둔 Redis가 받아주는 영역이라 이 글의 범위 밖이기도 합니다.

여기에 부수적인 효과가 하나 더 있습니다. 개인화를 걷어내면 shell이 그려야 할 게 줄어드니 렌더 1회당 단가가 내려갑니다. 3번에서 렌더 횟수의 상한을 포기했다고 적었는데, 횟수의 상한은 못 두더라도 단가는 낮춘 셈이라 두 결정이 같은 방향으로 맞물리게 되었습니다.


7. 앞으로 검증해야 할 것들#

여기까지가 설계 단계에서 내린 결정들이고, 아직 운영 데이터로 검증되지 않은 부분이 남아 있습니다. 스스로 못 박아두는 의미에서 적어둡니다.

1) 엣지 히트율과 오리진 렌더 횟수#

요청 중 몇 퍼센트가 엣지에서 끝났는지, 그리고 오리진이 페이지를 실제로 몇 번 그렸는지 이 두 값이 3번에서 적은 교환이 유효한지를 검증해줍니다. 특히 미스의 출처가 명시적 퍼지인지, TTL 만료인지, 롱테일 페이지의 축출인지를 구분해서 봐야 할 것 같은데요. 저는 축출 쪽이 가장 클 것으로 보고 있습니다. 검색엔진은 유저를 최신 글로만 보내지 않다보니 조회가 넓게 퍼질 수밖에 없고, 그러면 PoP별 캐시에서 밀려나는 페이지도 같이 늘어나니까요.

축출이 지배적이라면 데이터가 바뀌지 않았는데도 재렌더가 계속 발생하고 있다는 뜻이고, 그때가 cacheHandler를 붙일 시점이라고 보고 있습니다. 역설적이지만 이 설계를 택하게 만든 서비스 성격이 동시에 이 설계를 뒤집을 근거가 될 수도 있는 셈이라, 이 지표는 특히 눈여겨보려고 합니다.

덧붙여 프론트 쪽 파드는 개인화 호출을 받지 않다보니, 오토스케일링 기준도 전체 요청 수가 아니라 엣지에서 미스가 나서 오리진까지 내려온 요청 수가 되어야 맞을 것 같습니다.

2) 카운트는 얼마나 밀려도 되는가#

좋아요 수나 조회수는 누가 봐도 같은 값이니 공통 데이터고, 그래서 shell에 포함됩니다. 개인 데이터로 빠지는 건 "내가 눌렀는지" 여부뿐이지요.

그러다보니 캐시가 살아 있는 동안에는 숫자가 멈춰 있게 되는데요. 이건 설계상 불가피한 결과이고, 감수하기로 했습니다. 카운트가 1 올라갈 때마다 태그를 퍼지해버리면 5번에서 본 팬아웃이 그대로 발동해서, 렌더 비용을 아끼려던 목적과 정면으로 충돌하니까요. 인기 글일수록 좋아요가 자주 눌릴 텐데 그때마다 페이지를 다시 그린다면 CDN을 붙인 의미가 없어집니다.

다만 사용자가 직접 누른 액션만큼은 즉시 반영되어야 합니다. 좋아요를 눌렀는데 숫자가 그대로면 눌린 게 맞나 싶어지니까요. 개인화 API에서 "내가 눌렀다"는 값이 돌아오니, 클라이언트에서 그만큼을 카운트에 더해 그려주는 정도로 보정할 생각입니다.

결국 남는 질문은 TTL을 얼마로 둘 것인가 하나인데요. 숫자가 몇 분쯤 밀려도 어색하지 않은지는 서비스 성격과 유저 감각에 달린 문제라, 이건 운영해보면서 조정할 수밖에 없을 것 같습니다.

3) 태그 스키마와 Cloudflare 플랜#

변경 이벤트 하나가 어디까지 영향을 주는지 — 상세 페이지 외에 목록·피드·프로필까지 — 를 먼저 그려야 태그 입도를 정할 수 있습니다. 5번에서 적었듯 태그를 굵게 잡으면 재렌더 범위가 커지고, 잘게 쪼개면 퍼지 요청 수가 늘어나는 트레이드오프가 있어서요.

방향 자체는 자명합니다. 렌더 비용을 아끼려고 시작한 설계이다보니 요청 수보다 재렌더 범위를 줄이는 쪽에 무게를 두는 게 맞겠지요. 문제는 그러려면 퍼지 요청이 늘어나는 걸 받아줄 수 있어야 한다는 것인데, 퍼지 rate limit은 플랜에 따라 차이가 꽤 큽니다. 그래서 예상 퍼지 빈도가 나와야 필요한 플랜도 정해지는 순서라, 이 둘은 같이 결정할 예정입니다.

물론 태그를 잘게 쪼갠다고 해서 공짜는 아닙니다. 응답 헤더에 실을 수 있는 태그 총량에도 상한이 있다보니, 5번의 C 페이지처럼 목록에 걸린 항목을 전부 태그로 박는 설계는 또 다른 벽에 부딪힙니다. 리스트 페이지는 아예 캐시 전략을 달리 가져가야 할지도 고민해볼 지점이고요.

그리고 태그를 누가 붙일 것인가도 정해야 합니다. 페이지마다 손으로 태그를 나열하는 방식이면 A 페이지처럼 곁들여진 데이터의 태그를 빠뜨리기 쉬운데요. 태그가 누락되면 퍼지를 쏴도 그 페이지는 안 지워지고, 결국 TTL이 끝나거나 축출될 때까지 옛 제목을 보여주게 됩니다. 데이터를 조회하는 지점에서 태그가 자동으로 따라붙는 구조로 가야 할 것 같습니다.


8. 정리#

이전 글을 쓸 때만 해도 답은 정해져 있다고 생각했습니다. 정적 렌더링으로 바꾸고 CDN을 붙이면 되는 것 아닌가 싶었지요.

그런데 실제로 처음부터 설계를 해보니, 정답을 찾는 일이라기보다는 무엇을 포기할지를 고르는 일에 가까웠습니다. 저장소를 끼우면 렌더 비용에 상한이 생기지만 무효화가 두 뎁스가 되고, 걷어내면 무효화는 단순해지지만 렌더 비용에 상한이 없어집니다. 어느 쪽도 공짜가 아니었고요.

그래서 제가 결정권을 쥔 영역에서 기준으로 삼은 건 성능 수치가 아니라 운영에서 사람이 개입해야 하는 지점을 줄이는 것이었습니다. 저장소를 끼우지 않은 것도, 개인화를 클라이언트로 뺀 것도 결국 같은 얘기고요. 그리고 그 판단이 틀렸을 때 되돌릴 수 있게 남겨두는 것까지가 한 세트라고 봤습니다.

이 작업이 저에게 의미가 있었던 건, 그동안은 이미 깔려 있는 인프라 위에서 최적화를 해왔는데 이번엔 그 밑단부터 직접 정해봤기 때문인 것 같습니다. 이전 글에서 이렇게 바꾸겠다고 적었던 계획을 백지에서 다시 검토하니 다른 결론이 나왔다는 것도 재밌었고요.

물론 아직 검증되지 않은 가정 위에 서 있는 설계입니다. 운영 데이터가 쌓이면 이 판단들이 맞았는지 다시 회고해보려고 하는데, 특히 3번에서 적은 교환이 실제로 남는 장사였는지가 제일 궁금하네요ㅎㅎ