[Android] 푸시 알림 클릭 시 화면 이동 오류 및 PendingIntent 보안 설정 정리
앱을 운영하다 보면 푸시 알림을 보내는 것 자체보다, 사용자가 알림을 '클릭한 이후'에 생기는 버그 때문에 골치 아플 때가 많습니다.
상세 화면으로 바로 안 가고 메인 화면만 열린다거나, 이미 앱이 켜져 있는데 화면이 꼬이거나, 상세 화면에서 뒤로가기를 누르면 앱이 그냥 종료되는 현상 같은 것들입니다.
저의 경우도 예전에 알림 기능을 대충 구현했다가 Android 12 업데이트 이후 PendingIntent 크래시를 겪고 식은땀을 흘린 적이 있습니다. 알림 클릭 처리는 단순한 화면 이동이 아니라 내비게이션 스택, 사용자 인증, 보안 플래그까지 세트로 묶어서 관리해야 합니다. 자꾸 까먹어서 나중에 보려고 실무 기준으로 정리해 둡니다.
푸시 알림 클릭 시 자주 터지는 문제들
보통 알림 기능을 만들 때 아래와 같은 실수를 가장 많이 합니다.
모든 알림에 똑같은
requestCode를 써서, 마지막에 온 알림 데이터로 덮여버리는 현상FLAG_IMMUTABLE또는FLAG_MUTABLE을 명시하지 않아 Android 12 이상 기기에서 앱이 터지는 현상서버가 보내준 알림 payload(screen 값 등)를 검증 없이 그대로 Intent extras에 넣고 라우팅 데이터로 쓰는 경우 (보안 취약점)
알림을 누르고 상세 화면으로 들어갔는데, 뒤로가기를 누르면 메인 화면이 아니라 빈 화면이 나오거나 바탕화면으로 나가버리는 UX 오류
이미 앱이 실행 중인데 알림을 누를 때마다 Activity가 중복으로 계속 쌓이는 현상
해결 방법: 안전한 PendingIntent 생성 팩토리 코드
서버 URL, 사용자 식별자, FCM 토큰 같은 민감한 정보들을 로그나 예제 코드에서 다 걷어내고, 실무에서 안전하게 사용할 수 있도록 구조화한 팩토리 클래스 코드입니다.
서버에서 넘겨받은 payload 값을 먼저 검증(Allowlist 방식)한 뒤, 안전함이 확인된 데이터로만 PendingIntent를 생성하는 방식입니다.
data class NotificationRoute(
val screen: String,
val contentId: String,
)
class NotificationIntentFactory(
private val context: Context,
) {
fun createContentPendingIntent(route: NotificationRoute): PendingIntent {
// 1. 서버 payload 데이터 검증 (Allowlist 기반)
val safeRoute = sanitizeRoute(route)
// 2. 명시적 Intent 생성 및 중복 방지 플래그 설정
val intent = Intent(context, MainActivity::class.java).apply {
action = "com.example.app.OPEN_NOTIFICATION"
putExtra("screen", safeRoute.screen)
putExtra("contentId", safeRoute.contentId)
// 이미 실행 중인 Activity가 있으면 그 위의 스택을 비우고 기존 Activity 재사용
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
}
// 3. 알림별 고유 고유 ID 생성 (덮어쓰기 방지)
val requestCode = safeRoute.stableRequestCode()
// 4. Android 12 이상 필수 보안 플래그 지정 (기본값은 IMMUTABLE)
val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
return PendingIntent.getActivity(context, requestCode, intent, flags)
}
// 외부 입력값 검증 로직
private fun sanitizeRoute(route: NotificationRoute): NotificationRoute {
val allowedScreen = when (route.screen) {
"notice", "message", "reservation" -> route.screen
else -> "notice" // 허용되지 않은 화면 요청 시 기본 화면으로 튕김 처리
}
// ID 값 정규식 검증 (길이 및 허용 문자 제한)
val safeContentId = route.contentId
.takeIf { it.matches(Regex("^[0-9A-Za-z_-]{1,60}$")) }
?: ""
return NotificationRoute(allowedScreen, safeContentId)
}
// 데이터 조합으로 고유한 hashCode 생성
private fun NotificationRoute.stableRequestCode(): Int {
return "${screen}:${contentId}".hashCode()
}
}
핵심 설정 및 운영 포인트
1. FLAG_IMMUTABLE 과 FLAG_MUTABLE 선택 기준
Android 12(API 31) 이상을 타겟팅하는 앱은 PendingIntent 생성 시 mutability 플래그를 무조건 명시해야 합니다. 외부 시스템(알림 센터 등)이 내가 보낸 Intent의 내용을 변경할 필요가 없다면 무조건 FLAG_IMMUTABLE이 기본입니다.
| 플래그 | 사용 상황 | 주의할 점 |
| FLAG_IMMUTABLE | 일반적인 알림 클릭, 단순 상세 화면 이동 | 대부분의 알림 클릭에 이것을 사용합니다. |
| FLAG_MUTABLE | 알림창 내부에서 즉시 답장(Inline Reply), Bubbles 기능 등 | 시스템이 Intent 내용을 채워야 할 때만 제한적으로 사용합니다. |
| FLAG_UPDATE_CURRENT | 이미 생성된 PendingIntent가 있다면 extras 데이터만 갱신 | requestCode 설계와 함께 묶어서 생각해야 합니다. |
💡 운영 팁 (Android 14 대응)
Android 14(API 34)부터는 명시적 Intent가 아닌 암시적(Implicit) Intent를 mutable하게 만들면 보안 오류가 발생할 수 있습니다. 위 예시처럼 목적지 클래스(
MainActivity::class.java)를 명확히 지정하는 명시적 Intent를 쓰는 습관을 들여야 안전합니다.
2. requestCode를 고유하게 만들어야 하는 이유
많은 분들이 예제 코드를 보고 requestCode를 그냥 0으로 고정해서 씁니다. 이 상태에서 여러 개의 알림이 연속으로 오면, 시스템은 이 알림들을 다 같은 PendingIntent로 취급합니다.
결국 "A 상품 알림을 눌렀는데, 마지막에 온 B 상품 상세 화면이 열리는" 어처구니없는 배달 사고가 터집니다. 알림별로 각각 다른 상세 페이지를 보여줘야 한다면, 위의 예시처럼 contentId 등을 활용해 고유한 숫자를 뽑아내서 넣어줘야 합니다.
3. 알림 클릭 후 뒤로가기(Back Stack) 동선 설계
알림을 눌러 상세 화면으로 바로 진입했을 때, 사용자가 뒤로가기를 누르면 어디로 가야 할지 정책을 정해야 버벅거리는 느낌이 없습니다.
MainActivity 진입 후 내부 라우팅 (추천):
MainActivity로 무조건 먼저 진입시킨 뒤, Intent 내부의 extras 값을 읽어서 앱 내부(Compose, React Native 등) 내비게이션으로 라우팅하는 방식입니다. 흐름이 가장 일관되고 깔끔합니다.TaskStackBuilder 활용: 네이티브 다중 Activity 구조라면
TaskStackBuilder를 써서 상세 Activity가 열릴 때 부모 Activity(메인 등)를 스택에 강제로 쌓아주는 방식을 써야 뒤로가기를 눌렀을 때 메인 화면이 자연스럽게 나옵니다.
4. 사용자 인증 상태(로그인 여부) 대응
알림 payload는 화면 이동을 위한 힌트일 뿐, 그 자체로 권한을 증명하지 못합니다.
로그아웃 상태: 알림을 클릭했을 때 상세 화면을 바로 열면 안 되고, 로그인 화면으로 보낸 뒤 로그인이 완료되면 가려던 목적지(
pending route)로 이어지도록 처리해야 합니다.삭제되거나 권한이 없는 데이터: 알림을 누르고 들어갔는데 이미 지워진 글이거나 권한이 없다면 "삭제되었거나 확인할 수 없는 알림입니다." 같은 토스트나 얼럿 안내와 함께 메인 목록으로 튕겨주는 Fallback 처리가 필수적입니다.
구현 후 체크리스트
서비스 배포 전 아래 항목들은 꼭 기기에서 직접 테스트해보는 것이 좋습니다.
[ ] Android 12, 13, 14 버전별 기기에서 알림 클릭 시 앱 크래시가 나지 않는가?
[ ] 알림 여러 개가 동시에 왔을 때, 각각 누르면 해당하는 상세 화면으로 정확히 이동하는가?
[ ] 앱이 완전히 종료된 상태에서 알림을 누를 때와, 이미 켜져 있는 상태에서 누를 때 모두 정상 작동하는가?
[ ] 상세 화면에서 뒤로가기를 눌렀을 때 앱이 어색하게 종료되거나 빈 화면이 나오지 않는가?
[ ] 로그아웃 상태에서 알림을 누르면 로그인 화면으로 안전하게 차단되는가?
[ ] 코드나 로그에 FCM 토큰, 주소, 계정 정보 같은 민감 정보가 찍히지 않는가?
요약
Android 알림 클릭 처리는 단순히 PendingIntent 하나 만들어서 던지는 작업이 아닙니다. 외부 입력값인 payload를 엄격하게 검증하고, 안드로이드 버전별 보안 플래그와 requestCode 고유성을 보장해 주며, 뒤로가기 동선까지 고려해야 완성도 높은 앱이 됩니다.
처음 세팅할 때 조금 귀찮더라도 팩토리 구조로 뼈대를 잘 잡아두면 추후 알림 종류가 늘어나도 안정적으로 서비스를 운영할 수 있습니다.
댓글
댓글 쓰기