0372

FE

[SvelteKit] SvelteKit로 만든 서비스에서 SNS 미리보기(OG 태그)가 뜨지 않았던 이유와 해결 과정

| 서론 – 도메인도 샀는데 왜 미리보기가 안 뜨지?

안녕하세요 팡일입니다.

드디어 re-log 프로젝트의 공식 도메인을 구매했습니다! 구매한 도메인을 무사히 연결한 뒤에, 프로젝트 re-log를 배포하고 나서 SNS에 공유 기능을 눌러봤습니다. 카카오톡, 디스코드, 슬랙, 페이스북 등 대부분의 서비스가 링크 미리보기를 지원하잖아요?

그런데…아무것도 뜨지 않았습니다. 제목도, 이미지도, 설명도 전혀 로딩되지 않는 비어 있는 카드만 나타났습니다.

“도메인도 샀고, Firebase Hosting에도 성공적으로 배포했는데… 왜 아무것도 안 보이지?”

이 문제를 파고들기 시작했고, 결국 OG(Open Graph)의 존재를 알게 되었습니다.

| OG란 무엇인가?

OG(Open Graph)는 SNS나 메신저가 웹페이지 정보를 가져갈 때 참고하는 대표적인 메타데이터 규격입니다.

예를 들어 아래처럼 <head> 안에 넣는 코드들이죠.

TypeScript
<meta property="og:title" content="re-log - 나의 회고 기록" />
<meta property="og:description" content="회고를 작성하고 공유하는 서비스 re-log" />
<meta property="og:image" content="https://my-domain.com/og-image.png" />
<meta property="og:url" content="https://my-domain.com" />

SNS는 링크를 공유하는 순간, 서버로 직접 HTTP 요청을 보내 HTML을 가져와서 meta 태그를 읽어갑니다.

여기서 중요한 점 “SNS는 브라우저가 아니라 서버 환경에서 HTML을 가져간다.”는 점이었습니다. 즉, 서버에 렌더링된 HTML에 OG 태그가 존재해야만 미리보기가 뜹니다.

| 시도했던 점 – layout에 넣고, 페이지에 넣고… 하지만 모두 실패

처음에는 “그냥 OG 태그를 <head> 안에 넣으면 되는 거 아닌가?”라는 단순한 생각으로 접근했습니다. SvelteKit에서는 <svelte:head>를 사용하면 어떤 페이지든 head 메타데이터를 손쉽게 추가할 수 있으니까요.

그래서 다음과 같은 시도를 차례대로 해봤습니다.

  1. +layout.svelte에 OG 태그를 전역적으로 삽입
    → 모든 페이지에서 공통 head 영역이 있으니, 이 방식이면 당연히 SNS가 읽을 수 있을 거라고 예상했습니다.

  2. 랜딩 페이지에만 <svelte:head>로 직접 OG 태그를 작성
    → 공유 기능은 랜딩 페이지에서만 필요하다고 판단해, 개별 페이지에 OG를 넣는 방식도 시도했습니다.

  3. 상황에 따라 OG 태그가 달라지는 동적 OG도 잠깐 고려
    → 사용자가 공유하는 페이지마다 제목이나 설명이 다르게 뜨면 좋겠다고 생각했지만, 결국 이 부분은 구현조차 시도하지 못했습니다.

그리고 마지막으로 제대로 되고 있는지 확인하기 위해 curl -L https://my-domain.com 명령어로 서버에서 내려오는 HTML을 직접 확인했습니다. 이 과정을 거치면서 놀라운 사실을 발견했습니다. 브라우저에서는 분명 <svelte:head>로 작성한 OG 태그가 잘 보였습니다. 하지만 curl로 확인한 “서버가 SNS에게 응답하는 실제 HTML”에서는 딱 이렇게, OG 태그가 완전히 비어 있는 상태였습니다.

HTML
<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <!-- 아무 OG가 없음 -->
  </head>
  <body>...</body>
</html>

즉, SNS가 읽어가는 HTML은 브라우저에서 보는 화면과 달랐고, 그 결과 SNS는 제목, 설명, 이미지 어느 것도 가져갈 수 없어 링크 미리보기가 절대로 뜰 수 없는 상황이었던 것이죠. 이때부터 문제의 본질이 단순한 “태그를 넣느냐 마느냐”가 아니라 SvelteKit의 렌더링 방식과 Firebase Hosting의 구조적 특성 때문임을 알게 되었습니다.

| 왜 이런 문제가 발생했을까?

이번 문제의 원인은 단순히 태그를 잘못 작성해서가 아니라, SvelteKit의 SSR 구조Firebase Hosting의 SPA 배포 방식이 충돌한 데서 비롯되었습니다. 표면적으로는 “OG 태그를 넣었는데 SNS가 못 읽는다”라는 현상이지만, 내부적으로는 브라우저가 페이지를 그리는 과정과 SNS가 페이지를 파싱하는 과정이 완전히 다르기 때문에 발생한 문제였습니다.

1) Firebase Hosting은 기본적으로 정적 호스팅이다

: SvelteKit을 Firebase Hosting에 배포할 때, 많은 사람이 “SSR도 지원하겠지?”라고 생각하지만, 실제로는 그렇지 않았습니다. Firebase Hosting은 어디까지나 정적 파일을 제공하는 서비스이기 때문에, SNS 크롤러가 HTML을 요청하면 서버에서 즉시 렌더링된 SSR HTML이 반환되는 것이 아니라 빌드된 정적 index.html을 그대로 그대로 반환합니다.

즉, SNS는 항상 동일한 index.html만 가져가는 구조입니다.

2) 그 index.html 내부에는 OG 태그가 없다

여기서 문제가 본격적으로 드러납니다. SvelteKit의 <svelte:head>는 페이지가 로드된 후, 즉 브라우저가 자바스크립트를 실행한 뒤에 head 내용을 동적으로 삽입하는 방식입니다.

하지만 SNS 크롤러(Facebook, X/Twitter, Kakao, Discord 등)는 웹페이지를 파싱할 때 다음과 같은 고정 규칙을 가지고 있습니다.

SNS는 자바스크립트를 실행하지 않는다. SNS는 “최초 HTML”만 읽는다.

즉, SNS는 사용자가 브라우저로 페이지를 열었을 때 생성되는 head가 아니라, 서버에서 내려오는 초기 index.html만 읽습니다.

그런데 이 초기 HTML에는 <svelte:head>로 넣은 OG 태그가 당연히 포함되지 않습니다. 왜냐면 <svelte:head>는 HTML이 다운로드된 후 브라우저에서 JS가 실행될 때 비로소 DOM에 삽입되기 때문입니다.

결과적으로 SNS 입장에서는 아래와 같은 상황이 됩니다.

  • “브라우저용 head” → OG 있음

  • “서버가 응답한 초기 HTML” → OG 없음

  • “SNS 크롤러가 읽는 HTML” → OG 없음 (여기만 본다!)

3) 최종 결론

SNS가 가져가는 HTML에는 OG 태그가 아예 존재하지 않기 때문에 미리보기 카드(Preview)가 뜰 수가 없는 구조였다.

이는 SvelteKit을 Firebase Hosting 같은 정적 호스팅에 배포할 때 거의 100% 발생하는 전형적인 문제로, 별도의 SSR 설정이나 프리렌더링, 혹은 Cloud Functions 기반 SSR을 구축하지 않으면 해결하기 어렵다.

| 해결책 – 일단 manual patch: build된 index.html을 직접 수정

현재로서는 임시 방편으로 다음 방식으로 처리했습니다.

  1. npm run build 실행

  2. .svelte-kit/output 혹은 build/client/index.html을 열기

  3. head 안에 OG 태그를 직접 삽입

HTML
<head>
  <meta charset="utf-8" />
  <meta property="og:title" content="re-log" />
  <meta property="og:description" content="나의 회고를 기록하고 공유하는 서비스" />
  <meta property="og:image" content="/og-image.png" />
</head>

이렇게 배포하면 SNS도 해당 index.html 내부의 OG 태그를 읽을 수 있으므로 미리보기가 정상적으로 뜰 수 있었습니다.

| 추후 해결책 – 더 좋은 구조는 없을까?

현재처럼 빌드된 index.html을 직접 수정하는 방식은 어디까지나 임시 방편일 뿐입니다. 빌드할 때마다 파일을 열어 OG 태그를 수동으로 넣어야 할 뿐 아니라, 페이지마다 서로 다른 OG 정보를 적용하는 것도 사실상 어렵습니다. 그래서 장기적으로는 더 나은 구조가 필요하다고 판단했고, 아래와 같은 개선 방향들을 정리할 수 있었습니다.

1) SvelteKit + Cloud Functions 조합으로 SSR 구축하기

: Firebase Hosting을 그대로 사용하면서도 SSR을 구현하고 싶다면, Cloud Functions를 함께 사용하는 방법이 있습니다. SvelteKit의 SSR 출력을 Cloud Functions에서 처리하면, 각 페이지마다 서버에서 OG 태그를 동적으로 생성해 내려줄 수 있습니다. SNS는 서버에서 렌더링된 HTML만 그대로 읽어가기 때문에, 미리보기 문제도 자연스럽게 해결됩니다.

2) SvelteKit Adapter 변경 (예: adapter-node)

: Firebase Hosting 환경에서 벗어날 수 있다면, 가장 단순한 해결책은 adapter-node로 변경해 Node 서버로 서비스하는 방식입니다.

Node 환경에서는 SvelteKit의 SSR이 정상적으로 동작하기 때문에, 각 페이지별 OG 태그도 서버에서 즉시 구성해 응답할 수 있습니다.

배포 환경을 자유롭게 선택할 수 있다면 가장 확실하고 직관적인 방법입니다.

3) Prerendering 사용하기

: 페이지가 정적 구조라면 SvelteKit의 prerender 기능도 좋은 대안입니다.

C++
// +page.ts
export const prerender = true;

이 옵션을 적용하면 SvelteKit이 해당 페이지를 빌드 시점에 SSR 렌더링하여 정적 HTML 파일로 만들어줍니다. 여기에는 <svelte:head>에 작성된 OG 태그도 그대로 포함됩니다. 다만, 페이지 안에서 fetch를 사용하거나 동적인 데이터가 필요하면 제약이 생기기 때문에 이 점은 고려해야 합니다.

4) 별도의 OG 이미지/메타 생성 서버 구축

: Next.js의 @vercel/og처럼, OG 메타 태그와 OG 이미지를 동적으로 생성해 주는 서버를 따로 만드는 방법도 있습니다. SvelteKit에서도 API 라우트 또는 Firebase Functions로 비슷한 구조를 만들 수 있으며, 이를 활용하면 페이지마다 독립적인 OG 이미지와 메타 데이터를 제공할 수 있습니다. 규모가 커지거나 공유 기능의 비중이 높은 서비스라면 충분히 고려해볼 만한 접근입니다.

| 결론

SNS 미리보기 기능은 단순히 “meta 태그만 넣으면 된다” 수준의 문제가 아니었습니다. SNS가 실제로 어떤 HTML을 가져가는지, 그리고 SvelteKit의 SSR이 어떻게 동작하는지를 정확히 이해해야만 해결할 수 있는 문제였습니다.

이번 경험을 통해, 브라우저 렌더링과 서버 렌더링의 차이를 더 명확하게 이해할 수 있었고, Firebase Hosting 환경에서의 기술적 한계도 확실히 짚어볼 수 있었으며, OG 태그는 반드시 빌드된 정적 HTML 내부에 존재해야 한다는 중요한 원리를 깨닫게 되었습니다.

앞으로는 SSR 환경을 도입하거나, prerender 전략을 적절히 활용해 더 완전하고 안정적인 공유 기능을 구현해볼 수 있을 것 같습니다. 이번 문제는 단순한 기능 구현이 아니라, 웹의 렌더링 구조와 생태계를 더 깊이 이해할 수 있는 좋은 학습 경험이었습니다.