이전 회사에서 네이버 지도를 사용하다 발생하였던 이슈에 대해서 정리를 한번 하고자 합니다.
개발 상에서는 이슈가 전혀 없던 앱이 상용 배포 후 Crashlytics를 통해 MissingLibraryException를 종종 발생한다는 이슈였었죠.
발단
네이버 지도를 붙인 기능이 배포된 후 얼마 후 Crashlytics를 통해서 이상한 오류를 잡아내기 시작합니다.
해당 오류에서 가르키는 부분들은 개발 중에는 본 적이 없는 키워드 들이었습니다.
Caused by com.getkeepsafe.relinker.MissingLibraryException
Could not find 'libnavermap.so'. Looked for: [arm64-v8a, armeabi-v7a, armeabi], but only found: [].
하지만 설치된 단말과 백업한 앱을 다시 설치한 단말의 칫셉이 다르다면? 혹은 split apk의 base apk만 설치된다면??
한번 설치된 앱의 APK를 추출하여 다른 단말에 설치 후 실행해봅니다.
두둥. 재현이 되기 시작합니다.
테스트
당시의 테스트 방법이었습니다.
1. 일반 1-1. 구글플레이를 통해 설치한 앱의 APK 를 추출. a. CX파일탐색기 같은 앱 사용하여 APK 추출. (구글플레이 검색) b. Android Studio의 Device File Explorer 등을 사용하여 APK 복사 1-2. AAB로 업로드된 앱을 설치 후 1번 방법으로 APK 를 추출하면 정상적으로 설치가능한 APK가 나오지 않음. 1-3. 추출한 APK 속의 base.apk 를 추출하여 단말에 설치. 1-4. 앱 실행. (이때 크래시 발생)
ResourceTest :: 0. android.graphics.drawable.StateListDrawable@e71743d
ResourceTest :: 2. android.content.res.Resources$NotFoundException: Drawable com.test.app:color/selector_rect_color with resource ID #0x7f0601c3
2. 문제 범위 확인
문제 발생시 CallStack을 확인하여 비교할 코드 범위를 좁혀봅니다.
android.content.res.Resources$NotFoundException: Drawable com.test.app:color/selector_rect_color with resource ID #0x7f0601c3
Caused by: android.content.res.Resources$NotFoundException: File res/color/selector_rect_color.xml from drawable resource ID #0x7f0601c3
at android.content.res.ResourcesImpl.loadDrawableForCookie(ResourcesImpl.java:847)
at android.content.res.ResourcesImpl.loadDrawable(ResourcesImpl.java:631)
at android.content.res.Resources.getDrawableForDensity(Resources.java:888)
at android.content.res.Resources.getDrawable(Resources.java:827)
at android.content.Context.getDrawable(Context.java:626)
...
Caused by: org.xmlpull.v1.XmlPullParserException: Binary XML file line #3: <item> tag requires a 'drawable' attribute or child tag defining a drawable
at android.graphics.drawable.StateListDrawable.inflateChildElements(StateListDrawable.java:190)
at android.graphics.drawable.StateListDrawable.inflate(StateListDrawable.java:122)
at android.graphics.drawable.DrawableInflater.inflateFromXmlForDensity(DrawableInflater.java:142)
at android.graphics.drawable.Drawable.createFromXmlInnerForDensity(Drawable.java:1332)
at android.graphics.drawable.Drawable.createFromXmlForDensity(Drawable.java:1291)
at android.content.res.ResourcesImpl.loadDrawableForCookie(ResourcesImpl.java:833)
... 20 more
위 로그로 ResourcesImpl.loadDrawableForCookie 에서 Exception을 발생함을 확인할 수 있습니다.
Java로 작성된 코드들을 일부 Kotlin으로 변환하고 문법을 조금씩 손을 보는 작업을 진행 중이었죠.
컴파일 오류도 없고 빨간 줄 하나 없이 잘 빌드되었고 문제가 없는 줄 알았습니다.
하지만 앱에는 내부적으로 문제가 있었죠. 그 원인은 이런 것이었습니다.
Java 클래스 2개가 있었고 일부 동일한 멤버변수들이 가지고 있는 클래스들이었습니다.
이 클래스들을 서로 다른 레이어의 Model로 있으면 레이어 간 데이터 전달시 서로 Mapping되는 역할이 있었죠.
그러다보니 동일한 멤버들을 서로의 멤버에 부어서 변환을 해주는 메서드들이 있었고요.
여기서 apply로 빌더처럼 데이터를 전달하다가 문제가 발생하였습니다.
해당코드를 샘플로 다시 작성 해봤습니다.
class Human {
var sleep: Int = 0
var eat: Int = 0
var play: Int = 0
}
class Student {
var sleep: Int = 0
var eat: Int = 0
var study: Int = 0
fun getHuman(): Human {
return Human().apply {
sleep = sleep
eat = eat
}
}
}
Human 과 Student 클래스가 있고 Student 클래스를 Human으로 맵핑하면서 동일한 데이터를 전달하는 과정입니다.
아래를 보면 바로 Human 생성 후 return을 하는 것이 보이고 apply로 필요한 데이터를 전달하는 것을 볼 수 있습니다.
하지만 이때 sleep과 eat 변수들을 주의깊게 보질 않았던 것이 문제였습니다.
return Human().apply {
sleep = sleep
eat = eat
}
위에서 보이는 sleep과 eat는 apply 내에서 this 즉 Human의 멤버들을 뜻하게 됩니다.
그래서 제가 Human에 넘기려던 Student의 데이터들이 제대로 복사가 안되었던 것입니다.
Android 에서는 Geofencing 기능을 통해 현재 위치가 특정 위치 반경에 진입/머물기/이탈 등의 이벤트를 처리 가능합니다.
단말 유저 당, 앱 당 최대 100개의 geofence 를 사용할 수 있습니다.
You can have multiple active geofences, with a limit of 100 per app, per device user.
Location Services에 geofence 를 위한 이벤트들을 요청해놓을 수 있습니다.
요청한 변경들이 감지되면 BroadcastReciever 등을 통해 처리가 가능합니다.
반경 진입
반경 이탈
반경 진입 후 지정한 시간 동안 이탈 안함
geofence 만료 시간 (만료 시간 후 Location Service는 geofence를 알아서 지웁니다.)
항목
설명
최대 등록 수
100 per app, per device user
만료시간
Geofence 마다 millisecond 로 지정. NEVER_EXPIRE 옵션 지원. 시간 지정 후 만료시 자동으로 Geofence 제거됨.
영역 지정
Circle 형태로 등록 되며, 위경도와 반경(m) 지정. 최상의 결과는 반경 100~150m 사이로 설정해야 함. Wi-FI 사용 가능시 20~40m 까지 가능. 실내 위치 사용시는 5m 정도로 작을 수도 있음. Geofence 내부 위치를 알수 없다면 Wi-Fi 정확도는 약 50m라고 가정함. Wi-Fi 위치 사용할 수 없다면 수백m ~ 수Km 편차 생길수도 있음.
Google Play 서비스 업그레이드나 리소스 제한으로 종료 후 재시작시 Location 프로세스 충돌시
Geofence 재등록이 필요한 경우
단말 재부팅시 부팅 완료 시점을 받아 다시 등록해야 함. 앱이 제거 후 재 설치시 앱 데이터 삭제시 Google Play 서비스 데이터 삭제시 앱에 GEOFENCE_NOT_AVAILABLE 알림 수신시 (일반적으로 Android's Network Location Provider 비활성화시 발생함)
Geofence 진입이 잘 동작하지 않는 경우 (GEOFENCE_TRANSITION_ENTER)
Geofence 정확한 위치를 사용할 수 없거나 반경이 너무 작은 경우 Wi-Fi가 꺼져 있는 경우 (SettingsClient를 사용해 장치 설정 체크 필요) - 참고로 Android 10에서는 WifiManager.setEnabled() 호출 불가하므로 설정 패널 사용. - 시스템 앱이나 Device policy controller 에서는 가능. Geofence 내 안정적인 네트워크 연결이 없을 경우 단순히 알림이 늦는 경우. - 지속적으로 위치 쿼리하는 형식이 아니라 대기 시간이 필요함. - 일반적인 대기 시간은 2 분 미만이며 장치가 움직일 때는 훨씬 적음. - 백그라운드 위치 제한 적용시 대기 시간은 평균 약 2~3분 정도. - 단말이 장시간 정지된 경우 대기 시간이 증가할 수 있음. (최대 6분)
Android App 샘플 만들기
1. 권한 추가
먼저 권한을 아래와 같이 추가 후 앱에서 permission 체크 및 요청 로직을 추가합니다.
ACCESS_BACKGROUND_LOCATION 권한은 Android 10 부터 필요합니다.
ex) 위치 및 반경, 어떤 이벤트를 걸건지, 만료나 머무는 체크 시간을 얼마나 할지 등등
MainActivity.kt
val geofenceList: MutableList<Geofence> by lazy {
mutableListOf(
getGeofence("현대백화점", Pair(37.5085864,127.0601149)),
getGeofence("삼성역", Pair(37.5094518,127.063603))
)
}
private fun getGeofence(reqId: String, geo: Pair<Double, Double>, radius: Float = 100f): Geofence {
return Geofence.Builder()
.setRequestId(reqId) // 이벤트 발생시 BroadcastReceiver에서 구분할 id
.setCircularRegion(geo.first, geo.second, radius) // 위치 및 반경(m)
.setExpirationDuration(Geofence.NEVER_EXPIRE) // Geofence 만료 시간
.setLoiteringDelay(10000) // 머물기 체크 시간
.setTransitionTypes(
Geofence.GEOFENCE_TRANSITION_ENTER // 진입 감지시
or Geofence.GEOFENCE_TRANSITION_EXIT // 이탈 감지시
or Geofence.GEOFENCE_TRANSITION_DWELL) // 머물기 감지시
.build()
}
4. Geofence Client 생성
Location API 사용을 위하여 Geofencing Client 인스턴스를 생성해야 합니다.
MainActivity.kt
private val geofencingClient: GeofencingClient by lazy {
LocationServices.getGeofencingClient(this)
}
5. Geofencing Request 빌드
Geofence 지정 및 관련 이벤트 트리거 방식을 설정하기 위해 GeofencingRequest 를 빌드합니다.
MainActivity.kt
private fun getGeofencingRequest(list: List<Geofence>): GeofencingRequest {
return GeofencingRequest.Builder().apply {
// Geofence 이벤트는 진입시 부터 처리할 때
setInitialTrigger(GeofencingRequest.INITIAL_TRIGGER_ENTER)
addGeofences(list) // Geofence 리스트 추가
}.build()
}
6. Broadcast Receiver 추가
Geofencing 변경 이벤트를 받을 BroadcastReceiver 를 추가 후 등록해줍니다.
GeofenceBroadcastReceiver.kt
class GeofenceBroadcastReceiver: BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
val geofencingEvent = GeofencingEvent.fromIntent(intent)
if (geofencingEvent.hasError()) {
val errorMessage = GeofenceStatusCodes.getStatusCodeString(geofencingEvent.errorCode)
Log.e("GeofenceBR", errorMessage)
return
}
// Get the transition type.
val geofenceTransition = geofencingEvent.geofenceTransition // 발생 이벤트 타입
// Test that the reported transition was of interest.
if (geofenceTransition == Geofence.GEOFENCE_TRANSITION_ENTER ||
geofenceTransition == Geofence.GEOFENCE_TRANSITION_EXIT) {
// Get the geofences that were triggered. A single event can trigger
// multiple geofences.
val triggeringGeofences = geofencingEvent.triggeringGeofences
val transitionMsg = when(geofenceTransition) {
Geofence.GEOFENCE_TRANSITION_ENTER -> "Enter"
Geofence.GEOFENCE_TRANSITION_EXIT -> "Exit"
else -> "-"
}
triggeringGeofences.forEach {
Toast.makeText(context, "${it.requestId} - $transitionMsg", Toast.LENGTH_LONG).show()
}
} else {
Toast.makeText(context, "Unknown", Toast.LENGTH_LONG).show()
}
}
}
이 모델을 사용하면 데이터베이스가 단일 소스 Repository 역할을 하며 앱의 다른 부분은 Repository를 사용하여 데이터베이스에 엑세스 합니다. 디스크 캐시 사용 여부와 상관없이 Repository에서 데이터 소스를 나머지 앱의 단일 소스 Repository 로 지정하는 것이 좋습니다.
진행 중인 작업 표시
SwipeRefresh 같은 사용 사례에서는 네트워크 작업시 UI 표시가 중요합니다.
다양한 이유로 데이터가 업데이트 될 수 있으므로 실제 데이터와 UI 작업을 분리하는 것이 좋습니다.
LiveData 유형의 객체를 반환하도록 변경하고, 이 객체에는 네트워크 작업 상태가 포함됩니다.
Repository 클래스에 데이터 새로고침 상태를 반환할 수 있는 다른 함수를 제공합니다.
각 구성요소 테스트
관심사 분리의 원칙을 따랐을 때 한 가지 중요한 이점은 테스트 가능성입니다.
UI 및 인터렉션 : Android UI Instrumentation Test 를 사용합니다. Espresso 라이브러리를 사용하는 것이 가장 좋으며, Mock으로 ViewModel을 제공할 수 있습니다. Activity나 Fragment는 ViewModel 하고만 통신하므로 이 클래스만으로도 충분히 UI 를 테스트 할 수 있습니다.
ViewModel : JUnit Test 를 사용하여 ViewModel 클래스를 테스트할 수 있습니다. Repository만 Mock 객체로 테스트하면 됩니다.
Repository : 역시 JUnit Test로 테스트할 수 있습니다. WebService난 DAO를 Mock 객체로 테스트하면 됩니다.
Repository가 올바른 WebService를 호출하는지
Repository가 데이터베이스에 결과를 저장하는지
데이터가 캐시되고 최신인 경우 Respository가 불필요한 요청은 안만드는지
WebService와 DAO 는 모두 인터페이스이므로 더 복잡한 테스트를 위해 Mock이나 fake 구현을 만들 수 있습니다.
DAO : Instrumentation Test를 사용하여 테스트합니다. UI 구성요소가 필요하지 않으므로 빠르게 실행되며, 각 테스트에서 메모리 내에 데이터베이스를 만들어 테스트 부작용이 없게 합니다. (ex. 디스크의 DB 파일 변경)
Room에서 데이터베이스 구현을 지정하도록 허용하므로 SupportSQLiteOpenHelper 의 JUnit 구현을 제공하여 DAO를 테스트할 수 있습니다. 하지만 기기에서 실행 중인 SQLite 버전이 개발 PC의 SQLite 버전과 다를 수 있으므로 이 방법은 권장되지 않습니다.
WebService : 테스트에서 백엔드로 네트워크 호출하지 않도록 합니다. 웹 기반 테스트를 포함한 모든 테스트가 외부로부터 독립적이어야 합니다. MockWebServer 를 포함하여 여러 라이브러리를 활용하여 가짜 로컬 서버를 만들 수 있습니다.
아티팩트 테스트 : 아키텍처 구성요소는 백그라운드 스레드를 제어할 수 있는 maven 아티팩트를 제공합니다.
`androidx.arch.core:core-testing` 아티팩트에는 다음과 같은 JUnit 규칙이 있습니다.
InstantTaskExecutorRule : 호출 스레드에서 백그라운드 작업을 즉시 실행합니다.
CountingTaskExecutorRule : 아키텍처 구성요소의 백그라운드 작업을 기다립니다. Espresso와 연결할 수 있습니다.
권장사항
다음 권장사항은 필수는 아니지만 경험에 의한 권장사항을 따르는 경우 장기적으로 더 강력하고 테스트 및 유지관리가 쉬운 코드 베이스를 만들 수 있습니다.
Activity, Service, Broadcast Receiver와 같은 앱의 진입점을 데이터 소스로 지정하지 마세요.