Android WebView 파일 다운로드 구현 (DownloadManager vs SAF 선택 기준과 보안 설정)

WebView 기반으로 앱을 빌드하고 운영하다 보면, 웹 페이지 내의 파일 다운로드 기능을 처리해야 할 때가 꼭 생깁니다.

처음에는 단순히 다운로드 링크를 누르면 알아서 받아지겠지 생각하기 쉽지만, 실제로 운영해보면 그렇지가 않습니다. 다운로드 URL 검증부터 파일명 정제, Android 버전별 저장소 정책, 보안 검수까지 사전에 챙겨야 할 항목들이 꽤 많습니다.

저의 경우도 예전에 대충 넘겼다가 구글 플레이 보안 관련 지적을 받거나, Android 버전이 올라가면서 저장소 권한 문제로 다운로드가 안 된다는 사용자 피드백을 받고 식은땀을 흘린 적이 있습니다. 자꾸 까먹기도 해서, 실무에서 바로 쓸 수 있도록 다운로드 구현 기준과 필수 체크리스트를 정리해 둡니다.

1. WebView 파일 다운로드 시 자주 겪는 문제들

보통 다운로드 기능을 대충 구현하면 아래와 같은 지점에서 문제가 터집니다.

  • URL 검증 미흡: 다운로드 URL을 검증하지 않고 그대로 DownloadManager에 넘기면, 알 수 없는 스킴이나 피싱 목적의 HTTP URL까지 처리하게 되어 보안상 위험합니다.

  • 파일명 오염: 서버가 내보낸 Content-Disposition 값을 그대로 믿고 쓰면, 파일 이름에 허용되지 않는 특수문자가 섞여 있거나 이름이 너무 길어서 저장 실패가 날 수 있습니다.

  • MIME Type 누락: 파일 타입을 제대로 지정하지 않으면 사용자가 다운로드 폴더에서 파일을 열 때 어떤 앱으로 열어야 하는지 기기가 인지하지 못합니다.

  • 저장소 권한 반려: Android 10(API 29) 이후 Scoped Storage 정책을 고려하지 않고 과거 방식대로 공용 저장소에 직접 쓰려고 하면 앱이 뻗거나 구글 검수에서 권한 과다 요청으로 반려됩니다.

  • 인증 토큰 노출: 다운로드 URL의 Query 스트링에 Access Token이나 개인 식별자를 그대로 태워 보내면, 시스템 로그나 알림 창, 브라우저 기록에 고스란히 남는 보안 취약점이 발생합니다.

운영 관점에서 파일 다운로드는 단순한 링크 이동이 아니라, '외부의 입력을 기기 내부 저장소에 안전하게 남기는 기능'으로 보고 엄격하게 다루어야 합니다.

2. 해결 코드: 검증 로직을 포함한 WebView 다운로드 핸들러

제가 실무에서 안전한 다운로드를 처리하기 위해 검증 로직을 추가해서 사용하는 Kotlin 구현 코드입니다. setDownloadListener()를 통해 들어오는 요청을 가로채서 안전하게 처리하는 방식입니다.

Kotlin
class WebDownloadHandler(
    private val context: Context,
    private val allowedHosts: Set<String>, // 허용된 도메인 리스트
) {
    fun attach(webView: WebView) {
        webView.setDownloadListener { url, userAgent, contentDisposition, mimeType, contentLength ->
            handleDownload(
                url = url,
                userAgent = userAgent,
                contentDisposition = contentDisposition,
                mimeType = mimeType,
                contentLength = contentLength,
            )
        }
    }

    private fun handleDownload(
        url: String,
        userAgent: String?,
        contentDisposition: String?,
        mimeType: String?,
        contentLength: Long,
    ) {
        val uri = Uri.parse(url)
        
        // 1. URL 보안 검증 (HTTPS 및 허용 도메인 체크)
        if (!isAllowedDownloadUrl(uri)) {
            showMessage("다운로드할 수 없는 안전하지 않은 링크입니다.")
            return
        }

        // 2. 안전한 파일명 추출 및 정제
        val safeFileName = URLUtil.guessFileName(
            url,
            contentDisposition,
            mimeType ?: "application/octet-stream",
        ).sanitizeFileName()

        // 3. System DownloadManager 요청 생성
        val request = DownloadManager.Request(uri).apply {
            setTitle(safeFileName)
            setDescription("파일을 다운로드 중입니다.")
            setMimeType(mimeType ?: "application/octet-stream")
            // 다운로드 완료 시 상단 알림창 표시 설정
            setNotificationVisibility(
                DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED,
            )
            addRequestHeader("User-Agent", userAgent ?: "")
        }

        val manager = context.getSystemService(DownloadManager::class.java)
        manager.enqueue(request)
        showMessage("다운로드를 시작했습니다.")
    }

    // 도메인 및 스킴 검증 알고리즘
    private fun isAllowedDownloadUrl(uri: Uri): Boolean {
        return uri.scheme == "https" && allowedHosts.contains(uri.host)
    }

    // 파일명 특수문자 치환 및 길이 제한 확장 함수
    private fun String.sanitizeFileName(): String {
        return replace(Regex("[\\\\/:*?\"<>|]"), "_")
            .take(120) // 시스템 안정성을 위한 파일명 길이 제한
            .ifBlank { "download_file" }
    }

    private fun showMessage(message: String) {
        Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
    }
}

3. 구현 방식 선택 기준 (DownloadManager vs SAF)

Android에서 파일을 저장하는 방식은 요구사항에 따라 명확히 갈라야 나중에 코드가 꼬이지 않습니다. 무조건 하나의 방식을 고집하기보다는 서비스 목적에 맞춰 선택하는 것이 좋습니다.

저장 방식적합한 상황장점운영 시 주의할 점
DownloadManager일반적인 웹 페이지 내 첨부파일 다운로드 (PDF, DOCX 등)시스템 백그라운드 다운로드 엔진 활용, 알림창 기본 제공URL, 파일명, MIME Type 사전 검증 필수
Storage Access Framework (SAF)사용자가 파일 이름과 저장할 폴더 위치를 직접 지정해야 할 때별도의 무리한 저장소 권한 요청 없이 사용자 선택 기반 저장 가능앱이 직접 스트림(Stream)을 열고 데이터를 쓰는 코드를 구현해야 해서 공수가 듦
외부 브라우저 연결앱 내 다운로드 관리가 불필요하고, 완벽히 웹 브라우저 정책에 맡길 때구현이 매우 단순함앱 내부 UX가 깨지고 외부 브라우저로 이탈 발생
앱 내부 전용 저장소임시 캐시 데이터나 앱 안에서만 조회해야 하는 보안 파일외부 노출이 차단되어 안전함사용자가 일반 파일 앱에서 접근하기 어려움

실무 팁: 일반적인 WebView 서비스의 첨부파일 다운로드 기능이라면 백그라운드 처리가 깔끔한 DownloadManager가 가장 무난합니다. 반면, 중요한 개인 문서나 백업 파일처럼 사용자가 직접 위치를 정해서 보관해야 하는 성격의 기능이라면 ACTION_CREATE_DOCUMENT를 활용한 SAF 방식이 맞습니다.

4. 실무 운영 시 꼭 챙겨야 할 주의점

  • 인증 토큰은 Query에 넣지 않기: 파일 다운로드 권한을 체크하겠다고 https://example.com/file?token=ey... 형태로 토큰을 주소창에 달아버리면 로그나 크래시 리포트에 노출됩니다. 다운로드용 단기 서명 URL(Signed URL)을 발급받아 처리하거나 Cookie 기반 세션을 활용하는 편이 안전합니다.

  • 과도한 저장소 권한 요구 금지: 최근 Android 버전들은 권한 관리가 엄격합니다. 단순히 파일을 다운로드 폴더에 넣기 위해 무조건 무거운 읽기/쓰기 권한(READ_EXTERNAL_STORAGE 등)을 요구하면 구글 플레이 심사에서 거절당하기 딱 좋습니다. DownloadManagerMediaStore, SAF를 적절히 활용하면 넓은 범위의 저장소 권한 없이도 구현 가능합니다.

  • 실패 케이스 대응: 기기 용량 부족, 네트워크 단절 등으로 다운로드가 실패했을 때 최소한의 안내 문구나 재시도 처리가 들어가야 사용자 컴플레인이 줄어듭니다.

짧은 마무리

WebView 파일 다운로드는 단순히 링크 클릭으로 끝나는 기능이 아니라, 기기 저장소 제어와 보안 검수가 얽혀 있는 중요한 기능입니다.

구현하시기 전에 "이 파일이 사용자가 직접 위치를 골라야 하는 파일인가, 아니면 일반 첨부파일인가"를 먼저 정의한 뒤, 도메인 검증과 파일명 정제를 거쳐 코드를 올리시는 것을 추천합니다. 미리 안 챙겨두면 나중에 앱 업데이트 하거나 OS 버전 바뀔 때 크게 고생하는 영역이니 개발 단계에서 확실히 잡아두는 것이 좋습니다.

댓글

이 블로그의 인기 게시물

안드로이드 화면 꺼짐 방지 FLAG_KEEP_SCREEN_ON 및 WakeLock 적용 방법

안드로이드 백그라운드 타이머 구현 및 Foreground Service 알림 설계 정리

관광 앱 다국어 데이터 모델 및 검색 색인 설계 (자꾸 까먹어서 정리)