💡 본 게시글은 이상민 저자의 '자바 성능 튜닝 이야기' 교재를 공부하고, 이에 대해 정리한 내용입니다.
들어가며
최근 코딩테스트를 준비하면서, 코드가 빠르게 동작하게 하는 것이 얼마나 중요한지를 알게 되었습니다. 코딩테스트에선 문제를 맞추는 것뿐만 아니라, 코드가 빠르고 효율적으로 동작해야 하기 때문입니다.
이때, '조건문'이 코드 속도에 많은 영향을 미친다는 걸 깨달았습니다. 예를 들어, 데이터를 분류하거나 선택하는 문제에서 조건문을 많이 써야 했는데 이 조건문의 성능이 좋지 않으면, 전체 코드의 속도가 느려지기 때문이였습니다.
또한, 한번에 여러 가지 일을 처리하는 문제에서도 조건문의 중요성을 느꼈습니다. 이런 문제에서는 조건문이 빠르게 동작해야 전체적인 일처리 속도가 빨라졌습니다.
이러한 경험을 바탕으로 조건문이 어떻게 동작하는지 더 잘 이해하고, 조건문을 더 효율적으로 쓸 수 있는 방법을 찾아볼 생각입니다.
1) 조건문의 속도는?
(1) 조건문의 종류와 특성
먼저, 조건문이 성능에 얼마나 큰 영향을 끼치는지 알아보기 위해, 우선 조건문의 종류와 특성에 대해 알아보았습니다.
조건문에는 크게 'if-else if-else'와 'switch' 두 종류가 있습니다.
- if-else if-else
- switch
'if'문은 boolean 형태의 결과 값만을 사용할 수 있습니다. 반면에 'switch'문은 JDK 6까지는 byte, short, char, int 네 가지 타입을 사용한 조건 분기가 가능했으나, JDK 7부터는 String도 사용이 가능해졌습니다.
(2) if 문
성능과 조건문의 관계를 볼 때, 일반적으로 if문에서 분기를 많이 하면 시간이 많이 소요된다고 생각할 수 있습니다. 그러나 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()' 메서드를 호출하는 역할을 합니다. 이 메서드를 설정한 이유는, if문이 존재하는 경우와 그렇지 않은 경우를 비교하기 위한 기준을 마련하기 위함입니다.
이제 JMH를 이용해 if문의 성능을 측정한 결과를 살펴봅시다.
| 대상 | 응답 시간 (마이크로초) |
| randomOnly | 0.46 |
| if 10개 | 5 |
| if 100개 | 63 |
결과를 보면, if문이 10개인 경우에는 없는 경우보다 시간이 10배 더 소요되며, if문이 100개인 경우에는 140배 이상의 시간이 더 소요됩니다. 이 시간 차는 상황에 따라 크거나 작아질 수 있습니다.
하지만 주의할 점이 있습니다. 이 예제에서 if문이 10개였지만, 'LOOP_COUNT'의 반복 횟수는 1,000이므로, 총 10,000번의 if문을 거치는 것이 'if10()'의 결과입니다.
따라서, if문이 하나만 있을 경우, 기존 코드 대비 '응답 시간/10,000'만큼의 추가 시간이 소요될 것입니다. 이는 큰 성능 저하를 유발하는 수준은 아닙니다.
(3) switch 문
'switch' 문장도 마찬가지로 빠른 응답 결과를 제공하는 것이 특징입니다.
Oracle 사이트의 문서에 따르면, 숫자 비교에서 'if'보다 'switch'가 가독성이 뛰어나므로, 정해진 숫자로 분기를 하는 경우 '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' 비교가 가능해진 이유는 'Object' 클래스에 선언된 'hashCode()' 메서드 때문입니다. 'String'에서 오버라이딩한 'hashCode()' 메서드는 문자열을 'int' 값으로 구분하여 'switch-case' 문에서 활용합니다.
즉, 컴파일 과정에서 'case'문의 각 값들을 'hashCode'로 변환하고, 그 값들을 오름차순으로 정렬한 후 'String'의 'equals()' 메서드를 통해 실제 값과 동일한지 비교합니다. 이 때문에 'String' 사용이 가능해진 것입니다.
그러나, 이러한 점을 기억해두어야 합니다. 'switch-case'문은 작은 숫자부터 큰 숫자를 비교하는 것 가장 빠릅니다.
'case'의 수가 적으면 큰 차이가 없만, 'case'의 수가 많아질수록 'switch-case'에서 소요되는 시간이 길어집니다. 따라서, 'switch-case'를 사용할 때는 성능을 고려해야 합니다.
if문에서 조건에 만족할 때, 중괄호 안에서 아무런 작업을 하지 않으면,
자바의 JIT(Just In Time) 컴파일러는 최적화를 통해 해당 코드를 무시해 버릴 수도 있습니다.
2) 반복 구문에서의 속도는?
(1) 반복 구문의 종류
자바에서 사용하는 반복 구문은 세가지입니다.
- for
- do-while
- while
일반적으로 for문을 가장 많이 사용합니다.
(2) while 문
또한 가끔은 while문도 사용하는데, 이 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"일 경우에는 정상적으로 수행되겠지만, 그렇지 않을 경우에는 애플리케이션이 서버를 재시작하거나 스레드를 강제로 종료시키기 전까지 계속해서 반복문을 실행하게 됩니다. 이런 상황은 서버에 부하를 줄 수 있습니다.
(2) for 문
그렇다면 이제 for문에 대해 자세히 살펴보겠습니다. JDK 5.0 이전에는 for문을 아래와 같이 사용하였습니다.
for(int loop=0; loop<list.size(); loop++)
하지만 이와 같은 코딩 습관은 바람직하지 않습니다. 이는 반복문을 실행할 때마다 list.size() 메서드를 호출하기 때문입니다.
이를 보완하기 위해 아래와 같이 코드를 수정할 수 있습니다.
int listSize = list.size();
for(int loop=0; loop<listSize; loop++)
이렇게 하면 list.size() 메서드의 불필요한 반복 호출을 줄일 수 있습니다. JDK 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문은 속도가 빨라야 합니다.
그러나 실제 측정 결과는 다음과 같습니다.
| 대상 | 응답 시간 (마이크로초) |
| 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];
...
}
}
}
위의 코드는 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];
// ...
}
}
}
이렇게 수정하면, 불필요한 toArray() 메서드와 size() 메서드의 반복 호출을 줄일 수 있습니다. 이는 코드의 성능을 향상시키는 데 도움이 됩니다.
4) 정리하며
반복 구문은 어떤 애플리케이션 개발에도 필수적인 요소입니다. 그러나 조금만 실수하면 무한 루프가 발생해 애플리케이션을 재시작하거나 특정 스레드를 중단시켜야 하는 상황이 발생할 수 있습니다. 따라서 성능상의 문제를 발견하면, 그 문제가 발생하는 부분을 더 쉽게 해결할 수 있습니다.
본문에 있는 예제들은 대부분 1,000번에서 10,000번 정도 반복하여 비교하였습니다. 이는 실제 웹 환경과는 다르기 때문에, 이 차이가 크지 않다고 생각하는 사람들도 많을 수 있습니다.
그러나 예를 들어, 상품의 재고를 실시간으로 추적하고 관리하는 시스템이나, 빅데이터 분석을 위해 수천 개의 데이터를 처리해야 하는 시스템을 개발한다고 가정해봅시다. 이러한 시스템에서는 1,000번, 10,000번이 넘는 반복 로직이 필수적인 것일 것입니다.
성능 튜닝의 기본 원칙은 응답 시간이 가장 많이 소요되는 부분부터 개선하는 것입니다. 그러나 이는 작은 부분을 차지하는 반복 구문이 큰 성능 저하를 가져올 수 있다는 사실을 간과해서는 안됩니다. 따라서 이런 부분도 주의 깊게 고려해야 합니다.
'Java > 자바 성능 개선' 카테고리의 다른 글
| 07. 클래스 정보는 어떻게 알아낼 수 있는가? (0) | 2024.01.04 |
|---|---|
| 06. static 재대로 사용하기 (0) | 2024.01.04 |
| 04. 어떤 객체에 데이터를 담아야 하는지... (0) | 2023.12.31 |
| 03. 왜 자꾸 String을 쓰지 말라는 걸까? (1) | 2023.12.19 |
| 02. 내가 만든 프로그램의 속도를 알고 싶다. (0) | 2023.12.19 |