
캐시(cache)는 단순히 특정 자원의 접근 속도를 높이는 임시 저장 공간이 아니라, 보안과 접근성 측면에서도 중요한 역할을 수행합니다.
안드로이드 앱 기능을 구현할 때, 웹 기반 서비스를 앱 내부에 통합해야 하는 경우가 존재합니다. 우리는 이 때 웹뷰(WebView)를 활용합니다. 웹뷰를 활용하여 웹 콘텐츠를 표시할 뿐만 아니라, 때로는 이미지나 파일 등의 미디어 데이터를 주고받아야 하는 상황도 발생합니다.
하지만 웹뷰는 우리가 개발하는 앱과 별도의 프로세스에서 동작한다는 특성을 고려해야 합니다. 특히, 권한과 관련된 사항에 유의하지 않으면 해당 기능을 구현할 때 미디어 전송이 정상적으로 이루어지지 않는 오류가 발생할 수 있습니다.
이번 글에서는 이와 관련된 대표적인 오류인 NotReadableError가 일어날 수 있는 상황과 원인, 그리고 그 해결 방법을 자세히 살펴보겠습니다.
웹뷰 상에서 디바이스로부터 파일을 받아오는 것이 필요한 기능이 구현되어 있고, 해당 페이지를 앱 상에서 보여주어야 하는 상황을 가정해 보겠습니다. 아래와 같은 상황들이 그 예시가 될 수 있습니다.
웹 프론트엔드 상에서는 해당 기능을 HTML의 <input> 태그를 활용하여 다음과 같이 구현할 수 있을 것입니다.
<body>
<!-- ... -->
<input
type="file"
accept="image/*"
id="imageInput"
onchange="handleFileSelect(event)" />
</body>
해당 웹 페이지를 안드로이드 네이티브 측에서는 다음과 같이 활용할 수 있을 것입니다. (웹뷰를 활용하는 자세한 과정은 생략합니다.)
class WebChatActivity : AppCompatActivity() {
// Contains container view in layout(ex: container_web_view - FrameLayout)
private val binding: WebChatActivityBinding by lazy {
WebChatActivityBinding.inflate(layoutInflater)
}
// WebView object
private val webView: WebView by lazy { WebView(this) }
private val webChatViewModel: WebChatViewModel by viewModels {
/* initialization */
}
private var fileChooserCallback: ValueCallback<Array<Uri>>? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// ...
setUpView(url = "https://kame.chat") // Chat url from external API, etc...
}
private fun setUpView(url: String) {
// Add web view to the container
binding.containerWebView.addView(webView)
// Set-Ups
setUpSettings()
setUpWebChromeClient()
setUpWebViewClient()
// Load url into the webview
webView.loadUrl(url)
}
private fun setUpWebChromeClient() {
webView.webChromeClient = object : WebChromeClient() {
// ex) when clicking on <input type="file"> element
override fun onShowFileChooser(
webView: WebView,
filePathCallback: ValueCallback<Array<Uri>>,
fileChooserParams: FileChooserParams
): Boolean {
fileChooserCallback?.onReceiveValue(null)
fileChooserCallback = filePathCallback
viewModel.onShareImage() // To trigger image selection event
return true
}
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
return true
}
}
}
// setUpSettings
// setUpWebViewClient
}
웹뷰 내 <input> 태그를 통한 파일 업로드를 구현하기 위해, setUpWebChromeClient 메서드에서 WebChromeClient를 구성합니다. 이 과정에서 핵심은 onShowFileChooser()를 오버라이드하는 것입니다.
이 함수를 오버라이드하여, 웹 페이지에서 파일 선택 UI가 표시되는 순간 Android 네이티브 쪽에서 이를 가로채서 앱이 제공하는 파일 선택기를 실행할 수 있습니다. 사용자가 파일을 선택하면, 해당 콜백을 통해 전달되는 ValueCallback<Array<Uri>>를 활용하여 선택한 파일의 Uri를 웹뷰로 전달할 수 있습니다. 이를 통해 웹뷰는 HTML <input type="file"> 요소를 통해 선택된 파일을 받아 업로드할 수 있습니다.
이를 채팅 예시의 유즈케이스에 그대로 적용하면 다음과 같은 과정을 기대할 수 있습니다.
- 웹뷰로 구현되어 있는 채팅창에서 사용자가 사진 추가 버튼을 클릭
- HTML
<input type="file">가 트리거되고onShowFileChooser()호출- 네이티브 측에서 이 호출을 가로채 갤러리 또는 카메라 실행
- 파일을
ValueCallback<Array<Uri>>를 통해 웹뷰에 전달
그런데 파일을 전송하는 과정에서 네이티브 측 입장만을 고려하여 단순히 개발 중인 앱의 외부 프로세스(특히 갤러리)에 존재하던 파일의 원본 Uri 를 받아 그대로 onReceiveValue() 로 넘겨주는 방식은 문제를 야기할 수 있습니다. 일부 기기 혹은 환경에서 다음과 같은 오류가 발생하면서 업로드가 실패할 수 있기 때문입니다.
NotReadableError: The requested file could not be read, typically due to permission problems that have occurred after a reference to a file was acquired.
// in WebChatActivity...
override fun onCreate(savedInstanceState: Bundle?) {
// ... setUpView ...
observeEvents()
}
private fun observeEvents() {
lifecycleScope.launch {
viewModel.uiEvent.collectLatest { event ->
when (event) {
// ...
is ImageSelectionSuccess -> {
// fetch original uris from external app files
val originalUris = event.uris
fileChooserCallback?.onReceiveValue(originalUris) // ERROR!
fileChooserCallback = null
}
}
}
}
}
공용 키 자격 증명(public key credential) 생성 과정 중, FIDO에서 발생한 NotReadableError가 포함된 authenticator response exception이 발생하면 이 예외가 던져집니다.
이는 어떤 입출력(I/O) 읽기 작업이 실패했음을 의미합니다.NotReadableError | Android 공식 문서
설명에 다소 어려운 용어들(공용 키 자격 증명, FIDO 등)이 등장하지만, 궁극적으로 이 오류는 입출력(I/O) 작업이 실패했을 때 발생하는 문제로 이해할 수 있습니다. 다음과 같은 상황들에서 해당 오류가 발생할 수 있습니다.
WebView에서 파일을 업로드하거나 이미지 URI를 전달하는 과정에서, 내부적으로 I/O 작업이 수행되므로 현재 예시에서도 해당 오류가 발생할 수 있는 것입니다.
앞서 언급하였듯, 이러한 오류는 갤러리 같이 외부에 이미 존재하던 파일을 가져와서 업로드하는 과정에서 주로 발생합니다. 에러 메시지를 다시 읽어보면 권한 문제로 인해 파일을 읽을 수 없다고 명시되어 있습니다.
The requested file could not be read, typically due to permission problems that have occurred after a reference to a file was acquired.
흥미롭게도 카메라를 활용하여 사진 촬영을 하고 가져오는 과정에서는 해당 오류가 잘 발생하지 않는 것을 알 수 있습니다. 이어질 내용에서 이러한 차이가 발생하는 이유를 살펴보겠습니다.
갤러리 앱이 관리하는 파일의 URI를 받게 되는데, 이 URI는 갤러리 앱이 소유한 파일에 대한 참조입니다. 앱이 이 URI를 받을 때는 FLAG_GRANT_READ_URI_PERMISSION 플래그를 통해 임시 읽기 권한만 부여받습니다.
문제는 이 권한이 현재 앱 프로세스에만 유효하다는 점입니다. WebView는 별도의 프로세스에서 실행되기 때문에, 이 임시 권한을 공유받지 못합니다. 갤러리에서 반환되는 URI 예시는 다음과 같은데, WebView가 이 URI를 활용하여 파일에 접근하려고 하면 권한이 없어 읽을 수 없게 됩니다. 이로 인해 앞서 언급한 NotReadableError가 발생하는 것입니다.
content://com.google.android.apps.photos.contentprovider/…
[앱 외부 갤러리 저장소]
↓ FLAG_GRANT_READ_URI_PERMISSION
[앱 내부 프로세스] - 임시 읽기 권한 (앱 내부 프로세스만)
↓ URI 전달
[WebView 프로세스] - 권한 없음 → NotReadableError 발생
카메라로 촬영할 때는 앱이 직접 저장 위치를 지정합니다. 파일이 앱 디렉토리에 저장되므로, 앱이 해당 파일에 대한 완전한 소유권을 갖게 됩니다.
// 예시 1 - 자체 캐시 디렉토리 사용
val photoFile = File(context.cacheDir, "camera_photo_${System.currentTimeMillis()}.jpg")
val photoUri = FileProvider.getUriForFile(
this,
"${packageName}.fileprovider",
photoFile
)
// 예시 2 - 앱 전용 외부 저장소 사용
val storageDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
val photoFile = File.createTempFile(
imageFileName,
IMAGE_SUFFIX,
storageDir
)
val photoUri =
try {
FileProvider.getUriForFile(
context,
/* authority */,
imageFile
)
} catch (e: Exception) {
Uri.fromFile(imageFile)
}
val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
intent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri) *// 저장 위치 지정*
startActivityForResult(intent, CAMERA_REQUEST_CODE)
FileProvider를 통해 생성된 URI는 AndroidManifest 측의 태그 내부의 grantUriPermissions="true" 설정에 따라 다른 프로세스(WebView 포함)와도 공유가 가능하게 됩니다.
<!-- AnrdoidManifest.xml -->
<provider
android:name="..."
android:authorities="..."
android:exported="false"
android:grantUriPermissions="true">
결론적으로 카메라로부터 가져온 이미지의 경우 아래와 같은 흐름에 의하여 공유가 가능합니다.
[앱 외부 카메라]
↓ 사진 저장
[앱 내부 전용 디렉토리] - /data/.../cache/camera_photo.jpg 경로 활용
↓ FileProvider URI 생성
[앱 내부 프로세스] - 완전한 소유권
↓ URI 전달
[WebView 프로세스] - FileProvider 권한으로 접근 가능
Android 10(API 29)부터 도입된 Scoped Storage 정책도 이 문제를 더욱 복잡하게 만듭니다. 이는 앱이 외부 저장소에 무분별하게 접근하는 것을 제한하여 사용자 개인정보를 보호하기 위한 메커니즘입니다.
Android 9 이하에서는 READ_EXTERNAL_STORAGE 권한만 있으면 갤러리에서 받은 URI를 실제 파일 경로로 변환하여 직접 접근할 수 있었습니다. 이 경로를 WebView에 전달해도 WebView가 같은 경로로 파일에 접근할 수 있었기 때문에 공유가 어렵지 않았습니다.
// 권한만 있다면 직접 해당 파일 경로로 접근 가능
val path = "/storage/emulated/0/DCIM/photo.jpg"
val file = File(path)
Scoped Storage가 도입되면서, 갤러리 URI를 실제 파일 시스템 경로로 변환할 수 없게 되었고, 따라서 공통 경로를 통한 우회도 불가능해졌습니다.
파일에 접근하려면 반드시 ContentResolver를 사용해야 하며, 이때 권한은 호출하는 프로세스에 묶이게 됩니다. WebView 프로세스는 별도의 권한 컨텍스트를 가지므로, 앱 프로세스에서 임시로 부여받은 읽기 권한을 공유받지 못해 접근이 차단됩니다. 결국 Android 10 이상에서는 어떤 프로세스가 요청하는지가 중요해지고, 단순히 URI만 전달하는 방식으로는 파일 업로드가 제대로 이루어지지 않게 됩니다.
캐시 활용
이 문제를 해결하는 가장 확실한 방법은 선택된 파일을 앱의 캐시 디렉토리로 복사한 후, FileProvider를 통해 생성된 안전한 URI를 WebView에 전달하는 것입니다.
먼저 FileProvider에 캐싱을 위한 디렉토리를 설정해야 합니다.
res/xml/file_paths.xml
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 캐시 디렉토리 -->
<cache-path
name="cache"
path="." />
<!-- ... 기타 저장소 (필요시) ... -->
</paths>
<manifest>
<!-- 권한 선언 -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<application>
<!-- FileProvider 등록 -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
파일을 캐시로 복사하는 기능을 추가합니다. 아래 예시는 참고용입니다.
class WebChatActivity : AppCompatActivity() {
// ... 기존 코드 ...
private fun observeEvents() {
lifecycleScope.launch {
viewModel.uiEvent.collectLatest { event ->
when (event) {
is ImageSelectionSuccess -> {
// 원본 URI를 캐시에 복사 후 WebView에 전달
val cachedUris = event.uris.mapNotNull { uri ->
cachedUri(uri)
}.toTypedArray()
fileChooserCallback?.onReceiveValue(cachedUris)
fileChooserCallback = null
}
// ... 다른 이벤트 처리 ...
}
}
}
}
/**
* 외부 URI의 파일을 캐시 디렉토리로 복사하고 FileProvider URI 반환
*/
private fun cachedUri(uri: Uri): Uri? {
return try {
val inputStream = contentResolver.openInputStream(uri) ?: return null
val extension = fileExtension(uri)
val fileName = "cached_image_${System.currentTimeMillis()}.$extension"
val cacheFile = File(cacheDir, fileName)
inputStream.use { input ->
cacheFile.outputStream().use { output ->
input.copyTo(output)
}
}
FileProvider.getUriForFile(
this,
"${packageName}.fileprovider",
cacheFile
)
} catch (e: Exception) {
Log.e(TAG, "파일 캐시 생성 실패: ${e.message}", e)
null
}
}
/**
* URI로부터 파일 확장자 추출
*/
private fun fileExtension(uri: Uri): String {
val mimeType = contentResolver.getType(uri)
return when {
mimeType?.startsWith("image/") == true -> {
mimeType.substringAfter("image/").let { if (it == "jpeg") "jpg" else it }
}
mimeType?.startsWith("video/") == true -> mimeType.substringAfter("video/")
else -> "jpg" // 기본값
}
}
companion object {
private const val TAG = "WebChatActivity"
}
}
그 결과, 아래와 같은 흐름을 통해 외부 앱의 파일을 안전하게 웹뷰로 전달할 수 있게 됩니다.
1. 사용자가 갤러리에서 이미지 선택
↓
2. 갤러리 앱이 임시 권한이 부여된 URI 반환
- content://com.google.android.apps.photos.contentprovider/...
↓
3. copyToCacheAndGetUri()가 파일을 캐시로 복사
- /data/user/0/com.yourapp/cache/cached_image_1234567890.jpg
↓
4. FileProvider URI 생성
- content://com.yourapp.fileprovider/cache/cached_image_1234567890.jpg
↓
5. WebView로 URI 전달
이 과정을 통해 기존에 존재하던 문제들을 다음과 같이 해결할 수 있습니다.
cacheDir)는 앱이 완전히 제어하는 영역grantUriPermissions="true" 설정으로 WebView 프로세스와 안전하게 공유캐시가 무한정 쌓이면 디스크 공간을 차지하므로, 시간 주기 혹은 가용 공간에 따라 캐시를 정리하는 로직을 추가하는 것이 좋습니다. 자세한 방식은 이 글에서 다루지 않겠습니다.
Android 10 이상 환경에서는 웹뷰가 원본 파일 URI에 바로 접근할 수 없는 경우가 많아, 네이티브 측에서 선택한 파일을 그대로 WebView에 전달하면 업로드가 실패할 수 있습니다.
이 문제는 사용자가 선택한 파일을 앱 내 캐시 디렉토리에 안전하게 복사하여 해결할 수 있습니다. 복사된 파일은 앱이 완전히 제어할 수 있으며, FileProvider를 통해 생성된 URI는 WebView 프로세스와도 안전하게 공유됩니다.
이번 경험을 통하여, 웹뷰로의 파일 공유 과정에서 다음과 같은 사항들을 고려해야 함을 깨달았습니다.
WebView를 활용하는 환경에서 다양한 기기와 OS 버전에서 안정적으로 파일을 읽고 업로드할 수 있는 방식을 고민하며 개발을 진행하면 좋을 것 같습니다.
정말 좋은 글 잘 읽었습니다!
한 가지 궁금한 점이 있는데요
말씀하신 내용대로라면 Renderer 프로세스가 content:// URI에 직접 접근하면서 권한 문제가 발생해, 이를 해결하기 위해 앱 프로세스 쪽에서 캐싱하는 방식으로 처리하신 걸로 이해했습니다.
Q). 실제 테스트나 현업에서 Renderer 프로세스로 동작하는 케이스가 더 많았는지,
아니면 단일 프로세스(App 프로세스 내 렌더링) 케이스도 종종 있었는지 궁금합니다.