Java4 min read

Java의 JVM·GC·동시성 트레이드오프와 실용 예제 (가상 스레드 포함)

2026년 8월 20일4 min read

JVM의 핵심 구조와 가비지 컬렉션, JIT vs AOT, 모듈·언어 기능의 안정적 개념을 정리하고, 기존 쓰레드 풀과 가상 스레드를 비교하는 실용 Java 예제를 통해 설계 선택의 트레이드오프를 설명합니다.

Java의 JVM·GC·동시성 트레이드오프와 실용 예제 (가상 스레드 포함)

소개

이 글은 Java를 사용할 때 자주 만나게 되는 안정적 개념들(JVM 아키텍처, 가비지 컬렉터, JIT/AOT 등)과 각각의 트레이드오프를 정리합니다. 또한 동시성 선택지 중 하나인 가상 스레드(virtual threads)를 실무에서 어떻게 적용할지 간단한 예제로 보여줍니다. 목적은 “무엇이 있고, 언제 사용해야 하는가”를 판단할 수 있는 근거를 제공하는 것입니다.

JVM 핵심 구성과 설계 고려사항

JVM은 바이트코드 실행을 위해 클래스 로더, 실행 엔진(JIT), 힙/스택과 가비지 컬렉터로 구성됩니다. 설계 관점에서 주요 고려사항은 다음과 같습니다.

  • 메모리 사용(힙 크기, 메타스페이스) vs 응답성(GC 일시 중지 시간)
  • 처리량(throughput) vs 지연(latency)
  • 시작 시간(startup) vs 장시간 실행 성능

이러한 균형은 애플리케이션 성격(짧게 자주 시작하는 도구, 배치 처리, 대화형 서버 등)에 따라 달라집니다.

가비지 컬렉션(GC) 선택의 트레이드오프

Java에는 다양한 GC가 존재하며, 대표적으로 G1, ZGC, Shenandoah 등이 있습니다. 각각의 특성은 다음과 같습니다.

  • G1: 일반적인 서버 워크로드에서 균형 잡힌 선택. 메모리 단편화 관리를 목표로 하며 처리량과 일시 중지 시간의 균형이 좋음.
  • ZGC/Shenandoah: 낮은 일시 중지 시간을 목표로 하는 GC. 대용량 힙과 낮은 지연을 요구하는 서비스에 유리하지만, 메모리 오버헤드가 상대적으로 클 수 있음.

선택 시 고려할 점: 짧은 일시 중지 시간(레턴시 요구) → ZGC/Shenandoah, 높은 처리량과 메모리 효율성 → G1. 실제 환경에서는 프로파일링과 부하 테스트가 필수입니다.

JIT(Just-In-Time) vs AOT(예: GraalVM native-image)

JIT 컴파일러는 런타임에 성능을 최적화해 높은 처리량과 런타임 최적화 이점을 제공합니다. 반면 AOT(native image)는 빠른 시작 시간과 작은 메모리 풋프린트를 제공하지만 다음과 같은 제약이 있습니다.

  • 리플렉션, 동적 클래스 로딩 등의 동적 기능을 추가 설정해야 하거나 제약이 생길 수 있음
  • 일부 런타임 최적화(예: 프로파일 기반 인라이닝)가 JIT만큼 강력하지 않을 수 있음

단일 서버로 오래 동작하는 백엔드 서비스는 JIT가 유리한 경우가 많고, CLI 도구나 서버리스/컨테이너 환경에서는 AOT의 빠른 스타트업이 매력적입니다.

모던 Java 언어 기능과 설계의 영향

Record, sealed 클래스, 패턴 매칭 등 언어 레벨의 기능들은 코드 작성과 유지보수를 단순화합니다. 이 기능들은 설계(도메인 모델링, 불변성, 가시성 제어)에 직접적인 영향을 주므로 설계 원칙에 맞게 도입하면 코드베이스의 안정성을 높일 수 있습니다.

동시성: 전통 쓰레드 풀 vs 가상 스레드

전통적인 스레드 풀(fixed thread pool) 기반 설계는 스레드 개수를 제한하고 작업 큐를 통해 부하를 제어합니다. 반면 가상 스레드는 매우 가벼운 스레드 단위로 수만 개의 동시 작업을 보다 자연스럽게 표현할 수 있습니다. 장단점은 다음과 같습니다.

  • 전통 스레드 풀: 스레드 컨텍스트/메모리 비용이 크지만, 리소스 상한을 명시적으로 제어 가능. I/O가 긴 대기 없이 CPU 바인드 작업이 많은 경우 유리.
  • 가상 스레드: 많은 동시 I/O 작업을 간단한 코드로 처리 가능(블로킹 API 재사용 가능). 스레드 수가 폭발적으로 증가하면 메모리/스케줄링 관점의 다른 문제를 고려해야 함.

결정 포인트: 애플리케이션이 대량의 짧은 I/O 작업(예: 다수의 외부 HTTP 호출)을 수행하면 가상 스레드가 코드 단순성과 리소스 이용 측면에서 유리할 수 있습니다. 반대로 CPU 바운드 작업이 많고 명시적 리소스 제한이 필요하면 전통적인 스레드 풀 접근이 유리합니다.

실용 예제: 여러 외부 엔드포인트를 병렬로 호출하기

아래 예제는 두 가지 접근을 비교합니다. 1) 고정 스레드 풀을 사용하는 방식, 2) 가상 스레드를 사용하는 방식. 예제는 단순화를 위해 Java의 표준 HttpClient를 사용합니다.

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.*;

public class ParallelFetchExample {
    static final List<String> URLS = List.of(
        "https://example.com",
        "https://example.org",
        "https://example.net"
    );

    public static void main(String[] args) throws Exception {
        HttpClient client = HttpClient.newHttpClient();

        System.out.println("=== Fixed Thread Pool ===");
        runWithFixedPool(client);

        System.out.println("\n=== Virtual Threads ===");
        runWithVirtualThreads(client);
    }

    static void runWithFixedPool(HttpClient client) throws InterruptedException {
        ExecutorService pool = Executors.newFixedThreadPool(Math.min(URLS.size(), 4));
        try {
            List<Future<Integer>> futures = URLS.stream()
                .map(url -> pool.submit(() -> fetchStatus(client, url)))
                .toList();

            for (Future<Integer> f : futures) {
                try {
                    System.out.println("status=" + f.get());
                } catch (ExecutionException e) {
                    System.out.println("error: " + e.getCause());
                }
            }
        } finally {
            pool.shutdown();
            pool.awaitTermination(5, TimeUnit.SECONDS);
        }
    }

    static void runWithVirtualThreads(HttpClient client) throws InterruptedException {
        // virtual thread per task executor (try-with-resources로 닫기)
        try (ExecutorService virtualExecutor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<Integer>> futures = URLS.stream()
                .map(url -> virtualExecutor.submit(() -> fetchStatus(client, url)))
                .toList();

            for (Future<Integer> f : futures) {
                try {
                    System.out.println("status=" + f.get());
                } catch (ExecutionException e) {
                    System.out.println("error: " + e.getCause());
                }
            }
        }
    }

    static int fetchStatus(HttpClient client, String url) {
        try {
            HttpRequest req = HttpRequest.newBuilder()
                .uri(URI.create(url))
                .timeout(Duration.ofSeconds(5))
                .GET()
                .build();

            HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());
            return resp.statusCode();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

예제 설명 및 고려사항:

  • 가상 스레드는 코드 변경이 거의 없이 기존 블로킹 API를 재사용할 수 있게 해 줍니다. 복잡한 비동기 콜백 코드를 피할 수 있습니다.
  • 고정 스레드 풀은 명시적으로 동시성 상한을 만들기 때문에 외부 시스템에 대한 과도한 동시 요청을 방지하는 데 유리합니다. 반면 가상 스레드는 요청 폭주 시 외부 서비스에 부담을 줄 수 있으므로 자체적인 호출 제한(rate limiter)이나 서킷 브레이커 적용이 필요할 수 있습니다.
  • 가상 스레드는 많은 동시 블로킹 작업을 효율적으로 다루도록 설계되었지만, 애플리케이션이 주로 CPU 바운드이면 전통 스레드 모델과 작업 분산 전략을 고려해야 합니다.

결론 및 실무 가이드

핵심 요약:

  • JVM은 다양한 구성 요소와 튜닝 포인트(힙, GC, JIT/AOT)가 있으며, 애플리케이션 특성에 따라 최적화 방향이 달라집니다.
  • 가비지 컬렉터 선택은 지연 요구사항과 힙 크기를 고려해 결정하세요. 실제 부하 테스트로 확인해야 합니다.
  • JIT는 런타임 최적화에 유리하고, AOT는 빠른 시작/작은 메모리에 유리합니다. 용도에 맞춰 선택하세요.
  • 가상 스레드는 다수의 동시 블로킹 I/O 처리를 단순화하지만, 외부 리소스에 대한 제어(레이트 제한, 서킷 브레이커)를 잊지 마세요.

마지막으로, 어떤 기술을 도입하든 실제 서비스 환경에서의 프로파일링과 부하 테스트가 최종 판단의 기준입니다. 코드의 단순성, 운영에서의 안정성, 비용(메모리/CPU) 사이에서 합리적 균형을 찾으시길 바랍니다.