본문으로 건너뛰기
중급약 40분

딥링크·푸시 알림·백그라운드 작업 테스트 — OS가 중간에 끼어드는 기능 검증하기

마케팅이 뿌린 링크가 브라우저로 열리고, 푸시가 특정 기종에서만 안 오고, 예약한 동기화가 밤새 실행되지 않는다. 셋 다 앱 코드가 아니라 OS의 판단이 결과를 바꾸는 영역이다. App Links·Universal Links의 도메인 검증 파일을 직접 확인하고, 미설치·백그라운드·콜드 스타트별 딥링크 도착 경로를 매트릭스로 나누며, 앱 상태별 푸시 수신과 탭 동작, Doze·앱 대기 버킷이 백그라운드 작업을 지연시키는 조건까지 실행 가능한 절차로 검증한다.

  • #딥링크
  • #AppLinks
  • #UniversalLinks
  • #푸시알림
  • #Doze
  • #백그라운드제약

개념

이 세 가지를 한 레슨에 묶는 이유

세 기능은 테스트 관점에서 같은 문제다. 앱이 스스로 결정하지 못하고 OS가 끼어들어 결과를 바꾼다. 링크를 앱으로 보낼지는 도메인 검증 결과가, 알림을 언제 배달할지는 권한 상태와 절전 정책이, 예약 작업의 실행 시점은 Doze와 앱 대기 버킷이 정한다. 그래서 "안 됩니다" 한 줄짜리 리포트는 쓸모가 없다 — OS가 어떤 상태였는지 를 같이 적어야 재현이 된다. 모듈 17 레슨 2의 플랫폼별 알림 관습과 이 모듈 레슨 2의 POST_NOTIFICATIONS 권한 위에 배달 경로와 실행 시점 을 얹는다.

딥링크의 세 층위를 구분한다

"딥링크"라는 한 단어에 세 가지가 섞여 있다. 커스텀 스킴(myapp://)은 앱이 임의로 선언하므로 같은 스킴을 선언한 다른 앱이 가로챌 수 있고, 미설치 사용자에게는 아무것도 보여 주지 못한다. Android App Links 와 iOS Universal Links 는 https:// 링크에 도메인 소유 검증을 붙인 것으로, 가로채기가 불가능하고 앱이 없으면 브라우저가 그 웹페이지를 연다.

즉 검증된 https 링크만이 "브라우저를 거치지 않는다"는 보장을 준다. "앱이 있으면 앱으로, 없으면 웹으로"가 요구사항이면 App Links / Universal 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 쪽 이름은 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의 리디렉션 금지 — 이 셋을 순서대로 말할 수 있으면 출시를 겪어 본 사람으로 읽힌다. 푸시 쪽은 알림 메시지와 데이터 메시지의 차이가 백그라운드에서 만드는 결과를 설명할 수 있는지가 분기점이다.

실습 과제

과제 1 — 검증 파일 확인과 딥링크 도착 경로 매트릭스 (35분)

먼저 대상 서비스의 .well-known/assetlinks.json 과 .well-known/apple-app-site-association 을 curl -sSI 로 받아 상태 코드·리디렉션·Content-Type 을 표로 적는다. 그 다음 D1~D8 중 기기에서 재현 가능한 것을 돌린다. 최소 D2(콜드 스타트)·D3(백그라운드)·D5(로그인 복귀)는 포함한다.

adb shell am force-stop <패키지>                       # D2 상태 만들기
adb shell am start -a android.intent.action.VIEW \
  -c android.intent.category.BROWSABLE -d "<링크>"

각 행마다 기대 동작과 실제 동작을 적고 어긋난 것을 결함으로 쓴다. 결함에는 앱 상태 를 한 줄로 명시한다.

과제 2 — Doze에서 작업이 어떻게 밀리는지 관찰 (25분)

백그라운드 동기화나 예약 작업이 있는 앱을 골라 아래를 실행하고 작업 실행 시각을 기록한다.

adb shell dumpsys battery unplug
adb shell dumpsys jobscheduler | grep -A 20 <패키지>    # 진입 전
adb shell dumpsys deviceidle force-idle                 # 5~10분 대기
adb shell dumpsys jobscheduler | grep -A 20 <패키지>    # 연기되었는지
adb shell dumpsys deviceidle unforce && adb shell dumpsys battery reset

관찰 결과로 "이 기능의 요구사항이 절전 정책과 충돌하는가"를 두세 문장으로 판단한다. 충돌한다면 대안(높은 우선순위 메시지, 알람 종류 변경, 포그라운드 서비스, 요구 완화) 하나를 골라 근거를 적는다.

자가 체크리스트

  • 커스텀 스킴·App Links·Universal Links의 차이와 미설치 시 동작을 설명할 수 있다
  • assetlinks.json 의 경로·요건과 서명 지문이 어긋날 때의 증상을 말할 수 있다
  • AASA 파일의 경로·리디렉션 금지·Content-Type 요건으로 실패 원인을 좁힐 수 있다
  • adb shell pm get-app-links 로 도메인 검증 상태를 읽고 판정할 수 있다
  • 딥링크 도착 경로를 앱 상태별로 나눠 테스트할 수 있다
  • 알림 메시지와 데이터 메시지가 백그라운드에서 만드는 차이를 설명할 수 있다
  • 푸시 미수신 원인을 권한·채널·메시지 유형·앱 상태·절전 정책으로 분리할 수 있다
  • dumpsys deviceidle force-idle 로 Doze를 재현하고 작업 연기를 관찰할 수 있다
  • 앱 대기 버킷 다섯 단계를 순서대로 말하고 restricted 의 제한을 설명할 수 있다

흔한 실수

  • 디버그 빌드로만 딥링크를 테스트한다. 서명 지문이 달라 릴리스 빌드에서만 브라우저로 열리는 문제가 출시 후에 드러난다. 내부 테스트 트랙 빌드 로 한 번 더 한다.
  • AASA 파일을 리디렉션 뒤에 둔다. www 강제 리디렉션이나 CDN 규칙 하나로 Universal Links 전체가 죽는다.
  • 푸시를 "받았다/못 받았다"로만 기록한다. 권한·채널·메시지 유형·앱 상태·기종 중 무엇이 원인인지 못 좁힌다. 리포트 서식에 이 다섯 줄을 고정으로 넣는다. 권한이 허용이어도 개별 채널이 꺼져 있으면 알림은 뜨지 않는다.
  • 최근 앱에서 밀어 없앤 뒤의 동작을 안 본다. 실사용자가 가장 자주 만드는 상태인데 테스트에서는 거의 안 만든다. N4를 정규 시나리오에 넣는다.
  • Doze 지연을 전부 버그로 보고한다. 의도된 절전 동작인 경우가 많다. "요구사항과 정책 중 무엇을 바꿀 것인가"를 질문으로 올린다.
  • 딥링크 파라미터를 외부 입력으로 취급하지 않는다. 조작된 파라미터로 권한 밖 화면·데이터에 접근되는지 D7을 반드시 돌린다.

참고 자료

낯선 말 풀이

이 레슨에 나온 약어입니다. 본문에서도 처음 나올 때 점선으로 표시해 두었습니다 (제목과 링크 글자는 그대로 둡니다).

OS — 운영체제
기기를 돌리는 바탕 프로그램 — 윈도우, 맥, 안드로이드 같은 것.
HTTPS — 암호화된 웹 전송
웹 통신을 암호로 감싼 것. 중간에서 누가 들여다봐도 내용을 못 읽는다.
QA — 품질보증
만든 것이 쓸 만한지 확인하고, 애초에 덜 망가지게 만드는 방법을 정하는 일. "테스트"는 그 일의 일부다.
API — 응용 프로그램 인터페이스
프로그램끼리 주고받는 창구. 사람이 화면 버튼을 누르는 대신, 프로그램이 정해진 주소로 요청을 보내 답을 받는다.
CDN — 콘텐츠 전송 망
이미지나 파일을 세계 여러 곳에 미리 복사해 두고 가까운 곳에서 내려 주는 구조.
SDK — 소프트웨어 개발 도구 모음
어떤 기능을 쓰려고 할 때 필요한 도구와 코드를 묶어 둔 꾸러미.
CS — 고객 지원 · 칩 선택 신호
이 커리큘럼에서 두 가지로 쓰인다. ① 고객 문의와 불만을 직접 받는 조직 ② 여러 부품이 한 선을 나눠 쓸 때 "지금은 네 차례" 라고 하나를 고르는 신호. 앞뒤에 문의·고객이 있으면 ①, 통신·배선 이야기면 ②다.

용어집 전체 보기