모든 글
Android · 3분 읽기

안드로이드를 만들며 9 — QR을 찍은 사람이 앱이 있는데도 스토어로 갔어요

공유 그림의 QR이 스토어 주소를 직접 담고 있었습니다. 앱을 이미 깐 사람도 찍으면 스토어로 떨어졌어요. 주소를 우리 도메인으로 바꾸고 App Links를 세우면 링크 하나가 두 경우를 다 받습니다 — 그 구현을 처음부터 끝까지 적었습니다.

#딥링크#app-links#qr#공유#android

물타기 계산기에는 계산 결과를 그림으로 공유하는 기능이 있습니다. 그림 구석에 QR이 붙어 있어요 — 받아 본 사람이 찍으면 앱으로 오라는 문이죠.

그런데 그 QR이 담고 있던 주소가 Play 스토어 직링크였습니다.

앱이 없는 사람에게는 맞는 문입니다. 문제는 앱을 이미 깐 사람이에요. 찍으면 스토어가 열립니다. “설치됨” 버튼이 있는 스토어 페이지가요. 거기서 ‘열기’를 한 번 더 눌러야 앱에 도착합니다. 문을 잘못 단 거예요.

원하는 동작은 이겁니다.

  • 앱이 있는 기기: QR을 찍으면 앱이 바로 열린다
  • 앱이 없는 기기: 웹 페이지를 거쳐 스토어로 간다

링크 하나가 두 경우를 다 받아야 해요. 이걸 해 주는 게 App Links입니다.

딥링크가 뭔지부터

딥링크(deep link)는 링크를 눌렀을 때 웹 페이지가 아니라 앱의 특정 화면이 열리게 하는 장치입니다. 안드로이드에는 크게 두 세대가 있어요.

1세대 — 커스텀 스킴. mycalc://open 같은 주소를 앱이 받겠다고 선언하는 방식입니다. 간단하지만 약점이 커요. 아무 앱이나 같은 스킴을 선언할 수 있어서 누가 받을지 보장이 없고, 앱이 없는 기기에서는 링크가 그냥 죽습니다. QR에 담기엔 실격이에요 — 앱 없는 사람이 주 대상인데요.

2세대 — App Links. 그냥 https 주소를 씁니다. https://twinklelabs.kr/app/stock-calculator 같은 진짜 웹 주소요. 앱이 있으면 OS가 그 주소를 앱에게 주고, 없으면 브라우저가 여는 — 물러설 곳이 웹인 구조입니다. 대신 “이 도메인의 주소는 내가 받겠다”는 주장을 도메인 주인이 보증해야 해요. 그 보증 절차가 App Links의 전부입니다.

구현은 세 조각입니다

1. 매니페스트 — 앱이 ‘이 주소는 내 것’이라고 선언

<intent-filter android:autoVerify="true">
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data
        android:scheme="https"
        android:host="twinklelabs.kr"
        android:pathPrefix="/app/stock-calculator" />
</intent-filter>

autoVerify="true"가 핵심입니다. 이게 있으면 설치 시점에 OS가 도메인에 가서 보증을 확인하고, 통과하면 그 주소를 묻지 않고 앱으로 엽니다. (없으면 ‘어느 앱으로 열까요’ 선택지가 뜨거나 브라우저로 가요.)

여기서 실수하기 쉬운 게 경로의 폭입니다. pathPrefix 없이 도메인 전체를 잡으면, 소개 화면이든 블로그든 그 도메인의 모든 링크를 앱이 가로챕니다. 블로그 글을 눌렀는데 계산기가 열리는 거예요. 앱이 받아야 할 경로만 좁게 잡습니다.

2. assetlinks.json — 도메인이 ‘맞아, 그 앱 내 것’이라고 보증

도메인 루트의 정해진 자리에 파일을 하나 올립니다. https://twinklelabs.kr/.well-known/assetlinks.json

[{
  "relation": ["delegate_permission/common.handle_all_urls"],
  "target": {
    "namespace": "android_app",
    "package_name": "kr.twinklelabs.stockcalculator",
    "sha256_cert_fingerprints": ["8E:CB:…:03"]
  }
}]

패키지 이름과 서명 인증서 지문으로 앱을 특정합니다. 아무 앱이나 “twinklelabs.kr은 내 거”라고 우길 수 없는 이유가 이 지문이에요.

여기 함정이 하나 있습니다. 지문이 두 개예요.

  • 업로드 키 지문: 내가 서명해서 올리는 번들의 키
  • 앱 서명 키 지문: Play가 사용자에게 다시 서명해서 내보내는 키

Play 앱 서명을 쓰면(요즘은 거의 다 씁니다) 사용자 기기에 도착하는 APK는 Play의 키로 서명돼 있습니다. 그러니 assetlinks에는 앱 서명 키 지문이 서야 해요. 어디서 읽냐면 — Play Console의 ‘앱 무결성 → 앱 서명’이 assetlinks 스니펫째로 보여 줍니다. 짐작하지 말고 거기서 복사하세요. (사이드로드 빌드도 열리게 하려면 업로드 키 지문을 배열에 같이 넣으면 됩니다.)

3. 웹 페이지 — 앱 없는 기기의 물러설 곳

https://twinklelabs.kr/app/stock-calculator는 실제로 열리는 페이지여야 합니다. 앱 없는 기기에서는 브라우저가 여기로 오니까요. 이 페이지가 하는 일은 스토어로 넘기는 것 하나입니다 — 그리고 넘기면서 utm_source=share 같은 꼬리표를 대신 붙여 줍니다.

이게 부수입인데요, QR에 담기는 주소 자체는 짧게 유지하면서 취득 경로 집계는 웹이 이어 주는 구조가 됩니다.

설계에서 정한 것 두 가지

주소의 문법은 저장소가 이미 정해 두고 있었어요. 처음에는 /stock-calculator/app으로 갔다가 /app/stock-calculator로 바로잡았습니다. 저희 저장소는 제품을 ‘어떻게 사용자 손에 가는가’로 바구니에 나눕니다 — 스토어에 올라가는 앱은 app/ 아래에요. 웹 주소도 같은 문법을 따르면, 다음 앱은 규칙을 새로 정할 것 없이 /app/pocket-pdf같은 자리에 줄만 서면 됩니다. 실제로 같은 날 Pocket PDF가 두 번째 줄에 섰어요 — 매니페스트에 같은 필터, assetlinks에 항목 하나. 그게 다였습니다.

링크는 유입만 시킵니다. 처음엔 계산 값 전부를 쿼리에 실어서, 받는 쪽이 리포트를 다시 세우는 화면까지 만들었다가 걷었어요. 물리가 반대했거든요 — QR은 담는 바이트가 늘수록 칸이 잘아져서, 공유 그림 구석의 작은 QR에는 밀도 예산이 있습니다(한 칸 4px, 주소 약 106바이트). 종목 이름을 실은 링크는 그 예산에 들어가지 않았어요. 그리고 생각해 보면 공유 그림이 이미 리포트 전체를 보여 주고 있습니다. 링크가 그걸 또 나를 이유가 없어요.

이름도 하나 갈았습니다 — share_store_urlshare_app_url. 가리키는 곳이 스토어가 아니게 됐는데 이름이 그대로면, 다음에 읽는 사람에게 거짓말을 하게 되니까요.

마지막 함정 — 배포되기 전엔 서지 않습니다

코드를 다 짜고 머지까지 해도 App Links는 아직 안 섭니다. .well-known/assetlinks.json이 실제 도메인에서 서빙되고 있어야 설치 시점 검증이 통과하거든요. 저희 웹은 main 푸시에서만 배포되니, 웹이 나가기 전까지 앱 쪽 코드는 완성돼 있어도 링크는 브라우저로 갑니다.

검증이 붙었는지는 기기에서 확인할 수 있어요.

adb shell pm get-app-links kr.twinklelabs.stockcalculator

verified가 떠야 끝난 겁니다.

고치고 나서 적어 둔 것

  • 커스텀 스킴이 아니라 App Links입니다. QR의 주 대상은 앱 없는 사람이고, 물러설 곳이 웹이어야 링크가 죽지 않아요.
  • 경로는 좁게. 도메인 전체를 잡으면 앱이 웹사이트를 통째로 가로챕니다.
  • assetlinks의 지문은 Play 콘솔에서 읽습니다. 업로드 키 지문을 넣고 ‘왜 안 되지’ 하는 게 제일 흔한 함정이에요.
  • 링크에 무엇을 실을지는 QR의 밀도 예산과 화면이 이미 하는 일이 정합니다. 실을 수 있다고 싣는 게 아니라요.