안드로이드를 개발하다 보면 에러를 마주하게 된다.
이러한 에러들을 어떻게 분석하는지와 에러를 개선하는 방법에 대해 알아볼 것이다.
에러를 개선할 때 에러 종류에 따라 어떤 식으로 설계해야 하는지도 같이 알아볼 것이다.
에러 분석 방법
먼저 에러를 분석하는 방법은 크게 어플리케이션이 출시 전과 출시 후로 나뉜다
어플리케이션 출시 전 | 어플리케이션 출시 후 |
Logcat, JUnit Test, JVM 테스트, 실 기기 테스트 | Android Vitals |
어플리케이션 출시 전
코딩하면 흔하게 사용하는 방법으로 에러를 확인하고 분석한다.
Logcat -> IDE에서 제공하는 로그 창에서 에러 로그를 확인
JUnit Test -> JUnit과 같이 테스트 도구를 사용해서 단위 테스트를 하며 에러를 확인
JVM 테스트 -> 가상 머신에서 어플리케이션을 실행해서 에러를 확인
실 기기 테스트 -> 실제 기기에서 어플리케이션을 실행해서 에러를 확인
이외에도 많은 방법이 있지만 내가 주로 사용하는 방법 위주로 적었다.
어플리케이션 출시 후
실제 어플리케이션 사용자들에게서 에러 데이터를 수집하여 에러를 확인한다.
Android Vitals -> Google Play Console에서 제공하는 에러 분석 모니터링 도구
위와 같이 어디서 어떤 오류가 발생했는지 모니터링을 해준다.
에러 종류
위의 방법으로 에러를 분석했다면 에러 종류에 대해 알아볼 것이다.
개발 언어의 오류로 인한 에러나 오타 같은 에러 제외하고 안드로이드 특수성을 가진 에러만 다루겠다.
안드로이드의 에러 종류에는 크게 ANR과 비정상 종료가 있다.
ANR
Android 앱의 UI 스레드가 너무 오랫동안 차단되면 ANR(Application Not Responding) 오류가 트리거된다.
쉽게 말해서 안드로이드의 뷰는 이벤트가 발생하면 즉각적으로 응답해야 하는데 시간이 걸리는 작업이 생기면 그만큼 뷰가 반응하지 못하고 ANR 에러를 띄우는 것이다.
사용자가 ANR 대화상자에서 앱을 강제 종료할 수 있도록 설계되어 있다.
ANR은 다음과 같은 상황에서 발생한다.
- 입력 전달 타임아웃: 앱이 입력 이벤트(예: 키 누름 또는 화면 터치)에 5초 이내에 응답하지 않은 경우
- 서비스 실행: 앱에서 선언한 서비스가 몇 초 이내에 Service.onCreate() 및 Service.onStartCommand()/Service.onBind() 실행을 완료할 수 없는 경우
- Service.startForeground()가 호출되지 않음: 앱이 Context.startForegroundService()를 사용하여 포그라운드에서 새 서비스를 시작했지만 서비스가 5초 내에 startForeground()를 호출하지 않은 경우
- 인텐트 브로드캐스트: BroadcastReceiver가 설정된 시간 내에 실행을 완료하지 못한 경우. 앱에 포그라운드 활동이 있는 경우 이 제한 시간은 5초입니다.
UI 스레드 내부에 오래 걸리는 작업이 있는지 확인
ANR이 가장 많이 발생하는 시간이 오래 걸리는 대표적인 작업은 서버와 통신하는 네트워크 작업이다.
따라서 ANR은 UI스레드(메인 스레드)에서 오래 걸리는 작업이 실행되면 발생하는 에러이기 때문에 네트워크 통신과 같은 오래 걸리는 작업을 할 때는 비동기 처리를 통해 새로운 스레드에서 오래 걸리는 작업을 실행해주는 것이다.
그래서 네트워크 통신은 Volly나 Retrofit 라이브러리를 사용하면 기본적으로 비동기 처리로 작업된다.
네트워크 통신이 아니더라도 RX라이브러리나 Coroutine을 사용해서 비동기 처리를 하면 된다.
프로젝트에서 네트워크 통신을 레트로핏과 코루틴을 사용해서 비동기로 처리했는데 이렇게 한 이유가 바로 ANR 에러를 고려해서 작성한 것이다.
레트로핏과 Coroutine을 사용해서 비동기 처리 작업을 하는 방법은 아래 포스팅에 자세하게 설명했으니 참고 하면 된다.
비정상 종료
비정상 종료는 ANR을 제외한 나머지 에러 대부분이 포함되어 있다.
대표적으로 Throwable 클래스로 표현되는 처리되지 않은 예외가 발생하면 비정상 종료된다.
비정상 종료가 발생하는 경우는 개발자가 잘못된 코드를 작성하거나 NullPointException이라던지, 수많은 곳에서 발생할 수 있다.
따라서 안드로이드가 가지고 있는 특수성을 고려해 에러가 났을 때 고려해야 할 사항들을 뽑아봤다.
액티비티 생명주기에 맞게 설계되었는지 확인
액티비티를 다룰 때 중요한 점은 생명주기를 이해하고 각 상황에 적절하게 대처해야 한다.
액티비티 생명주기(Activity Life Cycle)
생명주기란 액티비티가 생성되어 소멸하기까지의 과정을 말하며, 액티비티 클래스는 액티비티가 상태변화를 알아차릴 수 있는 여러가지 콜백 함수를 제공한다.
액티비티의 상태는 3가지로 구분
- 활성: 액티비티 화면이 출력되고 있고 사용자가 이벤트를 발생시킬 수 있는 상태 [onCreate() -> onStart() -> onResume()]
- 일시정지: 액티비티의 화면이 출력되고 있지만 사용자가 이벤트를 발생시킬 수 없는 상태 [onPause() -> 활성화 -> onResume()]
- 비활성: 액티비티의 화면이 출력되고 있지 않는 상태 [onPause() -> onStop() -> 활성화 -> onRestart() -> onStart() -> onResume()]
예시를 들면 아래 코드는 UI 스레드에서 실시간으로 타이머가 흐르는 액티비티를 만든 코드이다.
타이머가 액티비티에 실시간으로 시간을 업데이트하고 있다.
class FindId : AppCompatActivity() {
//onCreate
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mBinding = ActivityFindIdBinding.inflate(layoutInflater)
setContentView(binding.root)
...생략...
}
//UI Thread 위에 그리는 타이머
private fun setTimer() {
second = timeTick % 60
minute = timeTick / 60
time = timer(period = 1000, initialDelay = 1000) {
runOnUiThread {...생략...}
}
...생략...
}
//onDestroy
override fun onDestroy() {
mBinding = null
time?.cancel()
super.onDestroy()
}
}
UI에 실시간으로 그리기 때문에 액티비티가 활성, 일시정지, 비활성 상태일 때를 모두 고려하여 코드를 작성해야 에러를 피할 수 있다.
타이머를 활성 상태일 때와 일시정지 상태일 때 비활성 상태일 때 모두 타이머가 흘러가도록 설계를 하여 딱히 건들진 않았지만 액티비티가 onDestroy() 상태일 때 비정상 종료가 되었다.
에러를 분석한 결과 액티비티가 종료되어도 타이머는 계속 종료된 UI 스레드에 시간을 업데이트하려고 했기 때문에 에러가 발생했었다.
따라서 코드안에 onDestroy() 함수 내부에 타이머가 멈추도록 time?.cancel() 이라는 코드를 작성한 것을 볼 수 있다.
위 상황처럼 액티비티 생명주기에 따라 비정상 종료가 되는 상황이 자주 발생이 되기 때문에 항상 액티비티 생명주기를 고려해서 코드를 설계를 해야 한다.
API 레벨 호한성 고려해서 설계되었는지 확인
안드로이드에서는 minSdk와 targetSdk를 설정할 수 있다.
minSdk = 지정한 SDK 이상부터 설치 가능
targetSdk = 지정한 API 레벨로 개발한다는 의미
여기서 앱을 개발할 때 minSdk에서 설정한 API레벨보다 높은 상위 API레벨부터 제공하는 라이브러리를 사용할 때 호환성을 고려해야 한다.
예시를 들면 ImageDecoder라는 클래스를 사용하려고 하는데 개발 환경이 minSdk는 21, targetSdk는 33이라고 가정한다.
ImageDecoder에 관한 문서를 찾아보면 오른쪽 상단에 Added in API level 28이라고 써있다.
이 말은 API level 28에 해당 기능이 추가되었다는 말이기에 API level 28 이상인 기기에서는 호환성에 문제가 없지만 그 이하인 API level 21 ~ 28까지는 호환성에 문제가 생길 수 있다는 것이다.
이런 경우 아래와 같이 Annotation을 사용해서 오류를 무시할 수 있다.
@RequiresApi(Build.VERSION_CODES.P)
fun imageDecode() {
...생략...
}
//또는
@TargetApi(Build.VERSION_CODES.P)
fun imageDecode() {
...생략...
}
따라서 실질적으로 지원하는 모든 API에서 실행이 잘 되도록 코드를 구성하기 위해서는 다음과 같이 조건문으로 처리를 하면 된다.
Build.VERSION_SDK_INT는 실행되는 기기의 API 레벨이다.
if(Build.VERSION_SDK_INT >= Build.VERSION_CODES.P) {
//API level 31이상에서 실행되는 코드
} else {
//API level 31미만에서 실행되는 코드
}
프로젝트에서 학생인증 이미지를 불러올 때 ImageDecoder를 사용해서 이미지를 호출 했었는데 API level 33인 갤럭시 S20에서는 문제 없이 이미지를 호출했지만 API level 27인 갤럭시S7에서 비정상 종료가 되었다.
에러 종류에 따라 안드로이드에서 주로 나타나는 특수한 에러 상황을 알아봤는데 사실 위와 같은 특수한 경우보다 일반적인 코드 에러가 더 많이 난다.(개발자 능력 이슈..)
그래도 UI 스레드 관리와 액티비티 생명주기와 API 레벨 호환성을 항상 고려하면서 설계하면 뜻밖의 골치아픈 에러가 생기는 경우는 어느정도 막을 수 있다.
'Android' 카테고리의 다른 글
[Android] LiveData VS Kotlin Flow with Chat GPT (1) | 2023.03.11 |
---|---|
[Android] Android Architecture (0) | 2023.03.03 |
[Android] REST API 예외처리 (0) | 2023.02.10 |
[Android] Retrofit Multipart로 이미지 전송하기 (0) | 2023.02.05 |
[Android] Retrofit + Kotlin Coroutine 서버 통신하기 (0) | 2023.01.29 |