개념
이 세 가지를 한 레슨에 묶는 이유
세 기능은 테스트 관점에서 같은 문제다. 앱이 스스로 결정하지 못하고 OS가 끼어들어 결과를 바꾼다. 링크를 앱으로 보낼지는 도메인 검증 결과가, 알림을 언제 배달할지는 권한 상태와 절전 정책이, 예약 작업의 실행 시점은 Doze와 앱 대기 버킷이 정한다. 그래서 "안 됩니다" 한 줄짜리 리포트는 쓸모가 없다 — OS가 어떤 상태였는지 를 같이 적어야 재현이 된다. 모듈 17 레슨 2의 플랫폼별 알림 관습과 이 모듈 레슨 2의 POST_NOTIFICATIONS 권한 위에 배달 경로와 실행 시점 을 얹는다.
딥링크의 세 층위를 구분한다
"딥링크"라는 한 단어에 세 가지가 섞여 있다. 커스텀 스킴(myapp://)은 앱이 임의로 선언하므로 같은 스킴을 선언한 다른 앱이 가로챌 수 있고, 미설치 사용자에게는 아무것도 보여 주지 못한다. Android App Links 와 iOS Universal Links 는 https:// 링크에 도메인 소유 검증을 붙인 것으로, 가로채기가 불가능하고 앱이 없으면 브라우저가 그 웹페이지를 연다.
즉 검증된 https 링크만이 "브라우저를 거치지 않는다"는 보장을 준다. "앱이 있으면 앱으로, 없으면 웹으로"가 요구사항이면 App Links / Universal Links가 정답이다.
Android App Links — 검증 파일과 검증 상태를 직접 본다
동작 조건은 두 개다. 앱의 인텐트 필터가 android:autoVerify="true" 를 갖고 있어야 하고(Android 6.0/API 23 이상에서 이 속성이 있으면 시스템이 자동 검증을 시도한다), 도메인 쪽에 Digital Asset Links 파일이 https://도메인/.well-known/assetlinks.json 에 HTTPS로 올라가 있어야 한다.
QA는 두 쪽을 모두 확인할 수 있다. 서버 파일은 curl -sSI https://example.com/.well-known/assetlinks.json 으로 리디렉션 없이 200이 오는지 본다. 파일 안에는 패키지명과 서명 인증서 SHA-256 지문이 들어 있고, 릴리스 빌드의 지문이 들어 있는지 가 가장 자주 터지는 지점이다. 디버그 빌드에서는 되는데 Play에서 받은 빌드에서는 브라우저로 열린다면 거의 이 문제다 — Play 앱 서명을 쓰면 Play가 최종 서명을 다시 하므로 Play Console이 알려 주는 앱 서명 인증서 지문 을 넣어야 한다. 다음은 기기 쪽 검증 상태다.
adb shell pm get-app-links --user cur com.example.app # 도메인별 검증 상태
adb shell pm verify-app-links --re-verify com.example.app # 재검증 (Android 12+)
adb shell pm set-app-links --package com.example.app 0 all # 검증 상태 초기화
adb shell am start -a android.intent.action.VIEW \
-c android.intent.category.BROWSABLE -d "https://example.com/products/123"pm get-app-links 결과에서 도메인별 상태가 verified 인지 확인한다. 아니면 파일·지문·네트워크 중 하나가 어긋난 것이다. Android 12(API 31)부터 검증 절차가 바뀌었고, 검증되지 않은 도메인은 사용자가 설정에서 직접 "이 앱에서 열기"를 켜 줘야 한다 — 즉 검증 실패는 조용한 실패 다. 앱은 오류를 내지 않고 링크만 브라우저로 간다. Android 15(API 35) 이상은 시스템이 주기적으로 재검증하므로 서버 파일을 고친 뒤 반영까지 시간이 걸릴 수 있다(문서상 최대 7일).
iOS Universal Links — AASA 파일과 연결 상태
iOS 쪽 이름은 apple-app-site-association(줄여서 AASA)이다. 확장자가 없다는 점이 함정이다.
- 위치:
https://도메인/.well-known/apple-app-site-association(루트 경로도 허용되나.well-known권장) - HTTPS 필수, 리디렉션 금지 — 301/302가 한 번이라도 끼면 실패한다. 실무 실패 원인 1위다
Content-Type: application/json, 압축 전 크기 128KB 이하- 앱 쪽은 Associated Domains 역량(entitlement)에
applinks:example.com형식으로 선언
curl -sSI https://example.com/.well-known/apple-app-site-association 로 헤더를 먼저 본다 — 중간에 3xx 가 하나라도 있으면 그것이 실패 원인이다.
기기는 이 파일을 애플의 CDN을 거쳐 받으므로 서버 파일을 고쳐도 바로 반영되지 않는다. 개발 중에는 entitlement 항목에 ?mode=developer 를 붙여 CDN을 우회하는 방법이 문서에 있고, 릴리스 빌드에는 남아 있으면 안 된다 — 릴리스 점검 항목에 넣는다. 기기가 옛 파일을 들고 있는 상태는 "내 폰에서는 되는데"의 흔한 정체다.
도착 경로 매트릭스 — 진짜 테스트는 여기서 시작한다
링크가 앱으로 오는지는 절반이다. 나머지 절반은 앱 상태에 따라 다른 코드 경로를 탄다 는 사실이다.
| # | 앱 상태 | 무슨 일이 일어나야 하나 | 자주 깨지는 지점 |
|---|---|---|---|
| D1 | 미설치 | 브라우저가 웹페이지를 연다 | 404(웹에 대응 경로 없음) |
| D2 | 설치됨 · 미실행(콜드 스타트) | 앱이 켜지면서 바로 해당 화면 으로 | 스플래시 → 홈, 링크가 버려진다 |
| D3 | 백그라운드(프로세스 생존) | 기존 스택 위에 해당 화면이 뜬다 | 반응 없이 마지막 화면만 |
| D4 | 포그라운드 | 현재 화면에서 해당 화면으로 전환 | 중복 화면이 쌓이거나 무시 |
| D5 | 로그인 필요 링크 · 비로그인 | 로그인 유도 → 로그인 후 목적지 복귀 | 로그인 후 홈으로, 목적지 유실 |
| D6 | 존재하지 않는 리소스 링크 | 사람이 읽을 수 있는 안내 화면 | 빈 화면 또는 크래시 |
| D7 | 조작된 파라미터가 붙은 링크 | 무시하거나 검증 실패 처리 | 검증 없이 신뢰해 잘못된 화면·데이터 노출 |
| D8 | 딥링크 도착 후 뒤로 가기 | 앱 안의 합리적인 상위 화면으로 | 즉시 앱 종료 |
D2와 D5가 실무 결함의 대부분이다. 콜드 스타트는 초기화 순서 때문에 링크 데이터가 유실되기 쉽고, 로그인 복귀는 아예 구현이 안 된 경우가 많다. D7은 보안 항목이다 — 딥링크 파라미터는 외부 입력 이므로 검증 없이 쓰면 안 된다. 콜드 스타트 상태는 adb shell am force-stop <패키지> 로 프로세스를 죽인 뒤 링크를 던져 만든다.
푸시 알림 — 권한 · 메시지 유형 · 앱 상태의 3중 곱
푸시 결함은 원인이 세 갈래로 갈리는데 리포트에는 보통 "푸시가 안 와요" 한 줄만 적힌다. 세 축을 분리한다.
축 1 — 권한과 채널. Android 13(API 33)부터 POST_NOTIFICATIONS 가 런타임 권한이라 신규 설치 앱은 알림이 기본 꺼짐이다(레슨 2). 권한과 별개로 알림 채널(Notification Channel)이 꺼져 있을 수도 있다 — "마케팅" 채널만 끈 사용자에게 마케팅 푸시가 안 오는 것은 정상 동작이다. adb shell dumpsys notification --noredact 로 둘을 함께 본다.
축 2 — 메시지 유형. FCM 기준 세 종류다.
| 유형 | 페이로드 | 앱이 백그라운드일 때 | 앱이 포그라운드일 때 |
|---|---|---|---|
| 알림(notification) | notification 키 | SDK가 자동으로 트레이에 표시. 앱 콜백 호출 안 됨 | 앱 콜백 호출. 표시 여부는 앱이 결정 |
| 데이터(data) | data 키만 | 앱 콜백 호출. 표시는 앱이 직접 | 동일하게 콜백 호출 |
| 혼합 | 둘 다 | 트레이 표시 + 탭할 때 데이터가 인텐트로 전달 | 콜백에 둘 다 전달 |
결론: 백그라운드에서 알림 메시지를 보내면 앱 코드가 아예 돌지 않는다. 그러므로 "알림은 뜨는데 탭해도 해당 화면으로 안 간다"는 결함은 알림 메시지 + 딥링크 처리 누락의 조합이고, 위 D2~D4 매트릭스와 그대로 이어진다. 푸시 탭 동작 테스트는 곧 딥링크 테스트다.
축 3 — 앱 상태. 최소 네 가지를 각각 돌린다. N1 포그라운드·해당 화면(중복 안내가 아닌가), N2 포그라운드·다른 화면(인앱 배너나 트레이 알림이 뜨는가), N3 백그라운드(트레이에 뜨고 탭하면 목적 화면으로 가는가), N4 프로세스 종료(트레이에 뜨는가, 탭하면 콜드 스타트 후 목적 화면으로 가는가).
N4가 국내 기기에서 특히 중요하다. 일부 제조사 ROM은 사용자가 최근 앱에서 앱을 밀어 없애면 앱을 강제 종료 상태로 만들어 메시지를 배달하지 않는다. 강제 종료 상태의 앱에 시스템이 브로드캐스트를 배달하지 않는 것은 Android의 원칙이고, 사용자가 앱을 다시 실행해야 풀린다. 앱 버그가 아니라 기기 정책이므로 기종·ROM 버전을 반드시 적는다.
우선순위. FCM 메시지는 일반(normal)과 높음(high) 우선순위를 가진다. 높은 우선순위는 절전 중인 기기를 깨워 즉시 배달을 시도하고, 일반은 지연될 수 있다(iOS로 가는 메시지는 APNs 우선순위로 매핑된다). 판정 기준은 즉시성이 제품 요구인 알림(보안 인증 코드, 통화 수신)이 일반 우선순위로 나가고 있지 않은가 다. 반대로 마케팅 푸시가 전부 높은 우선순위면 절전 관점의 문제 제기 대상이다.
백그라운드 실행 제약 — 지연·취소가 "정상"인 조건
예약한 작업이 안 돌면 코드를 의심하지만 대개는 OS가 의도대로 막은 것이다. 두 메커니즘을 구분한다.
Doze — 기기 단위. 충전 중이 아니고 정지 상태이며 화면이 꺼진 채 시간이 지나면 진입한다. 진입하면 네트워크 접근이 중단되고 wake lock이 무시되며, JobScheduler(그 위의 WorkManager 포함)·표준 알람·동기화 어댑터가 모두 연기 된다. 시스템은 주기적으로 짧은 유지보수 창(maintenance window) 을 열어 밀린 작업을 한꺼번에 실행하고 다시 닫으며, 이 창은 시간이 지날수록 뜸해진다. Doze에서도 살아남는 것은 높은 우선순위 FCM 메시지, setAndAllowWhileIdle()/setExactAndAllowWhileIdle() 알람(앱당 발동 간격 제한이 있다 — 문서상 9분), setAlarmClock(), 포그라운드 서비스다.
앱 대기 버킷 — 앱 단위. Android 9(API 28)부터 시스템이 사용 패턴에 따라 버킷을 매긴다. 제한이 약한 순서로 활성(active) → 작업 세트(working set) → 자주 사용(frequent) → 드물게 사용(rare) → 제한됨(restricted) 이고, 한 번도 실행되지 않은 앱을 위한 never 버킷이 따로 있다. restricted 는 Android 12(API 31)에서 도입됐고 가장 강하다 — 작업은 하루 한 번 10분짜리 묶음 세션, 알람은 하루 한 개로 제한되며 충전 중에도 제한이 유지된다. rare 부터는 네트워크 접근 자체가 제한된다.
QA가 이걸 손으로 재현한다.
# Doze 재현
adb shell dumpsys battery unplug # 충전 해제 상태로 위장
adb shell dumpsys deviceidle force-idle # Doze 진입 → 작업 관찰
adb shell dumpsys deviceidle unforce # 해제
adb shell dumpsys battery reset # 원복
# 앱 대기(비활성) 재현 / 버킷·작업 상태 확인
adb shell am set-inactive com.example.app true
adb shell am get-inactive com.example.app
adb shell am get-standby-bucket com.example.app
adb shell dumpsys jobscheduler | grep -A 30 "com.example.app"공식 문서는 앱을 특정 버킷에 넣도록 시스템을 조작하지 말라고 명시한다. 버킷 강제 지정 명령이 기기에서 동작하더라도 그것은 검증 수단이지 제품 동작의 근거가 아니다 — 원문 확인 후 팀 기준으로 삼는다.
백그라운드 위치. 포그라운드에 없을 때의 위치 접근은 별도 권한(ACCESS_BACKGROUND_LOCATION)이 필요하고, Android는 이를 다른 권한과 한 다이얼로그에서 받을 수 없게 해 두었다 — 전경 위치를 먼저 받고 설정 화면을 통해 따로 받아야 한다. iOS도 "앱을 사용하는 동안"에서 "항상"으로의 승격을 따로 확인한다. 검증 대상은 "항상 허용"이 아닌 상태에서 그 기능이 어떻게 동작한다고 사용자에게 말하는가 다. 조용히 아무 일도 안 일어나는 것이 가장 나쁘다.
실무에서 왜 필요한가
이 세 기능은 매출과 직결되면서 가장 늦게 발견되는 결함군이다. 딥링크가 깨지면 광고비를 쓴 캠페인의 유입이 통째로 홈으로 흘러 전환이 사라지는데, 앱 안에서는 아무 오류도 보이지 않으니 기능 테스트만으로는 잡히지 않는다. 푸시 미수신은 리텐션을 서서히 갉아먹고, "특정 기종에서만"이라는 형태라 CS 문의가 쌓이기 전까지 드러나지 않는다.
백그라운드 제약은 개발팀과의 커뮤니케이션 도구로서 값이 있다. "밤사이 동기화가 안 돼요"라는 제보에 dumpsys deviceidle force-idle 로 Doze를 재현해 "유지보수 창에서만 실행된다"를 보여 주면, 팀의 대화가 "버그 찾기"에서 "요구사항이 절전 정책과 맞는가"로 바뀐다. 국내 환경에서는 제조사 ROM의 추가 절전 정책이 변수를 하나 더 얹으므로, 표준 동작으로 설명되지 않는 미수신은 기종·ROM을 축으로 다시 나눠야 한다 — 그래서 디바이스 매트릭스가 필요해지고 다음 레슨으로 이어진다.
채용시장에서의 위치
면접에서 "딥링크가 앱 대신 브라우저로 열립니다. 무엇부터 확인하시겠어요?"는 모바일 QA 실무 질문의 전형이다. assetlinks.json 과 서명 지문, pm get-app-links 의 검증 상태, AASA의 리디렉션 금지 — 이 셋을 순서대로 말할 수 있으면 출시를 겪어 본 사람으로 읽힌다. 푸시 쪽은 알림 메시지와 데이터 메시지의 차이가 백그라운드에서 만드는 결과를 설명할 수 있는지가 분기점이다.