
한달 동안 버튼 하나를 못 붙였다
7월 마지막 날부터 8월 중순까지, 사내 모바일 웹에 "앱으로 열기" 버튼을 붙이는 작업을 했다. 정확히는 붙이려고 했다.
결말부터 밝힌다. 이 기능은 상용에 나가지 않았다.
iOS는 브라우저에서 끝내 앱이 열리지 않았다.
두 달 동안 성공한 경로는 메모 앱에 링크를 붙여넣고 길게 눌러 여는 것 하나였고, 그마저도 3편에서 다룰 동일 도메인 규칙이 적용되지 않는 조건이었다.
지금은 앱에 신규 호스트를 등록한 버전만 먼저 나갔고 웹은 서브도메인 신청서를 낸 채 대기 중이다.
그래서 이건 "이렇게 하면 됩니다" 가이드가 아니다.
밟아본 함정을 번호로 정리한 목록에 가깝다.
설계 · 값 · 배포 · 검증 · 동작 · 협업 여섯 갈래를 세 편에 나눠 싣고, 이 글은 그중 설계 일곱 개와 값 다섯 개를 다룬다.
먼저 깔아둘 전제
이 전제를 모르면 뒤에 반복해서 나오는 "미검증" 표기가 그냥 변명으로 읽힌다.
- 폐쇄망이라 로컬 빌드가 금지되어 있다. 컴파일조차 내 손으로 확인할 수 없다.
- 검증은 실기기를 가진 사람에게 전달해서 확인받는 방법뿐이다. 그래서 커밋마다
Not-tested:항목이 붙는다. - 정적 리소스는 WAS가 아니라 앞단 Apache가 직접 서빙한다. 이게 반복해서 사고를 낸다.
이 목록의 공통점
모아놓고 보니 하나가 눈에 띄었다. 거의 전부 에러를 내지 않는다.
| 실패 | 어떻게 조용한가 |
|---|---|
| AASA 앱 식별자 앞자리 불일치 | 아무 소리 없이 그냥 연결을 맺지 않는다 |
| SHA-256 지문 형식·값 오류 | 검증만 조용히 실패한다 |
| 경로 문자열 한 글자 불일치 | 링크가 그냥 웹으로 열린다 |
| 동일 도메인 제약 | 버튼을 눌러도 아무 일이 없다 |
| 검증파일 404 | 앱이 안 열릴 뿐이다 |
intent:// fallback URL 미인코딩 |
URI 파싱이 조용히 깨진다 |
| 업로드 키 지문 사용 | 개발에선 되다가 상용에서만 실패한다 |
| UA 스니핑 오판 | "앱이 없다"는 결과로 위장된다 |
컴파일 에러도 없고 500도 없고 스택트레이스도 없다. 그냥 안 된다. 그래서 오래 걸렸다.
시작 전에 세워둘 개념 세 가지
개념 설명을 길게 깔지는 않는다. 뒤의 함정을 읽는 데 필요한 만큼만 세운다.
1. 웹에서 앱을 여는 방법은 세 가지다
| 방법 | 원리 | 설치 판정 | 미설치 시 |
|---|---|---|---|
커스텀 스킴 myapp:// |
앱 전용 주소를 브라우저에 던진다 | 불가능 (추측만) | iOS는 Safari 오류창, Android는 무반응 |
유니버설 링크 / App Links https://… |
평범한 https 주소를 OS가 가로챈다 | OS가 정확히 판정 | 그냥 웹페이지로 열린다 |
intent:// (Android) |
스킴 문법 + 패키지 지목 + 폴백 URL | 브라우저가 판정 | fallback URL로 되돌려준다 |
2. 앱링크는 양쪽이 서로를 지목해야 성립한다
이 모델을 안 세우면 뒤의 실패가 전부 "설정 삽질"로만 보인다.

웹은 "이 앱을 믿는다"는 증명 파일을 서버에 올리고, 앱은 "이 도메인을 믿는다"는 설정을 자기 안에 넣는다.
OS가 둘을 대조해서 짝이 맞을 때만 링크를 앱으로 보낸다.
여기서 나오는 성질이 두 가지다.
앱 쪽 선언은 앱을 다시 빌드해야 들어가므로 앱 배포 사이클이 전체 일정을 잡아먹는다.
반면 웹 쪽 파일은 배치하는 순간 반영된다. 이 비대칭이 2편의 배포 순서 함정을 만든다.
규격 원문은 Apple의 Supporting associated domains와 Android의 Verify Android App Links에 있다.
3. iOS와 Android는 파일을 가져가는 주체가 다르다
iOS : 단말 ──► Apple CDN ──► 우리 서버 (중계, 캐시 있음, 외부 인터넷 필요)
Android: 단말 ─────────────────► 우리 서버 (직접 조회)
이 비대칭 하나가 뒤에 나올 세 현상을 전부 만든다.
고쳤는데 몇 시간을 기다려야 반영되는 것, 앱을 재설치해야 반영되는 것, 사내망 개발기에서는 iOS가 원천적으로 불가할 수도 있다는 것
자세한 건 2편에서 다룬다.
설계 함정
함정 1. 웹은 앱 설치 여부를 알 수 없다 — 빠뜨린 게 아니라 막아둔 것이다
최초 요구사항은 이랬다.
진입하면 자동으로 앱 설치 여부를 확인해서, 설치한 사람에게만 "앱으로 열까요?" 팝업을 띄워줘.
앱이 없는 사람에겐 팝업이 뜨면 안 돼.
브라우저에는 설치 여부를 물어보면 바로 답해주는 수단이 없다.
적어도 우리가 상대해야 하는 iOS Safari에는 없다.
그리고 없는 이유가 "아직 안 만들어서"가 아니라 "일부러 막아서"다.
왜 막았는지가 중요하다.
허용하면 아무 웹사이트나 방문자가 어떤 앱을 깔았는지 훑을 수 있다.
그런데 설치한 앱의 조합은 사람마다 다르다.
특정 은행 앱에 특정 지역 서비스 앱, 거기에 취미 앱까지 함께 잡히면 쿠키를 지우고 시크릿 모드를 써도
그 조합 자체가 방문자를 특정하는 식별자가 된다. 이걸 핑거프린팅이라고 부르고, iOS와 Android 둘 다 같은 이유로 막았다.
그래서 해결은 우회로가 아니라 요구사항 변경이었다. 판단을 포기하고, 일단 물어본 다음, 눌렀을 때 분기한다.
코드 주석에 이렇게 남겼다.
"브라우저는 앱 설치 여부를 알려주지 않는다.
Apple·Google 모두 사용자 식별에 악용될 수 있어 의도적으로 막아 둔 영역이다.
그래서 설치 여부를 미리 판정하지 않고, 일단 물어본 뒤 [실행]을 누른 시점에 분기한다."
요구사항이 플랫폼 정책과 충돌하면, 우회로를 찾기 전에 왜 막혀 있는지를 설명해서 합의를 바꾸는 쪽이 빠르다.
이건 두 달 내내 여러 번 다시 확인하게 된 원칙이다.
함정 2. 정답처럼 보이는 API는 실재한다. 다만 iOS에 없다
처음에 나는 사전 감지가 원리적으로 불가능하다고 단정했는데 부분적으로 틀렸다. navigator.getInstalledRelatedApps()라는 표준 API가 실제로 있다. (MDN)
문제는 제약이다.
| 제약 | 내용 |
|---|---|
| iOS | 지원하지 않는다. iOS Chrome에도 없다 |
| 브라우저 | Chromium 계열 전용. Safari·Firefox는 미지원 |
| 웹 선행작업 | 웹 매니페스트에 related_applications 선언 (platform: "play", id: 패키지명) |
| 앱 선행작업 | Android 앱에 그 사이트를 가리키는 asset_statements 메타데이터 |
| 시크릿 모드 | 빈 배열을 반환한다 |
| 기타 | HTTPS(보안 컨텍스트) + 최상위 프레임에서만 동작 |
// Chromium 계열에서만 의미가 있다. 그 외 브라우저에서는 함수 자체가 없다
async function isAppInstalled() {
if (!('getInstalledRelatedApps' in navigator)) return false;
const apps = await navigator.getInstalledRelatedApps();
return apps.some(function (app) { return app.id === 'com.example.app'; });
}
당시 조사 문서에 오류를 두 개 적어놨는데 여기서 정정한다.
먼저 "Chrome 전용이고 삼성인터넷은 미지원"은 자기모순이다.
삼성인터넷도 Chromium 기반이다. 맞는 서술은 "Chromium 계열 전용, Safari·Firefox 미지원"이다.
그리고 "앱이 assetlinks.json으로 도메인 검증을 선언해야 한다"는 방향이 반대다.
이 API가 요구하는 건 앱에서 사이트를 가리키는 asset_statements 쪽이고, 사이트의 /.well-known/assetlinks.json은 App Links용이지 이 API의 필수 조건으로 문서화되어 있지는 않다. (Chrome 문서)
그러니까 정확한 문장은 "불가능하다"가 아니다. "있긴 한데 iOS에서 안 되고, 웹과 앱 양쪽에 선언을 심어야 한다"가 맞다.
지름길처럼 보이던 API가 알고 보니 목적지랑 비슷한 준비물을 요구했다.
iOS의 모든 브라우저가 WebKit 엔진을 쓴다는 게 이 API가 iOS Chrome에도 없는 이유다.
다만 EU에서는 규제 때문에 대체 엔진이 허용됐으니, "전 세계 모든 iOS 브라우저가 영원히 WebKit"이라고 못 박지는 않는 게 좋겠다.
우리 서비스 사용자 기준으로는 여전히 사실이다.
함정 3. 숨김 iframe으로 스킴을 몰래 부르는 트릭은 이미 끝났다
증상은 단순했다. 앱이 깔린 아이폰에서도 앱이 실행되지 않았다.
원인은 iOS가 iframe 안의 커스텀 스킴 네비게이션을 처리하지 않는다는 것이었다.
그럼 왜 iframe을 썼냐면, 미설치 단말에서 location.href로 스킴을 호출하면 Safari 오류창이 뜨기 때문이었다.
그걸 억제하려고 넣은 거였다.
// Before — 오류 팝업은 안 뜨는데, 설치돼 있어도 앱이 안 열린다
var iframe = document.createElement('iframe');
iframe.style.display = 'none';
iframe.src = scheme;
document.body.appendChild(iframe);
// After — 앱은 열리지만, 미설치 시 Safari 오류창이 뜬다
location.href = scheme;
오류 팝업을 없애려던 기법이 기능 자체를 없앤 셈이다. 부작용 억제가 본기능을 죽인 전형적인 사례다.
여기서 하나 짚고 갈 게 있다. 이건 iOS만의 문제가 아니다.
숨김 iframe으로 스킴을 몰래 호출하는 방식은 양쪽 플랫폼에서 이미 오래전에 끝났다.
Android Chrome은 이 방식을 막으면서 대체제로 intent://를 내놨고, iOS도 어느 시점부터 WebKit이 서브프레임의 외부 스킴 네비게이션을 처리하지 않게 됐다.
오래된 블로그를 보고 따라 하면 안 되는 이유가 여기 있다.
다만 표현은 조심해야 한다. iOS의 이 차단을 명시한 Apple 공식 문서는 찾지 못했다.
재현은 확실하지만 어디까지나 경험적 사실이라, "차단 규격"이 아니라 "동작하지 않는다"로 쓰는 게 맞다.
차단이 도입된 정확한 버전을 특정하는 것도 마찬가지로 피했다.
숨김 iframe으로 스킴을 몰래 호출해 설치 여부를 추측하는 방식을 만들었다가 다음 날 전량 롤백했다.
iOS가 iframe 안의 스킴 네비게이션을 처리하지 않아 설치된 단말에서조차 앱이 열리지 않았다. 재시도 금지.
두 달 뒤 다른 설계를 검토할 때 이 주석 덕분에 같은 길을 다시 가지 않았다. 롤백 커밋과 주석은 "지웠다"가 아니라 "왜 이 길이 막혔는지"의 기록이다.
함정 4. 스킴 호출에는 성공 콜백도 리턴값도 없다
"호출해보고 되면 버튼 표시" 같은 조건부 UI는 코드로 성립하지 않는다.
| 원하는 동작 | iOS 현실 |
|---|---|
| 진입 시 자동 호출 | 클릭 없는 자동 호출은 Safari가 차단한다 |
| 호출이 먹혔는지 JS가 감지 | 성공/실패 콜백도 리턴값도 없다 |
| 미설치는 반응 없이 넘어감 | 오히려 에러 팝업이 뜬다 |
설치를 강요하지 않으려던 설계가 정반대 결과를 냈다. 앱이 있는 사용자에게는 "앱에서 이 페이지를 열겠습니까?" 확인창이, 앱이 없는 사용자에게는 "Safari가 페이지를 열 수 없습니다.
주소가 잘못되었습니다"라는 오류창이 떴다.
그럼 남는 선택지는 타이머로 추측하는 것뿐인데, 이건 함정 6에서 다시 다룬다. 요약하면 추측은 기능이 될 수 없다는 이야기다.
여기서 하나는 확정하지 못한 채 끝났다.
자동 호출 문맥에서 미설치 시 에러 팝업이 뜬다는 관측과, 버튼 클릭 문맥에서는 아무 반응이 없었다는 관측이 서로 충돌한 상태로 남았고 실기기 재확인을 받지 못했다.
"버튼 클릭 경로에서는 에러가 안 뜬다"고 단정하면 안 된다.
함정 5. "사파리에서 됐으니 크롬도 되겠지"는 성립하지 않는다
팀장님이 실기기에서 스킴을 확인해 줬는데 Safari에서는 동작하고 iOS Chrome에서는 무반응이었다.
원인은 브라우저 성능 차이가 아니라 호출 경로가 서로 달랐다는 데 있었다.
| 호출 방식 | Safari | iOS Chrome |
|---|---|---|
| 주소창 직접 입력 (테스트한 방법) | 앱 열기 시도 | 입력을 검색어로 우선 처리해 스킴을 무시 |
| 페이지 안 JS 자동 호출 (실제 구현) | 관대함, 동작 | user gesture 없음, 차단 |
| 페이지 안 버튼 클릭 | 동작 | 동작 |
그래서 실기기 테스트 결과를 공유받을 때는 "어떻게 눌렀는지"를 반드시 확인해야 한다.
주소창 입력, 링크 탭, JS 이동은 브라우저 입장에서 전부 다른 사건이다.
이 교훈은 두 달 뒤 유니버설 링크에서 똑같은 모양으로 다시 나온다.
3편의 "JS로 주소를 바꾸면 발동하지 않는다"가 정확히 같은 함정이다.
함정 6. iOS에서는 세 요구사항을 동시에 만족할 수 없다
앱을 여는 수단이 iOS에 두 가지뿐인데, 각각 다른 요구사항을 위반한다.
| 수단 | 설치된 사용자 | 미설치 사용자 | 위반하는 요구사항 |
|---|---|---|---|
| 커스텀 스킴 | 확인 다이얼로그 후 실행 (탭 2회) | Safari 시스템 오류 알림 | 오류 알림이 뜬다 |
| 유니버설 링크 | 앱 바로 실행 (탭 1회) | 그 URL로 페이지 이동 | 페이지를 떠난다 |
중간 지대가 없다. "조용히 시도해보고 안 되면 우리 팝업을 띄운다"는 이상적인 구조는 iOS에서 만들 수가 없다.
조용히 시도할 유일한 수단이던 iframe은 이미 실패가 확인됐고 (함정 3), 설치 여부를 미리 아는 방법도 없다(함정 1).
이 표 하나가 iOS 딥링크의 본질을 요약한다. 무엇을 포기할지 고르는 게 설계의 전부다.
그래서 추측을 완전히 없앨 수 없다면 어느 쪽으로 틀릴지를 설계했다.
미설치 안내를 띄우는 데 타이머 추측을 쓰지 않고, 안내를 먼저 띄워 둔 다음 앱으로 전환되는 실제 이벤트가 오면 거둬들이는 역방향 구조로 짰다.
// 앱이 열렸는지 타이머로 추측하지 않는다.
// 안내를 먼저 띄우고, 앱 전환 이벤트가 오면 거둬들인다.
function showGuideAndWatch() {
var guide = document.getElementById('appMissingGuide');
guide.style.display = 'block';
// once 옵션을 쓰면 안 된다.
// 첫 이벤트가 hidden=false로 들어오면 앱 전환을 감지하지 못한 채 리스너가 소진된다
document.addEventListener('visibilitychange', function onChange() {
if (document.hidden) {
guide.style.display = 'none';
document.removeEventListener('visibilitychange', onChange);
}
});
}
커밋에는 이렇게 적었다. "오판이 나더라도 '설치한 사용자가 안내를 잠깐 본다' 쪽이지, '설치한 사용자를 미설치로 확정한다'가 아니다." 같은 정확도라도 틀렸을 때의 피해가 작은 방향이 있다.
함정 7. 같은 경로 문자열을 세 곳에 손으로 넣어야 한다
앱링크 진입 경로는 세 군데에 중복 선언되고, OS는 문자열 일치로 판정한다.
]웹 검증파일의 components(Android는 intent-filter의 data), 앱의 host·path 등록, 그리고 웹 컨트롤러 매핑. 하나라도 틀리면 링크가 조용히 웹으로만 간다. 에러는 안 난다.
세 곳에 같은 문자열을 손으로 넣어야 하는 설계는 오타 사고를 예약해둔 것이나 다름없다.
그래서 협의 항목을 "앱팀과 협의"로 열어두지 않고, 경로를 웹팀이 단독으로 확정한 뒤 요청 메일에 못을 박았다.
가로챌 경로는 웹에서 /m/app/open.do로 확정했습니다.
이 문장을 iOS 섹션과 Android 섹션 양쪽에 넣고, Android용 intent-filter 스니펫은 복붙 가능한 형태로 메일에 그대로 실어 오타 여지를 없앴다.
<!-- 앱팀에 그대로 붙여 보낸 스니펫 -->
<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="m.example.com"
android:path="/m/app/open.do" />
<data android:scheme="https"
android:host="dev.example.com"
android:path="/m/app/open.do" />
</intent-filter>
한쪽이 단독으로 확정해서 복붙 가능한 형태로 배포하는 편이 협의보다 빠르다.
이 스니펫을 그대로 응용할 사람을 위해 하나 덧붙인다.
같은 intent-filter 안의 요소들은 태그 단위로 짝을 이루지 않는다.
Android는 필터 안의 모든 속성을 하나로 합쳐서 조합으로 매칭한다.
위처럼 path가 동일하면 결과가 같지만, 호스트마다 다른 path를 주고 싶다면 intent-filter를 따로 써야 한다.
이것도 물론 에러가 안 난다.
값 함정
여기서부터는 문자열 하나가 틀려서 생기는 함정들이다.
코드가 아니라 값이 틀린 경우인데, 값이 틀리면 컴파일도 통과하고 배포도 성공하고 파일도 잘 열린다. 앱만 안 열린다.
함정 8. AASA 앞자리는 Team ID가 아니라 앱에 실제로 박힌 값이다
이 작업 전체에서 가장 결정적인 발견이자 가장 비싼 오판이었다.
증상은 이랬다. 서버 파일 정상, Apple CDN 수집 정상, 앱 설정 정상. 그런데 앱이 안 열렸다.
원인은 AASA의 appIDs 앞자리였다.
거기에 Team ID를 적었는데 필요한 건 application-identifier의 접두사였고, 두 값이 달랐다.
왜 두 값이 따로 존재하나
| 항목 | 값 (마스킹) | 무엇을 가리키나 |
|---|---|---|
com.apple.developer.team-identifier |
TEAMID-A |
팀 번호. 어느 개발 조직이 만든 앱인가 |
application-identifier |
APPID-B.com.example.auth |
App ID Prefix + Bundle ID. 앱 하나를 특정 |
권한의 단위가 다르기 때문이다.
키체인 공유처럼 "같은 팀의 앱끼리" 묶는 기능은 팀 단위 식별자가 필요하고, 앱 하나를 특정해야 하는 기능은 앱 단위 식별자가 필요하다. AASA appIDs 항목의 형식은 ., 즉 뒤쪽이다.
그런데 App ID Prefix는 대부분의 경우 Team ID와 같은 값으로 발급된다.
그래서 대부분의 튜토리얼이 "Team ID를 적으세요"라고 써도 잘 동작하고, 아무도 두 값을 의심하지 않는다.
Apple Developer 포털이 App ID 상세 화면에서 Prefix를 Team ID와 별도 필드로 노출하는 이유가 여기 있다.
2011년 중반 이전에 생성됐거나 다른 계정에서 이관된 App ID는 Prefix가 Team ID와 달라진다. (TN2311)
우리 앱이 정확히 그 예외 케이스였다.
// 서버에 적혀 있던 것 (틀림)
"appIDs": ["TEAMID-A.com.example.auth"]
// 실제 앱이 가진 값 (정답)
"appIDs": ["APPID-B.com.example.auth"]
iOS는 두 값이 글자 하나까지 똑같은지만 본다. 다르면 아무 소리 없이 그냥 연결을 맺지 않는다.
어떻게 확정했나
앱팀 회신에 함께 나온 keychain-access-groups 항목 60여 개가 전부 APPID-B.로 시작했다.
이 항목은 규격상 앱 번호를 앞에 붙이게 되어 있으니, APPID-B가 이 앱의 앱 번호라는 사실이 같은 출력 안에서 두 번 확인된 셈이었다. 추측이 아니다
확인 명령은 하나다.
unzip -o App.ipa -d /tmp/x
codesign -d --entitlements :- /tmp/x/Payload/*.app
조치와 그 한계
서버 파일 하나만 수정했다. 앱 재빌드는 필요 없다. 태블릿 앱의 앞자리는 아직 모르는 상태였는데, 조합 가능한 네 개를 전부 넣었다.
맞지 않는 항목은 단말이 조용히 무시하니 부작용이 없고, 왕복 한 번이 사라지기 때문이다.
{
"applinks": {
"details": [
{
"appIDs": [
"APPID-B.com.example.auth",
"APPID-B.com.example.auth.tablet",
"TEAMID-A.com.example.auth",
"TEAMID-A.com.example.auth.tablet"
],
"components": [
{ "/": "/m/app/open.do" }
]
}
]
}
}
여기는 정확하게 써야 한다. 이걸 고쳐서 문제가 해결됐다고 쓰면 거짓말이 된다.
수정 후 앱과 도메인의 연결이 실증된 것까지가 사실이고, 그것이 브라우저 실패의 원인이었는지는 확정되지 않았다.
3편에 나올 동일 도메인 제약이라는 별개 차단이 위에 얹혀 있어서 두 원인을 분리 검증한 적이 없다.
태블릿 접두사도 끝까지 확인하지 못했고, AASA에 네 줄을 넣은 건 추정에 기댄 조치다.
정리하면, "Team ID를 적으세요"가 아니라 "앱에 박힌 application-identifier를 그대로 적으세요"가 맞는 규칙이다.
둘은 보통 같지만 항상 그렇지는 않다. 그렇다고 "Team ID를 쓰면 틀린다"가 규격인 것도 아니라는 점을 같이 알아야 한다.
가장 아팠던 건 이 한 줄 때문에 iOS 연결이 AASA를 처음 만든 시점부터 8월 중순까지 조용히 죽어 있었다는 사실이다.
그 죽은 연결 위에서 Content-Type 가설도, 사내망 CDN 가설도, entitlement 누락 가설도, 서브도메인 검토도, 실기기 테스트 여러 회도 전부 돌아갔다.
죽은 연결 위에서는 어떤 실험을 해도 음성이 나온다. 그리고 음성만 쌓이면 원인을 점점 더 먼 곳에서 찾게 된다.
함정 9. 내가 가진 키의 지문은 상용에서 틀린 값이다
Android assetlinks.json에는 앱 서명 인증서의 SHA-256 지문이 들어간다. 여기서 흔한 판단이 하나 있다. 키스토어가 있으니 keytool로 지문을 뽑아 쓰면 되지 않나.
그렇게 하면 상용에서 조용히 실패한다. Play App Signing 때문이다.

Play App Signing을 쓰면 Google이 업로드된 앱을 앱 서명 키로 다시 서명해서 배포한다. 그래서 개발자가 가진 업로드 키의 지문은 사용자 폰에 깔린 앱의 지문과 다르다. (Play App Signing)
증상이 최악인 이유는 실패 시점 때문이다. 업로드 키 지문을 넣으면 개발 중에는 되는 것처럼 보이다가 상용에서만 검증이 실패한다. 사이드로드로 테스트하면 업로드 키로 서명된 앱이 깔리므로 지문이 맞고, 스토어를 거치면 앱 서명 키로 바뀌기 때문이다. 에러도 안 뜨고 그냥 앱이 안 열리고 브라우저로만 간다.
정답은 키 파일을 아예 만지지 않는 것이다. Play Console의 앱 무결성 화면에서 앱 서명 키 인증서의 SHA-256 지문을 읽어 텍스트로 받는다. (지문 확인 방법 — 콘솔 UI는 개편이 잦으니 메뉴 이름은 문서 쪽을 보는 게 낫다.)
이 함정에는 따로 쓸 사건이 하나 붙어 있다. 지문 문자열 하나를 요청했는데 서명 키스토어와 비밀번호가 통째로 온 일인데, 그건 짧은 글로 따로 남긴다. 요약하면 값을 요청할 때 "파일이 아니라 문자열로", "어느 화면에서 복사하면 되는지"까지 적어야 한다는 이야기다.
요청이 모호하면 상대는 가장 확실해 보이는 것, 그러니까 원본 파일을 보낸다.
함정 10. 지문은 형식이 틀려도 에러가 안 난다
받은 지문 문자열이 그대로 들어가는 게 아니다. 형식이 정해져 있고, 틀려도 에러가 안 난다.
| 항목 | 올바른 형태 | 실제로 받은 값의 문제 |
|---|---|---|
| 구분자 | 2자리마다 콜론 (AB:CD:EF:…) |
붙여쓰기 |
| 대소문자 | 대문자가 관례 | 소문자 |
| 길이 | 16진수 64자, 콜론까지 세면 95자 | 하나는 붙여쓴 상태로 65자였다 (64자여야 한다) |
| 따옴표 | JSON 문자열이라 큰따옴표 유지 | — |
길이 계산은 단순하다. 바이트 32개 × 2자 = 64자, 여기에 콜론 31개를 더해 95자. 파일을 만든 직후 이 두 숫자를 세어보는 습관 하나가 하루를 아낀다. 대소문자는 검증기가 유연하게 처리한다는 이야기도 있지만, 굳이 시험해볼 이유가 없어서 대문자로 맞췄다.
붙여쓴 지문을 변환하는 건 한 줄이면 된다.
# 붙여쓴 지문을 콜론 구분 대문자로 변환 (Windows PowerShell)
$hex = "붙여쓴_지문"
((($hex -split '(..)' | Where-Object {$_}) -join ':')).ToUpper()
완성된 파일은 이렇게 생겼다.
[
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.app",
"sha256_cert_fingerprints": ["AB:CD:EF:… (95자, 대문자, 콜론 구분)"]
}
},
{
"relation": ["delegate_permission/common.handle_all_urls"],
"target": {
"namespace": "android_app",
"package_name": "com.example.app.tablet",
"sha256_cert_fingerprints": ["AB:CD:EF:… (95자, 대문자, 콜론 구분)"]
}
}
]
sha256_cert_fingerprints가 배열이라 지문을 여러 개 나열하고 하나만 맞으면 통과하는 건 사실이다. 다만 이번엔 그렇게 하지 못했다. 65자로 온 두 번째 지문은 끝내 재확인을 받지 못했고, 최종 파일에는 지문이 하나만 들어갔다.
함정 11. 앱이 하나가 아니라 네 개였다
"앱 하나 연동"이라고 듣고 시작했는데, 폰과 태블릿이 서로 다른 앱이었다.
iOS 두 개에 Android 두 개, 총 네 개를 전부 검증파일에 넣어야 했다. 하나라도 빠지면 그 기기에서만 앱이 안 열린다.
코드에도 영향이 있었다. iPadOS 13 이상의 Safari가 자신을 Macintosh로 보고하기 때문에 UA만으로는 아이패드를 가려낼 수 없다. 터치 지점 수를 같이 봐야 한다.
function getAppDeviceType() {
var ua = navigator.userAgent;
if (ua.indexOf('iPhone') > -1) return 'IOS_PHONE';
if (ua.indexOf('iPad') > -1
|| (ua.indexOf('Macintosh') > -1 && navigator.maxTouchPoints > 1)) return 'IOS_TABLET';
if (ua.indexOf('Android') > -1) {
return ua.indexOf('Mobile') > -1 ? 'AOS_PHONE' : 'AOS_TABLET';
}
return 'OTHER';
}
값을 요청할 때 "앱이 몇 개인지"부터 물어야 한다는 걸 이때 배웠다.
참고로 이 함수는 3편의 함정으로 이어진다. UA 스니핑이 폰과 태블릿을 오판하면 잘못된 패키지를 호출하게 되고, 그 결과가 "앱이 없다"로 위장된다. 이 건은 끝내 해결하지 못하고 한계로 문서화만 했다.
함정 12. 도메인이 두 개면 배치도 두 배다
운영과 개발 도메인이 따로 있으면 검증파일을 양쪽 서버에 각각 배치해야 하고, iOS Associated Domains에도 applinks:를 두 줄 넣어야 한다. 한쪽만 넣으면 그 환경에서만 앱이 열린다.
applinks:m.example.com
applinks:dev.example.com
다행히 검증파일 내용 자체는 완전히 같다. 파일 안에 도메인이 들어가지 않기 때문이다. 같은 파일을 양쪽 서버에 그대로 올리면 된다. 이 성질은 나중에 서브도메인을 추가할 때도 그대로 적용된다. 파일은 복사하면 되고, 앱 쪽 등록만 재빌드를 요구한다.
이 편에서 보장하지 못하는 것
정직하게 적는다. 위 함정들 중 다음은 확정된 사실이 아니다.
- 함정 8의 수정이 브라우저 실패의 원인이었는지는 확정되지 않았다. 연결이 살아났다는 것까지가 사실이다.
- 태블릿 동작 일체는 미검증이다. 앱 식별자 접두사를 확인하지 못했고 실기기 테스트 기록도 없다.
- 미설치 사용자 경험은 한 번도 구현·검증된 적이 없다. 지금도 조용히 로그인 화면으로 돌아온다.
- 함정 4의 "버튼 클릭 경로에서 미설치 시 에러 팝업이 뜨는가"는 관측이 충돌한 채 끝났다.
다음 편
2편은 배포 함정과 검증 함정이다. WAR에 넣은 검증파일이 왜 서버에는 없었는지, .well-known이 숨김 폴더라서 생기는 일, Apache 설정이 막는 두 가지, 그리고 캐시 두 겹 때문에 테스트 한 회차를 통째로 날린 이야기를 다룬다.
3편은 동작 함정과 협업 함정, 그리고 안 될 때 확인하는 순서다. iOS에서 끝내 앱이 열리지 않은 진짜 이유가 거기 있다.
(다음 편 링크 자리)