"자바는 느리다"는 말을 아직도 하는 사람이 있는데, 실제로 프로파일링해보면 병목은 어디에 있을까요?

결론부터 말하면, 현대 자바는 느리지 않습니다. JIT 컴파일러가 런타임에 핫스팟을 네이티브 코드로 변환하기 때문에, 특정 시나리오에서는 C/C++에 근접하는 성능을 보여줍니다. "자바가 느리다"는 인식은 JIT이 성숙하지 않았던 1990년대의 유산입니다.

하지만 "자바가 알아서 빠르다"는 것은 아닙니다. 성능 문제가 발생했을 때 ** 어디가 병목인지 측정하고, 올바른 도구로 프로파일링하는 능력 **이 필요합니다.

JIT 컴파일 — 실행할수록 빨라지는 원리

JVM은 바이트코드를 인터프리터로 실행하다가, 자주 호출되는 메서드(핫스팟)를 감지하면 네이티브 기계어로 변환합니다. C1(빠른 컴파일, 가벼운 최적화)과 C2(느린 컴파일, 공격적 최적화)가 단계적으로 적용되는 Tiered Compilation이 기본입니다.

JIT이 수행하는 대표적인 최적화를 보겠습니다.

JAVA
public int sum(int[] arr) {
    int total = 0;
    for (int i = 0; i < arr.length; i++) {
        total += arr[i];
    }
    return total;
}

이 코드에서 JIT은 (1) arr[i]의 배열 경계 검사를 루프 밖으로 이동하고, (2) 루프를 언롤링하여 분기 비용을 줄이고, (3) 메서드가 작으면 호출 지점에 인라이닝합니다. 특히 인라이닝은 다른 최적화(상수 전파, 이스케이프 분석)의 전제 조건이기 때문에 가장 중요한 최적화입니다.

JIT의 핵심 강점은 ** 런타임 프로파일링 기반 최적화 **입니다. AOT 컴파일과 달리 실제 실행 패턴을 보고 최적화하기 때문에, 이론적으로 더 나은 결과를 낼 수 있습니다.


JMH — 올바른 벤치마크 작성법

System.nanoTime()이 부족한 이유

System.nanoTime()으로 직접 시간을 재면 세 가지 함정에 빠집니다. (1) JIT 워밍업 전 상태에서 측정하게 되고, (2) JIT이 결과를 사용하지 않는 코드를 통째로 제거(Dead Code Elimination)할 수 있으며, (3) GC나 OS 스케줄링이 측정을 왜곡합니다. JMH는 이 모든 문제를 자동으로 처리합니다.

다음은 문자열 연결 방식을 비교하는 JMH 벤치마크입니다.

JAVA
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 10, time = 1)
@Fork(2)
@State(Scope.Benchmark)
public class StringBenchmark {
    private String[] words;

    @Setup
    public void setUp() {
        words = new String[100];
        for (int i = 0; i < 100; i++) words[i] = "word" + i;
    }

@Warmup이 JIT 컴파일을 완료시키고, @Fork가 JVM을 독립적으로 실행하여 측정 간 간섭을 제거합니다. 벤치마크 메서드는 결과를 반환하여 DCE를 방지합니다.

JAVA
    @Benchmark
    public String concatWithPlus() {
        String result = "";
        for (String word : words) result += word;
        return result;  // ← 반환값으로 DCE 방지
    }

    @Benchmark
    public String concatWithBuilder() {
        StringBuilder sb = new StringBuilder();
        for (String word : words) sb.append(word);
        return sb.toString();
    }
}

반환값이 없는 벤치마크에서는 Blackhole.consume(result)로 JIT의 코드 제거를 방지합니다. 또한 return 2 + 3;처럼 상수를 직접 사용하면 JIT이 컴파일 타임에 결과를 계산해버리므로, @State 필드를 통해 런타임 값으로 만들어야 합니다.


String 성능 — 연결 방식별 벤치마크

방식평균 시간 (ns)비고
+ 연산자 (루프)~12,000매 반복마다 새 StringBuilder 생성
StringBuilder~800단일 버퍼에 append
StringBuffer~1,200synchronized 오버헤드
String.join()~900내부적으로 StringJoiner 사용
JAVA
// 루프 안의 + 연산자 — 매번 새 StringBuilder가 생성됨
String result = "";
for (String s : list) {
    result += s;
    // 바이트코드: new StringBuilder(result).append(s).toString()
}

// StringBuilder를 직접 사용 — 한 번만 생성
StringBuilder sb = new StringBuilder(estimatedSize); // 초기 용량 지정
for (String s : list) {
    sb.append(s);
}
String result = sb.toString();

Java 9 이후에는 + 연산자가 invokedynamic으로 컴파일되어 ** 단순 연결 **(루프가 아닌 경우)에서는 StringBuilder와 비슷하거나 더 빠를 수 있습니다. 루프 안에서의 연결만 주의하면 됩니다.


컬렉션 선택과 성능

ArrayList vs LinkedList

대부분의 실무 시나리오에서 ArrayList가 LinkedList보다 빠릅니다. 그 이유는 CPU 캐시 구조에 있습니다. ArrayList는 연속된 메모리에 데이터를 저장하므로 캐시 라인에 한 번에 여러 요소가 올라옵니다. LinkedList는 노드가 메모리 곳곳에 흩어져 있어 캐시 미스가 잦습니다. 여기에 노드마다 prev/next 포인터로 16바이트 추가 메모리도 사용합니다.

HashMap 초기 용량

JAVA
// ❌ 기본 용량 16에서 시작 → 원소가 많으면 여러 번 리사이징
Map<String, Integer> map = new HashMap<>();

// ✅ 예상 크기를 알면 초기 용량 지정 (load factor 0.75 고려)
// 1000개 원소 → 최소 1000/0.75 = 1334 → 2의 거듭제곱으로 2048
Map<String, Integer> map = new HashMap<>(2048);

// Java 19+에서는 팩토리 메서드 사용
Map<String, Integer> map = HashMap.newHashMap(1000);

리사이징이 일어나면 모든 엔트리를 새 배열로 재해싱해야 하므로, 크기를 미리 알 때는 초기 용량을 지정하는 게 좋습니다.


불필요한 객체 생성 줄이기

오토박싱의 함정

JAVA
// ❌ 오토박싱: 매 반복마다 Long 객체 생성
Long sum = 0L;
for (long i = 0; i < 1_000_000; i++) {
    sum += i; // Long → long → 더하기 → Long (새 객체 생성)
}

// ✅ 프리미티브 타입 사용 — 5~10배 빠름
long sum = 0L;
for (long i = 0; i < 1_000_000; i++) {
    sum += i;
}

캐싱과 static factory

JAVA
// Integer -128 ~ 127은 캐시됨 (JLS 보장)
Integer a = 127;
Integer b = 127;
System.out.println(a == b);  // true — 같은 캐시 객체

Integer c = 128;
Integer d = 128;
System.out.println(c == d);  // false — 새 객체가 각각 생성됨

// static factory 메서드로 캐시된 인스턴스 활용
Boolean flag = Boolean.valueOf(true);   // Boolean.TRUE 반환
Integer num = Integer.valueOf(42);      // 캐시 범위 내면 기존 객체 반환

불변 객체 재사용

JAVA
public class DateRange {
    // ❌ 매번 새 포매터 생성 — DateTimeFormatter는 스레드 안전
    public String format(LocalDate date) {
        return date.format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
    }

    // ✅ 상수로 한 번만 생성
    private static final DateTimeFormatter FORMATTER =
        DateTimeFormatter.ofPattern("yyyy-MM-dd");

    public String formatBetter(LocalDate date) {
        return date.format(FORMATTER);
    }
}

VisualVM으로 핫스팟 찾기

VisualVM 은 무료 프로파일링 도구입니다(Java 9+에서는 별도 다운로드 필요). 실행하면 로컬 JVM 프로세스를 자동으로 감지하며, Monitor/Sampler/Profiler 탭으로 실시간 분석이 가능합니다.

실무에서는 **Sampling으로 핫스팟을 대략 잡고 **, 의심 지점만 Instrumentation으로 정밀 측정 하는 순서를 따릅니다.

방식원리오버헤드정밀도
Sampling주기적으로 스레드 스택 스냅샷낮음 (1~5%)상대적
Instrumentation바이트코드에 측정 코드 삽입높음 (10~30%)절대적

프로파일러에서 핫스팟으로 잡히는 메서드가 진짜 병목이 아닐 수 있습니다. GC가 자주 발생하면 GC 스레드가 핫스팟으로 잡히는데, 이 경우 필요한 것은 메서드 최적화가 아니라 GC 튜닝입니다.


JFR (Java Flight Recorder) — 프로덕션 프로파일링

VisualVM은 개발 환경에서 유용하지만, 프로덕션 서버에 연결하기는 부담됩니다. JFR 은 JVM에 내장된 프로파일링 도구로, 오버헤드가 1% 미만 이라 프로덕션에서도 상시 활성화할 수 있습니다. Java 11부터 OpenJDK에 무료로 포함되어 있습니다.

BASH
# JVM 시작 시 활성화
java -XX:StartFlightRecording=duration=60s,filename=recording.jfr MyApp

# 실행 중인 프로세스에 동적으로 붙이기
jcmd <PID> JFR.start duration=60s filename=recording.jfr

JFR은 CPU 핫스팟, 객체 할당, GC 이벤트, I/O, 잠금 경합, JIT 컴파일 이벤트를 수집합니다. .jfr 파일은 JMC(JDK Mission Control)로 분석합니다.

커스텀 이벤트

JAVA
import jdk.jfr.*;

@Label("주문 처리")
@Description("주문 처리 소요 시간을 기록하는 이벤트")
public class OrderProcessEvent extends Event {
    @Label("주문 ID")
    long orderId;

    @Label("처리 시간(ms)")
    long processingTimeMs;
}

// 사용
public void processOrder(Order order) {
    OrderProcessEvent event = new OrderProcessEvent();
    event.begin();
    doProcess(order);
    event.orderId = order.getId();
    event.processingTimeMs = event.getDuration().toMillis();
    event.commit();
}

메모리 최적화 팁

객체 풀링은 언제?

적합한 경우: DB 커넥션, 스레드, 소켓처럼 생성 비용이 매우 큰 리소스

** 부적합한 경우:** 일반 POJO, DTO — 현대 JVM의 GC가 단명 객체를 매우 효율적으로 처리하므로 풀 관리 비용이 더 큼

Primitive 배열 vs 래퍼 배열

JAVA
int[] primitive = new int[1_000_000];
// 메모리: 약 4MB (4바이트 x 100만)

Integer[] wrapper = new Integer[1_000_000];
// 메모리: 약 28MB (참조 8바이트 + Integer 객체 20바이트 x 100만)
// primitive의 약 7배!

빈 컬렉션 반환

JAVA
// ❌ 매번 새 빈 리스트 생성
return new ArrayList<>();

// ✅ 불변 빈 리스트 싱글턴 반환
return Collections.emptyList(); // 항상 같은 객체
return List.of();               // Java 11+

I/O 성능 — 버퍼링, NIO, 제로카피

버퍼링의 중요성

JAVA
// ❌ 바이트 단위 읽기 — 시스템 콜이 매번 발생
try (FileInputStream fis = new FileInputStream("data.bin")) {
    int b;
    while ((b = fis.read()) != -1) {
        process(b); // 1바이트마다 커널 모드 전환
    }
}

// ✅ 버퍼 사용 — 시스템 콜 횟수를 대폭 줄임
try (BufferedInputStream bis = new BufferedInputStream(
        new FileInputStream("data.bin"), 8192)) {
    int b;
    while ((b = bis.read()) != -1) {
        process(b); // 대부분 버퍼에서 읽기
    }
}

버퍼 하나로 ** 수십 배** 성능 차이가 날 수 있습니다.

제로카피(Zero-Copy)

일반적인 파일 → 네트워크 전송은 커널 버퍼 → 유저 버퍼 → 소켓 버퍼로 3번 복사됩니다. FileChannel.transferTo()를 사용하면 유저 공간을 거치지 않고 OS 레벨에서 직접 전송합니다.

JAVA
try (FileChannel src = FileChannel.open(Paths.get("source.dat"), READ);
     FileChannel dst = FileChannel.open(Paths.get("dest.dat"),
         WRITE, CREATE, TRUNCATE_EXISTING)) {
    // OS 레벨에서 직접 전송 — 유저 공간 복사 없음
    src.transferTo(0, src.size(), dst);
}

Kafka나 Netty 같은 고성능 시스템이 제로카피를 적극 활용하는 이유이기도 합니다.

Memory-Mapped File

JAVA
try (FileChannel channel = FileChannel.open(Paths.get("large-file.dat"), READ)) {
    // 파일을 가상 메모리에 매핑 — 접근하는 부분만 페이지 단위로 로드
    MappedByteBuffer buffer = channel.map(
        FileChannel.MapMode.READ_ONLY, 0, channel.size());

    while (buffer.hasRemaining()) {
        byte b = buffer.get();
    }
}


주의할 점

마이크로벤치마크 결과를 프로덕션에 그대로 적용하면 안 된다

JMH 결과는 격리된 환경에서의 측정값입니다. 프로덕션에서는 GC 압력, 캐시 경합, I/O 대기 등이 복합적으로 작용하므로, 벤치마크에서 10배 빠른 코드가 실제로는 차이가 미미할 수 있습니다. 반대로, 벤치마크에서 문제없던 코드가 GC 압력이 높은 환경에서 심각한 지연을 일으킬 수도 있습니다.

프로파일러 없이 "감으로" 최적화하면 엉뚱한 곳을 고친다

실제 병목은 직관과 다른 곳에 있는 경우가 많습니다. 코드 레벨이 아니라 DB 쿼리, 네트워크 I/O, GC가 진짜 원인인 경우가 대부분입니다. 반드시 프로파일러로 측정한 뒤 최적화해야 합니다.

"성급한 최적화는 모든 악의 근원이다" — Donald Knuth. 이 말의 진짜 의미는 "최적화를 하지 말라"가 아니라, "측정 없이 감으로 최적화하지 말라" 입니다.

정리

영역기법효과
문자열루프에서 StringBuilder 사용10~100배
컬렉션ArrayList 우선, HashMap 초기 용량 지정2~10배
박싱프리미티브 타입 사용5~10배
I/O버퍼링 적용, NIO/제로카피10~100배
측정JMH(마이크로벤치마크), VisualVM(개발), JFR(프로덕션)-
최적화 순서측정 → 알고리즘 → 데이터 구조 → I/O → 미시 최적화-
댓글 로딩 중...