AndroidGitHub Pages딥링크React NativeExpo

서버 없이 앱 콘텐츠 공유하기 — GitHub Pages + Android 딥링크

June 11, 20261 min read🌐 Only Korean available

앱 콘텐츠를 어떻게 공유할까?

ChainPlay는 YouTube 영상을 묶어서 순서대로 재생하는 앱이다. 직접 쓰다 보니 "이 영상 목록을 다른 사람한테 보내고 싶다"는 생각이 자연스럽게 들었다.

문제는 이 앱이 완전히 로컬 기반이라는 것. AsyncStorage에 데이터를 저장하고, 서버가 전혀 없다. 공유 기능을 붙이려면 보통 "서버에 저장 → 단축 링크 생성" 흐름이 필요한데, 서버를 운영하고 싶지는 않았다.

그래서 찾은 방법이 GitHub Pages + 딥링크 조합이다. 핵심 아이디어는 간단하다:

체인 데이터를 URL 자체에 담아서, 웹 페이지는 데이터를 저장하지 않고 그냥 통로 역할만 한다.


전체 흐름

[공유할 때]
앱 → 체인 데이터 base64 인코딩 → URL 생성
→ https://hyunseokyu1-netizen.github.io/chainplay/?c=BASE64DATA

[받을 때]
링크 탭
 ├─ 앱 설치됨 → chainplay:// 딥링크로 앱 오픈 → 체인 자동 가져오기
 └─ 앱 없음   → 브라우저에서 GitHub Pages 페이지 표시
               ├─ 영상 목록 보여주기
               └─ 구글 플레이스토어 설치 버튼

서버가 없어도 된다. 데이터가 URL 안에 있으니까.


Step 1. 체인 데이터 인코딩

체인 전체를 URL에 담으면 너무 길어진다. 그래서 최소 필드만 인코딩한다.

  • 필요한 것: chain name, videoId, title
  • 복원 가능한 것: thumbnailhttps://img.youtube.com/vi/{videoId}/mqdefault.jpg
  • 복원 가능한 것: urlhttps://youtu.be/{videoId}
// src/utils/share.ts

const SHARE_BASE_URL = 'https://hyunseokyu1-netizen.github.io/chainplay/';
const MAX_SHARE_ITEMS = 20; // URL 길이 제한

function toBase64(str: string): string {
  return btoa(
    encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, (_, p1) =>
      String.fromCharCode(parseInt(p1, 16))
    )
  );
}

export function encodeChain(chain: Chain): string {
  const payload = {
    n: chain.name,
    v: chain.items.slice(0, MAX_SHARE_ITEMS).map(({ videoId, title }) => ({
      i: videoId,
      t: title,
    })),
  };
  return toBase64(JSON.stringify(payload));
}

export async function shareChain(chain: Chain): Promise<void> {
  const base64 = encodeChain(chain);
  const url = `${SHARE_BASE_URL}?c=${encodeURIComponent(base64)}`;
  await Share.share({ message: url, title: chain.name });
}

한국어 제목 처리 주의: btoa()는 ASCII만 지원한다. 한국어 같은 유니코드 문자가 포함되면 에러가 난다. encodeURIComponent → 바이너리 변환 → btoa 순서로 처리해야 한다.

20개 제한을 둔 이유는 영상이 많으면 URL이 수천 자가 넘어가기 때문이다. 20개면 약 1,600자 정도로 안전하다.


Step 2. GitHub Pages 랜딩 페이지

저장소에 docs/ 폴더를 만들고 index.html을 작성한다.

<!DOCTYPE html>
<html lang="ko">
<head>
  <meta charset="UTF-8">
  <!-- OG 태그 (카카오톡 공유 미리보기용) -->
  <meta property="og:title" content="ChainPlay — YouTube 체인 공유">
  <meta property="og:description" content="YouTube 영상 체인을 받았습니다. 앱에서 열어보세요.">
  <meta property="og:image" content="https://hyunseokyu1-netizen.github.io/chainplay/og-image.png">
</head>
<body>
  <script>
    function fromBase64(b64) {
      const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
      return new TextDecoder().decode(bytes);
    }

    const params = new URLSearchParams(window.location.search);
    const c = params.get('c');
    const payload = JSON.parse(fromBase64(c));
    // payload.n = 체인 이름, payload.v = 영상 목록

    // 딥링크 시도
    function tryOpenApp() {
      window.location.href = 'chainplay://import?data=' + encodeURIComponent(c);
    }
  </script>
</body>
</html>

GitHub 저장소 Settings → Pages에서 Source를 main 브랜치의 /docs 폴더로 설정하면 바로 배포된다. 별도 서버 없이 무료다.


Step 3. Android 딥링크 등록

앱이 chainplay:// 스킴을 처리할 수 있도록 AndroidManifest.xml에 intent filter를 추가한다.

<!-- android/app/src/main/AndroidManifest.xml -->
<activity android:name=".MainActivity" ...>
  <!-- 기존 launcher intent -->
  <intent-filter>
    <action android:name="android.intent.action.MAIN"/>
    <category android:name="android.intent.category.LAUNCHER"/>
  </intent-filter>

  <!-- 딥링크 처리 추가 -->
  <intent-filter>
    <action android:name="android.intent.action.VIEW"/>
    <category android:name="android.intent.category.DEFAULT"/>
    <category android:name="android.intent.category.BROWSABLE"/>
    <data android:scheme="chainplay"/>
  </intent-filter>
</activity>

Expo 주의사항: app.jsonintentFilters를 추가해도 자동으로 반영되지 않는다. npx expo prebuild로 네이티브 코드를 재생성하거나, AndroidManifest.xml에 직접 추가해야 한다. 나는 android/ 폴더가 .gitignore에 있어서 직접 수정했다.


Step 4. 앱에서 딥링크 수신

// App.tsx

useEffect(() => {
  function handleDeepLink(url: string) {
    if (!url.startsWith('chainplay://import')) return;
    const match = url.match(/[?&]data=([^&]+)/);
    if (!match) return;

    const decoded = decodeChain(decodeURIComponent(match[1]));
    if (!decoded) return;

    Alert.alert(
      '체인 가져오기',
      `"${decoded.name}" (영상 ${decoded.videos.length}개)을 새 체인으로 가져올까요?`,
      [
        { text: '취소', style: 'cancel' },
        { text: '확인', onPress: () => importChain(decoded.name, decoded.videos) },
      ]
    );
  }

  // 앱이 꺼진 상태에서 딥링크로 열린 경우
  Linking.getInitialURL().then((url) => { if (url) handleDeepLink(url); });

  // 앱이 켜진 상태에서 딥링크가 들어온 경우
  const sub = Linking.addEventListener('url', ({ url }) => handleDeepLink(url));
  return () => sub.remove();
}, [importChain]);

Linking.getInitialURL()addEventListener 두 가지를 모두 처리해야 한다. 앱이 꺼진 상태와 켜진 상태 둘 다 커버하기 위해서다.


중복 이름 처리

같은 이름의 체인을 여러 번 가져오면 목록이 지저분해진다. importChain 함수에서 이름 중복을 체크해 번호를 자동으로 붙인다.

setChains((prev) => {
  const base = name.trim();
  let uniqueName = base;
  let n = 2;
  while (prev.some((c) => c.name === uniqueName)) {
    uniqueName = `${base} (${n++})`;
  }
  // "Kids" → "Kids (2)" → "Kids (3)"
  ...
});

트러블슈팅

"앱으로 열기" 버튼이 아무 반응이 없다

AndroidManifest.xml에 intent filter가 없어서다. app.json만 수정하고 prebuild를 안 했다면 반영이 안 된다. manifest 파일을 직접 열어서 chainplay 스킴이 등록돼 있는지 확인하자.

grep -A 5 "chainplay" android/app/src/main/AndroidManifest.xml

한국어 제목이 깨진다

btoa()에 한국어를 직접 넣으면 안 된다. 앱(인코딩)과 웹페이지(디코딩) 양쪽에서 같은 UTF-8 처리 방식을 써야 한다.

  • 앱: encodeURIComponent → 바이너리 → btoa
  • 웹: atobUint8ArrayTextDecoder

카카오톡에서 URL만 보인다

OG 태그가 없거나, GitHub Pages가 아직 활성화되지 않았을 때 그렇다. Pages 설정 후 1~2분 기다리면 반영된다. 카카오톡은 OG 태그를 캐싱하므로 처음 공유 시 바로 안 보일 수 있다.


정리

구성 요소역할
share.ts체인 → base64 URL 인코딩 (최대 20개)
docs/index.htmlGitHub Pages 랜딩 — 영상 목록 표시, 딥링크 시도, 플레이스토어 연결
AndroidManifest.xmlchainplay:// 스킴 등록
App.tsx딥링크 수신 → 확인 다이얼로그 → 체인 저장
OG 태그카카오톡 공유 미리보기

서버 없이, 무료로, 꽤 그럴듯한 공유 기능을 만들 수 있었다. 데이터가 URL 자체에 담겨 있어서 DB도 필요 없고 유지 비용도 없다. 물론 영상이 20개를 넘어가거나 체인 이름까지 미리보기에 표시하고 싶다면 서버사이드 렌더링이 필요하다. 그건 나중에.

PM

backtodev

A 40-something PM returns to code. Learning, failing, and growing.