이동식 저장소

한빛 캘린더 버그 소탕 대작전 본문

프로젝트/블린더

한빛 캘린더 버그 소탕 대작전

해스끼 2022. 10. 5. 21:50

한빛 캘린더는 내가 일하고 있는 한빛맹학교의 식단 및 학사일정을 알려주는 달력 앱이다. 그런데 앱을 처음 개발할 때부터 나를 괴롭히던 버그가 하나 있다.

 

학사일정은 전부 보이는데, 식단이 제대로 보이지 않는 것. 정확히는 처음 몇 일 간의 식단은 잘 보이지만, 대략 매달 10일 이후의 식단은 보이지 않는다.

 

[Bug] 9월 8일 이후의 식단이 보이지 않음 · Issue #23 · mwy3055/hanbit-calendar

왜?

github.com

날짜를 보면 알겠지만 무려 1달 전부터 있었던 버그이다. 1.0 출시가 8월 말이니까 사실상 출시 이후로 계속 있었다고 보면 될 듯.

 

버그를 살펴보기 전에 먼저 앱의 구조를 이해해 보자.

아키텍쳐

크게 원격에서 데이터를 가져와 로컬에 저장하는 부분(노란색 음영)로컬에 저장된 데이터를 UI에 보여주는 부분으로 나뉜다.

먼저 원격에서 가져오는 과정을 보자. 서버에 저장된 식단 정보를 ``RemoteRepository``가 가져오고, 가져온 데이터를 ``LocalRepository``에 넘겨준다. ``LocalRepository``는 넘겨받은 데이터를 Room 데이터베이스에 저장한다. 데이터를 가져오는 모든 과정은  ``FetchWorker``가 백그라운드로 처리한다. 

 

데이터를 UI에 보여주는 과정은 매우 직선적이다. ``LocalRepository``가 Room 에서 식단 데이터를 가져오면, ``UseCase``에서 식단 데이터와 학사일정 데이터를 합치고(식단 부분은 그림에서 생략), ``ViewModel``에서 UI가 사용할 속성만 취사선택하여 UI에 전달한다.

이상하다

그런데 이 버그에는 아주 특이한 점이 있다. Android Studio에서 에뮬레이터로 실행하면 모든 식단을 정상적으로 잘 보여주는데, 앱을 Google Play에서 설치하면 다시 버그가 발생한다. 나는 Bitrise라는 Continuous Delivery 서비스를 이용해 앱을 배포하는데, 신기하게도 Bitrise에서 빌드된 APK를 설치하면 문제가 발생하지 않는다. 오로지 Google Play에 업로드된 APK를 설치했을 때에만 버그가 발생한다.

 

이게 뭐지? 이런 버그는 듣도 보도 못했다. 모든 디버깅 환경에서 정상적으로 작동하는데 최종 사용자 기기에서만 버그가 생긴다고?

 

?????

사흘 동안 구글이 잘못한 게 아닐까 의심했는데, 아무리 생각해도 구글보다 내가 틀렸을 확률이 더 크다. 또 내가 잘못했겠지..

 

버그나 잡자. 

다시 버그로 돌아와서

지금 이 앱에는 식단 데이터의 일부가 보이지 않는 문제가 있다. 1) 데이터를 가져오는 과정 또는 2) 데이터를 보여주는 과정이 잘못됐기 때문이다. 물론 둘 다 잘못됐을 수도 있고.

 

일단 가장 작은 단위부터 검증해 보자. ``RemoteRepository``, ``LocalRepository`` 등 데이터 전달 과정에 참여하는 모든 객체를 단위 테스트 및 로그로 검증한 결과, 모든 객체가 자신의 역할을 완벽히 수행했다. ``RemoteRepository``는 식단 정보를 빠짐없이 가져왔고, ``LocalRepository``는 가져온 데이터를 모두 저장했으며, ``UseCase``와 ``ViewModel`` 역시 내가 의도한 대로 데이터를 잘 가공하고 있었다.

 

흠... 뭘까? 각 unit에 문제가 없다면, unit을 묶은 worker에 문제가 있는 게 아닐까?

아니다

``FetchWorker``를 테스트한 결과, 역시 정상적으로 작동함을 확인했다. Unit에도 문제가 없고, unit을 묶은 worker에도 문제가 없다. 뭐지? 내가 문제인가?

 

슬슬 벽 느끼기 시작.

유레카......?

앱을 계속 써 보다 새로운 사실을 알아냈다. 바로 매달 5개의 식단만 보인다는 것. 예를 들어 2022년 10월의 경우 4, 5, 6, 7, 11일의 식단만 보이고 그 뒤부터는 보이지 않는다.

 

5개라는 숫자에 주목해 보자. 왜냐하면 나이스 API에서 API Key가 없을 때 데이터를 최대 5개만 반환하기 때문이다. 그런데 생각해 보니 앱에서는 나이스 API에 직접 접근하지 않는다. 나이스 API에서 데이터를 가져오는 건 AWS 서버이고, 서버에는 당연히 데이터가 모두 저장돼 있다.

어쩔

뭔가 결정적인 발견인 줄 알았는데... 이제 저는 어찌해야 할까요.

ProGuard가 또?

내 글을 보면 알겠지만, 최근에 Proguard에 당한 적이 많다.

 

Gson에서 null이 반환될 때 with ProGuard

알 수 없는 에러 Proguard를 적용한 후 이상한 에러가 발생한다. java.lang.NullPointerException: Parameter specified as non-null is null: method com.practice.hanbitlunch.screen.Menu. , parameter name at..

thinking-face.tistory.com

 

[Android] Proguard가 사람 잡는다

이미 Proguard와 관련된 디버깅 글을 여러 번 쓴 바 있다. [Android] 코드 경량화 시 Instrumented test가 실행되지 않는 오류 [Android] R8 컴파일러로 앱 경량화하기 왜 경량화해야 하는가? APK 파일에는 실행.

thinking-face.tistory.com

사실 두번째 글은 Proguard 탓이 아니었다. 버그가 발생하지 않았던 이유는 에뮬레이터에서 앱을 실행했기 때문이지, Proguard 규칙을 추가했기 때문이 아니다.

 

어쨌든 의심은 자유니까. Gson 규칙은 이미 추가했고, 나머지 라이브러리는 별 문제가 없을 것 같아서 Retrofit 규칙만 추가하기로 결정했다. 친절하게도 Retrofit을 개발한 square 팀이 이미 규칙을 정의해 놓았다. 그냥 라이브러리에 내장하지..

 

GitHub - square/retrofit: A type-safe HTTP client for Android and the JVM

A type-safe HTTP client for Android and the JVM. Contribute to square/retrofit development by creating an account on GitHub.

github.com

효과는 미미했다...

미미한 게 아니라 아무 효과가 없었다. 문제가 전혀 해결되지 않았다. Proguard 탓은 아닌 걸로.

그럼 누구 탓이냐

코드 쓴 니 탓이지.... 점점 의식의 흐름대로 가는 것 같다면 정확히 봤다. 이때부터 슬슬 미쳐가기 시작했다.

 

문득 생각 하나가 머릿속을 스쳐 지나갔다. 아직 테스트하지 않은 부분이 남아 있다.

``WorkManager``?

AndroidX ``WorkManager``는 백그라운드 작업을 한 번 또는 일정 주기마다 실행해 준다. ``FetchWorker``는 매일 한 번씩 실행하도록 정의돼 있다. ``FetchWorker``를 ``WorkManager``에 등록하는 코드는 다음과 같다.

fun setPeriodicFetchMealWork(workManager: WorkManager) {
    val periodicWork = PeriodicWorkRequestBuilder<FetchRemoteMealWorker>(1, TimeUnit.DAYS)
        .addTag(fetchRemoteMealWorkTag)
        .build()
    workManager.enqueueUniquePeriodicWork(
        fetchRemoteMealWorkTag,
        ExistingPeriodicWorkPolicy.KEEP,
        periodicWork
    )
}

``enqueueUniquePeriodicWork`` 함수의 두 번째 파라미터는 같은 태그를 가진 작업이 이미 존재할 경우 어떻게 할 지 결정한다. 나는 work가 이미 존재한다면 기존 work를 계속 사용하기 위해 ``KEEP`` 옵션을 주었다.

 

이 함수는 ``MainActivity``에서 실행된다. 앱이 설치된 후 맨 처음 실행될 때에만.

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    enqueuePeriodicWork()
    // ...
}

// 앱을 첫 번째로 실행할 때에만...
private suspend fun enqueuePeriodicWork() {
    val isFirstExecution = preferencesRepository.fetchInitialPreferences().isFirstExecution
    if (isFirstExecution) {
        setPeriodicWork()
        preferencesRepository.updateIsFirstExecution(false)
    }
}

// ...work를 enqueue한다.
private fun setPeriodicWork() {
    val workManager = WorkManager.getInstance(this)
    setPeriodicFetchMealWork(workManager)
}

그런데 굳이 앱을 첫 번째로 실행하는지 확인할 필요가 있을까? 이미 existing policy로 ``KEEP`` 값을 주기 때문에 불필요한 확인이라는 생각이 들었다.

 

첫 실행인지 확인하지 말고, ``MainActivity``가 실행될 때마다 매번 work를 enqueue해도 될 것 같다. 이미 work가 있다면 알아서 keep되겠지.

override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setPeriodicWork()
    // ...
}

// work를 enqueue한다.
private fun setPeriodicWork() {
    val workManager = WorkManager.getInstance(this)
    setPeriodicFetchMealWork(workManager)
}

버그 해결?

어? 왜 되지?

 

어?

어?

진짜 ``WorkManager``가 문제였던 거야?

 

ㅋㅋㅋㅋㅋㅋㅋㅋ

고치고 나서 실제로 1분동안 미친듯이 웃었다. 동시에 허무함이 물결처럼 밀려들었다. 무엇을 위해 이토록 헤맸는가... 레고 블럭은 잘 만들었는데 끼우는 부분이 문제였구만. 역시 난 항상 마무리가 아쉽단 말야.


진짜 원인은?

버그의 주요 원인은 다음과 같다.

  1. Google Play에서 APK를 설치해야 한다.
  2. WorkManager에서 문제가 있었다...?

1번은 짐작 가는 부분이 있다. 구글 플레이에는 APK가 아니라 AAB가 업로드되고, AAB에서 기기별로 필요한 부분을 뽑아 APK가 만들어진다. 아마 APK를 만드는 과정에서 뭔가 문제가 있지 않았을까 추측하고 있다.

 

그런데 2번은 뭘까? Work가 아예 실행되지 않는 것도 아니고, 데이터를 가져오긴 하는데 앞의 5개만 가져오는 이유는 뭘까? 원인을 알고 싶어서 예전 코드를 다시 빌드해 봤는데, 이제는 또 버그가 발생하지 않는다?

 

끝까지 이러기야? 쩝.. 아쉽지만 근본적인 원인을 파악하기는 어려울 것 같다. 


소감

항상 물리적으로 생각하는 습관을 들여야 한다. A와 관련된 문제가 발생했다면 A가 문제일 수도 있고, A를 사용하는 객체가 문제일 수도 있고, 아니면 A에 사용된 라이브러리가 문제일 수도 있다. 가장 작은 단위부터 하나씩 검증하는 습관을 들여야 하고, 검증 결과 틀린 부분이 없다면 기뻐해야 한다. 원인의 후보가 줄어들었기 때문이다. 

 

하지만 막막한걸 어떡해.. 맞으면서 익숙해지는 수밖에 없겠지. 애초에 코드를 간단하게 잘 짜면 될 일이다.

 

뭐 어쨌든 버그 해결~~ 드디어 이슈 닫는다 히히

수고~

 

Comments