wp-discourse + Cloudflare로 배운 몇 가지 교훈

Discourse, wp-discourse, Cloudflare를 사용해 지난 몇 달간 얻은 개인 환경 설정에 관한 몇 가지 교훈을 공유합니다. 누군가에게 도움이 될 수 있기를 바랍니다.

환경:

  • Discourse와 Wordpress는 동일한 VPC 내의 서로 다른 AWS EC2 인스턴스에 호스팅
  • Wordpress 스택은 nginx + fastCGI 캐싱 + php8.3-fpm + redis + mariadb 구성
  • wp-discourse 플러그인을 사용하여 WP와 Discourse를 연결하고 블로그 게시물 아래에 Discourse 댓글을 임베드
  • Cloudflare가 Discourse와 WP 양쪽 모두를 프록시
  • WP는 APO 사용

1) VPC를 조금 활용하기

처음에 시스템을 배포했을 때, 웹 서버와 Discourse 서버 간의 API 트래픽을 Cloudflare가 레이트 리미팅하거나 차단하려는 문제를 즉시 겪었습니다. 제외 규칙을 설정하는 방법을 고민했지만, 두 서버가 동일한 AWS VPC에 있으므로 각 서버의 호스트 파일에 엔트리를 추가하여 해당 서버의 호스트네임을 공개 DNS 주소 대신 VPC 주소로 가리키도록 하는 것이 훨씬 쉬웠습니다. 이제 WP와 Discourse 사이에 Cloudflare가 개입하는 것을 걱정할 필요가 없습니다. 작은 변경이지만 큰 번거로움을 줄여줍니다.

2) wp-discourse와 Apple News 사이의 레이스 컨디션 처리

Discourse를 Wordpress와 함께 설정한 후 상당 시간 동안 이상한 간헐적 문제를 겪었습니다. 가끔(자주 그렇지는 않지만) 새로 게시된 Wordpress 게시물이 방문자에게는 Discourse가 아닌 기존 Wordpress 네이티브 댓글 블록이 하단에 표시되는 것처럼 보였습니다. 실제 게시물은 Discourse 댓글이 올바르게 포함된 상태였지만, Cloudflare의 엣지 캐시가 Discourse 댓글이 도착하기 직전의 게시물을 가져와 오래된 페이지를 유지하고 있었습니다.

이러한 행동의 방법이나 원인을 도무지 파악할 수 없었습니다. 명확한 트리거 없이 무작위처럼 보였고, 로그를 샅샅이 살펴보았지만 눈에 띄는 것은 없었습니다.

그래서 저는 이 문제를 다양한 캐시 레이어 간의 상호작용으로 받아들이고, chatGPT를 사용하여 작은 mu-plugin을 작성했습니다. 이 플러그인은 Discourse 링크가 설정되기 전에 WP가 새 게시물에 대해 "이것은 캐시하지 마세요"라고 강력하게 알리도록 하여 문제를 우회하고, 댓글 스레드가 연결된 후 CF 측에서 캐시 클리러을 한 번 더 수행하여 CF 캐시에 오래된 데이터가 남지 않도록 확실하게 보장합니다.

discourse-cloudflare-purge.php
<?php
/**
 * Plugin Name: Discourse → Cloudflare Safe Cache for Comments
 * Description: Prevents caching of single-post HTML until Discourse linkage exists; also purges Cloudflare when linkage lands.
 * Author: Lee Hutchinson + ChatGPT
 * Version: 1.6.1
 *
 * Changelog:
 * 1.6.1 - Added timeout failsafe so that we're not emitting no-cache headers forever
 * 1.6.0 — ADD pre-linkage no-cache gate (template_redirect) so edge/origin never cache the WP-native-comments HTML.
 * 1.4.0 — Purge on added/updated postmeta + status transition; purge slash/no-slash variants.
 * 1.3.0 — Credentials via wp-config.php constants only.
 */

if (!defined('ABSPATH')) exit;

/** Resolve Cloudflare credentials from wp-config.php */
function scw_dcfp_get_creds(): array {
    $zone  = defined('SCW_CF_ZONE_ID')   ? trim((string) SCW_CF_ZONE_ID)   : '';
    $token = defined('SCW_CF_API_TOKEN') ? trim((string) SCW_CF_API_TOKEN) : '';
    return ['zone_id' => $zone, 'api_token' => $token];
}

/** Discourse linkage meta keys (filterable) */
function scw_dcfp_meta_keys(): array {
    $keys = ['discourse_topic_id', 'discourse_post_id', 'discourse_permalink'];
    return apply_filters('scw_dcfp_meta_keys', $keys);
}

/** Build URLs to purge for a post (slash + no-slash, plus home + RSS) */
function scw_dcfp_build_urls(int $post_id): array {
    $urls = [];
    $permalink = get_permalink($post_id);
    if ($permalink) {
        $urls[] = $permalink;
        $urls[] = (substr($permalink, -1) === '/') ? rtrim($permalink, '/') : trailingslashit($permalink);
    }
    $urls[] = home_url('/');
    $urls[] = get_bloginfo('rss2_url');
    $urls = array_unique(array_filter($urls));
    return apply_filters('scw_dcfp_urls', $urls, $post_id);
}

/** Minutes since publish (UTC) or null if unknown */
function scw_dcfp_minutes_since_publish(int $post_id): ?int {
    $post_time = get_post_time('U', true, $post_id);
    if (!$post_time) return null;
    return (int) floor((time() - (int)$post_time) / 60);
}

/** Low-level Cloudflare purge-by-URL */
function scw_dcfp_purge_urls(array $urls): void {
    $creds = scw_dcfp_get_creds();
    if ($creds['zone_id'] === '' || $creds['api_token'] === '' || empty($urls)) return;

    $endpoint = sprintf('https://api.cloudflare.com/client/v4/zones/%s/purge_cache', $creds['zone_id']);
    $response = wp_remote_post($endpoint, [
        'headers' => [
            'Authorization' => 'Bearer ' . $creds['api_token'],
            'Content-Type'  => 'application/json',
        ],
        'body'    => wp_json_encode(['files' => array_values($urls)]),
        'timeout' => 10,
    ]);

    if (is_wp_error($response)) {
        error_log('[SCW CF Purge] WP_Error: ' . $response->get_error_message());
        return;
    }
    $code = (int) wp_remote_retrieve_response_code($response);
    if ($code < 200 || $code >= 300) {
        $body = wp_remote_retrieve_body($response);
        error_log(sprintf('[SCW CF Purge] HTTP %d. Response: %s', $code, $body));
    }
}

/** Common gate: only act when linkage meta key written & post is public */
function scw_dcfp_should_trigger($post_id, $meta_key, $meta_value): bool {
    $creds = scw_dcfp_get_creds();
    if ($creds['zone_id'] === '' || $creds['api_token'] === '') return false;
    if (empty($meta_value)) return false;
    if (!in_array($meta_key, scw_dcfp_meta_keys(), true)) return false;
    return (get_post_status($post_id) === 'publish');
}

/** Purge on added/updated Discourse linkage meta */
function scw_dcfp_on_post_meta_change($meta_id, $post_id, $meta_key, $meta_value): void {
    if (!scw_dcfp_should_trigger($post_id, $meta_key, $meta_value)) return;
    $urls = scw_dcfp_build_urls((int) $post_id);
    scw_dcfp_purge_urls($urls);
}
add_action('added_post_meta',   'scw_dcfp_on_post_meta_change', 10, 4);
add_action('updated_post_meta', 'scw_dcfp_on_post_meta_change', 10, 4);

/** Purge on publish transition, if linkage meta already present */
function scw_dcfp_on_transition_post_status($new_status, $old_status, $post): void {
    if ($new_status !== 'publish' || !($post instanceof WP_Post)) return;
    foreach (scw_dcfp_meta_keys() as $k) {
        if (get_post_meta($post->ID, $k, true)) {
            $urls = scw_dcfp_build_urls((int) $post->ID);
            scw_dcfp_purge_urls($urls);
            break;
        }
    }
}
add_action('transition_post_status', 'scw_dcfp_on_transition_post_status', 10, 3);

/**
 * NEW: Pre-linkage no-cache gate.
 * Until any Discourse linkage meta exists on a published single post,
 * emit strong no-cache headers so neither origin (FastCGI) nor Cloudflare
 * can store the pre-linkage HTML that shows WP's native comment form.
 */
function scw_dcfp_prelinkage_nocache(): void {
    if (!is_singular('post')) return;

    $post = get_queried_object();
    if (!($post instanceof WP_Post) || get_post_status($post) !== 'publish') return;

    foreach (scw_dcfp_meta_keys() as $k) {
        if (get_post_meta($post->ID, $k, true)) return; // linkage exists; cache normally
    }

    // In wp-config.php you can set: define('SCW_CF_LINKAGE_GRACE_MIN', 10);
    $grace = defined('SCW_CF_LINKAGE_GRACE_MIN') ? (int) SCW_CF_LINKAGE_GRACE_MIN : 0;
    if ($grace > 0) {
        $mins = scw_dcfp_minutes_since_publish($post->ID);
        if ($mins !== null && $mins >= $grace) {
            // We’ve waited long enough; stop gating and let caching resume.
            // (Optionally, trigger one purge to refresh edge with whatever is current.)
            // scw_dcfp_purge_urls(scw_dcfp_build_urls((int)$post->ID));
            return;
        }
    }

    // Block caching for this response (origin + edge + browsers)
    if (!headers_sent()) {
        nocache_headers();                    // Cache-Control: no-store, no-cache, must-revalidate, etc.
        header('cf-edge-cache: no-cache');    // Extra hint for Cloudflare/APO
    }
    if (!defined('DONOTCACHEPAGE')) define('DONOTCACHEPAGE', true);

    // Optional debug: uncomment for a few publishes
    // error_log('[SCW CF Gate] Pre-linkage no-cache for post ' . $post->ID);
}
add_action('template_redirect', 'scw_dcfp_prelinkage_nocache', 0);

/** Optional: admin notice if not configured */
add_action('admin_notices', function () {
    if (!current_user_can('manage_options')) return;
    $creds = scw_dcfp_get_creds();
    if ($creds['zone_id'] === '' || $creds['api_token'] === '') {
        echo '<div class="notice notice-error"><p><strong>SCW Discourse → Cloudflare:</strong> Missing credentials. Define <code>SCW_CF_ZONE_ID</code> and <code>SCW_CF_API_TOKEN</code> in <code>wp-config.php</code>.</p></div>';
    }
});

이 mu-plugin은 Cloudflare에 대한 API 작업을 수행하므로, wp-config.php에 CF 존(SCW_CF_ZONE_ID)과 API 키(SCW_CF_API_TOKEN) 상수를 정의해야 합니다. 또한 이러한 상수의 이름을 변경하는 것도 고려해볼 수 있습니다.

이것으로 문제 있는 행동이 완전히 사라졌습니다—discourse-cloudflare-purge.php를 배포한 순간부터 사라졌죠. 하지만 여전히 제 신경을 긁었습니다. 이런 일이 발생했을까요? mu-plugin을 비활성화하면 며칠 안에 문제 있는 행동이 다시 돌아왔으므로, 근본 원인은 여전히 남아 있었습니다.

결국 로그를 면밀히 검토한 끝에 문제를 발견했습니다:

제가 운영하는 WP 사이트는 이 플러그인을 사용하여 Apple News에 콘텐츠를 크로스 게시합니다. 이 플러그인은 정기적으로 업데이트되며 수년간 잘 작동해 왔습니다. 그러나 Apple News 게시 과정은 게시물 게시 시 즉시 트리거되며, 때때로 아래 nginx 로그 발췌에서 보인 것과 같은 행동이 발생합니다. 이 발췌는 Wordpress에서 작성자가 “게시” 버튼을 누르는 것으로 트리거된 “POST” 이벤트로 시작합니다:

Messy nginx access log excerpt
[22/Dec/2025:06:15:40 -0600] spacecityweather.com [-] [Poster IP] | POST /wp-admin/admin-ajax.php HTTP/2.0 200 Ref: "https://spacecityweather.com/wp-admin/post.php?post=53600&action=edit" UA: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"

[22/Dec/2025:06:15:40 -0600] spacecityweather.com [BYPASS] [Poster IP] | GET / HTTP/2.0 200 Ref: "-" UA: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"

[22/Dec/2025:06:15:41 -0600] spacecityweather.com [BYPASS] [Poster IP] | GET /wp-admin/admin.php?page=stats&noheader&proxy&chart=admin-bar-hours-scale HTTP/2.0 200 Ref: "https://spacecityweather.com/" UA: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"

[22/Dec/2025:06:15:41 -0600] spacecityweather.com [BYPASS] [Poster IP] | GET /wp-json/jetpack/v4/scan HTTP/2.0 200 Ref: "https://spacecityweather.com/" UA: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"

[22/Dec/2025:06:15:41 -0600] spacecityweather.com [MISS] 57.103.65.197 | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2/ HTTP/2.0 200 Ref: "-" UA: "AppleNewsBot"

[22/Dec/2025:06:15:41 -0600] spacecityweather.com [MISS] 57.103.65.183 | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2/ HTTP/2.0 200 Ref: "-" UA: "AppleNewsBot"

[22/Dec/2025:06:15:41 -0600] spacecityweather.com [-] [Discourse server IP] | POST /wp-json/wp-discourse/v1/update-topic-content HTTP/1.1 200 Ref: "-" UA: "Discourse/2025.12.0-latest-585840225f344268cf72cb570ec0f069b83088a8; +https://www.discourse.org/"

[22/Dec/2025:06:15:41 -0600] spacecityweather.com [MISS] 57.103.65.175 | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2 HTTP/2.0 301 Ref: "-" UA: "AppleNewsBot"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [MISS] [Discourse server IP] | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2/ HTTP/1.1 200 Ref: "-" UA: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [-] [Discourse server IP] | GET /wp-content/uploads/2025/12/image-29.png HTTP/1.1 200 Ref: "-" UA: "Discourse/2025.12.0-latest-585840225f344268cf72cb570ec0f069b83088a8; +https://www.discourse.org/"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [-] [Discourse server IP] | GET /wp-content/uploads/2025/12/change.jpg HTTP/1.1 200 Ref: "-" UA: "Discourse/2025.12.0-latest-585840225f344268cf72cb570ec0f069b83088a8; +https://www.discourse.org/"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [HIT] [Discourse server IP] | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2/ HTTP/1.1 200 Ref: "-" UA: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [MISS] 2a01:b747:3003:206::32 | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2/ HTTP/2.0 200 Ref: "-" UA: "AppleNewsBot"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [HIT] 57.103.65.182 | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2 HTTP/2.0 301 Ref: "-" UA: "AppleNewsBot"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [BYPASS] [Poster IP] | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2/ HTTP/2.0 200 Ref: "https://spacecityweather.com/" UA: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"

[22/Dec/2025:06:15:42 -0600] spacecityweather.com [HIT] 57.103.65.181 | GET /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2/ HTTP/2.0 200 Ref: "-" UA: "AppleNewsBot"

[Poster IP]은 WP 게시물 작성자의 IP 주소(비공개 처리됨)이고, [Discourse server IP]은 Discourse 서버의 VPC 주소입니다.

[HIT], [MISS], [BYPASS]는 해당 요청에 대한 로컬 nginx fastcgi 캐시의 캐시 히트 상태이며, 좋은 보조 지표입니다.

새로운 게시물 URL은 /its-beginning-to-feel-not-like-christmas-everywhere-you-go-2입니다.

간단히 말해, 로그는 게시물이 생성된 후, Discourse 플러그인이 작업을 수행하기 전에 AppleNewsBot이(게시물 게시로 트리거됨) 방금 게시된 퍼머링크를 방문하는 것을 보여줍니다. (Discourse가 게시물을 빌드하고 이미지를 가져오는 과정과 다른 Apple News 서버들도 사본을 가져가는 것을 바로 아래에서 볼 수 있습니다.)

이 레이스 컨디션이 CF의 엣지 캐시에 오래된 페이지가 표시되는 원인이었을 가능성이 매우 높습니다. 불일치가 발생하는 이유는 Discourse가 레이스에서 이기면 게시물이 캐시에 처음 저장될 때 적절한 댓글이 포함되지만, Apple News가 이기면 그렇지 않기 때문입니다. WP CF 플러그인에는 자체 캐시 클리러 로직이 있지만, Apple News보다 먼저 실행되는 것으로 보입니다.

어쨌든, 문제의 원인을 알게 되자 훨씬 마음이 편해졌습니다. Apple News 플러그인 유지 관리자에게 이슈 요청을 제출하는 것을 고려하고 있지만, 이 문제가 개발자의 관심을 받을 만큼 광범위한지 확신할 수 없습니다. 게다가, mu-plugin(nginx가 자체 캐싱에 대해 현명하게 처리하도록 하는 것과 결합하여)는 어쨌든 제게 이 문제를 완전히 해결해 줍니다.

3) Ajax 응답을 60초간 캐시하여 케이크도 먹고 남도 먹으려 하기

wp-discourse 플러그인은 WP 게시물/페이지에서 댓글 캐싱을 처리하는 여러 가지 방법이 있지만, 사이트 소유자의 요구사항에 맞추기 위해 댓글 표시를 위해 ajax 방식을 선택했습니다. 이것은 제가 원하는 것과 거의 정확히 일치하며, Discourse 측에서 댓글이 작성된 직후 WP 게시물에 새 댓글이 표시되므로 훌륭하게 작동합니다.

하지만 항상 높은 트래픽 이벤트의 그림자가 드리워져 있습니다—이것은 날씨 예보 사이트이고, 때로는 예보가 폭발적으로 퍼지니까요. 갑자기 임베드된 댓글을 보고 생성되는 모든 ajax 요청으로 인해 사용자가 몰려들면 어떻게 될까요?

결국 Cloudflare를 조금 악용하여 Cloudflare의 엣지에서 ajax 응답을 짧은 시간 동안 캐시하여 잠재적인 부하를 조금 덜어낼 수 있다는 것을 알게 되었습니다. 이것은 WP에게 무엇을 해야 하는지 알려주는 또 다른 mu-plugin과 CF에게 무엇을 해야 하는지 알려주는 Cloudflare 캐시 규칙이 필요합니다.

discourse-rest-edge-cache.php
<?php
/**
 * Plugin Name: Discourse REST Edge Cache
 * Description: Sets cache headers on WP-Discourse REST responses so Cloudflare can cache them (~60s at edge) while browsers revalidate.
 * Version: 1.0.3
 * Author: Lee Hutchinson + ChatGPT
 */

if (!defined('ABSPATH')) exit;

add_filter('rest_post_dispatch', function($result, $server, $request){
    if (!($request instanceof WP_REST_Request)) return $result;

    // Match the WP-Discourse namespace at the start of the route
    $route = $request->get_route(); // e.g., /wp-discourse/v1/...
    $attrs = method_exists($request, 'get_attributes') ? (array) $request->get_attributes() : [];
    $ns    = $attrs['namespace'] ?? '';

    $is_discourse = ($ns === 'wp-discourse') || (is_string($route) && preg_match('#^/wp-discourse(?:/|$)#', $route));
    if (!$is_discourse) return $result;

    // Safe methods only
    $method = strtoupper($request->get_method() ?: 'GET');
    if ($method !== 'GET' && $method !== 'HEAD') return $result;

    // Don’t cache personalized/authenticated requests
    if (is_user_logged_in()
        || $request->get_header('authorization')
        || $request->get_header('x-wp-nonce')
        || $request->get_header('cookie')) {
        return $result;
    }

    // Cache only successful responses
    if (is_wp_error($result)) return $result;
    $status = ($result instanceof WP_HTTP_Response) ? (int) $result->get_status() : 200;
    if ($status < 200 || $status >= 400) return $result;

    // Edge TTL ~60s, browsers revalidate; SWR smooths refresh
    $server->send_header('Cache-Control', 'public, s-maxage=60, max-age=0, stale-while-revalidate=30');
    $server->send_header('Vary', 'Accept-Encoding');

    return $result;
}, 10, 3);

이것은 WP가 /wp-json/wp-discourse/v1/discourse-comments?post_id= 호출에 Cache-Control 헤더를 붙이도록 합니다. s-maxage=60은 응답이 캐시에 남아있기를 원하는 길이를 설정하는 곳입니다.

Cloudflare는 일반적으로 이러한 유형의 요청을 캐시하지 않으므로, 캐시 규칙을 사용하여 이를 부드럽게 유도해야 합니다:

Some Cloudflare cache rule screenshots

이 규칙은 /wp-json/wp-discourse/가 포함된 경로를 캐싱 대상이 되도록 표시하고, CF의 엣지 캐시가 오리지신이 요청하는 동안만큼 이를 유지하도록 지시합니다. 우리 경우, 60초를 요청하고 있습니다. (세 번째 스크린샷의 그 좋은 “time-to-live 입력” 상자를 사용하지 않고 플러그인으로 이렇게 해야 하는 이유는, Cloudflare가 무료 계정에 대해 허용하는 최소 TTL은 2시간이고 Pro 계정에 대해서는 1시간인데, 저는 그보다 훨씬 짧은 시간을 원하기 때문입니다.)

WP 게시물이 폭발적으로 퍼져서 100,000명이 동시에 그것을 보기 시작하면, 이 짧은 시간 캐시가 제게 조금 도움이 될 것이라고 생각합니다—CF가 부하의 일부를 지탱하는 동안, 거의 실시간으로 업데이트되는 댓글의 외관을 유지할 수 있기 때문입니다.

이 항목들 외에—그 어느 것도 wp-discourse의 잘못이 아닙니다!—CF와 wp-discourse는 통합하기가 매우 쉬웠습니다. 저는 만족스러운 고객입니다. (솔직히 말하면, 조적거리는 구실도 꽤 즐겼습니다!)

2개의 좋아요