Troubleshooting 2026.08.12 10 min read

Ghost 블로그가 느려진 원인 분리하기: 테마 중복 조회부터 Cloudflare LAX 라우팅까지

내 Ghost 블로그의 느려짐을 원점 렌더링, Kubernetes Probe, 백업 부하, Cloudflare LAX/SJC 우회 경로로 나눠 진단하고 공개 HTML Edge Cache로 완화한 과정을 정리했다.

이슈 발생: 2026-08-11 · 검증: 2026-08-12
환경: Ghost 6.56, RKE2 Kubernetes, Cloudflare Tunnel, Cloudflare Cache

내가 운영하는 Ghost 블로그의 응답이 눈에 띄게 느려졌다. 처음에는 Ghost나 Kubernetes 자원 문제를 의심했지만, 측정 결과는 한 가지 원인으로 설명되지 않았다. 홈 테마는 같은 데이터를 여러 번 조회했고, Kubernetes 상태 점검은 주기적으로 홈 전체를 렌더링했다. 백업 작업은 같은 저장소에 부하를 더했고, 한국 사용자의 공개 요청은 Cloudflare ICN이 아니라 LAX와 SJC로 우회했다.

이번 작업에서는 애플리케이션 안쪽의 낭비와 외부 네트워크 경로를 따로 측정했다. 중복 조회를 줄여 원점 응답을 개선하고, 공개 HTML에는 짧은 Edge Cache를 적용했다. 한국 회선이 미국 Cloudflare POP로 들어가는 경로는 일반 Cloudflare Anycast 정책에서 운영자가 ICN으로 고정할 수 없다. 이 부분은 통제할 수 없는 제약으로 남기고 캐시로 영향만 줄였다.

느리다는 현상을 구간별로 나눴다

브라우저에서 느리게 보인다는 사실만으로 Ghost가 느리다고 결론 내리면 안 된다. 요청 시간을 다음 구간으로 나눠 확인했다.

사용자
  → DNS
  → Cloudflare 접속 POP
  → Cloudflare Tunnel
  → Kubernetes Service
  → Ghost 테마 렌더링
  → MySQL

같은 URL을 외부와 원점에 가깝게 각각 측정하고, Cloudflare 응답에서는 CF-RAY, CF-Cache-Status, Age를 함께 확인했다.

curl.exe -sSI https://sw-data.com/ |
  Select-String 'cf-cache-status|age|cf-ray|cache-control'

curl.exe -s https://sw-data.com/cdn-cgi/trace
curl.exe -s https://1.1.1.1/cdn-cgi/trace

직접 측정한 원점 응답은 공개 경로보다 훨씬 빨랐다. 이 차이 덕분에 Ghost 내부 최적화와 Cloudflare 라우팅을 별개의 문제로 다룰 수 있었다.

홈 테마의 get 9개를 먼저 분해했다

Ghost 테마의 {{#get}}은 브라우저가 보내는 단순 HTTP GET을 뜻하지 않는다. 서버가 페이지를 렌더링하면서 Content API 데이터를 가져오는 Helper다. 홈 템플릿을 확인하니 한 번의 렌더링에서 get을 9번 사용했다.

그중 네 곳은 홈 컨텍스트가 이미 가진 데이터를 다시 조회했다.

  • EVIDENCE 영역의 전체 글 수
  • 상단 시각화 영역의 최근 업데이트 날짜
  • Signal Strip의 전체 글 수
  • Signal Strip의 최근 업데이트 날짜

전체 글 수는 홈 컨텍스트의 pagination.total을 그대로 사용하고, 최근 날짜는 이미 전달받은 posts의 첫 항목에서 가져오도록 바꿨다. 화면에 표시하는 숫자와 날짜는 그대로 유지했다.

{{!-- 변경 전: 같은 전체 글 수를 다시 조회 --}}
{{#get "posts" limit="100"}}
  <strong>{{pagination.total}}</strong>
{{/get}}

{{!-- 변경 후: 현재 홈 컨텍스트 재사용 --}}
<strong>{{pagination.total}}</strong>

RCA 글 수, Troubleshooting 글 수, 전체 Topic 수, 상위 Tag 6개, 최신 Troubleshooting 카드처럼 별도 필터가 필요한 다섯 조회는 유지했다. 결과적으로 콘텐츠와 레이아웃을 바꾸지 않고 get을 9개에서 5개로 줄였다.

변경 전후에 원점 TTFB를 여러 번 측정했다. 평균은 약 314ms에서 184ms로, 중앙값은 약 241ms에서 163ms로 줄었다. 이 수치는 네트워크 우회를 제거한 값이 아니라 Ghost가 홈을 렌더링하는 비용을 줄인 결과다.

Kubernetes Probe가 홈 전체를 계속 렌더링하지 않게 했다

Ghost Pod의 startup, liveness, readiness Probe가 모두 /를 호출하도록 설정해놨었다. 홈은 테마 렌더링과 데이터 조회를 수행하므로 단순 생존 확인 경로로는 무겁다. 방문자가 없어도 Kubelet이 계속 실제 홈 렌더링을 발생시키는 구조였다.

상태 점검의 목적에 맞춰 세 가지를 분리했다.

  • startupProbe: Ghost가 포트를 열었는지 TCP로 확인
  • livenessProbe: 프로세스가 응답 가능한지 TCP로 확인
  • readinessProbe: /ghost/api/admin/site/ 경량 API로 실제 요청 처리 가능 여부 확인
startupProbe:
  tcpSocket:
    port: http

livenessProbe:
  tcpSocket:
    port: http

readinessProbe:
  httpGet:
    path: /ghost/api/admin/site/
    port: http

변경 전 경량 API가 200을 반환하는지 확인하고 Deployment를 롤아웃했다. 새 Pod가 Ready 상태로 들어온 뒤에도 공개 페이지와 Admin 경로를 각각 확인했다.

같은 저장소를 사용하는 백업 부하도 분리했다

백업은 30분마다 실행하고 매번 Restic prune까지 수행하도록 구성해놨었다. prune은 단순 스냅샷 생성보다 저장소를 넓게 읽고 정리한다. Google Drive API의 userRateLimitExceeded가 발생한 상태에서 재시도와 prune을 짧은 주기로 반복하면 블로그 성능과 무관한 백그라운드 부하까지 커진다.

백업 주기는 6시간으로 늘리고 prune은 일요일 새벽의 별도 CronJob으로 분리했다. rclone은 동시 전송 3개, API TPS 4로 제한하고 pacer와 low-level retry를 적용했다. 백업 컨테이너가 Secret의 읽기 전용 설정 파일을 직접 수정하지 않도록 writable emptyDir에 복사해서 사용하게 바꿨다.

설정 오류는 제거했지만 기존 Google Drive 사용자 할당량 제한은 즉시 사라지지 않았다. 검증 Job에서 같은 403이 계속되자 추가 호출을 만들지 않도록 중단했다. 이 부분은 애플리케이션 성능 조치와 분리해 다음 예약 실행에서 다시 확인한다.

cloudflared를 갱신하고 Tunnel 상태를 다시 확인했다

latest 태그로 서로 다른 구버전이 섞이지 않게 cloudflare/cloudflared:2026.7.3으로 고정했다. 두 Connector를 순차 교체한 뒤 모두 Ready, 재시작 0을 확인했다. 각 Connector는 ICN에 QUIC 연결을 등록했고 요청 오류도 증가하지 않았다.

이 결과로 Tunnel Connector 자체가 LAX 우회의 원인일 가능성을 낮췄다. 사용자가 어느 Cloudflare POP로 들어오는지와 cloudflared가 어느 POP에 연결하는지는 같은 설정이 아니다.

같은 한국 회선에서 블로그는 LAX, 1.1.1.1은 ICN으로 갔다

한국의 두 회선에서 /cdn-cgi/trace를 비교했다. 개인 회선 IP는 기록에서 마스킹했다.

한국 회선 A
  sw-data.com → colo=LAX
  1.1.1.1    → colo=ICN

한국 회선 B
  sw-data.com → colo=LAX
  1.1.1.1    → colo=ICN

서울 Cloud VM 대조군
  sw-data.com → colo=ICN
  1.1.1.1    → colo=ICN

한국 회선의 traceroute에서 블로그 주소는 약 140ms의 장거리 구간을 거쳐 미국으로 향했고, 1.1.1.1은 약 10ms로 ICN에 도착했다. DNS가 돌려준 두 Cloudflare IPv4 주소를 curl --resolve로 각각 고정해도 한 주소는 계속 LAX로, 다른 주소는 LAX와 SJC 사이로 연결됐다.

LAX는 로스앤젤레스, SJC는 산호세, ICN은 인천을 나타낸다. 일반 Cloudflare Cache Rule이나 DNS 설정으로 접속 POP를 ICN으로 고정할 수 없다. Cloudflare Anycast와 통신사 BGP 경로가 접속 POP를 선택한다.

공개 HTML만 2분 Edge Cache로 완화했다

Anycast 경로를 직접 고칠 수 없으므로 공개 HTML의 원점 왕복을 줄였다. 로그인이나 개인화 응답을 캐시하지 않도록 처음부터 좁은 조건을 사용했다.

  • 쿠키가 없는 공개 요청만 캐시
  • 쿼리 문자열이 없는 HTML 경로만 캐시
  • Edge TTL 2분, Browser TTL은 원점 설정 유지
  • /ghost, /.ghost, /members, /p/ 등은 강제 우회
  • 우회 규칙을 마지막에 두어 충돌 시 Bypass가 이기게 구성

검증 결과는 의도한 대로 나왔다.

공개 홈 1회차  cf-cache-status: MISS  POP: SJC
공개 홈 2회차  cf-cache-status: MISS  POP: LAX
공개 홈 3회차  cf-cache-status: HIT   Age: 3  POP: LAX

/ghost/        Cache-Control: private, no-store
                Age 없음

Cookie 포함 홈 cf-cache-status: DYNAMIC
                Age 없음

처음 두 요청이 연속 MISS였던 이유는 요청이 SJC와 LAX의 서로 다른 Cache로 들어갔기 때문이다. 세 번째 요청이 LAX의 기존 객체를 찾아 HIT가 되면서 규칙 자체는 정상임을 확인했다.

캐시 HIT도 한국에서 약 0.44~0.53초가 걸렸다. 원점 렌더링은 피했지만 TCP와 TLS 연결은 여전히 미국까지 왕복하기 때문이다. Edge Cache는 영향 완화책이지 잘못된 접속 경로의 해결책은 아니다.

최종 결과와 남겨둔 제약

  • 홈 테마의 중복 get: 9개 → 5개
  • 원점 TTFB 중앙값: 약 241ms → 163ms
  • Kubernetes 상태 점검: 홈 렌더링 → TCP와 경량 API
  • 백업: 30분 주기 → 6시간 주기
  • Restic prune: 매 백업 → 주 1회 별도 실행
  • cloudflared: 2026.7.3으로 고정하고 두 Connector 정상 확인
  • 공개 HTML: 2분 Edge Cache, Admin·회원·쿠키 요청은 캐시 제외
  • Cloudflare 접속 경로: 운영자가 POP를 지정할 수 없어 LAX/SJC 경로를 그대로 유지

같은 한국 회선에서 sw-data.com=LAX, 1.1.1.1=ICN으로 갈리는 trace와 서울 대조군을 통해 원점 문제가 아님을 확인했다. Cloudflare는 Anycast 경로가 항상 지리적으로 가장 가까운 데이터센터를 선택하는 것은 아니며, 안정성과 가용성을 위해 더 먼 POP를 선택할 수 있다고 안내한다. 일반 Cache Rule이나 DNS 설정에는 특정 POP를 지정하는 기능이 없으므로 현재 경로를 그대로 두기로 했다.

이번 작업에서 정리한 기준

  1. 느린 페이지는 원점 렌더링과 공개 네트워크 시간을 따로 측정한다.
  2. Ghost 테마의 get은 화면 요소가 아니라 서버 측 데이터 조회라는 점을 기준으로 중복을 찾는다.
  3. Probe도 실제 운영 요청이므로 생존 확인에 필요한 최소 비용만 사용한다.
  4. CF-Cache-Status만 보지 않고 CF-RAYAge를 함께 확인한다.
  5. Cache HIT가 느리면 원점보다 사용자와 접속 POP 사이의 RTT를 먼저 확인한다.
  6. 운영 설정으로 통제할 수 없는 Anycast 경로는 대조군과 trace로 원점 문제와 구분하고, 캐시처럼 통제 가능한 구간에서 영향을 줄인다.

이번 장애는 “Ghost가 느리다”가 아니라 네 개의 서로 다른 문제를 겹쳐서 보여줬다. 테마와 Probe는 내가 통제할 수 있는 원점 비용이었고, 백업은 같은 시간대의 배경 부하였다. 공개 HTML 캐시는 미국 우회의 영향을 줄였지만, 한국 사용자가 LAX와 SJC로 들어가는 경로는 Cloudflare Anycast 정책상 내가 지정할 수 없다. 각 구간을 분리해 측정했기 때문에 직접 해결할 부분과 정책상 그대로 둘 부분을 같은 문장으로 뭉개지 않을 수 있었다.

참고 문서