개발 slecs

원격 지원 WebSocket 연결 안정성과 Android 14 화면 공유

목차

원격 지원 기능을 손보면서 생각보다 건드려야 할 부분이 많았다. 겉으로는 "핑 메시지 추가"처럼 작은 변경이지만, WebSocket 연결의 생명주기를 제대로 다루려면 신경 쓸 게 적지 않다.

WebSocket 연결 안정성 - 핑과 타임아웃

원격 지원 기능의 연결 구조는 단순하다.

관리자 브라우저 <-> WebSocket 서버 <-> Android 

관리자 브라우저와 서버 사이, 서버와 Android 앱 사이에 각각 WebSocket 연결이 유지된다. 관리자가 실시간으로 화면을 보면서 기기 상태를 확인하는 구조다 보니, 연결이 조용히 끊겨버리는 상황이 제일 골치 아프다. 에러도 안 뜨고 데이터도 안 오는데 UI는 "연결됨" 상태를 보여주고 있는 케이스.

이게 생기는 근본 원인은 네트워크 장비다. NAT 장비, 방화벽, 로드밸런서 대부분은 일정 시간 동안 트래픽이 없는 연결을 조용히 끊어버린다. WebSocket은 TCP 위에 있으니 상위 레이어에서 별도로 "나 살아있음"을 알려줘야 한다. 그게 ping/pong이다.

WebSocket 프로토콜 자체에는 RFC 6455 기준으로 제어 프레임으로서 ping/pong이 정의되어 있는데, 라이브러리마다 지원 방식이 다르다. Spring WebSocket 기준으로 TextMessage 방식을 쓰면 이렇게 된다.

// 서버 → 클라이언트 핑 (30초 주기)
@Scheduled(fixedDelay = 30000)
public void sendPing() {
    sessions.forEach((id, session) -> {
        if (session.isOpen()) {
            try {
                session.sendMessage(new TextMessage("ping"));
            } catch (IOException e) {
                sessions.remove(id);
            }
        }
    });
}

// 클라이언트에서 pong 수신 시 타임스탬프 갱신
// 마지막 pong 시각 기준으로 타임아웃 판단

타임아웃 값 결정이 생각보다 까다롭다. 짧게 잡으면 모바일 네트워크처럼 잠깐 끊겼다 붙는 환경에서 정상 연결을 죽여버린다. 길게 잡으면 이미 죽은 연결이 서버 메모리에 좀비처럼 쌓인다. 실험한 결과 아래 기준이 무난했다.

항목 설정값 비고
핑 전송 주기 30초 장비 유휴 타임아웃(보통 60-120초)보다 짧게
응답 대기 시간 10초 핑 후 pong 없으면 미수신 카운트 증가
허용 미수신 횟수 2회 초과 시 연결 종료 후 재연결 트리거

"너무 공격적이지 않게, 근데 좀비는 빠르게 정리"가 기준이다. 환경마다 다르겠지만 핑 주기를 타임아웃의 절반 이하로 잡고, 미수신 허용을 1회보다 2회로 두는 게 일시적 네트워크 지연에 더 관대하다.

작업 규모 자체는 크지 않았지만 이런 게 없으면 관리자 입장에서 화면이 그냥 멈춰있는 걸 보고 판단을 잘못 내리기 쉽다. 운영 안정성 측면에서 필요한 수정이었다.

Android 14 MediaProjection 재사용 불가 이슈

화면 공유 기능에서 Android 14 이후 크래시가 발생하는 문제가 있었다. 원인은 MediaProjection 인스턴스 재사용 방식이 14부터 동작 방식이 바뀐 것.

Android 13 이하에서는 사용자가 화면 공유 권한을 허용한 후 받은 MediaProjection 인스턴스를 유지했다가 다시 써도 됐다. 그런데 14부터는 이전 세션을 명시적으로 stop() 하고 새로 퍼미션 요청을 해야 한다. 기존 인스턴스 들고 있다가 재사용하면 크래시.

if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
    // Android 14+ - 기존 프로젝션 명시적 종료 후 재요청
    if (mediaProjection != null) {
        mediaProjection.stop();
        mediaProjection = null;
    }
    startActivityForResult(
        mediaProjectionManager.createScreenCaptureIntent(),
        REQUEST_MEDIA_PROJECTION
    );
} else {
    // 13 이하 - 기존 인스턴스 재사용 가능
    startCapture(mediaProjection);
}

버전 분기를 넣는 것도 방법이고, 아예 매번 새로 요청하는 방향으로 통일하는 것도 방법이다. 후자가 유지보수 면에서 깔끔하긴 한데, 매 세션마다 권한 다이얼로그가 뜨는 건 UX상 거슬릴 수 있다. 이 기능이 관리자용 내부 도구라면 그 부분은 수용 가능한 트레이드오프다. 반대로 일반 사용자 대상 앱이라면 버전 분기를 유지하는 쪽이 낫다.

NotificationListenerService와 Android 13+ 알림 권한

NotificationListenerService는 시스템이 메모리 부족 등으로 강제 종료할 수 있다. onStartCommand에서 START_STICKY를 반환하면 자원이 확보됐을 때 시스템이 자동으로 재시작해준다.

@Override
public int onStartCommand(Intent intent, int flags, int startId) {
    return START_STICKY;
}

Android 13부터 알림 권한(POST_NOTIFICATIONS)이 런타임 요청으로 바뀌었다. 앱 첫 실행 때 다이얼로그를 직접 띄워줘야 하는데, 이걸 빠뜨리면 Android 13+ 기기에서 알림을 아예 못 받는 상태로 조용히 실패한다. 에러 로그도 없이 그냥 동작을 안 하는 케이스라 잡기 귀찮다.

여기서 한 가지 혼동하기 쉬운 지점이 있다.

  • POST_NOTIFICATIONS 권한 - AndroidManifest 선언 + 런타임 requestPermissions 호출로 요청 가능
  • NotificationListenerService 접근 권한 - 설정 > 알림 접근 권한에서 사용자가 수동으로 켜야 함, 런타임 요청 불가

두 가지가 완전히 다른 레이어다. 전자는 코드로 요청 가능하고 후자는 사용자를 설정 화면으로 유도하는 것 외에 방법이 없다. 혼동하면 엉뚱한 곳만 디버깅하다 시간 쓴다.

배포 채널이 구글 플레이가 아니라 APK 직접 배포라 업데이트 체크도 자체 서버 API로 처리하고 있다. 앱 실행 시 현재 버전과 서버가 내려주는 최신 버전을 비교해서 업데이트가 있으면 다운로드를 유도하는 구조다. 플레이스토어 자동 업데이트가 없으니 이걸 빠뜨리면 현장에 구버전이 계속 돌아간다.

이번 작업 전반을 돌아보면, Android 버전 파편화 대응이 생각보다 지속적인 비용이라는 걸 다시 느꼈다. 13, 14 각각 조용히 행동이 바뀌는 부분들이 있어서 새 버전 출시 때마다 릴리즈 노트를 한 번 훑는 습관이 필요하다. 크래시 로그가 남는 케이스는 그나마 낫고, 로그 없이 그냥 동작이 안 되는 케이스가 훨씬 더 잡기 어렵다.

댓글 0

첫 댓글 달아줘.