정상혁정상혁

수정이력

2019/04/09

  • Github로 글 이전하면서 이미지, 첨부파일 내용 복원

2007/08/13

  • '들어가며', '마치며' 대폭 수정

  • 오타 수정 : 여러개의 메소드에서 같이 써는 코드였었습니다.=⇒ 여러개의 메소드에서 같이 쓰는 코드였었습니다.

2007/08/09

  • 첫 단락 소제목 변경 ( "ArrayList나 Vector에는 iterator가 성능이 더 안 좋다."에서 )

2007/08/08

  • 소제목을 h3태그로

  • iterator를 쓸때 고려할 점 설명에 내용 보강

  • 반복될 필요가 없는 코드의 예 설명에서 오타수정, 문장 다듬기

  • 바이트 코드설명 부분의 한 문장 추가, 문장 다듬기

들어가며

저는 요즘 유지보수 프로젝트에서 일하고 있습니다. 많은 시간을 코드를 다듬는데 쓰고 있고 다른 개발자들의 코드를 많이 볼 수 있는 기회가 되고 있습니다. 그러다 보니 List의 크기만큼 반복문을 돌리는 코드에서 많은 개발자들은 루프 블럭 안에서만 쓰는 변수의 선언을 밖에다 하고 있다는 것을 발견하게 되었습니다. 이에 대해서 개발기간에 대화를 해볼 기회가 없었다는 것이 아쉽게 느껴지더군요. 그래서 그 내용과 함께 list와 loop에 관한 몇가지 이야기들을 같이 묶어서 글로 정리해 보게 되었습니다. 첫번째 소제목과 두번째 소제목 아래의 내용은 성능을 약간이라도 더 개선하고 싶을 때 도움이 될 정보들이고, 세번째 소제목에서 위에서 말한 많은 개발자들의 습관이 실제로는 성능에는 아무 영향이 없음을 설명할려고 했습니다.

ArrayList와 Vector는 RandomAccess inteface를 구현하고 있다.

List의 크기만큼 반복문을 도는 방법에는 크게 두 가지 방법이 많이 쓰이고 있습니다.

  1. java.util.Listsize()로 크기를 구해서 그 갯수만큼 반복문을 돌아서 get(int index)로 List안에 있는 객체를 가지고 온다.

  2. iterator()java.util.Iterator 객체를 얻은 후 이 객체의 hasNext()가 true인 동안 반복문을 돌아서 next()로 List안의 객체를 가지고 온다.

그런데 그 객체가 java.util.ArrayListjava.util.Vector가 확실할 때는 1번의 방법이 더 빠르다고 합니다. 그 이유는 API문서를 보시면 확인할 수 있듯이 ArryList와 Vector는 java.util.RandomAccess interface를 구현하고 있기 때문입니다. RandomAccess의 API문서에는 아래와 같이 적혀있습니다.

Marker interface used by List implementations to indicate that they support fast (generally constant time) random access.

List의 구현체가 빠른 임의접근을 지원한다는 것을 나타내는 표시 interface라는 말입니다. 선언되어 있는 메서드도 아무것도 없지요.

API문서에는 이렇게 적혀있지만 성능이 항상 우선순위의 가치는 아니므로 RandomAccess List에는 get(int index)을 써야 한다는 법칙을 만들 수는 없을 것입니다. 가령 List와 다른 Collection 객체, 배열 등을 한꺼번에 묶어서 처리할 때는 iterator pattern이 유용합니다. (참고자료5: Head first design pattern).

하지만 성능이 중요한 코드를 짜는 경우에는 객체를 생성하는 부분에서 ArrayList나 Vector를 생성한다는 정보를 확실히 가지고 있고, 그것이 LinkedList같은 다른 객체로 변경될 가능성이 없다면 java.util.RandomAccess interface의 명세는 무시할 수만은 없을 것입니다.

List 처리 시에 성능을 극대화 시키기 위해서 RandomAccess interface를 구현했는지 검사해서 get(int index)를 쓸지 iterator()를 사용할지 결정하는 코드도 있기는 합니다. (참고자료2의 자바퍼포먼스 튜닝 중). 보통 그렇게까지 성능을 쥐어짤 경우는 흔치 않겠죠.

List의 크기를 반복해서 구할 필요가 없다.

위에서 1번으로 제시된 것처럼 get(int index) 를 쓰는 방법을 쓸 때도 아래와 같은 코드를 많이 보게 됩니다.

for( int i = 0; i < list.size(); i++)\{

//일하기

}

흔하게 보는 코드죠? 그런데 위 코드에도 굳이 필요없는 성능의 손실이 있습니다. 바로 list의 크기를 구하는 size() 메서드가 매번 반복해서 호출된다는 것입니다. 반복문 내에서 list의 크기가 변하는 경우가 아니라면 for문의 초기화 때 한번으로 충분합니다.

for(int i = 0, n = list.size(); i < n; i++)\{

//일하기

}

n을 for문 앞에서 선언하는 방법도 있지만, for block 밖에서 n이 필요한 경우가 아니라면 n이 for의 초기화 부분에 선언되고 할당되는 것이 좋습니다. 변수의 유효범위가 최소화되기 때문이죠.

Loop안에서만 사용하는 변수의 선언을 loop밖에 해야 할까?

코드의 중복을 없애라거나 적절한 API를 쓴다거나 등의 바람직한 코드를 위한 지침에는 항상 다음과 같은 이유들이 붙어다닙니다.

  • 코드의 가독성이 좋아진다.

  • 유지보수가 편해진다.

  • 오류의 발생 가능성이 줄어든다.

지역변수의 유효범위를 최소화하라는 지침에도 위의 이유들이 역시나 인용되고 있지요. (참고자료1 Effective Java, 참고자료 3 Code completed 2nd Edition). 변수의 선언과 초기화, 사용 사이에 있는 코드가 많을 수록 취약성 있는 코드가 들어갈 여지는 커집니다. 이른 시점부터 쓰이지도 않는 객체가 초기화되어서 메모리를 낭비하고 있을 가능성도 있고, 선언되고 한번도 쓰이지 않는 변수들도 눈에 잘 들어오지 않게 됩니다. (뭐 쓰이지 않는 변수는 Eclipse의 warning으로 잡아낼수 있기는 합니다만)

지역변수를 쓰기 바로 전에 초기화하고, 선언과 함께 초기화 하는 좋습니다. 즉 가능한 변수가 처음 사용되는 곳의 가장 근접한 위치에서 선언되고 초기화되어야 한다는 말이죠. 변수의 수명을 가능한 짧게 유지하라는 말로도 표현됩니다. 하지만 많은 개발자들은 메소드의 처음에서 C언어처럼 쭉 변수를 선언하고 시작하고 있습니다.

반복문에서는 while보다는 for를 쓰는 것이 변수의 범위관리에 유리합니다.

보통 iterator와 while이 아래와 같이 많이 쓰이고 있습니다.

Iterator i = c.iterator();

while (i.hasNext())\{

 doSomething(i.next());

}

이 것을 for문으로 쓴다면 다음과 같습니다.

for (Iterator i = c.iterator() ; i.hasNext(); ) {

 doSomething(i.next());

}

작은 차이지만 Iterator i는 for block을 벗어나는 순간 잊어버려도 되는 것이니 block 밖에서 개발자의 머리는 조금이나마 가벼워 질 수 있습니다. 이것은 캡슐화의 원칙인 class의 맴버 중 밖에서 볼 필요 없는 것들은 private으로 선언해야 하는 이유와 일맥상통합니다.

그렇다면 다음의 경우는 어떠할 까요?

1.루프밖에서 list에서 꺼내서 담을 변수 선언

    static int countOfIncluded(List list, String str){
        int count = 0;
        String element;
        for (int i=0,n=list.size();i<n;i++)\{
           _*element = (String) list.get(i);*_
            if (element.indexOf(str)!= -1 ) count++;
        }
        return count;
    }

2.루프안에서 list에서 꺼내서 담을 변수 선언

    static int countOfIncluded(List list, String str){
        int count = 0;

        for (int i=0,n=list.size();i<n;i++){
            String element = (String) list.get(i);
            if (element.indexOf(str)!= -1 ) count++;
        }
        return count;
    }

많은 분들이 1번과 같이 코드를 작성하고 있고, 1번이 성능이 좋다는 '믿음’을 가지고 계실 것입니다. 실제로 한 번 테스트 해볼까요? 크기가 10000개인 리스트를 생성해서 100번씩 반복해서 실행시간을 찍어보는 코드를 만들어보았습니다. 1번 방법은 ListReader1.java, 2번 방법은 ListReader2.java, 시간을 찍어보는 코드는 LoopTester.java 로 첨부하였습니다. 이런 비교 시에는 실행순서에 따라서도 실행시간이 영향을 받으므로 1,2,2,2,1,1,2의 순서로 몇번씩 사이에 걸린 시간을 밀리세컨드로 출력하게했습니다. 실행결과는 다음과 같았습니다.

test1:밖에 선언 2243 test2:안에 선언 2153 test2:안에 선언 2184 test1:밖에 선언 2253 test1:밖에 선언 2213 test2:안에 선언 2123

거의 차이가 없거나 오히려 안에 선언한 쪽이 미묘하게 빠르기도 합니다.

이번에는 javap -c 를 이용해서 컴파일된 byte 코드를 분석해 보겠습니다.

> javap -c ListReader1

Compiled from "ListReader1.java"
public class looptest.ListReader1 extends java.lang.Object{
public looptest.ListReader1();
  Code:
   0:   aload_0
   1:   invokespecial   #1; //Method java/lang/Object."<init>":()V
   4:   return

static int countOfIncluded(java.util.List,java.lang.String);
  Code:
   0:   iconst_0
   1:   istore_2
   2:   iconst_0
   3:   istore  4
   5:   aload_0
   6:   invokeinterface #2,  1; //InterfaceMethod java/util/List.size:()I
   11:  istore  5
   13:  iload   4
   15:  iload   5
   17:  if_icmpge       50
   20:  aload_0
   21:  iload   4
   23:  invokeinterface #3,  2; //InterfaceMethod java/util/List.get:(I)Ljava/la
ng/Object;
   28:  checkcast       #4; //class String
   31:  astore_3
   32:  aload_3
   33:  aload_1
   34:  invokevirtual   #5; //Method java/lang/String.indexOf:(Ljava/lang/String
;)I
   37:  iconst_m1
   38:  if_icmpeq       44
   41:  iinc    2, 1
   44:  iinc    4, 1
   47:  goto    13
   50:  iload_2
   51:  ireturn

}
javap -c ListReader2
Compiled from "ListReader2.java"
public class looptest.ListReader2 extends java.lang.Object{
public looptest.ListReader2();
  Code:
   0:   aload_0
   1:   invokespecial   #1; //Method java/lang/Object."<init>":()V
   4:   return

static int countOfIncluded(java.util.List,java.lang.String);
  Code:
   0:   iconst_0
   1:   istore_2
   2:   iconst_0
   3:   istore_3
   4:   aload_0
   5:   invokeinterface #2,  1; //InterfaceMethod java/util/List.size:()I
   10:  istore  4
   12:  iload_3
   13:  iload   4
   15:  if_icmpge       49
   18:  aload_0
   19:  iload_3
   20:  invokeinterface #3,  2; //InterfaceMethod java/util/List.get:(I)Ljava/la
ng/Object;
   25:  checkcast       #4; //class String
   28:  astore  5
   30:  aload   5
   32:  aload_1
   33:  invokevirtual   #5; //Method java/lang/String.indexOf:(Ljava/lang/String
;)I
   36:  iconst_m1
   37:  if_icmpeq       43
   40:  iinc    2, 1
   43:  iinc    3, 1
   46:  goto    12
   49:  iload_2
   50:  ireturn

}

variable scope byte code diff

노란 줄이 많은 것은 라인수가 1라인 차이가 나고, local variable이 저장되는 공간의 index번호가 달라서입니다. 내용을 보시면 거의 똑같이 실행되고 있는 것을 알 수 있으실 것입니다. 루프가 도는 goto문장을 봐도 (왼쪽의 47라인과 오른쪽의 46라인) 같은 곳으로 (13라인과 12라인)으로 이동을 하기 때문에 특별히 오른쪽 예제2의 경우가 더 일을 하는 것은 없습니다. 다만 3번째 라인 istore n 이였던 것이 istore_n 으로 바뀌는 등 언더바(_)가 들어간 부분이 있습니다.

각각의 명령어의 의미는 다음 링크를 확인해 보시면 나와있습니다.

내용을 보면 istore과 istore_n은 전자가 암시적이라는 것만 빼고는 같다고 나옵니다. (Each of the istore_<n> instructions is the same as istore with an index of <n>, except that the operand <n> is implicit. ) iload의 경우도 마찬가고요. 어떤 차이가 있을까 해서 검색해 봤더니 아래와 내용을 발견했습니다.

'istore_<n>' is functionally equivalent to 'istore <n>', although it is typically more efficient and also takes fewer bytes in the bytecode

istore_n 쪽이 오히려 효율적이라는 말이 나와 있습니다. 만약 iload_n도 마찬가지라면 goto가 찾아가는 라인에서는 iload_n이 있는 예제2가 더 효율적인 코드일 수도 있다는 것입니다. 어쨓든 이런 작은 차이를 접어둔다면 코드가 하는 일의 절차는 차이가 없습니다.

결국 "루프 안에서 반복되는 변수 선언을 밖으로 빼는 것은 성능상에 아무런 이점이 없고 소스에서 변수의 유효범위만 늘어나게 한다. " 는 것입니다.

여기서 이런 말을 하실 분이 계실 것 같습니다.

"원래는 변수를 loop안에 생성하는 루프가 돌 때마다 String 객체의 참조를 저장하기 위한 공간이 따로 할당되는 것인데 위의 경우는 JVM에서 최적화를 해 준 것 아니냐? JVM에 따라서 이런 최적화가 안 되는 버전도 있을 수가 있는데 개발자는 어떤 JVM에서도 한번만 선언이 되도록 루프 밖에 변수를 선언해야 하지 않겠냐? "

그러나 Java™ Virtual Machine Specification, The, 2nd Edition 이라는 책을 보면 다음과 같은 내용이 있습니다.

The sizes of the local variable array and the operand stack are determined at compile time and are supplied along with the code for the method associated with the frame .

위의 문장은 메소드가 호출될 때 생성되는 저장공간인 frame에 대한 설명에서 인용한 것입니다. (원문링크 : http://java.sun.com/docs/books/vmspec/2nd-edition/html/Overview.doc.html#15722)

풀이하면 메소드 안의 local variable들의 값들은 고정된 크기의 배열에 저장되고 그 배열의 크기는 compile시에 결정된다는 내용이 있습니다. 즉 한 메소드 안에서 사용할 local variable이 저장될 공간의 갯수는 이미 compile 시에 정해져 있는 것이지 동적으로 변하는 것이 아니라는 말입니다. 만약 위의 소스에서 list의 갯수에 따라서 local variable의 값이 저장되는 공간(객체가 저장되는 공간을 가리키는 말이 아닙니다.)이 달라진다면 위의 설명과 모순되는 일입니다.

비슷한 설명과 논쟁이 인터넷의 여러 곳에서 이미 벌어졌으니 아래의 링크를 읽어보셔도 재밌을 것입니다.

이런 오해들은 전에 Java의 호출은 pass by value 에서 말했던 객체선언에 대한 오해와 어느 정도 관련이 있다고 봅니다. 객체를 생성이나 사용을 안 하더라도 선언 자체만으로 저장을 위한 큰 공간이 할당된다고 믿는 경우가 많은 것 같습니다.

전에 이런 코드를 본적이 있습니다. 클래스의 멤버 변수로 임시 객체를 생성해 놓고 그것을 여러개의 메소드에서 같이 쓰는 코드였었습니다.

Class MyClass {
    String  temp;

    void work(List list){
        for (int i=0; i = list.size(); i++)\{
            temp = (String) list.get(i);
            //기타...
        }
    }

    void play(List list)\{
        for (int i=0; i = list.size(); i++)\{
            temp = (String) list.get(i);
            //기타...
        }
    }

}

이런 코드를 짰던 사람도 경력도 어느 정도 되고, 실력도 있어보이는 사람이였죠. 아마 이 개발자는 이런 구조가 temp 객체가 저장될 공간을 상당히 아꼈다는 뿌듯함을 가지고 있었을 지도 모릅니다. 그러나 객체는 Heap 메모리에 있는 것이기에 지역변수가 선언이 하나 덜 되었다고 해서 객체가 줄어든 것은 아닙니다. 그리고 여기서는 list에서 받아오지 새로 생성되는 객체는 없습니다. 더군다나 설계상 의미로도 temp는 Class의 멤버로써 아무런 의미가 없습니다. 성능측면에서 봐도 클래스 멤버 변수는 지역변수에 비해서 더 비용이 큽니다. (참고자료 2: 자바 퍼포먼스 튜닝)

한편으로는 변수의 범위를 줄인다고 해서 반복적으로 Loop 안에서 실행해줄 필요가 없는 작업까지 loop안으로 끌고 들어가서는 안되겠죠. 다음과 같은 코드를 본적이 있습니다.

for (int i=0, n=userList.size(); i<n;i++)\{

    UserBiz biz = (UserBiz) lookup(UserBiz.ROLE);#*_ //비지니스 컴포넌트 객체를 얻어온다.
    User user = (User)userList.get(i);
    biz.add(user;
 }

위의 코드에서 비지니스 컴포넌트를 얻어오는 부분은 반복적으로 수행될 필요가 없으므로 loop 밖으로 나가는 것이 맞습니다. 위의 biz 객체를 loop안에서만 쓴다고 하여도 위와 같이 해당 코드가 안으로 들어가 있다면 한번만 얻어오는 되는 객체를 얻어오는 작업이 계속 반복될 것 입니다. 사실 위의 경우는 비지니스 객체에서 List를 받아서 처리하는 메소드를 하나 더 만드는 것이 나을 것 같네요. 아키텍쳐에 따라 다르지만 비지니스 객체의 호출은 비싼 작업일 경우가 많고 그런 호출의 횟수는 최대한 줄일수록 좋겠죠.

개선된 for문

Java 5의 개선된 for문을 아시는 분이라면 아래의 방법을 많이 쓰실 것입니다.

ArrayList<Integer> list = new ArrayList<Integer>();

for (Integer i : list) \{ …​ }

보기에도 이쁘고, 변수의 유효범위가 loop을 벗어나지도 않는 좋은 문법이네요. ^^ 프로젝트에서 Java 5 이상을 쓰시고 계신다면 변수를 loop안에 넣느냐로 논쟁하기 전에 개선된 for문을 먼저 적용해 보는것이 나을 것 같습니다.

마치며

위의 내용을 묶어서 결론을 내리자면, "loop안에서만 쓰는 변수 선언을 밖으로 빼는 것은 성능에 아무런 영향이 없으며, 굳이 성능을 개선하고 싶다면 RandomAccess 인터페이스의 고려, 반복되는 list크기 계산의 제거 등을 먼저 신경쓰라. 그리고 Java5을 쓰고 있다면 개선된 for문을 쓰라."는 것으로 정리하고 싶습니다.

사실 성능에 대한 고려는 병목이 발견될 때 필요하다면 하는 것이 바람직한 순서겠죠. 개발자는 알아보기 싶고, 관리하기 쉬운 코드를 짜는 것에 먼저 집중을 해야 할 것입니다. 그리고 고용량의 CPU가 돌아가는 환경이라면 개발자가 성능을 고려해서 짠 코드와 그렇지 않은 코드의 차이도 대부분 사람이 인지하지 못하는 정도의 미미한 것일 가능성이 높습니다. (TA팀 이상민 선임님의 테스트와 조언). 그런 점에서 본다면 이 글에서 가장 강조되어야 할 내용은 '변수의 유효범위의 최소화’라고 생각됩니다.

다른 분들이 추가로 자료를 찾는 번거로움을 덜어 드릴려고 관련 자료를 되도록 인용하고 링크를 걸었습니다. 그렇게는 했어도 혹시나 제가 잘못 이해해서 오류가 있는 부분이 있는지도 모르겠습니다. 고수분들은 그런 부분을 발견하신다면 즉각 지적해 주시기 바랍니다~ ^^

그리고 이 글은 계속적으로 갱신해서 개선을 하고 있는 중입니다. 제가 가입해 있는 커뮤니티 사이트 중 두 곳에도 이 글을 올렸는데, 거기에서 조언을 주신 많은 분들께도 감사드립니다.

참고자료

Java API문서와 링크외의 책에서 참고한 내용입니다.

  • Effective Java , Joshua Bloch저 이해일역

    • 7장 프로그래밍 일반 - 항목29 - 지역변수의 유효범위를 최소화하라.

  • 자바퍼포먼스 튜닝, Jack Shirazi 저 서민구 역

  • 11장 적절한 자료구조와 알고리즘

    • 질의 최적화 - 불필요한 반복적 메소드 호출 제거

    • RandomAccess 인터페이스

  • 6장 예외 단언, 캐스팅, 변수 - 변수

  • Code completed 2nd Edtion, Streve McConnell 저 서우석 역

    • 10장 변수사용시 일반적인 문제 - 10.3 변수의 초기화에 대한 지침

  • Head First Design Pattern

    • 9장 이터레이터와 컴포지트 패턴