안드로이드에서 파일 쓰기가 실패하면 공간 부족이든 저장장치의 입출력 거부든 전부 같은 입출력 예외로 올라옵니다. 이것을 예외 타입 하나로 뭉쳐 처리하면 공간이 모자랐을 뿐인데 저장장치가 고장 났다고 답하게 되고, 진짜 입출력 오류는 통계에서 "파일 없음"으로 집계됩니다. 둘을 가르는 값은 메시지 문자열이 아니라 예외의 원인 체인에 들어 있는 errno 숫자입니다. 이 글은 SD 카드 용량 검사 앱에서 실제로 난 오판 두 건과, 그것을 errno 로 고친 코드를 정리합니다.
증상 — 공간 부족이 "손상"으로, 입출력 오류가 "파일 없음"으로
앱은 저장소의 빈 공간을 검사 파일로 채운 뒤 되읽어 대조합니다. 쓰기 실패의 예외 원문을 계측으로 받기 시작한 첫 사흘 동안 쓰기 실패는 3건이었고, 내용은 이랬습니다.
| 계측에 남은 원문 | 상황 | 앱이 낸 답 |
|---|---|---|
write failed: ENOSPC (No space left on device) | 실사용자 기기 한 대의 내부 저장소 전체 검사, 진행률 75% 이상 구간 | 손상 판정 + 환불 근거 저장 버튼 |
open failed: EIO (I/O error) | 첫 파일을 여는 순간, 쓴 양 0 B | 손상 판정. 통계 키는 file_missing |
ftruncate failed: EIO | 첫 파일의 공간을 잡는 순간, 쓴 양 0 B | 손상 판정 |
첫째 줄은 오판입니다. 둘째·셋째 줄은 저장장치 이상 신호가 맞지만, 한 바이트도 못 쓴 상태라 용량에 대해 증명된 것이 없는데도 "0 B 지점에서 멈췄다"는 결과와 환불 근거 버튼이 나갔습니다. 판매자를 사기꾼으로 모는 쪽이어서 이 앱이 낼 수 있는 가장 나쁜 오답입니다.
원인 1 — 모든 IOException 을 한 판정으로 보냈다
고치기 전 쓰기 단계의 catch 입니다(커밋 diff 그대로).
} catch (e: IOException) {
cleanUp(dir)
emit(Event.Finished(Result.Damaged(
failedAtBytes = files.sumOf { it.second },
detail = "The card stopped accepting data after " +
"${Volumes.human(files.sumOf { it.second })}, although it reports " +
"${Volumes.human(volume.totalBytes)}. " + (e.message ?: ""),
phase = Phase.WRITING,
cause = e,
)))
return@flow
}
어떤 입출력 예외든 "카드가 데이터를 받지 않는다"가 됩니다. 그런데 ENOSPC 는 파일시스템의 공간 회계가 내는 값이지 매체 불량의 증거가 아닙니다. 용량을 속인 가짜 카드라면 파일시스템은 부풀린 용량을 그대로 믿으므로 공간이 모자란다고 말할 이유도 없습니다.
진짜 원인은 앱 쪽에 있었습니다. 검사를 시작할 때 잰 여유 공간에서 64MB 만 남기고 전부 채우도록 짰는데, 내부 저장소는 폰 전체가 같이 씁니다. 검사가 도는 수십 분 동안 다른 앱과 시스템이 남은 여유를 먼저 가져갔고, 그 순간 우리 쓰기가 ENOSPC 로 실패했습니다. 시작 전에 확인한 여유 공간은 그 시점의 값일 뿐입니다.
덤으로 e.message 를 문구 끝에 붙인 것도 결함이었습니다. 경로와 errno 가 그대로 실린 문자열이 사용자 화면과 환불 근거 파일에 들어갔습니다.
원인 2 — 타입만 보면 EIO 가 FileNotFoundException 에 묻힌다
실패 원인을 통계용 고정 키로 바꾸는 함수는 타입으로 분류하고 있었습니다.
is FileNotFoundException -> "file_missing"
is SecurityException -> "permission_denied"
is IOException -> "io_error"
안드로이드의 libcore 는 파일 열기 실패를 전부 FileNotFoundException 으로 던집니다. 파일이 없을 때만이 아닙니다. 그래서 저장장치가 입출력을 거부한 open failed: EIO 가 file_missing 으로 집계됐습니다. 공간 부족과 이름 충돌도 같은 키에 들어가 통계에서 셋을 가를 수 없었습니다.
message.contains("ENOSPC") 가 가장 먼저 떠오르지만, 같은 실패가 환경마다 다른 문구로 옵니다. 메시지는 사람이 읽으라고 만든 글이라 호출 이름(write failed, open failed, ftruncate failed)과 경로가 섞여 있습니다. 판정에 쓸 값은 숫자인 errno 입니다.
해결 1 — cause 체인에서 ErrnoException 을 찾는다
libcore 는 IOException·FileNotFoundException 의 cause 에 android.system.ErrnoException 을 실어 줍니다. 거기서 errno 를 꺼내 OsConstants.errnoName() 으로 이름을 얻습니다.
internal var errnoNameOf: (IOException) -> String? = { e ->
generateSequence(e as Throwable?) { it.cause }
.take(6)
.filterIsInstance<android.system.ErrnoException>()
.firstOrNull()
?.let { runCatching { android.system.OsConstants.errnoName(it.errno) }.getOrNull() }
}
cause를 한 단계만 보지 않고 체인을 따라갑니다. 중간에 다른 예외로 한 번 더 감싸여 있어도 찾습니다.take(6)은 체인이 끝없이 이어지는 경우를 막는 상한입니다.- errno 가 없으면
null을 돌려주고, 호출하는 쪽은 예전 방식으로 넘어갑니다. - 함수가 아니라
var람다인 이유는 테스트입니다(아래 FAQ).
난독화(R8)를 켠 릴리스 빌드에서도 실기기로 동작을 확인했습니다. 예외 클래스 이름을 문자열로 읽는 방식과 달리 타입 검사와 숫자만 쓰기 때문입니다.
해결 2 — 쓰기 실패 판정을 한 함수로 모은다
errno 와 "얼마나 썼는가"를 받아 세 갈래 중 하나를 돌려주는 함수를 따로 뺐습니다. 입출력이 없는 순수 함수라 표 전체를 단위 테스트로 고정할 수 있습니다.
internal fun onWriteFailure(errno: String?, writtenBytes: Long, completedBytes: Long): WriteFailure =
when {
errno == "ENOSPC" || errno == "EDQUOT" ->
if (completedBytes > 0) WriteFailure.VERIFY_WHAT_WAS_WRITTEN
else WriteFailure.COULD_NOT_START
writtenBytes <= 0 -> WriteFailure.COULD_NOT_START
else -> WriteFailure.DAMAGED
}
| 조건 | 판정 | 앱이 하는 일 |
|---|---|---|
ENOSPC·EDQUOT, 온전히 끝난 파일이 있다 | 쓴 만큼만 검증 | 반쯤 찬 파일은 지우고, 끝난 파일만 되읽어 판정한다. 계측에는 공간이 먼저 찼다는 사실만 남긴다 |
ENOSPC·EDQUOT, 끝난 파일이 없다 | 시작하지 못함 | 결과를 내지 않는다 |
| 그 밖의 errno 또는 errno 모름, 쓴 양 0 B | 시작하지 못함 | "첫 쓰기를 받지 않았다, 용량에 대해 말해 주는 것이 없다"고 안내한다 |
| 그 밖의 errno 또는 errno 모름, 쓰다가 멈춤 | 손상 | 멈춘 지점을 실제로 쓴 양으로 보고한다 |
catch 는 이 함수의 답에 따라 갈라집니다(줄인 코드).
} catch (e: IOException) {
val completed = files.sumOf { it.second }
when (onWriteFailure(errnoNameOf(e), allocatedBytes, completed)) {
WriteFailure.VERIFY_WHAT_WAS_WRITTEN -> {
current?.let { runCatching { it.delete() } } // 반쯤 찬 파일
emit(Event.SpaceRanOut(allocatedBytes, e)) // 계측용 알림, 검사는 계속
}
WriteFailure.COULD_NOT_START -> { /* Incomplete(cause = e) 로 끝낸다 */ }
WriteFailure.DAMAGED -> { /* Damaged(failedAtBytes = allocatedBytes, cause = e) */ }
}
}
함께 바꾼 것이 둘 있습니다. 예외 원문은 화면과 리포트 문구에서 빼고 cause 로 계측에만 보냅니다. 그리고 내부 저장소에 남기는 여유를 64MB 에서 1GB 로 늘렸습니다. 카드는 검사하는 동안 우리만 쓰지만 내부 저장소는 그렇지 않고, 64MB 만 남기면 검사 내내 폰 전체가 저장공간 부족 상태가 되기 때문입니다.
internal fun usableBytes(volume: Volume): Long {
val margin = if (volume.removable) SAFETY_MARGIN else INTERNAL_MARGIN
return (volume.freeBytes - margin).coerceAtLeast(0)
}
해결 3 — 통계 키도 errno 를 타입보다 먼저
is IOException -> errnoKey(e) ?: if (e is FileNotFoundException) "file_missing" else "io_error"
private fun errnoKey(e: IOException): String? = when (CapacityTest.errnoNameOf(e)) {
"EIO" -> "io_error_eio"
"ENOSPC", "EDQUOT" -> "no_space"
"EISDIR" -> "is_directory"
"EROFS" -> "read_only"
"EACCES", "EPERM" -> "permission_denied"
else -> null
}
아는 errno 면 그 키를 쓰고, 모르는 errno 이거나 errno 가 없으면 null 을 돌려 예전의 타입 분류로 넘어갑니다. 그래서 errno 를 읽지 못하는 환경에서도 결과가 전보다 나빠지지 않습니다.
errno 별 의미
| errno | 뜻 | 저장장치 탓인가 | 통계 키 |
|---|---|---|---|
EIO | 매체가 입출력을 거부했다 | 그렇다. 불량의 직접 신호. 단 0 B 에서 났다면 용량에 대해서는 증명된 것이 없다 | io_error_eio |
ENOSPC | 남은 공간이 없다 | 아니다. 파일시스템 회계가 내는 값 | no_space |
EDQUOT | 할당량을 넘었다 | 아니다. ENOSPC 와 같이 다룬다 | no_space |
EISDIR | 쓰려는 파일 이름을 폴더가 차지하고 있다 | 아니다 | is_directory |
EROFS | 읽기 전용으로 붙은 파일시스템 | 쓰기 전에 원인을 따로 확인해야 한다 | read_only |
EACCES·EPERM | 권한이 없다 | 아니다 | permission_denied |
그 밖(ENOENT 등)·errno 없음 | 따로 가르지 않는다 | 타입으로 분류 | file_missing 또는 io_error |
자주 묻는 것
ErrnoException 을 직접 catch 하면 안 되나요?
RandomAccessFile 같은 java.io API 를 쓰면 바깥으로 나오는 것은 IOException 이나 FileNotFoundException 이고, ErrnoException 은 그 cause 안에 있습니다. 그래서 바깥 예외를 잡은 뒤 체인을 따라가 찾아야 합니다.
JVM 단위 테스트에서는 어떻게 검증하나요?
JVM 단위 테스트에서는 ErrnoException 을 만들 수 없습니다. 그래서 errno 조회를 var 람다로 빼 두고 테스트가 그 자리만 바꿔 끼웁니다. 실패 자체는 손으로 만든 예외가 아니라 진짜 파일시스템 실패로 냅니다. 검사 파일이 들어갈 이름을 비울 수 없는 폴더로 막아 운영체제가 열기에 실패하게 하고, errno 이름만 주입합니다.
CapacityTest.errnoNameOf = { "ENOSPC" }
assertEquals(WriteFailure.VERIFY_WHAT_WAS_WRITTEN,
CapacityTest.onWriteFailure("ENOSPC", writtenBytes = 90, completedBytes = 80))
assertEquals(WriteFailure.COULD_NOT_START,
CapacityTest.onWriteFailure("EIO", writtenBytes = 0, completedBytes = 0))
assertEquals(WriteFailure.DAMAGED,
CapacityTest.onWriteFailure("EIO", writtenBytes = 10, completedBytes = 0))
주입을 하지 않은 기본 구현도 JVM 에서 죽지 않고 null 을 돌려주는지 따로 테스트합니다. 이때도 0 B 실패는 손상이 아니라 "시작하지 못함"으로 나와야 합니다.
0 B 에서 난 EIO 는 불량 신호인데 왜 손상이라고 하지 않나요?
신호는 맞습니다. 다만 이 검사가 답하려는 것은 "표기된 용량만큼 실제로 담기는가"이고, 아무것도 쓰지 못했다면 그 질문에는 근거가 없습니다. 그래서 화면에는 "시작하지 못했다"고 안내하고, 원인은 io_error_eio 키로 계측에 남깁니다. 쓰다가 중간에 멈춘 경우는 여전히 손상으로 판정합니다. 이 수정이 진짜 불량까지 덮지 않는지도 테스트로 고정했습니다.
errno 를 모를 때는 어느 쪽으로 보나요?
예전처럼 보수적으로 봅니다. 쓴 양이 있으면 손상, 0 B 면 시작하지 못함입니다. errno 는 판정을 더 정확하게 만드는 추가 정보로만 쓰고, 없다고 해서 판정이 멈추지 않게 했습니다.
