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

토큰을 쿠키로 주지 않는 WebView 앱이었다
앱은 웹 파일 화면을 WebView로 표시했다. 로그인 토큰은 WebView의 쿠키, DOM 저장소, URL, JavaScript에 전달하지 않았고 쿠키도 꺼 두었다. JSON API 호출은 JavaScript 브리지를 거쳐 네이티브가 Bearer 토큰을 붙였다.
문제는 브리지를 거치지 않는 요청이었다. 썸네일, 사진 뷰어, 영상은 페이지가 주소만 넣으면 WebView가 직접 가져간다. 쿠키도 헤더도 없으니 이런 요청에는 인증을 실을 방법이 없었다.

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를 그대로 다시 불렀다. 권한은 앱 표시가 아니라 서버에서 검사한다.

앱 시작 테스트는 뜻이 정반대가 됐다. 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이면 한 번만 갱신하고 다시 요청한다.

사진도 같은 통로를 쓴다. 썸네일은 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
'프로젝트 > 사내 클라우드 스토리지' 카테고리의 다른 글
| 이관 기록과 현재 운영 지침을 나누고, 소스 55개를 원본 SHA-256으로 대조했다 (0) | 2026.10.19 |
|---|---|
| RLock 파일 락이 동시 다운로드를 막던 구조, 읽기·쓰기 락으로 바꾼 과정 (0) | 2026.10.17 |
| Android 16에서 깨진 로그인, 범인은 OS가 아니라 오래된 주소였다 (0) | 2026.10.17 |
| 읽기 전용 공유, 버튼 하나가 아니었던 날 (0) | 2026.10.16 |
| DownloadManager 다운로드 목록 복원과 늦은 취소의 함정 (0) | 2026.10.15 |