Android 14 대응으로 결제 알림 캡처 안정성 개선
목차
결제 알림을 받아서 파싱하고 서버로 전송하는 Android 앱인데, 이번 릴리스는 기능 추가 없이 운영 안정성에만 집중했다. Android 14/15 MediaProjection API 대응, WebSocket 재연결, FCM 서비스 개선이 주된 내용. 드라마틱한 변경은 아니지만 이걸 안 하면 OS 업데이트한 기기에서 조용히 무너지는 유형의 작업이다.
Android 버전 대응은 항상 "당장 터지지 않으면 미루게 되는" 범주에 들어간다. 미루다 보면 여러 버전이 한꺼번에 쌓이고, 그게 어느 순간 사용자가 OS 업데이트하는 타이밍에 터진다. 결제 알림처럼 실시간성과 정확성이 중요한 도메인에서 "업데이트 전까지는 됐는데"는 변명이 안 된다.
NotificationListenerService 구조와 Android 13+ 권한 처리
앱의 핵심 캡처 경로는 NotificationListenerService다. 알림이 도착하면 onNotificationPosted가 호출되고, 텍스트를 뽑아서 정규식으로 금액·은행명을 추출하는 흐름.
Android 13부터 알림 권한(POST_NOTIFICATIONS)이 런타임 요청으로 바뀌었음. 그 이전에는 앱 설치 시 자동으로 붙었지만, 지금은 사용자가 명시적으로 허용해야 한다. NotificationListenerService는 거기에 더해서 android.permission.BIND_NOTIFICATION_LISTENER_SERVICE라는 별도 바인딩 권한도 시스템 설정에서 따로 켜줘야 한다. 두 가지가 동시에 충족돼야 동작하는 구조라 권한 플로우를 두 단계로 나눠서 처리해야 했다. 런타임 권한 요청 → 리스너 설정 화면 안내 순서로.
private fun isNotificationListenerEnabled(): Boolean {
val flat = Settings.Secure.getString(
contentResolver,
"enabled_notification_listeners"
)
return flat?.contains(packageName) == true
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
return START_STICKY
}
START_STICKY는 OS가 서비스를 종료했을 때 인텐트 없이 재시작하도록 하는 옵션. 백그라운드 제한이 강화된 환경에서 이것만으로 생존이 보장되진 않지만, 알림 리스너 서비스는 시스템이 자체적으로 바인딩을 유지하려 하기 때문에 일반 포그라운드 서비스보다 생존율이 높은 편이다.
Android 14 MediaProjection 인스턴스 재사용 문제
MediaProjectionHelper를 손댄 이유는 Android 14(API 34)부터 화면 캡처 권한 정책이 바뀌었기 때문이다. 14 이전까지는 MediaProjection 인스턴스를 세션 동안 자유롭게 재사용할 수 있었는데, 14부터는 재사용 시 예외가 발생하는 케이스가 생겼다. 구체적으로는 화면 캡처 요청마다 사용자 확인 프롬프트를 요구하는 방향으로 정책이 강화됐고, 기존 인스턴스를 그냥 들고 있으면 안 되는 상황이 생긴다.
이번에 변경된 내용을 정리하면 이렇다:
| 항목 | 변경 전 | 변경 후 |
|---|---|---|
| MediaProjection 인스턴스 | 세션 내 자유 재사용 | Android 14+ 분기 후 재생성 로직 추가 |
| 권한 프롬프트 | 최초 1회 허용으로 유지 | 14+에서 매 요청 시 재요청 가능성 고려 |
| WebSocket 연결 | 재연결 로직 없음 | 지수 백오프 기반 재연결 보강 |
| FCM 서비스 | 단순 패스스루 구조 | 메시지 타입별 분기 처리 추가 |
MediaProjection을 직접 쓰는 앱이 많지 않아서 레퍼런스 찾기가 조금 귀찮았음. 결제 알림 파싱 실패 시 화면 내용을 캡처해서 분석하는 폴백 경로가 있어서 이 API가 필요한데, 14/15 기기에서 그 경로가 조용히 실패하고 있었던 거다. 에러가 명확하게 터지는 게 아니라 그냥 캡처 결과가 없는 식으로 죽어서 처음에 원인 파악이 좀 걸렸다.
WebSocket은 연결이 끊겼을 때 재연결을 안 하고 있었다. 원격 지원 쪽에 쓰이는 연결인데, 네트워크 전환이나 절전 복귀 후 세션이 그냥 죽어 있는 케이스가 있었음. 지수 백오프로 재시도하도록 보강하고, 연결 상태 변화를 더 명확하게 추적하는 로직을 붙였다.
파싱 실패 AI 폴백과 배포 키스토어 관리
정규식 파싱이 실패할 경우를 대비한 AI 폴백이 붙어 있다. 은행·핀테크 앱마다 알림 포맷이 제각각이고, 앱 업데이트로 포맷이 바뀌는 경우도 있어서 정규식만으로 전체 케이스를 커버하기가 어려운 구조다.
파싱 흐름은 이렇다:
- 알림 텍스트 수신 후 정규식으로 금액/은행명 추출 시도
- 성공하면 바로 서버 전송
- 실패하면 AI 호출로 구조화된 데이터 재추출 시도
- 재시도도 실패하면 원문 텍스트 그대로 서버에 넘기거나 드롭
AI 폴백이 있으면 커버리지가 올라가지만 지연이 생긴다. 결제 알림은 실시간성이 중요하기 때문에 폴백은 어디까지나 예외 케이스용으로만 두고, 정규식 패턴은 별도로 계속 관리해야 한다.
배포 서명 설정은 local.properties에 분리해서 레포에 올라가지 않게 하고 있다.
# local.properties — .gitignore 처리
KEYSTORE_PATH=/path/to/release.keystore
KEYSTORE_PASSWORD=...
KEY_ALIAS=...
KEY_PASSWORD=...
CI에서는 환경변수로 주입. keystore 파일 자체는 별도로 안전하게 보관해야 하고, 실수로 레포에 커밋됐다면 파일을 삭제하는 것보다 새 키로 교체하는 걸 권장한다. 다만 서명이 바뀌면 기존 앱의 업데이트가 불가능해지기 때문에, 실제로 키를 교체하면 Play Store에서 앱을 새로 올려야 하는 상황이 생긴다. 그래서 키 관리는 처음부터 꼼꼼하게 하는 게 낫다.
작업 규모는 크지 않았다. 그래도 이걸 안 하면 사용자가 OS 업데이트하는 순간 알림이 들어오지 않거나, 캡처 폴백이 터지거나, WebSocket이 죽은 채 방치되는 상황이 발생했을 거다. 작은 수정이 쌓이는 방식으로 시스템이 버텨준다.
댓글 0
첫 댓글 달아줘.