원격지원 WebSocket 안정성과 Android 화면공유 크래시 개선
목차
원격지원 기능을 붙이는 작업 자체는 그렇게 크지 않았는데, 막상 뜯어보니 WebSocket 안정성 쪽에 손봐야 할 게 꽤 있었다. 관리자가 브라우저에서 실시간으로 Android 기기 화면을 확인하는 구조라서, 연결이 한 번 뚝 끊기면 그냥 끝이다. 재연결 로직이 없으면 관리자가 직접 새로고침해야 하고, 그 사이에 기기 상태가 어떻게 바뀌었는지 알 방법이 없다.
WebSocket 연결 유지 전략
기본 구조는 단순하다.
관리자 브라우저 <-> WebSocket 서버 <-> Android 앱
문제는 이 체인에서 어느 구간이든 조용히 끊어질 수 있다는 거다. 네트워크 레벨에서는 연결이 살아있는 척 하면서 실제로는 아무것도 안 오가는 상태가 생긴다. TCP keepalive로 일부 커버가 되지만, 애플리케이션 레벨에서 따로 확인하는 게 훨씬 확실하다.
그래서 핑/퐁 메시지를 주기적으로 보내도록 했다.
// 핑 메시지 전송
session.sendMessage(new TextMessage("ping"));
서버 쪽에서 일정 시간 내에 응답이 없으면 연결이 끊긴 걸로 판단하고 세션을 정리한다. Android 쪽은 pong 응답을 받지 못하면 재연결을 시도하는 흐름이다.
타임아웃 값 설정이 생각보다 까다롭다. 너무 짧게 잡으면 네트워크가 잠깐 느려지는 것만으로도 정상 세션이 끊겨버리고, 너무 길면 이미 죽은 연결이 서버에 좀비처럼 남아서 리소스를 먹는다. 딱 맞는 값이라는 게 없고, 실제 환경에서 네트워크 지연 패턴을 보면서 조정하는 수밖에 없다. 모바일 환경 기준으로는 보통 30~60초 사이에서 시작하고, 운영 지표 보면서 줄이거나 늘린다.
| 타임아웃 구간 | 장점 | 단점 |
|---|---|---|
| 짧게 (5~15s) | 좀비 연결 빠르게 정리 | 일시적 지연으로 정상 세션 끊김 |
| 중간 (30~60s) | 네트워크 변동 어느 정도 흡수 | 죽은 연결이 수십 초간 남을 수 있음 |
| 길게 (120s+) | 불안정 망에서 세션 유지에 유리 | 서버 리소스 낭비, 감지 지연 |
재연결 로직도 즉시 재시도하면 안 된다. 서버가 순간 과부하 상태이거나 네트워크 복구가 덜 됐을 때 클라이언트들이 동시에 폭격하는 thundering herd 문제가 생긴다. 지수 백오프(exponential backoff)로 간격을 점점 늘려가면서 재시도하는 게 기본이고, 여기에 약간의 jitter를 더하면 클라이언트들의 재시도 타이밍이 자연스럽게 분산된다.
NotificationListenerService 생존 전략
NotificationListenerService는 시스템 권한으로 알림을 가로채는 서비스라 OS가 마음대로 죽일 수 있다. 배터리 최적화가 발동하거나 시스템이 재시작되면 자동 종료된다.
onStartCommand에서 START_STICKY를 반환하는 건 기본 중의 기본이다.
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
return START_STICKY;
}
이렇게 해두면 시스템이 강제 종료해도 서비스를 재시작해준다. 단, START_STICKY는 재시작을 보장하지 않는다. 시스템 자원이 극도로 부족한 상황이면 재시작이 한참 뒤로 밀릴 수 있다. 포그라운드 서비스로 올려두면 생존율이 훨씬 높아지지만, 사용자 화면에 상시 알림이 붙는다. 어느 쪽이 나은지는 제품 방향에 따라 다르고, 지금 구조에서는 START_STICKY 선에서 처리하기로 했다.
Android 13부터 알림 권한이 런타임 요청으로 바뀐 것도 챙겼다. 기존에는 설치하면 곧바로 알림에 접근할 수 있었는데, 이제는 앱 최초 실행 시 POST_NOTIFICATIONS 권한을 명시적으로 요청해야 한다. 권한 다이얼로그 타이밍을 잘못 잡으면 사용자가 맥락 없이 거부해버리는 경우가 많다. 시스템 다이얼로그를 바로 띄우는 것보다, 권한이 왜 필요한지 설명하는 인앱 화면을 먼저 보여주고 그 다음에 시스템 다이얼로그를 여는 흐름이 전환율이 낫다.
배포 채널이 구글 플레이가 아닌 APK 직접 배포라서 자동 업데이트가 없다. 서버 API로 버전을 체크해서 새 버전이 있으면 앱 내에서 안내를 하는 방식으로 구현해둔 상태인데, 이 업데이트 채널을 얼마나 촘촘히 만드냐가 플레이 스토어 없이 앱을 관리할 때의 운영 편의성을 많이 좌우한다.
Android 14 MediaProjection 크래시
화면공유를 구현할 때 쓰는 MediaProjection은 Android 14부터 동작 방식이 바뀌었다. 이전까지는 한 번 발급받은 인스턴스를 다음 세션에서도 재사용할 수 있었는데, 14+에서는 그 방식이 크래시를 일으킨다.
정확히는, 기존 세션이 종료된 후 같은 인스턴스를 다시 createVirtualDisplay에 넘기면 예외가 터진다. 14부터는 MediaProjection.Callback의 onStop()이 불리면 해당 projection은 완전히 만료된 걸로 봐야 한다. 이후 재사용하면 IllegalStateException이다.
처리 방식은 두 가지다.
- 버전 체크로 분기:
Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE조건으로 14+ 경로를 따로 처리. 이전 버전 코드를 그대로 유지할 수 있지만, 분기가 영구적으로 남는다. - 매번 새로 요청하는 방식으로 통일: 버전 무관하게 화면공유 시작 전에 항상 새 projection을 요청. 코드가 단순해지고, 이후 OS 버전에서 또 동작이 바뀌어도 덜 영향받는다.
두 번째가 낫다. 버전 분기는 시간이 지나면서 쌓이고, 어느 시점에서 테스트할 경우의 수가 너무 많아진다. 매번 새로 요청하면 권한 다이얼로그가 한 번 더 뜰 수 있는데, 화면공유 시작 시점에 뜨는 거라 맥락이 맞아서 크게 이상하지 않다.
작업 규모 자체는 작았다. 그런데 WebSocket 좀비 연결이나 MediaProjection 크래시 같은 건 재현 타이밍이 불규칙해서 놓치기 쉽다. 로컬에서 잘 돌아가다가 특정 Android 버전이나 네트워크 조건에서만 터지는 종류의 문제들. 이번처럼 OS 변경 사항을 미리 파악해서 선제적으로 처리해두는 게, 운영 중에 터지고 나서 원인 찾는 것보다 훨씬 덜 아프다.
댓글 0
첫 댓글 달아줘.