Java3 min read

현대 Java 설계 선택: Records, 동시성 모델, 그리고 실용 예제

2026년 10월 5일3 min read작성자 Flature

Java의 안정된 언어/플랫폼 개념(레코드와 불변성, 동시성 도구들)과 각 접근 방식의 장단점을 정리하고, CompletableFuture와 Executor를 사용한 실용적 병렬 API 호출 예제를 통해 설계 트레이드오프를 설명합니다.

현대 Java 설계 선택: Records, 동시성 모델, 그리고 실용 예제

개요

이 글은 Java에서 흔히 맞닥뜨리는 설계 선택 — 데이터 표현(예: record와 불변성)과 동시성 처리(쓰레드, Executor, CompletableFuture 등) — 에 대해 안정적인 개념을 정리하고, 각 접근 방식의 트레이드오프를 설명합니다. 마지막으로 간단한 실무 예제를 통해 비동기 병렬 호출의 구현 패턴을 제시합니다.

핵심 개념 요약

다음 개념들은 Java에서 안정적으로 사용되는 도구입니다.

Records와 불변성

record는 간결한 데이터 캐리어(값 객체)를 만들기 위한 문법적 도구입니다. 불변 필드와 자동 생성된 접근자, equals/hashCode, toString을 제공하므로 DTO나 값 객체에 적합합니다. 불변 객체는 동시성 환경에서 안전성이 높고 reasoning(추론)이 쉬워집니다. 단점은 상태 변경이 필요한 경우 매번 새로운 객체를 만들어야 하므로 복잡한 상태 모델에는 적합하지 않을 수 있습니다.

동시성: Threads, Executor, CompletableFuture

기본 쓰레드는 제어가 어려워 직접 생성/관리 시 리소스 과다/누수 위험이 있습니다. Executor(특히 ThreadPool 기반)는 쓰레드 재사용과 작업 큐를 제공해 안정적인 처리량 제어가 가능합니다. CompletableFuture는 비동기 파이프라인을 구성하고, 예외 처리·결합(thenCompose/thenCombine)·비동기 합성에 유용합니다.

트레이드오프 요약

주요 트레이드오프는 다음과 같습니다.

  • 동기 vs 비동기: 동기는 단순하지만 대기 시간이 긴 작업에서 자원 낭비(블로킹) 발생. 비동기는 복잡도가 증가하지만 높은 동시성을 제공.
  • 블로킹 I/O + 고정 쓰레드풀: 구현이 단순하지만, I/O 대기 시 쓰레드가 묶여 전체 처리량이 줄어들 수 있음.
  • 비동기 논블로킹 I/O (예: NIO/비동기 클라이언트): 높은 확장성, 그러나 복잡한 오류 처리와 흐름 제어가 필요함.
  • 불변 데이터(Record) 사용: 동시성 안전성과 명확성 제공, 하지만 상태 변화가 잦은 도메인에는 비용 발생.
  • ParallelStream: 짧은 작업이나 CPU-bound 작업에 유용하지만 공유 리소스/외부 호출과 함께 쓰면 예기치 못한 스레드풀 경쟁을 유발.

실용 예제: 여러 API 응답 병렬 수집 (CompletableFuture + Executor)

다음 예제는 외부 API(예: 마이크로서비스)의 여러 엔드포인트를 병렬로 호출해 결과를 수집하는 패턴입니다. record를 DTO로 사용하고, ExecutorService로 동작을 제한하며, 타임아웃 처리를 위해 ScheduledExecutorService를 사용합니다.

import java.util.*;
import java.util.concurrent.*;
import java.util.stream.*;

// 간단한 불변 DTO
public record UserProfile(String id, String name) {}

public class ParallelFetcher {
    private final ExecutorService executor;
    private final ScheduledExecutorService scheduler;

    public ParallelFetcher(ExecutorService executor, ScheduledExecutorService scheduler) {
        this.executor = executor;
        this.scheduler = scheduler;
    }

    // 예시: 외부 호출을 시뮬레이트
    private UserProfile fetchFromRemote(String id) {
        try {
            // 실제 코드에서는 HTTP 클라이언트 호출이 들어감
            Thread.sleep(500); // blocking I/O를 가정
            return new UserProfile(id, "name-" + id);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException(e);
        }
    }

    // 타임아웃을 적용한 CompletableFuture 래퍼
    private CompletableFuture withTimeout(CompletableFuture future, long timeoutMs) {
        CompletableFuture timeout = new CompletableFuture<>();
        scheduler.schedule(() -> timeout.completeExceptionally(new TimeoutException("Timeout after " + timeoutMs + "ms")), timeoutMs, TimeUnit.MILLISECONDS);
        return future.applyToEither(timeout, Function.identity());
    }

    public CompletableFuture> fetchAll(List ids, long perCallTimeoutMs) {
        List> futures = ids.stream()
            .map(id -> CompletableFuture.supplyAsync(() -> fetchFromRemote(id), executor)
                                     .handle((v, ex) -> { // 개별 호출의 예외를 로깅하고 null로 변환
                                         if (ex != null) {
                                             // 실제 코드에서는 로깅
                                             return null;
                                         }
                                         return v;
                                     }))
            .map(f -> withTimeout(f, perCallTimeoutMs))
            .collect(Collectors.toList());

        // 모든 작업이 끝날 때까지 기다리고 결과 수집
        return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]))
            .thenApply(v -> futures.stream()
                .map(CompletableFuture::join) // join은 이미 완료된 future에서 안전
                .filter(Objects::nonNull)     // 실패한 호출은 필터링
                .collect(Collectors.toList()));
    }

    public void shutdown() {
        executor.shutdown();
        scheduler.shutdown();
    }

    // 사용 예시 (main 메서드)
    public static void main(String[] args) throws Exception {
        ExecutorService exec = Executors.newFixedThreadPool(10);
        ScheduledExecutorService sched = Executors.newSingleThreadScheduledExecutor();
        ParallelFetcher fetcher = new ParallelFetcher(exec, sched);

        List ids = Arrays.asList("A", "B", "C", "D");
        CompletableFuture> resultFuture = fetcher.fetchAll(ids, 1000);

        List results = resultFuture.get();
        results.forEach(up -> System.out.println(up));

        fetcher.shutdown();
    }
}

예제에 대한 설명 및 설계 판단

핵심 포인트:

  • record(UserProfile)을 사용해 불변 DTO를 간결하게 표현했습니다.
  • ExecutorService로 동시 작업 수를 제한하여 외부 시스템에 대한 동시 요청 폭주를 제어합니다.
  • 각 호출에 대해 타임아웃을 적용해 한 호출의 지연이 전체 응답을 가로막지 않도록 합니다.
  • 실패한 개별 호출은 null로 처리한 뒤 필터링합니다. 실제 시스템에서는 더 정교한 재시도/회로 차단 패턴을 적용할 수 있습니다.

추가 고려사항

실제 프로덕션 수준에서는 다음을 고려해야 합니다.

  • HTTP 클라이언트는 논블로킹/비동기 클라이언트(예: Reactive WebClient)로 대체해 블로킹 쓰레드 사용을 줄일지 검토.
  • 재시도, 지수 백오프, 회로 차단(circuit breaker) 등 신뢰성 패턴 적용.
  • 모니터링(타임아웃/실패율), 적절한 스레드풀 사이즈 큐 길이 조정.
  • 공유 리소스(데이터베이스, 파일 등)에 대한 동시 접근 제어.

마무리

Java에서의 설계 선택은 단순히 문법적 편의성을 넘어서 성능·안정성·운영 비용에 직접적인 영향을 미칩니다. record와 불변성은 코드의 안전성과 가독성을 높여주며, Executor와 CompletableFuture 조합은 블로킹 기반 I/O 환경에서 비교적 간단하게 높은 동시성을 달성할 수 있는 실용적 패턴입니다. 하지만 높은 확장성과 낮은 지연이 목표라면 논블로킹 I/O와 더 정교한 동시성 모델을 고려해야 합니다.