이전 회사에서 네이버 지도를 사용하다 발생하였던 이슈에 대해서 정리를 한번 하고자 합니다.

개발 상에서는 이슈가 전혀 없던 앱이 상용 배포 후 Crashlytics를 통해 MissingLibraryException를 종종 발생한다는 이슈였었죠.

 

발단

네이버 지도를 붙인 기능이 배포된 후 얼마 후 Crashlytics를 통해서 이상한 오류를 잡아내기 시작합니다.

해당 오류에서 가르키는 부분들은 개발 중에는 본 적이 없는 키워드 들이었습니다.

Caused by com.getkeepsafe.relinker.MissingLibraryException
Could not find 'libnavermap.so'. Looked for: [arm64-v8a, armeabi-v7a, armeabi], but only found: [].

 

멀쩡히 잘 넣어둔 라이브러리가 실종이라니 처음에는 황당한 이슈였습니다.

 

분석

어쨋든 발생하고 있는 이슈이니 분석을 해봅니다.

발견 당시 기준으로 소수의 사용자들이 반복적으로 크래쉬를 경험하고 있었습니다.

단말도 넥서스, 픽셀나 에뮬레이터로 보이는 단말명들이었죠.

 

그래서 첫번째 추정을 하였습니다. 특정 칩셋을 쓰는 단말들에서만 발생하는건가??

그러면서 추가 수집된 정보로 arm, x86 단말들이 섞여 있었죠.

사무실에 가지고 있는 단말과 에뮬레이터로 테스트 해보았지만 이상이 없었습니다.

애초에 네이버 지도가 arm, x86을 다 지원하고 있으므로 문제가 될 것이 없었죠.

 

구글플레이에 업로드하였던 aab 파일을 받아서 다시 까보았습니다.

amr, x86 so 파일들이 다소곳이 잘 들어가 있었습니다.

첫번째 추정은 정답이 아니었습니다.

 

두번째 추정을 해보았습니다. 누군가 앱 설치 후 APK를 백업하여 재설치를 해본건가??

저희는 당시 apk가 아닌 aab로 버전을 업로드하였습니다.

aab는 split apk를 통해 다운받는 단말에 필요한 조각들로 앱이 설치됩니다.

https://developer.android.com/guide/app-bundle/app-bundle-format

 

Android App Bundle 형식  |  Android 개발자  |  Android Developers

Android App Bundle 형식 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. Android App Bundle은 Google Play에 업로드하는 파일(파일 확장자는 .aab)입니다. App Bundle은 그림 1

developer.android.com

하지만 설치된 단말과 백업한 앱을 다시 설치한 단말의 칫셉이 다르다면? 혹은 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. 앱 실행. (이때 크래시 발생)


2. aab로 split apks 변환 후 테스트시
    2-1. bundletool을 이용해 aab 파일을 split apks로 변환
        a. bundletool 다운로드 : https://github.com/google/bundletool/releases
        b. 아래 명령어로 변환

        java -jar bundletool-all-1.14.0.jar build-apks \
          --bundle=app-googlePlayStore-dev-debugWithProguard.aab \
          --output=output.apks \
          --connected-device

    2-2. output.apks 파일을 zip 파일로 확장자 변경 후 압축 해제
    2-3. splits 폴더 안의 base-master.apk 을 단말에 설치
    2-4. 앱 실행. (이때 크래시 발생)


3. split apks 설치 방법 (만약에 split apks를 정상적으로 설치해보고 싶다면!!)
    1. adb install-multiple *.apk

 

해결?

원인은 확인하였지만, 당시에 이슈를 바로 해결할 수가 없었습니다.

다음과 같은 이유에서였죠.

  • 확인 결과 MissingLibrary는 네이버 맵 SDK에서 사용하는 라이브러리에서 제공하는 것임.
  • 네이버 맵 SDK에서 해당 부분을 사용하는 부분은 static 영역인데, static 영역이 초기화 중에 exception이 발생하여 ExceptionInInitializerError가 발생함.
  • Exception이 아니라 Error라 핸들링할 수가 없음.

네이버 맵 SDK 에서 수정되지 않는 이상 Exception 핸들링도 불가하였고, 일반적이지 않고 극소수의 이슈라 우선순위도 낮아 다른 업무에 조금씩 치여가고 있었습니다.

그리고 네이버 클라우드 플랫폼에 해당 현상을 문의하였고, 답변에서도 비정상적 활동이고 빈도가 높지 않을 것으로 보이니 무시하는 것을 권장한다고 하였죠. 나름 뭉갤 수 있는 정당성이 부여되었다라고 죄책감을 조금 내려놓을 때 쯔음....

 

불현듯이 찾아온 컨퍼런스 영상 속에서 그 해답을 찾게 되었습니다.

[드로이드나이츠 2021] 차영호 - AppBundle 괴담 영상 속에서 제가 필요로 하던 답이 있었습니다.

https://youtu.be/EVYnTe6aXWQ?t=706

 

그리고 위 이슈를 SideLoading이라는 키워드로 부르는 것을 알게 되었고, 아래와 같은 포스팅에서도 도움을 얻게 되었습니다.

 

App Bundle and Sideloading: how to prevent crashes

Read here, why the new Google Android App Bundle format can cause sideloading crashes when using a native library. Learn how to fix it.

objectbox.io

 

 

덕분에 최종 수정은 아래와 같이 하였습니다.

  • 앱 실행시 split apk 체크로 문제가 인지되면, 토스트 띄우고 앱 종료함.
  • split apk 체크로직은 앱이 build 타입이 release일때만 체크하도록 함.
fun Context.checkSplitApks(): Boolean {
    val splitNames = packageManager.getPackageInfoCompat(packageName, 0).splitNames ?: emptyArray()

    // 네이버 지도 라이브러리는 arm/x86으로 split apk 확인이 가능하네 ㅎㅎ
    val armPattern = "arm"
    val x86Pattern = "x86"

    // split apk 추출 후 base or master apk만 설치시 dpi가 없으니 확인하자!!
    val dpiPattern = "dpi"
    return splitNames.any { it.contains(armPattern) || it.contains(x86Pattern) }
            && splitNames.any { it.contains(dpiPattern) }
}

 

추가로 해당 처리로 앱 종료 처리된 경우 Crashlytics 로그 추가하여 추이를 보기로 하였습니다.

위 수정사항으로 더이상 저런 헤괴망측한 오류는 나오지 않았습니다.

하지만 간헐적으로 종종 로그는 수집되고 있었답니다.

오늘의 괴담은 여기까지입니다. ^^

먼저 SonarQube 서버가 확보된 상태에서 시작합니다.

서버가 없으시다면 아쉬운 대로 개인 로컬에서 서버를 돌린 뒤 사용해보시면 됩니다.

참고 : https://nobase-dev.tistory.com/275

간단합니다.

build.gradle에 아래 내용만 추가해주면 됩니다.

buildscript {
    dependencies {
        classpath "org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:2.8"
    }
}

apply plugin: "org.sonarqube"
sonarqube {
    properties {
        property "sonar.host.url", "http://localhost:9000/"
        property "sonar.login", "admin"        // 로그인 id 또는 인증토큰
        property "sonar.password", "admin"    // 인증토큰 사용시는 공백으로
        property "sonar.projectKey", "projectKey"
        property "sonar.projectName", "projectName"
        property "sonar.projectVersion", "1.0"
        property "sonar.sourceEncoding", "UTF-8"
        //property "sonar.sources", "src/"
        property "sonar.profile", "Sonar way"
        property "sonar.coverage.jacoco.xmlReportPaths", "build/reports/coverageReport/coverageReport.xml"    // Test Coverage Report 생성한 경우 사용
    }
}

SonarQube에서 다양한 Property들을 제공하고 있습니다.

보다 많은 설정이나 상세한 내용은 아래 링크를 참고하시면 됩니다.

https://docs.sonarqube.org/latest/analysis/analysis-parameters/

다 되었다면 Android StudioTerminal 탭에서 실행하면 됩니다.

> gradlew sonarqube

그리고 SonarQube 프로젝트 리스트에서 리포트가 잘 생성이 되었는지 확인하면 끝~

은 아니고 이제부터 시작입니다.

버그와 취약점, 악취들을 제거하기 위한 준비는 끝~ 입니다~

참고링크 :

https://question0.tistory.com/2

https://plugins.gradle.org/plugin/org.sonarqube

[Android] Daum Map SDK 적용하기

다음 맵 SDK를 사용하면서 공식 가이드대로 진행시 일부 안되는 부분들이 있음.

  1. 카카오 개발자 사이트 가입

  2. 카카오 개발자 사이트에서 앱 생성

  3. Android 플랫폼 추가 : 앱 선택 - 설정 - 일반 - 플랫폼 추가 - Android 선택 후 추가

  4. 키 해시 등록 : Android 플랫폼 선택 후 키 해시 등록

  5. 라이브러리 파일 추가 Android Daum Map 라이브러리 구성  가이드에는 위와 같이 libs 파일에 추가하라고 하지만 이대로 할 경우 오류 발생함. libDaumMapAndroid.jar는 위 위치가 맞고 gradle 에 아래와 같이 추가가 필요함.

    dependencies {
        implementation fileTree(include: ['*.jar'], dir: 'libs')
        implementation files('libs/libDaumMapAndroid.jar')
    }

    armeabi와 armeabi-v7a는 /src/main 디렉토리 아래 jniLibs 디렉토리 생성 후 복사함. 

  6. AndroidManifest.xml 에 Permission과 APP Key 추가

    <uses-permission android:name="android.permission.INTERNET">
    </uses-permission>
    <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION">
    </uses-permission>
    <meta-data android:name="com.kakao.sdk.AppKey" android:value="XXXXXXXXXXXXXXXXXXXXXXXXXXXX"/>
  7. 코드 작성

    MapView mapView = new MapView(this);
    ViewGroup mapViewContainer = (ViewGroup) findViewById(R.id.map_view);
    mapViewContainer.addView(mapView);


Android 앱 개발을 하면서 서버와 통신을 위해 네트워크 기능을 쓸 경우가 대다수이다.


기존 네트워크 기능을 위해 HttpURLConnectionHttpClient (OS 6.0 부터 삭제) 등이 쓰여왔고, OS 3.0 (API 11) 이후에 


NetworkOnMainThreadException 를 피하고자 Thread, AsyncTask 등을 사용하기도 했다.


그 후 여러 편의성을 제공하는 통신 라이브러리 들이 등작하였다. OkHttp, Volley (설명), Retrofit ... (라이브러리 비교)


그 중에서 최근까지 많이 쓰이고 있는 Retrofit에 대해서 초간단 맛만 보기로 하자.


0. 준비물 : API, Android Studio, 잠시나마 집중하겠다는 마음가짐.


1. API

직접 준비하기 어려울 경우 공개되어 있는 고마운 API들을 찾아서 사용해보자. 

예를 들면 빗썸 OPEN API 같은... 


연습을 위해 https://api.bithumb.com/public/ticker/{currency}를 사용해보자.


2. build.gradle 설정

dependencies에 retrofit 최신버전을 추가한다. (Gson 사용을 위해 Gson 관련 추가한다.)


dependencies {
    compile 'com.google.code.gson:gson:2.8.2'                 // Gson 사용시
    compile 'com.squareup.retrofit2:retrofit:2.3.0'
    compile 'com.squareup.retrofit2:converter-gson:2.3.0'     // Gson 처리시
    compile 'com.squareup.retrofit2:converter-scalars:2.3.0'  // String 처리시
    ...
}


3. Data 클래스 

결과 JSON에 맞춰 Data 클래스를 생성하자. (Json to Java Object 서비스)


public class BithumbTickerData {
    @SerializedName("opening_price")
    public String openingPrice;
    @SerializedName("closing_price")
    public String closingPrice;
    @SerializedName("min_price")
    public String minPrice;
    @SerializedName("max_price")
    public String maxPrice;
    @SerializedName("average_price")
    public String averagePrice;
    @SerializedName("units_traded")
    public String unitsTraded;
    @SerializedName("volume_1day")
    public String volume1day;
    @SerializedName("volume_7day")
    public String volume7day;
    @SerializedName("buy_price")
    public String buyPrice;
    @SerializedName("sell_price")
    public String sellPrice;
    @SerializedName("date")
    public long date;
}

public class BithumbTicker {
    @SerializedName("status")
    public String status;
    @SerializedName("data")
    public BithumbTickerData data;
}


4. interface 생성

API Method 방식 및 Path에 대해서 정의하자.


    public interface OpenApiService {
        @GET("public/ticker/{path}")
        Call tickerInfo(@Path("path") String path);
    }


5.1. Retrofit 초기화

Retrofit 생성 후 사용할 interface를 통해 서비스를 만들자. (Response를 String 형태로 받고 싶다면 ScalarsConverterFactory 사용)


        Retrofit retrofit = new Retrofit.Builder()
                .baseUrl("https://api.bithumb.com/")
                .addConverterFactory(GsonConverterFactory.create())        // Gson 처리시
                //.addConverterFactory(ScalarsConverterFactory.create())  // String 등 처리시
                .build();
        OpenApiService openApiService = retrofit.create(OpenApiService.class);


5.2. Call 생성

만들어 둔 interface를 호출하는 Call을 생성하자.


        Call tickerInfo = openApiService.tickerInfo("BTC");


5.3. Callback 정의

enqueue() 를 통해 Callback에 대해서 정의하자.


        tickerInfo.enqueue(new Callback() {
            @Override
            public void onResponse(Call call, Response response) {
                Toast.makeText(TestMainActivity.this, response.body().toString(), Toast.LENGTH_SHORT).show();
            }

            @Override
            public void onFailure(Call call, Throwable t) {
                Toast.makeText(TestMainActivity.this, t.toString(), Toast.LENGTH_SHORT).show();
            }
        });


이상으로 간단하게 Retrofit 시식을 마칠까 한다.




Retrofit 로그 보는 법과 Proguard 설정에 대해서 추가한다.


6. 로그 보는 법

build.gradle에 아래와 같이 추가한다.


dependencies {
        compile 'com.squareup.okhttp3:logging-interceptor:3.9.1'
        ...
}


그리고 Retrofit 초기화 하는 부분에 아래와 같이 추가한다.


        HttpLoggingInterceptor interceptor = new HttpLoggingInterceptor();
        interceptor.setLevel(HttpLoggingInterceptor.Level.BODY);
        OkHttpClient client = new OkHttpClient.Builder().addInterceptor(interceptor).build();

        Retrofit.Builder retrofitBuilder = new Retrofit.Builder().baseUrl(url);

        if(BuildConfig.DEBUG) {
            retrofitBuilder.client(client);
        }

        if(!isString) {
            retrofitBuilder.addConverterFactory(GsonConverterFactory.create());
        } else {
            retrofitBuilder.addConverterFactory(ScalarsConverterFactory.create());
        }
        Retrofit retrofit = retrofitBuilder.build();


기존과 비교했을 때 OkhttpClient 라는 놈을 Retrofit Builder에 추가하는 모습이 보인다.

개인적으로 디버그 모드일때만 로그를 보기 위해 BuildConfig.DEBUG 로 분기 처리를 하였다.

그리고 로그 수준은 아래와 같다.


public final class HttpLoggingInterceptor implements Interceptor {
   ...
  public enum Level {
    /** No logs. */
    NONE,
    /**
     * Logs request and response lines.
     *
     * 

Example: *

{@code
     * --> POST /greeting http/1.1 (3-byte body)
     *
     * <-- 200 OK (22ms, 6-byte body)
     * }
*/ BASIC, /** * Logs request and response lines and their respective headers. * *

Example: *

{@code
     * --> POST /greeting http/1.1
     * Host: example.com
     * Content-Type: plain/text
     * Content-Length: 3
     * --> END POST
     *
     * <-- 200 OK (22ms)
     * Content-Type: plain/text
     * Content-Length: 6
     * <-- END HTTP
     * }
*/ HEADERS, /** * Logs request and response lines and their respective headers and bodies (if present). * *

Example: *

{@code
     * --> POST /greeting http/1.1
     * Host: example.com
     * Content-Type: plain/text
     * Content-Length: 3
     *
     * Hi?
     * --> END POST
     *
     * <-- 200 OK (22ms)
     * Content-Type: plain/text
     * Content-Length: 6
     *
     * Hello!
     * <-- END HTTP
     * }
*/ BODY }

위 소스를 보면 로그 레벨은 NONE, BASIC, HEADERS, BODY 로 이루어져 있다. 필요에 따라 설정하면 된다.

- NONE : No logs.

- BASIC : Logs request and response lines.

- HEADERS : Logs request and response lines and their respective headers.

- BODY : Logs request and response lines and their respective headers and bodies (if present).


위와 같이 하였을 때 로그는 아래와 같이 떨어진다.


12-26 17:53:03.411 15500-15624/com.test.testblabla D/OkHttp: --> GET https://api.bithumb.com/public/ticker/BTC
12-26 17:53:03.411 15500-15624/com.test.testblabla D/OkHttp: --> END GET
12-26 17:53:04.101 15500-15624/com.test.testblabla D/OkHttp: <-- 200 https://api.bithumb.com/public/ticker/BTC (693ms)
12-26 17:53:04.101 15500-15624/com.test.testblabla D/OkHttp: date: Tue, 26 Dec 2017 08:53:04 GMT
12-26 17:53:04.101 15500-15624/com.test.testblabla D/OkHttp: content-type: application/json
12-26 17:53:04.101 15500-15624/com.test.testblabla D/OkHttp: set-cookie: __cfduid=dd24fcf80c27cf456c60e275644e40a0e1514278384; expires=Wed, 26-Dec-18 08:53:04 GMT; path=/; domain=.bithumb.com; HttpOnly
12-26 17:53:04.101 15500-15624/com.test.testblabla D/OkHttp: access-control-allow-origin: *
12-26 17:53:04.101 15500-15624/com.test.testblabla D/OkHttp: access-control-allow-methods: *
12-26 17:53:04.101 15500-15624/com.test.testblabla D/OkHttp: access-control-allow-headers: *
12-26 17:53:04.101 15500-15624/com.test.testblabla D/OkHttp: set-cookie: ci_session=a%3A5%3A%7Bs%3A10%3A%22session_id%22%3Bs%3A32%3A%22cf7e414ece7af81b8a35a0cdcaccd317%22%3Bs%3A10%3A%22ip_address%22%3Bs%3A12%3A%221.227.62.114%22%3Bs%3A10%3A%22user_agent%22%3Bs%3A12%3A%22okhttp%2F3.9.1%22%3Bs%3A13%3A%22last_activity%22%3Bi%3A1514278384%3Bs%3A9%3A%22user_data%22%3Bs%3A0%3A%22%22%3B%7D4ca3239efcb6f4f81c5c8703401ade3bbef4e9fd; expires=Tue, 26-Dec-2017 10:53:04 GMT; Max-Age=7200; path=/
12-26 17:53:04.101 15500-15624/com.test.testblabla D/OkHttp: server: cloudflare-nginx
12-26 17:53:04.101 15500-15624/com.test.testblabla D/OkHttp: cf-ray: 3d32cec0fef194b7-NRT
12-26 17:53:04.101 15500-15624/com.test.testblabla D/OkHttp: {"status":"0000","data":{"opening_price":"19157000","closing_price":"20632000","min_price":"18853000","max_price":"21400000","average_price":"19977707.8742","units_traded":"26050.76859352","volume_1day":"26050.76859352","volume_7day":"176662.05473862","buy_price":"20620000","sell_price":"20623000","date":"1514278384536"}}
12-26 17:53:04.101 15500-15624/com.test.testblabla D/OkHttp: <-- END HTTP (323-byte body)

참조링크1 : https://github.com/square/okhttp/tree/master/okhttp-logging-interceptor

참조링크2 : https://stackoverflow.com/questions/32514410/logging-with-retrofit-2


7. Progaurd 설정

앱을 실제 signed release apk 만들시 Progaurd 사용하는데, 이때 Retrofit 사용시 아래와 같이 proguard.pro 파일에 추가해준다.


# Platform calls Class.forName on types which do not exist on Android to determine platform.
-dontnote retrofit2.Platform
# Platform used when running on Java 8 VMs. Will not be used at runtime.
-dontwarn retrofit2.Platform$Java8
# Retain generic type information for use by reflection by converters and adapters.
-keepattributes Signature
# Retain declared checked exceptions for use by a Proxy instance.
-keepattributes Exceptions


Retrofit 로그에서 문제가 될시 아래도 추가해 보자.


-dontwarn okhttp3.**
-dontwarn okio.**
-dontwarn javax.annotation.**
# A resource is loaded with a relative path so the package of this class must be preserved.
-keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase

참조링크1 : http://square.github.io/retrofit/

참조링크2 : https://github.com/square/okhttp

역동적인 UI 지원을 할 수 있는 Lottie 라이브러리(2017년 2월에 airbnb에서 출시)에 대해서 초간단히 알아보자.


Android Source 예제 : http://airbnb.io/lottie/android/android.html


Lottie Json 샘플 : https://www.lottiefiles.com/


Lottie는 안드로이드(ICS/API14부터), iOS, 리액트 네이티브, 웹까지 다양한 플랫폼을 지원한다.


다 알아본 것 같다. 이제 돌려보자.


0. 준비물 : Lottie Json 샘플, 잠시나마 집중하겠다는 마음가짐.


Lottie Json 샘플은 Lottie Files 에서 마음에 드는 것을 선택하도록 한다.


원래는 주로 디자이너들이 쓸 것으로 보이는 After Effect 의 출력물인 .aep 파일을 After Effect의 플러그인인 

BodyMovin 을 통해서 json 파일을 뽑아낼 수 있다고 한다. (참조)

하지만 After Effect가 뭔지도 모르는 입장인지라 Lottie Files 에서 샘플을 받아 진행하기로 한다.


1. 샘플 json 파일을 assets 폴더에 넣어준다.




1. build.gradle 에 dependencies에 lottie를 추가한다.

dependencies {
    compile 'com.airbnb.android:lottie:2.3.0'
    ...
}


2. 사용할 xml에 LottieAnimationView를 추가한다.  이 때 json 경로를 넣어도 된다.

    <com.airbnb.lottie.LottieAnimationView
        android:id="@ id/animation_view"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        app:lottie_fileName="penguin.json"
        app:lottie_loop="true"
        app:lottie_autoPlay="true" />


3. LottieAnimationView에 json 설정 후 동작시킨다. 이 때 Listener를 달아 Animation 이벤트 처리도 가능하다.

        LottieAnimationView animationView = (LottieAnimationView) findViewById(R.id.animation_view);
        animationView.setAnimation("rejection.json");
        animationView.loop(true);
        animationView.playAnimation();


생각보다 간단하게 Lottie 를 시식코너 마냥 맛만 볼 수 있었다.

+ Recent posts