Tomcat vs Netty
Tomcat과 Netty는 Java 생태계에서 가장 널리 사용되는 두 서버 엔진이다. 둘 다 네트워크 I/O를 처리하지만 설계 철학과 스레드 모델이 근본적으로 다르다. Spring MVC와 Spring WebFlux의 기반이 되는 두 엔진을 이해하면 성능 문제를 더 잘 진단하고 올바른 기술을 선택할 수 있다.
Tomcat 아키텍처
개요
Apache Tomcat은 Java Servlet 명세를 구현한 서블릿 컨테이너이자 웹 서버다. Spring MVC의 기본 내장 서버이며, Thread-Per-Request 모델을 따른다.
내부 컴포넌트
[Client Request]
↓
[Connector] ← HTTP/1.1 또는 HTTP/2 처리
↓
[ProtocolHandler] ← NIO 기반 소켓 처리
↓
[Executor (Thread Pool)] ← 작업 스레드 할당
↓
[Engine → Host → Context → Wrapper]
↓
[Servlet / Filter Chain]
↓
[DispatcherServlet] (Spring MVC)
- Connector: 클라이언트 연결을 받아들이는 입구. HTTP/1.1, HTTP/2, AJP 등 프로토콜 지원
- ProtocolHandler: 실제 소켓 I/O를 처리. NIO, NIO2, APR 방식 선택 가능
- Executor: 요청을 처리하는 스레드 풀
NIO 스레드 모델
Tomcat 8.5부터 NIO가 기본값이다.
[Acceptor Thread (1~2개)]
→ 새 연결을 수락하고 Poller에 등록
[Poller Thread (1~2개)]
→ java.nio.channels.Selector로 I/O 이벤트 감시
→ I/O 준비된 소켓을 Worker Pool로 전달
[Worker Thread Pool (기본 200개)]
→ 실제 HTTP 요청 처리
→ 요청 처리 완료까지 스레드 점유
# Spring Boot application.yml
server:
tomcat:
threads:
max: 200 # 최대 워커 스레드 수
min-spare: 10 # 최소 유지 스레드 수
max-connections: 8192
accept-count: 100
connection-timeout: 20s
Thread-Per-Request의 한계
[문제 시나리오]
maxThreads=200, 외부 API 응답 지연 3초 발생 시:
t=0: 200개 요청 도착 → 200개 스레드 모두 외부 API 대기 중
t=1: 201번째 요청 → accept-count 대기 큐 진입
t=2: 큐도 가득 참 → 503 Service Unavailable
결과: 200개 스레드 전부 BLOCKED, CPU는 놀고 있음 → 리소스 낭비
Netty 아키텍처
개요
Netty는 비동기 이벤트 기반 네트워크 I/O 프레임워크다. Spring WebFlux의 기본 서버이며, Reactor 패턴을 기반으로 한다.
핵심 컴포넌트
[NioEventLoopGroup (Boss, 1~2개 스레드)]
→ 새 TCP 연결 수락 전담
[NioEventLoopGroup (Worker, CPU코어 × 2개 스레드)]
→ Channel I/O 처리 전담
[Channel]
→ 각 연결을 표현하는 객체
→ 하나의 EventLoop에 고정 바인딩
[ChannelPipeline]
→ [Handler 1] → [Handler 2] → ... → [Handler N]
EventLoop
하나의 스레드가 하나의 EventLoop를 담당하고, 하나의 EventLoop는 여러 Channel을 처리한다.
EventLoop Thread 1 (무한 루프)
├── select() ← I/O 이벤트 대기 (논블로킹)
├── processSelectedKeys() ← 준비된 채널 처리
└── runAllTasks() ← 큐에 쌓인 작업 실행
EventLoop Thread 1이 담당하는 Channel들:
├── Channel A (연결 1)
├── Channel B (연결 2)
└── Channel C (연결 3)
핵심 원칙: EventLoop 스레드를 절대 블로킹하면 안 된다. 블로킹 작업은 별도 스레드 풀(Schedulers.boundedElastic())로 오프로드해야 한다.
ChannelPipeline
소켓 → [인바운드 방향 →]
[ByteToMessage Decoder ]
[HTTP 객체 Decoder ]
[Business Logic Handler ]
[HTTP 객체 Encoder ]
[MessageToByte Encoder ]
[← 아웃바운드 방향] → 소켓
기본 Netty 서버 코드
public class SimpleNettyServer {
public void start(int port) throws InterruptedException {
NioEventLoopGroup bossGroup = new NioEventLoopGroup(1);
NioEventLoopGroup workerGroup = new NioEventLoopGroup(); // 기본: CPU × 2
try {
ServerBootstrap bootstrap = new ServerBootstrap()
.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.option(ChannelOption.SO_BACKLOG, 128)
.childOption(ChannelOption.SO_KEEPALIVE, true)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline()
.addLast(new HttpServerCodec())
.addLast(new HttpObjectAggregator(65536))
.addLast(new SimpleServerHandler());
}
});
ChannelFuture future = bootstrap.bind(port).sync();
future.channel().closeFuture().sync();
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
}
@ChannelHandler.Sharable
class SimpleServerHandler extends SimpleChannelInboundHandler<FullHttpRequest> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest request) {
// EventLoop 스레드에서 실행 — 블로킹 금지!
ByteBuf content = Unpooled.copiedBuffer("Hello, Netty!", CharsetUtil.UTF_8);
FullHttpResponse response = new DefaultFullHttpResponse(
HttpVersion.HTTP_1_1, HttpResponseStatus.OK, content
);
response.headers()
.set(HttpHeaderNames.CONTENT_TYPE, "text/plain")
.set(HttpHeaderNames.CONTENT_LENGTH, content.readableBytes());
ctx.writeAndFlush(response);
}
}
Spring WebFlux + Netty
@RestController
public class ReactiveController {
private final WebClient webClient = WebClient.create("https://api.example.com");
@GetMapping("/users/{id}")
public Mono<UserDto> getUser(@PathVariable Long id) {
return webClient.get()
.uri("/users/{id}", id)
.retrieve()
.bodyToMono(UserDto.class)
.map(user -> new UserDto(user.id(), user.name().toUpperCase()))
.timeout(Duration.ofSeconds(3))
.onErrorReturn(new UserDto(-1L, "Unknown"));
}
@GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> stream() {
return Flux.interval(Duration.ofSeconds(1))
.map(i -> "Event: " + i)
.take(10);
}
}
스레드 모델 비교
동시 연결 처리
Tomcat (Thread-Per-Request)
1,000 동시 요청 (각 100ms I/O 대기)
├── 스레드 최대 200개 → 나머지 800개는 대기
├── 200개 스레드 모두 BLOCKED 상태
└── 메모리: 200 × 1MB(스택) ≈ 200MB
Netty (Event Loop)
1,000 동시 연결 (각 100ms I/O 대기)
├── EventLoop 스레드 8개 (CPU 4코어 × 2)
├── I/O 대기 중 다른 채널 처리 → 8개로 1,000개 처리 가능
└── 메모리: 8 × 1MB(스택) ≈ 8MB
I/O 바운드 vs CPU 바운드
| 작업 유형 | Tomcat | Netty |
|---|---|---|
| I/O 바운드 (외부 API, DB) | 스레드 블로킹 → 낭비 | EventLoop가 다른 채널 처리 → 효율적 |
| CPU 바운드 (복잡한 계산) | 자연스러움 | EventLoop 점유 시 다른 채널 지연 → 별도 풀 필요 |
처리량 비교 (이론적)
| 시나리오 | Tomcat (200스레드) | Netty (8 EventLoop) |
|---|---|---|
| 빠른 응답 (<1ms) | 높음 | 더 높음 |
| I/O 대기 (100ms) | ~200 req/s | 수천 req/s |
| CPU 집약 (50ms) | ~4,000 req/s | 비슷 (별도 풀 필요) |
| 대용량 연결 유지 | 스레드 고갈 가능 | 수만 연결 가능 |
Virtual Thread와의 비교 (Java 21+)
Virtual Thread란
Java 21에서 정식 출시된 경량 스레드다. JVM이 OS 스레드 위에 수백만 개의 가상 스레드를 실행할 수 있다.
// 블로킹 코드를 그대로 작성해도 OS 스레드는 해제됨
Thread.ofVirtual().start(() -> {
String result = blockingHttpCall(); // 블로킹 발생 시 OS 스레드 언마운트
process(result); // 응답 도착 시 다시 마운트
});
Spring Boot + Virtual Thread 활성화
spring:
threads:
virtual:
enabled: true # Spring Boot 3.2+
3가지 모델 비교
| 항목 | Tomcat (플랫폼 스레드) | Netty (리액티브) | Tomcat + Virtual Thread |
|---|---|---|---|
| 프로그래밍 모델 | 동기/블로킹 | 비동기/비블로킹 | 동기/블로킹 |
| 코드 복잡도 | 낮음 | 높음 | 낮음 |
| I/O 바운드 성능 | 낮음 | 매우 높음 | 높음 |
| CPU 바운드 성능 | 보통 | 보통 | 보통 |
| 스택 트레이스 가독성 | 명확 | 복잡(리액티브 체인) | 명확 |
| 메모리 (1만 연결) | ~10GB | ~수십MB | ~수백MB |
| JPA/JDBC 사용 | 자연스러움 | 불가(블로킹) | 자연스러움 |
| 학습 곡선 | 낮음 | 높음 | 낮음 |
WebFlux에서 블로킹 코드 처리
@Service
public class UserService {
// JPA(블로킹)를 WebFlux 환경에서 사용할 때
public Mono<User> findById(Long id) {
return Mono.fromCallable(() -> userRepository.findById(id).orElseThrow())
.subscribeOn(Schedulers.boundedElastic()); // 블로킹 작업용 스레드 풀
}
// R2DBC(리액티브 DB 드라이버) 사용 시
public Mono<User> findByIdReactive(Long id) {
return r2dbcUserRepository.findById(id); // 논블로킹
}
}
선택 기준
Tomcat (플랫폼 스레드)
→ 레거시 코드베이스, JPA 사용, 팀 경험 부족, 단순 CRUD 서비스
Netty (WebFlux)
→ 대용량 실시간 스트리밍, SSE, WebSocket, 극한 성능 필요
→ 팀이 리액티브 프로그래밍에 익숙한 경우
Tomcat + Virtual Thread
→ 신규 프로젝트, I/O 바운드 위주, 비동기 복잡도 없이 성능 개선 원할 때
→ Java 21+, Spring Boot 3.2+ 환경
마치며
Tomcat과 Netty는 각각 다른 문제를 해결하기 위해 설계됐다. Tomcat은 단순함과 안정성을, Netty는 극한의 처리량과 확장성을 추구한다. Java 21의 Virtual Thread 도입으로 Tomcat도 I/O 바운드 시나리오에서 경쟁력을 갖게 됐다. 새 프로젝트라면 Virtual Thread + Tomcat 조합이 학습 비용 대비 성능을 얻기 쉬운 선택이다.