
서론
Java는 엔터프라이즈부터 임베디드까지 넓게 사용되는 언어이자 플랫폼입니다. 오래 유지되는 시스템을 만들려면 언어와 런타임의 안정적인 개념을 이해하고, 각 선택지의 장단점을 알고 설계에 반영해야 합니다. 여기서는 안정적인 개념과 설계 트레이드오프를 정리하고, 실용적인 비동기/병렬 HTTP 요청 예제를 통해 적용 방식을 보여줍니다.
핵심 개념: 불변성, 예외 처리, 함수형 스타일
안정적인 설계의 기초로 다음 요소들을 권장합니다.
- 불변성(Immutable data): 상태 변경을 줄이면 동시성 버그가 줄고 추론이 쉬워집니다. Java의
record나 final 필드를 이용하면 DTO를 간단히 불변으로 만들 수 있습니다. - 명시적 예외 처리: 체크 예외와 언체크 예외의 적절한 사용, 오류 경로의 로깅과 래핑은 운영 환경에서 문제 원인 파악에 도움이 됩니다.
- 함수형 스타일과 스트림: 컬렉션 변환, 필터링, 집계 작업은 스트림 API와 람다로 간결하게 표현할 수 있습니다. 그러나 무분별한 스트림 체인은 가독성과 디버깅을 해칠 수 있으므로 적절히 분리해야 합니다.
동시성의 절충: 쓰레드, Executor, 비동기
동시성 모델을 선택할 때 흔히 고려하는 축은 다음과 같습니다.
- 단순성 vs 제어: 직접
Thread를 관리하면 구현이 직관적이지만 확장성과 오류 복구에서 한계가 있습니다.ExecutorService는 스레드 풀과 작업 큐를 통해 제어성을 제공합니다. - 블로킹 vs 논블로킹: I/O가 많은 시스템에서는 논블로킹(비동기) API가 스레드 사용 효율을 높일 수 있습니다. 반면 CPU 바운드 작업은 스레드 수를 코어 수에 맞추는 것이 일반적입니다.
- 복잡도 vs 성능: CompletableFuture나 Reactive 라이브러리는 높은 동시성 처리 능력을 제공하지만 모델이 복잡해질 수 있습니다. 간단한 병렬 작업에는 제한된 스레드 풀과 동기/비동기 혼합이 더 실용적일 수 있습니다.
실무에서는 보통 다음과 같은 규칙을 적용합니다: I/O 바운드 작업(네트워크, DB)은 비동기/논블로킹 또는 많은 수의 가벼운 작업을 처리할 수 있는 풀로 처리하고, CPU 바운드 작업은 코어 수 기반으로 제한된 풀을 사용합니다.
JVM과 가비지 컬렉션(GC): 설계 상 고려사항
JVM 런타임은 GC 동작이 애플리케이션 성능에 큰 영향을 줍니다. 설계 시 고려할 점은 다음과 같습니다.
- 지연(latency) vs 처리량: 일부 GC는 낮은 일시 지연을 목표로 하고(예: 저지연 GC), 일부는 전체 처리량을 우선합니다. 서비스의 SLA에 따라 적합한 GC를 선택해야 합니다.
- 힙 설계: 객체 생명주기(짧은 생명 vs 긴 생명)를 이해하면 세대 기반 튜닝(young/old)과 메모리 레이아웃을 최적화할 수 있습니다.
- 객체 할당 최소화: 단기 객체가 많은 워크로드는 GC 오버헤드로 이어집니다. 가능한 경우 객체 재사용, 버퍼 풀링, 프리미티브 배열 사용 등을 고려합니다.
요약하면, 아키텍처 차원에서 불필요한 객체 생성을 줄이고, 지연 요구사항을 기준으로 GC 및 힙 설정을 선택해야 합니다.
실용 예제: HttpClient와 CompletableFuture로 병렬 URL 요청
아래 예제는 여러 URL을 병렬로 조회하는 실용적인 패턴을 보여줍니다. 핵심 아이디어는:
- 불변 데이터를 표현하기 위해
record사용 - 비동기 HttpClient(
java.net.http.HttpClient)와CompletableFuture조합 - 동시성 제한을 위해 제한된
ExecutorService사용 - 예외 처리는 CompletableFuture 체인에서 중앙 집중적으로 처리
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.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
// 간단한 응답 DTO: 불변
public record SimpleResponse(String url, int statusCode, String bodySnippet, Throwable error) { }
public class AsyncFetcher {
private final HttpClient httpClient;
private final ExecutorService executor;
public AsyncFetcher(int maxConcurrency) {
// 제한된 스레드 풀을 사용하여 동시성 제어
this.executor = Executors.newFixedThreadPool(maxConcurrency);
this.httpClient = HttpClient.newBuilder()
.executor(executor) // HttpClient의 비동기 실행에도 같은 풀을 사용
.connectTimeout(Duration.ofSeconds(10))
.build();
}
public CompletableFuture fetch(String url) {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(url))
.GET()
.build();
return httpClient.sendAsync(req, HttpResponse.BodyHandlers.ofString())
.thenApply(resp -> new SimpleResponse(
url, resp.statusCode(),
// 큰 바디를 모두 담지 않고 스니펫만 보관
(resp.body() == null ? "" : resp.body().substring(0, Math.min(200, resp.body().length()))),
null
))
.exceptionally(ex -> new SimpleResponse(url, -1, "", ex));
}
public List fetchAll(List urls) {
var futures = urls.stream()
.map(this::fetch)
.collect(Collectors.toList());
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
return futures.stream()
.map(CompletableFuture::join)
.collect(Collectors.toList());
}
public void shutdown() throws InterruptedException {
executor.shutdown();
if (!executor.awaitTermination(10, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
}
}
// 사용 예(간단히 호출 흐름):
// AsyncFetcher f = new AsyncFetcher(20);
// List results = f.fetchAll(List.of("https://example.com", "https://example.org"));
// f.shutdown();
예제에서 고려한 트레이드오프:
- 장점: 제한된 스레드 풀로 동시성 제어, HttpClient의 비동기 API를 이용해 많은 요청을 효율적으로 처리, 예외는 각 응답에 포함되어 호출자가 후처리 가능
- 단점: 각 비동기 요청이 내부적으로 스레드 풀을 사용하므로 논블로킹 IO의 이점이 제한될 수 있음(런타임 구현에 따라 다름). 또한 매우 많은 요청(수만 건 이상)은 더 경량화된 논블로킹/리액티브 스택을 요구할 수 있음
결론: 실용적 선택의 원칙
아래 원칙을 따르면 Java 시스템 설계에서 흔한 함정을 피할 수 있습니다.
- 요구사항(지연, 처리량, 운영 복잡도)을 먼저 명확히 하라.
- 불변성과 명확한 오류 경로로 안전성을 높여라.
- 동시성 모델은 I/O/CPU 특성에 맞춰 선택하라(간단하면 Executor, 많은 I/O면 비동기/논블로킹 고려).
- JVM/GC 설정은 프로파일링을 통해 튜닝하라. 설계 단계에서 객체 할당을 줄이는 것이 근본적 해결책이다.
이 글의 예제는 작은 규모에서 바로 적용할 수 있는 패턴을 보여줍니다. 실제 서비스에서는 장애 시 재시도 전략, 회로 차단(circuit breaker), 지표/로깅 통합 등 추가 요소를 결합해 운영성을 확보해야 합니다.