인터넷에 공개된 IP와 포트는 열어 두는 순간부터 스캐너와 봇의 표적이 된다.(너무 너무 무섭다) 겁이 많은 나는 오라클 A1 무료 서버와 로컬 Mac으로 블로그 플랫폼을 구성하면서 원칙을 하나 정했다. ‘인터넷에서 트래픽이 들어오는 포트를 하나도 열지 않겠다‘

지금 블로그 원본 서버의 공인 IP는 80·443과 22번 포트가 하나도 열려 있지 않다 . 방문자의 요청은 모두 Cloudflare 엣지를 거쳐 Cloudflare Tunnel 로만 방문자 트래픽이 들어오는 구조다. 터널은 원본에 있는 cloudflared 가 Cloudflare 쪽으로 아웃바운드 연결을 하는 방식이라 서버의 방화벽에 웹서비스를(80, 443) 위한 인바운드 규칙을 두지 않아도 서비스가 가능하다. 더불어 Cloudflare의 무료 요금제 CDN도 이 구조에 찰떡같은 궁합을 보여준다.
얻는것이 있다면 잃는것도 있다. 무료 요금제에서는 KT 회선 방문자가 서울(ICN)이 아니라 미국 시애틀(SEA) PoP 로 들어오는 것을 감수해야 한다. 이 글에는 Public IP에 포트를 Open하지 않는 Cloudflare Tunnel과 무료 CDN 을 어떻게 구성했는지, Cloudflare API 로 바꿀 수 없었던 설정이 무엇이 있었는지, 어느 정도의 지연 문제를 감수해야 하는지 실측된 값과 함께 정리했다. 관리용 네트워크는 Tailscale 글에, 서버비 0원 구성 요소를 고른 과정은 무료 구성 요소 조사 글에 따로 정리해 두었다.
목차
A1 인스턴스에 INBOUND Port를 만들지 않는 구조

prod 는 OCI A1(오사카)에서, dev 는 로컬 Mac 에서 돈다. 두 곳 모두 compose 로 nginx·WordPress·n8n 을 띄우고 같은 compose 안의 cloudflared 컨테이너가 Cloudflare 엣지와 QUIC 연결 4개를 유지한다. 엣지가 받은 요청이 이 연결을 타고 cloudflared 로 내려오면 cloudflared 가 컨테이너 네트워크 안의 http://nginx:80 으로 넘긴다. 요청이 안쪽으로 흘러도 연결은 원본이 먼저 열었으니 원본 쪽에서 인바운드 포트를 열 이유가 없는 것이다.
# compose/compose.yaml (발췌)
cloudflared:
image: cloudflare/cloudflared:2026.10.0
restart: unless-stopped
networks:
- public
command: tunnel --no-autoupdate run --token ${CLOUDFLARE_TUNNEL_TOKEN}
nginx:
ports:
# loopback 에만 바인딩한다. 외부 요청은 cloudflared 가 컨테이너 네트워크로 바로 넘긴다
- "${NGINX_HOST_BIND}:80" # dev 127.0.0.1:8080 · prod 127.0.0.1:8090
- "${NGINX_TAILNET_BIND}:8081" # tailnet 전용 리스너, 터널은 여기를 가리키지 않는다
Code language: YAML (yaml)
OCI 쪽은 VCN 을 만들면 기본 Security List 가 22/tcp 를 0.0.0.0/0 에 열어 둔다. 이 규칙은 Terraform 으로 삭제했고 서브넷 Security List 에는 Path MTU Discovery 용 ICMP(type 3, code 4) 하나만 남겼다. 인스턴스에 붙는 NSG 는 인바운드로 SSH 22 하나만 받는다. 출발지는 OCI Bastion 이 있는 서브넷뿐이다.
# terraform/oci/modules/network/security.tf (발췌)
# VCN 기본 Security List 는 22/tcp 를 0.0.0.0/0 에 연다 — 규칙 블록 없이 관리해 비운다
resource "oci_core_default_security_list" "empty" {
manage_default_resource_id = oci_core_vcn.this.default_security_list_id
display_name = "${var.name_prefix}-default-sl-emptied"
}
# 인스턴스 NSG — 인바운드는 Bastion 서브넷에서 오는 SSH 하나뿐
resource "oci_core_network_security_group_security_rule" "ssh_from_subnet" {
network_security_group_id = oci_core_network_security_group.instance.id
direction = "INGRESS"
protocol = "6" # TCP
source = var.subnet_cidr
source_type = "CIDR_BLOCK"
tcp_options {
destination_port_range {
min = 22
max = 22
}
}
}
Code language: plaintext (plaintext)
외부에서 직접 테스트 접속을 해봤다. 집 Mac 에서 A1 공인 IP 로 접속하면 SSH 도, 웹 포트도, nginx 가 호스트에 묶인 8090·8091 도 응답이 없다. 거부(RST)조차 돌아오지 않고 타임아웃으로 끝난다.

관리 작업도 같은 원칙을 따른다. SSH 는 OCI Bastion 의 관리형 SSH 세션으로만 연다. 세션을 만들 때 IAM 권한과 세션 키가 필요하고 만든 세션은 3시간이 지나면 닫힌다. 모니터링 지표 push 와 n8n 의 로컬 AI 호출은 Tailscale(WireGuard)로 A1 에서 Mac 쪽으로만 보낸다. A1 은 tailnet 인바운드도 firewalld 로 막아 두었다.
nginx 는 터널로 들어온 요청의 실제 IP 를 CF-Connecting-IP 헤더 하나로만 판단한다. X-Forwarded-For 는 클라이언트가 값을 넣어 보낼 수도 있어 신뢰하지 않기로 했다. 이 헤더(CF-Connecting-IP)를 사용하는 리스너도 터널이 붙는 80 하나뿐이다. tailnet 용 8081 리스너와는 설정을 섞지 않았다. 두 리스너가 같은 헤더를 믿으면 공개 쪽에서 tailnet 주소를 위조할 틈이 생기기 때문이다. 거기에 더해 로그인 페이지에는 IP 마다 분당 6회(burst 5) 제한을 Nginx로 걸었다.
포기한 것도 있다. 방문자의 TLS 요청은 Cloudflare 엣지에서 터미네이션 된다. 엣지와 원본 사이는 터널이 다시 암호화해도 요청 내용 자체는 Cloudflare 엣지를 지나면 HTTP 평문으로 전송 된다. (🙏 Cloudflare 를 믿슙니다 🙏)
터널과 DNS 는 Terraform 으로 관리한다
Cloudflare 쪽 설정은 대부분 Terraform(Cloudflare provider v5) 코드로 관리한다. 터널은 config_src = "cloudflare" 로 원격 관리 방식을 사용했고 ingress 규칙도 Terraform 의 _config 리소스가 관리한다. 대시보드에서 손을 댈 필요가 없도록 했다.
# terraform/cloudflare/modules/blog/tunnel.tf · dns.tf (발췌)
resource "cloudflare_zero_trust_tunnel_cloudflared" "this" {
account_id = var.account_id
name = var.tunnel_name
config_src = "cloudflare" # 나중에 바꾸면 터널이 다시 만들어진다
tunnel_secret = random_bytes.tunnel_secret.base64
}
resource "cloudflare_zero_trust_tunnel_cloudflared_config" "this" {
account_id = var.account_id
tunnel_id = cloudflare_zero_trust_tunnel_cloudflared.this.id
config = {
ingress = concat(
[for fqdn in local.hostnames : { hostname = fqdn, service = var.origin_service }], # http://nginx:80
[{ hostname = null, service = "http_status:404" }], # 등록하지 않은 호스트는 404
)
}
}
resource "cloudflare_dns_record" "this" {
for_each = local.dns_records
zone_id = var.zone_id
name = each.value.name
type = "CNAME"
content = "${cloudflare_zero_trust_tunnel_cloudflared.this.id}.cfargotunnel.com"
proxied = true # 터널로 가는 CNAME 은 반드시 프록시
ttl = 1 # 프록시 레코드는 automatic
}
Code language: plaintext (plaintext)
ingress 는 맨 앞에 Rule이 우선순위가 높다. 호스트 이름 목록을 정렬해 순서를 고정했다(순서가 바뀌면 plan 마다 차이가 생긴다). 맨 끝에는 404 를 돌려주는 catch-all 을 둔다. DNS 는 모든 호스트가 <터널 ID>.cfargotunnel.com 으로 가는 프록시 CNAME 이라 DNS 를 조회해도 Cloudflare 애니캐스트 IP(104.21.x·172.67.x)만 나온다. 원본 IP 는 DNS 어디에도 없다.
Free-tier의 한계로인해 dev 는 같은 byitx.com 존에 dev- 접두어로 둬야만 했다. Cloudflare의 무료 Universal SSL 인증서는 *.byitx.com 한 단계만 지원한다. blog.dev.byitx.com 같은 두 단계의 Subdomain을 지원하지 않는다. 존이 하나라서 Cache Rules·Configuration Rules 같은 존 단위 ruleset 은 phase 마다 하나뿐이다. dev 와 prod 워크스페이스가 각자 만들면 같은 객체를 두고 서로 덮어쓰므로 존 단위 규칙은 prod 워크스페이스에서만 관리하도록 했다.

② dev 터널 상태 healthy
③ prod 터널(A1)은 오사카 kix03·05·06
④ 모든 호스트가 터널로 가는 프록시 CNAME 이다.
터널을 쓰면 DNS 목적지와 원본의 위치를 종속시켜 운영할 필요가 없다. 같은 터널 토큰으로 cloudflared 를 다른 곳에서 올려 http://nginx:80으로 트래픽이 보낼 수 있으면 그곳이 새로운 원본으로 사용이 가능해진다. 이런 특성을 이용해 prod 를 A1 에서 Mac 넘기는 전환 작업을 할때 DNS 를 건드릴 필요가 없다. (cloudflared 만 로컬 Mac에 올리면 작업이 완료 된다)
무료 요금제로 쓰는 CDN 설정
Cloudflare 는 기본적으로 HTML 을 캐시하지 않고 이미지·CSS·JS 같은 정적 파일만 확장자를 보고 캐시한다. WordPress 의 HTML 까지 엣지에서 내보내려면 Cache Rules 를 설정해야한다. 규칙을 짤 때 TTL 은 원본(nginx)이 정하고 Cloudflare 는 그 값을 사용하게 했다.
Cache TTL 은 Origin Nginx가 정한다
nginx 는 자기 FastCGI 캐시(tmpfs 512 MB, 10분)에서 HIT 한 응답에만 public, max-age=0, s-maxage=60, stale-while-revalidate=600 을 붙인다. MISS·BYPASS 응답에는 private, no-store 가 붙는다. nginx 는 Set-Cookie 가 붙은 응답을 캐시에 저장하지 않으니 HIT 까지 온 응답은 쿠키가 없는 익명 응답이다.
Cache Rule 의 edge TTL 을 respect_origin 으로 두면 이 헤더가 그대로 엣지 TTL 이 된다. 쿠키 우회 규칙에 빈틈이 있더라도 로그인한 사람의 화면은 no-store 라 엣지에 저장되지 않도록 했다. 로그인 페이지가 전 세계 PoP 에 캐시되는 사고 예방은 Cache Rule 한 줄에서만 끝내지 않고 응답 헤더에서 한 번 더 막도록 했다.
# compose/nginx/templates/default.conf.template (발췌)
map $upstream_cache_status $wp_html_cache_control {
default "private, no-store, max-age=0";
"HIT" "public, max-age=0, s-maxage=60, stale-while-revalidate=600";
"STALE" "public, max-age=0, s-maxage=60, stale-while-revalidate=600";
"UPDATING" "public, max-age=0, s-maxage=60, stale-while-revalidate=600";
"REVALIDATED" "public, max-age=0, s-maxage=60, stale-while-revalidate=600";
}
location ^~ /wp-content/uploads/ {
# always 를 붙이지 않는다 — 4xx·5xx 에도 붙으면 오류 응답이 1년짜리 immutable 이 된다
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
try_files $uri =404;
}
Code language: Nginx (nginx)
글을 고친 뒤에 방문자가 옛 본문을 오래 보지 않도록 max-age=0 으로 두어 브라우저가 글을 열 때마다 재검증하게 했다. 설정에 쓰는 API 토큰에 Cache Purge 권한을 주지 않아서 발행 직후 엣지 캐시를 비울 수 없다. 퍼지 없이도 1분 안에 새 글이 보이게 하려고 엣지 TTL 은 60초로 짧게 잡았다. 업로드 이미지는 WordPress 가 이름이 겹치면 foo-1.jpg 처럼 새 이름을 주므로 1년 immutable 로 설정했다.
Cache Rules 4개

② 규칙 2·3: 관리 경로와 로그인·댓글·글 비밀번호 쿠키 요청은 우회한다
③ 규칙 4: n8n 은 엣지 캐시를 쓰지 않는다. 관리 호스트 이름은 가렸다.
| 순서 | 조건 | 동작 | 이유 |
|---|---|---|---|
| 1 | WordPress 호스트 4개와 dev 호스트 4개(이름을 하나씩 나열) | 캐시 대상, edge·browser TTL 은 원본 값, cache deception armor, 원본 장애 때 만료된 사본 제공 | HTML 까지 캐시 대상으로 만든다. contains "byitx.com" 으로 쓰면 n8n·dev 호스트까지 잡힌다 |
| 2 | /wp-admin·/wp-json·/wp-login.php·/wp-cron.php·/xmlrpc.php·preview=true | Bypass | 관리 화면과 API 응답 |
| 3 | wordpress_logged_in_·wordpress_sec_·comment_author_·wp-postpass_ 쿠키(정적 자원 제외) | Bypass | Cloudflare 는 요청 쿠키를 캐시 키에 넣지 않는다 |
| 4 | n8n 호스트 | Bypass | n8n은 SPA를 사용한다. .js·.css 기본 캐시 때문에 오류 응답이 엣지에 남는 상태를 대비했다 |
Cache Rules 는 위에서부터 차례로 적용되고 같은 설정은 마지막에 맞은 규칙이 최종 적용된다. 그래서 캐시를 켜는 규칙을 먼저 두고 Bypass 규칙을 뒤쪽에 둬야한다. apex 와 www 는 axgo 로 301 만 하므로 규칙 1 의 대상에서 뺐다. nginx 의 return 301 응답에는 Cache-Control 이 없어서 대상에 남겨 두면 Cloudflare 의 상태 코드별 기본 TTL 로 리다이렉트가 엣지에 남는다.

② 업로드 이미지는 1년 immutable, 엣지 HIT
③ 로그인 페이지는 private·no-store 라 nginx BYPASS·Cloudflare DYNAMIC
④ cf-ray 끝 세 글자가 응답한 PoP 다 (KT 회선 방문자는 LHR·AMS·BOS PoP정보가 보인다 ㅠㅠ).

Tiered Cache를 적용해야 한다
엣지 캐시는 PoP 마다 따로 캐시를 가지고 있다. 트래픽이 적은 블로그는 방문자가 여러 PoP 로 흩어져서 PoP 마다 처음 오는 요청이 터널을 타고 원본까지 간다. Tiered Cache 를 켜면 하위 PoP 에 사본이 없을 때 원본 대신 상위 계층 PoP 을 먼저 찔러보게 된다. 이 기능이 Smart Tiered Cache 인데 무료 요금제에서도 활성화해 사용할 수 있다.
Smart 토폴로지는 Terraform(cloudflare_tiered_cache)으로 9월에 켜 두었고 Tiered Cache 가 돌고 있다고 생각 했었다. 그런데 대시보드의 Tiered Cache 기본 스위치(API 이름 argo/tiered_caching)가 꺼져 있었다. 이 스위치가 꺼져 있으면 Smart 토폴로지가 켜져 있어도 Tiered Cache가 돌지 않는다. 기본 스위치는 API 로 바꿀 수 없어서(editable: false) 10월 13일에 대시보드에서 켰다.

② 무료 요금제에서 고를 수 있는 Smart Tiered Cache
③ 상위 계층은 prod 호스트가 KIX(오사카, A1 터널 쪽), dev 호스트가 ICN(서울, Mac 터널 쪽)이고 장애 조치는 NRT 다.
켜기 전과 후를 Cloudflare GraphQL 분석(httpRequestsAdaptiveGroups)으로 비교했다. 대상은 blog·axgo 의 업로드 이미지다.
| 구간 | 이미지 요청 | 원본까지 간 요청 | 다른 PoP 가 같은 이미지를 또 원본에서 | 쿠키 때문에 DYNAMIC 처리 |
|---|---|---|---|---|
| 켜기 전 7일 | 5,263건 | 2,691건(이미지 986개를 평균 2.7번) | 1,509건 | 1,490건 |
| 켠 뒤 11시간 | 497건 | 225건(이미지 225개를 한 번씩) | 0건 | 0건 |
켜기 전에는 1년 immutable 인 이미지를 PoP 마다 원본에서 따로 받아 갔다. 그 가운데 1,509건은 다른 PoP 가 이미 가져간 이미지였다. DYNAMIC 1,490건은 로그인·댓글 작성자 쿠키가 있는 방문자의 이미지 요청까지 규칙 3 이 우회시켜서 생겼다. 같은 날 규칙 3 에서 정적 자원을 뺐다. 관측 시간이 짧아 거의 모두가 그 이미지의 첫 요청이라서 켠 뒤에도 원본까지 간 비율이 45% 로 높다. 켜기 전과 달리 이미지마다 원본 왕복은 한 번으로 끝났다.

켠 직후에는 7일 동안 아무도 부르지 않은 썸네일 하나를 10개 PoP 에서 16번 요청해 봤다. 원본 왕복은 첫 요청 1번뿐이었고 나머지 15번은 HIT 였다. 원본까지 갔는지는 GraphQL 의 originResponseStatus 로 본다(0 이면 원본에 가지 않은 요청).
Speed 설정

② 원본에 대한 HTTP/2
③ HTTP/3(QUIC)
④ 향상된 HTTP/2 우선 순위 지정은 Pro 이상 (그림에 떡)
⑤ 0-RTT 연결 재개

① Cloudflare Fonts
② Early Hints.
| 기능 | 상태 | 우리 블로그에서 |
|---|---|---|
| HTTP/3 · 0-RTT | 켬 | 0-RTT 는 GET·HEAD·OPTIONS 에만 쓰이고 원본에는 Early-Data: 1 헤더가 붙는다. 상태를 바꾸는 요청에는 쓰이지 않는다 |
| 원본에 대한 HTTP/2 | 켬 | 터널 구성에서는 엣지와 원본 사이가 cloudflared 의 QUIC 연결이다. cloudflared → nginx 구간은 cloudflared 의 http2Origin 옵션이 정하고 기본값인 HTTP/1.1 을 쓴다 |
| Early Hints | 켬 | 원본의 Link: rel=preload 헤더를 기억했다가 103 으로 먼저 보내는 기능이다. 원본이 그 헤더를 보내지 않아 할 일이 없다 |
| Cloudflare Fonts | 켬 | Google Fonts 를 자기 도메인으로 바꿔 서빙하는 기능이다. Pretendard 는 jsDelivr 에서 받아 해당이 없다 |
| Rocket Loader | 켬, 관리 경로는 끔 | 공개 글에는 문제가 없지만 wp-login 스크립트, n8n 편집기 스크립트에 영향을 줄 수 있다 |
Rocket Loader 는 Configuration Rule 하나로 관리 화면과 n8n 에서 끈다. 공개 페이지에서 먼저 실행돼야 하는 테마 초기화(다크 모드)와 AdSense 스크립트에는 data-cfasync="false" 를 붙였다. 이 속성은 src 보다 앞에 있어야 Rocket Loader 가 건너뛴다.
# terraform/cloudflare/prod/config_rules.tf (발췌, 호스트 이름은 변수에서 채운다)
resource "cloudflare_ruleset" "config_settings" {
zone_id = var.zone_id
name = "blog-platform config settings"
kind = "zone"
phase = "http_config_settings"
rules = [{
ref = "rocket_loader_off_admin_apps"
expression = <<-EOT
(http.host in {"<n8n 호스트>" "<dev n8n 호스트>"})
or starts_with(http.request.uri.path, "/wp-admin")
or http.request.uri.path in {"/wp-login.php" "/wp-signup.php" "/wp-activate.php"}
EOT
action = "set_config"
action_parameters = { rocket_loader = false }
}]
}
Code language: plaintext (plaintext)
API 로 바꿀 수 없는 설정
Cloudflare 설정을 전부 Terraform 으로 관리하고 싶었지만 그렇게 되지 않는 값이 있다. API 응답의 editable 이 false 이거나 무료 요금제에서 조회 조차 되지 않았는 경우들이 있었다.

② Tiered Cache 기본 스위치도 editable false
③ Regional Tiered Cache·Cache Reserve 는 무료 요금제에서 조회조차 되지 않는다.
| 설정 | 무료 요금제에서 | 관리 방법 |
|---|---|---|
Tiered Cache 기본 스위치(argo/tiered_caching) | 켤 수 있지만 API 는 editable false | 대시보드 Caching › Tiered Cache 토글. Terraform 은 이 값을 모르니 누가 끄면 plan 으로 알 수 없다. 운영 문서에 적어 두었다 |
| Smart Tiered Cache 토폴로지 | API 로 켤 수 있다 | Terraform cloudflare_tiered_cache |
| HTTP/2 | 늘 켜져 있다(editable false) | 바꿀 수 없다 |
| Polish·Mirage·WebP 변환 | Pro 이상(editable false) | 원본에서 해결 — WordPress 하위 크기를 WebP 로 만든다 |
| Regional Tiered Cache·Cache Reserve | not available for your plan type | 쓰지 않는다 |
| Argo Smart Routing·Health Checks | 유료 | 쓰지 않는다 |
| Rocket Loader·Early Hints·0-RTT·HTTP/3 | API 로도 바꿀 수 있다 | 지금은 대시보드에서 켜고 Terraform 은 always_use_https·automatic_https_rewrites 두 개만 관리한다 |

① 무료 요금제에서 켤 수 있는 것은 Smart Tiered Cache
② Regional Tiered Cache·캐시 예약(Cache Reserve)·Health Checks·Argo Smart Routing 은 업그레이드 필요
③ 상위 계층에서 원본으로 가는 연결 재사용은 늘 켜져 있다.
Cache Reserve 는 대시보드에 “모든 요금제에서 사용할 수 있다”고 나와서 처음에는 무료로 쓸 수 있는 줄 알았다. 실제로는 결제 수단을 등록해야 하는 사용량 과금 상품이다. 공식 문서에도 유료 Cache Reserve 플랜이 필요하다고 나온다. 요금은 저장 GB·월 $0.015, 쓰기 100만 건당 $4.50, 읽기 100만 건당 $0.36 이다. 따로 주는 무료 사용량은 없는것 같다.

② 지난 30일 캐시 MISS 로 원본에서 가져온 양 926.65 MB·요청 약 3만 건
Cache Reserve 켜지 않기로 했다(무료도 아니다!). Tiered Cache 를 켠 뒤로는 상위 계층(KIX)에서 TTL이 끝난 파일을 재요청할 때 정도만 오리진 요청을 받아주는건 감당할만하다. 원본이 KIX 와 같은 오사카에 있어서 오리진이 응답을해도 수십 ms 다. Cache Reserve 는 하위 PoP 와 상위 계층 사이의 MISS 를 줄여 주지는 못하기 때문에 굳이 돈을 내고 얻을 것이 많지 않을 것 같다.
통신사마다 다른 PoP 로 들어온다
무료 요금제의 가장 큰 단점은 이거다. Cloudflare 는 애니캐스트라서 방문자의 통신사가 Cloudflare 와 어떻게 연결되어 있느냐에 따라 방문자가 연결되는 PoP 가 달라진다. 무료 요금제의 Domain 존은 방문자가 연결될 PoP 를 고를 수 없다.

Globalping 의 한국 측정점에서 같은 정적 파일을 HEAD 로 요청해 통신사별로 비교했다(2026-10-12 22:56, 10-13 03:29·04:30·11:47 네 번).
| 통신사(ASN) | 들어간 PoP | TLS 완료 | 전체 |
|---|---|---|---|
| KT (AS4766) | SEA, 낮에는 LHR | 2.5초(22:56) · 0.28~0.41초(03:29) · 0.15초(04:30) · 0.29초(11:47) | 0.44~3.6초 |
| SK브로드밴드 (AS9318) | HKG, 가끔 NRT | 52~201ms | 0.15~0.36초 |
| LG U+ (AS17858) | HKG, 가끔 NRT | 41~54ms | 0.16~0.25초 |
| 국내 클라우드(Oracle·MS 등) | ICN | 대개 7~68ms | 대개 0.03~0.19초 |
KT 회선을 사용중인 로컬 Mac 에서 같은 파일(엣지 캐시 HIT)을 시각마다 5번씩 테스트를 진행해 봤다.
| 시각(KST) | 응답한 PoP | TCP 연결 | TLS 완료 | TTFB | Cloudflare IP ping |
|---|---|---|---|---|---|
| 10-12 22:56 | SEA | 0.12초 | 1.07~2.59초 | 2.08~6.05초 | 890~958ms, 손실 최대 20% |
| 10-13 03:29 | SEA | 0.12초 | 0.25~0.45초 | 0.40~0.79초 | 126~299ms, 손실 최대 10% |
| 10-13 04:30 | SEA | 0.12초 | 0.25~0.27초 | 0.39~0.45초 | 117ms, 손실 0 |
| 10-13 11:47 | LHR·AMS·BOS | 0.22~0.27초 | 0.44~0.55초 | 0.79~1.15초 | 117ms, 손실 0 |
KT 만 이렇게 되는 배경은 Cloudflare 의 2016년 블로그 글에 이미 나온다. Cloudflare 는 한국 트래픽의 2% 만 피어링한다고 밝히고 KT 를 로컬 피어링 협의에 응하지 않는 비싼 네트워크 여섯 곳 가운데 하나로 꼽았다. 그런데 같은 KT 회선에서도 1.1.1.1 은 4ms 에 응답하고 cloudflare.com 도 서울(ICN)에서 처리된다. 무료 요금제 존이 쓰는 104.21.x·172.67.x 대역만 미국으로 간다.

② KT 회선에서 axgo 요청은 AMS(암스테르담)에서 처리됐다
③ 같은 회선에서 cloudflare.com 은 ICN(서울)
④ 존 IP 로 ping 116ms ⑤ 1.1.1.1 은 4ms

① KT 백본(112.174.x) 안에서 9ms 이던 지연이 다음 홉에서 118ms 로 뛴다.
② Cloudflare 시애틀(108.162.243.x)
③ 1.1.1.1 은 국내 Cloudflare 로 바로 간다. 집 근처 홉 주소는 가렸다.
KT 회선의 PoP 는 미국 안에서도 계속 바뀌었다. 9월 10일에는 ATL·BOS(애틀랜타·보스턴), 9월 23일에는 PDX·ATL·SJC, 10월 8일에는 LAX(엘에이), 10월 12일부터는 SEA(시애틀) 였다.
낮 측정에서는 cf-ray 가 LHR·AMS·BOS 로 찍혔다. 그런데 ping 은 여전히 116ms(시애틀까지의 거리)였고 TCP 연결 시간만 0.12초에서 0.22~0.27초로 늘었다. 연결이 가까운 SEA 로 들어간 뒤 요청은 런던·암스테르담에서 받아 처리한 것으로 보인다. Cloudflare 는 2023년 Traffic Manager 글에서 데이터센터의 부하를 덜어야 할 때 Free 요금제 고객부터 다른 데이터센터로 옮긴다고 설명하고 있다. (😭 저… 저기요??)
저녁 22:56 은 PoP 가 SEA 로 같은데 ping 이 900ms 를 넘고 손실이 20% 였다. KT 의 미국행 구간이 저녁 시간대에 부하가 컸다는 말이다. 같은 날 12일 Cloudflare 커뮤니티에도 오후부터 KT 회선만 SEA 로 가고 패킷 손실이 생긴다는 글이 올라왔다. 새벽 4시 30분에는 ping 117ms·손실 0 으로 돌아왔다. 거리 만큼의 지연만 감수하면 되는 상태가 되었다.
KT 방문자는 연결을 맺을 때마다 태평양을 건넌다. 평소에는 TLS 0.25~0.5초, TTFB 0.4~1초 정도라 크게 불편하진 않지만 저녁 혼잡 시간이면 페이지를 하나 여는데 1초가 넘게 걸리는 경우가 발생한다. 이미지가 많은 글일수록 체감이 커서 WordPress 가 서빙하는 이미지 크기를 WebP 로 바꿔 요청 수와 바이트수를 최대한 줄였다(axgo 홈 689 KB → 159 KB).
지연을 추적한 기록

“서버가 느리다”는 증상에서 서버부터 의심하면 캐시·PHP·DB 를 며칠씩 들여다 봤지만. 몇 번 그렇게 시간을 쓴 뒤부터는 그림의 순서대로 측정을 해보고 있다. nginx 의 upstream_response_time 으로 서버 처리 시간을 본 뒤 터널을 빼고 컨테이너 안에서 nginx 를 직접 호출 해본다. 이어서 A1 안에서 공개 URL 을 불러 원본 쪽 엣지(KIX)를 보고 방문자 회선에서 cdn-cgi/trace 의 colo 와 TLS 시간을 본다. 마지막으로 페이지 자원 하나하나의 HIT·MISS 와 바이트를 본다. 로그인한 브라우저로의 Network 레이턴시 확인은 의미가 없다. 로그인 쿠키가 있으면 nginx 캐시와 엣지 캐시가 둘 다 CDN 엣지를 타지 않도록 했기 때문이다.(의도한 동작이다).
| 날짜 · 증상 | 실제 원인 | 확인한 방법 | 조치와 결과 |
|---|---|---|---|
| 2026-09-10 · WordPress 사이트 건강 “서버 응답 시간 816ms” | 사이트 건강·WP-Cron 의 루프백 요청이 공개 URL → 북미 PoP(ATL·BOS, RTT 140ms) → 터널로 돌아왔다 | 컨테이너 → nginx 직접 1.3~2.1ms, 공개 URL 598~1,251ms | mu-plugin 이 자기 호스트로 가는 요청을 http://nginx/ 로 바로 보낸다. 816ms → 2ms |
| 2026-09-10 · n8n 편집기가 빈 화면(401) | 인증 게이트가 낸 401 에 immutable 헤더가 붙어 엣지에 캐시됐다(.js·.css 는 확장자로 기본 캐시) | curl 은 BYPASS·MISS 라 보이지 않았고 브라우저에서 cf-cache-status: HIT 인 401 을 확인 | n8n 호스트 엣지 캐시 우회(규칙 4), 정적 응답 헤더에서 always 제거 |
| 2026-09-17 · 검색엔진 소유 확인 실패 | 확인 파일을 올리기 전에 한 번 요청한 URL 의 404 가 엣지에 3분 남았다(헤더 없는 응답은 상태 코드별 기본 TTL) | 원본은 200, 엣지는 404 | 확인 파일은 max-age=3600 으로 내고 공개 URL 이 200 인 것을 본 뒤 확인 버튼을 누른다 |
| 2026-09-23 · dev-axgo 만 느리다 | 서버는 0.2초, 엣지(PDX·ATL) 경유는 0.9~3.4초. 홈 썸네일 PNG 10장 689 KB 를 PoP 마다 MISS 로 받았다 | 재는 순서 ①~⑤ | 하위 크기를 WebP 로(689 → 159 KB), Smart Tiered Cache 토폴로지 켬 |
| 2026-10-08 · 설정을 바꾸자마자 wp-admin 520 | 저장 요청이 원본에 오지도 않았다. KT → LAX 구간의 일시 장애 | 실패한 요청이 nginx 로그에 없음, 같은 시각 A1(KIX)에서는 12번 모두 정상 | 몇 분 뒤 저절로 회복. 진단 기준을 “실패한 요청이 원본 로그에 있는가”로 정리 |
| 2026-10-12 · “Cloudflare 터널이 너무 느려졌다” | KT → SEA 저녁 혼잡(ping 900ms·손실 20%). 터널 구간은 0.05초 | A1(KIX) 대조 측정, Globalping 통신사별 측정, 새벽 재측정 | 새벽 4시 30분 117ms·손실 0 으로 확인. CloudFront 무료 플랜을 앞에 두는 대안은 문서로만 남김 |
| 2026-10-13 · 1년 immutable 인 이미지의 절반이 원본까지 | 쿠키 우회 규칙이 이미지에도 걸렸고 Tiered Cache 기본 스위치가 꺼져 있었다 | GraphQL originResponseStatus, PoP 별 원본 왕복 집계 | 규칙 3 에서 정적 자원 제외(Terraform), 대시보드에서 기본 스위치 켬. 다른 PoP 반복 원본 왕복 1,509건 → 0건 |
| 2026-10-13 · Rocket Loader 를 켠 뒤 관리 화면 스크립트 지연 | wp-login 스크립트 26개 중 25개와 n8n 편집기 스크립트까지 미뤄졌다 | 헤드리스 Chrome(UA 에 HeadlessChrome 이 있으면 Rocket Loader 가 붙지 않아 일반 UA 로 시험) | Configuration Rule 로 관리 경로·n8n 에서 끄고 먼저 실행할 스크립트는 data-cfasync="false" |
장점과 단점
장점
- 서버로 들어오는 포트가 하나도 없다. 원본 공인 IP 의 22·80·443 이 모두 닫혀 있고 DNS 에도 원본 IP 가 없다. 포트 스캐너가 찾아낼 대상이 없다.
- CDN·TLS·DDoS 방어가 무료다. 인증서 갱신, HTTP/3, 기본 DDoS 방어를 Cloudflare 가 맡는다. 이 구성으로 쓰는 비용은 월 0원이다.
- Origin 위치가 DNS 와 분리된다. 터널 토큰만 있으면 cloudflared 가 어디서 뜨든 같은 이름으로 서비스된다. dev(로컬 Mac)와 prod(A1)가 같은 구조라 스크립트와 호스트가 위치를 신경 쓰지 않아도 된다.
- 설정이 코드로 남는다. 터널·DNS·Cache Rules·Configuration Rule·Smart Tiered Cache 가 Terraform 코드에 들어 있으니 바꾼 이유가 커밋으로 남아 있게 된다.(기억에 의존하지 않아도 된다)
단점
- KT 방문자는 미국을 돌아온다. 평소 RTT 117ms(TLS 0.25~0.5초), 저녁 혼잡 시간에는 TLS 1~2.6초다. 무료 요금제는 PoP 를 고를 수 없고, 요금제를 올린다면 서울 PoP 가 보장 될런지 모르겠다.
- API 로 바꿀 수 없는 설정이 있다. Tiered Cache 기본 스위치처럼 대시보드에서만 바뀌는 값이 있다. Terraform 으로 관리 할 수 없어서 꺼져 있어도 모르는채 지나치기 쉬웠다.
- 무료 분석은 필드가 막혀 있다. GraphQL 에서 통신사(clientAsn)·상위 계층(upperTierColoName)·쿼리 문자열을 볼 수 없어서 통신사별 지연은 Globalping 같은 외부 측정으로 따로 재야 했다.
- 한 회사에 모두 맡긴다. DNS·TLS·CDN·터널이 전부 Cloudflare 다. Cloudflare 가 멈추면 돌아갈 길이 없다. TLS 가 엣지에서 끝나므로 요청 내용이 Cloudflare 를 지난다. 믿을만하고, 듬직한데 가끔 재채기(장애)를 쎄게(?) 한다.
요즘 AI 에이전트 해킹, 금융권 해킹으로 뉴스가 시끄럽다. 지금은 지연을 감수하는 쪽이 맞다고 본다. KT 지연이 평소 수준을 넘어 계속되면 CloudFront 무료 플랜을 앞에 두는 안을 dev 호스트 하나로 먼저 시험해 볼까한다. Cloudflare 터널은 원본 쪽 연결로 그대로 두니 공개 포트 0개라는 원칙은 바뀌지 않도록 할 생각이다.