$ cat ./posts/dev-log/*.mdrss ↗

> Dev_Log

홈 화면 위젯이 며칠째 로딩만 뜨는 이유

사용자로부터 “홈 화면 위젯이 며칠째 로딩 아이콘만 빙글빙글 돈다”는 버그 리포트가 들어왔습니다. 홈 화면 위젯에서는 Room DB + Prefs 조합으로 실제 사용자 데이터와 사용자가 지정한 색상대로 데이터를 렌더링해야했으나 실제로 있어야 할 일정 목록 자체가 통째로 비어 있었고, 사용자가 지정한 컬러는 간혹 반영되어 있지 않다는 버그까지 같은 화면에 겹쳐 있었습니다. 하나씩 추적해보니 두 가지 독립된 원인이 우연히 같은 증상(위젯이 이상해 보인다)으로 수렴하고 있었습니다.

1. 로딩 상태 플래그가 되돌아오지 못하는 경로

위젯 새로고침 버튼을 누르면 “동기화 중” 플래그를 켜고, 동기화가 끝나면 콜백에서 다시 꺼주는 구조였습니다.

fun onRefreshTapped() {
    save("isSyncing", true)
    redrawWidget()

    startSync { isSuccess ->
        if (!isSuccess) return       // 실패하면 그냥 종료
        save("isSyncing", false)     // 성공했을 때만 여기 도달
        redrawWidget()
    }
}

문제는 이 플래그를 다시 꺼주는 코드가 앱 전체에서 딱 한 곳뿐이었고, 그마저도 앱 내 구현한 서비스 동기화가 성공해야만 도달한다는 점이었습니다. 오프라인 상태에서 탭했거나, 서버 응답이 실패했거나, 동기화 도중 프로세스가 종료되면 콜백 자체가 안 오거나 중간에 빠져나가서 플래그는 영구히 켜진 채로 남습니다. 이 값은 휘발성 메모리가 아니라 디스크에 저장되는 값이라 앱을 재시작해도, 기기를 재부팅해도 되돌아오지 않습니다. 게다가 로딩 아이콘이 떠 있는 동안은 그 자리가 눌리지도 않아서, 사용자가 직접 재시도할 방법조차 없는 완전한 교착 상태였습니다.

해결 방향은 단순했습니다. “성공했을 때만 끈다”가 아니라 “어떤 경로로 끝나든 반드시 콜백이 온다”는 걸 보장하는 것으로 바꿨습니다.

fun startSync(onDone: (Boolean) -> Unit) {
    if (isOffline() || notConnected()) {
        onDone(false)   // 실패도 명시적으로 콜백을 태운다
        return
    }
    launch {
        val ok = try { runSyncTask() } catch (e: Exception) { false }
        onDone(ok)      // 성공/실패/예외 어느 경우든 여기로 수렴
    }
}

그리고 안전망으로, 앱이 새로 시작될 때 이 플래그를 무조건 초기화하도록 추가했습니다. 이미 며칠째 고착돼 있던 사용자도 앱을 한 번 열기만 하면 바로 복구되도록 하기 위해서입니다.

2. 색상 캐시가 채워지기 전에 렌더링되는 경합

두 번째는 “사용자가 지정한 컬러는 간혹 반영되어 있지 않다”는 제보였습니다. 코드를 보니 색상 유효성 검사 로직이 이런 식이었습니다.

fun resolveColor(rawColor: Int): Int {
    return if (rawColor in knownColors) rawColor
    else FALLBACK_COLOR   // 캐시에 없는 색이면 무조건 대체
}

knownColors라는 캐시가 비어있는 상태에서 이 함수가 호출되면, 어떤 색을 넣어도 무조건 “모르는 색”으로 취급되어 대체 색으로 바뀝니다. 그리고 로컬 저장소를 마이그레이션하면서 이 캐시를 채우는 초기화 로직이 동기 호출에서 비동기 방식으로 바뀌어 있었습니다. 예전엔 캐시가 다 채워질 때까지 다른 스레드가 기다렸지만, 지금은 초기화 함수가 즉시 반환하고 캐시는 백그라운드에서 나중에 채워지는 구조였던 겁니다. 이러면 위젯에서는 일시적으로 사용자에게 오해를 불러일으킬 수 밖에 없는 케이스가 발생할 수 있습니다.

그런데 인앱 데이터 리스트 화면에서는 이런 문제를 육안으로 확인할 수는 없었습니다. 앱 화면은 몇백 ms마다 계속 다시 그려지기 때문에, 캐시가 채워지는 순간 다음 프레임에서 자동으로 올바른 색으로 교정됩니다. 반면 위젯은 갱신 한 번당 렌더링도 딱 한 번이고, 그 결과 이미지가 런처 쪽에 그대로 고정됩니다. 캐시가 비어있는 그 짧은 순간에 딱 걸리면, 다음 성공적인 갱신이 오기 전까지 잘못된 색이 영구히 남는 것이었습니다. -> 결국 위젯에도 이런 메커니즘이 적용되어야합니다.

fun resolveColor(rawColor: Int): Int {
    if (knownColors.isEmpty()) return rawColor   // 캐시 미준비 상태면 원본을 그대로 신뢰
    return if (rawColor in knownColors) rawColor else FALLBACK_COLOR
}

“모르면 대체 색으로 바꾼다”가 아니라 “아직 판단할 수 없으면 원래 값을 건드리지 않는다”로 방향을 바꿨습니다. 캐시를 재구성하는 로직도 기존 목록을 비운 뒤 다시 채우던 방식이라 그 사이에 같은 경합이 또 발생할 수 있었는데, 새 목록을 다 만든 다음 한 번에 통째로 교체하는 방식으로 바꿔서 이 구간도 없앴습니다.

정리

두 버그 모두 겉으로는 “위젯이 이상하다”는 하나의 증상으로 보였지만, 원인은 완전히 독립적이었습니다.

  • 상태 플래그는 성공 경로만 되돌아오는 구조라, 실패·예외·오프라인이 조용히 플래그를 고착시켰습니다.
  • 캐시 초기화가 동기에서 비동기로 바뀌면서 생긴 짧은 경합 구간이, 매번 다시 그려지는 화면에서는 안 보이다가 한 번 렌더링하고 끝나는 위젯에서만 영구히 드러났습니다.

공통점을 하나 꼽자면, 두 경우 모두 “정상 경로”만 보고 고쳤을 때는 재현이 안 되다가, 화면이 한 번만 렌더링되고 끝나는 위젯이라는 특수한 실행 맥락에서만 숨어있던 경합·예외 처리 누락이 드러났다는 점입니다. 위젯은 인앱보다 사용자에게 우선적으로 보일 수 있는 부분이기에 이러한 데이터 갱신 처리 부분에서 초기화 맥락에 대한 주의가 더 필요하겠습니다.

Comments