오늘은 안드로이드 앱에서 txt 로그 파일을 처리하다가 마주친 OutOfMemoryError를 해결한 경험을 공유해보려고 한다.
안드로이드 앱에서 200MB가 넘는 대용량 TXT 파일을 읽어와 특정 GPS 정보만 파싱하는 로직을 만들었다. 그런데 파일을 선택하자마자 앱이 강제 종료되는 사건이 발생하였다. 로그를 확인해보니, java.lang.OutOfMemoryError가 문제였다.
200MB 파일을 한 번에 메모리에 올리려다 보니 앱에 할당된 메모리 한계를 초과하여 발생한 오류였다.
OutOfMemoryError
를 해결하기 위해 Flow
와 collect
를 사용한 스트림 처리 방식을 도입했다. 파싱된 데이터를 Flow
로 방출하고, collect
블록에서 리스트에 하나씩 추가하는 방식이었다.
// FilePickerViewModel.kt (문제의 코드)
fun readTextFromFileUri(uri: Uri?) {
viewModelScope.launch {
try {
_parsedContent.value = emptyList() // 초기화
val resultList = mutableListOf<String>()
readFileAndParse(uri).collect { parsedLine ->
resultList.add(parsedLine)
_parsedContent.value = resultList.toList() // UI에 업데이트
}
Timber.d("File parsing completed.")
Timber.d("Parsed content: ${_parsedContent.value}")
} catch (e: Exception) {
e.printStackTrace()
_parsedContent.value = listOf("파일을 읽는 중 오류가 발생했습니다: ${e.message}")
}
}
}
하지만 이 방식은 근본적인 문제를 해결하지 못했다. resultList
는 메모리에 모든 파싱 데이터를 누적시켰고, resultList.toList()
는 매번 새로운 리스트 객체를 생성하며 메모리를 더 많이 사용했다.
/**
* Returns a [List] containing all elements.
*/
public fun <T> Iterable<T>.toList(): List<T> {
if (this is Collection) {
return when (size) {
0 -> emptyList()
1 -> listOf(if (this is List) get(0) else iterator().next())
else -> this.toMutableList()
}
}
return this.toMutableList().optimizeReadOnlyList()
}
toList()
는 어떤 경우든 기존의 리스트를 변경하지 않고, 모든 요소를 복사하여 새로운 리스트를 생성한다.
결국 수십만 개의 라인을 처리하다가 다시 OutOfMemoryError
가 발생한 거다.
운이 좋아 메모리가 널널하다면 파싱은 잘된다... 하지만 운이 나빠 메모리가 부족하다면 앱이 터진다. 이는 백퍼 QA에서 뚜까뚜까 맞을 앱이다.
Flow
를 사용하더라도collect
블록 안에서 모든 데이터를 메모리에 쌓는 건OutOfMemoryError
의 지름길이다.
메모리 문제를 해결하는 과정에서 또 다른 문제가 발견됐다. 파일을 선택하면 화면이 즉시 업데이트되지 않고, 잠시 동안 검은 화면이 나타났다.
앱 화면 -> 파일 선택기 -> 파일 선택기에서 한참 있다가 -> 검은 화면에서 한참 있다가 -> 앱 화면으로 돌아온다
내가 뷰모델에서 설정한 UiState가 전혀 반영되고 있지 않았다.
난 바보다. 파일 파싱을 메인 스레드에서 하고 앉아 있었던 것이다. UIstate 자체는 viewModelScope의 메인 스레드에서 업데이트하는게 맞지만, 실제 파일 읽기 작업 또한 메인 스레드에서 하고 있어버리니 메인스레드가 막혀 버린 것이다.
그래서 급하게 flow에 Dispatchers.IO를 달아준다.
// FilePickerViewModel.kt (문제의 코드)
private fun readFileAndParse(uri: Uri): Flow<GpsData> = flow {
withContext(Dispatchers.IO) { /* ... */ } // ❌ 이 부분이 문제!
}
하지만... 이는 반쪽짜리 해결책이었다.
Flow
빌더 내부에 withContext(Dispatchers.IO)
를 사용하면 emit
이 IO 스레드에서 발생하고 collect
는 메인 스레드에서 발생하여 Flow
의 컨텍스트 보존 규칙을 위반하는 오류가 발생한 것이다.
flowOn
을 사용한 올바른 스레드 분리withContext
대신 flowOn
연산자를 사용해 문제를 해결했다. flowOn
은 Flow
의 업스트림(파일 읽기/파싱) 실행 컨텍스트를 백그라운드 스레드로 전환해 준다.
// FilePickerViewModel.kt (최종 해결책)
fun readTextFromFileUri(uri: Uri?) {
viewModelScope.launch {
_state.value = UiState.Loading
try {
// Flow의 실행 컨텍스트를 IO 스레드로 전환
val resultList = mutableListOf<GpsData>()
readFileAndParse(uri)
.flowOn(Dispatchers.IO)
.onStart { _state.value = UiState.Loading }
.collect { gpsData ->
resultList.add(gpsData)
_state.value = UiState.Success(FilePickerState(resultList.toList()))
Timber.d("Parsed items: ${resultList.size}")
}
} catch (e: Exception) {
e.printStackTrace()
_state.value = UiState.Error("파일을 읽는 중 오류가 발생했습니다: ${e.message}")
}
}
}
테스트를 해보던 중.. 아직도 OOM이 터지는것을 알게되었다.. 사실 알고 싶지 않았다.
그래서 코드를 gemini한테 주고 문제점을 알려달라고 했더니, distinct가 문제라고 했다. distinct()
가 전체 파일을 메모리에 로드하기 때문에 터지는 것으로 파악했다.
public fun <T> Sequence<T>.distinct(): Sequence<T> {
return this.distinctBy { it }
}
- 메모리 누적 문제: Flow를 사용하더라도 collect 내부에서 모든 데이터를 메모리에 누적하면 OutOfMemoryError가 발생한다.
- UI 블로킹 문제: 파일 읽기/쓰기 같은 I/O 작업은 flowOn(Dispatchers.IO)를 사용해 백그라운드 스레드로 분리해야 한다.
- UI 상태 관리: Flow의 onStart 연산자를 사용해 로딩 상태를 먼저 발행하고, collect 블록에서 데이터가 들어올 때마다 UI 상태를 업데이트하여 사용자에게 즉각적인 피드백을 제공해야 한다.