[Android] 성능을 위한 설계 – 이동훈님

블로그에 왠만하면 펌질은 피할려고 생각중이지만 너무 좋은 글이라 두고두고 보고 싶어서 퍼왔습니다.
항상 많은곳에서 활동하시는 마메럴핀(이동훈)님이 번역하신 글입니다.




마메렐핀님(이동훈님) – 2009.06.05

성능을 위한 설계




안드로이드 애플리케이션의 속도는 빨라야만 합니다. 음, 효율적이어야 한다고 말하는 쪽이 더 정확할 듯싶네요. 다시 말해, 제한된 컴퓨팅 파워와 데이터 저장소, 작은 화면, 갑갑한 배터리 수명 같은 모바일 장치 환경에서 가능한 한 효율적으로 실행되어야 한다는 것입니다.


애플리케이션을 개발할 때에는 이것을 명심하세요. 듀얼코어 개발 컴퓨터에서 실행하는 에뮬레이터에서는 충분히 잘 작동할지도 모르지만, 모바일 기기에서 실행할 때엔 그리 잘 되지 않을 것입니다. — 최고 성능의 모바일 기기라도 일반적인 데스크탑 시스템의 성능을 따라잡을 수는 없습니다. 그런 이유로, 다양한 모바일 기기들에게 최상의 성능을 보장하기 위해 여러분은 효율적인 코드를 작성하도록 열심히 노력하셔야 합니다.


일반적으로, 빠르거나 효율적인 코드라는 것은 메모리 할당을 최소화 하고, 꽉 짜인 코드를 작성하고, 특정 프로그래밍 언어나 잠재적으로 성능상 문제가 될만한 프로그래밍 어법들을 피하는 것을 말합니다. 객체지향 용어로 말하자면, 이러한 일이 가장 빈번히 일어나는 곳은 메소드 레벨이며, 이와 비슷하게 실제 코드 라인들과 반복문 등에서 발생합니다 .


이 문서는 다음 주제들을 다룹니다:



소개


자원 제한적 시스템에는 두 가지 기본 규칙이 있습니다:



  • 필요 없는 일은 하지 말 것
  • 메모리 할당을 피할 수 있다면 그렇게 할 것

아래의 모든 팁들은 이 두 가지 기본 주의를 따르고 있습니다.


누군가는 이 페이지상의 많은 조언이 “섣부른 최적화”나 마찬가지라고 비판할지도 모릅니다. 미시 최적화는 때로는 효율적인 데이터 구조와 알고리즘을 개발하는 것을 더 어렵게 만든다는 것은 사실입니다. 하지만, 핸드셋과 같은 임베디드 기기에서는 때로는 별다른 선택지가 없습니다. 예를 들어, 여러분이 데스크탑에서 개발할 때 생각하는 VM의 성능에 대한 가정을 안드로이드에도 적용한다면, 여러분은 시스템 메모리를 소진해버리는 코드를 꽤나 작성해 버리고 말 것입니다. 이것은 여러분의 애플리케이션이 바닥을 기도록 할 수 있습니다 — 시스템에서 동작하는 다른 프로그램들에게 무엇을 하는지 지켜보세요!


이것이 바로 이 가이드라인이 중요한 이유입니다. 안드로이드의 성공은 여러분의 애플리케이션이 제공하는 사용자 경험(UX)에 달렸고, 사용자 경험이란 것은 여러분의 코드가 빠르고 팔팔하게 반응하는지, 아니면 느리고 무거운지에 달렸습니다. 모든 우리의 애플리케이션들은 같은 장치에서 동작할 것이기 때문에, 어떤 의미로, 우리 모두 함께 이 것들을 지키도록 최선을 다해야 합니다. 이 문서를 운전면허를 딸 때 배워야만 하는 도로교통법이라고 생각하세요: 모든 이가 따르면 문제없이 원활하겠지만, 따르지 않는다면 사고가 날 것처럼 말입니다.


자세한 내용을 다루기 전에, 간단한 주의사항입니다: 아래 설명된 대부분의 이슈들은 VM이 JIT 컴파일러이든 아니든 효과적입니다. 같은 기능을 수행하는 두 메소드가 있고 interpret 방식에서 foo()의 실행속도가 bar()보다 빠르다면, 컴파일 된 버전에서도 아마 foo()가 bar()과 비슷하거나 더 빠른 속도를 보여줄 것입니다. 컴파일러가 여러분을 “구해줄”것이라던가 충분히 빠르게 만들어줄 것이라고 의존하는 건 현명하지 못하다는 것이죠.


객체 생성을 피하라


객체의 생성은 결코 공짜가 아닙니다. 임시 객체들을 위해 쓰레드-당(per-thread) 할당 풀을 사용하는 세대형(generational) GC는 더 낮은 비용으로 할당 할 수 있지만, 메모리를 할당한다는 것은 메모리를 할당하지 않는 것 보다 언제나 더 높은 비용이 듭니다.


만약 사용자 인터페이스 루프에서 객체를 할당한다면, 주기적으로 가비지 컬렉션을 강요하게 될 것이고 사용자 경험에 있어서 조그마한 “딸꾹질(거북함)”을 만들게 될 겁니다.


그러므로, 필요로 하지 않는 객체 생성을 피해야 합니다. 도움이 될 몇 가지 예제들이 있습니다.



  • 입력 데이터 셋에서 문자열을 추출할 때, 복사 생성된 것 대신 원본 데이터의 부분문자열을 받으십시오. 새로운 String 객체가 만들어졌더라도 원본 데이터의 char[]을 공유할 것입니다.
  • 문자열을 반환하는 메소드가 있고 그 결과가 언제나 StringBuffer에 더해지게 되는 경우에 있다면, 짧은 수명의 임시 객체를 생성하는 대신 직접적으로 더해지는 방식으로 식별자와 구현방식을 바꾸세요.

좀 더 급진적인 아이디어는 다차원 배열을 병렬의 단일 일 차원 배열로 잘라내는 것입니다:



  • int 배열은 Integer 배열보다 더 좋습니다만, 이것으로 또한 int형의 두 병렬 배열이 (int,int) 객체의 배열보다 더 많이 효과적이라는 사실을 추론할 수 있습니다.
  • 만약 (Foo,Bar) 튜플로 저장하는 컨테이너를 구현할 필요가 있다면, 직접 만든 (Foo,Bar) 객체의 단일 배열보다 두 개의 병렬 Foo[] 와 Bar[] 배열이 일반적으로 더욱 더 좋다는 것을 기억하십시오. (물론, 다른 코드들이 접근해야 하는 API를 설계할 때에는 예외가 있습니다; 이 경우 작은 속도 향상을 노리는 것 보다 좋은 API설계가 항상 좋습니다. 그러나 여러분의 내부 코드를 작성할 때에는 가능한 한 효율적인 코드가 되도록 해야 하겠습니다.)

일반적으로, 가능하다면 짧은 수명의 임시 객체 생성을 피하십시오. 더 적은 객체들을 만든다는 것은 사용자 경험에 직접적인 영향을 주는 가비지 컬렉션 줄여줌을 뜻합니다.


네이티브 메소드를 사용하라


문자열을 처리할 때, String.indexOf(), String.lastIndexOf() 와 그 밖의 특별한 메소드를 사용하는 것을 주저하지 마십시오. 이 메소드들은 대체적으로, 자바 루프로 된 것 보다 대략 10-100배 빠른 C/C++ 코드로 구현이 되어있습니다.


이 조언의 반대적 측면은 네이티브 메소드를 호출하는 것이 interpret방식의 메소드 호출보다 더 비용이 높다는 것입니다. 피할 수 있다면, 사소한 계산에는 네이티브 메소드를 사용하지 마십시오.


인터페이스보다 가상 연결을 선호하라


여러분이 HashMap 객체를 하나 가지고 있다고 합시다. 여러분은 HashMap이나 제네릭 Map 으로 선언을 할 수 있습니다.

Map myMap1 = new HashMap();
HashMap myMap2 = new HashMap();

어떤것이 더 좋은가요?


전통적인 지혜에서는 Map을 사용해야 한다고 할 것입니다. Map 인터페이스를 구현한 어떤 것으로라도 구현체를 바꿀 수 있기 때문입니다. 전통적인 지혜는 전통적인 프로그래밍에는 맞습니다만, 임베디드 시스템에는 그다지 대단하지 않습니다. 인터페이스 참조를 통해 호출하는 것은 명확한 참조를 통한 가상 메소드 호출보다 2배 더 소요될 수 있습니다.


여러분이 하는 일에 적합하여 HashMap사용을 선택했다면 Map으로 호출하는 것은 거의 가치가 없습니다. 코드를 리팩터링 해 주는 IDE의 가능성을 고려해 보더라도, Map으로 호출하는 것은 큰 가치가 없습니다. 여러분이 코드의 방향을 확신하지 못한다 해도 말입니다. (다시금 이지만, 공용 API는 예외입니다: 작은 성능 고려보다 좋은 API가 언제나 으뜸입니다.)


가상 연결보다 정적 연결을 선호하라


만약 객체의 필드에 접근할 필요가 없다면, 여러분의 메소드를 정적(static)으로 만드세요. 가상 메소드 테이블을 필요로 하지 않기 때문에 그게 더 빠르게 불려집니다. 또한, 메소드 식별자를 보고 메소드 호출이 객체의 상태를 바꿀 수 없다고 말할 수 있으므로, 좋은 관습이 됩니다.


내부에서 Getter/Setter 사용을 피하라


C++와 같은 네이티브 언어에서 필드에 직접적으로 접근하는 것 (예. i = mCount) 보다 getter를 사용하는 것 (i = getCount())은 일반적인 관습입니다. 이 방법은 C++에서는 훌륭한 습관입니다. 왜냐하면 항상 접근을 inline화 할 수 있는 컴파일러를 사용하고 있고, 필드에 접근을 제한하거나 디버그 해야 한다면 언제나 코드를 추가할 수 있기 때문입니다.


안드로이드에서는 나쁜 생각입니다. 가상 메소드 호출은 인스턴스 필드 참조보다 더 비용이 높습니다. 일반적인 객체 지향 프로그래밍 관습에 따르거나, 공용 인터페이스에서 getter, setter을 가지는 것은 이치에 맞습니다. 그러나 클래스 내부에서는 언제나 직접적으로 필드 접근을 해야합니다.


필드 참조들을 캐시하라


객체의 필드에 접근하는 것은 지역 변수에 접근하는 것 보다 더 느립니다. 이렇게 작성하는 것 대신:

for (int i = 0; i < this.mCount; i++)
dumpItem(this.mItems[i]);

이렇게 하십시오:

  int count = this.mCount;
Item[] items = this.mItems;

for (int i = 0; i < count; i++)
dumpItems(items[i]);


(멤버 변수라는 것을 명확히 하기 위해 명시적인 “this”를 사용하고 있습니다.)


유사한 가이드라인은, 결코 “for”문의 두 번째 조건에서 메소드를 호출하지 말라는 것입니다. 예를 들어, 다음 코드는 간단하게 int 값으로 캐쉬 할 수 있는 경우임에도 큰 낭비가 되는 getCount()메소드를 매번 반복 마다 실행하게 됩니다:

for (int i = 0; i < this.getCount(); i++)
dumpItems(this.getItem(i));

인스턴스 필드를 한번 이상 접근해야 한다면, 지역 변수를 만드는 것 또한 좋은 생각입니다. 예를 들어:

    protected void drawHorizontalScrollBar(Canvas canvas, int width, int height) {
if (isHorizontalScrollBarEnabled()) {
int size = mScrollBar.getSize(false);
if (size <= 0) {
size = mScrollBarSize;
}
mScrollBar.setBounds(0, height – size, width, height);
mScrollBar.setParams(
computeHorizontalScrollRange(),
computeHorizontalScrollOffset(),
computeHorizontalScrollExtent(), false);
mScrollBar.draw(canvas);
}
}

mScrollBar 멤버 필드에 네 개의 분리된 참조가 있습니다. 지역 스택 변수로 mScrollBar를 캐싱 함으로써, 네 개의 멤버 필드 참조가 더욱 효율적인 네 개의 스택 변수 참조로 바뀌었습니다.


덧붙여 말하자면, 메소드 인자들은 지역 변수와 같은 성능 특성을 가집니다.


상수를 Final로 선언하라


클래스의 상단에 있는 다음 선언을 고려해 봅시다:

static int intVal = 42;
static String strVal = “Hello, world!”;

컴파일러는 클래스가 처음 사용될 때 실행하게 되는 <clinit>라 불리는 ‘클래스 초기화 메소드’를 생성합니다. 이 메소드가 intVal에 42 값을 저장하고, strVal에는 클래스파일 문자 상수 테이블로부터 참조를 추출하여 저장합니다. 나중에 참조될 때 이 값들은 필드 참조로 접근됩니다.


이를 “final” 키워드로 향상시킬 수 있습니다:

static final int intVal = 42;
static final String strVal = “Hello, world!”;

클래스는 더이상 <clinit> 메소드를 필요로 하지 않습니다. 왜냐하면 상수들은 VM에 의해 직접적으로 다루어 지는 ‘클래스파일 정적 필드 초기자’에 들어가기 때문입니다.intVal의 코드 접근은 직접적으로 정수 값 42를 사용할 것이고, strVal로의 접근은 필드 참조보다 상대적으로 저렴한 “문자열 상수” 명령을 사용하게 될 것입니다.


“final”으로 메소드나 클래스의 선언을 하는 것은 즉각적인 성능 이득을 주지는 못하지만, 특정한 최적화를 가능하게 합니다. 예를 들어, 컴파일러가 서브클래스에 의해 오버라이드될 수 없는 “getter”메소드를 알고 있다면, 메소드 호출을 inline화 할 수 있습니다.


여러분은 또한 지역 변수를 final로 선언할 수 있습니다. 하지만 이것은 결정적인 성능 이득은 없습니다. 지역 변수에는 오직 코드를 명확히 하기 위해서 “final”을 사용합니다 (또는 예를 들어 익명 내부 클래스를 사용해야 한다면 가능).


주의 깊게 향상된 반복문(Enhanced For Loop)을 사용하라


향상된 반복문(때로 “for-each”로 알려진 반복문)은 Iterable 인터페이스를 구현한 컬렉션들을 위해 사용될 수 있습니다. 이러한 객체들로, 반복자는 hasNext() 와 next()을 호출하는 인터페이스를 만들기 위해 할당됩니다. ArrayList의 경우 여러분이 직접 탐색하는 것이 좋을 수 있습니다만, 다른 컬렉션들에서는 향상된 반복문 구문이 명시적인 반복자의 사용과 동등한 성능을 보여줍니다.


그럼에도, 다음 코드로 향상된 반복문의 만족스러운 사용법을 볼 수 있습니다:

public class Foo {
int mSplat;
static Foo mArray[] = new Foo[27];

public static void zero() {
int sum = 0;
for (int i = 0; i < mArray.length; i++) {
sum += mArray[i].mSplat;
}
}

public static void one() {
int sum = 0;
Foo[] localArray = mArray;
int len = localArray.length;

for (int i = 0; i < len; i++) {
sum += localArray[i].mSplat;
}
}

public static void two() {
int sum = 0;
for (Foo a: mArray) {
sum += a.mSplat;
}
}
}


zero() 는 반복되는 매 주기마다 정적 필드를 두 번 부르고 배열의 길이를 한번 얻습니다.


one() 은 참조를 피하기 위해 지역 변수로 모든 것을 끌어냈습니다.


two() 는 자바 언어의 1.5버전에서 소개된 향상된 반복문 구문을 사용합니다. 컴파일러에 의해 생성된 코드는 배열 참조와 배열의 길이를 지역 변수로 복사해주어, 배열의 모든 원소를 탐색하는데 좋은 선택이 될 수 있습니다. 주 루프 내에 추가적인 지역 읽기/저장이 만들어지고(명백하게 “a”에 저장), one()보다 쪼금 느리고 4 바이트 길어지게 하긴 합니다.


좀 더 명확하게 모든 것을 종합하자면: 향상된 반복문 구문은 배열과 잘 동작하지만, 추가적인 객체 생성이 있게 되는 Iterable 객체와 함께 사용할 때엔 조심해야 합니다.


열거형(Enum)을 피하라


열거형은 매우 편리합니다, 그러나 불운하게도 크기와 속도 측면에서 고통스러울 수 있습니다. 예를들어, 다음의 코드는:

public class Foo {
public enum Shrubbery { GROUND, CRAWLING, HANGING }
}

900 바이트의 클래스 파일 (Foo$Shrubbery.class) 로 변환됩니다. 처음 사용할 때, 클래스 초기자는 각각의 열거화된 값들을 표기화 하는 객체상의 <init>메소드를 호출합니다. 각 객체는 정적 필드를 가지게 되고 총 셋은 배열(“$VALUES”라 불리는 정적 필드)에 저장됩니다. 단지 세 개의 정수를 위해 많은 코드와 데이터를 필요로 하게 됩니다.


다음 코드:

Shrubbery shrub = Shrubbery.GROUND;

는 정적 필드 참조를 야기합니다. “GROUND”가 정적 final int 였더라면, 컴파일러는 알려진 상수로서 다루고, inline화 했을 수도 있습니다.


물론, 반대적 측면에서 열거형으로 더 좋은 API를 만들 수 있고 어떤 경우엔 컴파일-타임 값 검사를 할 수 있습니다. 그래서 통상의 교환조건(trade-off)이 적용됩니다: 반드시 공용 API에만 열거형을 사용하고, 성능문제가 중요할 때에는 사용을 피하십시오.


어떤 환경에서는 ordinal() 메소드를 통해 정수 값 열거를 갖는 것이 도움이 될 수 있습니다. 예를 들어, 다음 코드를:

for (int n = 0; n < list.size(); n++) {
if (list.items[n].e == MyEnum.VAL_X)
// do stuff 1
else if (list.items[n].e == MyEnum.VAL_Y)
// do stuff 2
}

다음 코드로 대신합니다:

   int valX = MyEnum.VAL_X.ordinal();
int valY = MyEnum.VAL_Y.ordinal();
int count = list.size();
MyItem items = list.items();

for (int n = 0; n < count; n++)
{
int valItem = items[n].e.ordinal();

if (valItem == valX)
// do stuff 1
else if (valItem == valY)
// do stuff 2
}


때로는, 보장할 수 없습니다만, 이것이 더 빠를 수 있습니다.


내부 클래스와 함께 패키지 범위를 사용하라


다음 클래스 정의를 고려해 봅시다:

public class Foo {
private int mValue;

public void run() {
Inner in = new Inner();
mValue = 27;
in.stuff();
}

private void doStuff(int value) {
System.out.println(“Value is ” + value);
}

private class Inner {
void stuff() {
Foo.this.doStuff(Foo.this.mValue);
}
}
}


여기서 주목해야 할 중요한 것은, 외부 클래스의 private 메소드와 private 인스턴스 필드에 직접 접근하고 있는 내부 클래스(Foo$Inner)를 정의했다는 것입니다. 이것은 적법하고, 코드는 기대했던 대로 “Value is 27″을 출력합니다.


문제는 Foo$Inner는 기술적으로는 (비밀로써) 완전히 분리된, Foo의 private 멤버로 직접적인 접근을 하는 것은 적법하지 못한 클래스라는 것 입니다. 이 차이를 연결짓기 위해, 컴파일러는 두 개의 합성 메소드를 만듭니다:

/*package*/ static int Foo.access$100(Foo foo) {
return foo.mValue;
}
/*package*/ static void Foo.access$200(Foo foo, int value) {
foo.doStuff(value);
}

내부 클래스 코드는 외부 클래스에 있는 “mValue” 필드에 접근하거나 “doStuff” 메소드를 부르기 위해 이 정적 메소드를 부릅니다. 이것은 이 코드가 결국은 직접적인 방법 대신 접근자 메소드를 통해 멤버 필드에 접근하고 있다는 것을 뜻합니다. 이전에 우리는 어째서 접근자가 직접적인 필드 접근보다 느린지에 대해 이야기 했었는데, 이 문제로서 “보이지 않는” 성능 타격 측면에서 특정 언어의 어법이 야기하게 되는 문제에 대한 예제가 될 수 있겠습니다.


이 문제는 내부 클래스가 접근하는 필드와 메소드 선언에 private 범위가 아닌 package 범위를 가지도록 함으로써 피할 수 있습니다. 이로써 더욱 빠르게 동작하게 되고 자동 생성되는 메소드에 의한 오버헤드를 제거할 수 있습니다. (불운하게도 이 또한 직접적으로 같은 패키지 내의 다른 클래스들이 필드들에 접근할 수 있다는 것을 뜻하게 되며, 모든 필드들은 private로 해야 한다는 표준적인 OO 관습에 거스르게 됩니다. 다시 한번 더 말하자면, 공용 API를 설계하게 된다면 이 최적화를 사용하는 것을 조심스럽게 고민해야만 할 것입니다.)


Float를 피하라


펜티엄 CPU가 출시되기 전, 게임 제작자들에겐 정수 계산에 최선을 다하는 것이 일반적이었습니다. 펜티엄과 함께 부동소수점 계산 보조 프로세서는 일체형이 되었고, 정수와 부동소수점 연산을 넣음에 따라 순수하게 정수 계산만을 사용하는 것 보다 게임은 더 빠르게 되었습니다. 자유롭게 부동소수점을 사용하는 것은 데스크탑 시스템에서는 일반적입니다.


불운하게도, 임베디드 프로세서에게는 빈번하게 하드웨어적으로 부동소수점 계산이 제공되지 않고 있어, “float” 와 “double”의 모든 계산이 소프트웨어적으로 처리됩니다. 어떤 기초적인 부동소수점 계산은 완료까지 대략 일 밀리 초 정도 걸릴 수 있습니다.


또한, 정수에서도 어떤 칩들은 하드웨어 곱셈을 가지고 있지만 하드웨어 나눗셈이 없기도 합니다. 이러한 경우, 정수 나눗셈과 나머지 연산은 소프트웨어적으로 처리됩니다 — 만약 해시 테이블을 설계하거나 많은 계산이 필요하다면 생각해 보아야 할 것입니다.


성능 예시 숫자 몇 개


우리의 몇 가지 아이디어를 설명하기 위해, 약간의 기초적인 행동들에 대해 대략적인 실행시간을 나열한 테이블을 만들었습니다. 이 값들은 절대적인 숫자가 아니라는 것을 주목해 주십시오: CPU시간과 실제 구동 시간의 조합이고, 시스템의 성능 향상에 따라 변화할 수 있습니다. 그러나 이 값들 사이에 관계를 적용해 보는 것은 주목할 만한 가치가 있습니다 — 예를 들어, 멤버 변수를 더하는 것은 지역 변수를 더하는 것보다 대략 네 배가 걸립니다.
















































행동 시간
지역 변수 더하기 1
멤버 변수 더하기 4
String.length() 호출 5
빈 정적 네이티브 메소드 호출 5
빈 정적 메소드 호출 12
빈 가상 메소드 호출 12.5
빈 인터페이스 메소드 호출 15
HashMap의 Iterator:next() 호출 165
HashMap의 put() 호출 600
XML로부터 1 View 객체화(Inflate) 22,000
1 TextView를 담은 1 LinearLayout 객체화(Inflate) 25,000
6개의 View 객체를 담은 1 LinearLayout 객체화(Inflate) 100,000
6개의 TextView 객체를 담은 1 LinearLayout 객체화(Inflate) 135,000
빈 activity 시작 3,000,000

맺음 말


임베디드 시스템을 위해 좋고 효율적인 코드를 작성하는 최선의 방법은 여러분이 작성하는 코드가 실제로 무엇을 하는지 이해하는 것 입니다. 여러분이 정말로 반복자를 할당하기를 원한다면, List에 향상된 반복문을 반드시 사용하십시오; 부주의한 부작용이 아닌 신중한 선택을 통해서 말입니다.


유비무환입니다! 무엇을 하는지 알고 하세요! 좋아하는 좌우명을 여기에 넣으세요, 그러나 언제나 여러분의 코드가 무엇을 하는지 주의 깊게 생각하고, 속도를 높이는 방법을 찾도록 경계하십시오.