GCPNGINXCloudflare

Cloudflare를 사용한 Subdomain Routing

2023.05.28


기독교 선교단체 ECU 는 캠퍼스 복음화를 위해 성경공부, 전도, 양육에 전념하는 건강한 선교단체입니다. ECU에서는 메인 도메인(ecukorea.com) 외에도 수련회 신청, 연합수련회 데이터베이스, 토요모임 주보 등의 서비스를 subdomain으로 등록하여 사용하고 있습니다. 이 때 Cloudflare를 어떻게 활용하고 있는지에 관해 정리해보았습니다.

최초 요구사항

홈페이지는 SSR을 위해 Next.JS로 제작되어 Netlify로 배포되어있습니다. Netlify에서는 도메인 등록을 지원하기 때문에, 가비아에서 구입한 도메인에 Netlify 네임서버를 등록하여 사용하고 있었습니다. 기독교 연합 수련회를 찾고 있는 사람들에게 정보를 제공함과 함께, ECU 선교단체를 홍보할 수 있겠다는 생각을 가진 분이 계셨고, 해당 정보를 정리해서 제공하는 노션페이지가 제작되어있는 상태였습니다. 최초 요구사항은 ecukorea.com/campdb 로 접근했을 때 해당 노션 페이지를 그려줄 수 있는 것이었는데, subdomain으로 분리되지 않은 상태에서 pathname으로 접근할 때, 다른 서버로 접근하게 위해서는 아래와 같은 방법들이 떠올랐습니다.

  1. Proxy 서버를 만들어서, pathname 패턴에 따라 지정된 서버로 안내해준다.

  2. Next.JS로 작성되어있으니, pages 내에 campdb 컴포넌트를 만들어놓고

    2-1. iframe으로 노션 페이지에 접근한다.

    2-2. document.location.href = "노션 주소" 로 url을 바꿔버린다.

Parts of URL

각 방법들은 아래와 같은 특징이 있습니다.

1. Proxy 서버를 만들어서, pathname 패턴에 따라 지정된 서버로 안내해준다.

Nginx를 사용한다면, 추후 별도의 서비스가 추가될 때 nginx.conf 파일을 수정해서 배포만 하면 되니 쉽게 확장할 수 있다는 장점이 있으나, 관리해야할 서버가 생기게 된다는 단점이 있었습니다.

2. Next.JS의 pages 내에 campdb 컴포넌트를 만들어 routing list에 추가한 후

  • 2-1. iframe으로 노션 페이지에 접근한다.

    노션에서 iframe으로 접근하는 것을 막고 있어서 이 방식은 사용할 수 없었습니다.

  • 2-2. documetn.location.href = "노션 페이지 주소" 로 url 을 바꿔버린다.

    어쩌면 가장 쉬운 방법이지만, URL이 campdb.ecukorea.com 으로 유지되지 않아 아쉬운 방법이었습니다.


가비아 도메인 포워딩의 한계

가장 쉬울 것으로 생각이 든 방법은 굳이 위의 작업을 하기 보다는, 가비아에서 제공하는 포워딩 기능을 활용하는 것이었습니다. 기존에 ecukorea.com 은 원래대로 Netlify 쪽으로, campdb.ecukorea.com 으로 접근할 때는 notion 페이지로 연결시켜주는 방법이었는데, 여기서 문제가 발생합니다.

Gabia alert

즉, ecukorea.com 이라는 도메인은 Netlify 네임서버를 사용하고 있었기에, 네임서버를 변경하지 않는 상태로는 도메인 포워딩이 불가능했습니다. 브라우저에 홈페이지 주소를 입력하면 어떤 과정을 통해 서버를 찾게되는지를 생각해보면 왜 그런지 알 수 있는데, 그 당시 여기까지 미처 생각하지 못했습니다.

DNS flow diagram

가비아에서 CNAME을 등록해서 서브도메인을 생성할 수 있지만, 도메인 포워딩의 경우, 서브도메인별로 포워딩이 불가능했습니다. 가비아에서는 네임서버를 10개까지는 무료로 등록 가능합니다. 따라서, Netlify 네임서버를 유지한채로, 가비아 네임서버를 등록해놓을 수 있었지만, 도메인 포워딩의 기능에서 별도의 CNAME을 설정할 수 없고 A 레코드로만 설정되는 것으로 보아, 노션 페이지로 포워딩을 등록해놓는다면 ecukorea.com 으로 접근하게 될 때, Netlify로 배포된 서비스가 아니라, 노션 페이지로 이동하게 될 것으로 예상되어 해당 방법으로는 원하는 결과를 얻을 수 없었습니다.


Google Cloud Run의 한계

결국, 1번 방법으로 해결해야겠다 라는 생각이 들게 되었고, Nginx로 proxy 서버를 만들어 Google Cloud Run으로 배포하기로 했습니다. Google Cloud Run으로 생성된 서버에 ecukorea.com 도메인을 등록하기 위해서는, 구글 네임서버를 등록해줘야했고, Nginx가 ecukorea.com 으로 요청이 들어오면 Netlify로, campdb.ecukorea.com으로 요청이 들어오면 Notion으로 각각 proxy_pass를 해주도록 했습니다. Notion에서는 보안상의 목적으로, request origin이 notion.so 가 아니면 요청을 거부하도록 정책을 세워놓은 것 같습니다. 여기 를 참고해서 proxy 설정을 진행해봤으나, 이 상태로 배포하면 아래와 같은 메시지를 마주하게 됩니다.

Mismatch URL

몇 년 전만해도 위 링크처럼 proxy 설정을 할 경우 잘 작동했던 모양입니다. 그러나 이 레포지토리 에서 Notion API를 활용하여 해당 페이지를 크롤링 해와서 유저에게 제공하는 서비스를 만든것을 보면, 현재는 proxy pass 만으로 간단히 해결할 수 있는 범위를 벗어난 것으로 느껴졌습니다.

Diagram

그래서 일단, 도메인이 유지되지 못하는 것은 아쉽지만, 단순히 노션 페이지로 redirect 하는 방식으로 Nginx 서버를 배포했습니다.

그러나, 새로고침을 하다보면 503 Service Unavailable이 뜨게 되는 새로운 문제가 생겼습니다.

아마도 GCR은 서버리스 서비스이고, 인스턴스가 자동으로 terminate 되어서 그런지, 일반적인 서버가 떠있는 것 처럼 작동하지 않았던 것 같습니다. (구글 네임서버에서 특정 인스턴스의 IP로 요청을 전달하는 과정에서 이미 terminate 된 인스턴스로 연결시키는 듯 했습니다.)

그래서 새로운 고민을 시작하게 되었습니다.

  • Notion API를 활용해서 해당 페이지의 블록들을 가져오고, CSS를 별도로 적용할까?
  • 혹시 원하는 기능을 하는 라이브러리가 이미 존재하나?
  • 만약 직접 구현한다면, 테이블의 필터링 기능은 어떻게 구현해야하나?

Notion API를 활용해서 원하는대로 구현이 가능하지만, 코드를 작성하는 과정에서 많은 시간이 걸릴 것으로 예상되었습니다.

Cloudflare를 적용해보기

Cloudflare를 사용해본 적이 없었기에, 검색하다 발견된 블로그 글들에 눈길이 가지 않았으나, 해당 방식을 한번 시도해보게 되었습니다. 방법은 굉장히 간단했습니다. Cloudflare 네임서버로 변경한 후, subdomain 별로 worker를 작성해주면 완료됩니다.

Fruitionsite 에서 소유하고 있는 도메인 주소와 어떤 노션 페이지에 연결시킬지를 정하면, worker에 입력할 코드를 생성해줍니다.

생성된 Notion worker 코드
/* CONFIGURATION STARTS HERE */

/* Step 1: enter your domain name like fruitionsite.com */
const MY_DOMAIN = "campdb.ecukorea.com";

/*
 * Step 2: enter your URL slug to page ID mapping
 * The key on the left is the slug (without the slash)
 * The value on the right is the Notion page ID
 */
const SLUG_TO_PAGE = {
  "": "노션 페이지 hash",
};

/* Step 3: enter your page title and description for SEO purposes */
const PAGE_TITLE = "전국 기독교 연합수련회/연합캠프 소개 데이터베이스";
const PAGE_DESCRIPTION =
  "이번 여름을 준비하는 많은 기독교인들의 유익을 위해, 주변 분들께 이 페이지를 공유해주시기 바랍니다. 한국 교회를 위한 소중한 정보를 공유해주셔서 감사합니다.";

/* Step 4: enter a Google Font name, you can choose from <https://fonts.google.com> */
const GOOGLE_FONT = "";

/* Step 5: enter any custom scripts you'd like */
const CUSTOM_SCRIPT = `GA 코드 삽입`;

/* CONFIGURATION ENDS HERE */

const PAGE_TO_SLUG = {};
const slugs = [];
const pages = [];
Object.keys(SLUG_TO_PAGE).forEach((slug) => {
  const page = SLUG_TO_PAGE[slug];
  slugs.push(slug);
  pages.push(page);
  PAGE_TO_SLUG[page] = slug;
});

addEventListener("fetch", (event) => {
  event.respondWith(fetchAndApply(event.request));
});

function generateSitemap() {
  let sitemap =
    '<urlset xmlns="<http://www.sitemaps.org/schemas/sitemap/0.9>">';
  slugs.forEach(
    (slug) =>
      (sitemap +=
        "<url><loc>https://" + MY_DOMAIN + "/" + slug + "</loc></url>")
  );
  sitemap += "</urlset>";
  return sitemap;
}

const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Methods": "GET, HEAD, POST, PUT, OPTIONS",
  "Access-Control-Allow-Headers": "Content-Type",
};

function handleOptions(request) {
  if (
    request.headers.get("Origin") !== null &&
    request.headers.get("Access-Control-Request-Method") !== null &&
    request.headers.get("Access-Control-Request-Headers") !== null
  ) {
    // Handle CORS pre-flight request.
    return new Response(null, {
      headers: corsHeaders,
    });
  } else {
    // Handle standard OPTIONS request.
    return new Response(null, {
      headers: {
        Allow: "GET, HEAD, POST, PUT, OPTIONS",
      },
    });
  }
}

async function fetchAndApply(request) {
  if (request.method === "OPTIONS") {
    return handleOptions(request);
  }
  let url = new URL(request.url);
  url.hostname = "www.notion.so";
  if (url.pathname === "/robots.txt") {
    return new Response("Sitemap: https://" + MY_DOMAIN + "/sitemap.xml");
  }
  if (url.pathname === "/sitemap.xml") {
    let response = new Response(generateSitemap());
    response.headers.set("content-type", "application/xml");
    return response;
  }
  let response;
  if (url.pathname.startsWith("/app") && url.pathname.endsWith("js")) {
    response = await fetch(url.toString());
    let body = await response.text();
    response = new Response(
      body
        .replace(/www.notion.so/g, MY_DOMAIN)
        .replace(/notion.so/g, MY_DOMAIN),
      response
    );
    response.headers.set("Content-Type", "application/x-javascript");
    return response;
  } else if (url.pathname.startsWith("/api")) {
    // Forward API
    response = await fetch(url.toString(), {
      body: url.pathname.startsWith("/api/v3/getPublicPageData")
        ? null
        : request.body,
      headers: {
        "content-type": "application/json;charset=UTF-8",
        "user-agent":
          "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.163 Safari/537.36",
      },
      method: "POST",
    });
    response = new Response(response.body, response);
    response.headers.set("Access-Control-Allow-Origin", "*");
    return response;
  } else if (slugs.indexOf(url.pathname.slice(1)) > -1) {
    const pageId = SLUG_TO_PAGE[url.pathname.slice(1)];
    return Response.redirect("https://" + MY_DOMAIN + "/" + pageId, 301);
  } else {
    response = await fetch(url.toString(), {
      body: request.body,
      headers: request.headers,
      method: request.method,
    });
    response = new Response(response.body, response);
    response.headers.delete("Content-Security-Policy");
    response.headers.delete("X-Content-Security-Policy");
  }

  return appendJavascript(response, SLUG_TO_PAGE);
}

class MetaRewriter {
  element(element) {
    if (PAGE_TITLE !== "") {
      if (
        element.getAttribute("property") === "og:title" ||
        element.getAttribute("name") === "twitter:title"
      ) {
        element.setAttribute("content", PAGE_TITLE);
      }
      if (element.tagName === "title") {
        element.setInnerContent(PAGE_TITLE);
      }
    }
    if (PAGE_DESCRIPTION !== "") {
      if (
        element.getAttribute("name") === "description" ||
        element.getAttribute("property") === "og:description" ||
        element.getAttribute("name") === "twitter:description"
      ) {
        element.setAttribute("content", PAGE_DESCRIPTION);
      }
    }
    if (
      element.getAttribute("property") === "og:url" ||
      element.getAttribute("name") === "twitter:url"
    ) {
      element.setAttribute("content", MY_DOMAIN);
    }
    if (element.getAttribute("name") === "apple-itunes-app") {
      element.remove();
    }
  }
}

class HeadRewriter {
  element(element) {
    if (GOOGLE_FONT !== "") {
      element.append(
        `<link href="<https://fonts.googleapis.com/css?family=$>{GOOGLE_FONT.replace(' ', '+')}:Regular,Bold,Italic&display=swap" rel="stylesheet">
        <style>* { font-family: "${GOOGLE_FONT}" !important; }</style>`,
        {
          html: true,
        }
      );
    }
    element.append(
      `<style>
      div.notion-topbar > div > div:nth-child(3) { display: none !important; }
      div.notion-topbar > div > div:nth-child(4) { display: none !important; }
      div.notion-topbar > div > div:nth-child(5) { display: none !important; }
      div.notion-topbar > div > div:nth-child(6) { display: none !important; }
      div.notion-topbar-mobile > div:nth-child(3) { display: none !important; }
      div.notion-topbar-mobile > div:nth-child(4) { display: none !important; }
      div.notion-topbar > div > div:nth-child(1n).toggle-mode { display: block !important; }
      div.notion-topbar-mobile > div:nth-child(1n).toggle-mode { display: block !important; }
      </style>`,
      {
        html: true,
      }
    );
  }
}

class BodyRewriter {
  constructor(SLUG_TO_PAGE) {
    this.SLUG_TO_PAGE = SLUG_TO_PAGE;
  }
  element(element) {
    element.append(
      `<div style="display:none">Powered by <a href="<http://fruitionsite.com>">Fruition</a></div>
      <script>
      window.CONFIG.domainBaseUrl = '<https://$>{MY_DOMAIN}';
      const SLUG_TO_PAGE = ${JSON.stringify(this.SLUG_TO_PAGE)};
      const PAGE_TO_SLUG = {};
      const slugs = [];
      const pages = [];
      const el = document.createElement('div');
      let redirected = false;
      Object.keys(SLUG_TO_PAGE).forEach(slug => {
        const page = SLUG_TO_PAGE[slug];
        slugs.push(slug);
        pages.push(page);
        PAGE_TO_SLUG[page] = slug;
      });
      function getPage() {
        return location.pathname.slice(-32);
      }
      function getSlug() {
        return location.pathname.slice(1);
      }
      function updateSlug() {
        const slug = PAGE_TO_SLUG[getPage()];
        if (slug != null) {
          history.replaceState(history.state, '', '/' + slug);
        }
      }
      function onDark() {
        el.innerHTML = '<div title="Change to Light Mode" style="margin-left: auto; margin-right: 14px; min-width: 0px;"><div role="button" tabindex="0" style="user-select: none; transition: background 120ms ease-in 0s; cursor: pointer; border-radius: 44px;"><div style="display: flex; flex-shrink: 0; height: 14px; width: 26px; border-radius: 44px; padding: 2px; box-sizing: content-box; background: rgb(46, 170, 220); transition: background 200ms ease 0s, box-shadow 200ms ease 0s;"><div style="width: 14px; height: 14px; border-radius: 44px; background: white; transition: transform 200ms ease-out 0s, background 200ms ease-out 0s; transform: translateX(12px) translateY(0px);"></div></div></div></div>';
        document.body.classList.add('dark');
        __console.environment.ThemeStore.setState({ mode: 'dark' });
      };
      function onLight() {
        el.innerHTML = '<div title="Change to Dark Mode" style="margin-left: auto; margin-right: 14px; min-width: 0px;"><div role="button" tabindex="0" style="user-select: none; transition: background 120ms ease-in 0s; cursor: pointer; border-radius: 44px;"><div style="display: flex; flex-shrink: 0; height: 14px; width: 26px; border-radius: 44px; padding: 2px; box-sizing: content-box; background: rgba(135, 131, 120, 0.3); transition: background 200ms ease 0s, box-shadow 200ms ease 0s;"><div style="width: 14px; height: 14px; border-radius: 44px; background: white; transition: transform 200ms ease-out 0s, background 200ms ease-out 0s; transform: translateX(0px) translateY(0px);"></div></div></div></div>';
        document.body.classList.remove('dark');
        __console.environment.ThemeStore.setState({ mode: 'light' });
      }
      function toggle() {
        if (document.body.classList.contains('dark')) {
          onLight();
        } else {
          onDark();
        }
      }
      function addDarkModeButton(device) {
        const nav = device === 'web' ? document.querySelector('.notion-topbar').firstChild : document.querySelector('.notion-topbar-mobile');
        el.className = 'toggle-mode';
        el.addEventListener('click', toggle);
        nav.appendChild(el);
        onLight();
      }
      const observer = new MutationObserver(function() {
        if (redirected) return;
        const nav = document.querySelector('.notion-topbar');
        const mobileNav = document.querySelector('.notion-topbar-mobile');
        if (nav && nav.firstChild && nav.firstChild.firstChild
          || mobileNav && mobileNav.firstChild) {
          redirected = true;
          updateSlug();
          addDarkModeButton(nav ? 'web' : 'mobile');
          const onpopstate = window.onpopstate;
          window.onpopstate = function() {
            if (slugs.includes(getSlug())) {
              const page = SLUG_TO_PAGE[getSlug()];
              if (page) {
                history.replaceState(history.state, 'bypass', '/' + page);
              }
            }
            onpopstate.apply(this, [].slice.call(arguments));
            updateSlug();
          };
        }
      });
      observer.observe(document.querySelector('#notion-app'), {
        childList: true,
        subtree: true,
      });
      const replaceState = window.history.replaceState;
      window.history.replaceState = function(state) {
        if (arguments[1] !== 'bypass' && slugs.includes(getSlug())) return;
        return replaceState.apply(window.history, arguments);
      };
      const pushState = window.history.pushState;
      window.history.pushState = function(state) {
        const dest = new URL(location.protocol + location.host + arguments[2]);
        const id = dest.pathname.slice(-32);
        if (pages.includes(id)) {
          arguments[2] = '/' + PAGE_TO_SLUG[id];
        }
        return pushState.apply(window.history, arguments);
      };
      const open = window.XMLHttpRequest.prototype.open;
      window.XMLHttpRequest.prototype.open = function() {
        arguments[1] = arguments[1].replace('${MY_DOMAIN}', 'www.notion.so');
        return open.apply(this, [].slice.call(arguments));
      };
    </script>${CUSTOM_SCRIPT}`,
      {
        html: true,
      }
    );
  }
}

async function appendJavascript(res, SLUG_TO_PAGE) {
  return new HTMLRewriter()
    .on("title", new MetaRewriter())
    .on("meta", new MetaRewriter())
    .on("head", new HeadRewriter())
    .on("body", new BodyRewriter(SLUG_TO_PAGE))
    .transform(res);
}

Cloudflare의 free plan 은 하루에 100,000개의 요청까지 사용 가능하기에 과금에 대한 걱정은 현재 상황에서 불필요했습니다.

메인 홈페이지와 다른 서비스들에는 서브도메인별로 cloudflare의 worker를 생성해주었습니다. Notion으로 이동시키는 worker를 제외한 나머지 worker들은 아래와 같이 간단하게 작성할 수 있었습니다.

export default {
  async fetch(request, env) {
    try {
      const { pathname, search } = new URL(request.url);
      return fetch(`${배포된_서비스_URL}${pathname}${search}`);
    } catch(e) {
      return new Response(err.stack, { status: 500 })
    }
  }
}

어떤 차이가 다른 결과를 만들었을까?

노션이 페이지를 렌더링 하는 과정에서 두 개의 주요한 API를 호출하게 됩니다.

  1. POST api/v3/getClientExperimentsV2
  2. POST api/v3/getPublicPageData

이 API들이 정상적으로 호출되어야 데이터를 가져오게 되고, 해당 데이터를 가지고 브라우저에서 렌더링(CSR)이 진행됩니다. 이 과정에서 노션은 origin이 notion.so 와 다를 경우, request를 reject 하는 것으로 보입니다.

  • GCP를 Proxy 서버로 사용했을 때에는, CSR을 하기 위한 data fetching 과정에서 request origin이 일치하지 않으므로 노션에서 요청을 거부하게 됩니다.

GCP Flow

  • Cloudflare를 사용했을 때의 다른 점은, CSR을 하기 위한 data fetching 과정에서 cloudflare가 개입한다는 점 입니다. 이 때 요청이 Notion.so 에서 일어난 것 처럼 바꿔치기하는 Worker를 작성했다는 점이 차이점이 되겠습니다.

    이 때, Cloudflare의 HTMLRewriter가 title / meta / head / body를 해당 페이지에서 요청이 일어난 것 처럼 response를 변환시켜줍니다.

Cloudflare

정리

간단하게 해결될 것으로 예상했던 요구사항이었지만 생각보다 다양한 삽질을 하게 됐습니다. Notion이 생각보다 까다롭게 API 요청을 다루고 있었고, 당연히 될 거라고 생각했던 서브도메인 포워딩 기능을 가비아에서는 제공하지 않고 있었습니다. 앞으로 ECU에서 또 어떤 서비스를 제공하게 될지 모르겠지만, Cloudflare를 활용해서 편리하게 다양한 서비스를 관리할 수 있을 것 같습니다.

아래 링크들은 현재 ECU에서 사용하고 있는 서비스 주소들입니다. Cloudflare가 어떤식으로 개입하고 있는지 개발자 도구의 network 탭에서 확인해보실 수 있습니다.

캠퍼스 복음화의 비전을 갖고 사역해나가고 있는 ECU를 위해 기도해주시고 많은 관심 가져주시기 바랍니다.


참고