본문 바로가기

안드로이드

오류처리의 시작은 의미있는 로그와 그것을 부릅뜨고 관찰하는 개발자이다

꼼꼼한 오류처리 달성하셨나요?

우테코에서 진행한 Trip Draw 프로젝트의 제1 목표로 삼은것이 꼼꼼한 오류처리였다. 물론 원래도 오류처리를 좋아하지만 이번에는 혼신을 다해서 진짜 기존까지 했던 기술들을 총동원하자는 목표가 있었다.

 

물론 우테코 특성상 레벨 3가 끝난 2023.08.22 시점에 Crashlytics 를 통한 모니터링 환경 구축(추가 작업 필요), sealed class 로 경우의 수를 나눈 callAdpater, callAdapter를 거쳐나온 코드의 보일러 플레이트를 제거하며 default handler 를 제공할 수 있는 util 함수 process 밖에 적용하지 못했다.(그래도 process 를 직접 손으로 빚어가며 만든 예쁜 내새끼라 맘에든다.)

 

그래서 이번 프로젝트의 오류처리에 관한 달성해야하는 청사진을 바라보면

3단 혹은 2단의 체를 쳐서 사용자가 절대로 의미없는 crash 를 겪지 못하게 하는것이다.(crash가 안날수는 없다 그거 안나면 난 nasa가서 로켓만들어야지)

이런부분에서 error가 자주 발생하는 지점이 어디인지 그에 따른 대응방법이 무엇인지를 각각의 handler의 우선순위를 위주로 살펴보도록 하겠다.


오류가 그래서 언제 많이 발생하는데?

일단 오류 처리 관련 글은 너무 방대하기 때문에 이 글에서는 다루지 않을것이다. 그것만 시리즈로 한 3편 나올것 같다.(이거 쓰고 바로 3~4편 짜리 시리즈 글을 써야겠다. 리본돼지의 오류처리 최신화!!!)

그래서 간단하게 상황과 TripDraw에서 적용한 각각의 error handler를 소개하자면 이러하다

1. 서버통신

서버통신의 경우 모두가 아는 오류가 많이 발생하는 지점이다. -> 외부와 맞닿아 있는 지점이므로 당연히 외부요인(서버) 혹은 네트워크 상황에 영향을 받을 수 밖에없고 이는 오류가 제일 많이 터지는 지점으로 모두가 경계하는 대상이다.

이에 대한 오류처리 방법은 한없이 많을 것이다.

 

다양한 방법을 살펴보며 그 중 최종적으로 선택한 것은 callAdpater를 커스텀 하는 방법이었고 그에 대한 보일러플레이트 코드에 대응할 수 있도록 디폴트 핸들러를 달아놓은 process 함수를 제작하여 사용하고 있다.(서버 관련 오류처리 대응 핸들러들 후보는 정말 많았다.)

 

2. 로컬 IO

현재 TripDraw의 경우 Room 등 로컬에 있는 영속성 저장소를 이용하고 있지않다. 하지만 추후 기능 고도화에서 1순위로 뽑히는것이 10분마다 여행지점을 저장하는 기능에 대해 서버통신이 아닌 local 에 저장한 후 batch 로 처리하는것이다.(서버통신에 대한 부담을 줄이고 비용을 절감하기 위해서이다.)

로컬 영속성 저장소에 접근할때도 오류가 다른 상황보다 분명 많이 날수 있는 지점이다. 서버통신 만큼은 아니지만 우리 process가 아닌 외부지점과 맞닿아 있는것은 로컬에 있는 영속성도 마찬가지이다.(즉 프로그램 입장에서는 외부나 다름없다.)

 

그래서 이에 대한 에러처리 handler로 고려하는것이 coroutineExceptionHandler 이다.

현재 프로젝트에서는 비동기 처리를 위하여 coroutine 를 사용하고있고 오류처리에 대한 적용 또한 간편하며(필요한 시점에 일괄적으로 달아놓고 처리하기 좋음) 코틀린에서 지원하는 coroutine 자체가 워낙 강력해서 한동안은 대세가 안바뀔것 같기도하다(나의 예상).

이러한 이유에서 로컬 저장소를 이용하는 날이 온다면 coroutineExceptionHandler를 통해서 오류 처리를 할 예정이다.

 

여기서 이런 의문이 들 수 있다.

-> 근데 왜 에러처리 하나로 안몰음? 걍 coroutineExceptionHandler로 서버통신이든 로컬 IO든 다하면 되잖아

맞는 말이긴하다. 하지만 생각보다 각각의 코드들이 만만치 않게 많아서 하나의 handler로 처리하면 유지보수가 힘들것이라 예상되고

각각의 역할을 확실히 하여 로컬이 터지면 coroutineExceptionHandler, 서버가 터지면 callAdapter이런식으로 역할을 분명히 하기 위해 나누었다. 이런 이유로 coroutineExceptionHandler를 사용하더라도 서버통신 관련 에러핸들링은 배제할 예정이다.

 

3. 그냥 어디서 튀어나올지 모르는 논리오류 및 예상 될수 없는 오류

아무리 오류가 많이 일어나는 부분에서 대부분의 오류들을 방어한다고 해도 분명 오류는 일어난다. 심지어 오히려 뭐지 싶을 정도로 이해가 안가는 방향으로 일어나기도 한다. 저 이미지의 두꺼비(콩쥐야 조때써) 짤은 우리가 오류를 열심히 막아도 막아도 계속 세어나오는 모습을 보여준다.

이럴때 추가적으로 오류가 나거나 혹은 문제가 일어나는 부분은 무엇이 있을까 살펴보자

 

1. 논리오류

일단 논리오류는 개발단계에서 다 잡히거나 처리를 하는게 맞지만 세상일이 그렇게 호락호락하지 않다. 진짜 생각지도 못한 부분에서 섞여 들어갈수도 있고 비상식적인 행위들이 팀원에 의해서 혹은 나에 의해서 벌어지는 경우도 있다.(그래서 테스트,CI 해야함)

 

2. 뭔지 모르겠는데 그냥 남 (Unknown은 어디든 존재한다.)

안드로이드 프레임워크의 특성을 이해하지 못한상태로 코드가 작성되어 문제가 생길때도 있고(ex. Fragment는 그냥 사고뭉치 그자체다.)

안드 현업에 있는 친구 이야기를 들어보면 별 이상한 오류가 많이난다.(안드로이드라 그런거라고 안드로이드가 미친거라고 맨날 눈물을 흘린다.) 실제로 사례를 들어보면 IOS의 독자규격(ex.m4a) 때문에 눈물흘리게 된일 등등 안드로이드가 서러운점이 많다.

 

이런것들은 사실상 막는다고 막히는것도 아니고 일어났을때 사용자에게 안좋은 경험으로 남는것을 막기위해 노력해야한다.

이런것들을 어떻게 처리해야할지 굉장히 고민을 많이 했고 상용앱들은 오류를 어떻게 대응하는가 보고싶었는데 상용앱은 오류가 잘안난다.

그래서 보기힘들지만 가뭄에 콩나듯 일어나는 오류를 살펴보면 오류상황 전으로 새로고침 시키고 사용자가 앱 이탈이 아닌 다시 상황을 시도해 볼 수 있도록 처리한다.

 

전역에서 오류에대한 이런 처리를 하는것이 나의 최종 목표였고 근데 사실 코드로 작성하자니 감이 잘 안와서 단계별로 정복후 적용하려했는데 노력하는자에게 복이있나니 맨날 오류처리에 대해 노래를 부르고 고민하며 구글링을 해댔더니 신께서 내앞에 Ted Park님의 아름다운 글앞으로 인도하셨다.(사실 전 무신론자입니다.) 싸랑해요 Ted Park (왠지모를 헤이딜러에 대한 내적 친밀감)

 

최고의 오류처리 글

이 글은 안드로이드 전역에서 최종 정류하는 오류처리 핸들러인 UncaughtExceptionHandler(쓰레드에 딸린 에러 핸들러) 를 통하여 안드로이드 전역에서 발생하는 에러를 핸들링하고 좋은 사용자 경험으로 이끌 수 있는 방법에 대하여 논하는데 내가 너무 하고 싶었던것을 예시까지 제공해주며 짚어준다.

 

ㄹㅇ 너무좋다 한번 꼭 읽어보고 적용해보기를 추천한다. -> 이번에 TripDraw에서도 상황에 맞게 변경하여 1차 업데이트로 내보낼 예정이다.


각 오류처리 핸들러의 구조

서론이 엄청 길었는데 이번글에서는 저런 오류처리 방법에 대한 글이 아니다.

그 오류가 일어났을때 정보를 수집하는 log에 관한 이야기이다.

 

log에 대해 자세히 살펴보기 이전에 에러처리 핸들러 혹은 최종적으로 성공 실패를 분기하고 처리하는 부분을 살펴보고 log의 용도를 알아보자. 다음 코드들은 TripDraw프로젝트의 코드들이다.

 

ResponseState.kt

//process 함수의 기본 핸들러들을 발췌한것이다.
private fun defaultFailureListener(code: Int, errorBody: ResponseBody?): Result<Nothing> {
        // todo log전략 수립후 Log 변경
        Log.d("ResponseState.Failure", "code : $code, body : $errorBody")
        return Result.failure(
            IllegalAccessException(
                FAILURE_EXCEPTION_MESSAGE_FORMAT.format(code, errorBody?.string() ?: ""),
            ),
        )
    }
​
    private fun defaultNetworkErrorListener(error: UnknownHostException): Result<Nothing> {
        // todo log전략 수립후 Log 변경
        Log.e("ResponseState.NetworkError", "error : $error, message : ${error.message}")
        // 네트워크 연결 요청 액티비티 이동로직 혹은 네트워크 끊김 메시지 알림 로직
        return Result.failure(error)
    }

 

HomeViewModel.kt

fun createPoint(locationResult: LocationResult) {
    viewModelScope.launch {
        pointRepository.createRecordingPoint(
            getPrePoint(locationResult),
            tripId,
        ).onSuccess {
            _openPostWritingEvent.value = Event(it)
        }.onFailure {
            // todo log전략 수립후 외부에 수집되는 로그 찍기
        }
    }
}

이렇게 오류처리 핸들러 혹은 성공 실패 후 결과를 처리하는 곳에서는 todo로 log전략을 수립 후 원격으로 수집하는 Log로 변경하겠다고 쓰여있다. 이번 글의 주제는 이 todo를 정복하는 이야기이다.

 

오류처리 핸들러의 용도와 처리 하는 일들을 살펴보면 다음과 같다.

  1. exception이 발생하더라도 앱이 crash 나지 않도록 한다.
  2. 현재 어플리케이션에서 해결할 수 있는 부분에 대한 처리
    (ex. 서버에서 올라온 메시지 출력, 네트워크 확인 후 네트워크 연결하라고 사용자에게 명시)
  3. Log 수집

예시와 같이 에러 핸들러에는 항상 Log를 수집하는 구문이 있다. 이렇듯 오류처리에는 항상 Log가 따라다닌다.

Log를 잘 수집(필요한 요소들을 적절히)하고 대응하여 전체 애플리케이션의 품질을 높이고 좋은 유지보수를 하는것 또한 에러핸들링의 큰 요소중 하나이다.

 

이와 같이 Log는 오류처리의 필수 불가결한 요소이고 이를 잘 설계하는것 또한 개발자의 중요한 역량중 하나이다.


Log가 뭐고 어캐해야하는데?

logging의 정의 위키피디아 문서

logging의 정의를 대략적으로 살펴보면 다양한 용도를 위하여 기록을 시스템,혹은 개발자에 의해서 남기는것이다.

 

안드로이드 개발자에게 log가 뭔지알아? 하면 가장 먼저 튀어나오는것이 안드로이드 Log클래스이다.

안드로이드 Log 클래스를 통해서 프로그램 내부의 값 혹은 기타 상황들을 Logcat를 통해서 모니터링 할 수 있다.

하지만 이런 안드로이드의 Log 클래스는 당장 내 눈앞에 돌리고있는 실기기 혹은 에뮬레이터의 것들만 확인할 수 있으므로 사실상 개발자의 디버깅 용도 이외에는 사용이 불가능하다.

사용자가 어디서 터진다고 계속해서 컴플레인이 들어오는데 사용자한테 회사까지 그 기기를 가져오라고 해서 내 맥북에 꼽아놓고 log를 확인해서 고쳐줄 수는 없지 않겠는가?

 

그러므로 안드로이드 Log클래스를 제외하고도 원격으로 중요한 정보들을 알잘딱하게 수집할 수 있는 어떠한 솔루션이 꼭 필요하다.

실제로 아까 등장한 일하고있는 친구의 사연들 들어보면 cs로 들어오는 사항들에 대해 로그, 에러메시지를 잘 수집하도록 장치를 해놨다면

빠르게 문제해결에 도달할 수 있지만 그렇지 않은 상황에서는 cs는 거의 탐정놀이에 가까운 수준이였다.

(단서로 방탈출 게임하는걸 보는 느낌이랄까)

또한 cs를 빠르게 대응 못할시 퇴근을 못할 가능성이 크다고하니 야근의 매운맛을 보기싫다면 이러한 장치들을 촘촘히 잘 설정해야 할것이다.

-> 이런면에서 보면 개발만큼 중요하다 퇴근은해야지 ;;;

 

또한 Log의 경우 안드로이드 만의 일이 아니다.

개발에 모든 분야에서 Log는 중요한 이야기이고 DevOps의 영역에 속한다.(DevOps 개발자는 뭐할까 궁금했는데 CI/CD, 인프라 관리, 로깅 및 모니터링 이런거 하는 사람들이었다.)

 

또한 로그는 아무렇게나 막 정보를 많이 수집한다고 장땡이 아니다.

필요없는 정보까지 로그에 쏟아져 들어온다면 오히려 원하는 정보를 찾기 어려워지고 전체적인 모니터링 관리에 악영향을 미칠것이다.

하지만 어느것을 수집하고 어떻게 이용할것인가는 팀마다 개발자마다 생각이 다를것이므로 팀원들과 상의를 통해 결정하고 그것을 사용해보며 개선시켜나가는 과정을 거쳐야 할것이다.

이런측면에서 Logging tool을 선정하는 기준으로 쉽게 원하는 정보를 검색할수 있거나 분류할 수 있고, 원하는 정보를 빠르게 분리해낼 수 있는 기능이 중요할 것이다.


안드로이드에서의 Logging tool

안드로이드에서 원격,로컬에서 로그를 수집하고 모니터링 할수있는 Logging tool들은 뭐가 있는지 살펴보고 각 장단점에 대해서 살펴 볼 것이다.

 

- 원격

Google Analytics

GA 공식문서 GA는 아주 유명한 툴로 앱을 분석할수 있도록 도와준다 .(사실 개발자용 아님)

GA의 소개글을 살펴보자

Google 애널리틱스는 앱 사용 및 사용자 참여에 대한 통계를 제공하는 무료 앱 측정 솔루션입니다.

제한 없는 무료 분석 솔루션인 Google 애널리틱스는 Firebase의 핵심이라고 할 수 있습니다. 애널리틱스는 Firebase 전체 기능에 통합되어 Firebase SDK를 사용하여 정의할 수 있는 최대 500개의 고유한 이벤트에 대한 무제한 보고를 제공합니다. 애널리틱스 보고서는 사용자의 행동을 이해하여 정보에 기반한 앱 마케팅 및 실적 극대화 결정을 내릴 수 있도록 합니다.

소개글과 같이 무료로 무제한 로깅이 가능하지만 애초에 사용 대상이 앱 마케팅, 기획적인 측면을 분석하기 위한것이다.(기능 또한 이런쪽으로 쏠려있다.)

애초에 기획자들이 주로 사용자를 추적하고 기획의 근거를 만들기위해서 분석툴로 사용하는 경우가 대부분이다.

 

최초에 TripDraw 의 로깅 시스템을 구축하려 했을때 GA에도 개발자를 위한 기능이 간단하게라도 있겠지 라고 생각하고 아주 복잡한 형태로 Log를 수집할 생각이 아니였기에 GA를 이용하려 했었다.

하지만 공식문서를 자세히 읽어보고 기본제공하는 이벤트, praram 등등을 살펴보니 애초에 개발에 관련된것은 하나도 없었다. 즉 개발자를 위한것이 아니었다.

 

또한 분석툴을 들어가서 살펴봐도 개발자가 편하게 오류관련 정보를 보라고 만든것이 아니여서 정보를 알잘딱하게 보여주지도 않고 참 접근하기 어렵게 되어있다.

그래서 내린결론으로 억지로 GA를 사용하고자 하면 사용할 수는 있겠지만 적절치 못하고 불편하니 다른 툴을 사용하는것이 적절할것이라고 생각하였다.

 

Firebase Crashlytics

Firebase Crashlytics 공식문서

바로 강력한 선수 Crashlytics가 등장한다.

Crashlytics는 어떻게 이렇게 알잘딱깔센하게 잘 만들었지 싶을정도로 강력한 오류 추적 및 로깅 툴이다.(TedPark 님의 전역적인 에러핸들러에서도 이용한다.)

 

일단 연결 방법도 쉽고 연결만 해놓으면 앱이 crash가 났을때 이메일도 해주고 slack 연동해놓으면 slack 알림도 주고

stackTrace도 저장해놨다가 어디가 어떻게 터졌는지 정말 알잘딱하게 보여준다👍.(진짜 만든사람 상줘야함)

Firebase console 에서 볼 수 있는 분석들도 개발자가 보기 용이하게 잘정리되어있다.

그래서 개발자라면 적절히 Crashlytics 를 연결해놓고 매일 아침 루틴으로 Crashlytics를 확인하며 우리 앱 밤새 잘 지냈나 우르르 까꿍 해주면 된다.

 

그래서 GA에게 처음으로 좌절당했을때 Crashlytics은 개발자들을 위해 만들어 놓은거니까 Log를 잘 찍을 수 있게 장치가 있겠지 하고 Crashlytics 공식문서를 재정독 했다.

그렇게 찾은 결과가 Log를 찍을수는 있지만 원하는 형태로 찍을수 없었다.(불완전했다)

 

일단 현재 Trip Draw에서 원하는 Log의 형태는 에러 핸들러 혹은 결과를 처리하는 지점에서 실패가 일어났을때 실패하는 지점을 수집하여 개발에 이용하기 위하여 수집하는 용도이다. 즉 앱이 Crash가 나면 안되고 그런 상황에서 Log들을 무한이 수집하여 분석할수 있어야했다.

하지만 Crashlytics 에서는 이러한 형태가 불가능했다.

 

불가능 했던 이유를 살펴보자면

 

1. crashlytics의 Log 수집시점은 앱이 crsah가 났을때이다.

사실 crashlytics 의 네이밍에서도 느껴지지만 crashlytics의 경우 앱이 crash가 났을때 로그를 수집하도록 설정되어있다.

Crashlytics에서는 UncaughtExceptionHandler를 사용하여 작업을 하는데 Java 환경에서는 사실상 프로세스상에서 가장 마지막으로 정류하는 오류처리 핸들러가 UncaughtExceptionHandler이다.

즉 Thread에 달려있는UncaughtExceptionHandler 에 다 다르기 전에 에러를 핸들링한다면 crashlytics에서 수집할수 없는것이다.

이것을 우회하는 방법으로 그냥 에러를 UncaughtExceptionHandler까지 전파하여 Crashlytics에서 수집시키는 방법을 사용해 볼 수도 있겠지만 이는 이미 처리한 오류를 재전파 하는것이므로 비효율적이며 위험해 보여서 굳이라는 생각이 들었다.

또한 Ted Park님의 전역 오류처리 핸들러를 적용하려면 이방법은 적절해보이지 않았기에 배제했다.

 

2.그 외에 로그를 찍는 방법이 있지만 8개 까지만 기록하게 되어있다.

Crashlytics의 문서를 살펴보면 심각하지 않은 예외보고 라고 Crash 가 나지 않고도 로그를 찍을 방법이 있었다.

하지만 애초에 Crash가 나는 상황을 대응하기위해 만들어진 툴이라서 그런지 이런 로그수집은 8개로 제한되어있다고 한다.

즉 많은 오류사항의 정보를 수집하는데는 한계가있으므로 원하는 용도로 사용하지는 못해서 다른 방법을 사용하게 되었다.

 

번외 이부분에서 엄청 많은 고민을 하였는데 사실 그냥 Crashlytics 원툴로 이런 에러 로깅을 하고싶었다.

예전의 경험으로 비춰봤을때 너무많은 툴을 사용하면 너무 정신없고 집중도가 떨어져서 그 어느것도 제대로 사용하지 못한 경험이 많았다.

또한 그냥 Crashlytics만 사용한다면 그냥 그것만 심심할때마다 보면되는데 그렇지 못하다면 관리대상이 새로이 생기는것도 부담이였다.

그래서 Crashlytics만을 사용하고자 이런저런 방법을 찾아보고 아이디어를 뽑아봤는 다들 애매했다.

그래서 눈물을 머금고 다른 방법을 찾아 나섰다.

자체로깅 시스템

사실 서버와 합작해서 자체로깅 시스템을 만든다면 더할나위 없이 좋을것이다.

그리고 큰 기업이라면 이런 자체 로깅 툴을 제작해서 사용하겠지만 우리는 토이프로젝트를 하고있는 개발자 7명짜리 팀이다.

지금 현재 상황에서는 로깅시스템을 만드는데 많은 비용이 들 뿐더러 이미 상용화 되어있는 툴에비해 조악할것이다.(이걸 하느니 차라리 로깅툴에 돈을 내는게 초반에는 더 효율적)

이런 연유로 다른 방법을 찾아보았다.

Sentry

Sentry 공홈

그렇게 돌고돌아 정착지가 Sentry였다.

Sentry를 간단하게 설명하는 글을 살펴보자

Sentry는 실시간 로그 취합 및 분석 도구이자 모니터링 플랫폼입니다. 로그에 대해 다양한 정보를 제공하고 이벤트별, 타임라인으로 얼마나 많은 이벤트가 발생하는지 알 수 있고 설정에 따라 알림을 받을 수 있습니다. 그리고 로그를 수집하는데서 그치지 않고 발생한 로그들을 시각화 도구로 쉽게 분석할 수 있도록 도와주며 다양한 플랫폼을 지원합니다.

출처:https://tech.kakaopay.com/post/frontend-sentry-monitoring/

사실 여태까지 살펴본 Crashlytics와 비슷한면이 없지 않아 있지만 그래도 좀 더 많은 기능들을 제공하기도하고(분석툴에서 정보를 접근하는데 굉장히 효율적인 기능들이 많다.)

또한 Crash가 안나더라도 로그를 수집할 수 있음이 참 좋았다.

 

근데 슬픈건 Sentry는 유료다 물론 무료버전에서 월당 몇건까지는 해주지만 좀 아쉽다.

(구글이 굉장히 후한거였다. 엄청 좋은거 다 무료로 풀음)

 

안드로이드 진영에서는 Crashlytics가 무료에 워낙 강력한 편이라 Sentry가 뿌리내리기 힘들어 그런지 많이 쓰이지는 않는것 같지만 (구글링 해보면 Sentry 관련 자료가 현저히 부족하다. 근데 사실 구글링의 반 이상이 똥글이니까. 난이도가 올라가면 올라갈수록 자료가 없는것도 사실이기에 주늑들 필요없다.)

하지만 Crashlytics가 지원하지 않는 플렛폼인 Web FE와 Web Be에서는 흔하게 사용되는 툴이라고한다. -> 그리고 사용하는 회사들을 공홈에 쭉 나열해놨는데 빠방하다. ㅋㅋㅋㅋ

Sentry라는 툴에대해 의심이 많아서 우테코 잡담에 괜찮은 툴인가 물어봤는데 코치님이 웹쪽에서는 흔히 쓰이는 툴이라고 인증해주셨다.


- 로컬

사실 로컬은 곁다리로 집어넣은거라 이번글의 주제와는 핀트가 좀 벗어나있고 개인적인 생각으로는 어짜피 요즘 Logcat도 예쁘게 잘나오는데 굳이? 라는 생각이라 도입을 안했었다.

 

그렇게 로컬 로깅 라이브러리를 사용 안하다가 간단한 이유로 도입하게 되었다.

이번 로그유틸을 만들때 목표가 로그를 찍었을때 그것을 호출한 클래스와 메서드를 표시해주는것이였다.

근데 글쎄 그 기능이 알잘딱하게 팀버에 있길래 도입했다.

(기본기능은 클래스까지이고 메서드 까지 표시하려면 다른 작업을 해줘야한다 참고 블로그)

-> 다른 기능도 많은것 같은데 그것은 차차 나중에 알아보려한다.

 

그리고 다른 Log관련 의문 때문에 안드로이드 잡담에 이야기했을때

이런 댓글이 달려서 난독화에서 벗어나게 해주나 싶긴한데 애초에 릴리즈 버전에서는 로그를 걷어낼거라 내알바 아니다 쿠쿠루삥뽕

 

그 이후에도 뭔가 더욱 많은것들이 있나 싶어서 레아에게 질문했는데 Timber가 단순 로그캣에 이쁘게 찍히는것 말고도 여러 편리한 기능을 제공해준다고 한다. 근데 지금 시점에서는 안써도되니 deep dive 그만하라고 경고문을 받았다.

 

그래서 간단히 git 링크만 남기고 넘어가려고 한다.

timber: timber 깃헙

logger: logger 깃헙


선택의 아이 Sentry

자 이제 약속의 아이 Sentry를 자세히 살펴볼 차례이다.

Sentry를 이용한 에러 추적기, React의 선언적 에러 처리 / if(kakao)2022

시간이 없다면 이 영상을 보면된다. 대충 Sentry가 어떤기능을 제공하는지 어떤 형식으로 사용되는지 알 수 있으므로 이 영상을 참고하면 좋을것이다.(자바스크립트와 리액트 이야기지만 어짜피 뜻은 통한다. axios가 retrofit같은 http통신 라이브러리 인것만 알아두자)

 

일단 가볍게 Sentry를 연결해보고 쓸만한 주요기능들을 소개해 보려고한다.

 

sentry andriod docs

센트리 공식문서인데 차근 차근 따라가보며 연결해보자(공식문서가 굉장히 잘되어있고 붙이는것 자체는 쉬워서 엄청 금방한다.)

 

1.sentry에 회원가입 해야한다.

Sentry 무료버전의 경우 프로젝트를 조회할수있는 계정은 하나로 제한된다고하니 google 팀계정을 이용하는것이 좋을것 같다.

 

2.그냥 시키는 대로 연결한다.

DSN같은경우 뭔가 꼬롬해서 sentry.properties로 뺐고 나머지는 시키는대로 연결하면 바로 작동한다.(연결이 쉽다.)

 

3.테스트 이벤트를 하나 찍어보자

그냥 테스트를 위해서 IllegalStateException 하나 만들어서 찍어봤는데 별의별 정보가 다 수집되는걸 볼수 있었다. 역시 사용하길 잘했어 ㅎㅎㅎ

 

수집된 정보들

- 종합 적인 데이터

이렇게 릴리즈 버전, 유저 고유코드, 발생한 오류 장소 등등 아주 현란하게 나온다

 

 

- StackTrace

그 시점의 stackTrace도 제공해준다

 

- 기타 정보들 (네트워크 상태, 액티비티 라이프 사이클, 어플리케이션 라이프 사이클)

추가적으로 빌드 버전, 시작시간, 권한상태, 배터리 레벨, 핸드폰 제조사, 충전중 여부, free memory, 화면 사이즈 등등 별의별 정보를 다 수집해서 제공한다.

 

❤️아무래도 Sentry와 사랑에 빠진것 같다. 무료 범위를 넘어도 걍 돈내고 쓰는게 좋을것같다.❤️

심지어 이러한 것들이 여타 설정을 해준것이 아니라 그냥 최초로 한번 실험하도록 공식문서에서 제공한 제일간단한 방법을 통해서 찍어본 내용이다.

즉 설정이 추가된다면 더 자세하거나 검색이 쉽게 로그를 수집할수 있을것이다.(이 좋은걸 왜 안쓰지?)

난독화

난독화의 경우 다음 문서와 블로그를 참고한다면 뚝딱 할 수 있다.

https://docs.sentry.io/platforms/android/configuration/gradle/?original_referrer=https%3A%2F%2Fsentry.io%2F

https://jizard.tistory.com/243(블로그에서는 sentry.proprties 만드는 부분만 보면된다.-> 옛날자료라 현재와 많이다름)

기타설정

기타설정은 TripDraw에 적용하며 issue에 기록하였는데 여타 설정에 관해 관심이 있다면 살펴보면 좋을것같다.

✈️✈️✈️✈️✈️TripDraw 이슈✈️✈️✈️✈️✈️✈️


주요기능(공식문서 요약본, 사실 공식문서가 최고!!!)

이 부분은 공식문서를 순서대로 읽으며 필요한 부분을 발췌해서 요약한것이니 가만하고 읽으면 된다.

센트리 관련 기본설정

basic options

 

이곳을 살펴보면 다양한 기본옵션들이 존재하는데 정말 많은 기능들을 내포하고 있구나 생각이 들었다. 예를들어 6.0.0 부터는 로그를 수집할때 애플리케이션의 화면 캡쳐를 떠서 전송받는것도 있다고한다.

 

기능들이 엄청 많고 방대하기 떄문에 오히려 정보의 홍수 속에서 필요한 정보들이 멀어질 수 있으니 필요한 시점에 한번 둘러보고 기능들을 사용하는 편이 좋을것같다. -> 현재는 가장 기본적인 옵션만 가져갔다.

 

참고로 디버그 모드는 정말 별로니 키지마라 왠만해서(logcat이 sentry로 뒤덮여 식별이 불가해진다.)

Configuration 하위 목록들(Sampling,Release&Health)

보다 보면 별의별 기능이 다있다 예를들어 transaction 등을 수집할 수 있는데 필요하다면 수집하겠지만 당장 이번글의 관심사와는 동떨어져 있으니 생략하겠다.(transaction-> 성능 모니터링 관련 지표로 사용할수 있는것)

Filtering

Filtering 관련 공식문서

분석툴에서 필터링을 통해서 원하는 정보들을 받을수도 있겠지만 클라이언트에서 필터링을 걸어서 애초에 원하는 정보만 받아올수 있는 방법을 설명한다.

이 방법을 채택한다면 불필요하게 로그를 수집하지않아 자원을 절약할 수 있다.

하지만 읽어보면 Sentry기능이 방대하고 왠만한건 알잘딱하게 설계되어있어서 큰 의도가 없는이상 딱히 건들필요가없다 -> 필요하다면 그때 설정하자

integrations

integrations 공식문서

본인이 사용하는 라이브러리 혹은 기능이 있는경우 읽어보고 설정해볼수 있겠지만 읽어본결과

Sentry 3.1.0부터 대격변이 일어나서 모든것들을 알아서 이미 자동화로 지정해주기 때문에 수동으로 지정해줄 필요가 없다. 그나마 OkHttp혹은 Room의 경우 필요에의해 설정해줄수 있겠지만 이번 프로젝트에서는 전혀 필요가없어서 자세한 내용은 생략하려 한다.(그냥 자동에 맞기는게 알잘딱하게 잘되어있다. Sentry 오마카세 구다사이)

심지어 okhttpclient에 이벤트 리스너까지 알아서 바이트코드 단에서 달아준다고한다.(뭐 이렇게 똑똑하지?)


사용법

위에 기본설정을 완료한다면(사실 자동화 따르면 됨 문서는 다 읽어봤는데 딱히 한건없다 -> ANR만 날려버림) 이제 사용하면되는것이다.

기본적 사용법 정리 공식문서

자 여기보면 다 나와있는데 일단 용어정리가 제일 중요하다.

  • event: error 나 exception을 데이터 삼아 전달하기위한 인스턴스(단위)
  • Issue: 비슷한 이벤트를 묶은것
  • Capturing: 이벤트를 Sentry로 보내는 행위

 

가장 기본적으로 Error를 capturing하는것이 일반적이고 Message또한 capture할수있다.

공식문서가면 잘 나와있는데 링크 타고 들어가기 귀찮을 수 있으니 복붙 해와야겠다.

Capturing Errors

import io.sentry.Sentry
​
try {
  aMethodThatMightFail()
} catch (e: Exception) {
  Sentry.captureException(e)
}

Capturing Messages

import io.sentry.Sentry
​
Sentry.captureMessage("Something went wrong")

이런식으로 로그를 수집하고 싶은곳에 error혹은 메시지를 Sentry에서 수집하도록 심고 다니면 되는것이다.(GA심듯이 농부도 아니고 심을게 많다.... )

 

이벤트 강화 기능

또한 이벤트에 이런저런 기능들을 집어 넣는 부분들이있는데

공식문서를 참고하고 아까 첨부된 카카오 동영상에서 원하는 기능만 쏙쏙 빼와서 쓰면 될것같다.

-> 본인에게 필요한 기능들을 사용하자.

 

현재 TripDraw 에서는 사실 기본적으로 제공하는 부분들이 수집하려고 했던부분이라 딱히 필요가 없었다.

개발자가 메시지를 넣을 수 있도록 context를 사용해서 메시지를 첨부하는것을 제외하고는 다른 기능을 사용하지는 않았다.

scope의 개념만 알고가면 될것같으니 Scopes and Hubs 같은 경우 꼭 읽어보자

 

이를 제외하고도 추가 기능이 너무많은데 sentry 안드로이드 관련 글이 없으니 처음 보는사람들을 위해 다 한줄 요약해보겠다.

 

FingerPrinting: 비슷한 event들을 묶어서 관리하는것이다 -> 기본적으로 알고리즘이 알아서 나누는데 의도대로 안되면 재설정해주는 기능있음 (안쓸것 같아서 배제)

로그 레벨 설정: 이것도 딱히? 필요할때 사용하면 될것 같다

performance monitoring: 성능을 측정하는 기능이다. -> 지금 성능까지 따질때가 아니다 구멍이 숭숭인데 뭔 성능인가


TripDraw Log Util

그래서 로그유틸을 만든 결과물을 봐야하지 않겠는가? 각각 release인지 debug인지,또한 어떤 상황인지를 적절히 분기하여 사용할 수 있도록 만든 TripDraw 특제 로그유틸을 살펴보자

 

2023.08.26 업데이트: 금방 LogUtil 마무리 하고 남은 방학동안 롤도 하고 유튜브도 보면서 여유를 즐길줄 알았는데 방학내내 이렇게 마무리 못하고 시달릴줄을 몰랐다.(참고로 오늘은 방학의 거의 끝인 토요일이다 -> 이러면 오류처리 글은 언제쓰냐 ㅜㅠ)

 

어쨋든 TripDrawLogUtil을 만들며 겪은 고초를 정리해보겠다.

 

Timber

로그유틸에서 팀원들과 회의를 통해 합의 본 사항이 로그에 그것을 호출한 클래스명과 함수명을 표시 해주기 였다.(원격이든 로컬이든)

그래서 GA를 통해 원격 로그 수집하려 시도했을때는 이 모든걸 잘 받아와서 param 으로 잘 넣어주고 event명을 검색하기 쉽게하기위해 집중했지만

Sentry 쓰니까 그냥 지가 알아서 슉슉 수집해서 보여줬고 (Sentry 사랑해요) 원격은 허무하게 해결됐다.

 

그래서 남은 로컬을 어떻게 찍어주지 하고 고심하던중 스택트레이스를 까서 정보를 받아 로컬 로그를 만들겠다고 결정을 내렸다.

근데 뭔가 라이브러리로 더 예쁘게 해결할 수 있을것 같아서 팀버를 찾아봤고 예쁘게 해결할 수 있는 방법이 있었다(심지어 ReadMe에도 적용되어있더라 난 바보야 써놓고도 모르다니 ㅠㅠ)

 

어쨋든 참고 블로그를 참고하여 클래스명,메서드명 ,호출 라인수 를 tag로 표시해주는 작업을 해봤는데

문제가 tag 의 글씨수제한이 22자라 클래스명,호출라인,메서드명을 모두 넣으면 ... 으로 생략되어 모든 정보를 알 수 없었다.

태그명 22자 제한은 오레오 하위버전을 지원하기 위해 어쩔수 없이 지정되어 있다고 한다.

 

그래서 스택 트레이스로 다시 다까서 메시지에 담아줄 수 있도록 만들까 고민하다가

그냥 클래스명과 호출라인을 보여주면 찾아가기 충분할것이라고 판단하여 그냥 클래스명과 호출라인까지만 표시할수 있도록 변경하였다.

(메서드명이 필요없을거라 믿으며 검증의 의미로 레아에게 로그에서 뭐가 필요할것같은지 집요하게 물어봤는데 클래스명,메서드명 이런건 안나와서 안심하고 제외시켰다.) 그렇게 적용한 Timber 관련 코드를 첨부한다.

 

TripDrawApplication.kt

 private fun initTimber() {
        if (!BuildConfig.IS_RELEASE) Timber.plant(getTripDrawDebugTree())
    }
​
    private fun getTripDrawDebugTree() = object : Timber.DebugTree() {
        override fun createStackElementTag(element: StackTraceElement): String? {
            return "${element.fileName}:${element.lineNumber}"
        }
    }

 

Sentry 에 뭣모르고 적용한 debug모드

뭣 모르고 적용하면 큰 코 다칠수 있다는 사례를 보여줬다.

나는 개인적으로 로그캣이 정신 없이 올라가는걸 굉장히 싫어하고 이상한 정보(알아먹기 힘든 정보)로 가득차는걸 싫어한다.

심지어 Okhttp의 HttpLogingInterceptor도 프로젝트 초반에 정신없게 로그캣만 오염시킨다고 적용하지 않았었다.(물론 이건 없으니까 불편해서 바로 적용함 ㅋㅋㅋㅋ)

 

근데 sentry를 적용하며 흥분한 나머지 debug모드에서 자세한 정보를 표시해준다는 유혹에 속아넘어가 확인도 안하고 덜컥 적용해버렸다.

그리고 Timber 커스텀하는걸 테스트한다고 계속 로그를 찍어보는데 Logcat이 Sentry한테 점령당해 아무것도 안보이고 미친듯이 올라가서 검색해도 잘 안나오는 수준이었다.

심지어 열받는게 Sentry가 너무 친절해서 내가 Log나 Timber를 로컬에서 찍으면 그거까지 싹 걷어가서 로그로 수집해버린다. 그럼으로 그 내용에 대한 Sentry 로그로 뒤덮여 Logcat에 출력되어 검색이 더 안된다.

 

어쨋든 이게 처음에는 debug모드 때문인지도 모르고 디버그 모드에서 걷어내려고 갖은 방법을 써봤는데 plugIn으로 초기화하는 방식을 사용하고있어 자료도 없고 이런 방식자체가 별로 없어서 정말 애먹다가 공식문서를 다시한번 정독하고 debug 모드 때문임을 깨닫고 눈물을 흘리며 해결했다.

뭐 어쨋든 교훈은 모든 기술을 적용할때는 정신을 똑띠 차리자

 

짠내나는 Crashlytics 적용기

Http 에러 관련로그를 찍는데 다른 에러들은 일어났을때 꼭 보고를 받아야하고 확인해야하는 상황이지만 Network에러 같은경우에는 좀 애매했다. 사용자가 실수로 네트워크를 끄고 사용하는것을 내가 일일히 심각한 사안으로 확인하면서 보고 받을 필요는 없는것 아닌가?

근데 또 마냥 무시하고 로그 수집을 안하기에는 애매한게 주변 개발자들의 이야기를 들어보면 cs의 95%는 네트워크재 연결 및 전원 껏다 키기로 해결된다고한다. -> 이때 좀 더 프로패셔널하게 추적하는 용도로 조금 받아보면 좋지않을까도 생각했다.

 

근데 계속 설명했듯이 Sentry는 유료이다 그리고 네트워크상태까지 알아서 수집해서 간다. 그러므로 여기에 낭비하기는 아까웠다(돈 없어요 ㅠㅠ)

그래서 무료이고 8개만 저장되는 단점이있지만 network에서는 오히려 알잘딱한 양이고 애초에 이름부터 심각하지 않은 예외보고인 Crashlytics의 recordException을 이용하로 마음먹었다.

 

여기서 문제가 일어난다.

애초에 이 프로젝트에서는 Crashlytics를 debug에 적용하지 않으려했다 -> 디버그에서 개발하다 터지는걸 따박따박 슬랙이 울리고 크래시리틱스에 올라가서 수치플을 당할필요는 없지 않겠는가? 그리고 이렇게 디버그 에러가 쌓이면 진짜 봐야하는 릴리즈 에러를 무시하는 경우가 생긴다. 이런 이유로 아예 디버그에서는 수집하지않기위해 releaseImpementation 을 적용하여 debug 빌드에서는 아예 걷어냈다.

 

이런 스토리 라인을 가지고 LogUtil을 만드는 현재 상황이 와서 support 모듈에 Crashlytics를 releaseImpementation 으로 추가해주고 뚱땅뚱땅 만들었다.

 

이때 Build Variants 를 release로 놓고 개발했을 때는 잘되던게 debug로 놓으니까 Crashlytics에 접근 못하는 문제가 생겼다. 당연한 문제였다. release에만 추가해주는 것이니 debug에서는 아예 추가가 안되었고 참조가 안되는 것이었다.

근데 내가 쓰는 코드는 하나인데 어떻게 처리할지 곰곰히 생각해보고 구글링해보니 갖가지 편법으로 이 문제점을 해결할 수 있는 방법들이 있었다. (gradle의 afterEvaluate 블록 이용하기, 크래시리틱스 기능 꺼버리기)

 

이중 가장 깔끔하다고 생각되는 상위 인터페이스를 만들고 구현은 각 빌드의 디렉토리에서 하는 방법을 이용하였다.

이 방법은 일단 상위 인터페이스를 선언해놓고 구현자체는 각각의 빌드별 디렉토리에서 하는것이다.

그럼 알아서 build variant를 변경할 때마다 각 빌드별로 디렉토리가 알아서 추가되기 때문에 빌드별 행위를 다르게 해줄수 있으며 참조 문제 또한 해결된다.

이제 코드를 보며 살펴보자

 

우선 이렇게 상위 인터페이스를 메인 코드에 만들어준다.

interface CrashlyticsHandler {
    fun recordException(throwable: Throwable, keyName: String, keyValue: String)
}

 

다음 각각 release,debug 디렉토리(각 빌드시에만 추가되는)를 추가해주어 그곳에 구현체를 만들어 놓고

  • 릴리즈에 추가하는것
// src/release/java/CrashlyticsHandlerImpl.kt
class CrashlyticsHandlerImpl : CrashlyticsHandler {
    override fun recordException(throwable: Throwable, keyName: String, keyValue: String) {
        Firebase.crashlytics.run {
            setCustomKey(keyName, keyValue)
            recordException(throwable)
        }
    }
}
  • 디버그에 추가하는것
// src/debug/java/CrashlyticsHandlerImpl.kt
class CrashlyticsHandlerImpl : CrashlyticsHandler {
    override fun recordException(throwable: Throwable, keyName: String, keyValue: String) {
        // no_op
    }
}

 

이후 로그를 사용할때 인터페이스에 의존하여 코드를 사용하는 형태를 취한다.

import CrashlyticsHandlerImpl
​
inner class HttpClient : LogUtil.HttpClient {
    private val crashlyticsHandler: CrashlyticsHandler = CrashlyticsHandlerImpl()
  // crashlyticsHandler에 의존해서 코드를 막짬
}

이렇게 해결하였고 잘 동작함을 확인하였다.

 

이렇게 내부에 내용이 없는 구현체를 만드는 방법을 NO OP 혹은 No Operation 이라고 칭하며 Leakcanary에서도 채용하고 있다고 한다. 참고블로그

최종 결론 TripDrawLogUtil

그래서 최종적으로 만든 LogUtil 코드를 첨부하고 글을 마무리하려한다.

release 인지 debug 인지 구분하여 release라면 원격으로 debug라면 로컬로 로그를 수집하게 하였고.

원격이라면 각 에러상황에 맞춰서 Crashlytics,Sentry 둘 중 하나로 수집되게 만들었다.

또한 원래 목표였던 로그 수집시 클래스명, 메서드명, 을 수집할 수 있도록 하였다.(로컬은 함수명 대신 코드라인넘버)

 

class TripDrawLogUtil() : LogUtil {
    override val general: LogUtil.General = object : LogUtil.General {

        override fun log(throwable: Throwable?, message: String?) {
            when (IS_RELEASE) {
                true -> generalReleaseErrorLog(throwable, message)
                false -> generalDebugErrorLog(throwable, message)
            }
        }

        private fun generalReleaseErrorLog(throwable: Throwable?, message: String?) {
            when (throwable == null) {
                true -> {
                    Sentry.captureMessage(message ?: NO_MESSAGE_NOTICE)
                }
                false -> {
                    Sentry.captureException(throwable) { scope ->
                        scope.setContexts(SENTRY_MESSAGE_KEY, message ?: NO_MESSAGE_NOTICE)
                    }
                }
            }
        }

        private fun generalDebugErrorLog(throwable: Throwable?, message: String?) {
            when (throwable == null) {
                true -> Timber.e(
                    DEBUG_GENERAL_ERROR_LOG_FORMAT.format(
                        message ?: NO_MESSAGE_NOTICE,
                    ),
                )
                false -> Timber.e(
                    throwable,
                    DEBUG_GENERAL_ERROR_LOG_FORMAT.format(message ?: NO_MESSAGE_NOTICE),
                )
            }
        }
    }

    override val httpClient: LogUtil.HttpClient = object : LogUtil.HttpClient {

        private val crashlyticsHandler: CrashlyticsHandler = CrashlyticsHandlerImpl()

        override fun failure(code: Int, errorBody: ResponseBody?) {
            when (IS_RELEASE) {
                true -> failureReleaseLog(code, errorBody)
                false -> failureDebugLog(code, errorBody)
            }
        }

        private fun failureReleaseLog(code: Int, errorBody: ResponseBody?) {
            Sentry.captureMessage(SENTRY_RESPONSE_STATE_FAILURE_MESSAGE) { scope ->
                scope.setTag(SENTRY_HTTP_ERROR_TAG_KEY, RESPONSE_STATE_FAILURE)
                scope.setContexts(RESPONSE_STATE_FAILURE_CODE_KEY, code)
                scope.setContexts(
                    RESPONSE_STATE_FAILURE_ERROR_BODY_KEY,
                    errorBody ?: NO_ERROR_BODY_MESSAGE,
                )
            }
        }

        private fun failureDebugLog(code: Int, errorBody: ResponseBody?) {
            Timber.e(DEBUG_RESPONSE_STATE_FAILURE.format(code, errorBody ?: NO_ERROR_BODY_MESSAGE))
        }

        override fun network(error: UnknownHostException) {
            when (IS_RELEASE) {
                true -> networkReleaseLog(error)
                false -> networkDebugLog(error)
            }
        }

        private fun networkReleaseLog(error: UnknownHostException) {
            crashlyticsHandler.recordException(
                error,
                CRASHLYTICS_HTTP_ERROR_KEY_NAME,
                RESPONSE_STATE_NETWORK,
            )
        }

        private fun networkDebugLog(error: UnknownHostException) {
            Timber.e(error, RESPONSE_STATE_NETWORK)
        }

        override fun timeOut(error: SocketTimeoutException) {
            when (IS_RELEASE) {
                true -> timeOutReleaseLog(error)
                false -> timeOutDebugLog(error)
            }
        }

        private fun timeOutReleaseLog(error: SocketTimeoutException) {
            Sentry.captureException(error) { scope ->
                scope.setTag(SENTRY_HTTP_ERROR_TAG_KEY, RESPONSE_STATE_TIME_OUT)
            }
        }

        private fun timeOutDebugLog(error: SocketTimeoutException) {
            Timber.e(error, RESPONSE_STATE_TIME_OUT)
        }

        override fun unknown(error: Throwable) {
            when (IS_RELEASE) {
                true -> unknownReleaseLog(error)
                false -> unknownDebugLog(error)
            }
        }

        private fun unknownReleaseLog(error: Throwable) {
            Sentry.captureException(error) { scope ->
                scope.setTag(SENTRY_HTTP_ERROR_TAG_KEY, RESPONSE_STATE_UNKNOWN)
            }
        }

        private fun unknownDebugLog(error: Throwable) {
            Timber.e(error, RESPONSE_STATE_UNKNOWN)
        }
    }

    companion object {
        private const val DEBUG_GENERAL_ERROR_LOG_FORMAT = "message : %s"
        private const val SENTRY_MESSAGE_KEY = "ERROR_MESSAGE"
        private const val NO_MESSAGE_NOTICE = "입력된 메시지가 없습니다."
        private const val SENTRY_HTTP_ERROR_TAG_KEY = "HTTP_ERROR"
        private const val CRASHLYTICS_HTTP_ERROR_KEY_NAME = SENTRY_HTTP_ERROR_TAG_KEY
        private const val DEBUG_RESPONSE_STATE_FAILURE =
            "RESPONSE_STATE_FAILURE code: %d errorBody: %s"
        private const val SENTRY_RESPONSE_STATE_FAILURE_MESSAGE = "HTTP_FAILURE"
        private const val RESPONSE_STATE_FAILURE = "RESPONSE_STATE_FAILURE"
        private const val RESPONSE_STATE_FAILURE_CODE_KEY = "code"
        private const val RESPONSE_STATE_FAILURE_ERROR_BODY_KEY = "error body"
        private const val NO_ERROR_BODY_MESSAGE = "에러 바디가 없습니다."
        private const val RESPONSE_STATE_NETWORK = "RESPONSE_STATE_NETWORK"
        private const val RESPONSE_STATE_TIME_OUT = "RESPONSE_STATE_TIME_OUT"
        private const val RESPONSE_STATE_UNKNOWN = "RESPONSE_STATE_UNKNOWN"
    }
}

금방 끝날줄 알았는데 생각보다 오래 걸렸다 끝!!!!!!