웹 사이트의 링크를 친구에게 공유해 본 경험이 있으실 겁니다. 보통 메신저나 SNS에 링크를 붙여 넣으면 해당 페이지의 제목과 이미지가 미리보기 형태로 표시됩니다.
하지만 React 기반의 SPA(Single Page Application)로 만든 웹사이트에서는 예상과 다른 결과가 나타나는 경우가 있습니다.

프로젝트 아카이빙 플랫폼 모아온을 개발하면서도 이런 현상을 확인 할 수 있었습니다.
해당 페이지는 프로젝트 상세페이지로, '피움'이라는 프로젝트에 해당하는 이미지와 소개글을 확인 할 수 있습니다.

https://moaon.co.kr/project/59 페이지 링크를 공유한 결과, 프로젝트별 메타데이터가 반영되지 않고 메인 페이지의 메타 정보가 동일하게 노출되는 문제를 확인할 수 있었습니다.
프로젝트마다 상세 정보가 다른데도 불구하고, 해당 사이트의 모든 링크가 똑같은 제목과 똑같은 이미지로 표시되고 있습니다.
'피움'이라는 서비스에 관심이 높은 사용자에게 해당 링크를 공유하는 상황에서, 미리보기 정보가 프로젝트 내용과 일치하지 않는다면 사용자에게 혼란을 줄 수 있습니다. 또한, 콘텐츠에 대한 신뢰도가 낮아지고 클릭률 역시 크게 저하될 것이라고 판단해 이러한 문제를 해결하기로 했습니다.
해당 문제는 CSR(Client-Side Rendering) 방식에서 비롯되었습니다.
CSR은 서버에서 완성된 HTML을 내려주는 것이 아니라, 기본적인 HTML만 전달한 뒤 브라우저에서 JavaScript를 실행하여 화면을 렌더링하는 방식입니다. React 기반 SPA가 대표적인 예입니다.
<!DOCTYPE html>
<html lang="ko">
<head>
<title>모아온</title>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta property="og:type" content="website" />
<meta property="og:title" content="모아온" />
<meta property="og:description" content="프로젝트를 모아모아, 모아온 📦" />
<meta
property="og:image"
content="https://moaon-prod.s3.ap-northeast-2.amazonaws.com/images/logo.png"
/>
...
이 구조에서는 어떤 경로로 접근하더라도 서버는 동일한 index.html을 반환하고, 이후 클라이언트에서 페이지 내용과 메타데이터가 동적으로 구성됩니다.
하지만 카카오톡이나 SNS 크롤러는 JavaScript를 실행하지 않고, 서버로부터 전달받은 초기 HTML의 <meta> 정보만을 기준으로 미리보기를 생성합니다.
크롤러는 일반 브라우저와 달리 빠르고 가볍게 미리보기 정보를 수집하는 것이 목적입니다., JavaScript를 실행하면 렌더링 비용이 커지고 처리 속도가 느려지기 때문에 대부분의 크롤러는 정적인 HTML만을 분석합니다.
그 결과, 프로젝트 상세 페이지에 접근하더라도 프로젝트별 메타데이터가 반영되지 않고, 모든 페이지에서 동일한 메타 정보가 노출되는 문제가 발생했습니다.
import { useEffect } from "react";
import setMetaTags from "@shared/utils/setMetaTags";
export function useMeta({ title, description, imageUrl }) {
useEffect(() => {
document.title = title;
const metaConfigs = [
{ attr: { key: "name", value: "description" }, content: description },
{ attr: { key: "property", value: "og:title" }, content: title },
{ attr: { key: "property", value: "og:description" }, content: description },
{ attr: { key: "property", value: "og:image" }, content: imageUrl },
];
setMetaTags(metaConfigs);
}, [title, description, imageUrl]);
}
기존에는 위 코드와 같이 useEffect를 통해 메타태그를 동적으로 변경하는 방식으로 문제를 해결하고자 했습니다.

실제로 브라우저에서 확인해보면, 네트워크 탭과 Elements 탭에서 메타태그가 정상적으로 변경되는 것을 확인할 수 있었습니다.
하지만 해당 링크를 카카오톡 등에서 공유해보면, 여전히 변경된 메타데이터가 반영되지 않고 기존 메인 페이지의 정보가 노출되는 것을 확인할 수 있었습니다.
이를 통해 메타태그가 클라이언트에서 정상적으로 변경되더라도, 크롤러가 이를 반영하지 않는다는 점을 확인할 수 있었고, 앞서 설명한 것처럼 크롤러는 JavaScript를 실행하지 않고 초기 HTML만을 기반으로 메타데이터를 수집한다는 사실을 검증할 수 있었습니다.

참고로 구글에서는 SNS와 다르게, 상세페이지에 대한 메타정보가 정상적으로 반영된 것을 확인할 수 있었습니다. 이는 구글 크롤러가 JavaScript를 실행하여 페이지를 렌더링한 뒤 메타데이터를 수집하기 때문입니다.
이 문제를 해결하는 가장 정석적인 방법은 SSR(Server Side Rendering) 입니다.
SSR은 CSR과 달리 서버에서 페이지의 HTML을 완성한 뒤 브라우저에 전달하는 방식입니다.
CSR의 동작 방식이 다음과 같다면
SSR은 다음과 같이 동작합니다.
예를 들어 /project/59 페이지를 요청하면 서버는 다음과 같은 HTML을 생성합니다.
<meta property="og:title" content="피움">
<meta property="og:description" content="식물과 함께하는 시행착오, 기록으로 성장의 경험을 만들어 드립니다">
<meta property="og:image" content="thumbnail.png">
이렇게 페이지마다 다른 메타태그가 포함된 HTML이 반환되기 때문에 SNS 크롤러도 정상적으로 OG 태그를 수집할 수 있습니다.
SSR은 확실한 해결책이지만 전환 시 몇 가지 부담이 존재합니다. 기존에는 서버 관리가 필요 없어 비용이 저렴했으나, SSR을 도입하면 HTML을 생성할 서버가 반드시 필요해 비용이 증가합니다.
또한 기존의 프로젝트가 CSR 기반의 React로 이미 구현되어 있기 때문에 SSR 도입 시 프로젝트 구조를 수정해야 하는 비용도 발생합니다.
SNS 공유 시 OG 태그만 페이지별로 다르게 제공하기 위해서 전체 렌더링 구조를 바꾸는것은 과하다고 판단해서 다른 방식을 찾아보게 되었습니다.
레퍼런스를 통해 사용자들은 기존의 CSR 방식을 유지하고, SNS 크롤러에게만 OG 태그가 포함된 HTML을 제공하는 방식을 찾게되었습니다.

사용자가 moaon.co.kr에 접속하면 먼저 CloudFront(CDN) 로 요청이 전달됩니다.
CloudFront는 요청을 받은 뒤 해당 리소스가 CDN 캐시에 존재하는지 확인합니다.
만약 캐시에 이미 저장된 파일이 있다면, CloudFront는 Origin 서버에 요청하지 않고 캐시된 파일을 즉시 사용자에게 반환합니다. 이 과정 덕분에 전 세계 어디서 접속하더라도 빠른 응답 속도를 제공할 수 있습니다.
반대로 캐시에 해당 리소스가 존재하지 않는 경우에는 CloudFront가 Origin으로 설정된 S3에 파일을 요청하게 됩니다.
이때 CloudFront는 요청과 응답이 이루어지는 과정의 특정 시점에 커스텀 로직을 실행할 수 있는 기능을 제공합니다. 이를 통해 요청이나 응답을 수정할 수 있습니다.
또한, 캐시 동작에 연결할 엣지 함수와 실행 시점(이벤트)을 선택할 수 있습니다.

CloudFront에서 제공하는 주요 실행 단계는 다음과 같습니다.
Viewer Request(뷰어 요청)
사용자가 CloudFront에 요청을 보내는 즉시 실행되는 단계입니다.
요청 URL을 변경하거나 특정 조건에 따라 다른 응답을 반환하는 등의 처리를 할 수 있습니다.
Origin Request(뷰어 응답)
CloudFront에 캐시된 데이터가 없어 Origin(S3 등)에 요청을 보내기 직전에 실행됩니다.
Origin으로 전달되는 요청을 수정하거나 다른 Origin으로 라우팅할 수 있습니다.
Origin Response(원본 요청)
Origin에서 응답이 CloudFront로 돌아온 직후 실행되는 단계입니다.
Origin이 반환한 응답 데이터를 수정하거나 헤더를 추가할 수 있습니다.
Viewer Response(원본 응답)
CloudFront가 최종적으로 사용자에게 응답을 보내기 직전에 실행됩니다.
응답 헤더를 추가하거나 일부 응답 데이터를 수정할 수 있습니다.
각 단계에서는 Lambda@Edge 또는 CloudFront Functions를 연결하여 원하는 로직을 실행할 수 있습니다.
요청이 CloudFront를 통과하는 흐름에 맞추어 특정 시점에 코드를 실행할 수 있기 때문에, URL을 변경하거나 응답을 가공하는 등의 다양한 처리가 가능합니다.
여기서 먼저 Lambda와 Lambda@Edge의 차이를 간단히 이해할 필요가 있습니다.
Lambda
AWS에서 제공하는 서버리스 컴퓨팅 서비스로, 서버를 직접 운영하지 않고도 코드를 실행할 수 있도록 해주는 서비스입니다.
특정 이벤트가 발생했을 때 코드가 실행되는 구조이며, API 요청 처리, 데이터 가공, 백엔드 로직 처리 등 다양한 용도로 사용됩니다.
예를 들어 다음과 같은 작업을 Lambda로 처리할 수 있습니다.
Lambda는 보통 특정 리전에서 실행되며, 요청이 들어오면 해당 리전에서 코드가 실행됩니다.
Lambda@Edge
Lambda@Edge는 Lambda의 한 종류로, CloudFront의 엣지 로케이션(사용자랑 가장 가까운 곳에 있는 CDN 서버) 에서 실행되는 Lambda 함수입니다.
즉 일반 Lambda가 특정 리전에서 실행되는 것과 달리, Lambda@Edge는 CloudFront 요청 흐름 안에서 실행됩니다.
이를 통해 다음과 같은 작업을 할 수 있습니다.
사용자 → CloudFront → Lambda@Edge → Origin(S3)
예를 들어 위와 같은 흐름에서 Lambda@Edge가 중간에 개입하여 요청을 가공할 수 있습니다.
왜 Lambda@Edge를 선택했는가?
이번 문제에서는 단순히 요청을 수정하는 것만으로는 해결할 수 없었습니다.
OG 태그를 동적으로 생성하기 위해서는 다음 과정이 필요했습니다.
- 요청 URL에서 프로젝트 ID 추출
- 백엔드 API 호출
- 프로젝트 정보 조회
- OG 태그가 포함된 HTML 생성
이 과정에는 외부 API 호출과 HTML 생성 로직이 필요했기 때문에, 외부 API 호출 불가한 기능 제약이 있는 CloudFront Functions 대신 Lambda@Edge를 사용하는 방식을 선택했습니다.

처음에는 Origin Response 단계에서 OG 태그를 수정하는 방식으로 구현을 시도했습니다. Origin Response는 S3에서 응답이 CloudFront로 돌아온 직후 실행되기 때문에, 반환된 HTML을 가공하여 메타태그를 동적으로 변경할 수 있을 것이라고 생각했습니다.
하지만 실제로 테스트해보니 원하는 결과가 나오지 않았습니다. 이유는 CloudFront의 캐시 동작 때문이었습니다.
Origin Response는 CloudFront에 캐시된 데이터가 없어 S3에 요청을 보낸 경우에만 실행됩니다. 즉 CloudFront에 캐시가 존재하는 경우에는 S3에 요청 자체를 보내지 않기 때문에 Origin Response 단계가 아예 실행되지 않습니다.
크롤러 요청이 들어왔을 때 캐시가 존재한다면 Lambda@Edge가 실행되지 않아 OG 태그를 동적으로 생성할 수 없게 됩니다. 따라서 캐시 여부와 관계없이 모든 요청에서 무조건 실행되는 Viewer Request 단계에서 처리해야 한다는 결론에 도달했습니다.
그래서 선택한 단계가 바로 Viewer Request 단계입니다.

Viewer Request는 사용자의 요청이 CloudFront에 도착하자마자 실행되기 때문에 /project/59와 같은 실제 요청 URL을 그대로 확인할 수 있습니다. 이 시점에서 요청의 경로를 분석하거나 User-Agent를 확인하여 크롤러 여부를 판단하고, 필요하다면 S3로 요청을 전달하기 전에 직접 HTML 응답을 생성할 수 있습니다.
즉 최종적으로 구현한 흐름은 다음과 같습니다.
이 방식을 통해 일반 사용자에게는 기존 CSR 구조를 그대로 유지하면서,
크롤러에게만 OG 태그가 포함된 HTML을 제공하는 구조를 구현할 수 있었습니다.
Lambda@Edge를 활용하기 위해서는 region을 버지니아 북부(us-east-1)로 먼저 설정하셔야 합니다.
왜 버지니아 북부인가요?
AWS는 Lambda@Edge를 전 세계 엣지에 배포하기 전에 반드시 하나의 "기준 리전"에서 관리, 복제해야 하는데, 그 기준이 미국 버지니아 북부입니다.AWS가 글로벌 서비스들의 기준 리전을 정해놨는데, Lambda@Edge는 us-east-1(AWS에서 가장 오래된 리전)를 기준으로 두었기 때문에, 해당 리전에 함수를 생성하고 CloudFront에 연결하면 AWS가 전 세계 엣지 로케이션에 복제합니다.

Lambda에서 새 함수를 생성합니다. 함수 이름을 적절히 설정한 후 함수 생성을 진행합니다.
생성한 Lambda 함수의 코드 소스를 작성합니다.
import https from 'https';
// CloudFront가 요청을 받을 때마다 이 함수 실행
export const handler = async (event) => {
const request = event.Records[0].cf.request;
const headers = request.headers;
// User-Agent로 봇인지 확인 (카카오, 페이스북, 디스코드 등 링크 미리보기 봇)
const ua = headers['user-agent']
? headers['user-agent'][0].value.toLowerCase()
: '';
const isBot = /(facebook|twitterbot|kakaotalk-scrap|naverbot|discordbot|slackbot)/i.test(ua);
// URL에서 프로젝트 ID 추출 (예: /project/123 → "123")
const match = request.uri.match(/^\/project\/(\d+)/);
const projectId = match ? match[1] : null;
// 봇이 아니거나 프로젝트 ID가 없으면 그냥 일반 페이지로 통과
if (!isBot || !projectId) {
return request;
}
try {
// API에서 프로젝트 정보 가져오기
const project = await getProject(projectId);
if (!project) {
return request;
}
const title = project.title || "모아온";
const description = project.summary || "프로젝트를 모아모아, 모아온";
const image =
(project.imageUrls && project.imageUrls.length > 0)
? project.imageUrls[0]
: "S3 버킷 이름/images/logo.png";
// 봇에게 OG 메타태그만 담긴 HTML 응답 (링크 미리보기용)
const html =
'<!doctype html>' +
'<html lang="ko">' +
'<head>' +
'<meta charset="utf-8"/>' +
`<meta property="og:title" content="${escapeHtml(title)}"/>` +
`<meta property="og:description" content="${escapeHtml(description)}"/>` +
`<meta property="og:image" content="${image}"/>` +
`<meta property="og:url" content="https://moaon.co.kr/project/${projectId}"/>` +
'<meta property="og:type" content="website"/>' +
'<meta property="og:site_name" content="모아온"/>' +
`<meta name="twitter:title" content="${escapeHtml(title)}"/>` +
`<meta name="twitter:description" content="${escapeHtml(description)}"/>` +
`<meta name="twitter:image" content="${image}"/>` +
'<meta name="twitter:card" content="summary_large_image"/>' +
`<title>${escapeHtml(title)}</title>` +
'</head>' +
'<body></body>' +
'</html>';
return {
status: '200',
statusDescription: 'OK',
headers: {
'content-type': [
{ key: 'Content-Type', value: 'text/html; charset=UTF-8' }
]
},
body: html,
bodyEncoding: 'text'
};
} catch (e) {
// 에러 발생 시 그냥 일반 페이지로 통과
return request;
}
};
// 백엔드 API에서 프로젝트 정보 가져오기
function getProject(projectId) {
return new Promise((resolve, reject) => {
const req = https.request(
{
hostname: '백엔드 API 주소',
path: `/projects/${projectId}`,
method: 'GET'
},
(res) => {
let data = '';
res.on('data', chunk => { data += chunk; });
res.on('end', () => {
try {
resolve(JSON.parse(data));
} catch {
reject();
}
});
}
);
req.on('error', reject);
req.end();
});
}
// HTML 특수문자 이스케이프 (XSS 방지)
function escapeHtml(str) {
return String(str)
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """);
}
이 코드를 통해 크롤러의 경우 OG 태그가 담긴 HTML을 따로 받기때문에 의도한대로 미리보기가 동작합니다.
escapeHtml 함수를 사용하는 이유는 무엇인가요?
프로젝트 제목에 <, >, " 같은 특수문자가 포함되면 HTML이 깨지거나 보안 문제(XSS)가 생길 수 있습니다. 이를 미리 안전한 문자로 변환해주기 위해 사용합니다.

코드를 작성했다면, 좌측에 위치한 Deploy 버튼을 클릭해 해당 함수를 배포합니다.

배포가 성공했다면, 버전 탭에서 새 버전 게시를 선택해 새 버전을 게시합니다. 이때, 버전 설명을 입력하셔야 합니다.

버전 설명 입력 후 발행 시 좌측에 버전이 자동으로 생성됩니다. 생성된 버전의 번호를 기억하셔야 합니다.

CloudFront 콘솔에서 동작 탭에서 기본값 경로패턴을 가진 동작을 클릭 후 편집 버튼을 선택합니다.

가장 하단 부분에 위치한 함수 연결에서 뷰어 요청의 함수 유형을 Lambda@Edge로 선택합니다.
그 후 함수 ARN/이름 탭에는 Lambda에서 생성한 함수의 ARN을 복사해 붙여넣고 그 뒤에는 :9 와 같이 버전 숫자를 기입하고 변경사항을 저장합니다.
arn:aws:lambda:us-east-1:647:function:moaon-meta:9
만약 Lambda 함수의 코드를 다시 배포한다면, 버전을 게시하고 이 곳에서 새로운 버전 숫자로 업데이트 해주셔야합니다.

Lambda@Edge 연결하는 과정에서 위와 같은 경고 문구가 발생하며 저장이 불가한 경우, IAM에서 신뢰 정책을 편집해야 합니다.

IAM에서 생성한 함수와 관련된 역할 이름을 확인 할 수 있습니다. 해당 이름을 클릭합니다.

신뢰 정책 편집에서 "edgelambda.amazonaws.com"을 신뢰한다고 한 후 저장하고 다시 시도하면 경고 문구가 사라진것을 확인 할 수 있습니다.!

CloudFront 콘솔 세부 정보에서 마지막 수정 부분이 배포로 변경되었다면 배포가 진행중인 것이므로 기다려야합니다.
배포가 완료된 후에 미리보기가 의도대로 동작하는지 결과를 확인하시면 됩니다.

의도한대로 미리보기가 잘 출력되는 것을 확인 할 수 있었습니다.
이번 작업을 통해 SSR로 전환하지 않고도 CSR 구조를 그대로 유지하면서 동적 OG 태그 문제를 해결할 수 있었습니다.
크롤러가 JavaScript를 실행하지 않는다는 근본적인 원인을 파악하고, CloudFront의 요청 흐름을 이해하고, Lambda@Edge의 실행 단계를 선택하는 과정까지 생각보다 고려해야 할 부분이 많았습니다.
특히 처음에 Origin Response 단계에서 시도했다가 원하는 결과가 나오지 않아 Viewer Request 단계로 변경한 경험은, 단순히 기능을 구현하는 것을 넘어 CloudFront의 요청 흐름 전체를 이해하게 된 계기가 되었습니다.
SSR 도입이 부담스러운 상황에서 OG 태그 문제를 해결해야 한다면, Lambda@Edge를 활용한 이 방식이 좋은 선택지가 될 수 있을 것 같습니다.
코드 한 줄 안 바꾸고 CSR에서 동적 OG 메타태그 만들기 (feat. Lambda@Edge)
CSR에서 공유 링크 OG 태그 동적 생성