공유되는 페이지의 모바일 설계 — OG 이미지, Web Share, 그리고 인앱 브라우저

모바일 개발

Open GraphWeb Share API인앱 브라우저딥링크공유 UX

이 글은 누구를 위한 것인가

  • 사용자가 만든 페이지가 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에 해시를 넣어두면 겪지 않는다.