자사 서비스를 개발하던 중, 대량 데이터를 가지고 있는 특정 사용자의 CS에서 화면에 보여야 할 데이터의 렌더링이 느리다는 보고를 받았습니다.
화면을 넘기다가 멈추면 일정이 늦게 그려져요.
서비스의 데이터(일정)를 보여주는 화면의 구조는 ViewPager로 구성되어 있었고, 각 페이지가 Selected 될 때마다 매번 전체 데이터 중 현재 화면에 보여줘야 할 데이터들을 쿼리 하는 작업이 수행되는 구조였습니다.
문제점은 빠르게 스크롤을 하면 대량의 쿼리 요청이 축적되고, 데이터 DB는 작업의 부하가 걸리면서 최종적으로 보이는 마지막 페이지의 쿼리 후 데이터 렌더링이 지연되는 것이었습니다.
해결 방안
DB 작업을 취소하던지, 순서 제어를 하던지 둘 중 하나의 방법이 필요했습니다.
하지만 안드로이드 SQLite에서 도중에 쿼리를 강제 취소하는 방법은 존재하지 않습니다.
(스레드, job으로 묶어서 cancel 한다해도 DB의 쿼리 동작 자체는 취소되지 않습니다.)
따라서 순서를 제어할 수 있는 로직을 구성하기로 했습니다.
해당 로직을 구성하기 전에, 일단 빠른 스크롤의 경우 불필요한 작업 처리를 아예 수행하지 않도록 정책적으로 딜레이를 지정했습니다.
특정 딜레이 (ex : 200ms) 이상 해당 페이지의 잔류하지 않으면 해당 페이지를 위한 작업을 굳이 수행하지 않습니다.
private val pagingHandler = Handler(Looper.getMainLooper())
private var pageChangeRunnable: Runnable? = null
.
.
.
override fun onPagingDate(pageTime: Long) {
pageChangeRunnable?.let { pagingHandler.removeCallbacks(it) }
pageChangeRunnable = Runnable { /*TODO for page*/ }
pageChangeRunnable?.let { pagingHandler.postDelayed(it, 300) }
}
핸들러로 간단하게 처리가 가능합니다.
페이지가 진행될때 대기 중이던 핸들러 작업을 새롭게 대체시키는 것입니다.
이제 순서 제어 로직을 구성하는 과정입니다.
먼저 현재 스크롤된 페이지가 최대한 빠르게 보이게 하려면 앞 전에 있는 불필요한 작업들을 배제해야겠습니다.
이를 위해 스택 구조를 활용했습니다.
먼저 들어온 작업의 처리가 완료되고 나면 그다음 순차적인 작업이 아니라 스택의 상위. 즉, 가장 최근에 예약된 작업을 우선적으로 진행하도록 합니다.

위 그림처럼 첫 번재 페이지의 작업이 진행 중일 때, 스크롤을 하면 DB의 작업들이 순차적으로 스택에 쌓이게 됩니다.
하지만 현재 작업이 마무리되면 Second가 아니라 Latest를 먼저 처리하게 되는 것입니다.
이렇게 순서를 앞당김으로서 사용자가 보는 마지막 페이지의 렌더링 처리 속도를 향상할 수 있었습니다.
또한 순차적 이해를 위해 초기에는 스택을 도입했으나, Second, Third 등의 중간 작업은 불필요하기 때문에 스택이 아니라 CurrentTask, ReservedTask 두 개만으로 나뉘어 작업을 처리할 수도 있겠습니다.
자세한 코드 로직을 보면..
해당 작업 시스템을 관리할 싱글톤 Manager를 구성하여
Task 클래스 및 두 개의 Task를 선언해 줍니다.
data class Task(
val uid: String,
val job: Deferred<List<Result>>
)
object DataFetchManager {
private var reservedTask: Task? = null
private var currentTask: Task? = null
private val fetchResult = StateFlow<Map<String, List<Result>>>(emptyMap())
private var isProcessing = false
...
Task 클래스에서 작업은 Deferred 타입으로 구성됩니다.
Deferred를 await() 시에 해당 코루틴 작업이 완료될 때까지 기다리게 할 수 있습니다.
즉 DB 작업을 예약 형태로 가지고 있을 수 있습니다.
그리고 작업 결과 리스트들은 Flow 형태로 가지고 있어 완료된 상태를 키와 함께 가지고 있다가 UI에서 키에 따라 필요한 Result를 반환해 줄 수 있습니다.
fun updateTask(uid: String) {
CoroutineScope(Dispatchers.IO).launch {
val deferredJob = async(start = CoroutineStart.LAZY) {
//Lazy는 원하는 시점에 코루틴을 실행할 수 있도록 합니다.
//DB Query 작업 명시
}
val newTask = Task(uid, deferredJob)
if (isProcessing) {
reservedTask = newTask
return@launch
}
isProcessing = true
currentTask = newTask
while (true) {
val task = currentTask ?: run {
isProcessing = false
return@launch
}
val result = task.job.await()
fetchResult.update { currentMap ->
currentMap.toMutableMap().apply {
this[task.uid] = result
}
}
currentTask = reservedTask
reservedTask = null
}
}
}
newTask를 매번 생성해 주고, isProcessing 변수를 통해 아직 작업의 처리가 진행 중인지 판단합니다.
만일 작업이 처리 중이라면 신규 작업은 reserved 변수에 등록됩니다.
이후 해당 작업이 완료되어 결과물이 flow로 전달될 때까지 대기하고 선행 작업이 마무리되면 그때 reserved의 작업을 current로 가져와 작업을 수행하게 됩니다.
중간에 들어온 작업들은 실행될 일도 없이 무조건 reserved에 의해서 다른 작업이 완료될 때까지 새롭게 갱신될 것입니다.
작업 전

거의 4.5초 정도 렌더링 지연이 발생합니다.
작업 후

딜레이가 거의 눈에 띄지 않는 정도로 렌더링 속도가 개선된 것을 확인할 수 있습니다.
자사 서비스를 개발하던 중, 대량 데이터를 가지고 있는 특정 사용자의 CS에서 화면에 보여야 할 데이터의 렌더링이 느리다는 보고를 받았습니다.
화면을 넘기다가 멈추면 일정이 늦게 그려져요.
서비스의 데이터(일정)를 보여주는 화면의 구조는 ViewPager로 구성되어 있었고, 각 페이지가 Selected 될 때마다 매번 전체 데이터 중 현재 화면에 보여줘야 할 데이터들을 쿼리 하는 작업이 수행되는 구조였습니다.
문제점은 빠르게 스크롤을 하면 대량의 쿼리 요청이 축적되고, 데이터 DB는 작업의 부하가 걸리면서 최종적으로 보이는 마지막 페이지의 쿼리 후 데이터 렌더링이 지연되는 것이었습니다.
해결 방안
DB 작업을 취소하던지, 순서 제어를 하던지 둘 중 하나의 방법이 필요했습니다.
하지만 안드로이드 SQLite에서 도중에 쿼리를 강제 취소하는 방법은 존재하지 않습니다.
(스레드, job으로 묶어서 cancel 한다해도 DB의 쿼리 동작 자체는 취소되지 않습니다.)
따라서 순서를 제어할 수 있는 로직을 구성하기로 했습니다.
해당 로직을 구성하기 전에, 일단 빠른 스크롤의 경우 불필요한 작업 처리를 아예 수행하지 않도록 정책적으로 딜레이를 지정했습니다.
특정 딜레이 (ex : 200ms) 이상 해당 페이지의 잔류하지 않으면 해당 페이지를 위한 작업을 굳이 수행하지 않습니다.
private val pagingHandler = Handler(Looper.getMainLooper())
private var pageChangeRunnable: Runnable? = null
.
.
.
override fun onPagingDate(pageTime: Long) {
pageChangeRunnable?.let { pagingHandler.removeCallbacks(it) }
pageChangeRunnable = Runnable { /*TODO for page*/ }
pageChangeRunnable?.let { pagingHandler.postDelayed(it, 300) }
}
핸들러로 간단하게 처리가 가능합니다.
페이지가 진행될때 대기 중이던 핸들러 작업을 새롭게 대체시키는 것입니다.
이제 순서 제어 로직을 구성하는 과정입니다.
먼저 현재 스크롤된 페이지가 최대한 빠르게 보이게 하려면 앞 전에 있는 불필요한 작업들을 배제해야겠습니다.
이를 위해 스택 구조를 활용했습니다.
먼저 들어온 작업의 처리가 완료되고 나면 그다음 순차적인 작업이 아니라 스택의 상위. 즉, 가장 최근에 예약된 작업을 우선적으로 진행하도록 합니다.

위 그림처럼 첫 번재 페이지의 작업이 진행 중일 때, 스크롤을 하면 DB의 작업들이 순차적으로 스택에 쌓이게 됩니다.
하지만 현재 작업이 마무리되면 Second가 아니라 Latest를 먼저 처리하게 되는 것입니다.
이렇게 순서를 앞당김으로서 사용자가 보는 마지막 페이지의 렌더링 처리 속도를 향상할 수 있었습니다.
또한 순차적 이해를 위해 초기에는 스택을 도입했으나, Second, Third 등의 중간 작업은 불필요하기 때문에 스택이 아니라 CurrentTask, ReservedTask 두 개만으로 나뉘어 작업을 처리할 수도 있겠습니다.
자세한 코드 로직을 보면..
해당 작업 시스템을 관리할 싱글톤 Manager를 구성하여
Task 클래스 및 두 개의 Task를 선언해 줍니다.
data class Task(
val uid: String,
val job: Deferred<List<Result>>
)
object DataFetchManager {
private var reservedTask: Task? = null
private var currentTask: Task? = null
private val fetchResult = StateFlow<Map<String, List<Result>>>(emptyMap())
private var isProcessing = false
...
Task 클래스에서 작업은 Deferred 타입으로 구성됩니다.
Deferred를 await() 시에 해당 코루틴 작업이 완료될 때까지 기다리게 할 수 있습니다.
즉 DB 작업을 예약 형태로 가지고 있을 수 있습니다.
그리고 작업 결과 리스트들은 Flow 형태로 가지고 있어 완료된 상태를 키와 함께 가지고 있다가 UI에서 키에 따라 필요한 Result를 반환해 줄 수 있습니다.
fun updateTask(uid: String) {
CoroutineScope(Dispatchers.IO).launch {
val deferredJob = async(start = CoroutineStart.LAZY) {
//Lazy는 원하는 시점에 코루틴을 실행할 수 있도록 합니다.
//DB Query 작업 명시
}
val newTask = Task(uid, deferredJob)
if (isProcessing) {
reservedTask = newTask
return@launch
}
isProcessing = true
currentTask = newTask
while (true) {
val task = currentTask ?: run {
isProcessing = false
return@launch
}
val result = task.job.await()
fetchResult.update { currentMap ->
currentMap.toMutableMap().apply {
this[task.uid] = result
}
}
currentTask = reservedTask
reservedTask = null
}
}
}
newTask를 매번 생성해 주고, isProcessing 변수를 통해 아직 작업의 처리가 진행 중인지 판단합니다.
만일 작업이 처리 중이라면 신규 작업은 reserved 변수에 등록됩니다.
이후 해당 작업이 완료되어 결과물이 flow로 전달될 때까지 대기하고 선행 작업이 마무리되면 그때 reserved의 작업을 current로 가져와 작업을 수행하게 됩니다.
중간에 들어온 작업들은 실행될 일도 없이 무조건 reserved에 의해서 다른 작업이 완료될 때까지 새롭게 갱신될 것입니다.
작업 전

거의 4.5초 정도 렌더링 지연이 발생합니다.
작업 후

딜레이가 거의 눈에 띄지 않는 정도로 렌더링 속도가 개선된 것을 확인할 수 있습니다.