Javahiddencosts kr

Java의 숨겨진 비용을 파악하고 앱 성능 향상하기

안드로이드에 Java 8이 적용되면서 모든 표준 라이브러리 API와 언어 피처들에 비용이 발생한다는 점을 염두에 두어야합니다. 디바이스가 더 빨라지고 더 많은 메모리가 사용 가능해졌지만 코드 사이즈와 성능 오버헤드는 여전히 큰 문제입니다. 이번 360AnDev 강연은 몇 가지 자바 기능과 관련된 숨은 비용에 대해 알아봅니다. 특히 1.라이브러리와 어플리케이션 개발자를 위한 최적화와 2.그 성능 향상 측정을 위한 도구에 대해 주로 다루고 있습니다.


소개 (0:00)

이 강연에서 지난 6개월동안 조사했던 내용에 대해 얘기하고 여러분과 나누려고 합니다. 이 과정을 통해 아마 여러분이 앱에 적용할 수 있는 어떤 실질적인 것들을 갖지 못할 수도 있습니다. 하지만 끝까지 들으신다면 앞으로 살펴볼 문제를 피할 수 있는 구체적인 팁을 드리겠습니다. 또 제가 사용하는 다양한 커맨드 라인 툴과, 이 모든 리소스에 대한 링크를 마지막에 보여드리겠습니다.

Dex files (1:14)

문제 하나 풀면서 시작하죠, 다음 코드에는 몇 개의 메서드가 있나요? 0, 1, 2개?


class Example {
}

아마 여러분은 본능적으로 반응할겁니다. 아마도 0개, 아마도 1개 또는 아마도 2개.. 이 질문에 대한 답을 찾아볼까요? 첫째, 이 클래스 안에는 0개의 메서드가 있습니다. 저는 이 소스 파일에 메서드를 쓰지 않았습니다. 따라서 0개라고 하는 것은 유효한 답입니다. 물론, 이 대답은 재미가 없죠. 자, 본격적으로 얘기를 시작하겠습니다. 과연 답은 어떻게 될지 한 번 볼까요.


$ echo "class Example {
}" > Example.java

$ javac Example.java

$ javap Example.class
class Example {
Example();
}

내용을 파일로 쓰고 Java컴파일러를 사용해 소스코드를 클래스 파일로 만들겠습니다. 그리고 ‘javap’라고 하는 자바 개발 툴로 무엇이 컴파일 되었는지 클래스 파일 내부를 볼 수 있도록 해보겠습니다. 컴파일된 클래스를 실행시키면 이 예시에 생성자가 있는 것을 볼 수 있습니다. 소스코드에 쓴 것은 아니지만 Java C는 생성자를 자동으로 추가하는 라이브러리를 가지고 있습니다. 즉, 이것은 위 예시 소스파일에는 0개 메서드가 있지만 클래스 안에는 1개의 메서드가 있음을 의미합니다. 그런데 안드로이드 빌드가 여기서 끝나는 것이 아닙니다!


$ dx --dex --output=example.dex Example.class

$ dexdump -f example.dex

안드로이드 SDK에는 dexing 역할의 ‘dx’라는 도구가 있습니다. 이 도구는 자바 클래스 파일을 가지고 안드로이드의 Dalvik 바이트코드로 변환시킵니다. 위 예시코드를 dex를 통해 실행시킬 수 있고 또 다른 안드로이드 SDK의 ‘dexdump’라고 하는 도구를 사용해서 dex파일 안에 무엇이 있는지 정보를 볼 수도 있습니다. 이 도구를 실행하면 파일들의 상세내용이나 카운트와 다양한 테이블 등 많은 내용들을 출력해주죠. dex파일 안에 있는 메서드 테이블을 볼까요?


method_ids_size : 2

아까 만든 클래스 안에 두개의 메서드가 있다고 나오는데, 사실 이해가 안되죠? 안타깝게도 dexdump로는 쉽게 어떤 메서드가 있는지 볼 수가 없습니다. 그래서 dex 파일안의 메서드를 dump할 수 있는 툴을 작성했습니다.


$ dex-method-list example.dex
Example <init>()
java.lang.Object <init>()

이걸 실행해보면 두개의 메서드를 리턴하는 것을 알 수 있습니다. 하나는 우리가 작성하지 않아도 Java 컴파일러가 생성하는 생성자입니다. 그런데 또 오브젝트 생성자가 있다고 합니다. 확실한건 예제 코드에서는 어디서도 새로운 오브젝트를 요청하지 않았다는 것이죠. 그럼 이 메서드는 어디서 나타나서 dex파일에 참조되고 있는걸까요? 다시 클래스 파일의 정보를 제공해주는 javap로 돌아가보면, 더 상세 정보를 보기 위해 추가로 플래그 설정을 할 수 있습니다. ‘-c’ 플래그를 주겠습니다. 이렇게 하면 바이트코드를 좀 더 사람이 읽을 수 있는 모양으로 디컴파일합니다.


$ javap -c Example.class
class Example {
    Example();
        Code:
            0: aload_0
            1: invokespecial #1 //java/lang/Object."<init>":()V
            4: return
}

1번 인덱스를 보면 super 클래스의 생성자를 부르는 생성자가 있습니다. 왜냐하면 직접 선언하지 않았더라도 ‘Example’은 ‘Object’를 상속하는 것이기에 그렇습니다. 모든 생성자는 super클래스의 생성자를 호출해야 합니다. 그래서 자동으로 삽입됩니다. 즉, 예제 클래스 플로우에서 두개의 메서드가 있음을 의미합니다.

제 첫 질문에 대한 대답은 모두 맞으며, 용어의 차이가 있을 뿐입니다. 그것들이 어디에 있느냐에 따른 것이죠. 어떤 메서드도 정의하지 않긴 했지만 사람만이 이것을 신경씁니다. 사람으로서, 우리는 이 파일들을 읽고 씁니다. 그리고 우리는 유일하게 뭐가 입력되고 나오는지 신경쓰는 유일한 존재입니다. 다른 두 가지가 더 중요한데, 컴파일된 클래스 파일안의 메서드의 개수와 그리고 이것들이 클래스 안에 선언한것이든 아니든 클래스 안에 있었나에 대한 것이죠.

그 두 개의 메서드는 참조된 메서드의 개수입니다. 여기에는 우리만의 메서드로 직접 작성한 것과 안드로이드의 logger에 요청하는 것으로부터 그 메서드에 참조된 다른 모든 메서드까지 포함합니다. 제가 참조하는 ‘Log.d’ 메서드는 참조 메서드 패널과는 다릅니다. 그것은 dex파일에 있는 것이죠. 이는 사람들이 안드로이드에서 메서드 개수를 셀 때 늘상 얘기하던 것입니다. 왜냐하면 dex는 참조되는 메서드 개수를 세는데 제한점이 있기 때문입니다.

선언하지 않았더라도 생성되는 생성자를 살펴 봤습니다. 그럼 다른 숨어있는, 그래서 거기 있었는지도 모르게 생성된 것에 대해 살펴보죠. 중첩 클래스들은 유용하게 사용되는 구조입니다.


// Outer.java
public class Outer {
    private class Example {
    }
}

Java 1.0에는 없었던 중첩 클래스는 이후 버전에서 생겼습니다. 아마 뷰나 프레젠터에 아답터를 정의하시면서 이런걸 본 기억이 있을 겁니다.


// ItemsView.java
public class ItemsView {
    private class ItemsAdapter {
    }
}

$ javac ItemsView.java

$ ls
ItemsView.class
ItemsView.java
ItemsView$ItemsAdapter.class

이 클래스를 컴파일하면 두개의 클래스를 갖는 하나의 파일이 되는데, 하나가 다른 하나를 중첩하는 구조입니다. 즉, 컴파일 이후 파일시스템에서 두개의 분리된 클래스 파일을 확인할 수 있습니다. 만약 Java가 진정한 중첩구조를 갖는다면, 우리는 하나의 클래스 파일을 가져야 합니다. ItemsView.class를 가질 수 있겠죠. 그런데 진정한 의미의 중첩은 Java에 존재하지 않습니다. 그럼 이 클래스들은 무엇일까요? 외부클래스인 ItemsView를 보면, 우리는 생성자만 가지고 있습니다. 내부 클래스가 있는 것에 대해 참조하는 것은 없습니다:


$ javap 'ItemsView$ItemsAdapter'
class ItemsView$ItemsAdapter {
    ItemsView$ItemsAdapter();
}

중첩된 클래스 안의 내용을 본다면, 암묵적인 생성자가 있다는 것과 외부 클래스 안에 있다는 것을 알 수 있습니다. 다른 언급할만한 중요한 점으로 다시 돌아가보면 ‘ItemsView’클래스는 public임을 알 수 있는데요, 우리가 소스파일에 설정한 내용이죠. 그런데 내부 클래스(중첩된 클래스)는 private이라고 선언했음에도 불구하고 클래스 파일에서 private이 아닙니다. 패키지 범위로 묶이기 때문입니다. 같은 패키지 안에서 두 개의 클래스 파일을 가지고 있기에 그렇습니다. 결국 Java에 진정한 의미의 중첩구조는 없음을 다시금 확인해 주는 부분이죠.


// ItemsView.java

public class ItemsView {
}

// ItemsAdapter.java

class ItemsAdapter {
}

만약 저 두개의 클래스 정의를 중첩한다 하더라도 여러분은 동일한 패키지 안에서 나란히 두개의 클래스를 만들게 되는 것입니다. 원한다면 그렇게 만들 수 있습니다. 여러분은 두개의 별도 소스 파일로써 네이밍 스킴을 이용할 수 있습니다.


// ItemsView.java

public class ItemsView {
}

// ItemsView$ItemsAdapter.java

class ItemsView$ItemsAdapter {
}

$ 싸인은 Java에서 메서드들과 추가 이름 등 이름을 지을 때 유용한 캐릭터입니다.


// ItemsView.java
public class ItemsView {
    private static String displayText(String item) {
        return ""; // TODO
    }
private class ItemsAdapter {
    }
}

이제 정말 흥미로운 부분이 나옵니다. 왜냐하면 외부 클래스에서 private static 메서드를 찾기 위해 무언가를 할 수 있고, 또 내부 클래스에서 이를 참조할 수 있기 때문입니다.


// ItemsView.java

public class ItemsView {
    private static String displayText(String item) {
        return ""; // TODO
    }
}

// ItemsView$ItemsAdapter.java

class ItemsView$ItemsAdapter {
    void bindItem(TextView tv, String item) {
        tv.setText(ItemsView.displayText(item));
    }
}

진정한 중첩이라는게 없다는 것을 알고 있음에도 불구하고 어떻게 가정적으로 분리된 시스템에서 이렇게 동작할 수 있을까요? ItemsAdapter클래스는 ItemsView안에 있는 private 메서드를 어디서 참조할까요? 이렇게 컴파일하지는 않을텐데, 그렇게 됩니다.


// ItemsView.java

public class ItemsView {
    private static String displayText(String item) {
        return ""; // TODO
    }

    private class ItemsAdapter {
        void bindItem(TextView tv, String item) {
            tv.setText(ItemsView.displayText(item));
        }
    }
}

여기서 무슨 일이 벌어지고 있을까요? javac를 사용해 보죠.


$ javac -bootclasspath android-sdk/platforms/android-24/android.jar \
    ItemsView.java

$ javap -c 'ItemsView$ItemsAdapter'
class ItemsView$ItemAdapter {
    void bindItem(android.widget.TextView, java.lang.String);
    Code:
        0: aload_1
        1: aload_2
        2: invokestatic #3 // Method ItemsView.access$000:…
        5: invokevirtual #4 // Method TextView.setText:…
        8: return
}

이런 개발 뉴스를 더 만나보세요

TextView를 참조하고 있으니 Android API를 Java에 추가해야 합니다. 이제 어떤 메서드를 불렀는지 보기 위해 중첩된 클래스 내용을 출력합니다. 두번째 인덱스를 보면 displayText를 부르지 않고 정의하지 않은 access%000을 부르고 있습니다. 이건 ItemsView 클래스에 있는걸까요?


$ javap -p ItemsView123

class ItemsView {
    ItemsView();
    private static java.lang.String displayText();
    static java.lang.String access$000();
}

다시 보면 그렇군요, private static 메서드라 여전히 저기에 있는 것을 알 수 있습니다. 그런데 쓰지 않는 메서드가 자동으로 추가돼 있습니다.


$ javap -p -c ItemsView123

class ItemsView {
    ItemsView();
        Code: <removed>

private static java.lang.String displayText();
    Code: <removed>

static java.lang.String access$000();
    Code:
        0: aload_0
        1: invokestatic #1 // Method displayText:…
        4: areturn
}

저 메서드의 내용을 보면, 메서드가 하는 것은 요청을 원래 displayText메서드로 전달하는 것입니다. 클래스에 패키지 범위에서 private 메서드를 부르기 위해 어떤 경로가 필요한 것은 당연하겠죠. Java는 패키지 스코프 메서드를 메서드 요청을 편리하게 할 수 있게 하기 위해 통합시킵니다.


// ItemsView.java
public class ItemsView {
    private static String displayText(String item) {
        return ""; // TODO
    }

    static String access$000(String item) {
        return displayText(item);
    }
}

// ItemsView$ItemsAdapter.java
class ItemsView$ItemsAdapter {
    void bindItem(TextView tv, String item) {
        tv.setText(ItemsView.access$000(item));
    }
}

예시를 이와 같은 방식으로 컴파일할 수 있습니다. 메서드를 추가하고 다른 클래스가 이것을 참조할 수 있도록 업데이트할 수 있습니다. dex파일은 메서드의 이런 제한을 지닙니다. 그래서 이처럼 소스파일에 써서 추가할 메서드를 갖고 있다면 추가할 수 있습니다. 이게 동작할 수 없는 어떤 곳에서 private 멤버에 접근하려고 하기 때문에 일어나는 일임을 이해하셔야 합니다.

More Dex (10:52)

그럼 이렇게 생각하실 수 있습니다 “당신은 단지 Java C만 했군요. 아마도 dex도구가 이걸 확인할 수 있고 또 자동으로 그 메서드를 지워줄 수 있겠네요” 라고요.


$ dx --dex --output=example.dex *.class

$ dex-method-list example.dex

ItemsView <init>()
ItemsView access$000(String)  String
ItemsView displayText(String)  String
ItemsView$ItemsAdapter <init>(ItemsView)
ItemsView$ItemsAdapter bindItem(TextView, String)
android.widget.TextView setText(CharSequence)
java.lang.Object <init>()

두 클래스 파일을 컴파일해보면 그렇지 않다는 것을 알 수 있습니다. dex도구는 이게 별도의 다른 메서드인 것처럼 컴파일합니다.

그럼 이렇게 얘기하실 수 있죠, “Jack 컴파일러를 들어본 적이 있는데요. 이게 소스 파일을 직접 가져가고 또 직접적으로 dex파일을 생산한다고 해요, 그럼 아마도 이 추가 메서드를 생성할 필요가 없는데서 뭔가를 하겠네요.” 확실히 메서드에 접근하는 것은 없습니다. 그런데 -wrap0메서드가 효과적으로는 같은 것입니다.


$ java -jar android-sdk/build-tools/24.0.1/jack.jar \
        -cp android-sdk/platforms/android-24/android.jar \
        --output-dex . \
        ItemsView.java

$ dex-method-list classes.dex

ItemsView -wrap0(String)  String
ItemsView <init>()
ItemsView displayText(String)  String
ItemsView$ItemsAdapter <init>(ItemsView)
ItemsView$ItemsAdapter bindItem(TextView, String)
android.widget.TextView setText(CharSequence)
java.lang.Object <init>()

또 다른 툴로 ProGuard가 있습니다. 아마 이미 많은 분들이 사용하고 계실겁니다. “ProGuard는 이걸 분명 다뤄줄 수 있을거야, 그렇죠?” 라고 생각하시겠죠? 예제 클래스 파일을 ProGuard로 돌리면 아래와 같은 내용을 얻을 수 있습니다.


$ echo "-dontobfuscate
-keep class ItemsView$ItemsAdapter { void bindItem(...); }
" > rules.txt

$ java -jar proguard-base-5.2.1.jar \
    -include rules.txt \
    -injars . \
    -outjars example-proguard.jar \
    -libraryjars android-sdk/platforms/android-24/android.jar

$ dex-method-list example-proguard.jar

ItemsView access$000(String)  String
ItemsView$ItemsAdapter bindItem(TextView, String)
android.widget.TextView setText(CharSequence)

생성자가 사용되지 않았기 때문에 제거되었습니다. 이제 다시 이걸 추가하겠습니다. 왜냐하면 일반적으로 생성자는 남아있으니까요.


$ dex-method-list example-proguard.jar

ItemsView <init>()
ItemsView access$000(String)  String
ItemsView$ItemsAdapter <init>(ItemsView)
ItemsView$ItemsAdapter bindItem(TextView, String)
android.widget.TextView setText(CharSequence)
java.lang.Object <init>()

그러면 접근하는 메서드가 남아 있는 것을 확인하실 수 있습니다. 그런데 자세히 보면, 접근 메서드는 있지만 displayText가 사라졌습니다. 도대체 무슨 일이 벌어진걸까요? ProGuard가 생성한 jar파일을 풀고 javap도구를 사용해서 그 안을 보겠습니다.


$ unzip example-proguard.jar

$ javap -c ItemsView

public final class ItemsView {
    static java.lang.String access$000(java.lang.String);
        Code:
            0: ldc #1 // String ""
            2: areturn
}

접근 메서드를 찾아보면 더 이상 displayText라고 부르지 않습니다. ProGuard는 displayText의 내용을 가져가고 접근 메서드로 이것들을 옮긴 뒤, displayText메서드를 제거하였습니다. 이 접근 메서드는 private method를 참조하는 유일한 것입니다. 그래서 다른 아무도 이걸 사용하지 않아서 인라인(inline)되었습니다. 물론 ProGuard는 도움이 됩니다. 그런데 또한 이걸 할 수 있다는 보장이 되진 않습니다. 다행히 우리 예제가 그다지 중요하지 않은 예제이긴 하지만, 어쨌든 분명한건 보장이 안된다는 것이죠. 아마 이렇게 생각하실 수 있습니다. “뭐, 나는 그렇게 많이 클래스를 중첩하지는 않을거야. 그럼 난 다룰 수 있을 만큼만 추가메서드들을 가질테니 별 문제 없을거야!?”

Anonymous class (13:06)

익명 클래스를 소개해 드리겠습니다.


class MyActivity extends Activity {
    @Override protected void onCreate(Bundle state) {
        super.onCreate(state);

        setContentView(R.layout.whatever);
        findViewById(R.id.button).setOnClickListener(
            new OnClickListener() {
                @Override public void onClick(View view) {
                    // Hello!
                }
            });
    }
}

익명 클래스는 이것과 똑같이 동작합니다. 효과적으로 똑같죠. 중첩클래스이지만 이름이 없습니다. 여기에 매우 자주 사용되는, 클래스에서 private메서드를 참조하는 리스너들은 접근 메서드를 생성하게 됩니다.


class MyActivity extends Activity {
    @Override protected void onCreate(Bundle state) {
        super.onCreate(state);

        setContentView(R.layout.whatever);
        findViewById(R.id.button).setOnClickListener(
            new OnClickListener() {
                @Override public void onClick(View view) {
                    doSomething();
                }
            });
    }

    private void doSomething() {
        // ...
    }
}

필드에 대해서도 그렇습니다.


class MyActivity extends Activity {
    private int count;

    @Override protected void onCreate(Bundle state) {
        super.onCreate(state);

        setContentView(R.layout.whatever);
        findViewById(R.id.button).setOnClickListener(
            new OnClickListener() {
                @Override public void onClick(View view) {
                    count = 0;
                    ++count;
                    --count;
                    count++;
                    count--;
                    Log.d("Count", "= " + count);
                }
            });
    }
}

아주 흔히 벌어지는 일입니다. 외부 클래스에 여러 필드를 가지고 있는데, 상태를 바꾸려고하는 활동들은 이 리스너 안에 있습니다. 이제 값을 세팅하기 위한 끔찍한 구현 예제를 하나 보여드릴텐데요. 전치 증가, 전치 감소, 후치 증가, 후치 감소를 하고 있고 로그메시지로 필드에서 값을 읽습니다. 여기서 몇개의 메서드가 필요할까요? 아마 두개겠죠? 하나는 읽는 것, 하나는 쓰는 것, 그리고 증가와 감소된 것이 읽히거나 쓰이겠죠.


$ javac -bootclasspath android-sdk/platforms/android-24/android.jar \
MyActivity.java

$ javap MyActivity
class MyActivity extends android.app.Activity {
    MyActivity();
    protected void onCreate(android.os.Bundle);
    static int access$002(MyActivity, int); // count = 0    write
    static int access$004(MyActivity);        // ++count         preinc
    static int access$006(MyActivity);        // --count         predec
    static int access$008(MyActivity);        // count++         postinc
    static int access$010(MyActivity);        // count--         postdec
    static int access$000(MyActivity);        // count         read
}

이걸 컴파일하면 각 타입에 따라 하나씩의 메서드가 생성됩니다. 그래서 액티비티나 프래그먼트 또는 어떤 것이든 그 안에 4~5개의 리스너를 가지고, 아마 외부 클래스의 private인 10개 정도의 필드를 가지게 될 것입니다. 접근 메서드 발생에 대한 좋은 조합처럼 보이고, 이게 문제라는 것을 아직 확신하지 못할것 같은데요. 가령, “아마도 50이거나 아마도 100이다. 이게 진짜 큰 문제가 될까??” 한 번 보죠.

In the wild (15:03)

이제 얼마나 이게 만연해있는지 확인할 수 있을겁니다. 이 명령들은 여러분 폰에서 모든 APK를 뽑아 올 것입니다. 여러분이 설치한 모든 앱은 써드파티 앱입니다.


$ adb shell mkdir /mnt/sdcard/apks

$ adb shell cmd package list packages -3 -f \
| cut -c 9- \
| sed 's|=| /mnt/sdcard/apks/|' \
| xargs -t -L1 adb shell cp

$ adb pull /mnt/sdkcard/apks

이 dex메서드 리스트를 사용하는 스크립트를 쓰고 모든 다른 숫자를 grep할 것입니다.


#!/bin/bash                                                 accessors.sh
set -e

METHODS=$(dex-method-list $1 | \grep 'access\$')
ACCESSORS=$(echo "$METHODS" | wc -l | xargs)
METHOD_AND_READ=$(echo "$METHODS" | egrep 'access\$\d+00\(' | wc -l | xargs)
WRITE=$(echo "$METHODS" | egrep 'access\$\d+02\(' | wc -l | xargs)
PREINC=$(echo "$METHODS" | egrep 'access\$\d+04\(' | wc -l | xargs)
PREDEC=$(echo "$METHODS" | egrep 'access\$\d+06\(' | wc -l | xargs)
POSTINC=$(echo "$METHODS" | egrep 'access\$\d+08\(' | wc -l | xargs)
POSTDEC=$(echo "$METHODS" | egrep 'access\$\d+10\(' | wc -l | xargs)
OTHER=$(($ACCESSORS - $METHOD_AND_READ - $WRITE - $PREINC - $PREDEC - $POSTINC - $POSTDEC))

NAME=$(basename $1)

echo -e "$NAME\t$ACCESSORS\t$READ\t$WRITE\t$PREINC\t$PREDEC\t$POSTINC\t$POSTDEC\t$OTHER"

이제 이 명령을 실행할 수 있습니다. 이건 제 폰에서 뽑아온 모든 APK에 대해 돌아갈 것입니다. 그리고 꽤 예쁜 테이블을 작성해주죠.


$ column -t -s $'\t' \
<(echo -e "NAME\tTOTAL\tMETHOD/READ\tWRITE\tPREINC\tPREDEC\tPOSTINC\tPOSTDEC\tOTHER" \
&& find apks -type f | \
xargs -L1 ./accessors.sh | \
sort -k2,2nr)

77번 슬라이드에서 그 테이블을 볼 수 있습니다. 그 내용은 패키지명에 따라 많은 접근 메서드순으로 되어 있습니다. 아마존이 제 폰의 상위 순위 중에서도 다수를 차지하고 있습니다. 가장 많은 것은 5000개 메서드입니다. 5000개, 이건 전체 라이브러리죠. 마치 apken 패드 같습니다. 여러분은 오직 다른 메서드로 넘어가기 위해 존재하는 쓸모없는 메서드의 전체 apken pad를 갖고 있습니다. 또한 ProGuard를 보면 혼란스러움이 가중됩니다. 인라이닝(inlining)도 심하군요. 이들이 정확한 수라고 생각하지 마세요. 이건 단지 얼마나 많은 메서드가 만들어지는지 깨우쳐 줄 뿐입니다. 얼마나 많은 메서드가 여러분의 앱에 있을까요? 또 그것들이 이 쓸모없는 접근 인풋들보다 더 나을까요? 그건 그렇고, Twitter는 아래에 있네요? 트위터는 1000개를 가지고 있군요. Twitter는 앱을 ProGuard하니까 아마 더 많을 것입니다. 그런데 흥미로운 것은 171,000개의 메서드를 가지고 있는데 단지 1000개의 합성된 접근자를 사용한다는 점입니다. 매우 인상적이죠.

이런 상황을 쉽게 수정할 수 있습니다. 단지 private을 만들지 않으면 됩니다. 이 경계를 넘어 참조할 수 있으려면 패키지 범위(package scoped)를 만들어야 합니다. IntelliGate는 이를 위한 검토를 해줍니다. 기본으로는 동작하지 않고, 직접 private 멤버를 찾아야 합니다. 예시에서는 노란색 강조가 되어 있습니다. ‘option enter’를 할 수 있는데 이것은 접근하려고 하거나 패키지 스코프로 만들려하는 private 멤버를 취하는 의도있는 액션을 제공합니다.

중첩된 클래스의 부모 관계보다는 형제자매 관계로 한 번 살펴볼까요. 외부 클래스에서는 private 멤버로 접근할 수 없는데요, 이걸 패키지 스코프로 만들어야만 합니다. 이게 라이브러리가 아닌 이상 문제를 야기하지는 않을 것입니다. 아마 클래스들을 더 가시성을 주어서 접근할 수 있게하기 위해 같은 패키지에 넣는 실수를 저지르시진 않겠죠? 만약 링크 체크를 위한 피처 요청을 넣는다면 빌드를 성공하지 못할 수 있습니다.

저는 아주 많은 오픈소스 라이브러리를 살펴봤고, 이 가시성 문제를 수정해왔습니다. 그래서 그 라이브러리들이 백여개의 추가 메서드들을 부과하지 않도록 했습니다. 이건 라리브러리가 코드를 생성하기때문에 아주 중요한 문제입니다. 어플리케이션의 메서드 수를 코드 생성 단계를 변경하면서 2700개로 줄일 수 있었습니다. 2700 메서드는 private 스코프 대신 패키지 스코프인 메서드를 무료로 생성합니다.

Synthetic methods (18:45)

이 합성 메서드들을 ‘synthetic’ 이라 부르는 이유는 직접 쓴게 아니기 때문입니다.


// Callbacks.java

interface Callback<T> {
    void call(T value);
}

class StringCallback implements Callback<String> {
    @Override public void call(String value) {
        System.out.println(value);
    }
}

이들은 자동으로 생성된 것입니다. 이 접근자들 뿐만 아닙니다. Generics는 후기 Java 1.0에서 나온 것으로 어떻게 Java가 작동하는지에 대해 맞추어져 있으며, 라이브러리들과 우리 어플리케이션에서도 흔히 볼 수 있습니다. 편리하고 타입 안정성을 보장할 수 있기 때문에 이 제네릭 인터페이스를 사용합니다.


$ javac Callbacks.java

$ javap StringCallback
class StringCallback implements Callback<java.lang.String> {
    StringCallback();
    public void call(java.lang.String);
    public void call(java.lang.Object);
}

만약 제네릭 밸류만을 허용하는 메서드를 만들고 컴파일을 하게되면, 모든 메서드가 제네릭 밸류를 갖는지 찾을 것이고 이는 두 가지의 메서드로 나타납니다. 제네릭 타입이 무엇이 되었든 하나는 스트링을 갖는 것이고 다른 하나는 오브젝트를 갖는 것입니다. 그것은 지울 수 있는 링크입니다. 많은 사람들이 erasure에 대해 얘기하는 것을 들어는 봤지만 아마도 무슨일이 발생하는 것인지는 이해하지 못했을 것입니다. 이것은 지워지는/캔슬되는 것을 표방하는 링크입니다. 오직 오브젝트를 사용하는 코드를 만들어야 하는 이유는 여러분이 이 제네릭 메서드에 접근할 때 결국 요청하는 것이기 때문입니다.


$ javap -c StringCallback
class StringCallback implements Callback<java.lang.String> {
    StringCallback();
        Code: <removed>

    public void call(java.lang.String);
        Code: <removed>

    public void call(java.lang.Object);
        Code:
        0: aload_0
        1: aload_1
        2: checkcast #4 // class java/lang/String
        5: invokevirtual #5 // Method call:(Ljava/lang/String;)V
        8: return
}

여분의 메서드에서 일어나는 캐스트를 살펴볼까요? 캐스트는 year타입을 캐스트하고 제네릭을 수용하는 실제 구현을 요청하게 됩니다. 이 콜 메서드를 부르는 그 어떤 것이든 이 오브젝트 메서드에 디스패치하게 됩니다. 이 요청 코드는 어떤 오브젝트에라도 전달되며, 이 코드는 이것을 캐스트하여 실제 구현을 요청해야 합니다. 모든 제네릭을 생성하는 메서드는 두가지 메서드로 구분됩니다.


// Providers.java

interface Provider<T> {
    T get(Context context);
}

class ViewProvider implements Provider<View> {
    @Override public View get(Context context) {
        return new View(context);
    }
}

리턴 밸류에 대해서도 그렇습니다. 제네릭을 리턴하는 메서드를 가지고 있다면 여러분은 기본적으로 같은 것을 보게 됩니다.


$ javac -bootclasspath android-sdk/platforms/android-24/android.jar \
    Example.java

$ javap -c ViewProvider
class ViewProvider implements Provider<android.view.View> {
    ViewProvider();
        Code: <removed>

    public android.view.View get(android.content.Context);
        Code: <removed>

    public java.lang.Object get(android.content.Context);
        Code:
            0: aload_0
            1: aload_1
            2: invokevirtual #4 // Method get:(…)Landroid/view/View;
            5: areturn
}

두개 메서드가 생성되었습니다. 하나는 리턴하는 것입니다. 그리고 밑에서 오브젝트를 리턴합니다.뷰를 허용하고 다시 오브젝트 타입으로 리턴하는 간단한 코드입니다.


// Providers.java

interface Provider<T> {
    T get(Context context);
}

class ViewProvider implements Provider<View> {
    @Override public View get(Context context) {
        return new View(context);
    }
}

또 다른 흥미로운 것은, 많은 사람들이 깨닫지 못하는 것인데요, 바로 이것이 Java 언어의 특징이라는 것입니다.


class ViewProvider implements Provider<View> {
    @Override public View get(Context context) {
        return new View(context);
    }
}

class TextViewProvider extends ViewProvider {
    @Override public TextView get(Context context) {
        return new TextView(context);
    }
}

오버라이딩하는 메서드가 있다면, 그 리턴밸류를 특정한 버전의 타입으로 변경할 수 있습니다. 이것을 공변(covatiant) 리턴 타입이라고 합니다.필수는 아니지만, 이 경우에는 인터페이스를 구현하고 있습니다. 베이스 클래스는 꼭 인터페이스 등이어야 하는 것은 아닙니다. 베이스 클래스로부터 메서드를 오버라이딩 할 수 있죠. 이 리턴타입을 다른 특정한 것으로 변화시킬 수 있습니다.

이 것이 가능한 이유는 두번째 클래스에 다른 메서드가 있고 그게 꼭 이 get메서드를 요청해야 한다면, 구현 타입이 필요하하기 때문입니다. 이 경우는 View타입입니다. 이미 그 클래스에 있으므로 이를 통해 TextView에 맞춘 커스터마이제이션이 가능합니다.

Covariant return type (21:58)

아래 예제에서 무슨 일이 일어날지 추측해 보시죠.


$ javap TextViewProvider

class TextViewProvider extends ViewProvider {
    TextViewProvider();
    public android.widget.TextView get(android.content.Context);
    public android.view.View get(android.content.Context);
    public java.lang.Object get(android.content.Context);
}

generic이면서 covariant return 타입이기도 한 또 다른 메서드가 있습니다. 아무것도 한 것 없이 하나의 메서드를 세 가지로 변환했습니다. 이건 파이썬 스크립트 입니다.


#!/usr/bin/python

import os
import subprocess
import sys

list = subprocess.check_output(["dex-method-list", sys.argv[1]])

class_info_by_name = {}

for item in list.split('\n'):
    first_space = item.find(' ')
    open_paren = item.find('(')
    close_paren = item.find(')')
    last_space = item.rfind(' ')

    class_name = item[0:first_space]
    method_name = item[first_space + 1:open_paren]
    params = [param for param in item[open_paren + 1:close_paren].split(', ') if len(param) > 0]
    return_type = item[last_space + 1:]
    if last_space < close_paren:
        return_type = 'void'

    # print class_name, method_name, params, return_type

    if class_name not in class_info_by_name:
        class_info_by_name[class_name] = {}
    class_info = class_info_by_name[class_name]

    if method_name not in class_info:
        class_info[method_name] = []
    method_info_by_name = class_info[method_name]

    method_info_by_name.append({
        'params': params,
        'return': return_type
    })

count = 0
for class_name, class_info in class_info_by_name.items():
    for method_name, method_info_by_name in class_info.items():
        for method_info in method_info_by_name:
            for other_method_info in method_info_by_name:
                if method_info == other_method_info:
                    continue # Do not compare against self.
                params = method_info['params']
                other_params = other_method_info['params']
                if len(params) != len(other_params):
                    continue # Do not compare different numbered parameter lists.

                match = True
                erased = False
                for idx, param in enumerate(params):
                    other_param = other_params[idx]
                    if param != 'Object' and not param[0].islower() and other_param == 'Object':
                        erased = True
                    elif param != other_param:
                        match = False

                return_type = method_info['return']
                other_return_type = other_method_info['return']
                if return_type != 'Object' and other_return_type == 'Object':
                    erased = True
                elif return_type != other_return_type:
                    match = False

                if match and erased:
                    count += 1
                    # print "FOUND! %s %s %s %s" % (class_name, method_name, params, return_type)
                    # print " %s %s %s %s" % (class_name, method_name, other_params, other_return_type)

print os.path.basename(sys.argv[1]) + '\t' + str(count)

이해하기에 시간이 많이 걸렸는데요, 저는 어플리케이션에 이런 상황이 얼마나 빈번하게 일어나는지 알고 싶었습니다. 제가 설치한 모든 앱에 대하여 앞서와 같은 절차를 거치치고 실행시킬 수 있습니다.


$ column -t -s $'\t' \
    <(echo -e "NAME\tERASED" \
        && find apks -type f | \
            xargs -L1 ./erased.py | \
            sort -k2,2nr)

많아야 수천줄 입니다. 여기서 할 수 있는 것은 많지 않습니다. ProGuard를 유용하게 활용하는 것을 보여드리겠습니다. 이 상황에서 장점은 만약 ProGuard가 아무것도 메서드의 generic을 참조하지 않음을 탐지할 수 있다면 그 메서드는 오브젝트를 취하거나 오브젝트를 리턴할 수 있다는 것입니다. 그러면 ProGuard에 의해 수백, 수천개가 지워진 것을 보실 수 있습니다. 근데 그들 중 몇가지는 그럴 수 없습니다. 왜냐하면 인터페이스 상황에서 메서드를 부를 때 generic상황에서 사용하고 있기 때문이죠.

마지막 예시로 보려는 것은 Java8 언어 특징이면서 안드로이드에서 곧 새로 나타날 특징과 관련된 메서드입니다.


class Greeter {
    void sayHi() {
        System.out.println("Hi!");
    }
}

class Example {
    public static void main(String... args) {
        Executor executor = Executors.newSingleThreadExecutor();
        final Greeter greeter = new Greeter();
        executor.execute(new Runnable() {
            @Override public void run() {
                greeter.sayHi();
            }
        });
    }
}

잠깐 복구풍의 코드를 보셨는데요, 지금은 Jack 컴파일러가 이런 것을 아주 쉽게 구현할 수 있습니다. 그런데 이 새로운 언어 피처와 관련하여 비용이 발생할까요??

여기 간단한 클래스가 있습니다. 이 클래스는 메서드를 부르면 Hi라고 하죠. 제 ‘Greeter’를 가져다가 이 ‘Executor’에서 인사하게 해보겠습니다. ‘Executor’는 run이라고 하는 하나의 메서드를 가지고 있고 이것은 Runnable을 받습니다. 또한 생성자 타입 final을 만들 수 있습니다. 이제 새로운 러너블을 만들고 메서드를 직접 부를 수 있습니다.


class Greeter {
    void sayHi() {
        System.out.println("Hi!");
    }
}

class Example {
    public static void main(String... args) {
        Executor executor = Executors.newSingleThreadExecutor();
        Greeter greeter = new Greeter();
        executor.execute(() -> greeter.sayHi());
    }
}

람다 세계에서는 이렇게 장황함을 줄여줍니다. Runnable을 암묵적으로 생성하며, 타입을 명세할 필요도 없습니다. 실제 메서드 이름이나 아규먼트 타입을 알지 않아도 됩니다.

마지막은 메서드 참조입니다. 좀 더 재밌는 부분인데요, 왜냐하면 이것은 메서드가 리턴하는게 아무것도 없고 아규먼트를 받지도 않기 때문입니다. 단지 이 메서드를 부르기만하면 되므로 자동으로 Runnable로 변경할 수 있습니다.


class Greeter {
    void sayHi() {
        System.out.println("Hi!");
    }
}

class Example {
    public static void main(String... args) {
        Executor executor = Executors.newSingleThreadExecutor();
        Greeter greeter = new Greeter();
        executor.execute(greeter::sayHi);
    }
}

얼마나 비용이 들까요? (24:45)

이 언어 피처를 적용하는데 비용은 얼마일까요? 이를 알아보기 위해 Retrolambda 툴체인과 Jack을 이용한 툴체인을 설정하겠습니다.


Retrolambda toolchain

$ javac *.java

$ java -Dretrolambda.inputDir=. -Dretrolambda.classpath=. \
    -jar retrolambda.jar

$ dx --dex --output=example.dex *.class

$ dex-method-list example.dex


Jack toolchain

$ java -jar android-sdk/build-tools/24.0.1/jack.jar \
        -cp android-sdk/platforms/android-24/android.jar \
        --output-dex . *.java

$ dex-method-list classes.dex

이 둘은 모두 ProGuard를 사용하지 않고, 또 Jack의 경우 minification을 쓰지 않습니다. 익명 클래스의 경우 항상 두 가지 메서드가 쓰이곤 합니다.


Example$1 <init>(Greeter)
Example$1 run()

$ javap -c 'Example$1'
final class Example$1 implements java.lang.Runnable {
    Example$1(Greeter);
        Code: <removed>

    public void run();
        Code:
        0: aload_0
        1: getfield #1 // Field val$greeter:LGreeter;
        4: invokevirtual #3 // Method Greeter.sayHi:()V
        7: return
}

예제를 컴파일해서 익명클래스를 볼 수 있습니다. 단조롭게 수를 증가시키는 기능을 하고 있죠.

생성자도 볼 수 있습니다. 생성자는 Greater 클래스에서 런 메서드를 가지고 있는데 이걸 컴파일하면 그 메서드를 부릅니다. 기대한 대로죠.

람다의 경우 만약 retrolambda의 예전 버전을 사용한다면 매우 비쌀 것입니다. 아주 작은 코드 라인이 기능하기 위해 6개에서 7개 정도의 메서드를 생성하죠. 고맙게도, 현재 버전은 4개 정도로 낮춰주었습니다. 그리고 Jack은 심지어 3개로 맞춰줍니다. 따라서 익명 클래스 보다 딱 하나 나쁩니다. 그런데 차이가 있을까요? 저기 왜 추가 메서드가 있는 것일까요?

retrolambda를 살펴보면, 이는 두개의 추가 메서드를 가집니다.


Example lambda$main$0(Greeter)
Example$$Lambda$1 <init>(Greeter)
Example$$Lambda$1 lambdaFactory$(Greeter)  Runnable
Example$$Lambda$1 run()

$ javap -c 'Example$$Lambda$1'

final class Example$$Lambda$1 implements java.lang.Runnable {
    public void run();
        Code:
            0: aload_0
            1: getfield #15 // Field arg$1:LGreeter;
            4: invokestatic #21 // Method Example.lambda$main$0:
            7: return
}

위에 있는게 바로 새로운 것입니다. 여기서 어떤 일이 벌어질까요. 람다 안에서 몇개 코드 블럭을 정의하고 있습니다. 그리고 그 코드는 어디론가 갑니다. 메서드 안에서 인코드 되는 것이 아니라 람다를 정의합니다. 왜냐하면 그 코드가 해당 메서드에 속해 있는 것이 아니기 때문입니다. 어떤 것을 호출할 때 전달되어야 하기 때문에 이건 어딘가에 저장이 되어야 합니다.

그것이 바로 저 위의 메서드입니다. 같은 클래스의 메서드로 람다표현으로 작성한 모든것에 대한 복사 & 붙여넣기 일 뿐이죠. 구현부가 하는 것은 ‘sayHi’메서드로 위임(delegate)하는 것입니다. 여러분의 러너블이 하는 것과 거의 비슷하죠. 생성자를 가지고 있고 여전히 런 메서드도 가지고 있는데, 런 메서드를 수정하는 것만 다릅니다. ‘Greeter’를 직접 부르는 대신 오리지널 클래스에 콜백하여 람다 메서드를 호출합니다. 그게 추가 메서드입니다. 이 부분에서 retrolambda가 복잡해지죠.


Example lambda$main$0(Greeter)
Example$$Lambda$1 <init>(Greeter)
Example$$Lambda$1 lambdaFactory$(Greeter)  Runnable
Example$$Lambda$1 run()

생성된 클래스를 생성하기 생성자를 직접 부르는 것 대신에 다른 메서드를 생성합니다. 그 다른 메서드는 static factory 메서드로 생성자를 부릅니다. Jack은 그것이 추가적인 static메서드가 없는 것만 제외하면 기본적으로 같습니다.


Example -Example_lambda$1(Greeter)
Example <init>()
Example main(String[])
Example run(Runnable)
Example$-void_main_java_lang_String__args_LambdaImpl0 <init>(Greeter)
Example$-void_main_java_lang_String__args_LambdaImpl0 run()
Greeter <init>()
Greeter sayHi()
java.io.PrintStream println(String)
java.lang.Object <init>()
java.lang.Runnable run()

희한한 이름이긴 하지만, 여전히 람다를 가지고 있습니다. 그리고 생성된 클래스는 전체 타입 시그니처에 따라 창의적으로 네이밍 되었습니다. 네, 이게 다입니다. 세개의 메서드가 있습니다. 람다는 상단에 추가 메서드를 생성케 하므로 하나의 추가 메서드에 비용을 지불해야 합니다.

메서드 참조는 정말 흥미롭습니다. Retrolambda와 Jack은 밀접히 연관이 있죠. Retrolambda는 때때로 추가 메서드를 생성해야 합니다. 한편 Java는 합성의(synthetic) 접근자 메서드를 생성합니다. private 메서드를 통해 다른 클래스로 참조를 패싱하려하기 때문일텐데 그렇게 요청할 방법이 없기 때문입니다. 그래서 네번째 부분이 생성됐죠.

Jack은 흥미롭게도 private 케이스를 제외한 모든 하나의 메서드 참조에 대하여 3개를 생성합니다. 현재로는 모든 메서드 참조에 대해 접근자 메서드를 생성합니다. 물론 버그도 있습니다만 바라건대 Jack이 곧 두개 메서드로 줄여줄 겁니다. 이건 매우 중요한데요, 왜냐하면 메서드 참조를 익명클래스로 같은 위치에 놓기 떄문입니다. 이것을 익명클래스에서 메서드 참조로 변환시킴으로써 추상화 비용을 없앤다면 좋겠죠.

안타깝게도 람다는 같은 양으로 줄이지는 않을 것입니다. 그 이유는 람다 내에서 private 멤버나 메서드를 역시 참조할 수 있기 떄문입니다. 이건 생성된 러너블로 복사 할 수가 없습니다. 또한 그것들에 접근할 수 있는 방법도 없죠. 우리는 역시 이것들의 가치를 알 수 있습니다.

람다의 실제 활용 (30:05)

얼마나 많은 람다가 실제로 사용되는지 볼까요? 꽤 시간이 걸리는 작업입니다.


#!/bin/bash             lambdas.sh
set -e

ALL=$(dex-method-list $1)

RL=$(echo "$ALL" | \grep ' lambda\$' | wc -l | xargs)
JACK=$(echo "$ALL" | \grep '_lambda\$' | wc -l | xargs)

NAME=$(basename $1)

echo -e "$NAME\t$RL\t$JACK"


$ column -t -s $'\t' \
    <(echo -e "NAME\tRETROLAMBDA\tJACK" \
        && find apks -type f | \
            xargs -L1 ./lambdas.sh | \
            sort -k2,2nr)

NAME                         RETROLAMBDA     JACK
com.squareup.cash             826             0
com.robinhood.android         680             0
com.imdb.mobile             306             0
com.stackexchange.marvin     174             0
com.eventbrite.attendee     53                 0
com.untappdllc.app             53                 0

10분정도 걸리는데 이 시간은 얼마나 많은 앱들이 불리냐에 따라 달라집니다. 언젠가는 결과를 갖게 될테니 인내심을 가지고 기다려 보세요.

안타깝게도 많은 사람들이 람다를 사용하는 것은 아닙니다. 가장 비용을 많이 지불하는 것을 파악하는 것이 흥미롭죠. 여기 826개의 람다가 있는데, 이건 메서드의 수가 아니라 람다의 수입니다. 우리 람다에서 메서드의 수는 아마 826개의 세네배쯤 되겠죠.

아직 Jack을 쓰는 사람은 없습니다. 적어도 제가 설치한 앱에 대해서는 아무로 람다와 함께 Jack을 쓰진 않아요. 아마도 Jack은 쓰는데 람다를 안쓰는 것일텐데, 좀 이상하죠. 또는 ProGuarding을 하는 것일 수도 있습니다.

ProGuard는 람다 클래스들과 메서드들의 이름을 완전히 숨기므로, 아마도 이게 왜 이 리스트에 뜨지 않았는지의 이유일 것입니다. 또는 제가 단지 그 앱을 좋아하지 않았거나요. 이게 메서드에 있는 전부 입니다.

제가 여기서 이것들을 살펴본 이유는 65K 한계를 피하기 위해서입니다. 그런데 이 메서드들은 런타임 코스트도 있습니다. 추가적인 바이트 코드를 로딩하는데 추가 비용이 듭니다. 또한 그걸 실행했을 때 거쳐야 할 추가적인 트탬펄린(trampoline)에 대한 비용도 있습니다. 제가 private 필드를 선호하는 이유는 이러한 익명 클래스 리스너들 안에서 정말 자주 발생하는 것이기 때문입니다. 그것들은 보통 메인 스레드에서 UI인터렉션의 결과로 작동하죠.

여러분은 메인 스레드에서 돌려야 하는 것에 대해 계산적으로 고비용의 코드조각을 원하진 않을 것입니다. 애니메이션이건, 크기 계산에 대한 것이든 말이죠. 매번 관련 필드를 참조하기를 원하지는 않을테고, 또 추가 메서드를 통해 점프해가는 것도 원치 않으시겠죠. 필드를 찾는 것은 꽤 빠릅니다. 메서드를 작동시키고 필드를 찾는 것은 여전히 빠를 수 있습니다. 그런데 당연히 그냥 필드를 찾는 것 보다는 느려집니다. 아마 이런 간접적인 것을 사용해 오셨을 겁니다. 이것들은 갑작스레 포기하진 않을 프레임들인데, 왜냐하면 이 접근자 메서드들이 존재하니 때문입니다. 하지만 사실 아들은 쓸모없는 메서드들입니다. APK를 부풀리고, 조금씩 조금씩 여러분 앱을 느리게 만들죠.

Collections (33:21)

조금 수정하여 런타임에 초점을 맞춰 얘기해보겠습니다. 컬렉션과 관련된 것인데요.


HashMap<K, V>                ArrayMap<K, V>
HashSet<K, V>                ArraySet<V>
HashMap<Integer, V>            SparseArray<V>
HashMap<Integer, Boolean>    SparseBooleanArray
HashMap<Integer, Integer>    SparseIntArray
HashMap<Integer, Long>        SparseLongArray
HashMap<Long, V>            LongSparseArray<V>

여러분의 앱에 이런 것을 쓰고 계시다면, 필요 이상으로 리소스를 낭비하고 있을지 모릅니다.

많은 분들이 익히 알고 계시듯, 안드로이드는 이런 특별한 컬렉션을 가지고 있습니다. 세부 구현은 다르지겠만 흔하게 접하게 되는 상황들에 특화되어 있습니다. 예를 들어, 맵에서 어떤 값을 나타내주는 인티저 인덱스가 있다고 하면, 여기에 사용할 수 있도록 특화된 컬렉션이 있습니다.

많은 사람들이 이런 오토박싱(autoboxing)에 대해 많이 얘기했습니다. 오토박싱이 생소하신 분들을 위해 잠시 설명드리겠습니다.

HashMap<Integer, V>
int ---> Integer.valueOf ---> put(Integer, V)
getKey() ---> Integer.intValue ---> int

인티저 키를 받을 수 있는 해쉬맵이 있고 특정 인티저 값을 맵에 넣고자 하는 상황을 가정해 보겠습니다. 아래 경우처럼, 엔트리를 순회하면서 그리고 키로부터 밸류를 꺼내고 싶다면 이 컨버전은 간단하지 않습니다. 이 때 오토박싱이라고 하는 추가 단계를 거치면서 primitive 값을 받아서 클래스 버전으로 돌려줍니다. 지금 상황에서는 인터저겠죠.

아래의 경우 타입을 언랩핑하므로 비용이 높지 않습니다. 그러나 위의 경우 비용이 커질 가능성이 높습니다. 적은 수에 대해서는 캐쉬가 있으니 그리 나쁘지 않은데, 만약 대량의 랜덤 인티저를 처리해야하는 경우라면 메소드를 부를 때마다 매번 오브젝트를 할당하게 됩니다. 이것은 제네릭이면서 많은 I 인티저를 받습니다. 이게 많은 사람들이 장점으로 꼽는 이유입니다. 그런데 이것 말고도 잘 알려지지 않은 다른 두 개의 큰 장점이 있습니다.

첫째는 데이터 인디렉션입니다. 해쉬맵의 내부 구현을 보면 노드들의 배열로 되어있고 고유 사이즈를 갖습니다. 밸류를 넣거나 찾으려면 이 배열로 와야합니다. 이 것이 해싱 단계로, 해쉬를 찾고나서 차감을 계산하는 과정에서 비용과 시간이 소요됩니다. 한편 이는 노드의 배열로 이뤄지는데, 노드타입은 키와 밸류를 모두 갖고 있습니다. 또한 추가 노드를 가리키는 포인터를 갖는 해쉬를 가지고 있죠.

배열이 있고, 노드에 대한 참조를 찾았으니 이제 그 노드로 가야할 차례입니다. 해당 밸류를 원한다면 그 노드 안을 살펴야겠죠. 즉, 밸류에 대한 참조값을 갖고 그 내부 값을 살펴보는 인디렉션들을 거쳐야 합니다. 이들이 메모리의 다른 공간에 들어있으므로 여러분은 하나의 키에 대한 값을 얻거나 값을 얻기 위해 이동을 해야 하죠. 이 경우 상황은 더 나빠질 수 있습니다.

이를 해쉬 충돌 문제라고 하는데, 두개의 아이템이 하나의 버킷에 해쉬될 때 발생하는 것입니다. 해쉬맵은 버킷 안의 링크드 리스트로 바뀌며, 그 링크드 리스트를 따라가 정확히 매치되는 해쉬를 찾아야 합니다. sparse 배열의 재배열 예제로 한 번 살펴 볼까요? 미리 언급하자면 앞서 말한 또 다른 장점은 오버헤드와 관련이 있습니다. 이 컬렉션들이 사용하는 메모리 상의 오버헤드를 염두에 두고 한번 살펴봅시다. 아래 예제에는 두 개의 클래스가 있습니다.


$ java -jar jol-cli-0.5-full.jar internals java.util.HashMap
# Running 64-bit HotSpot VM.
# Objects are 8 bytes aligned.
java.util.HashMap object internals:
OFFSET     SIZE     TYPE DESCRIPTION                 VALUE
0         4         (object header)                 01 00 00 00
4         4         (object header)                 00 00 00 00
8         4         (object header)                 9f 37 00 f8
12         4         Set AbstractMap.keySet             null
16         4         Collection AbstractMap.values     null
20         4         int HashMap.size                 0
24         4         int HashMap.modCount             0
28         4         int HashMap.threshold             0
32         4         float HashMap.loadFactor         0.75
36         4         Node[] HashMap.table             null
40         4         Set HashMap.entrySet             null
44         4         (loss due to the next object alignment)

Instance size: 48 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

Java object layout이라고 하는 도구를 사용하면 오브젝트를 메모리에 생성하는 오버헤드에 대해 알 수 있습니다. 해쉬맵 위에서 동작하는 이 도구는 많은 정보들을 프린트해줍니다. 필드별로 비용을 보여준 비용을 살펴볼까요? 중요한 숫자는 아래에 있는데, 해쉬맵의 모든 인스턴스가 있는 곳입니다. 노드나, 키, 밸류 등이 아닌 바로 해쉬맵입니다. 해쉬맵 자체는 48 bytes입니다. 나쁘지 않은 크기네요.


$ java -jar jol-cli-0.5-full.jar internals 'java.util.HashMap$Node'
# Running 64-bit HotSpot VM.
# Objects are 8 bytes aligned.

java.util.HashMap$Node object internals:
OFFSET     SIZE     TYPE DESCRIPTION     VALUE
0         12         (object header)     N/A
12         4         int Node.hash         N/A
16         4         Object Node.key     N/A
20         4         Object Node.value     N/A
24         4         Node Node.next         N/A
28         4         (loss due to the next object alignment)

Instance size: 32 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

이제 노드 오브젝트를 돌려 볼까요? 같은 과정을 통해 4개의 필드를 확인할 수 있습니다. 맵의 각 노드별로 32 bytes입니다. 맵의 각각의 아이템, 키 밸류 쌍은 이 노드들 중의 하나에 속하게 됩니다. 전체 항목 수의 32배가 되죠.

값을 입력하기 시작할때, 런타임시 실제 오브젝트의 사이즈를 파악하기 위해 이런 공식을 사용할 수 있습니다. 어레이 같은 것들도 계산해야 하므로 완전히 정확한 것은 아닙니다. 노드를 홀드해야 하는 어레이가 있다면 어레이가 차지할 공간도 당연히 계산해야 할테니 복잡한 문제입니다. 각 어레이를 참조하는 하나의 인티저는 4이며, 어레이 크기에 따라 배가 됩니다.

문제는 해쉬맵이 로드팩터라고 하는 것을 가지고 있다는 것입니다. 끝의 8은 모든 어레이의 상수 오버헤드입니다. 그런데 로드 팩터는 가득 채워지지 않습니다. 어느 정도로 차있는 수준을 유지합니다. 그래서 그 적정 선에 도달하면 어레이 리스트와 마찬가지로 커지게 됩니다. 해쉬맵 역시 빈 공간을 유지하기 위해 커집니다.

그 이유는, 이렇게 하지 않는다면 많은 충돌과 성능 저하를 경험하게 될 것이기 때문입니다. 아마도 로드 팩터가 어떻든 많은 엔트리를 갖는 해쉬맵 밸류가 될 겁니다. 우리는 이게 메모리에서 얼만큼의 bytes를 사용하는지 확인할 수 있습니다. 그런데 디폴트 로드 팩터는 75%입니다. 해쉬맵은 3/4만큼만 채워진 상태를 유지하려 합니다.

Sparse array(희소 행렬)는 이렇게 해쉬맵을 재배치할 때 꼭 사용해야만 하는 것입니다. 해쉬맵에서 봤던 2개의 경우를 볼까요. sparse array는 형제인 2개의 어레이를 가집니다. 하나는 키이고, 다른 하나는 밸류입니다. 이 맵에서 밸류를 찾거나 밸류를 넣기위해 처음 해야하는 것은 해쉬맵에서와 달리 인티저 어레이로 가는 것입니다. 해시맵과 달리 상수시간이 걸리는 작업이 아닙니다. 어레이 안에서 바이너리 서치를 해서 밸류 어레이로 갈 수 있고,밸류가 있는 셀을 돌려받을 수 있습니다. 즉, 밸류 어레이이므로 반환받은 후 바로 레퍼런스로 갈 수 있고 값을 반환받을 수 있다는 것이죠.

메모리에 대해서는 덜 간접적인 것들이 많습니다. 인티저 어레이는 연속적이며 링크드 리스트가 없고, 바로 밸류 어레이 내부로 들어갈 수 있습니다. 우리가 언랩해서 밸류에 접근해야 하는 노드 오브젝트도 없고, 간접성도 줄어듭니다. 그렇지만, 완전히 상수 시간이 아닌 작업의 속도가 더 느려질 수 있습니다. 이 때문에 상대적으로 작은 맵을 유지하고자 하게 되죠. 작다는 정도는 수백 개 정도 규모의 엔트리들을 뜻하는데, 만약 수천 개를 넣는다면 바이너리 서치의 성능은 느려지게 될 것입니다. 아마 이제 해쉬맵의 오버헤드가 성능면에서 매력적으로 보이기 시작하실테죠.


$ javac SparseArray.java

$ java -cp .:jol-cli-0.5-full.jar org.openjdk.jol.Main \
internals android.util.SparseArray

# Running 64-bit HotSpot VM.
# Objects are 8 bytes aligned.

android.util.SparseArray object internals:
OFFSET     SIZE     TYPE DESCRIPTION                 VALUE
0         4         (object header)                 01 00 00 00
4         4         (object header)                 00 00 00 00
8         4         (object header)                 1a 69 01 f8
12         4         int SparseArray.mSize             0
16         1         boolean SparseArray.mGarbage     false
17         3         (alignment/padding gap)         N/A
20         4         int[] SparseArray.mKeys         [0, 0, 0, 0, 0, 0, ]
24         4         Object[] SparseArray.mValues     [null, null, null, ]
28         4         (loss due to the next object alignment)

Instance size: 32 bytes
Space losses: 3 bytes internal + 4 bytes external = 7 bytes total

이 경우는 클래스들은 완전히 동일하며, JVM은 64bit입니다. 그리고 안드로이드도 이제 64bit입니다. 이게 싫다면 근사치로 처리하고 20% 정도의 variance를 허용해주세요. 분명히 안드로이드 자체에서 오브젝트 사이즈를 구하는 불가능한 일을 시도하는 것보다 쉬운 길입니다.

sparse array를 위한 오브젝트 자체는 32bytes로 조금 작습니다. 그 사이즈는 문제가 되지 않지만, 이것들이 단지 32 plates가 아니라는 점이 문제가 됩니다. 또 엔트리 문제 역시 있습니다. 이 어레이 역시 계산돼야 하죠. 즉, 키를 위한 인티저들 그리고 엔트리 수의 4배, 여기에 8을 더해야 합니다. 그 후 밸류를 위한 어레이가 또 있습니다. 똑같이 엔트리의 4배에 8을 더해야 하죠.

여기서 다른 점은 sparse array는 로드팩터를 가지지 않는데, 이 어레이들은 바이너리 트리이므로 암묵적으로 로드팩터를 갖는다는 점입니다. 이들은 채워지지 않고, 인접하지 않으며, 내부에 사용되지 않는 공간이 있습니다. 로드팩터와 유사한 임시방편적인 요소(fudge factor)를 가지고 살펴볼까요? 이 경우는 맵에 넣는 데이터에 전적으로 의존합니다. 같은 밸류를 사용하면서, 어레이들이 75%정도로 채워진다고 안전하게 가정해 보겠습니다.


SparseArray<V>
32 + (4 * entries + 4 * entries) / 0.75

HashMap<Integer, V>
48 + 32 * entries + 4 * (entries / loadFactor) + 8

이제 이들을 직접적으로 비교할 수 있습니다. 앞서 indirection jump를 카운트 할 수 있음을 설명드렸죠. 이제 간단한 식들을 사용해서 인스턴스들이 차지하는 실제 메모리의 값들을 비교할 수 있습니다.


SparseArray<V>
32 + (4 * 50 + 4 * 50) / 0.75 = 656

HashMap<Integer, V>
48 + 32 * 50 + 4 * (50 / 0.75) + 8 = 1922

해쉬맵에 디폴트값인 .75를 사용하겠습니다. 엔트리 수를 위해 수를 선택할 수 있는데, 여기서는 50을 선택했습니다. 아마도 미국 주에 대한 맵일 수 있겠네요. 이제 계산을 실행합니다. sparse array가 전체 사이즈의 1/3사이즈라는 것을 볼 수 있을 겁니다. 약간의 성능 오버헤드가 있음을 기억하세요. 왜냐하면 각각의 오퍼레이션은 더 이상 상수 시간이 아니기 때문입니다. 50개 요소가 있으니까 바이너리 트리에서 검색하는 수는 매우 빠를 것입니다.

결론 (44:18)

많은 작업을 했습니다만, 결국은 이런 오버헤드를 피하기 위해서 컴파일 타임이나 런타임에 해야 할 아주 사소한 것들이 있다는 겁니다.

첫번째는 이미 말씀드린대로 private 멤버를 절대 무시하지 말고 잘 조사해 보시라는 점입니다. 모든 싱글 타입에 대해 할 필요도, 한번에 모든 것을 다 찾아 해야 할 필요도 없습니다. 여러분의 앱에서 하시듯 하면 되겠죠. 반대로 라이브러리라면 조금 더 중요하겠습니다.

라이브러리라면 여러분은 필히 APK사이즈와 런타임 성능에 미치는 영향을 최소화해야 할 것입니다. deck 사이즈와 런타임 성능도 신경써야 합니다. 라이브러리를 가지고 계신다면, 아마 이런 모든 것들을 찾기를 원하실 겁니다. 라이브러리에 dex 파일에서 공간을 낭비하는 synthetic accessor 메소드들이 존재해야 할 이유는 없습니다. 런타임에서 시간을 낭비하는 이런 메소드를 발견하면 버그 리포트를 하세요.

만약 retrolambda를 사용하고 계신다면 제발, 제발, 제발 최신 버전으로 업그레이드하세요. 아니면 아마도 수천개의 메소드를 낭비하게 될겁니다. 만약 오픈소스 라이브러리를 작성한다면, 익명 클래스를 잘 다루고 받아들이셔야 합니다. 아주 어려운 일은 아닙니다. 그런데 한 번 더, 여러분이 앱 개발자들에게 미치는 영향을 최소화하고 싶다면, 라이브러리라서 문제가 되진 않습니다.

Jack에 대해 말해보자면, 이건 꽤 큰 일입니다. 여전히 빠르게 개발이 진행 중이며, 많은 개발자들이 적용할 수 있는 많은 것들을 놓치고 있습니다. 하지만 이보다 더 진부하거나 빌드타임에 더 색다른 작업을 하는 어플리케이션도 물론 존재하겠죠.

버그를 무시하지 마세요. 스위치 하지 말고, 충돌나는 것을 찾으면서, 어쨌든 ‘에이, 2년 안에는 고쳐야지’ 하면서 넘어가지 마세요. 여러분은 할 수 있습니다. Java C 인덱스로 돌아가기 전에 버그를 리포트 할 수 있습니다. 이건 나중에 할 일이라면서 귀를 닫고 눈을 감을 수 있겠지만, 이건 항상 벌어지는 일이므로 가능한 빨리 찾는게 더 낫습니다. 이런 상황에서 ProGuard가 도움이 됩니다.

과도하게 룰을 사용하지는 마세요. ProGaurd 룰 파일에서 * * 를 본다면, 이건 100% 틀린 것이므로 쓰지 말아야 합니다. 왜냐하면 이를 사용하면 ProGuard에서 얻은 장점들이 없어지기 때문입니다. 여러분은 “맞아, 난 OkHttp에서 풀링하니까 지금 쓰지 않는 HTTP/2의 이 메소드들을 원하지는 않아. 그래도 그것들을 버리진 않을거야. 만약을 위해 그것들을 가지고 있겠어”라고 할 수는 있겠지만, 현명한 방법은 아니죠. 만약 오픈 소스 라이브러리에서 이것들을 발견하신다면 버그 리포팅을 하세요. 만약 여러분 앱에서도 가지고 있다면 왜인지부터 알아 내고, 이를 제거하고 나서 ProGuard의 실패에 대해 살펴보세요. 또 여러분의 룰을 바꿀 수 있는 더 구체적인 것이 있는지 보시고요. 만약 더 깊게 알아보고 싶다면 아래 프레젠테이션들을 참고해 보세요.

Resources

다음: Realm Java를 사용하면 안드로이드 앱 모델 레이어를 효과적으로 작성할 수 있습니다.

General link arrow white

컨텐츠에 대하여

2016년 7월에 진행한 [360 AnDev](http://360andev.com/) 행사의 강연입니다. 영상 녹화와 제작, 정리 글은 Realm에서 제공하며, 주최 측의 허가 하에 이곳에서 공유합니다.

Jake Wharton

Jake Wharton은 Square의 Square Cash에서 일하는 안드로이드 개발자입니다. 지난 5년간 나쁜 API와 틀에 박힌 상용구 코드를 보면서 괴로워 해왔죠. 마치 전염병처럼 퍼져 많은 개발자를 괴롭히는 이런 현상에 대한 경각심을 알리기 위해 전 세계 컨퍼런스에서 강연을 하고 있습니다.

4 design patterns for a RESTless mobile integration »

close