5. 지금까지 사용하던 for 루프를 더 빠르게 사용할 수 있다고?

de_sj_awa·2021년 8월 29일
0

5. 지금까지 사용하던 for 루프를 더 빠르게 사용할 수 있다고?

1. 조건문에서의 속도는?

조건문은 성능에 얼마나 많은 영향을 줄까? 먼저 조건문에 어떤 것들이 있는지 살펴보고, 조건문이 얼마나 성능에 영향을 주는지 알아보자. 조건문의 종류는 다음과 같다.

  • if-else if-else
  • switch

if문 안에는 boolean 형태의 결과 값만 사용할 수 있다. switch문은 JDK 6까지는 byte, short, char, int 이렇게 네 가지 타입을 사용한 조건 분기만 가능했지만, JDK 7부터는 String도 사용 가능하다. 일반적으로 if문에서 분기를 많이 하면 시간이 많이 소요된다고 생각한다. if문 조건 안에 들어가는 비교 구문에서 속도를 잡아먹지 않는 한, if 문장 자체에서는 그리 많은 시간이 소요되지 않는다.

package com.perf.condition;

import java.util.Random;
import java.util.concurrent.TimeUnit;

import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.GenerateMicroBenchmark;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.State;

@State(Scope.Thread)
@BenchmarkMode({ Mode.AverageTime })
@OutputTimeUnit(TimeUnit.MICROSECONDS)
public class ConditionIf {
    int LOOP_COUNT=1000;
	
    @GenerateMicroBenchmark
    public void randomOnly() {
        Random random=new Random();
        int data=1000+random.nextInt();
        for(int loop=0;loop<LOOP_COUNT;loop++) {
            resultProcess("dummy");
        }
    }
    @GenerateMicroBenchmark
    public void if10() {
        Random random=new Random();
        String result=null;
        int data=1000+random.nextInt();
        for(int loop=0;loop<LOOP_COUNT;loop++) {
            if(data<50) { result="50";
            } else if(data<150) { result="150";
            } else if(data<250) { result="250";
            } else if(data<350) { result="350";
            } else if(data<450) { result="450";
            } else if(data<550) { result="550";
            } else if(data<650) { result="650";
            } else if(data<750) { result="750";
            } else if(data<850) { result="850";
            } else if(data<950) { result="950";
            } else { result="over";
            }
            resultProcess(result);
        }
    }
    @GenerateMicroBenchmark
    public void if100() {
        Random random=new Random();
        String result=null;
        int data=10000+random.nextInt();
        for(int loop=0;loop<LOOP_COUNT;loop++) {
            if(data<50) { result="50";
            } else if(data<150) { result="150";
            } else if(data<250) { result="250";
            } else if(data<350) { result="350";
            } else if(data<450) { result="450";
            } else if(data<550) { result="550";
            } else if(data<650) { result="650";
            } else if(data<750) { result="750";
            } else if(data<850) { result="850";
            } else if(data<950) { result="950";
            } else if(data<1050) { result="1050";
            } else if(data<1150) { result="1150";
            } else if(data<1250) { result="1250";
            } else if(data<1350) { result="1350";
            } else if(data<1450) { result="1450";
            } else if(data<1550) { result="1550";
            } else if(data<1650) { result="1650";
            } else if(data<1750) { result="1750";
            } else if(data<1850) { result="1850";
            } else if(data<1950) { result="1950";
            } else if(data<2050) { result="2050";
            } else if(data<2150) { result="2150";
            } else if(data<2250) { result="2250";
            } else if(data<2350) { result="2350";
            } else if(data<2450) { result="2450";
            } else if(data<2550) { result="2550";
            } else if(data<2650) { result="2650";
            } else if(data<2750) { result="2750";
            } else if(data<2850) { result="2850";
            } else if(data<2950) { result="2950";
            } else if(data<3050) { result="3050";
            } else if(data<3150) { result="3150";
            } else if(data<3250) { result="3250";
            } else if(data<3350) { result="3350";
            } else if(data<3450) { result="3450";
            } else if(data<3550) { result="3550";
            } else if(data<3650) { result="3650";
            } else if(data<3750) { result="3750";
            } else if(data<3850) { result="3850";
            } else if(data<3950) { result="3950";
            } else if(data<4050) { result="4050";
            } else if(data<4150) { result="4150";
            } else if(data<4250) { result="4250";
            } else if(data<4350) { result="4350";
            } else if(data<4450) { result="4450";
            } else if(data<4550) { result="4550";
            } else if(data<4650) { result="4650";
            } else if(data<4750) { result="4750";
            } else if(data<4850) { result="4850";
            } else if(data<4950) { result="4950";
            } else if(data<5050) { result="5050";
            } else if(data<5150) { result="5150";
            } else if(data<5250) { result="5250";
            } else if(data<5350) { result="5350";
            } else if(data<5450) { result="5450";
            } else if(data<5550) { result="5550";
            } else if(data<5650) { result="5650";
            } else if(data<5750) { result="5750";
            } else if(data<5850) { result="5850";
            } else if(data<5950) { result="5950";
            } else if(data<6050) { result="6050";
            } else if(data<6150) { result="6150";
            } else if(data<6250) { result="6250";
            } else if(data<6350) { result="6350";
            } else if(data<6450) { result="6450";
            } else if(data<6550) { result="6550";
            } else if(data<6650) { result="6650";
            } else if(data<6750) { result="6750";
            } else if(data<6850) { result="6850";
            } else if(data<6950) { result="6950";
            } else if(data<7050) { result="7050";
            } else if(data<7150) { result="7150";
            } else if(data<7250) { result="7250";
            } else if(data<7350) { result="7350";
            } else if(data<7450) { result="7450";
            } else if(data<7550) { result="7550";
            } else if(data<7650) { result="7650";
            } else if(data<7750) { result="7750";
            } else if(data<7850) { result="7850";
            } else if(data<7950) { result="7950";
            } else if(data<8050) { result="8050";
            } else if(data<8150) { result="8150";
            } else if(data<8250) { result="8250";
            } else if(data<8350) { result="8350";
            } else if(data<8450) { result="8450";
            } else if(data<8550) { result="8550";
            } else if(data<8650) { result="8650";
            } else if(data<8750) { result="8750";
            } else if(data<8850) { result="8850";
            } else if(data<8950) { result="8950";
            } else if(data<9050) { result="9050";
            } else if(data<9150) { result="9150";
            } else if(data<9250) { result="9250";
            } else if(data<9350) { result="9350";
            } else if(data<9450) { result="9450";
            } else if(data<9550) { result="9550";
            } else if(data<9650) { result="9650";
            } else if(data<9750) { result="9750";
            } else if(data<9850) { result="9850";
            } else if(data<9950) { result="9950";
            } else { result="over";
            }
            resultProcess(result);
        }
		
    }
    String current;
	
    public void resultProcess(String result) {
        current=result;
    }
	
}

여기서 randomOnly() 메서드는 랜덤한 숫자를 생성하고, resultProcess() 메서드를 호출하는 작업을 수행했다. randomOnly() 메서드를 만든 이유는, if가 있는 경우와 없는 경우를 비교하기 위한 기준이 필요하기 때문이다.

if문의 성능을 JMH로 측정한 결과를 보자.

대상 응답 시간(마이크로초)
randomOnly 0.46
if 10개 5
if 100개 63

결과를 보면 if문 10개를 거치는 경우, 없을 때보다 10배의 시간이 소요된다. 그리고 100개일 경우에는 140배 이상의 시간이 더 소요된다. 여기서 소요되는 시간은 어떻게 보면 매우 미미한 숫자일 수도 있고, 어떻게 보면 큰 숫자일 수 있다.

그런데, 여기서 유의해야 할 점이 있다. 이 예제 코드는 if문이 10개라 할지라도 LOOP_COUNT라는 반복 횟수는 1,000이므로, 총 10,000번의 if문을 거친 결과가 if10()의 값이라는 점이다. 그러므로, if가 하나만 있을 경우에는 기존에 있는 코드 대비 약 "응답 시간/10,000"만큼 더 소요가 된다고 볼 수 있으므로 아주 큰 성능 저하가 발생한다고 보기는 어렵다.

switch 문장도 마찬가지로 빠른 응답 결과가 나온다. Oracle 사이트에 있는 문서를 보면 switch는 숫자 비교 시 if보다 가독성이 좋아지므로 정해져 있는 숫자로 분기를 할 때는 switch를 권장한다.

또한 JDK 7에서는 String 문자열을 switch 문에 사용할 수 있다. 다음과 같이 영어로 된 달을 숫자로 변경하는 메서드가 있다.

package com.perf.condition;

public class SwitchCaseString {

    public static void main(String[] args) {
        SwitchCaseString scs=new SwitchCaseString();
        scs.getMonthNumber("February");
    }
    public int getMonthNumber(String str) {
        int month=-1;
        switch(str) {
        case "January": month=1;
            break;
        case "February": month=2;
            break;
        case "March" : month=3;
            break;
        case "April": month=4;
            break;
        case "May": month=5;
            break;
        case "June": month=6;
            break;
        case "July": month=7;
            break;
        case "August": month=8;
            break;
        case "September": month=9;
            break;
        case "October": month=10;
            break;
        case "November": month=11;
            break;
        case "December": month=12;
            break;
        }
//	System.out.println("January".hashCode());
//	System.out.println("February".hashCode());
//	System.out.println("March".hashCode());
//	System.out.println("April".hashCode());

        return month;
    }
	
}

JDK 6까지만 해도 switch-case문에서는 주로 정수와 enum을 처리할 수 있었는데, 어떻게 JDK 7에서는 String을 비교할까? 그 답은 int 정수를 리턴하는 Object 클래스에 선언되어 있는 hashCode()라는 메서드에 있다. String에서 Overriding한 hashCode() 메서드는 문자열을 int 값으로 구분하여 switch-case 문에서 사용하는 것이다. 즉, 컴파일하면서 case문에 있는 각 값들을 hashCode로 변환하고, 그 값이 작은 것부터 정렬한 다음에 String의 equals() 메서드를 사용하여 실제 값과 동일한지 비교한다. 그러므로 String이 사용가능한 것이다.

그런데, 여기서 한가지 꼭 기억해두어야 하는 것이 있다. 바로 숫자들이 정렬되어 있다는 점이다. switch-case문은 작은 숫자부터 큰 숫자를 비교하는 게 가장 빠르다. 대상이 되는 case의 수가 적으면 상관 없지만, 많으면 많을수록 switch-case에서 소요되는 시간이 오래 걸린다. 따라서, 간단한 switch-case라도 성능을 고려해 사용해야 한다.

if문에서 조건에 만족할 때, 중괄호 안에서 아무런 작업을 하지 않으면, 자바의 JIT(Just In Time) 컴파일러는 최적화를 통해 해당 코드를 무시해 버릴 수도 있다.

2. 반복 구문에서의 속도는?

자바에서 사용하는 반복 구문은 세가지이다.

  • for
  • do-while
  • while

일반적으로 for문을 많이 사용한다. 가끔 while문도 사용하는데, while문은 잘못하면 무한 루프에 빠질 수 있으므로 되도록이면 for문을 사용하기를 권장한다.

public void test(ArrayList<String> list) {
    boolean flag = true;
    int idx = 0;
    do {
        if(list.get(idx).equals("A")) flag=false;
    } while(flag);
}

만약 ArrayList의 첫번째 값이 "A"면 정상적으로 수행이 되겠지만, 그렇지 않으면 해당 애플리케이션은 서버를 재시작하거나 스레드를 강제 종료시킬 때까지 계속 반복문을 수행할 것이다. 그렇게 되면 당연히 서버에 부하도 많이 발생한다.

그럼 for 구문에 대해서 자세히 알아보자. JDK 5.0 이전에는 for 구문을 다음과 같이 사용하였다. 여기서 list는 값이 들어있는 ArrayList이다.

for(int loop=0; loop<list.size(); loop++)

이렇게 코딩하는 습관은 좋지 않다. 매번 반복하면서 list.size()라는 메서드를 호출하기 때문이다. 이럴 때는 다음과 같이 수정하여야 한다.

int listSize = list.size();
for(int loop=0; loop<listSize; loop++)

이렇게 하면 필요 없는 size() 메서드 반복 호출이 없어지므로 더 빠르게 처리된다.

5.0부터는 다음과 같이 For-Each라고 불리는 for 루프를 사용할 수 있다.

ArrayList<String> list = new ArrayList<String>();
'''
for(String str : list)

For-Each를 사용하면 별도로 형변환하거나 get() 메서드 또는 elementAt() 메서드를 호출할 필요 없이 순서에 따라서 String 객체를 for 문장 안에서 사용할 수 있으므로 매우 편리하다. 다만, 이 방식은 데이터의 첫 번재 값부터 마지막까지 처리해야할 경우에만 유용하다. 만약 순서를 거꾸로 돌리거나 특정 값부터 데이터를 탐색하는 경우에는 적절하지 않다.

그럼 지금까지 나온 방식들의 성능을 비교해보자.

package com.perf.condition;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;

import org.openjdk.jmh.annotations.BenchmarkMode;
import org.openjdk.jmh.annotations.GenerateMicroBenchmark;
import org.openjdk.jmh.annotations.Mode;
import org.openjdk.jmh.annotations.OutputTimeUnit;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;

@State(Scope.Thread)
@BenchmarkMode({ Mode.AverageTime })
@OutputTimeUnit(TimeUnit.MICROSECONDS)
public class ForLoop {
	
    int LOOP_COUNT=100000;
    List<Integer> list;
	
    @Setup
    public void setUp() {
        list=new ArrayList<Integer>(LOOP_COUNT);
        for(int loop=0;loop<LOOP_COUNT;loop++) {
            list.add(loop);
        }
    }
    @GenerateMicroBenchmark
    public void traditionalForLoop() {
        int listSize=list.size();
        for(int loop=0;loop<listSize;loop++) {
            resultProcess(list.get(loop));
        }
    }
    @GenerateMicroBenchmark
    public void traditionalSizeForLoop() {
        for(int loop=0;loop<list.size();loop++) {
            resultProcess(list.get(loop));
        }
    }
    @GenerateMicroBenchmark
    public void timeForEachLoop() {
        for(Integer loop:list) {
            resultProcess(loop);
        }
    }
    int current;
    public void resultProcess(int result) {
        current=result;
    }
	
}

결과는 어떻게 나올까? 이론적으로는 반복할 때마다 list.size() 메서드를 호출하는 부분이 가장 느려야 하고, JDK 5.0에서 추가된 for문은 속도가 빨라야 한다.
JMH로 측정한 결과는 다음과 같다.

대상 응답 시간(마이크로초)
for 410
for 크기 반복 비교 413
for-each 481

지금까지 알아본 결과, 가장 빠르고 그나마 편리한 방법은 배열이나 ArrayList의 크기를 먼저 읽어온 후 반복 구문을 돌리는 것이다. 물론 예제에서 10만 번을 반복했기 때문에 차이가 났을 뿐, 실제 운영중인 웹 시스템에선 이렇게 많이 반복하지 않기 때문에 별 차이가 없을 것이다. 하지만 검증 부분에 크기를 계속 비교하는 구문은 피해서 개발해야 한다.

3. 반복 구문에서의 필요 없는 반복

가장 많은 실수 중 하나는 반복 구문에서 계속 필요 없는 메서드 호출을 하는 것이다.

public void sample(DataVO data, String key) {
    TreeSet treeSet2 = null;
    treeSet2 = (TreeSet)data.get(key);
    if(treeSet != null) {
        for(int i = 0; i < treeSet2.size(); i++) {
            DataVO data2 = (DataVO)treeSet2.toArray()[i];
            ...
        }
    }
}

TreeSet 형태의 데이터를 갖고 있는 DataVO에서 TreeSet을 하나 호출하여 처리하는 부분이다. 이 소스의 문제는 toArray() 메서드를 반복해서 수행한다는 것이다.

참고로 sample 메서드는 애플리케이션이 한 번 호출되면 40번씩 수행된다. 또한 treeSet2 객체에 256개의 데이터들이 들어가 있으므로, 결과적으로 toArray() 메서드는 한 번 호출될 때마다 10,600번씩 반복 호출된다. 그러므로, 이 코드는 toArray() 메서드가 반복되지 않도록 for문 앞으로 옮기는 것이 좋다. 게다가 이 소스 코드의 for문을 보면 treeSet2.size() 메서드를 지속적으로 호출하도록 되어 있다. 수정한 결과는 다음과 같다.

public void sample(DataVO data, String key) {
    TreeSet treeSet2 = null;
    treeSet2 = (TreeSet)data.get(key);
    if(treeSet2 != null) {
        DataVO2[] dataVO2 = (DataVO2)treeSet2.toArray();
        int treeSet2Size = treeSet2.size();
        for(int i = 0; i < treeSet2Size; i++) {
            DataVO data2 = dataVO2[i];
        }
    }
}

참고

  • 자바 성능 튜닝 이야기
profile
이것저것 관심많은 개발자.

0개의 댓글