Framer의 한 섹션만 브랜드 도메인에 붙이기 글 표지
AWSCloudFrontCloudFront FunctionsFramerReverse ProxyDNS

Framer의 한 섹션만 브랜드 도메인에 붙이기

Framer 안의 한 페이지를 브랜드 도메인으로 열기 위해 CloudFront에서 경로를 바꿔 전달한 과정을 적었습니다.

홈페이지 전체는 Framer 프로젝트 하나에서 관리하고 있었습니다. 마케팅팀 요청은 그중 한 프로덕트 섹션만 새 브랜드 도메인의 첫 화면으로 여는 것이었습니다. 주소창은 끝까지 브랜드 도메인을 유지해야 했습니다.

기술 예시는 실제 도메인 대신 www.example.com, brand.example, /brand로 적었습니다. 당시 작업은 기존 Framer 프로젝트의 편집 흐름을 유지하면서 브랜드 진입점만 분리하는 일이었습니다.

문제를 경로 변환으로 좁히기

원본과 공개 URL의 관계는 단순했습니다.

공개 URL                         Framer에서 발행된 경로
https://brand.example/       -> https://www.example.com/brand
https://brand.example/notice -> https://www.example.com/brand/notice

301 또는 308 redirect라면 구현은 쉽지만 브라우저가 원본 도메인으로 이동합니다. <iframe>은 URL을 유지해도 검색 노출, 탐색 기록, 스크롤과 포커스 문제를 새로 만듭니다. Framer 프로젝트를 브랜드별로 복제하면 배포 구조는 명료해지지만 같은 콘텐츠를 여러 프로젝트에서 관리해야 합니다.

당시에는 세 대안보다 reverse proxy가 요구에 가까웠습니다. CloudFront가 브랜드 도메인의 요청을 받고, viewer-request 단계에서 URI를 Framer의 하위 경로로 바꿔 origin에 전달하는 방식입니다.

브랜드 도메인 요청을 CloudFront가 Framer의 하위 경로로 바꾸어 전달하는 흐름
browser: brand.example
       |
       v
Route 53: A/AAAA Alias
       |
       v
CloudFront + ACM certificate
       |
       | viewer-request URI rewrite
       v
Framer origin: /brand

작업 당시에는 한 프로젝트 안의 특정 페이지만 별도 브랜드 도메인의 루트로 연결하는 편집 구조가 맞지 않아 외부 proxy를 선택했습니다. 다만 이 선택을 “Framer는 언제나 도메인 하나당 사이트 하나만 허용한다”는 규칙으로 일반화할 수는 없습니다. Framer의 hosting 기능과 요금제는 바뀔 수 있으며, 현재 문서에는 reverse proxy와 Multi Site rewrite가 별도 기능으로 안내되어 있습니다.

왜 CloudFront Functions였나

필요한 로직은 요청 본문을 읽거나 외부 API를 호출하는 일이 아니라 URI와 일부 헤더를 다루는 일이었습니다. 이 범위에는 CloudFront Functions가 맞았습니다.

CloudFront Functions는 viewer request와 viewer response에서 짧은 edge 로직을 실행합니다. 대신 네트워크, 파일 시스템, 환경 변수, timer에 접근할 수 없고 request body도 읽지 못합니다. origin request/response가 필요하거나 외부 조회, 무거운 연산, 본문 변환이 필요하다면 Lambda@Edge나 별도 proxy 서버를 검토해야 합니다. 특히 URI를 바꿔도 이미 선택된 cache behavior와 origin은 바뀌지 않습니다. 경로별 origin 선택까지 기대해서는 안 됩니다.

필요한 실행 지점과 기능이 경량 rewrite에 맞았기 때문에 CloudFront Functions를 택했습니다.

DNS와 TLS를 먼저 고정했다

브랜드 도메인이 brand.example처럼 zone apex라면 표준 CNAME을 만들 수 없습니다. Route 53에서는 CloudFront distribution을 가리키는 A Alias를 만들고, IPv6를 켰다면 같은 대상의 AAAA Alias도 추가합니다. www.brand.example 같은 하위 도메인에는 CNAME을 쓸 수 있지만, 루트와 하위 도메인의 규칙을 섞으면 안 됩니다.

CloudFront의 Alternate Domain Name에는 공개 도메인을 등록했습니다. Viewer와 CloudFront 사이에 쓸 ACM 인증서는 반드시 us-east-1에서 발급하거나 가져와야 하며, 인증서의 SAN이 공개 도메인을 포함해야 합니다. Viewer protocol policy는 HTTP 요청을 HTTPS로 redirect하도록 두고, origin 연결도 HTTPS로 제한했습니다.

모든 경로를 붙이는 rewrite의 문제

초기 구현은 / 앞에 /brand를 붙이는 짧은 함수였습니다. 프로젝트에서 실제로 확인한 첫 실패는 trailing slash였습니다. /notice//brand/notice/로 전달하자 origin이 404를 반환했고, /brand/notice는 정상 응답했습니다.

단순히 slash만 제거해 내부 rewrite하면 /notice//notice가 같은 문서를 서로 다른 공개 URL로 제공합니다. 공개 URL을 하나로 모으려면 slash가 붙은 요청을 먼저 308로 정규화하고, 다음 요청에서 rewrite하는 편이 분명합니다.

더 큰 문제는 전체 경로를 무조건 prefix한 범위였습니다. HTML이 참조하는 정적 파일, robots.txt, sitemap.xml, 존재하지 않는 페이지까지 모두 브랜드 섹션 아래로 보낼 수 있습니다. 당시 코드는 경로 전체를 포괄했고, 원문에 남은 검증 기록도 root와 하위 페이지 위주였습니다. 같은 구성을 다시 만든다면 공개할 문서 경로와 실제 발행 HTML에서 확인한 정적 파일 경로를 분리하겠습니다.

아래 코드는 기존 배포본이 아니라 그 회고를 반영한 보강 예시입니다. STATIC_PREFIXES는 복사해서 바로 쓰는 값이 아니라, 발행된 HTML과 브라우저 Network 패널에서 확인한 경로로 교체해야 합니다. Framer 전용 CDN처럼 다른 host를 가리키는 asset은 이 distribution에 요청조차 들어오지 않습니다.

var ROUTE_MAP = {
  '/': '/brand',
  '/notice': '/brand/notice',
  '/privacy': '/brand/privacy'
};
 
var STATIC_PREFIXES = ['/verified-static-prefix/'];
 
function hasStaticPrefix(uri) {
  return STATIC_PREFIXES.some(function (prefix) {
    return uri.indexOf(prefix) === 0;
  });
}
 
function response(statusCode, description, headers) {
  return {
    statusCode: statusCode,
    statusDescription: description,
    headers: headers || {}
  };
}
 
function getQuerySuffix(querystring) {
  var pairs = [];
 
  for (var key in querystring) {
    var item = querystring[key];
    var values = item.multiValue || [item];
 
    values.forEach(function (entry) {
      pairs.push(key + '=' + entry.value);
    });
  }
 
  return pairs.length ? '?' + pairs.join('&') : '';
}
 
function handler(event) {
  var request = event.request;
  var uri = request.uri;
 
  if (uri.length > 1 && uri.endsWith('/')) {
    return response(308, 'Permanent Redirect', {
      location: {
        value: uri.slice(0, -1) + getQuerySuffix(request.querystring)
      }
    });
  }
 
  if (hasStaticPrefix(uri)) {
    return request;
  }
 
  var originPath = ROUTE_MAP[uri];
 
  if (!originPath) {
    return response(404, 'Not Found', {
      'cache-control': { value: 'no-store' }
    });
  }
 
  request.uri = originPath;
  return request;
}

실제 프로젝트의 경로 수가 적어서 rewrite 자체는 작게 유지할 수 있었습니다. 경로가 CMS에서 계속 생기는 구조라면 이 객체를 수동으로 관리하기 어렵습니다. 그때는 배포 과정에서 목록을 생성하거나, KeyValueStore를 쓰거나, Framer의 hosting 기능을 택하는 쪽이 낫습니다.

origin 요청은 별도 계약이다

URI가 맞아도 origin이 어떤 요청을 받는지는 cache policy와 origin request policy에 따라 달라집니다.

Framer의 현재 CloudFront 안내는 origin으로 Framer의 *.framer.website 도메인을 쓰고 AllViewerExceptHostHeader 정책을 적용하는 구성을 제시합니다. CloudFront는 viewer의 Host: brand.example을 그대로 보내는 대신 origin domain을 Host로 사용합니다. 당시 프로젝트에서는 기존 공식 홈페이지의 custom domain을 origin으로 두고 정상 응답을 확인했습니다. 재구성한다면 현재 공식 안내를 따라 전용 Framer subdomain을 origin으로 삼겠습니다.

원래 작업 기록에는 cookie와 query를 어떤 policy로 전달했는지 남아 있지 않습니다. 공개 랜딩이라면 사용자별 응답을 만들지 않는 값은 origin 전달과 cache key에서 함께 빼는 편이 낫습니다. 반대로 password protection, 로그인, 개인화, form 처리처럼 cookie나 method가 의미 있는 페이지라면 정적 랜딩과 같은 behavior를 공유하면 안 됩니다. 필요한 cookie를 전달하고 cache key를 맞추거나, 해당 경로의 caching을 끄는 별도 behavior가 필요합니다. Authorization처럼 origin의 접근 제어에 쓰는 값을 전달하면서 cache key에서 빼면 다른 사용자의 cached response를 돌려줄 수 있습니다.

Query string도 같은 기준으로 판단합니다. 분석용 query가 응답을 바꾸지 않는다면 cache key에서 제외할 수 있습니다. 언어, 미리보기, 개인화처럼 응답을 바꾸는 query라면 포함해야 합니다. 많이 전달할수록 안전하다는 식의 설정은 cache hit ratio를 낮추고 응답 변형 누락을 숨깁니다.

cache, 압축, redirect를 따로 검증해야 했다

compress: true가 origin의 gzip을 그대로 통과시킨다고 단정할 수는 없습니다. CloudFront cache policy에서 Gzip과 Brotli 지원을 켜면 viewer의 Accept-Encoding을 정규화해 origin에 전달하고 압축 variant를 cache합니다. 그래서 plain 요청과 curl --compressed 요청을 나눠 Content-Encoding, Vary, Age를 확인해야 합니다.

HTML과 asset의 갱신 전략도 달라야 합니다. fingerprint가 붙은 asset은 긴 TTL에 잘 맞지만 같은 URL로 다시 발행되는 HTML은 오래 cache하면 게시 직후 구버전이 남습니다. 짧은 HTML TTL을 택하거나 발행 시 필요한 문서 경로를 invalidate해야 합니다. CloudFront invalidation은 다음 요청 때 origin에서 새 객체를 가져오게 하며, versioned filename을 쓸 수 있는 asset에는 파일 버전 관리가 더 예측 가능합니다.

Origin redirect도 빠뜨리기 쉽습니다. Locationwww.example.com/brand/...를 가리키면 proxy는 동작해도 사용자는 origin domain으로 빠져나갑니다. 기존 기록에는 CloudFront 경유, origin 응답과 동일한 ETag, root 및 trailing-slash 동작을 확인한 내용이 남아 있습니다. 아래 항목은 그 확인 범위를 운영용 회귀 목록으로 확장한 것입니다.

  • 공개 경로의 200 응답과 알 수 없는 경로의 404
  • trailing slash의 308과 공개 도메인 Location
  • CSS, JavaScript, font, image 요청의 200과 올바른 MIME type
  • query string이 있는 URL의 cache key와 origin 전달 결과
  • cookie가 없는 요청과 예상치 못한 cookie가 붙은 요청의 cache 분리 여부
  • HTTP에서 HTTPS로 이동한 뒤에도 공개 host가 유지되는지

x-cache, Age, Via, x-amz-cf-pop은 CloudFront 경유와 cache 상태를 확인하는 데 도움이 됩니다. 다만 두 URL의 ETag가 같다는 사실만으로 응답 본문과 header가 모두 같다고 증명할 수는 없습니다. 최종 판정은 status, redirect, 핵심 header와 body를 함께 비교해야 합니다.

SEO 파일은 페이지 rewrite와 다르다

같은 Framer 콘텐츠를 origin 경로와 브랜드 도메인에서 모두 열면 검색 엔진에는 중복 URL이 생깁니다. Framer도 reverse proxy에서는 공개 URL을 canonical로 지정하라고 안내합니다. 페이지 source에서 <link rel="canonical">brand.example을 가리키는지 확인해야 합니다.

한 Framer 프로젝트의 여러 섹션을 각기 다른 브랜드 도메인에 연결하면 제약이 더 커집니다. 프로젝트 전역 canonical 설정만으로 브랜드별 canonical을 모두 표현할 수 없다면 CloudFront Functions만으로 해결할 수 없습니다. 이 함수는 HTML response body를 읽거나 바꾸지 못합니다. 브랜드별 프로젝트, Framer Multi Site, Lambda@Edge 또는 응답 본문을 다룰 수 있는 별도 proxy를 비교해야 합니다.

/robots.txt/sitemap.xml도 자동으로 맞는다고 가정할 수 없습니다. Framer가 생성한 파일은 원본 사이트 전체와 원본 URL을 기준으로 할 수 있습니다. 브랜드에 공개한 경로만 담는지 확인하고, 맞지 않으면 별도 static origin과 전용 cache behavior에서 브랜드용 파일을 제공할 수 있습니다. 이 behavior에는 문서 rewrite Function을 연결하지 않습니다. URI를 바꾼 뒤에는 origin 선택이 달라지지 않으므로 Function 안에서 다른 origin으로 보내려 해서는 안 됩니다.

장애를 작게 만드는 운영 기준

다시 배포한다면 Function의 test event로 root, 허용 경로, trailing slash, 정적 경로와 알 수 없는 경로를 각각 실행하겠습니다. 이어 staging distribution에서 origin 응답까지 본 뒤 production association으로 옮겨야 합니다. 함수 오류가 생기면 브랜드 사이트 전체가 막히므로 이전 Function 버전과 distribution 설정을 되돌릴 절차도 필요합니다.

확인은 다음처럼 DNS, TLS, route, cache를 나눠 진행할 수 있습니다.

dig A brand.example
dig AAAA brand.example
 
curl -sSIL http://brand.example/
curl -sSI https://brand.example/
curl -sSI https://brand.example/notice/
curl -sSI https://brand.example/not-allowed
curl --compressed -sSI https://brand.example/

발행 직후에는 변경된 HTML이 언제 갱신되는지도 확인합니다. “페이지가 열린다” 한 번으로 끝내면 DNS와 TLS는 통과해도 오래된 cache, 새는 redirect, 잘못된 canonical을 놓칩니다.

지금 다시 고른다면

이 구조는 소수의 공개 랜딩과 안정된 경로에는 실용적이었습니다. Framer 편집 흐름을 유지했고, 브랜드 도메인도 주소창에 남겼습니다. 두 번째 브랜드 역시 같은 구조를 바탕으로 설정할 수 있었습니다. 다만 이 경험을 “distribution을 복사하면 끝”이라고 요약하면 중요한 운영 비용을 지웁니다.

경로가 늘 때마다 allowlist와 SEO 파일을 관리해야 하고, 발행과 invalidation도 연결해야 합니다. 인증이나 개인화가 들어오면 cookie와 cache 경계가 달라집니다. 브랜드마다 canonical이 달라지면 Function의 능력 밖으로 나갑니다. 현재 같은 요구를 다시 받는다면 Framer가 제공하는 Multi Site와 reverse proxy 기능을 먼저 확인하고, 외부 CloudFront가 필요한 제어권을 실제로 주는 경우에만 이 구조를 선택하겠습니다.

CloudFront Function은 몇 줄이면 끝났습니다. 오래 걸린 건 public URL, origin URL, cache key, 검색 엔진이 보는 URL을 따로 확인하는 일이었습니다. 이 네 가지를 같은 주소처럼 다루면 프록시는 겉으로만 동작합니다.

참고한 공식 문서