본문 바로가기

Java/자바 성능 개선

02. 내가 만든 프로그램의 속도를 알고 싶다.

💡 본 게시글은 이상민 저자의 '자바 성능 튜닝 이야기' 교재를 공부하고, 이에 대해 정리한 내용입니다.

들어가며

 저는 개발 작업을 진행하면서, 제가 설계한 백엔드 시스템의 성능에 대한 의문이 종종 들곤 했습니다. 사용자의 요청이 급증하는 상황에서 서버 응답 시간이 어떻게 변화하며, 특정 데이터베이스 쿼리가 시스템에 미치는 영향은 어떤지 등이 궁금했습니다. 특히 새로운 기능 추가나 기존 코드 최적화 시에 이런 의문은 더욱 커지곤 했습니다.

 

성능 분석은 단지 시스템 응답 속도를 측정하는 것을 넘어, 시스템 전체에서 어떤 부분이 전체 성능에 가장 크게 영향을 주는지 파악하는 것이 더 중요합니다. 그래서 이 교재에서는 시스템 성능 저하 시 가장 중요한 단계 '병목 지점 찾기'로 강조하고 있습니다.

 

 병목 지점이란 시스템 내에서 데이터의 흐름이나 처리 속도가 느려지는 부분을 말합니다. 이를 발견하고 해결하는 것은 시스템 전반의 성능 향상으로 이어집니다. 예컨대, 데이터베이스 쿼리를 최적화하거나 캐싱 전략을 개선하거나, 코드를 효율적으로 리팩토링하는 등의 방법으로 이를 해결할 수 있습니다.

 

또한 자바 기반 시스템의 경우, 응답 속도와 데이터 처리 성능을 측정할 수 있는 다양한 프로그램이 있습니다. 성능 문제를 분석할 때 유용한 도구로는 프로파일링 툴APM(Application Performance Management) 툴이 있습니다. 이러한 툴을 사용하면 마치 고속도로 위에서 헬기나 비행기로 내려다보듯 시스템의 병목 지점을 쉽게 파악할 수 있습니다.

그러나 실제 프로젝트나 운영 환경에서는 예산 제약으로 인해 이러한 분석 도구를 사용하지 못하는 경우가 많습니다. 그럼에도 불구하고, 제한된 조건 하에서 우리가 할 수 있는 방법들이 있습니다. 이번 포스트에서는 그러한 방법들에 대해 알아보겠습니다.


1) 프로파일링 툴이란?

 프로파일링 툴은 시스템의 문제를 분석하는 데 사용되는 도구입니다. 최근 많이 사용되는 프로파일링 툴 중 하나는 APM(Application Performance Monitoring 또는 Management)입니다.

출처: https://www.parkplacetechnologies.com/blog/what-is-apm-application-performance-monitoring/


(1) 프로파일링 툴의 종류

 APM 툴은 운영 서버의 성능을 진단하고 모니터링하는 데 주로 사용됩니다. 대표적인 국산 APM 툴로는 미소 정보사의 WebTune과 케이와이즈사의 Pharos가 있으며, 외산 툴로는 CA Wily의 Introscope와 Compuware의 dynaTrace 등이 있습니다. 특히, 미소 정보사의 WebTune은 개발 장비에서 무료로 사용할 수 있는 라이선스를 제공합니다.

프로파일링 툴과 APM 툴을 비교해보면, 프로파일링 툴은 주로 개발자가 사용하는 도구이며, APM 툴은 운영 환경에서 사용되는 도구라고 할 수 있습니다.

구분 특징
프로파일링 툴 - 소스 레벨의 분석을 위한 툴이다.
- 애플리케이션의 세부 응답 시간까지 분석할 수 있다.
- 메모리 사용량을 객체나 클래스, 소스의 라인 단위까지 분석할 수 있다.
- 가격이 APM 툴에 비해서 저렴하다.
- 보통 사용자 수 기반으로 가격이 정해진다.
- 자바 기반의 클라이언트 프로그램 분석을 할 수 있다.
APM  툴 - 애플리케이션의 장애 상황에 대한 모니터링 및 문제점 진단이 주 목적이다.
- 서버의 사용자 수나 리소스에 대한 모니터링을 할 수 있다.
- 실시간 모니터링을 위한 툴이다.
- 가격이 프로파일링 툴에 비하여 비싸다.
- 보통 CPU 수를 기반으로 가격이 정해진다.
- 자바 기반의 클라이언트 프로그램 분석이 불가능하다.

(2) 프로파일링 툴의 기능

프로파일링 툴은 기본적으로 두 가지 중요한 기능을 제공합니다: 

 

- 응답 시간 프로파일링
 이 기능의 목적은 메서드나 클래스 단위에서의 응답 시간을 측정하는 것입니다. 어떤 툴은 심지어 소스 코드 라인 단위로도 응답 시간을 측정할 수 있습니다. 여기서 중요한 것은 CPU 시간과 대기 시간이 제공된다는 점입니다. CPU 시간은 CPU를 사용하는 동안의 시간을, 대기 시간은 CPU를 기다리는 시간을 의미합니다. 이 두 시간을 합하면 실제 소요 시간이 됩니다.
- 메모리 프로파일링
 메모리 프로파일링은 잠시 사용되고 곧 가비지 컬렉터(GC)에 의해 처리되는 객체를 찾거나 메모리 누수가 발생하는 부분을 파악하는 데 사용됩니다. 클래스나 메서드 단위의 메모리 사용량을 분석하고, 일부 툴은 소스 코드 라인 단위의 메모리 사용량도 측정할 수 있습니다.

 

그렇다면 CPU 시간과 대기 시간이란 무엇일까요?
 프로파일링 툴을 이용한 시스템 분석에서 CPU 시간과 대기 시간은 중요한 요소입니다. 메서드나 코드 라인을 수행하는 데 필요한 시간은 이 두 요소로 나뉩니다.

- CPU 시간: 이는 메서드나 코드 라인을 실행하는 데 실제로 CPU가 작업하는 시간을 의미합니다.
- 대기 시간: CPU가 다른 작업을 처리하고 있어 해당 메서드나 코드 라인이 실행을 기다리는 시간을 말합니다.

이 두 시간을 합하면 메서드나 라인의 전체 소요 시간을 알 수 있으며, 때로는 CPU 시간이 스레드 시간으로 표현되기도 합니다.


(3) 프로파일링 툴 사용에 대한 오해

 많은 사람들이 APM 툴이나 프로파일링 툴이 자동으로 모든 분석을 해줄 것이라고 생각합니다. 하지만 이러한 툴은 자동으로 모든 문제를 해결해주지 않습니다. 분석하려면 우선 해당 메서드가 실행되어야 하며, 실행되지 않은 메서드는 분석할 수 없습니다. 특히 메모리 부족 현상은 가장 분석하기 어려운 부분 중 하나입니다.


(4) 더 간단한 속도 측정 방법

 프로그램의 속도를 측정하는 더 간단한 방법은 `System` 클래스에서 제공하는 메서드를 활용하는 것입니다. 이 방법은 프로파일링 툴보다 덜 복잡하며, 빠르게 속도 측정을 할 수 있습니다.


2) System 클래스

 Java의 System 클래스모든 메서드가 static으로 선언되어 있으며, in, out, err과 같은 객체들 역시 static입니다. 이 클래스는 생성자가 없기 때문에, System 객체를 직접 생성할 수 없습니다. 대신 System.XXX와 같은 방식으로 사용합니다.

 

 그렇다면 System 클래스에서 자주 사용하지는 않지만 알아두면 매우 유용한 메서드에는 어떤 것들이 있는지 알아보고자 합니다.


(1) System 클래스의 유용한 메서드: System.arraycopy

System.arraycopy 메서드는 배열을 효율적으로 복사할 때 사용됩니다. 이 메서드를 사용하여, 한 배열의 특정 부분을 다른 배열의 특정 위치로 복사할 수 있습니다. 

 

사용 예시는 다음과 같습니다:

package com.perf.timer;

public class SystemArrayCopy {
  public static void main(String[] args) {
    String[] arr = new String[] {"AAA", "BBB", "CCC", "DDD", "EEE"};
    String[] copiedArr = new String[3];
    System.arraycopy(arr, 2, copiedArr, 1, 2);
    for(String value : copiedArr) {
      System.out.println(value);
    }
  }
}

이 코드는 원본 배열 arr의 2번째 위치("CCC")부터 시작하여 2개의 요소를 copiedArr 배열의 1번째 위치부터 복사합니다.

 

결과적으로 copiedArr는 null, "CCC", "DDD"를 포함하게 됩니다:

null
CCC
DDD

 

 

만약 복사하는 길이(length)를 배열 크기를 초과하게 지정하면 ArrayIndexOutOfBoundsException이 발생합니다.


(2) Java에서 JVM 설정 이해하기: 속성(Property)과 환경(Environment)

 Java에서 JVM(Java Virtual Machine) 설정은 '속성(Property)' '환경(Environment)' 두 가지로 나뉩니다. 속성은 JVM에 의해 지정된 설정 값들을 말하며, 환경은 서버나 장비에 지정된 설정 값을 의미합니다. Java에서는 속성을 `Properties`, 환경을 `env`로 표현합니다. 이번에는 `Properties`와 관련된 메서드들을 살펴보겠습니다.

① static Properties getProperties()
   - 현재 자바 시스템의 속성 값들을 반환합니다.

② static String getProperty(String key)
   - 지정된 `key`에 해당하는 자바 시스템 속성 값을 반환합니다.

③ static String getProperty(String key, String def)
   - 지정된 `key`에 해당하는 자바 시스템 속성 값을 반환합니다. 만약 `key`가 존재하지 않으면 `def`로 지정한 기본값을 반환합니다.

④ static void setProperties(Properties props)
   - `props` 객체에 담긴 내용을 자바 시스템 속성으로 설정합니다.

⑤ static String setProperty(String key, String value)
   - 자바 시스템 속성 중 지정된 `key`의 값을 `value`로 변경합니다.

이러한 메서드들은 Java 애플리케이션에서 JVM의 동작을 조절하는데 중요한 역할을 합니다. 특히, 애플리케이션의 환경 설정이나 구성을 관리할 때 유용하게 사용됩니다.

 

이러한 자바 속성 관련 메서드를 어떻게 사용하는지 다음의 예를 통해 알아보겠습니다:

package com.perf.timer;

import java.util.Iterator;
import java.util.Properties;
import java.util.Set;

public class GetProperties {
  public static void main(String[] args) {
    // 'JavaTuning'이라는 키에 'Tune Lee'라는 값을 설정
    System.setProperty("JavaTuning", "Tune Lee");

    // 시스템 속성 전체를 가져옴
    Properties prop = System.getProperties();
    Set key = prop.keySet();
    Iterator it = key.iterator();

    // 모든 시스템 속성을 출력
    while(it.hasNext()) {
      String curKey = it.next().toString();
      System.out.format("%s=%s\n", curKey, prop.getProperty(curKey));
    }
  }
}

 

 이 코드는 'JavaTuning'이라는 키를 갖는 시스템 속성에 'Tune Lee'라는 값을 설정한 후, 모든 시스템 속성 값을 화면에 출력합니다. 실행하면 수십 개의 자바 시스템 속성이 나타나며, 우리가 추가한 'JavaTuning' 속성도 함께 출력됩니다.


(3) 시스템 환경 변수 관련 메서드 사용법

시스템 환경 변수를 다루는 데 사용되는 메서드들은 다음과 같습니다:
① static Map<String, String> getenv()
- 현재 시스템 환경 값을 문자열 형태의 맵으로 반환합니다.

 

② static String getenv(String name)
- 지정된 환경 변수의 값을 가져옵니다.


이 메서드들은 애플리케이션 실행 환경의 특정 변수들을 접근하고 활용하는 데 유용합니다. 시스템 환경 변수를 사용하는 방법은 자바 속성을 사용하는 방법과 유사합니다.

 

다음은 시스템 환경 변수를 사용하는 예제 코드입니다:

package com.perf.timer;

import java.util.Iterator;
import java.util.Map;
import java.util.Set;

public class GetEnv {
  public static void main(String[] args) {
    Map<String, String> envMap = System.getenv();
    Set key = envMap.keySet();
    Iterator it = key.iterator();

    while(it.hasNext()) {
      String curKey = it.next().toString();
      System.out.format("%s = %s\n", curKey, envMap.get(curKey));
    }
  }
}

 

 이 코드를 실행하면, 윈도우 커맨드 창에서 'set' 명령어를 사용했을 때와 유사한 환경 변수 목록을 출력합니다.


(4) 네이티브 라이브러리를 위한 System 클래스 메서드

네이티브 라이브러리를 활용할 때 사용할 수 있는 System 클래스 메서드는 다음과 같습니다:

ⓛ static void load(String filename)
- 파일 이름을 지정하여 네이티브 라이브러리를 로드합니다.


② static void loadLibrary(String libname)
- 라이브러리 이름을 지정하여 네이티브 라이브러리를 로드합니다.


(5) 사용을 피해야 하는 System 클래스 메서드

일부 System 클래스 메서드는 운영 중인 코드에서 사용하지 않는 것이 좋습니다:

ⓛ  static void gc()
- 명시적으로 가비지 컬렉터(GC)를 호출하여 메모리를 해제합니다.

 

  static void exit(int status)
- 현재 실행 중인 자바 JVM을 종료합니다.

 

  static void runFinalization()
- finalize() 메서드를 가진 모든 객체에 대해 참조 해제 작업을 수동으로 수행합니다. 이 메서드는 가비지 컬렉터가 자동으로 처리할 작업을 강제로 수행하도록 합니다.


3) 시간 측정 관련 메서드

(1) System.currentTimeMillis와 System.nanoTime

 자바에서 시간을 측정하는 데 주로 사용되는 두 가지 메서드는 System.currentTimeMillis와 System.nanoTime입니다.

- static long currentTimeMillis()
이 메서드는 현재 시간을 밀리초(ms, 1/1000초) 단위로 반환합니다. UTC 시간 체제를 기준으로 1970년 1월 1일부터 경과한 시간을 long 타입으로 반환합니다. 따라서 호출할 때마다 값이 달라지며, 이 값을 변환하여 현재 날짜를 구할 수도 있습니다.

 

- static long nanoTime()
이 메서드는 나노초(ns, 1/1,000,000,000초) 단위의 시간을 반환합니다. 보다 정밀한 시간 측정이 가능하며, 특히 짧은 시간 간격을 측정할 때 유용합니다.


다음은 이러한 메서드들을 사용하는 예제 코드입니다:

package com.perf.timer;

import java.util.ArrayList;
import java.util.HashMap;

public class CompareTimer {
  public static void main(String[] args) {
    CompareTimer timer = new CompareTimer();
    for(int loop = 0; loop < 10; loop++) {
      timer.checkNanoTime();
      timer.checkCurrentTimeMillis();
    }
  }

  private DummyData dummy;

  public void checkNanoTime() {
    long startTime = System.nanoTime();
    dummy = timeMakeObjects();
    long endTime = System.nanoTime();
    double elapsedTime = (endTime - startTime) / 1000000.0;
    System.out.println("nano = " + elapsedTime);
  }

  public void checkCurrentTimeMillis() {
    long startTime = System.currentTimeMillis();
    dummy = timeMakeObjects();
    long endTime = System.currentTimeMillis();
    long elapsedTime = endTime - startTime;
    System.out.println("milli = " + elapsedTime);
  }

  public DummyData timeMakeObjects() {
    HashMap<String, String> map = new HashMap<String, String>(1000000);
    ArrayList<String> list = new ArrayList<String>(1000000);
    return new DummyData(map, list);
  }
}

class DummyData {
  HashMap<String, String> map;
  ArrayList<String> list;

  public DummyData(HashMap<String, String> map, ArrayList<String> list) {
    this.map = map;
    this.list = list;
  }
}

 

 이 코드는 nanoTime과 currentTimeMillis를 사용하여 특정 작업의 수행 시간을 밀리초와 나노초 단위로 측정합니다. 결과는 각각의 단위로 출력되며, 두 메서드의 시간 측정 차이를 비교할 수 있습니다.

 

 만약 사용 중인 자바 버전이 JDK 5.0 이상이라면, 시간을 측정할 때 System.nanoTime() 메서드를 사용하는 것이 좋습니다. 이 메서드는 특히 짧은 시간 간격을 측정하기 위해 설계되었으며, 밀리초보다 더 정밀한 나노초 단위의 시간 측정을 가능하게 합니다.

초기에 nanoTime() 메서드를 사용했을 때 성능이 느리게 나올 수 있는데, 이는 몇 가지 이유 때문입니다:

① 클래스 로딩: 처음 메서드를 호출할 때 클래스 로딩 과정에서 성능 저하가 발생할 수 있습니다.
② JIT Optimizer: 자바의 Just-In-Time 컴파일러인 JIT Optimizer가 작동하면서 시간이 지날수록 성능을 최적화합니다. 따라서 초기에는 성능이 저하되었다가 시간이 지나면서 점차 향상될 수 있습니다.


 nanoTime() 메서드는 프로그램의 성능 테스트나 벤치마킹에 매우 유용하며, 특히 반복적인 작업이나 빠른 실행 시간이 요구되는 작업을 측정할 때 정확한 시간 측정을 제공합니다.


(2) 메서드의 성능 측정 방법: 전문 측정 라이브러리 활용

 메서드 성능을 측정하는 방법에는 여러 가지가 있습니다. `System.nanoTime()`과 같은 메서드로 직접 측정하는 것 외에도, 전문 성능 측정 라이브러리를 사용하는 것이 좋은 방법 중 하나입니다. 대표적인 성능 측정 라이브러리로는 JMH, Caliper, JUnitPerf, JUnitBench, ContiPerf 등이 있습니다.

① JMH(Java Microbenchmark Harness)
 JMH는 OpenJDK에서 개발한 자바용 성능 측정 라이브러리로, 매우 정밀한 마이크로벤치마크 테스트를 위해 설계되었습니다. 

 

예를 들어, HashMap과 ArrayList 객체 생성 속도를 JMH로 측정하는 예제 코드는 다음과 같습니다:

package com.perf.timer;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.concurrent.TimeUnit;
import org.openjdk.jmh.annotations.*;

@BenchmarkMode({ Mode.AverageTime }) // 1.
@OutputTimeUnit(TimeUnit.MILLISECONDS) // 2.
public class CompareTimerJMH {

  @GenerateMicroBenchmark // 3.
  public DummyData makeObjectWithSize1000000() {
    HashMap<String, String> map = new HashMap<String, String>(1000000);
    ArrayList<String> list = new ArrayList<String>(1000000);
    return new DummyData(map, list);
  }
}

 

 

1. @BenchmarkMode: 벤치마크 모드를 설정합니다. 여기서는 평균 응답 시간을 측정합니다.
2. @OutputTimeUnit: 결과 출력 시간 단위를 설정합니다. 여기서는 밀리초로 지정합니다.
3. @GenerateMicroBenchmark: 테스트 대상 메서드를 선언할 때 사용합니다.

JMH를 실행한 결과에서 주요한 부분은 다음과 같습니다:

Run statistics "makeObject": min=2.015, avg=2.089, max=2.127, stdev=0.064

 

- `min`은 최소, `avg`는 평균, `max`는 최대 값을 의미합니다.
- `stdev`는 표준편차를 나타냅니다.
- 이 결과를 보면 평균적으로 약 2.089ms가 소요되었다고 볼 수 있습니다.

JMH를 사용하면 복잡한 케이스 구성 없이 각 테스트 케이스별로 성능을 측정하고 결과를 확인할 수 있습니다.


4) 정리하며

(1) 프로젝트에서 프로파일링 툴과 APM 툴의 중요성
 프로파일링 툴이나 APM(Application Performance Management) 툴은 프로젝트에서 중요한 역할을 합니다. 성능 문제가 중요한 이슈가 되는 사이트의 경우, 이러한 툴들은 필수적입니다. 하지만, 비싼 툴을 구입한 후 제대로 활용하지 못한다면 그 가치를 발휘할 수 없습니다. 따라서, 이 교재에서는 프로젝트의 상황에 맞는 APM이나 프로파일링 툴을 신중하게 선택하는 것이 중요하다고 강조합니다.

(2) 성능 차이 비교를 위한 JMH 사용
 또한, 서로 다른 API에서 제공하는 메서드들 사이의 성능 차이를 비교하고 싶을 때, JMH(Java Microbenchmark Harness)의 사용을 고려해볼 수 있습니다. JMH는 성능 차이를 명확하게 비교하고 분석할 수 있도록 도와주는 강력한 도구입니다. 이를 통해 다양한 코드의 실행 시간을 정밀하게 측정하고, 성능상의 차이를 명확히 이해할 수 있습니다.