본문 바로가기
프로젝트/사내 클라우드 스토리지

WebView 미디어 인증 중계 11시간 뒤, 화면을 Compose로 갈아엎었다

by iwbap 2026. 10. 21.
728x90
반응형

사내 클라우드 스토리지 앱의 WebView 미디어 요청에 인증 중계를 추가했다. 같은 날 밤 화면을 Jetpack Compose로 바꿨지만, 낮에 만든 중계 코드는 네이티브 영상 경로에서도 재사용됐다. 변경 과정과 검증 범위를 정리했다.

어두운 배경에 WebView 미디어 인증 중계 11시간 뒤 Compose로라는 제목과, 10월 1일 12시 45분 1.0.1 미디어 인증 중계부터 17시 40분 1.1.0, 21시 39분 1.1.1을 거쳐 23시 58분 1.2.0 Compose 전환까지 이어지는 버전 타임라인을 그린 대표 이미지
낮에 WebView용으로 만든 미디어 중계 코드가, 밤의 Compose 화면에서도 그대로 쓰였다

토큰을 쿠키로 주지 않는 WebView 앱이었다

앱은 웹 파일 화면을 WebView로 표시했다. 로그인 토큰은 WebView의 쿠키, DOM 저장소, URL, JavaScript에 전달하지 않았고 쿠키도 꺼 두었다. JSON API 호출은 JavaScript 브리지를 거쳐 네이티브가 Bearer 토큰을 붙였다.

문제는 브리지를 거치지 않는 요청이었다. 썸네일, 사진 뷰어, 영상은 페이지가 주소만 넣으면 WebView가 직접 가져간다. 쿠키도 헤더도 없으니 이런 요청에는 인증을 실을 방법이 없었다.

쿠키와 토큰이 없는 WebView 파일 화면에서 JSON API는 JavaScript 브리지를 거쳐 네이티브가 Bearer 토큰을 붙이지만, 이미지와 영상 주소는 WebView가 직접 요청해 인증이 빠지고, 1.0.1부터 shouldInterceptRequest가 허용된 미디어 요청만 가로채 토큰과 Range 헤더를 붙여 대신 받아 오는 흐름을 비교한 개념 도식

10월 1일 12시 45분, shouldInterceptRequest로 미디어 요청을 가로채는 커밋을 올렸다. 허용된 주소만 네이티브 연결로 대신 받아 오는 방식이다.

override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
    if (!CloudMediaTransport.accepts(request.url.toString(), request.method)) return null
    val response = CloudMediaTransport.open(request.url.toString(), request.method,
        request.requestHeaders, session?.accessToken.orEmpty())
    if (response.code == 401) runOnUiThread { refreshAndReload() }
    return WebResourceResponse(response.mime, null, response.code, response.reason, response.headers, response.body)
}

허용되지 않는 요청은 null을 반환해 WebView가 처리하게 했다. 응답 본문은 메모리에 전부 올리지 않고 스트림으로 전달했다.

  • GET과 HEAD만 허용하고 HTTPS 및 설정된 서버의 호스트·포트까지 일치시킨다.
  • 사용자 정보, 쿼리 문자열, 프래그먼트가 있는 주소는 거부한다.
  • 32자리 16진수 파일 ID와 thumbnail, preview, stream 경로만 허용한다.
  • Range와 If-Range만 전달하고 리다이렉트는 따라가지 않는다. 3xx 응답은 502로 처리한다.

단위 테스트 5개로 이 규칙을 고정했다. 가짜 Authorization과 Cookie는 전달되지 않았고, 빈 토큰으로는 네트워크에 닿지 않았다.

11시간 뒤 WebView를 걷어냈다

17시 40분에는 인앱 로그인과 WebView 두 개를 확인하는 StartupTest를 추가했다. 21시 39분에는 인증 앱 실행 흐름을 고쳤고, 23시 58분에는 Compose 1.2.0으로 전환했다.

미디어 인증 커밋에서 11시간 12분 38초 뒤였다. 전환 이유는 기록에 없다. 31개 파일에서 1,976줄을 더하고 756줄을 지웠고, MainActivity에서만 650줄이 빠졌다.

로그인부터 관리자 화면까지 Compose로 새로 만들었지만 서버 API는 바꾸지 않았다. 웹 화면이 쓰던 API를 그대로 다시 불렀다. 권한은 앱 표시가 아니라 서버에서 검사한다.

1.1.1의 로그인 WebView와 파일 화면 WebView, 미디어 가로채기와 JS 브리지 구조가 1.2.0에서 Compose 화면과 썸네일 읽기, 영상 데이터 소스로 바뀌고, 두 버전 모두 변경 없는 미디어 중계 계층을 거쳐 서버에 요청하는 구조를 나란히 비교한 도식

앱 시작 테스트는 뜻이 정반대가 됐다. 6시간 전에 WebView 두 개를 확인하려고 넣은 테스트였다. 이제는 WebView가 하나도 없어야 통과한다.

-val webViews = (0 until root.childCount).map(root::getChildAt).filterIsInstance<WebView>()
-assertEquals(2, webViews.size)
+fun containsWebView(view: View): Boolean = view is WebView ||
+    (view is android.view.ViewGroup && (0 until view.childCount).any { containsWebView(view.getChildAt(it)) })
+assertTrue(root is androidx.compose.ui.platform.ComposeView)
+assertTrue(!containsWebView(root))

기존 중계 코드를 네이티브 영상에 재사용했다

낮에 만든 CloudMediaTransport는 1.2.0 커밋에서 한 줄도 바뀌지 않았다. 미디어 요청은 계속 이 코드를 거쳤다.

영상은 MediaPlayer에 MediaDataSource를 연결해 재생했다. 플레이어가 요청한 위치부터 최대 2MiB를 Range 요청으로 받도록 했다.

val end = minOf(entry.size - 1, position + 2*1024*1024 - 1)
var response = CloudMediaTransport.open(url, "GET", mapOf("Range" to "bytes=$position-$end"), session.accessToken)
if (response.code == 401) {
    response.body.close()
    session = runBlocking { auth.refresh(session.refreshToken, session.accessToken) }
    response = CloudMediaTransport.open(url, "GET", mapOf("Range" to "bytes=$position-$end"), session.accessToken)
}
if (response.code != 206 || response.headers.entries.firstOrNull { it.key.equals("Content-Range", true) }?.value
        ?.startsWith("bytes $position-") != true) throw IOException("Video range unavailable")

206과 요청한 위치의 Content-Range가 올 때만 조각을 쓴다. 받은 블록은 들고 있다가, 다음 읽기가 그 안이면 네트워크 없이 돌려준다. 401이면 한 번만 갱신하고 다시 요청한다.

MediaDataSource의 readAt이 요청한 위치가 캐시 블록 안이면 바로 돌려주고, 밖이면 그 위치부터 2MiB를 Range 요청으로 받아 206 응답과 시작 위치가 맞는 Content-Range일 때만 쓰며, 401이면 토큰을 한 번 갱신해 다시 요청하는 과정을 그린 개념 도식

사진도 같은 통로를 쓴다. 썸네일은 2MiB, 미리보기는 24MiB까지만 읽고 긴 변을 각각 320과 2048 이하로 줄여 디코딩한다. HTTP 202면 해당 이미지만 최대 20회 재시도한다.

로그아웃 전에 시작한 토큰 갱신도 보호했다. 갱신 시작 당시의 세션이 저장소에 그대로 있을 때만 새 토큰을 저장한다. 늦게 온 응답이 로그아웃된 세션을 되살리지 못하게 했다.

검증 결과와 남은 범위

  • 디버그 APK, 서명 전 릴리스 APK, 테스트 APK 빌드 성공
  • JVM 단위 테스트 16개 통과, lint 오류 0개와 경고 34개
  • Android 16(API 36) 에뮬레이터 계측 테스트 11개 중 8개 통과, E2E 3개는 기본 실행에서 건너뜀
  • 별도 E2E에서 TXT 업로드·다운로드 바이트 비교와 테스트 영상의 구간 재생 등을 확인

실기기 설치, 실제 릴리스 서명 업데이트, Play Store 배포와 관리자 용량 설정의 실제 반영은 검증하지 않았다. 산출물은 디버그 서명 테스트 APK였다.

이번에 배운 것

  • WebView의 이미지·영상 요청은 JSON API와 인증 경로가 다를 수 있다.
  • 미디어 중계는 주소, 헤더, 리다이렉트를 좁게 제한하고 테스트해야 한다.
  • 네트워크 계층을 화면과 분리하면 UI 전환 뒤에도 재사용할 수 있다.

자주 묻는 질문

Q. WebView에서 쿠키 없이 이미지 요청에 인증을 붙일 수 있나?
A. shouldInterceptRequest에서 허용된 미디어 주소만 가로채고, 네이티브 연결에 Bearer 토큰을 붙여 받아 왔다.

Q. 인증이 필요한 영상을 MediaPlayer로 구간 재생하려면?
A. MediaDataSource로 요청한 위치부터 Range 요청을 보냈다. 토큰은 URL에 넣지 않았다.

관련 글: Android 16에서 깨진 로그인, 범인은 OS가 아니라 오래된 주소였다
참고: Android Developers: MediaDataSource

728x90
반응형