Java3 min read

현대 Java 설계 원칙과 동시성: 핵심 개념, 트레이드오프, 그리고 실전 예제

2026년 8월 25일3 min read

JVM, GC, 컴파일 전략, 모듈화, 언어 기능과 동시성 모델의 핵심 개념과 트레이드오프를 정리하고, HttpClient와 CompletableFuture를 이용한 실전 예제를 통해 적용 방식을 설명합니다.

현대 Java 설계 원칙과 동시성: 핵심 개념, 트레이드오프, 그리고 실전 예제

개요

이 글은 현대 Java 플랫폼의 핵심 개념, 주요 트레이드오프, 그리고 실전 예제를 통해 실무에서의 적용 방식을 정리합니다. JVM, GC, 컴파일 전략, 모듈화, 그리고 동시성 모델을 중심으로 설명합니다.

핵심 구성 요소

JVM: 바이트코드 실행 환경으로 플랫폼 독립성을 제공합니다. JIT(Just-In-Time)와 AOT(Ahead-Of-Time) 컴파일은 실행 성능과 시작 시간을 조절하는 수단입니다.

Garbage Collector: G1, ZGC 등 다양한 GC가 존재하며, 지연 시간(latency), 처리량(throughput), 메모리 오버헤드 간의 트레이드오프가 있습니다. 워크로드(대량 객체 생성 vs 장시간 유지)에 따라 적절한 GC를 선택해야 합니다.

모듈과 클래스패스: JPMS(모듈 시스템)는 캡슐화와 의존성 관리를 돕지만, 복잡한 모듈 의존성과 런타임 설정으로 이어질 수 있습니다. 작은 서비스나 라이브러리 설계 시 단순함을 유지하는 것도 고려하세요.

언어 기능: record, sealed class, 패턴 매칭 등은 데이터 모델링과 가독성을 개선합니다. 그러나 레거시 프레임워크와의 호환성이나 직렬화 요구사항을 고려해야 합니다.

동시성 모델과 트레이드오프

전통 스레드: OS 스레드 기반으로 직관적이지만, 많은 수의 스레드는 컨텍스트 전환 비용과 스택 메모리 부담을 초래합니다.

비동기(CompletableFuture, Reactive): 논블로킹 모델은 높은 동시성을 처리하는 데 유리합니다. 하지만 제어 흐름과 에러 처리, 리소스 제어(backpressure) 설계가 복잡해질 수 있습니다.

스레드풀과 스케줄링: 적절한 스레드풀 크기와 작업 분할 전략은 성능을 좌우합니다. 과도한 동시성은 GC, 락 컨텐션, 네트워크 소켓 제한 등에 악영향을 줄 수 있습니다.

실전 예제: 비동기 HTTP 집계기

다음 예제는 java.net.http.HttpClient와 CompletableFuture를 사용해 여러 HTTP 엔드포인트를 병렬로 호출하여 결과를 합치는 간단한 패턴입니다. 비동기(논블로킹) 방식과 블로킹(스레드풀) 방식 두 가지를 비교합니다.

비동기(CompletableFuture) 예제

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.stream.Collectors;

public class AsyncAggregator {
    public static void main(String[] args) {
        HttpClient client = HttpClient.newHttpClient();
        List uris = List.of(
            URI.create("https://example.com"),
            URI.create("https://example.org")
        );

        List<CompletableFuture<String>> futures = uris.stream()
            .map(uri -> client.sendAsync(HttpRequest.newBuilder(uri).GET().build(),
                                             HttpResponse.BodyHandlers.ofString())
                              .thenApply(HttpResponse::body))
            .collect(Collectors.toList());

        CompletableFuture<Void> all = CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]));
        all.join(); // 모든 요청 완료 대기

        List<String> results = futures.stream()
            .map(CompletableFuture::join)
            .collect(Collectors.toList());

        results.forEach(System.out::println);
    }
}

블로킹(ExecutorService) 예제

import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.Executors;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Future;
import java.util.stream.Collectors;

public class BlockingAggregator {
    public static void main(String[] args) throws Exception {
        HttpClient client = HttpClient.newHttpClient();
        List uris = List.of(
            URI.create("https://example.com"),
            URI.create("https://example.org")
        );

        ExecutorService executor = Executors.newFixedThreadPool(10);
        List<Future<String>> futures = uris.stream()
            .map(uri -> executor.submit((Callable<String>) () -> {
                HttpRequest req = HttpRequest.newBuilder(uri).GET().build();
                HttpResponse<String> resp = client.send(req, HttpResponse.BodyHandlers.ofString());
                return resp.body();
            }))
            .collect(Collectors.toList());

        for (Future<String> f : futures) {
            System.out.println(f.get());
        }
        executor.shutdown();
    }
}

비교와 실무 팁

두 접근 방식의 차이와 고려사항:

  • 리소스 사용: 비동기 방식은 스레드 수를 적게 유지하면서 많은 동시 요청을 처리할 수 있습니다. 블로킹 방식은 스레드당 스택과 컨텍스트 전환 비용이 발생합니다.
  • 오류 처리: CompletableFuture 체인은 예외 전파를 명시적으로 다뤄야 하며, 복잡한 조합은 가독성을 해칠 수 있습니다. 블로킹 방식은 전통적 try/catch 패턴이 직관적입니다.
  • 백프레셔와 제어: 고부하 환경에서는 요청 수를 제한하거나 큐잉 전략을 두어야 합니다. Reactive 라이브러리는 백프레셔를 제공하지만 설계 복잡도가 증가합니다.
  • 운영 고려사항: 프로파일링(멀티쓰레드 시 GC/락 컨텐션), 네트워크 타임아웃, 재시도/회로 차단 패턴을 적용하세요.

결론

Java는 다양한 런타임 옵션과 언어 기능을 통해 광범위한 워크로드를 처리할 수 있습니다. 중요한 것은 특정 워크로드의 요구(지연 허용치, 처리량, 메모리 한계, 개발 복잡도)를 명확히 하고 그에 맞는 실행 모델과 구성(GC, 쓰레드풀, 비동기 패턴 등)을 선택하는 것입니다. 코드와 운영 환경을 함께 고려한 프로파일링과 점진적 튜닝이 실무에서는 가장 효과적입니다.