본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2026. 2. 1. · 7 Views
MCP 서버 구현 WebFlux 완벽 가이드
Spring WebFlux를 활용한 MCP(Model Context Protocol) 서버 구현 방법을 다룹니다. Reactive Programming의 기초부터 비동기 스트림 처리, Backpressure 관리까지 실무에서 바로 활용할 수 있는 내용을 담았습니다.
목차
1. Reactive Programming 기초
어느 날 김개발 씨는 회사에서 새로운 프로젝트를 맡게 되었습니다. "이번 프로젝트는 WebFlux로 진행합니다"라는 팀장님의 말씀에 김개발 씨는 고개를 갸우뚱했습니다.
동기 방식의 Spring MVC만 사용해왔던 그에게 Reactive Programming이란 낯선 세계였습니다.
Reactive Programming은 데이터 흐름과 변화의 전파에 중점을 둔 프로그래밍 패러다임입니다. 마치 엑셀 스프레드시트에서 한 셀의 값이 바뀌면 연결된 다른 셀들이 자동으로 업데이트되는 것과 같습니다.
이 방식을 이해하면 비동기 데이터 스트림을 우아하게 처리할 수 있습니다.
다음 코드를 살펴봅시다.
// Reactive Streams의 핵심 인터페이스
public interface Publisher<T> {
void subscribe(Subscriber<? super T> subscriber);
}
public interface Subscriber<T> {
void onSubscribe(Subscription subscription);
void onNext(T item); // 데이터가 도착할 때마다 호출
void onError(Throwable t); // 에러 발생 시 호출
void onComplete(); // 스트림 완료 시 호출
}
// Mono: 0-1개의 데이터를 다루는 Publisher
Mono<String> mono = Mono.just("Hello MCP");
// Flux: 0-N개의 데이터를 다루는 Publisher
Flux<Integer> flux = Flux.just(1, 2, 3, 4, 5);
김개발 씨는 입사 2년 차 백엔드 개발자입니다. 그동안 Spring MVC로 REST API를 개발해왔고, 나름 자신감도 생겼습니다.
그런데 오늘 팀장님이 던진 한마디가 그의 자신감을 흔들어 놓았습니다. "이번 MCP 서버는 WebFlux로 구현해주세요." 점심시간에 선배 개발자 박시니어 씨에게 조심스럽게 물었습니다.
"선배님, Reactive Programming이 뭔가요? 왜 굳이 익숙한 MVC 대신 WebFlux를 쓰는 거죠?" 박시니어 씨가 커피를 한 모금 마시며 말했습니다.
"좋은 질문이야. 우리가 만들 MCP 서버는 AI 모델과 통신하면서 동시에 수많은 클라이언트 요청을 처리해야 해.
기존 방식으로는 한계가 있거든." 그렇다면 Reactive Programming이란 정확히 무엇일까요? 쉽게 비유하자면, 기존의 동기식 프로그래밍은 마치 식당에서 주문을 받고 음식이 나올 때까지 카운터 앞에서 기다리는 것과 같습니다.
반면 Reactive Programming은 진동벨을 받고 자리에서 다른 일을 하다가 벨이 울리면 음식을 가져오는 방식입니다. 기다리는 동안 아무것도 못하는 것과 다른 일을 할 수 있는 것, 이 차이가 바로 핵심입니다.
Reactive Programming이 없던 시절에는 어땠을까요? 개발자들은 요청마다 스레드를 하나씩 할당해야 했습니다.
100명의 사용자가 동시에 접속하면 100개의 스레드가 필요했죠. 문제는 각 스레드가 I/O 작업을 기다리는 동안에도 메모리를 차지하고 있다는 점입니다.
사용자가 10,000명으로 늘어나면 어떻게 될까요? 서버는 금세 자원이 고갈되어 버립니다.
바로 이런 문제를 해결하기 위해 Reactive Programming이 등장했습니다. Reactive의 핵심은 Publisher와 Subscriber 관계입니다.
Publisher는 데이터를 생산하고, Subscriber는 그 데이터를 소비합니다. 그리고 이 둘 사이에 Subscription이라는 계약이 존재합니다.
Subscriber가 "나 준비됐어, 데이터 줘"라고 요청하면 그때 Publisher가 데이터를 보내는 방식입니다. 위의 코드를 살펴보겠습니다.
Mono는 0개 또는 1개의 데이터를 다루는 Publisher입니다. 데이터베이스에서 사용자 한 명을 조회하거나, 단일 결과를 반환하는 경우에 사용합니다.
Flux는 0개부터 N개까지의 데이터를 다룹니다. 목록을 조회하거나, 실시간 스트리밍 데이터를 처리할 때 적합합니다.
실제 현업에서는 어떻게 활용할까요? MCP 서버를 예로 들어봅시다.
AI 모델에 프롬프트를 보내고 응답을 받는 과정은 시간이 오래 걸립니다. 이때 Reactive 방식을 사용하면 응답을 기다리는 동안 다른 요청을 처리할 수 있습니다.
한 대의 서버로 훨씬 많은 동시 요청을 감당할 수 있게 되는 것입니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수는 Reactive 코드를 동기식으로 블로킹하는 것입니다. mono.block()이나 flux.collectList().block()을 남발하면 Reactive의 장점이 사라집니다.
반드시 구독(subscribe) 방식으로 데이터를 처리해야 합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.
박시니어 씨의 설명을 들은 김개발 씨는 고개를 끄덕였습니다. "아, 그래서 WebFlux를 쓰는 거군요.
더 적은 자원으로 더 많은 요청을 처리할 수 있으니까요!"
실전 팁
💡 - Mono.block()은 테스트 코드에서만 사용하고, 프로덕션 코드에서는 절대 사용하지 마세요
- Reactive 코드를 디버깅할 때는 .log() 연산자를 활용하면 데이터 흐름을 추적할 수 있습니다
2. WebFlux 기반 MCP 서버
Reactive Programming의 개념을 이해한 김개발 씨는 본격적으로 MCP 서버 구현에 들어갔습니다. 그런데 막상 코드를 작성하려니 어디서부터 시작해야 할지 막막했습니다.
"MCP 프로토콜을 WebFlux에서 어떻게 구현하죠?"라는 질문에 박시니어 씨가 화이트보드 앞으로 다가갔습니다.
MCP 서버는 AI 모델과 클라이언트 사이에서 컨텍스트를 관리하는 중간 계층입니다. WebFlux를 사용하면 비동기 방식으로 여러 AI 요청을 동시에 처리할 수 있습니다.
RouterFunction과 HandlerFunction을 활용하여 함수형 엔드포인트를 구성하는 것이 WebFlux의 특징입니다.
다음 코드를 살펴봅시다.
@Configuration
public class McpRouterConfig {
@Bean
public RouterFunction<ServerResponse> mcpRoutes(McpHandler handler) {
return RouterFunctions.route()
// MCP 프로토콜 엔드포인트 정의
.POST("/mcp/initialize", handler::initialize)
.POST("/mcp/tools/list", handler::listTools)
.POST("/mcp/tools/call", handler::callTool)
.POST("/mcp/resources/list", handler::listResources)
.POST("/mcp/prompts/list", handler::listPrompts)
.build();
}
}
@Component
public class McpHandler {
public Mono<ServerResponse> initialize(ServerRequest request) {
return request.bodyToMono(InitializeRequest.class)
.map(req -> new InitializeResponse("1.0", getCapabilities()))
.flatMap(response -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(response));
}
}
김개발 씨는 화이트보드에 그려지는 아키텍처 다이어그램을 유심히 살펴보았습니다. 박시니어 씨가 설명을 이어갔습니다.
"MCP, 그러니까 Model Context Protocol은 AI 모델이 외부 도구나 리소스에 접근할 수 있게 해주는 표준 프로토콜이야." 그렇다면 WebFlux로 MCP 서버를 어떻게 구현할까요? 먼저 RouterFunction에 대해 알아봅시다.
Spring MVC에서는 @Controller와 @RequestMapping 어노테이션으로 라우팅을 정의합니다. 반면 WebFlux에서는 함수형 방식으로 라우팅을 구성할 수 있습니다.
마치 레고 블록을 조립하듯이 route()를 시작으로 POST(), GET() 같은 메서드를 체이닝하여 엔드포인트를 정의합니다. MCP 프로토콜은 여러 가지 엔드포인트를 요구합니다.
/mcp/initialize는 클라이언트가 서버와 연결을 시작할 때 호출됩니다. 서버의 버전과 지원하는 기능(capabilities)을 반환합니다.
/mcp/tools/list는 서버가 제공하는 도구 목록을 반환하고, /mcp/tools/call은 실제로 도구를 실행합니다. 리소스와 프롬프트도 비슷한 패턴을 따릅니다.
위의 코드에서 McpHandler 클래스를 자세히 살펴보겠습니다. initialize 메서드는 요청 본문을 Mono<InitializeRequest>로 변환합니다.
이것이 바로 Reactive의 힘입니다. 요청 본문을 읽는 I/O 작업이 블로킹 없이 비동기로 처리됩니다.
map 연산자로 응답 객체를 생성하고, flatMap으로 ServerResponse를 만들어 반환합니다. 실제 현업에서 MCP 서버는 어떻게 활용될까요?
예를 들어 코딩 어시스턴트 서비스를 개발한다고 가정해봅시다. 사용자가 "이 코드의 버그를 찾아줘"라고 요청하면, AI 모델은 MCP 서버를 통해 파일 시스템에 접근하고, 코드 분석 도구를 호출하고, 검색 엔진을 사용할 수 있습니다.
이 모든 외부 연동이 MCP 서버를 거쳐 이루어집니다. WebFlux를 선택한 이유는 무엇일까요?
AI 모델의 응답은 보통 수 초에서 수십 초까지 걸립니다. 동기 방식이라면 이 시간 동안 스레드가 대기 상태로 묶여 있어야 합니다.
하지만 WebFlux의 비동기 방식에서는 응답을 기다리는 동안 같은 스레드가 다른 요청을 처리할 수 있습니다. 결과적으로 훨씬 적은 서버 자원으로 많은 동시 요청을 감당할 수 있게 됩니다.
하지만 주의할 점도 있습니다. WebFlux의 함수형 라우팅은 컴파일 시점에 URL 매핑 오류를 잡아내지 못합니다.
따라서 통합 테스트를 꼼꼼히 작성해야 합니다. 또한 모든 핸들러 메서드가 Mono나 Flux를 반환해야 한다는 점을 잊지 마세요.
김개발 씨가 물었습니다. "그런데 선배님, 어노테이션 방식이 더 익숙한데 꼭 함수형으로 해야 하나요?" 박시니어 씨가 웃으며 답했습니다.
"아니, WebFlux도 @RestController를 지원해. 하지만 함수형 방식이 라우팅을 한눈에 파악하기 좋고, 테스트하기도 편해.
일단 익숙해지면 더 선호하게 될 거야."
실전 팁
💡 - RouterFunction은 별도의 @Configuration 클래스에 모아두면 라우팅 구조를 한눈에 파악할 수 있습니다
- Handler 메서드에서 예외가 발생하면 onErrorResume으로 적절한 에러 응답을 반환하세요
3. 비동기 스트림 처리
MCP 서버의 기본 구조를 잡은 김개발 씨는 새로운 요구사항과 마주했습니다. "AI 모델의 응답을 스트리밍으로 전달해야 해요.
사용자가 답변이 생성되는 걸 실시간으로 볼 수 있도록요." 이 말을 듣자마자 김개발 씨는 걱정이 앞섰습니다. 스트리밍이라니, 어떻게 구현해야 할까요?
비동기 스트림 처리는 데이터가 생성되는 대로 즉시 클라이언트에 전달하는 방식입니다. WebFlux의 Flux를 활용하면 AI 모델의 응답을 토큰 단위로 스트리밍할 수 있습니다.
Server-Sent Events(SSE)나 WebSocket을 통해 실시간 데이터 전송이 가능합니다.
다음 코드를 살펴봅시다.
@Component
public class McpStreamHandler {
private final AiModelClient aiClient;
// SSE를 통한 스트리밍 응답
public Mono<ServerResponse> streamToolCall(ServerRequest request) {
return request.bodyToMono(ToolCallRequest.class)
.flatMap(req -> {
// AI 모델에서 스트리밍 응답을 받아 Flux로 변환
Flux<String> tokenStream = aiClient.streamCompletion(req.getPrompt())
.map(token -> formatSseEvent(token));
return ServerResponse.ok()
.contentType(MediaType.TEXT_EVENT_STREAM)
.body(tokenStream, String.class);
});
}
// Flux 연산자를 활용한 데이터 변환
public Flux<ToolResult> processMultipleTools(List<ToolRequest> requests) {
return Flux.fromIterable(requests)
.flatMap(req -> executeTool(req), 4) // 최대 4개 동시 실행
.filter(result -> result.isSuccess())
.timeout(Duration.ofSeconds(30));
}
}
김개발 씨는 ChatGPT를 떠올렸습니다. 질문을 하면 답변이 한 글자씩 화면에 나타나는 그 경험 말입니다.
"아, 저걸 구현해야 하는 거구나." 막막했지만 WebFlux의 Flux가 바로 이런 상황을 위해 존재한다는 걸 곧 알게 되었습니다. 그렇다면 스트리밍은 어떻게 동작할까요?
쉽게 비유하자면, 일반적인 HTTP 응답은 택배 배송과 같습니다. 물건이 모두 포장되어야 배송이 시작됩니다.
반면 스트리밍은 컨베이어 벨트와 같습니다. 물건이 생산되는 족족 바로 전달됩니다.
AI 모델이 토큰을 하나 생성할 때마다 바로 클라이언트에게 보내는 것이죠. **Server-Sent Events(SSE)**는 이런 스트리밍을 위한 표준 프로토콜입니다.
HTTP 연결을 유지한 채로 서버가 클라이언트에게 이벤트를 계속 보낼 수 있습니다. WebSocket보다 구현이 간단하고, HTTP/2와도 잘 호환됩니다.
무엇보다 브라우저에서 EventSource API로 쉽게 수신할 수 있다는 장점이 있습니다. 위의 코드에서 streamToolCall 메서드를 살펴보겠습니다.
aiClient.streamCompletion()은 AI 모델에서 토큰을 하나씩 받아오는 Flux를 반환합니다. 각 토큰은 map 연산자를 통해 SSE 형식으로 변환됩니다.
그리고 ServerResponse.ok().contentType(MediaType.TEXT_EVENT_STREAM)으로 SSE 응답임을 명시합니다. 클라이언트는 이 응답을 구독하면서 토큰이 도착할 때마다 화면을 업데이트합니다.
processMultipleTools 메서드는 또 다른 패턴을 보여줍니다. Flux.fromIterable()로 요청 목록을 Flux로 변환하고, flatMap으로 각 요청을 처리합니다.
여기서 두 번째 인자 4는 동시성(concurrency)을 의미합니다. 최대 4개의 도구 호출이 동시에 실행됩니다.
filter로 성공한 결과만 통과시키고, timeout으로 30초 제한을 겁니다. 실제 MCP 서버에서는 이런 스트리밍이 필수입니다.
사용자가 복잡한 질문을 하면 AI 모델은 답변을 생성하는 데 10초 이상 걸릴 수 있습니다. 전체 답변이 완성될 때까지 기다리면 사용자 경험이 나빠집니다.
하지만 생성되는 대로 바로 보여주면 사용자는 응답이 진행 중임을 알 수 있고, 필요 없는 응답이면 중간에 취소할 수도 있습니다. 하지만 주의할 점도 있습니다.
스트리밍 연결은 오래 유지됩니다. 클라이언트가 연결을 끊었는데 서버가 계속 데이터를 보내면 자원 낭비입니다.
doOnCancel 연산자로 취소 시 정리 작업을 수행하세요. 또한 네트워크 불안정으로 중간에 연결이 끊길 수 있으니 클라이언트에서 재연결 로직을 구현해야 합니다.
김개발 씨가 테스트를 마치고 환호했습니다. "와, 진짜 글자가 하나씩 나타나요!" 박시니어 씨가 덧붙였습니다.
"Flux의 연산자들을 잘 활용하면 복잡한 스트림 처리도 선언적으로 표현할 수 있어. 마치 SQL처럼 말이야."
실전 팁
💡 - SSE 응답에서는 각 이벤트를 "data: {내용}\n\n" 형식으로 포맷해야 합니다
- 스트리밍 중 에러가 발생하면 onErrorResume으로 에러 이벤트를 보내고 스트림을 종료하세요
4. Backpressure 관리
스트리밍 기능을 구현한 김개발 씨는 부하 테스트를 돌려보았습니다. 100명의 동시 사용자까지는 문제없었는데, 1000명이 되자 서버가 버벅거리기 시작했습니다.
메모리 사용량이 급증하고, 응답 시간이 늘어났습니다. "왜 이러지?" 당황한 김개발 씨에게 박시니어 씨가 한마디 했습니다.
"Backpressure를 고려했어?"
Backpressure는 데이터 생산 속도가 소비 속도를 초과할 때 발생하는 문제를 제어하는 메커니즘입니다. WebFlux에서는 Subscriber가 처리할 수 있는 만큼만 Publisher에게 데이터를 요청하는 방식으로 Backpressure를 관리합니다.
이를 통해 메모리 고갈을 방지하고 시스템 안정성을 확보할 수 있습니다.
다음 코드를 살펴봅시다.
@Component
public class BackpressureAwareMcpHandler {
// Backpressure 전략을 적용한 스트림 처리
public Flux<ToolResult> processWithBackpressure(Flux<ToolRequest> requests) {
return requests
.onBackpressureBuffer(100,
dropped -> log.warn("Request dropped: {}", dropped),
BufferOverflowStrategy.DROP_OLDEST)
.flatMap(this::executeTool, 10) // 동시성 제한
.limitRate(50); // 요청 속도 제한
}
// 배치 처리로 부하 분산
public Flux<List<ToolResult>> batchProcess(Flux<ToolRequest> requests) {
return requests
.bufferTimeout(10, Duration.ofMillis(100)) // 10개씩 또는 100ms마다
.flatMap(batch -> processToolBatch(batch), 2);
}
// 재시도와 타임아웃 설정
public Mono<ToolResult> executeWithResilience(ToolRequest request) {
return executeTool(request)
.timeout(Duration.ofSeconds(10))
.retryWhen(Retry.backoff(3, Duration.ofMillis(100))
.filter(ex -> ex instanceof TimeoutException));
}
}
김개발 씨는 고개를 갸우뚱했습니다. "Backpressure요?
그게 뭔가요?" 박시니어 씨가 칠판에 그림을 그리기 시작했습니다. 그렇다면 Backpressure란 정확히 무엇일까요?
쉽게 비유하자면, 수도꼭지에서 물이 쏟아지는데 컵이 작다고 상상해보세요. 물이 넘쳐흐르겠죠.
이때 물을 버리거나, 수도꼭지를 잠그거나, 더 큰 통을 가져오거나 해야 합니다. Backpressure는 바로 이런 상황을 다루는 방법입니다.
데이터가 너무 빨리 생산되어 소비자가 따라가지 못할 때 어떻게 대응할지를 정하는 것이죠. Backpressure가 없으면 어떤 일이 벌어질까요?
1000명의 클라이언트가 동시에 AI 도구 호출을 요청한다고 가정해봅시다. 서버는 모든 요청을 메모리에 쌓아두고 처리하려 합니다.
하지만 AI 모델 호출은 느립니다. 요청은 계속 쌓이고, 메모리는 금세 바닥납니다.
결국 OutOfMemoryError가 발생하거나, 가비지 컬렉션이 과도하게 일어나 서버가 멈춰버립니다. WebFlux는 여러 가지 Backpressure 전략을 제공합니다.
onBackpressureBuffer는 버퍼를 두고 잠시 요청을 쌓아두는 방식입니다. 버퍼가 가득 차면 가장 오래된 것을 버리거나(DROP_OLDEST), 가장 최신 것을 버리거나(DROP_LATEST), 에러를 발생시킬 수 있습니다.
limitRate는 Subscriber가 요청하는 데이터 개수를 제한합니다. 위 코드에서 limitRate(50)은 한 번에 50개씩만 데이터를 요청한다는 의미입니다.
flatMap의 동시성 제한도 중요한 전략입니다. flatMap(this::executeTool, 10)에서 두 번째 인자 10은 동시에 실행되는 내부 스트림의 개수를 제한합니다.
1000개의 요청이 들어와도 한 번에 10개씩만 처리됩니다. 나머지는 대기 큐에서 차례를 기다립니다.
이렇게 하면 시스템 자원을 예측 가능한 범위 내에서 사용할 수 있습니다. 배치 처리도 효과적인 방법입니다.
bufferTimeout(10, Duration.ofMillis(100))은 요청을 10개씩 모아서 처리하거나, 100밀리초마다 모인 것들을 처리합니다. 개별 처리보다 배치 처리가 효율적인 경우가 많습니다.
데이터베이스 조회나 외부 API 호출 횟수를 줄일 수 있기 때문입니다. 마지막으로 회복력(Resilience) 패턴도 살펴봅시다.
네트워크는 불안정합니다. AI 모델 서버가 일시적으로 응답하지 않을 수도 있습니다.
timeout으로 최대 대기 시간을 설정하고, retryWhen으로 실패 시 재시도 로직을 구현합니다. exponential backoff는 재시도 간격을 점점 늘리는 방식으로, 서버에 과도한 부하를 주지 않으면서 복구를 시도합니다.
김개발 씨가 Backpressure 설정을 추가하고 다시 부하 테스트를 돌렸습니다. 이번에는 1000명이 접속해도 메모리가 안정적으로 유지되었습니다.
"와, 이게 이렇게 차이가 나는군요!" 박시니어 씨가 고개를 끄덕였습니다. "Reactive의 진정한 힘은 Backpressure에 있어.
이걸 제대로 다루지 않으면 프로덕션에서 큰일 나."
실전 팁
💡 - 부하 테스트 시 반드시 메모리 사용량과 GC 횟수를 모니터링하세요
- flatMap 동시성은 외부 시스템의 처리 능력에 맞춰 설정해야 합니다
5. 성능 최적화
Backpressure 문제를 해결한 김개발 씨는 이제 성능 최적화에 관심을 갖게 되었습니다. "현재 초당 500 요청을 처리하는데, 1000까지 올릴 수 있을까요?" 팀장님의 질문에 김개발 씨는 WebFlux의 성능 튜닝 포인트들을 조사하기 시작했습니다.
WebFlux 성능 최적화는 이벤트 루프 활용, 캐싱, 커넥션 풀 관리, 그리고 적절한 연산자 선택을 포함합니다. Netty 기반의 WebFlux는 적은 스레드로 높은 동시성을 달성할 수 있지만, 블로킹 코드가 섞이면 성능이 급격히 저하됩니다.
프로파일링과 모니터링을 통해 병목 지점을 찾아 개선해야 합니다.
다음 코드를 살펴봅시다.
@Configuration
public class WebFluxOptimizationConfig {
// WebClient 커넥션 풀 최적화
@Bean
public WebClient optimizedWebClient() {
ConnectionProvider provider = ConnectionProvider.builder("ai-pool")
.maxConnections(200)
.maxIdleTime(Duration.ofSeconds(30))
.maxLifeTime(Duration.ofMinutes(5))
.pendingAcquireTimeout(Duration.ofSeconds(10))
.build();
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(
HttpClient.create(provider)
.responseTimeout(Duration.ofSeconds(30))
.compress(true)))
.build();
}
// 캐시를 활용한 성능 개선
@Bean
public CacheManager reactiveCacheManager() {
return new ConcurrentMapCacheManager("tools", "prompts", "resources");
}
}
@Component
public class OptimizedMcpService {
@Cacheable(value = "tools", key = "#serverId")
public Mono<List<Tool>> getToolsCached(String serverId) {
return fetchToolsFromRemote(serverId)
.cache(Duration.ofMinutes(5)); // Reactor 캐시
}
}
김개발 씨는 성능 측정 도구를 설치하고 프로파일링을 시작했습니다. 어디가 병목인지 찾아야 했습니다.
결과를 보니 AI 모델 서버와의 HTTP 통신에서 가장 많은 시간이 소요되고 있었습니다. 그렇다면 WebFlux 성능 최적화는 어떻게 해야 할까요?
첫 번째는 커넥션 풀 최적화입니다. HTTP 연결을 맺는 것은 비용이 큰 작업입니다.
TCP 핸드셰이크, TLS 협상 등이 필요하기 때문입니다. 매 요청마다 새 연결을 맺으면 큰 오버헤드가 발생합니다.
커넥션 풀을 사용하면 연결을 재사용할 수 있습니다. 위 코드의 ConnectionProvider 설정을 살펴봅시다.
maxConnections(200)은 최대 200개의 연결을 유지합니다. maxIdleTime은 유휴 연결의 최대 대기 시간, maxLifeTime은 연결의 최대 수명입니다.
오래된 연결은 문제를 일으킬 수 있으므로 주기적으로 새로 고칩니다. pendingAcquireTimeout은 풀에서 연결을 얻기 위해 대기하는 최대 시간입니다.
두 번째는 캐싱입니다. MCP 서버에서 도구 목록이나 프롬프트 템플릿은 자주 변하지 않습니다.
매번 원격 서버에서 조회하는 대신 캐시에 저장해두면 응답 시간을 크게 줄일 수 있습니다. Spring의 @Cacheable과 Reactor의 cache() 연산자를 함께 활용하면 효과적입니다.
세 번째는 블로킹 코드 제거입니다. WebFlux의 이벤트 루프 스레드에서 블로킹 작업을 수행하면 전체 시스템이 멈춰버립니다.
JDBC 같은 블로킹 데이터베이스 드라이버나, 동기식 파일 I/O는 절대 사용하면 안 됩니다. 만약 어쩔 수 없이 블로킹 코드를 호출해야 한다면 Schedulers.boundedElastic()으로 별도 스레드 풀에서 실행해야 합니다.
네 번째는 적절한 연산자 선택입니다. flatMap과 concatMap은 비슷해 보이지만 동작이 다릅니다.
flatMap은 순서를 보장하지 않지만 동시에 여러 내부 스트림을 처리합니다. concatMap은 순서를 보장하지만 하나씩 처리합니다.
성능이 중요하고 순서가 상관없다면 flatMap을, 순서가 중요하다면 concatMap을 선택하세요. 모니터링도 빠뜨릴 수 없습니다.
Micrometer와 Prometheus를 연동하면 요청 처리 시간, 활성 연결 수, 에러율 등을 실시간으로 모니터링할 수 있습니다. Reactor의 Hooks.onOperatorDebug()를 활성화하면 스택 트레이스가 더 명확해져 디버깅에 도움이 됩니다.
단, 프로덕션에서는 성능 영향이 있으니 개발 환경에서만 사용하세요. 김개발 씨는 커넥션 풀을 최적화하고 캐싱을 적용한 후 다시 성능 테스트를 돌렸습니다.
초당 처리량이 500에서 1200으로 늘어났습니다. "생각보다 간단한 설정 변경으로 이렇게 성능이 좋아지다니요!" 박시니어 씨가 말했습니다.
"성능 최적화의 80%는 올바른 설정에서 나와. 코드를 복잡하게 바꾸기 전에 항상 설정부터 점검해봐."
실전 팁
💡 - WebClient 인스턴스는 재사용하세요. 매번 새로 생성하면 커넥션 풀 효과가 사라집니다
- 블로킹 코드 감지를 위해 BlockHound 라이브러리를 테스트 환경에 적용해보세요
6. WebMVC vs WebFlux 비교
프로젝트가 성공적으로 마무리되자, 후배 개발자가 김개발 씨에게 질문했습니다. "선배님, 그러면 앞으로 모든 프로젝트를 WebFlux로 해야 하나요?" 김개발 씨는 잠시 생각에 잠겼습니다.
과연 WebFlux가 항상 정답일까요?
WebMVC와 WebFlux는 각각의 장단점이 있습니다. WebMVC는 동기식 블로킹 모델로 이해하기 쉽고 풍부한 생태계를 가졌습니다.
WebFlux는 비동기 논블로킹으로 높은 동시성에 유리하지만 학습 곡선이 가파릅니다. 프로젝트의 특성에 따라 적절한 기술을 선택해야 합니다.
다음 코드를 살펴봅시다.
// WebMVC 방식 - 동기식, 직관적
@RestController
public class MvcController {
@PostMapping("/mcp/tools/call")
public ToolResult callTool(@RequestBody ToolRequest request) {
// 블로킹 호출 - 스레드가 응답을 기다림
return toolService.execute(request);
}
}
// WebFlux 방식 - 비동기식, 리액티브
@RestController
public class FluxController {
@PostMapping("/mcp/tools/call")
public Mono<ToolResult> callTool(@RequestBody ToolRequest request) {
// 논블로킹 - 스레드가 다른 작업 수행 가능
return toolService.executeReactive(request);
}
}
// 선택 기준 요약
// WebMVC: JDBC 사용, 단순한 CRUD, 팀의 Reactive 경험 부족
// WebFlux: 높은 동시성, 스트리밍, 마이크로서비스 간 통신
김개발 씨는 후배의 질문에 답하기 위해 그동안의 경험을 정리해보았습니다. MCP 서버 개발을 통해 WebFlux의 강점을 확실히 느꼈지만, 동시에 어려움도 많았습니다.
WebMVC의 장점부터 살펴봅시다. 첫째, 이해하기 쉽습니다.
코드가 위에서 아래로 순차적으로 실행되므로 흐름을 따라가기 쉽습니다. 디버깅할 때 스택 트레이스도 명확합니다.
둘째, 생태계가 풍부합니다. 대부분의 라이브러리와 프레임워크가 동기식을 기본으로 지원합니다.
JDBC, JPA 같은 블로킹 기술과 자연스럽게 통합됩니다. 셋째, 팀의 학습 비용이 낮습니다.
대부분의 개발자가 동기식 프로그래밍에 익숙합니다. WebFlux의 장점은 무엇일까요?
첫째, 높은 동시성입니다. 적은 스레드로 많은 요청을 처리할 수 있습니다.
I/O 바운드 작업이 많은 경우 특히 효과적입니다. 둘째, 자원 효율성입니다.
스레드 풀 크기를 늘리지 않고도 동시 사용자를 늘릴 수 있습니다. 메모리 사용량도 예측 가능합니다.
셋째, 스트리밍 지원입니다. SSE나 WebSocket을 통한 실시간 데이터 전송이 자연스럽습니다.
그렇다면 언제 WebFlux를 선택해야 할까요? MCP 서버처럼 AI 모델과 통신하고, 동시에 많은 클라이언트를 처리해야 하며, 스트리밍 응답이 필요한 경우 WebFlux가 적합합니다.
마이크로서비스 아키텍처에서 서비스 간 통신이 빈번한 경우에도 좋습니다. 반면 단순한 CRUD API, 복잡한 비즈니스 로직, 팀의 Reactive 경험이 부족한 경우에는 WebMVC가 나은 선택입니다.
혼합 사용도 가능합니다. Spring Boot 애플리케이션에서 WebMVC와 WebFlux를 동시에 사용할 수는 없지만, 마이크로서비스 아키텍처에서는 서비스별로 다른 기술을 선택할 수 있습니다.
외부 API 게이트웨이는 WebFlux로, 내부 비즈니스 서비스는 WebMVC로 구현하는 식입니다. 주의해야 할 함정도 있습니다.
WebFlux를 사용한다고 무조건 성능이 좋아지는 것은 아닙니다. CPU 바운드 작업이 많거나, 블로킹 라이브러리를 사용해야 하는 경우 오히려 복잡성만 늘어납니다.
또한 Reactive 코드는 디버깅이 어렵습니다. 스택 트레이스가 이벤트 루프를 거치면서 끊기기 때문입니다.
김개발 씨가 후배에게 말했습니다. "결론적으로, 은탄환은 없어.
프로젝트의 요구사항, 팀의 역량, 사용할 라이브러리를 종합적으로 고려해서 선택해야 해. 이번 MCP 프로젝트는 AI 응답 스트리밍과 높은 동시성이 필요했기 때문에 WebFlux가 맞았던 거야." 후배 개발자가 고개를 끄덕였습니다.
"아, 그렇군요. 기술 선택에도 트레이드오프가 있는 거네요." 김개발 씨가 웃으며 답했습니다.
"맞아. 좋은 개발자는 상황에 맞는 도구를 선택할 줄 아는 사람이야.
그러니까 WebMVC도 WebFlux도 둘 다 잘 알아두는 게 좋아."
실전 팁
💡 - 새 프로젝트 시작 전에 팀원들의 Reactive 경험을 파악하고, 필요하다면 학습 기간을 확보하세요
- 기존 WebMVC 프로젝트를 WebFlux로 전환하는 것은 비용이 크므로 신중하게 판단하세요
이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!
댓글 (0)
함께 보면 좋은 카드 뉴스
실전 MCP 통합 프로젝트 완벽 가이드
Model Context Protocol을 활용한 실전 통합 프로젝트를 처음부터 끝까지 구축하는 방법을 다룹니다. 아키텍처 설계부터 멀티 서버 통합, 모니터링, 배포까지 운영 레벨의 MCP 시스템을 구축하는 노하우를 담았습니다.
MCP 동적 도구 업데이트 완벽 가이드
AI 에이전트의 도구를 런타임에 동적으로 로딩하고 관리하는 방법을 알아봅니다. 플러그인 시스템 설계부터 핫 리로딩, 보안까지 실무에서 바로 적용할 수 있는 내용을 다룹니다.
MCP Sampling 완벽 가이드
Model Context Protocol의 Sampling 기능을 통해 AI 응답의 품질과 다양성을 제어하는 방법을 알아봅니다. 프롬프트 샘플링부터 A/B 테스팅까지 실무에서 바로 적용할 수 있는 기법을 다룹니다.
MCP 어노테이션 기반 개발 완벽 가이드
Spring AI와 MCP(Model Context Protocol)를 활용한 어노테이션 기반 도구 개발 방법을 알아봅니다. 선언적 프로그래밍으로 AI 에이전트용 도구를 쉽게 만드는 방법을 초급자도 이해할 수 있게 설명합니다.
MCP 클라이언트 구현 완벽 가이드
Model Context Protocol 클라이언트를 Java/Spring 환경에서 구현하는 방법을 다룹니다. 서버 디스커버리부터 멀티 서버 관리까지 실무에서 바로 사용할 수 있는 패턴을 학습합니다.