Skip to content

CSR 환경에서 동적 og 태그 적용 (CloudFront Functions 활용)

Web | 2025년 11월 19일


들어가며

카카오톡이나 Slack 같은 플랫폼에 네이버 링크를 공유하면 어떤 모습으로 표시될까요?

아래처럼 링크의 이미지, 제목, 설명이 담긴 미리보기 카드가 표시됩니다.

네이버 링크 미리보기

이 미리보기는 링크를 열기 전 사용자가 마주하는 첫인상이며, 클릭을 결정하게 만드는 중요한 요소입니다.

미리보기에 빈 이미지나 잘못된 정보가 노출된다면, 사용자는 링크를 신뢰하기 어렵습니다.

이는 첫인상을 해치는 UX 문제일 뿐 아니라, 클릭률을 낮추는 비즈니스 문제로도 이어질 수 있습니다.

이번 글에서는 이 링크 미리보기 문제를 해결한 팀의 트러블 슈팅 과정을 정리합니다.

요구사항

먼저 프로젝트의 서비스 흐름을 예로 들어보겠습니다.

저희 서비스는 사용자가 조회한 콘텐츠에 따라 동적으로 URL을 만들고, 사용자는 그 URL을 다른 사람에게 공유합니다.

예를 들어 A 조건으로 만든 결과 페이지와 B 조건으로 만든 결과 페이지는 서로 다른 정보를 보여줍니다.

따라서 결과를 공유할 때 링크 미리보기의 썸네일과 제목도 콘텐츠에 맞게 달라져야 합니다.

입력 페이지와 결과 페이지

하지만 어떤 결과 URL을 공유해도 소셜 미디어와 메신저의 미리보기에는 늘 같은 기본 정보만 표시됐습니다.

입력 페이지 링크 공유 시 미리보기 결과 페이지 링크 공유 시 동일한 미리보기

결과 페이지 링크를 받은 사용자가 링크를 열기 전에 결과를 이해할 수 있도록, 미리보기에 결과 정보를 담기로 했습니다.

즉, 아래처럼 콘텐츠 정보에 따라 동적으로 생성한 제목과 썸네일을 미리보기에 보여주는 것이 목표였습니다.

변경된 결과 페이지 링크 공유 시 미리보기

요구사항은 다음과 같이 정리했습니다.

링크 공유 시에 적절한 정보가 메타 태그에 동적으로 반영되게 수정해주세요.

이제 이 문제를 어떻게 해결했는지 살펴보겠습니다.

OG 태그란

이 문제를 해결하기 위해선 ‘og 태그’라 불리는 Open Graph 프로토콜을 이해해야 합니다.

Open Graph(OG) 프로토콜은 2010년 페이스북이 만든 인터넷 프로토콜입니다.

이 프로토콜은 웹페이지가 Facebook, 카카오톡, Slack 같은 소셜 플랫폼에서 ‘풍부한 객체(rich object)’로 표현될 수 있도록 표준화된 메타데이터를 제공합니다.

<head>
  <title>제목</title>
  <meta property="og:title" content="웹페이지 제목" />
  <meta property="og:description" content="웹페이지 설명" />
  <meta property="og:image" content="이미지 URL" />
  <meta property="og:url" content="웹페이지 URL" />
  <meta property="og:type" content="웹페이지 유형" />
</head>

즉, 개발자가 링크 미리보기 카드의 제목, 설명, 이미지를 직접 제어할 수 있게 해주는 HTML <head> 안의 <meta> 태그 세트입니다.

미리보기 카드 생성 로직

링크가 공유되면 카카오톡이나 Facebook 같은 플랫폼의 서버가 사용자의 웹페이지로 ‘스크레이퍼’, 즉 봇(Bot)을 보냅니다.

봇은 페이지 HTML을 내려받은 뒤 <head>를 파싱해 og:로 시작하는 메타 태그를 수집합니다.

미리보기 카드 생성 흐름

플랫폼은 이 정보를 읽어 아래와 같은 미리보기 카드를 생성합니다.

생성된 링크 미리보기 카드

그렇다면 왜 우리 서비스에서는 미리보기, 즉 og 태그가 모두 동일하게 보여졌을까요?

원인은 프로젝트의 렌더링 방식인 CSR(Client-Side Rendering)에 있습니다.

CSR vs SSR

웹 페이지를 렌더링하는 방식은 크게 CSR과 SSR(Server-Side Rendering)로 나뉩니다.

SSR (Server-Side Rendering)

먼저 SSR(Server-Side Rendering)은 이름 그대로 서버 쪽에서 렌더링을 마치는 방식입니다.

사용자가 페이지를 요청하면, 서버가 필요한 모든 데이터를 조합하여 완전한 HTML 파일을 생성한 뒤 응답합니다. 봇이든 사용자든 처음부터 완성된 콘텐츠를 받게 됩니다.

SSR 렌더링 흐름

CSR (Client-Side Rendering)

반면, 저희 프로젝트와 같은 CSR(Client-Side Rendering) 환경은 조금 다릅니다.

서버는 <div id="root"></div> 같은 텅 빈 HTML 뼈대와 JavaScript 파일을 응답합니다. 실제 콘텐츠는 사용자의 브라우저가 JavaScript를 다운로드하고 실행해야 비로소 그려집니다.

CSR 렌더링 흐름

보통 CSR은 SPA(Single Page Application) 방식으로 개발됩니다. 말 그대로 ‘페이지가 단 하나인 애플리케이션’이라는 뜻입니다.

SPA에서 같은 index.html을 반환하는 흐름

이게 무슨 말이냐면, 우리가 브라우저 주소창에서 /home으로 접속하든, /result로 접속하든, 서버에 실제로 존재하는 물리적인 파일은 index.html 딱 하나뿐이라는 것입니다.

서버 입장에서는 어떤 URL 요청이 들어와도 항상 똑같은, 내용이 비어있는 index.html 뼈대 파일 하나만을 돌려줍니다. 화면이 바뀌는 건 그 이후에 브라우저가 자바스크립트를 실행해서 내용을 채워 넣는 것이죠.

여기서 문제가 발생합니다.

물리적인 파일이 index.html 하나뿐이다 보니, 그 파일의 <head>에 적혀있는 OG 태그도 하나로 고정되어 있을 수밖에 없습니다.

JavaScript 실행 전에는 고정된 index.html만 반환

우리를 방문하는 카카오톡, 페이스북의 ‘봇’은 JavaScript를 실행하는 최신 브라우저가 아닙니다.

우리의 React 앱은 CSR 방식이므로, //result/{id}?count={count}든 모든 요청에 동일한 정적 index.html 파일을 서빙합니다.

봇이 /result/{id}?count={count}&place={place}라고 URL을 요청해도, 봇이 받는 것은 JavaScript가 실행되기 전의 텅 빈 index.html 뼈대뿐입니다. 봇은 그저 index.html<head>에 하드 코딩된 단 하나의 기본 og 태그만 읽게 됩니다.

봇은 React가 렌더링할 동적인 og:title을 결코 보지 못합니다.

SSR로 변경?

이 문제의 가장 정석적인 해결책은 Next.js 같은 SSR 프레임워크로 프로젝트 전체를 마이그레이션하는 것입니다.

SSR은 모든 요청마다 서버에서 동적 HTML을 생성하므로, 봇은 항상 완벽하게 렌더링된 og 태그를 수신할 수 있습니다.

하지만 이 방안은 ‘동적 og 태그 적용’이라는 하나의 요구사항을 해결하기에는 비용이 너무 큽니다.

S3와 CloudFront를 사용한 정적 호스팅 아키텍처

그래서 저희는 현재의 인프라를 유지하면서 해결할 방법을 찾기로 했습니다.

저희 서비스는 AWS S3와 CloudFront를 사용하는 아주 전형적인 정적 호스팅 아키텍처입니다.

  • Amazon S3: index.html, app.js 등 빌드된 정적 파일을 저장하는 오리진(Origin)입니다.
  • Amazon CloudFront: S3 앞단에 위치한 CDN입니다. 전 세계 엣지 로케이션에 파일을 캐시하여 사용자에게 빠르고 안전하게 콘텐츠를 전달합니다.

해결의 핵심은 CloudFront의 엣지 컴퓨팅(Edge Computing) 기능입니다. CloudFront가 단순히 정적 리소스를 캐싱하는 역할을 넘어, 요청과 응답의 처리 단계(Lifecycle)에서 사용자 정의 코드를 실행할 수 있다는 점을 활용해 문제를 해결했습니다.

CloudFront 요청과 응답 단계

화면을 보시면 1번 Viewer Request부터 4번 Viewer Response까지, 데이터가 이동하는 네 단계가 표시되어 있습니다. CloudFront는 이 모든 단계마다 개발자가 작성한 코드를 실행할 수 있는 환경을 제공합니다.

저희는 이 흐름 사이에 엣지 컴퓨팅을 배치해서, 봇이 감지될 때만 동적 OG 태그를 반환하도록 설계했습니다.

다시 말하자면, 유저에게 4번 Viewer Response를 통해 최종 결괏값인 HTML 파일을 전달하게 되는데, 이 파일에 og meta tag를 추가한 다음 리턴하는 것으로 수정하면 해결될 것이라 생각했습니다.

Lambda@Edge vs CloudFront Function

AWS에서 엣지 컴퓨팅을 구현하는 방법은 크게 Lambda@Edge와 CloudFront Functions, 두 가지가 있습니다.

둘 다 CloudFront의 엣지에서 코드를 실행한다는 공통점은 있지만, 차이가 있습니다.

구분CloudFront Functions (CFF)Lambda@Edge (L@E)
실행 위치모든 CloudFront 엣지 로케이션리전 엣지 캐시(중앙화)
성능(지연 시간)1ms 미만(초고속)수십~수백 ms(상대적으로 느림)
런타임경량 JavaScript (ECMAScript 5.1)Node.js, Python
네트워크/파일 접근불가능가능(S3, DynamoDB 등)
요청/응답 본문 접근불가능가능(Origin Request/Response 한정)
주요 용도헤더·URL 조작, 리디렉션, 경량 응답 생성외부 연동, 복잡한 본문 수정
비용매우 저렴상대적으로 비쌈

보시는 것처럼, Lambda@Edge는 Node.js나 Python 같은 범용 런타임을 지원하고 외부 네트워크 통신도 가능한 ‘만능 엔터테이너’입니다. 복잡하고 무거운 작업에 적합하죠.

반면, CloudFront Functions는 오직 경량 JavaScript만 실행할 수 있는 대신, 1ms 미만이라는 압도적인 속도와 매우 저렴한 비용을 자랑하는 ‘단거리 스프린터’입니다.

Lambda@Edge를 사용하여 해결하는 방식

처음에는 Lambda@Edge를 사용하여 S3의 원본 파일을 ‘수정’하는 방식을 고려했습니다.

봇이 오면 람다가 S3에 있는 index.html 원본 파일을 가져와서(GetObject), 태그를 수정한 뒤 던져주는 방식이죠.

Lambda@Edge가 오리진 응답을 수정하는 위치

하지만 이 방식에는 큰 단점이 있었습니다. 요청·응답 본문을 수정할 수 있는 단계가 Origin Request/Response로 한정됩니다. 즉 수정을 위해서는 원본 파일이 있는 S3(Origin)로 네트워크 호출을 보내야 합니다.

이는 엣지에서 실행되지만, 원본 서버까지 갔다 돌아오는 불필요한 네트워크 지연과 추가 비용이 발생합니다.

뿐만 아니라 S3 GetObject 권한 설정과 같은 인프라 복잡도를 높이는 이슈도 있었습니다.

비용과 성능을 고민하던 중, 저희는 근본적인 의문이 들었습니다.

‘과연 봇에게 보여주기 위해 원본 HTML 파일이 필요할까?’

CloudFront Function을 사용하여 해결하는 방식

여기서 저희는 한 가지 오해를 깨달았습니다. 봇은 다른 정보는 관심없고, 오직 <head> 속의 메타 태그만 필요로 합니다.

즉, 굳이 S3까지 가서 원본 파일을 가져올 필요가 전혀 없었던 거죠.

그냥 엣지 단계에서 봇이 원하는 메타 태그만 담은 가벼운 HTML을 ‘즉석에서 코드로 생성(Generate)’해서 주면 되는 것이었습니다.

CloudFront Function이 Viewer Request에서 응답하는 위치

우리의 요구사항은 1) User-Agent 확인, 2) 쿼리 스트링 파싱, 그리고 3) HTML 문자열 생성. 딱 이 세 가지입니다.

CloudFront Function을 통한 요청 흐름

이런 이유로 S3로의 네트워크 요청을 하지 않고도 빠르고 저렴하고 간단한 CloudFront Functions가 해결책으로 적합하다고 판단했습니다.

메인 핸들러 - 봇과 사용자 분리

CloudFront 엣지에서 실행되는 메인 핸들러 함수의 로직은 간단한 두 가지로 나뉩니다.

function handler(event) {
  var request = event.request;
  var qs = request.querystring;

  // 1. 봇(Bot)인지 식별하고, 결과 페이지인지 확인합니다.
  var isBot = checkIsBot(request.headers);
  var isResultPage = checkUrlPattern(request.uri);

  // 2. 쿼리스트링에서 동적 데이터 파싱 (count, place)
  var count = getParam("count");
  var place = getParam("place");

  // 3. 봇이고, 결과 페이지이며, 필요한 데이터가 존재하면?
  if (isBot && isResultPage && count) {
    // S3로 가는 요청을 막고, 동적 OG 태그를 즉시 반환합니다.
    return createOgResponse(request, count, place);
  }

  // 4. 일반 사용자 요청은 S3로 그대로 전달합니다.
  return request;
}

먼저 들어온 요청이 ‘봇(Bot)’인지, 그리고 ‘결과 페이지’인지를 확인합니다.

만약 봇이라면 if 문 안으로 진입합니다. 여기서 S3로 가는 길을 막고, 우리가 만든 가짜 HTML을 즉시 반환해버립니다. S3는 봇이 왔는지조차 모르게 되죠.

반면, 일반 사용자라면 이 if 문을 건너뛰고 return request를 통해 원래 목적지인 S3로 자연스럽게 흘려보냅니다.

이 간단한 분기 처리 하나로 봇과 사용자를 완벽하게 분리했습니다.

봇에게 던져주는 가짜 HTML

그렇다면 봇에게 던져주는 ‘가짜 HTML’은 어떻게 생겼을까요?

보시는 것처럼 아주 단순합니다.

function createOgResponse(request) {
  // 쿼리 파라미터에서 데이터를 꺼내 동적 제목을 만듭니다.
  var title = createDynamicTitle(request.querystring);

  // 봇에게 보여줄 최소한의 HTML 문자열을 작성합니다.
  var html = `
    <!doctype html>
    <html lang="ko">
      <head>
        <meta property="og:title" content="${title}" />
        <meta property="og:image" content="https://example.com/og.png" />
        <meta property="og:description" content="..." />
      </head>
      <body></body>
    </html>
  `;

  return {
    statusCode: 200,
    body: html,
  };
}

URL의 쿼리 스트링에서 정보를 꺼내 **동적인 제목(Title)**을 만들고, 이를 문자열 템플릿에 넣어줍니다.

여기에는 수 메가바이트짜리 리액트 번들도, 복잡한 CSS도 없습니다. 오직 봇이 필요로 하는 <head> 태그와 Open Graph 메타 태그만 존재합니다.

이렇게 생성된 HTML은 용량이 매우 작기 때문에, 봇이 데이터를 긁어가는 속도도 훨씬 빨라집니다.

결과

결과를 확인해 볼까요? 코드를 배포한 후 링크를 공유하자, 미리보기 카드가 사진처럼 사용자가 선택한 장소와 인원수에 맞춰 동적으로 표시되었습니다.

결론적으로, 저희는 CSR과 S3/CloudFront라는 기존의 인프라 아키텍처를 그대로 유지하면서, SSR로의 복잡한 마이그레이션 없이 하나의 경량 엣지 함수인 CloudFront Functions를 추가하여 동적 og 태그 문제를 해결했습니다.

변경된 결과 페이지 미리보기

마무리

저희 프로젝트의 경우, 결과 정보가 중요하지 않았기에 메타 태그에 필요한 정보를 URL 파라미터에 그대로 노출할 수 있었습니다.

하지만 만약 공유하려는 정보가 개인정보나 민감한 데이터라면, 이 해결 방안은 적절하지 않습니다. 그런 경우에는 다른 보안 방안을 모색해야 할 것 같습니다.

정적 호스팅 환경에서 비슷한 고민을 하고 계신다면, 이 가볍고 강력한 엣지 함수를 꼭 검토해 보시기 바랍니다.

감사합니다!

참고자료