이 글은 누구를 위한 것인가
- 사용자가 만든 페이지가 SNS·메신저로 공유되는 서비스를 만드는 팀
- 공유했는데 썸네일이 안 뜨거나 이전 이미지가 나오는 문제를 겪는 경우
- 인앱 브라우저에서만 기능이 깨진다는 제보를 받은 개발자
들어가며
콘텐츠가 링크로 퍼지는 서비스에서 공유 경로의 대부분은 모바일 메신저다. 그리고 그 경로에는 웹 개발자가 평소 다루지 않는 제약이 몰려 있다.
카톡·인스타 등에서 링크를 붙임
↓ 크롤러가 OG 태그를 읽어 미리보기 생성
받는 사람이 탭
↓ 앱 안의 인앱 브라우저에서 열림 ← 여기서 대부분이 깨진다
외부 브라우저로 나가거나, 앱을 설치하거나, 이탈
이 흐름의 각 단계가 다른 종류의 문제를 만든다. 이 글은 단계별로 정리한다.
1. OG 태그 — 최소 세트와 흔한 실수
미리보기가 안 뜨는 원인의 대부분은 태그 자체가 아니라 태그를 읽을 수 없는 상태다.
<meta property="og:title" content="캔버스 보드 — 사용자 이름" />
<meta property="og:description" content="한 줄 설명" />
<meta property="og:image" content="https://example.com/og/abc123.png" />
<meta property="og:image:width" content="1200" />
<meta property="og:image:height" content="630" />
<meta property="og:url" content="https://example.com/p/abc123" />
<meta property="og:type" content="website" />
규격은 Open Graph 프로토콜에 정의돼 있다. 실무에서 걸리는 지점은 따로 있다.
[미리보기가 실패하는 실제 원인]
✗ 클라이언트 렌더링 크롤러는 JS를 실행하지 않는 경우가 많다
→ OG 태그는 반드시 서버 응답 HTML에 있어야 함
✗ 상대 경로 이미지 og:image 는 절대 URL이어야 한다
✗ 로그인 필요 크롤러는 비로그인 상태로 온다
✗ 이미지가 너무 큼 5MB 넘으면 무시하는 크롤러가 있다
✗ robots.txt 차단 크롤러 UA가 막혀 있음
첫 번째가 압도적으로 많다. SPA로 만든 공개 페이지는 초기 HTML이 빈 껍데기라 크롤러가 아무것도 못 읽는다. 공유 대상 페이지만이라도 서버 렌더링해야 한다.
// Next.js — 공개 페이지의 메타데이터를 서버에서 생성
export async function generateMetadata({ params }): Promise<Metadata> {
const { id } = await params;
const page = await getPublicPage(id);
if (!page) return { title: '페이지를 찾을 수 없습니다' };
return {
title: `${page.title} — ${page.ownerName}`,
description: page.summary ?? undefined,
openGraph: {
title: page.title,
description: page.summary ?? undefined,
url: `${SITE}/p/${id}`,
images: [{ url: `${SITE}/og/${id}?v=${page.updatedAtHash}`, width: 1200, height: 630 }],
},
};
}
?v={hash}가 다음 절의 캐시 문제를 해결한다.
2. 미리보기 캐시 — 바꿔도 이전 이미지가 나오는 이유
가장 자주 겪는 문제다.
페이지 수정 → 다시 공유 → 이전 썸네일이 그대로 나옴
원인은 각 플랫폼이 OG 정보를 URL 단위로 캐시하기 때문이다. 우리 서버의 이미지를 바꿔도 그들의 캐시는 갱신되지 않는다.
해결책은 이미지 URL 자체를 바꾸는 것이다.
// 콘텐츠 해시를 쿼리에 넣는다 — 내용이 바뀌면 URL이 바뀜
const ogUrl = `${SITE}/og/${id}?v=${hash(page.title, page.cards.length, page.updatedAt)}`;
updatedAt 타임스탬프만 쓰면 안 된다. 저장할 때마다 값이 바뀌어서 실제 표시 내용이 그대로여도 캐시가 매번 무효화되고, 이미지 생성 비용이 계속 발생한다. 미리보기에 실제로 반영되는 필드만 해시에 넣는다.
[플랫폼별 참고]
· 대부분 URL 단위로 캐시하며 유효기간이 다르다
· 일부는 개발자 도구로 강제 갱신(스크랩 재요청)을 제공한다
· 강제 갱신은 개발 중 확인용이고, 운영 해법이 될 수 없다
→ URL 변경이 유일하게 확실한 방법
3. OG 이미지 생성 — 동적으로 만들 때의 함정
사용자마다 다른 이미지를 만들어야 하면 서버에서 렌더링한다.
[생성 방식]
헤드리스 브라우저 자유도 높음 / 무겁고 느림 (수백 ms~수 초)
이미지 라이브러리 빠름 / 레이아웃 표현 제한
엣지 렌더링 빠름 / 사용 가능한 기능이 제한적
어느 쪽이든 캐시가 필수다. 크롤러는 같은 URL을 반복 요청하고, 사용자가 공유할 때마다 새로 그리면 비용이 감당 안 된다.
export async function GET(req: Request, { params }) {
const { id } = await params;
const v = new URL(req.url).searchParams.get('v') ?? '0';
const cached = await ogCache.get(`${id}:${v}`);
if (cached) return imageResponse(cached);
const png = await renderOgImage(await getPublicPage(id));
await ogCache.set(`${id}:${v}`, png, { ttlDays: 30 });
return new Response(png, {
headers: {
'Content-Type': 'image/png',
'Cache-Control': 'public, max-age=31536000, immutable', // v가 바뀌면 URL이 바뀌므로
},
});
}
immutable을 쓸 수 있는 이유는 내용이 바뀌면 v가 바뀌어 URL이 달라지기 때문이다. 이 조합이 아니면 긴 캐시를 걸 수 없다.
[이미지 설계]
크기 1200×630 (1.91:1) — 대부분의 플랫폼 기본
안전 영역 가장자리 60px 안쪽에 핵심 요소
→ 플랫폼마다 잘리는 비율이 다름
글자 크기 최소 32px — 메신저 미리보기는 실제로 매우 작게 표시됨
텍스트 양 제목 2줄 이내
폰트 로딩이 실패했을 때의 폴백도 정해야 한다. 서버 렌더링에서 웹폰트를 못 불러오면 한글이 두부(□□□)로 나온다. 폰트 파일을 번들에 포함시키는 편이 안전하다.
4. Web Share API — 네이티브 공유 시트 띄우기
자체 공유 버튼을 만드는 대신 OS 공유 시트를 쓸 수 있다.
async function share(page: { title: string; url: string }) {
if (navigator.share) {
try {
await navigator.share({ title: page.title, url: page.url });
track('share_native');
return;
} catch (e) {
if ((e as Error).name === 'AbortError') return; // 사용자가 취소 — 정상
// 그 외 오류는 폴백으로
}
}
await navigator.clipboard.writeText(page.url);
toast('링크를 복사했어요');
}
navigator.share() 사용 시 주의할 점이 셋이다.
1. 사용자 제스처 안에서만 호출 가능
→ 비동기 작업 후 호출하면 차단된다
→ URL을 미리 준비해두고 클릭 핸들러에서 바로 호출
2. AbortError는 오류가 아니다
→ 사용자가 공유 시트를 닫은 것. 에러 토스트 띄우지 말 것
3. 지원되지 않는 환경이 많다
→ 데스크톱 브라우저, 일부 인앱 브라우저
→ 클립보드 복사 폴백은 선택이 아니라 필수
1번이 실제로 자주 걸린다. "공유 클릭 → 서버에서 단축 URL 생성 → share() 호출" 흐름을 만들면 await 이후에 제스처 컨텍스트가 사라져 실패한다. 단축 URL은 미리 만들어두거나, 공유는 원본 URL로 하고 단축은 다른 용도로 쓴다.
5. 인앱 브라우저 — 가장 많이 깨지는 구간
메신저에서 링크를 탭하면 앱 내장 브라우저가 열린다. 여기서 동작하지 않는 것들이 있다.
[인앱 브라우저의 흔한 제약]
· navigator.share 미지원 (앱에 따라)
· 클립보드 API 제한
· 파일 다운로드 불가 또는 무반응
· 새 탭 열기(target="_blank") 동작 안 함
· 카메라·위치 권한 요청 실패
· 저장소(localStorage)가 세션마다 초기화
· 결제 리다이렉트 복귀 실패
마지막 두 개가 치명적이다. 로그인 상태가 유지되지 않고, 외부 인증이나 결제에서 돌아오지 못한다.
// 인앱 브라우저 감지 — UA 기반 (완벽하지 않지만 실용적)
const IN_APP_PATTERNS = [/KAKAOTALK/i, /Instagram/i, /FBAN|FBAV/i, /Line\//i, /NAVER\(inapp/i];
export function isInAppBrowser(ua = navigator.userAgent): boolean {
return IN_APP_PATTERNS.some(re => re.test(ua));
}
감지했다면 중요한 흐름 직전에 외부 브라우저로 나가도록 안내한다.
┌────────────────────────────────────┐
│ 원활한 이용을 위해 │
│ 브라우저에서 열어주세요 │
│ │
│ [브라우저로 열기] [주소 복사] │
└────────────────────────────────────┘
전체 화면을 막지 말고 필요한 시점에만 띄운다. 단순 열람은 인앱에서도 문제없으므로, 로그인·결제·다운로드 직전에만 안내하는 것이 이탈을 줄인다.
안드로이드는 인텐트 스킴으로 외부 브라우저를 직접 열 수 있고, iOS는 표준 방법이 없어 주소 복사 + 안내가 현실적인 폴백이다.
6. 공유 후 도착 — 첫 화면이 전부다
공유 링크로 들어온 사용자는 서비스를 처음 보는 사람이다.
[공유 유입의 특성]
· 서비스 이름을 모른다
· 로그인 상태가 아니다
· 인앱 브라우저 (성능 제약)
· 이탈 판단이 매우 빠르다
[공개 페이지 첫 화면에 있어야 하는 것]
1. 콘텐츠 자체 (가장 먼저, 가장 크게)
2. 만든 사람이 누구인지
3. 이 서비스가 무엇인지 (작게, 방해하지 않게)
4. 나도 만들 수 있다는 진입점 (스크롤 아래 또는 하단 고정)
✗ 로그인 유도 모달
✗ 앱 설치 배너가 화면 절반
✗ 쿠키 배너가 콘텐츠를 가림
콘텐츠보다 먼저 무언가를 요구하면 그 사용자는 돌아오지 않는다. 팬 활동처럼 공유가 핵심 경로인 콘텐츠는 특히 그렇다. Linkme의 K-pop 팬보드 안내처럼 템플릿과 예시를 먼저 보여주는 구성이 이 원칙에 맞는다 — 무엇을 만들 수 있는지 보고 나서 가입을 결정하게 하는 순서다.
성능도 첫 화면 문제다. 인앱 브라우저는 대체로 느리므로, Core Web Vitals의 LCP 기준을 인앱 환경에서 별도로 측정해야 한다. 외부 브라우저에서 2초인 페이지가 인앱에서는 4초일 수 있다.
7. 앱이 있다면 — 딥링크 복귀
네이티브 앱을 함께 운영한다면 공유 링크가 앱을 열게 할 수 있다.
[판단 기준]
앱 설치자 → 앱으로 (더 나은 경험)
미설치자 → 웹으로 (설치 강요 금지)
인앱 브라우저 안 → 웹 유지 (앱 전환이 실패하는 경우가 많음)
미설치자를 스토어로 보내지 않는 것이 중요하다. 공유받은 콘텐츠를 보러 온 사람에게 설치를 요구하면 대부분 이탈한다. 웹에서 먼저 보여주고, 그 다음에 앱을 제안한다.
[웹에서 콘텐츠 표시]
↓ 스크롤 하단
"앱에서 더 편하게 볼 수 있어요" [앱으로 열기]
Universal Links / App Links는 원본 URL 전체를 앱에 전달하므로, 앱 쪽에서 경로를 파싱해 해당 화면으로 바로 이동시킬 수 있다. iOS는 Associated Domains 설정이 전제다.
8. 정리
1. OG 태그는 서버 응답 HTML에 (클라이언트 렌더링은 크롤러가 못 읽음)
2. og:image URL에 콘텐츠 해시 — 표시에 영향 주는 필드만 해시에 포함
3. OG 이미지는 캐시 + immutable, 폰트는 번들에 포함
4. navigator.share는 제스처 안에서, AbortError는 무시, 폴백 필수
5. 인앱 브라우저는 감지하되 전체 차단하지 말고 중요 흐름 직전에만 안내
6. 공유 도착 화면은 콘텐츠 먼저 — 로그인·설치 요구는 나중에
7. 미설치자를 스토어로 보내지 않는다
가장 자주 발생하고 가장 늦게 발견되는 건 2번의 캐시 문제다. 개발 중에는 강제 갱신 도구로 확인하다가, 운영에서 사용자들이 "썸네일이 예전 거예요"라고 제보한 뒤에야 알게 된다. 처음부터 URL에 해시를 넣어두면 겪지 않는다.