CodeGym /행동 /JAVA 25 SELF /프로파일링과 코드 최적화: 도구와 접근법

프로파일링과 코드 최적화: 도구와 접근법

JAVA 25 SELF
레벨 63 , 레슨 4
사용 가능

1. 프로파일링 소개

프로파일링은 프로그램을 위한 건강검진과 같습니다: 우리는 단순히 “체온”(모니터링)만 보는 것이 아니라, 애플리케이션이 어디가 “아픈지”, 무엇이 느린지, 어디에서 메모리나 리소스를 과도하게 쓰는지를 찾습니다.

프로파일링은 병목(bottlenecks)과 비효율적인 코드 구간을 찾아내기 위해 프로그램 동작 정보를 수집·분석하는 과정입니다. 일반적으로 전체 지표(프로세서 부하, 메모리, 스레드 수)를 추적하는 모니터링과 달리, 프로파일링은 내부를 들여다봅니다: 어떤 메서드가 가장 자주 호출되는지, 얼마나 시간을 쓰는지, 어떤 객체가 얼마나 자주 생성되는지, 그리고 정확히 어디에서 메모리 누수가 발생하는지를 알 수 있습니다.

프로파일링이 정말 필요한 때

  • 애플리케이션이 “느리다”지만 이유를 모르겠을 때.
  • 메모리 사용량이 갑자기 늘었을 때.
  • 코드 업데이트 후 일부가 더 오래 걸리기 시작했을 때.
  • 서버 자원이 왜 부족한지 파악해야 할 때.

덧붙여 말하면, 거의 모든 개발자가 한 번쯤은 잘못된 부분을 최적화해 본 경험이 있습니다. 왜 그럴까요? 눈대중으로 병목을 정확히 찾기 거의 불가능하기 때문입니다 — 그래서 프로파일러가 필요합니다.

프로파일링의 핵심 지표

  • 메서드 실행 시간(CPU 프로파일링): 어떤 메서드가 시간을 가장 많이 쓰나요? 어디에서 CPU가 소모되나요?
  • 메모리 사용량(메모리 프로파일링): 어떤 객체가 가장 자주 생성되나요? 필요 이상으로 오래 메모리에 남아 있지는 않나요?
  • 객체 수: 동일한 유형의 객체를 과도하게 생성하고 있지는 않나요?
  • 스레드: 스레드 수가 과도하지 않나요? 락(데드락, contention)은 없나요?
  • 메서드 호출: 스택 깊이는 어떤가요? 종료 없는 재귀가 일어나지 않나요?

2. 프로파일링 도구

Java 세계에는 프로파일링을 수행할 수 있는 몇 가지 고전적(그리고 무료!) 도구가 있습니다. 그중 주요 도구들을 살펴보겠습니다.

VisualVM

VisualVM은 JDK(JDK 6부터)에 포함된 무료 도구입니다. 다음을 할 수 있습니다:

  • 로컬 및 원격 JVM에 연결.
  • 메모리, 스레드, CPU, 가비지 컬렉션 보기.
  • heap dump 생성 및 분석.
  • CPU 및 메모리 프로파일링.

VisualVM을 어떻게 실행하나요?
일반적으로 JDK 폴더에 있습니다: <put’_k_JDK>/bin/jvisualvm
실행 후 Java 애플리케이션 프로세스를 선택하면 — 객체와 스레드를 어항 속 물고기 보듯 관찰할 수 있습니다.

JProfiler, YourKit

상용이지만 매우 강력한 도구입니다. 다음을 할 수 있습니다:

  • 메모리, CPU, 스레드 프로파일링.
  • 메모리 “스냅샷”(heap dump) 분석.
  • 누수, 긴 락, 느린 메서드 탐지.
  • IDE 및 CI/CD 통합.

처음에는 VisualVM으로도 충분하지만, 더 큰 프로젝트로 “성장”하면 — 이 도구들을 살펴보세요.

Java Flight Recorder (JFR)

JFR은 JVM 동작 이벤트를 수집하는, JDK에 내장된 도구입니다. 매우 가볍고 성능에 거의 영향을 주지 않으며, 다음 정보를 수집할 수 있습니다:

  • 메서드 실행 시간.
  • 가비지 컬렉션.
  • 스레드, 락, 오류.

JFR은 애플리케이션을 둔화시킬 수 없는 프로덕션 환경에 매우 적합합니다.

3. 실습: 간단한 애플리케이션 프로파일링

긴 연산을 수행하고 연산 기록을 저장할 수 있는 미니 계산기를 만들어 봅시다(루프, 컬렉션, 메모리 사용을 모두 만들어 보려는 목적입니다).

코드 예: “느린 계산기”

import java.util.ArrayList;
import java.util.List;

public class SlowCalculator {
    private final List<String> history = new ArrayList<>();

    public int add(int a, int b) {
        simulateHeavyOperation();
        int result = a + b;
        history.add(a + " + " + b + " = " + result);
        return result;
    }

    public int multiply(int a, int b) {
        simulateHeavyOperation();
        int result = a * b;
        history.add(a + " * " + b + " = " + result);
        return result;
    }

    public List<String> getHistory() {
        return history;
    }

    // "무거운" 작업 시뮬레이션
    private void simulateHeavyOperation() {
        for (int i = 0; i < 5_000_000; i++) {
            Math.sqrt(i);
        }
    }
}

이제 메인 클래스입니다:

public class Main {
    public static void main(String[] args) {
        SlowCalculator calc = new SlowCalculator();
        for (int i = 0; i < 10; i++) {
            calc.add(i, i * 2);
            calc.multiply(i, i + 5);
        }
        System.out.println("연산 기록:");
        for (String entry : calc.getHistory()) {
            System.out.println(entry);
        }
    }
}

이 애플리케이션을 어떻게 프로파일링하나요?

  1. 애플리케이션을 컴파일하고 실행합니다.
  2. VisualVM(jvisualvm)을 엽니다.
  3. 자신의 프로세스를 찾습니다(보통 Main 클래스 이름으로 찾습니다).
  4. CPU Profiler 탭으로 이동해 Start를 클릭합니다.
  5. 프로그램이 어느 정도 실행되도록 둡니다(또는 느린 버튼을 한 번 더 누르세요).
  6. 어떤 메서드가 시간을 가장 많이 쓰는지 확인합니다.

질문: 어떤 메서드가 가장 “무거울”까요?
답변: 당연히 simulateHeavyOperation()입니다 — 5_000_000번 반복 루프를 돌며 Math.sqrt를 호출하기 때문이죠.

4. 흔한 성능 문제

느린 알고리즘

가장 흔한 원인: 부적절한 알고리즘 또는 자료구조 선택입니다. 예를 들어, HashMap 대신 리스트에서 선형 탐색을 하거나, 버블 정렬 대신 퀵 정렬을 사용해야 할 때가 있습니다.

예시:

// 느린 탐색
for (String s : list) {
    if (s.equals("target")) {
        // 찾음
    }
}

빠른 조회를 위해서는 Set이나 Map을 사용하는 것이 좋습니다.

메모리 누수

메모리 누수는 객체가 더 이상 필요하지 않지만(참조가 남아 있어) “살아있는” 상태로 남는 상황을 말합니다. 이는 메모리 사용량 증가로 이어지고, 결국 OutOfMemoryError가 발생할 수 있습니다.

public class MemoryLeakDemo {
    private static List<byte[]> leakyList = new ArrayList<>();

    public static void main(String[] args) {
        while (true) {
            leakyList.add(new byte[1_000_000]); // 1 MB
            try { Thread.sleep(100); } catch (InterruptedException ignored) {}
        }
    }
}

누수를 어떻게 찾을까요?
heap dumpVisualVM에서 생성해 어떤 객체가 왜 가장 많은 메모리를 차지하는지, 왜 참조가 남아 있는지 확인합니다.

과도한 객체 생성

루프에서 동일한 유형의 객체를 많이 생성하면 가비지 컬렉터에 부담을 줄 뿐 아니라 애플리케이션을 느리게 할 수 있습니다.

for (int i = 0; i < 1_000_000; i++) {
    String s = new String("hello"); // 나쁨!
}

상수나 문자열 풀(String pool)을 사용하는 것이 좋습니다.

스레드 락

여러 스레드가 동일한 리소스를 두고 경쟁(예: synchronized 메서드)하면 락이 발생하고 성능이 저하될 수 있습니다.

public synchronized void doWork() {
    // ...
}

어떻게 찾나?
VisualVM의 Threads 탭에서 어떤 스레드가 “멈춰” 있는지와 그 이유를 확인할 수 있습니다.

5. 최적화 접근법

먼저 측정하고, 그다음 최적화하라

최적화의 핵심 규칙: 느리지 않은 것은 최적화하지 마라.

먼저 프로파일링하여 “핫스폿”을 찾고, 그다음 코드를 변경하세요. 때로는 가장 “명백해 보이는” 코드가 전체 시간의 1%만 차지하고, 진짜 “괴물”은 라이브러리나 예상치 못한 곳에 숨어 있습니다.

프로파일러로 핫스폿 찾기

핫스폿은 애플리케이션 실행 시간의 가장 큰 비중을 차지하는 메서드 또는 코드 구간입니다.

VisualVM의 CPU Profiler 탭에서 확인할 수 있습니다:

  • 메서드를 실행 시간 기준으로 정렬합니다.
  • 스택 트레이스를 확인해 누가 누구를 호출하는지 봅니다.
  • 때로는 문제의 원인이 여러분의 코드가 아니라 라이브러리나 심지어 JDK일 수 있음을 기억하세요.

최적화 예시

예시 1: 알고리즘 교체
리스트 검색에 시간이 가장 많이 든다면 ListHashSet으로 바꾸세요.

Set<String> set = new HashSet<>(list);
if (set.contains("target")) {
    // 빠름!
}

예시 2: 할당 수 줄이기
루프에서 새 객체를 만드는 대신 재사용하거나 StringBuilder를 사용하세요.

// 나쁨:
for (int i = 0; i < 10000; i++) {
    String s = "결과: " + i;
}

// 더 나음:
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
    sb.setLength(0);
    sb.append("결과: ").append(i);
    String s = sb.toString();
}

예시 3: 캐싱
무거운 메서드가 동일한 매개변수로 자주 호출된다면 캐시를 사용하세요.

Map<Integer, Double> sqrtCache = new HashMap<>();
public double cachedSqrt(int x) {
    return sqrtCache.computeIfAbsent(x, Math::sqrt);
}

6. 데모: 우리의 계산기 가속

문제: simulateHeavyOperation()이 시간을 많이 소모함

1단계. 프로파일링
VisualVM에서 거의 모든 시간이 Math.sqrt(i) 호출이 포함된 5_000_000회 반복 루프에 쓰이는 것이 보입니다.

2단계. 최적화
단순 부하 시뮬레이션이라면 — 제거하거나 반복 횟수를 줄이세요.
실제 비즈니스 로직이라면 — 다음을 고려해 보세요:

  • 결과 캐싱.
  • 더 빠른 알고리즘 사용.
  • (사용자에 지장이 없다면) 별도 스레드로 계산을 옮기기.

최적화 예:

private void simulateHeavyOperation() {
    // 이전: 5_000_000, 이후: 100_000
    for (int i = 0; i < 100_000; i++) {
        Math.sqrt(i);
    }
}

3단계. 결과 검증
다시 프로파일링을 실행합니다 — 프로그램이 더 빨라지고 CPU 부하가 줄었습니다.

7. 시각화: 최적화 프로세스

flowchart TD
    A[애플리케이션 실행]
    B["프로파일링 (VisualVM)"]
    C[병목 식별]
    D[코드 최적화]
    E[재프로파일링]
    F[성능 개선]

    A --> B --> C --> D --> E --> F
    E --> C

8. 프로파일링과 최적화에서 흔한 실수

실수 1: 감으로 최적화하기. 개발자가 실제 문제가 어디인지 측정하지 않고 코드를 바꾸기 시작하는 경우가 매우 많습니다. 결과적으로 — 일은 많고 효과는 미미합니다.

실수 2: “비현실적인” 조건에서 프로파일링. 실제 데이터와 실제에 가까운 부하에서 프로파일링해야 합니다. “빈” 상태에서의 프로파일링은 진짜 문제를 드러내지 못할 수 있습니다.

실수 3: 메모리 누수 무시. heap dump와 참조를 보지 않으면, 프로그램이 “비대해지고” 곧 장애가 날 것을 오래도록 알아차리지 못할 수 있습니다.

실수 4: 미시적 최적화에 집착. 애플리케이션 전체 시간의 0.1%만 차지하는 코드를 빠르게 하려고 며칠을 쓰지 마세요. 먼저 — 주요 병목부터 해결하세요.

실수 5: 스레드와 동기화를 간과. 멀티스레드 애플리케이션에서는 성능 문제가 종종 알고리즘이 아니라 락과 대기(synchronized, contention) 때문입니다.

실수 6: 변경 후 프로파일링을 잊음. 최적화 후에는 반드시 결과를 다시 확인하세요: 때로는 “최적화”가 오히려 성능을 떨어뜨리기도 합니다!

1
과제
JAVA 25 SELF, 레벨 63, 레슨 4
잠금
에코 활동가
에코 활동가
1
과제
JAVA 25 SELF, 레벨 63, 레슨 4
잠금
캡틴 커크
캡틴 커크
1
설문조사/퀴즈
로깅, 레벨 63, 레슨 4
사용 불가능
로깅
로깅, 모니터링 그리고 프로파일링
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION