App Links 실패는 웹 서버가 주는 연결 선언, 설치된 앱의 신원, 기기의 검증 상태, 앱 내부 URL 이동을 나눠 봐야 합니다. JSON을 브라우저로 열 수 있거나 앱을 지정한 테스트가 성공했다는 사실만으로 운영 링크가 검증됐다고 결론내리지 않습니다. 이 글은 HTTPS 링크가 브라우저로 열리는 상황에서 서버와 서명 경계를 먼저 확인하는 방법을 다룹니다.
2026년 10월 9일 Node.js 24.19.0의 격리 HTTP 서버에서 13개 응답을 요청하고 16개 기대 조건을 검사했습니다. 리디렉션 자동 추적을 끄고 헤더와 본문을 받은 뒤, 사이트에서 작성한 사전 점검 함수로 실패 단계를 나눴습니다. 테스트는 127.0.0.1 HTTP였고 Android 기기·실제 인증서·HTTPS 검증은 수행하지 않았습니다. 아래의 preflight-match는 우리 검사 결과 이름이지 Android의 verified 상태가 아닙니다.
검사 대상은 도메인 전체가 아니라 한 응답과 한 앱 신원
먼저 실제 사용자가 누르는 host, 앱 application ID, 설치 산출물의 서명 인증서를 한 줄에 기록합니다. www 유무나 하위 도메인이 다르면 다른 host입니다. debug·release·제품 flavor에서 application ID가 달라질 수도 있습니다. Play App Signing을 사용한다면 업로드 키와 사용자에게 배포된 앱의 서명 키를 혼동하지 않습니다.
공식 연결 선언 위치는 각 host의 https://HOST/.well-known/assetlinks.json입니다. HTTPS, 직접 접근, JSON Content-Type을 확인합니다. 여기서 host를 example.com에서 www.example.com으로 보내는 302를 “최종 페이지가 잘 열린다”는 이유로 통과시키지 않습니다. 로그인 페이지나 CDN 오류가 HTTP 200 HTML로 반환되는 상황도 분리합니다.

실제로 받은 응답을 단계별로 거절했다
| 로컬 입력 응답 | 관찰한 판정 | 우선 수정할 대상 |
|---|---|---|
| 200 + application/json | preflight-match | 다음으로 앱·기기 검증 |
| 200 + application/json; charset=utf-8 | preflight-match | 매체 유형에 추가 인자가 있어도 분리해 읽음 |
| 302, Location=/valid | http-302 | 검증 주소에서 직접 JSON 제공 |
| 404 | http-404 | 파일 위치·host 배포 확인 |
| 200 + text/html 로그인 본문 | content-type | 로그인·오류 페이지로 치환된 응답 |
| 깨진 JSON / 배열 아닌 객체 | invalid-json / not-array | 파일 문법·최상위 배열 |
| 다른 package 또는 relation | identity-or-relation-mismatch | 앱 ID와 연결 권한 선언 |
| 다른·축약·소문자 지문 | certificate-mismatch | 배포 인증서와 완전한 대문자 지문 |
정상 두 응답을 제외한 HTTP 사례는 모두 기대한 단계에서 거절됐습니다. 추가로 입력 지문의 대소문자 정규화, 잘못된 입력 지문 거부, 여러 statement 중 실제 일치 항목 찾기를 확인했습니다. 공개 파일에는 Android 문제 해결 문서가 안내하는 완전한 대문자 SHA-256 지문을 사용합니다. 검사 함수가 대소문자를 바꿔 비교할 수 있다는 사실을 기기 검증기의 호환성 보장으로 해석하지 않습니다.
// redirect: 'follow'가 아니라 최초 응답을 검사
const r = await fetch(url, { redirect: 'manual' });
console.log(r.status, r.headers.get('content-type'));
const body = await r.text();
// http-302이면 최종 JSON을 찾았어도 이 단계는 실패
// 로컬 실행: 13 requests, 16 checks, passed: true
package와 지문을 같은 statement에서 찾는 이유
중요한 반례는 HTTP 200의 유효한 JSON 안에 정답이 모두 등장하지만 서로 다른 항목에 있는 경우였습니다. 첫 statement는 올바른 package에 다른 인증서를 넣고, 둘째는 다른 package에 올바른 인증서를 넣었습니다. 문서 전체에서 두 문자열의 존재만 확인하는 검사는 이 파일을 통과시킬 수 있지만, 이번 함수는 certificate-mismatch로 거절했습니다.
// 이 식처럼 조건을 서로 다른 항목에서 찾으면 안 됩니다.
// hasPackageAnywhere && hasFingerprintAnywhere
// 실제 점검은 같은 항목의 relation + namespace + package + 지문
data.some(statement =>
matchesRelation(statement) &&
matchesTargetPackage(statement) &&
matchesTargetCertificate(statement));
relation은 URL 처리 권한, namespace는 android_app, package_name은 실제 앱 ID, sha256_cert_fingerprints는 설치 파일 서명과 대응시킵니다. 같은 파일에 여러 앱과 여러 서명 지문을 넣을 수 있어 “첫 배열 원소만 맞다”는 가정도 버립니다. 같은 package라도 certificate가 다르면 동일한 설치 신원으로 취급하지 않습니다.
실험에는 com.example.links.lab과 00부터 1F까지 32바이트를 이어 만든 합성 지문을 사용했습니다. 실제 인증서가 아니므로 이 값을 운영 도메인에 복사하지 않습니다. 이전의 AA:BB:CC:DD:… 같은 축약 예제도 완전한 지문이 아니며, 테스트에서는 이를 거절했습니다. 비밀 키는 이 파일에 넣지 않습니다. 공개되는 것은 인증서 지문입니다.
운영 서버를 확인할 때 자동 이동을 끈 요청을 남긴다
curl -i --max-time 20 https://YOUR_HOST/.well-known/assetlinks.json
# curl -L 없이 최초 상태·Content-Type·Location 확인
# YOUR_HOST와 앱 ID, 인증서 지문은 실제 값으로 교체
이 명령은 독자가 자신의 서버에서 사용할 절차이며 이번 실행에서 특정 운영 Android 앱을 검증한 명령은 아닙니다. WAF·VPN·국가 차단처럼 개발 PC와 기기의 접근 조건이 다른 경우도 남습니다. 설정 변경 뒤에는 기존 실패 응답과 새 응답을 함께 보관하고, CDN의 캐시된 선언이 남아 있는지 확인합니다.
서버 사전 점검 뒤에만 Android 상태를 읽기
Android 12 이상 테스트 기기에서는 먼저 현재 도메인 상태와 사용자 설정을 읽습니다. 서명 종류·앱 버전·host별 결과를 함께 적습니다. 상태 초기화는 링크 기본 설정에 영향을 주므로 운영 사용자 기기에 일괄 실행하지 않고 테스트 기기에서만 수행합니다.
adb shell pm get-app-links PACKAGE_NAME
adb shell pm get-app-links --user cur PACKAGE_NAME
# 필요할 때 테스트 기기에서만 초기화 후 재검증
adb shell pm set-app-links --package PACKAGE_NAME 0 all
adb shell pm verify-app-links --re-verify PACKAGE_NAME
# 검증 에이전트가 끝난 뒤 get-app-links로 다시 읽기
| 확보한 증거 | 다음 판단 |
|---|---|
| 서버만 preflight-match | manifest·설치 서명·기기 결과는 아직 미검증 |
| none | 기록 없음 또는 검증 미완료, 연결과 후속 결과 확인 |
| verified이나 사용자가 링크 열기 비활성화 | 검증 실패와 사용자 선택을 구분 |
| 강제 approved | 실제 서버 검증 성공과 동일하게 기록하지 않음 |
| 앱은 열리나 잘못된 상세 화면 | 앱의 path·query 처리로 조사 범위 이동 |
URL 테스트에 package를 강제 지정하면 OS의 기본 앱 선택을 우회할 수 있습니다. 일반 사용 동작을 확인할 때는 앱 지정 없이 VIEW·BROWSABLE과 실제 HTTPS URL로 시작하고, 열린 앱과 상세 화면을 둘 다 기록합니다. manifest의 URL 허용 범위와 Android 15 이상 동적 규칙은 별도 경계이며 서버 JSON 신원 일치만으로 경로 매칭을 보증하지 않습니다.
담당자에게 넘길 최소 기록
서버팀에는 host별 최초 상태·헤더·JSON, 앱팀에는 application ID·서명 종류·manifest URL 범위, 테스트팀에는 OS 버전·기기 검증 상태·사용자 선택·최종 화면을 전달합니다. 이번 실험은 HTTP 사전 점검과 합성 대상 일치 검사만 했으므로 Android 검증기·동적 규칙·TLS·실제 서명 키를 재현했다고 주장하지 않습니다.
운영 앱 출시의 다른 실패 흐름은 동시성·오류 흐름 출시 검증에서 다룹니다. 이 글의 목적은 앱 설치나 일반 웹 로그인 문제가 아니라 링크 연결 신원과 검증 단계의 분리입니다.
2026년 10월 9일 웹사이트 연결 선언, 기기 검증 상태, 리디렉션·서명 문제 해결 공식 문서와 비교했습니다. 문서의 기기 절차와 이번 로컬 함수의 판정 이름을 구분해서 기록했습니다.