Non-blocking and Reactive
Non-blocking and Reactive (논블로킹·리액티브)
기본적으로 AI Service 를 호출하면 LLM 호출, 도구 실행, 채팅 메모리 접근, 가드레일이 모두 끝나야 메서드가 반환되면서 호출 스레드를 블로킹해요. 대부분 애플리케이션에선 단순하고 잘 동작하지만, 리액티브 스택(Quarkus/Mutiny, Vert.x, Spring WebFlux)을 쓰거나 한 스레드로 많은 동시 상호작용을 처리해야 한다면 논블로킹 실행이 필요해요. 이 페이지는 LangChain4j 의 네 가지 실행 모드와 그 운영 함의를 다뤄요.
출처: 공식문서
:::note
비동기·리액티브 지원은 실험적이에요. 이 API 는 @Experimental 로 표시되어 있고, 동기·TokenStream API 는 영향을 받지 않아요.
:::
네 가지 모드
AI Service 메서드의 반환 타입만 바꾸면 모드가 달라져요 — 나머지 인터페이스는 그대로예요.
| Return type | Nature |
|---|---|
String, POJO, Result<T>, … |
동기, 호출 스레드 블로킹 |
TokenStream |
콜백 기반 스트리밍 |
CompletableFuture<T>, CompletionStage<T> |
한 번의 응답, 논블로킹 |
Flow.Publisher<AiServiceStreamingEvent>, Flow.Publisher<String> |
이벤트 스트림, 논블로킹 |
interface Assistant {
// synchronous
String chat(String message);
// one response, without blocking the caller
CompletableFuture<String> chatAsync(String message);
// the answer, streamed token by token
Flow.Publisher<String> chatStreaming(String message);
// everything that happens during the interaction, as events
Flow.Publisher<AiServiceStreamingEvent> chatEvents(String message);
}
future 를 취소하면(future.cancel(true)) 호출자를 풀어주고 진행 중인 HTTP 호출을 중단하려 시도해요. future 는 모델 자체 스레드(HTTP 모델은 transport I/O 워커)에서 완성되므로, 명시적 executor 없이 붙인 thenApply/thenAccept 연속 작업은 그 스레드에서 돌아요 — 논블로킹으로 유지하거나 future.thenApplyAsync(fn, executor) 로 자신의 executor 를 넘기세요.
이벤트 스트리밍
Flow.Publisher<AiServiceStreamingEvent> 는 답변 텍스트뿐 아니라 상호작용 전체를 이벤트로 표면화해요: PartialResponseEvent(답변 청크), PartialThinkingEvent(추론 청크), PartialToolCallEvent/CompleteToolCallEvent(도구 호출 조립·완성), BeforeToolExecutionEvent/AfterToolExecutionEvent(도구 실행 전후), IntermediateResponseEvent(한 도구 호출 라운드를 닫은 응답), RetrievedContentsEvent(RAG 콘텐츠), ToolCompensatedEvent(취소·실패 후 보상된 도구), RawEvent(프로바이더 특유 이벤트), FinalResponseEvent(최종 답변).
onNext에서 블로킹하면 안 돼요. 이벤트는 이를 만든 스레드(토큰 레벨 이벤트는 모델 transport I/O 워커, 도구 실행 이벤트는 도구 호출을 완료한 스레드)에서 전달돼요. 무거운 작업은 자신의Executor로 offload 하세요. 이벤트는 경계(buffer)를 거치므로 뒤처진 구독자는 무제한 버퍼링 대신IllegalStateException으로 종료돼요(기본 16384 이벤트,AiServices.builder(...).streamingBufferSize(int)로 설정).
서드파티 리액티브 타입
AI Service 메서드는 JDK 타입(CompletableFuture, Flow.Publisher)을 반환해 특정 리액티브 라이브러리에 묶이지 않아요. Reactor 타입은 langchain4j-reactor 모듈로 지원되요 — 단일 응답엔 Mono<T>, 리액티브엔 Flux<AiServiceStreamingEvent>. 의존성 추가만 하면 어댑터가 ServiceLoader 로 스스로 등록돼요. Mutiny 의 Uni/Multi 는 아직 미지원이고(셈이 있어도 어댑터 미제공), Flux<String> 은 이전 TokenStream 기반 어댑터가 처리하며 모든 프로바이더에서 동작해요.
모든 계층이 논블로킹이어야 함
논블로킹은 모든 계층에서 유지돼야 해요 — 어디서든 한 단계가 블로킹이면 전체 호출이 다시 블로킹돼요. 각 계층은 기존 블로킹 메서드 옆에 비동기 대응판이 있어요: 채팅 모델(chatAsync), 임베딩 모델(embedAsync), 스코어링 모델(scoreAsync), 채팅 메모리·스토어(addAsync/messagesAsync/setAsync, getMessagesAsync/updateMessagesAsync/deleteMessagesAsync), 가드레일(validateAsync), 도구(ToolExecutor.executeAsync), RAG(augmentAsync/retrieveAsync/routeAsync/aggregateAsync/transformAsync), 임베딩 스토어(searchAsync), 웹 검색(searchAsync), MCP(executeToolAsync), HTTP 클라이언트(executeAsync/stream).
대응판을 구현하지 않은 컴포넌트는 조용히 블로킹하는 대신 뚜렷하게 실패해요: 반환된 future·publisher 가 컴포넌트와 빠진 메서드를 명명하는 AsyncNotSupportedException 으로 실패해요. 이 예외는 UnsupportedFeatureException 이어서 한 catch 절로 두 종류의 "미지원"을 모두 잡을 수 있고 재시도되지 않아요.
피할 수 없는 블로킹 코드
도구가 전형적인 사례예요 — DB 나 블로킹 HTTP API 를 호출하는 도구는 논블로킹으로 만들 수 없어요. 이런 도구는 모델 자신의 스레드를 블로킹하지 않도록 offload 돼요.
Java 21+ 에서 offload executor 는 가상 스레드를 만들므로 블로킹 도구가 가상 스레드를 주차시키고 캐리어를 점유하지 않아요. LangChain4j 는 Java 17 을 타깃으로 하는데, Java 17-20 에서는 같은 executor 가 무제한 플랫폼 스레드 풀이 돼요 — 부하 시 리소스 프로파일이 크게 달라져요. 신경 쓰이면 자신의 bounded
Executor를 제공하세요.
비동기·리액티브 모드에서는 도구가 기본으로 동시 실행돼요. 하나씩 실행하려면 단일 스레드 executor 를 넘기세요:
AiServices.builder(Assistant.class)
.chatModel(model)
.tools(new MyTools())
.executeToolsConcurrently(Executors.newSingleThreadExecutor())
.build();
RAG 쪽에서는 retrieveAsync 를 구현하지 않은 리트리버가 조용히 블로킹하는 대신 기본으로 실패하고, DefaultRetrievalAugmentor 나 EmbeddingStoreContentRetriever 에서 offloadBlocking(true) 로 offload 를 선택할 수 있어요.
동기 모드와 다른 기본값
동기 / TokenStream |
CompletableFuture / Flow.Publisher |
|
|---|---|---|
| 여러 도구 호출 | 순차 실행 | 동시 실행 |
| 도구 실행 오류 | LLM 에 다시 보냄 | 호출 실패 |
| 도구 인자 파싱 오류 | 호출 실패 | LLM 에 다시 보냄 |
@Moderate |
지원 | AI Service 생성 시 거부 |
도구 오류 기본값 두 가지는 의도적으로 반대예요. 실행 실패를 LLM 에 보내면 도구의 버그가 숨고 모델이 그 주변에 답을 지어낼 수 있어서 비동기 모드는 호출을 실패시키고, 인자 문자열 오류는 모델이 만든 것이고 알려주면 보통 고칠 수 있어서 실패 대신 다시 보냅니다. 둘 다 설정 가능하고, 명시적으로 구성한 핸들러는 모든 모드에서 쓰여요 (.toolExecutionErrorHandler(...), .toolArgumentsErrorHandler(...)).
executor 제어와 컨텍스트 전파
LangChain4j 가 호출자 스레드 밖에서 작업(동시 도구 호출, offload 검색, 재시도 백오프)을 실행할 때마다 ExecutorProvider SPI 라는 하나의 플러그형 지점에서 executor 를 가져와요. ServiceLoader 로 등록하거나 테스트·비 DI 애플리케이션에선 ExecutorProvider.set(() -> myExecutor) 로 설정해요. 없으면 Java 21+ 는 태스크당 가상 스레드 executor, Java 17-20 은 무제한 플랫폼 스레드 풀을 써요.
한 호출이 이제 여러 스레드를 거치므로 MDC 로깅 컨텍스트, 트레이싱 span, 시큐리티 컨텍스트 같은 ambient
ThreadLocal상태는 자동 전파되지 않아요. 컨텍스트 전파 executor(Quarkus/MicroProfile의ManagedExecutor, Spring 의TaskDecorator-감싼 executor, OpenTelemetry 의Context.taskWrapping(executor), Micrometer 의ContextSnapshot.wrap(executor))를 반환해 작업을 따라가게 하세요.InvocationContext는 영향 없어요 — 스레드로컬이 아니라 파라미터로 명시 전달되니까.
Spring Boot
Flux<String> 은 ❌ 행 포함 모든 프로바이더에서 계속 동작해요 — 이 페이지의 논블로킹 경로가 아니라 langchain4j-reactor 의 TokenStream 기반 어댑터가 서빙하거든요. Mono<T> 와 Flux<AiServiceStreamingEvent> 는 langchain4j-reactor 모듈에서 오고 JDK 타입과 같은 프로바이더 제약을 지녀요.
ambient 컨텍스트가 비동기 호출을 따르게 하려면 LangChain4j 가 기본 executor 대신 애플리케이션 자신의 executor 로 offload 하게 해요:
langchain4j.executor.use-spring-task-executor=true
Spring 의 task executor 는 TaskDecorator 가 설치돼 있으면(Micrometer 컨텍스트 전파와 Spring Security 는 설치함) 트레이싱 span·MDC·시큐리티 컨텍스트를 전파하고, 풀은 spring.task.execution.* 을 따릅니다. 이 설정은 애플리케이션 컨텍스트 단위가 아니라 프로세스 전체라 기본은 꺼져 있어요.
더 알아보기
- Response Streaming — 저수준 스트리밍 핸들러 API
- AI Services — 스트리밍·Flux 반환 타입
- Observability — 리스너 콜백은 블로킹하면 안 되는 이유
- Spring 관측성 빌드: Observability with Spring Boot 3