CSR 환경에서 Dynamic OpenGraph 구현하기
개발팀에서 CloudFront Function을 활용하여 CSR 웹 애플리케이션에서 동적 OpenGraph 메타 태그를 제공하는 방법을 소개합니다.
안녕하세요. 더스윙에서 프론트엔드 개발을 담당하고 있는 손지웅입니다.
이번 포스팅에서는 Vite + React로 구성된 CSR 웹 애플리케이션에서 동적 OpenGraph 메타 태그를 제공하는 방법에 대해 소개하고자 합니다. SSR로 전환하지 않고도 간단히 카카오톡 등의 소셜 미디어 서비스에서 다채로운 공유 카드를 보여줄 수 있었던 경험을 공유합니다.
배경: 멤버십 시스템 개편과 새로운 요구사항
모빌리티 서비스인 SWING은 시즌별 프로모션, 제휴 이벤트, 신규 서비스 출시를 위한 랜딩 페이지를 끊임없이 제작합니다. 이러한 페이지들은 대부분 수명이 짧고 빠르게 배포해야 하기 때문에 Vite + React 기반의 CSR 구조로 템플릿화하여 관리하고 있습니다. 검색 엔진 최적화보다는 빠른 배포와 유연한 수정이 우선이었고, CloudFront + S3 조합은 이런 요구사항에 잘 맞았습니다.
그런데 최근 멤버십 시스템을 개편하면서, 기존과는 다른 성격의 요구사항이 생겼습니다.
멤버십 플랜별 특징과 혜택을 더 명확히 전달하기 위한 개편이었기에, 각 플랜의 정체성을 앱 외부에서도 효과적으로 표현할 필요가 있었습니다. 앱 내 멤버십 상세 페이지에서 사용자가 "공유하기" 버튼을 누르면 친구들에게 해당 멤버십을 소개할 수 있어야 했죠.
핵심은 각 플랜마다 다른 시각적 정보를 제공하는 것이었습니다. 카카오톡이나 페이스북에 링크를 공유했을 때, 단순한 URL이 아닌 각 멤버십의 특징을 담은 이미지, 제목, 요약 설명이 포함된 공유 카드가 표시되어야 했습니다. 베이직 멤버십을 공유하면 베이직 전용 디자인과 요약 정보가, 에센셜 멤버십을 공유하면 에센셜 전용 정보가 담긴 카드로 보여야 하는 것입니다.
바로, CSR 프로젝트에서 OpenGraph 메타 태그가 동적으로 제공되어야 하는 순간이었습니다.
문제: CSR에서는 소셜 크롤러가 메타 태그를 읽지 못한다
SPA에서 동적으로 메타 태그를 변경하는 방법으로 React Helmet 같은 라이브러리가 종종 사용됩니다. 클라이언트 사이드에서 페이지 전환 시 title이나 description을 바꾸는 데는 유용합니다.
<Helmet>
<meta property="og:title" content="에센셜 멤버십 | SWING" />
<meta property="og:description" content="스윙 탈 때마다 무제한 할인, 택시 10% 적립" />
<meta property="og:image" content="..." />
</Helmet>하지만 이 방식은 소셜 미디어 크롤러 대응에는 무의미합니다.
카카오톡, 페이스북, X(트위터)의 크롤러(봇)는 서버로부터 HTML을 받는 즉시 메타 태그를 파싱하고 공유 카드를 생성합니다. JavaScript를 실행하지 않습니다. React Helmet은 React 컴포넌트가 마운트된 후 클라이언트에서 DOM을 조작하는 방식이므로, 봇의 입장에서는 메타 태그가 존재하지 않는 것과 같습니다.
결론은 명확합니다. 초기 HTML 응답에 이미 메타 태그가 포함되어 있어야 한다는 것. 즉, 서버 측에서 메타 태그를 제공할 수 있는 구조가 필요했습니다.
SSR/SSG 전환을 해야하나?
가장 먼저 떠오르는 해결책은 SSR(Server-Side Rendering)이나 SSG(Static Site Generation)로의 전환일 것입니다. Next.js, Remix, Astro 등 여러 프레임워크가 이를 지원하고 있죠.
SSR은 요구사항에 비해 과도합니다. 이 프로젝트는 단기 이벤트와 프로모션 랜딩 페이지들의 모음으로, 각 페이지의 수명은 짧고 자주 추가되거나 삭제됩니다. 런타임 서버 인프라 구축, 배포 파이프라인 재설계, CDN과 SSR을 조합한 캐시 전략 수립 등 전체 아키텍처 변경이 필요한데, 대부분의 트래픽은 앱 내에서 발생하며 OpenGraph가 실제로 필요한 경우는 소셜 공유 시점뿐이었습니다.
반면 SSG는 우리 케이스에 적합해 보였습니다. 멤버십 플랜은 고정되어 있고, 각 플랜의 메타 정보는 배포 주기에 맞춰 변경되며, 사용자별로 다른 정보를 보여줄 필요도 없었습니다. 빌드 타임에 HTML을 미리 생성해두면 되는 상황이었죠.
하지만 SSG를 위해 별도의 프레임워크를 이용하는 것은 비효율적입니다. 기존 구조를 유지하면서, 필요한 만큼만 SSG를 적용하면 어떨까요?
첫 번째 시도: SSG를 Vite 플러그인으로 구현
SSG의 핵심은 "빌드 타임에 필요한 HTML을 미리 생성"하는 것입니다. 프레임워크 전환 없이, Vite 플러그인으로 이 개념만 구현하기로 했습니다.
이 접근의 장점은 명확했습니다. 각 멤버십별로 완전한 HTML 파일을 생성하면, 소셜 크롤러뿐만 아니라 실제 사용자가 접속했을 때도 동일한 메타 태그를 제공할 수 있습니다. 브라우저 탭 제목, 북마크 저장 시 정보, 검색 엔진 노출까지 모두 멤버십별로 최적화된 정보를 제공하는 "완벽한" 경험이 가능해집니다.
Vite 플러그인을 작성하여 빌드 프로세스에 통합했습니다. 멤버십별 메타 정보를 설정 파일에 정의하고, 빌드 완료 후 closeBundle 훅에서 각 멤버십별 HTML 파일을 자동 생성하도록 구현했습니다. 이렇게 하면 빌드 시점에 dist/share/membership-basic, dist/share/membership-essential 같은 파일들이 자동으로 생성됩니다.
문제: SPA 라우팅 구조와의 충돌
빌드 자체는 성공적이었지만 CloudFront + S3 환경에서 라우팅 이슈가 발생했습니다.
우리 서비스는 SPA이기 때문에 모든 라우팅이 index.html로 fallback되도록 설정되어 있었습니다. 즉, <https://${SwingDomain}/share/membership-basic으로> 접속하든 <https://${SwingDomain}/share/membership-essential로> 접속하든 index.html이 응답되는 것이죠.
CloudFront 설정만으로는 /share/membership-basic 경로를 /share/membership-basic.html 파일과 매핑할 방법이 명확하지 않았습니다. S3 웹 호스팅 설정 변경 등은 보안 이슈, OAI 사용 불가 등의 문제로 인해 선택지에 없었으며, 더 깔끔한 방법이 필요했습니다.
해결책: 확장자 없는 HTML 파일 + Content-Type 명시
"파일 자체를 membership-basic(확장자 없이)로 만들고, Content-Type을 text/html로 설정하면 되지 않을까?"
일반적인 웹 서버는 파일 내용을 분석해서 <!DOCTYPE html>로 시작하는 파일을 자동으로 HTML로 인식합니다. S3도 이론적으로는 마찬가지입니다.
그러나 실제 환경에서 S3는 확장자 없는 파일을 application/octet-stream으로 인식하여 다운로드를 유도하는 경우가 있었습니다. 따라서 GitHub Actions 배포 스크립트에서 AWS CLI의 --content-type 옵션을 사용하여 명시적으로 text/html; charset=utf-8을 설정했습니다.
이 방식으로 /share/membership-basic 경로에 접속하면 올바른 HTML 파일이 응답되도록 구성할 수 있었습니다. 기능적으로는 완벽했고, 모든 사용자에게 일관된 메타 정보를 제공할 수 있었습니다.
두 번째 접근: 엣지에서 동적으로 메타 생성하기
정적 파일 방식은 작동했지만, 실무 관점에서 두 가지 고민이 생겼습니다.
첫째는 확장성 문제였습니다. 멤버십 플랜이 추가될 때마다 설정 파일을 수정하고 빌드를 새로 해야 했고, 경로마다 파일을 관리해야 하는 구조였습니다. 메타 정보를 수정할 때도 코드 변경 → 빌드 → 배포 전체 사이클을 거쳐야 했죠.
둘째는 본질적인 필요성에 대한 재검토였습니다. 정적 파일 방식의 가장 큰 장점은 "실제 사용자가 접속해도 멤버십별 메타 태그가 유지된다"는 점이었습니다. 하지만 이 프로젝트의 페이지들을 다시 보니, 대부분 앱으로 연결되는 브릿지 역할을 하거나, 랜딩 페이지 내에서 정보를 전달하고 종료되는 구조였습니다. 또한 SPA 특성상 첫 페이지 로드 이후에는 클라이언트 사이드 라우팅으로 동작하기 때문에, 초기 HTML의 메타 태그는 사실상 의미가 없었습니다.
진짜 필요한 것은 소셜 크롤러에게만 멤버십별 메타를 제공하는 것이었습니다. 실제 사용자에게는 기본 메타 태그(스윙 서비스 범용 정보)로도 충분했죠.
그렇다면 CloudFront 엣지에서 봇에게만 메타 태그를 분기하여 제공하면 어떨까? 이 방식은 정적 파일처럼 "모든 접속자에게 일관된 메타"를 제공하지는 못하지만, 실용적으로는 문제가 없고, 성능과 유지보수 측면에서 훨씬 효율적입니다.
AWS는 CloudFront 엣지에서 코드를 실행할 수 있는 두 가지 옵션을 제공합니다: Lambda@Edge와 CloudFront Function. 어떤 것을 선택해야 할까요?
CloudFront의 요청/응답 플로우 이해하기
CloudFront는 다음 Viwer Request/Response, Origin Request/Response의 4개 지점에서 코드를 실행할 수 있습니다:


- Lambda@Edge: 4단계 모두에서 실행 가능
- CloudFront Function: Viewer Request/Response에서만 실행 가능
우리에게 필요한 로직 분석
구현하려는 로직을 단계별로 보면:
- Viewer Request: User-Agent 헤더 확인 → 봇인가 판단
- 분기:
- 봇이면 → 즉시 HTML 생성하여 Viewer Response로 응답
- 일반 사용자면 → Origin(S3)의 index.html 제공
핵심은 Origin에 갈 필요가 없다는 점입니다. Viewer Request에서 봇을 감지하면 즉시 Viewer Response로 HTML을 응답하면 됩니다. Origin Request/Response 단계는 전혀 사용하지 않습니다.
Lambda@Edge vs CloudFront Function
Lambda@Edge는 Origin과의 통신, 외부 API 호출 등 좀 더 복잡한 로직에 적합합니다. 하지만 우리 케이스는 단순히 User-Agent를 보고 올바른 메타 정보를 포함한 HTML 생성하는 수준입니다. CloudFront Function만으로 충분하며, 게다가 더 나은 선택입니다:

Viewer Request에서 분기하여 Viewer Response로 응답하는 우리 케이스에는, CloudFront Function이 기능적으로 충분하면서도 성능과 비용 면에서 훨씬 유리했습니다.
구현: CloudFront Function으로 봇 분기하기
CloudFront Function은 JavaScript로 작성되며, 요청이 오리진(S3)에 도달하기 전 엣지 로케이션에서 실행됩니다. 구현의 핵심은 다음 세 가지입니다.
1. 봇 감지 로직
User-Agent 헤더를 분석하여 소셜 크롤러를 감지합니다. False Positive를 최소화하기 위해 2단계 검증 로직을 구현했습니다:
알고리즘: 봇 감지 및 분기
입력: HTTP 요청 (request)
출력: OpenGraph HTML 또는 원본 요청
1. User-Agent 헤더 추출 및 소문자 변환
userAgent = request.headers['user-agent'].toLowerCase()
2. 1단계 검증: 명확한 소셜 크롤러 패턴 확인
definitiveBots = [소셜 미디어 크롤러 식별자 목록]
FOR EACH bot IN definitiveBots:
IF userAgent CONTAINS bot:
RETURN generateOpenGraphHTML(request)
3. 2단계 검증: 일반 봇 패턴 확인 (브라우저 제외)
hasBotPattern = userAgent CONTAINS ['crawler', 'spider', 'bot'] 중 하나
hasBrowserPattern = userAgent CONTAINS 'mozilla/5.0'
IF hasBotPattern AND NOT hasBrowserPattern:
RETURN generateOpenGraphHTML(request)
4. 일반 사용자 처리
RETURN request (S3로 전달)2. 경로 기반 메타 정보 매칭
요청 URI와 쿼리스트링을 분석하여 적절한 메타 정보를 선택합니다:
알고리즘: 메타 정보 선택
입력: URI 경로, 쿼리스트링
출력: 메타 정보 객체 (title, description, image)
1. 언어 파라미터 추출
lang = 쿼리스트링에서 'lang' 파라미터 추출
IF lang이 없으면:
lang = 'ko' (기본값)
2. 멤버십 타입 추출
URI 패턴 매칭: /share/membership-{type}
type = 패턴에서 추출된 멤버십 타입
IF type이 없으면:
type = 'basic' (기본값)
3. 메타 정보 데이터 구조
membershipData = {
basic: {
ko: { title, description, image },
en: { title, description, image }
},
essential: { ko: {...}, en: {...} },
seoulpass: { ko: {...}, en: {...} }
}
4. 메타 정보 반환
RETURN membershipData[type][lang]3. OpenGraph HTML 응답 생성
메타 태그를 포함한 최소한의 HTML을 동적으로 생성하고, 환경별 캐시 정책을 적용합니다:
function generateOpenGraphResponse(request) {
const contentInfo = getContentInfo(request.uri, request.querystring);
const host = request.headers.host?.value;
// 환경별 캐시 전략
const cacheControl = host.includes('dev.')
? 'no-cache, no-store, must-revalidate' // 개발: 캐시 없음
: 'public, max-age=300'; // 운영: 5분 캐시
const html = `<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<title>${contentInfo.title}</title>
<meta name="description" content="${contentInfo.description}" />
<!-- OpenGraph -->
<meta property="og:title" content="${contentInfo.title}" />
<meta property="og:description" content="${contentInfo.description}" />
<meta property="og:image" content="${contentInfo.image}" />
<meta property="og:url" content="https://${host}${request.uri}" />
<!-- 카카오톡 최적화 -->
<meta property="kakao:title" content="${contentInfo.title}" />
<meta property="kakao:image" content="${contentInfo.image}" />
</head>
<body></body>
</html>`;
return {
statusCode: 200,
headers: {
'content-type': { value: 'text/html; charset=utf-8' },
'cache-control': { value: cacheControl }
},
body: html
};
}실제 동작: 봇과 사용자의 다른 경험
봇(크롤러)의 경우: 카카오톡 크롤러가 /share/membership-basic을 요청하면 CloudFront Function이 User-Agent에서 kakaotalk-scrap을 감지하여 봇으로 판단하고, 즉시 OpenGraph 메타 태그가 포함된 HTML을 응답합니다. 카카오톡은 이 메타 태그를 읽고 이미지, 제목, 설명이 포함된 풍부한 공유 카드를 생성합니다. 응답 시간은 약 1ms 이하입니다.
실제 사용자의 경우: 사용자가 링크를 클릭하면 CloudFront Function이 User-Agent를 확인하여 일반 브라우저로 판단하고 요청을 그대로 통과시킵니다. S3에서 index.html이 응답되고 React 앱이 로드되어, 클라이언트 사이드 라우팅으로 해당 페이지가 렌더링됩니다. 기존 SPA 경험이 그대로 유지됩니다.


구현 성과
CloudFront Function을 활용한 동적 메타 생성 방식으로 다음과 같은 성과를 달성했습니다:
- 성능: 엣지 실행으로 ~1ms 이하의 응답 시간
- 비용: Lambda@Edge 대비 약 90% 절감
- 안정성: 기존 서비스 무중단, False Positive 최소화
- 확장성: 코드 수정만으로 즉시 반영, 빌드/배포 사이클 불필요

마치며
CSR 환경에서 OpenGraph 메타 태그를 제공하는 것은 번거로운 문제입니다. SSR/SSG는 초기 로딩 성능, SEO, 사용자 경험 개선 등 다양한 이점을 제공하며, OpenGraph 문제 역시 자연스럽게 해결됩니다.
하지만 OpenGraph만을 위해 전체 아키텍처를 변경하는 것은 과도할 수 있습니다. 프로젝트의 특성, 기존 인프라, 팀의 리소스를 고려했을 때, 문제의 범위에 맞는 최소한의 해결책이 더 적절할 수 있습니다. CloudFront Function을 활용한 이번 구현이 그런 사례입니다.
기술을 선택할 때는 문제의 본질을 정확히 정의하고, 현재 제약 조건 안에서 최적의 해결책을 찾으며, 미래의 확장성과 유지보수성을 함께 고려해야 합니다. 이 과정에서 이루어지는 기술적 검토와 논의가 결국 더 나은 제품을 만드는 밑거름이 될 것이라 생각합니다.
감사합니다.
🚖 함께 할 동료를 찾고 있어요 🚖
SWING은 더 나은 도시를 만들기 위해 노력하는 팀입니다. 데이터, 기술, 사용자 중심의 혁신을 통해 이동 경험을 바꾸고자 하는 분들을 기다리고 있습니다.
여정에 함께하고 싶다면, 지금 바로 아래 링크를 통해 지원해주세요!
👉 SWING 채용 공고 확인하기 👈