이전 글에서는 어떻게 Java NIO는 비동기 프로그래밍을 구현했는지 알아보았다.
이번 시간에는 다양한 예시를 통해서 성능 테스트를 해보려고 한다.
이를 Apache JMeter 를 사용하는 방법을 정리하면서 해가려고 한다.
목표
0. Apachce JMeter 를 실행하는 법을 이해한다.
1. 부하 테스트를 하기 위해서는 무엇을 고려해야 하는지 알아보고 시나리오를 작성한다.
2. Spring boot 와 Netty 서버를 구축하고, 실제적으로 실행한 결과를 분석해본다.
0. Apache JMeter 다운로드 + 실행
필자는 Apache JMeter 공식 사이트에 들어가서 다음을 받았다. (다운로드 공식 사이트 여기!)
다운을 받으면 Github 사이트에서 어떻게 실행하는지 나와있다. (깃헙은 여기 클릭!)

필자는 Binaries 에서 zip 파일을 다운로드 받아서 압축을 푼 후에 ApacheJMeter.jar를 실행하면 된다.

그리고 실행하면...

따단! 아주~ 쉽죠잉?
하지만 공식문서에서는 CLI Mode 로 테스트를 권장하고 있다.(Get Started 공식문서는 여기!)

읽어보면 딱히 이유는 적혀 있지 않고 "Don't run load test using GUI mode! " 라는데...
이에 대한 이유는 다음 글에서 찾았다. (Running JMeter Scripts Through Command Line)
또한 CLI 세팅을 하는 방법도 잘 정리를 해 줘서 참고를 많이 했다.
0.1) 왜 CLI 모드로 실행해야 하는가?
- 효율성 (Efficiency): GUI 모드는 상당한 메모리와 시스템 리소스를 잡아먹지만, 명령줄 모드(Non-GUI)는 훨씬 가볍고 빠릅니다.
- 자동화 (Automation): CI/CD 파이프라인이나 스케줄링 도구와 손쉽게 연동하여 테스트를 자동화할 수 있습니다.
- 동적 설정 (Dynamic Configuration): 테스트 계획(Test Plan) 파일을 직접 수정하지 않고도, 실행 시점에 스레드 수, 램프업(Ramp-up) 시간, 지속 시간 등의 파라미터를 유연하게 주입할 수 있습니다.
동적 설정은 cli 에서 변수를 직접 입력해서, 파일을 수정할 필요가 없다는 것이다.
바로 다음에서 어떻게 jmx를 파일을 만들어서 테스트를 실행하는지 알아보도록 하자.
0.2) Apache JMeter CLI로 실행하기
0.2.1) 테스트 계획 -> 쓰레드 그룹 생성

0.2.2) 쓰레드 수 및 Ramp-up 시간 설정
쓰레드들의 수 : ${__P(threads, 0)}
Ramp-up 시간 : ${__P(ramp_up_time, )}
지속시간 : ${__P(duration,)}

여기서 Ramp-up 시간 설정 항목이 있는데... 간단히 알아가보자.

Ramp는 영단어로 "비탈길" 이다.
하지만 공학적인 부분에서는 Ramp Function 을 많이 사용하는데, 단위 계단 함수를 적분하여 얻을 수 있다.
그러면 Ramp-Up은 그러면 무슨 말이냐?
Ramp-Up은 말 그대로 '목표치까지 점진적으로 올라가는 시간'을 의미한다.
한번에 와장창! 100 개를 때려넣지 않고 순차적으로 10, 20, 30, ... , 100! 에 도달하는것이다.
그런데 왜 굳이 이렇게 할까?
이는 Gemini에게 이유를 물어봤다.
나:
그냥 100을 투입하면 되지 왜 이렇게 점진적으로 하니?
잼민이 :
물론 된다.
하지만 그건 **'테스트'가 아니라 '공격(DDoS)'**이 될 수 있다.
우리가 아침에 일어나서 바로 100m 전력 질주를 하지 않고 스트레칭을 하듯, 서버도 **워밍업(Warm-up)**이 필요하다.
DB 커넥션 풀을 맺고, 캐시를 쌓고, 자바의 JIT 컴파일러가 최적화할 시간을 줘야, 서버가 제 성능을 발휘하며 부하를 받아낼 수 있기 때문이다.
0.2.3) 요청 추가 (HTTP)

서버 이름 또는 IP : ${__P(host, 127.0.0.1)}
포트번호 : ${__P(port, )}
경로 : ${__P(path, )}
0.2.4) 상수 타이머 생성 및 설정
상수 타이머 : ${__P(constant_timer_delay,)}

여기서 필자는 spring boot + 톰캣 서버는 8080 포트로, Netty 서버는 8081 포트로 할 것이지 참고!
상수 타이머 : ${__P(constant_timer_delay,)}

0.2.5) jmx 파일 생성 및 실행 - Netty_vs_Tomcat.jmx
이건 그냥... 파일 -> 저장을 누르면 된다.

필자는 여기서 진짜로 처리량이 높은지, 지연시간이 낮은지를 일반적인 요청처리와 Netty의 요청 처리를 비교하고자 한다.
Netty 공식문서에서 Netty의 성능에 대한 특징을 다음과 같이 말한다.
- 높은 처리량(throughput)과 낮은 지연시간(latency)
- 최소화된 메모리 복사로 리소스 효율 극대화
이 특징을 곱씹어보면, 메모리 사용량도 어쩌면 적을수 있다는 판단이다. 또한, GC의 사용도 적어진다.
최소화된 메모리 복사를 사용하고, 이렇기에 불필요하게 중복된 메모리가 GC에 쌓이지 않기 때문이다.
그러면 필자는 이러한 점을 고려해서 테스트를 하여 입증할 수 있어야 한다.
이 요물... 그러면 이제 실제로 Netty 서버와 일반적인 Spring Boot 를 구성하려고 한다.
그렇다면! 이 목표를 위해 테스트를 어떻게 실행해야 하는지 좀 알아야 겠다.
1. 테스트 시나리오 작성
테스트를 진행하기 전에 테스트 관련 용어를 학습하고 정리하려고 한다.
사실 내가 해당 부분에 대해서 관심도 없었으며 뭐가 있는지도 몰라서 우선 구글에 검색을 했다.
그러니 현재 글을 적는시점을 기준으로 두번째로 아주~ 상세히 정리가 된 글을 보았다. (참고하고자 하는 글 여기!)
요약본은 여기! 뭐... 그런 셈 치고 하려고 한다.
### 1. 부하 테스트(Load Testing)란?
* **정의**: 시스템에 임계치 수준의 부하를 가하여 성능(응답 시간, 처리 속도, 오류 발생 등)을 측정하고, 많은 사용자가 동시 접속해도 안정적으로 작동하는지 검증하는 테스트입니다.
* **유사 개념과의 차이**:
* **부하 테스트**: 예상 사용자 수만큼의 부하를 주어 **기대 성능 유지 여부** 확인.
* **스트레스 테스트**: 한계점 이상으로 부하를 주어 **최대 처리 능력과 파괴 지점** 확인.
* **지속성 테스트**: 장시간 부하를 주어 **메모리 누수** 등 내구성 확인.
### 2. 왜 중요한가?
* 단순히 요청을 많이 보내는 것을 넘어, 실제 서비스 환경과 유사한 조건에서 시스템이 견딜 수 있는지 미리 확인하여 **서비스 중단과 성능 저하를 예방**합니다.
* 사용자 만족도와 비즈니스 신뢰도를 보장하는 핵심 검증 과정입니다.
### 3. 부하 테스트의 4대 핵심 원리
1. **부하 시뮬레이션**: JMeter 등의 도구를 활용해 실제 사용자의 행동 패턴(로그인 -> 검색 -> 주문 등)을 반영한 시나리오를 설계합니다.
2. **부하 방식 설정**: 목적에 따라 **고정 부하**(일정 수준 유지) 또는 **증가 부하**(점진적 증가)를 선택합니다.
3. **성과 지표 수집**: 응답 시간, 처리량(TPS), 오류율, 서버 리소스(CPU/RAM) 사용량을 수집합니다.
4. **병목 현상 분석**: 성능이 급격히 저하되는 지점의 원인(DB, 네트워크, 코드 등)을 파악합니다.
### 4. 효과적인 시나리오 설계를 위한 5가지 요소
테스트의 성공은 도구보다 **'어떻게 설계하느냐'**에 달려 있습니다.
1. **테스트 대상 선정**: 비즈니스 핵심 기능이나 병목이 예상되는 지점(예: 결제, 검색 API)을 선정합니다.
2. **사용자 행동 모델링**: 단순 API 호출이 아닌, 실제 사용자의 흐름(User Journey)을 반영해야 합니다.
3. **동시 사용자 수 정의**: 실제 예상 트래픽이나 목표 수용 인원을 기준으로 설정합니다.
4. **테스트 지속 시간**: 워밍업(Ramp-up), 유지(Steady), 종료(Ramp-down) 구간을 포함하여 설정합니다.
5. **성능 기준(SLA) 정의**: "평균 응답 2초 이내", "오류율 1% 미만" 등 명확한 성공/실패 기준을 세웁니다.
### 5. 결과 분석 및 병목 탐지
* **CPU 병목**: 사용률이 100%에 육박하면 연산 최적화 필요.
* **DB 병목**: 쿼리 지연, 락 충돌, 인덱스 부재 확인.
* **메모리 병목**: 과도한 GC 발생 시 메모리 누수나 객체 재사용 문제 확인 (Netty 테스트 시 중점적으로 볼 부분).
* **네트워크 병목**: 대역폭 부족이나 외부 API 지연 확인.
모든것을 완벽하게 알고서 무언가를 시작할 수 는 없다.
적당히 알아가면서 체득하는것도 능력이라고 생각하니 이정도는 봐달라! 우리에게는 AI가 있자나!
우리는 다음을 입증해야한다.
- 지연 테스트를 통해 -> Non-Blocking 구조가 스레드 대기 시간을 없애는 것을 입증.
- 대용량 전송 테스트를 통해 -> Zero-Copy가 메모리 복사와 GC 부하를 없애는 것을 입증.
Gemini 에게 어떻게 하면 내가 원하는 성능 테스트를 할 수 있는지 시나리오를 작성해 달라고 했다.
시나리오 1: 대량 연결 유지 테스트 (The "Resource Efficiency" Proof)
공유하신 글에서 언급된 **'볼륨 테스트(Volume Testing)'**의 일환입니다. Netty의 낮은 리소스 점유율을 증명합니다.
- 목표: 활성 스레드 개수와 메모리 점유율 비교.
- 시나리오 구성:
- 행동: JMeter가 요청을 보낸 후 연결을 끊지 않고 Keep-Alive를 유지하며, 10초에 한 번씩만 데이터를 보낸다 (Idle 상태 유지).
- 부하 패턴:
- Ramp-up: 1분 동안 동시 접속자 5,000명 ~ 10,000명까지 아주 천천히 증가.
- 비교 포인트:
- Tomcat: 스레드 모델(One Thread per Request) 때문에 동시 접속자가 늘어날수록 스레드가 폭증하고, 스택(Stack) 메모리 사용량으로 인해 **OutOfMemoryError**가 발생할 가능성이 높습니다.
- Netty: 소수의 EventLoop 스레드(보통 코어 수 * 2)만으로 1만 개의 연결을 유지하므로, 스레드 개수가 늘어나지 않고 메모리가 안정적임을 증명합니다.
이번 글에서는 시나리오 1만 테스트해볼 것인데, 다음 글에서는 시나리오 2로 대용량 데이터 처리 속도 비교를 할 것이다.
2. 부하 테스트 환경 구성하기 + 돌려보기
코드를 구성해보도록 하겠다.
보이듯이 코드 구조는 다음과 같다.

BlockingController.java
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class BlockingController {
@GetMapping("/test/blocking")
public String blockingRequest() throws InterruptedException {
// [시나리오 1] DB 조회 등으로 인한 300ms 지연 (Blocking)
// Tomcat 스레드는 이 300ms 동안 아무것도 못하고 이 요청에 묶여있습니다.
Thread.sleep(300);
return "Tomcat Blocking OK";
}
}
NettyServer.java 는 Bean으로 등록함과 동시에 @PostConsr
uctor를 이용해서 빈 생성시 Netty Server 도 한번에 실행되게 하였다.
NettyServer.java
import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.http.*;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.PreDestroy;
import org.springframework.stereotype.Component;
@Component
public class NettyServer {
private final int PORT = 8081; // 8081 포트 사용
private EventLoopGroup bossGroup;
private EventLoopGroup workerGroup;
@PostConstruct
public void start() {
new Thread(() -> {
bossGroup = new NioEventLoopGroup(1); // 연결 수락용 (1개면 충분)
workerGroup = new NioEventLoopGroup(); // 데이터 처리용 (기본: 코어수 * 2)
try {
ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) {
ChannelPipeline p = ch.pipeline();
p.addLast(new HttpServerCodec()); // HTTP 코덱
p.addLast(new HttpObjectAggregator(65536)); // HTTP 메시지 합치기
p.addLast(new NettyHandler()); // ★ 핵심 비즈니스 로직
}
});
ChannelFuture f = b.bind(PORT).sync();
System.out.println("🚀 Netty Server started on port " + PORT);
f.channel().closeFuture().sync();
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
@PreDestroy
public void stop() {
if (bossGroup != null) bossGroup.shutdownGracefully();
if (workerGroup != null) workerGroup.shutdownGracefully();
}
}
NettyHandler.java
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.http.*;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.TimeUnit;
public class NettyHandler extends SimpleChannelInboundHandler<FullHttpRequest> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest req) {
// [시나리오 1] Non-Blocking 지연 구현
// 절대 Thread.sleep()을 쓰면 안 됩니다! (그건 이벤트루프를 죽이는 행위)
// 대신 Netty의 스케줄링 기능을 사용하여 "300ms 뒤에 실행해줘"라고 예약합니다.
ctx.executor().schedule(() -> {
// 300ms 뒤에 실행될 콜백 (응답 보내기)
FullHttpResponse response = new DefaultFullHttpResponse(
HttpVersion.HTTP_1_1,
HttpResponseStatus.OK,
Unpooled.copiedBuffer("Netty Non-Blocking OK", StandardCharsets.UTF_8)
);
response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain");
response.headers().set(HttpHeaderNames.CONTENT_LENGTH, response.content().readableBytes());
// Keep-Alive 처리 (성능 테스트 시 중요)
if (HttpUtil.isKeepAlive(req)) {
response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.KEEP_ALIVE);
ctx.writeAndFlush(response);
} else {
ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
}
}, 300, TimeUnit.MILLISECONDS);
// 이 라인은 즉시 실행됩니다.
// 즉, 이벤트루프 스레드는 예약을 걸어두고 즉시 리턴되어 다른 요청을 받으러 갑니다.
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
cause.printStackTrace();
ctx.close();
}
}
여기서 keep-alive 라는 기능 설정이 있는데, 필자가 이게 뭔지 이해를 못했다.
이를 보니 아... 나는 네트워크 지식도 부족하구나 해서 이것도 공부하기로 맘을 먹었다 ㅠㅠ...
요약을 하자면
매 요청마다 발생하는 값비싼 3-Way Handshake 비용을 아끼기 위해,
한 번 맺은 TCP 연결(파이프)을 끊지 않고 재사용하는 기술!
이라고 한다.
그러면 실제로 작동시켜볼까?
서버를 실행했다.

Spring Boot 의 서버 포트는 8080, Netty 서버의 포트는 8081이다.
우선 Apache JMeter 를 다음과 같이 설정한다.
그리고 다음 명령어로 실행한다.
[JMeter 실행 옵션]
Mode (-n) : GUI 없이 실행 (리소스 절약)
Test_Plan (-t) : 시나리오 파일(.jmx) 경로
Log_File (-l) : 원시 결과 데이터(.jtl) 저장 경로
Report_Gen (-e -o) : 테스트 종료 후 HTML 리포트 생성 폴더
[사용자 정의 변수 (-J)]
threads : 10,000 (최종 목표 동시 접속자 수)
ramp_up_time : 120 (120초 동안 0 → 10,000명 진입)
duration : 600 (전체 테스트 지속 시간 10분)
constant_timer_delay : 10,000 (10초 대기, 연결 유지용)
host : 127.0.0.1 (서버 IP)
port : 8080 / 8081 (Tomcat / Netty 포트)
// Spring Boot + Tomcat 서버 요청
del /q "D:\apache-jmeter-5.6.3\result_tomcat\scenario1_tomcat.jtl"
rmdir /s /q "D:\apache-jmeter-5.6.3\result_tomcat\report_scenario1_tomcat"
D:\apache-jmeter-5.6.3\bin\jmeter -n -t "D:\apache-jmeter-5.6.3\bin\Netty_vs_Tomcat.jmx" ^
-l "D:\apache-jmeter-5.6.3\result_tomcat\scenario1_tomcat.jtl" ^
-e -o "D:\apache-jmeter-5.6.3\result_tomcat\report_scenario1_tomcat" ^
-Jthreads=10000 -Jramp_up_time=60 -Jduration=120 ^
-Jconstant_timer_delay=10000 ^
-Jhost=127.0.0.1 -Jport=8080 ^
-Jpath=/test/blocking
// Netty 서버 요청
del /q "D:\apache-jmeter-5.6.3\result_netty\scenario1_netty.jtl"
rmdir /s /q "D:\apache-jmeter-5.6.3\result_netty\report_scenario1_netty"
D:\apache-jmeter-5.6.3\bin\jmeter -n -t "D:\apache-jmeter-5.6.3\bin\Netty_vs_Tomcat.jmx" ^
-l "D:\apache-jmeter-5.6.3\result_netty\scenario1_netty.jtl" ^
-e -o "D:\apache-jmeter-5.6.3\result_netty\report_scenario1_netty" ^
-Jthreads=10000 -Jramp_up_time=60 -Jduration=120 ^
-Jconstant_timer_delay=10000 ^
-Jhost=127.0.0.1 -Jport=8081 ^
-Jpath=
결과값을 저장하기 위해서 필자는 result_tomcat 폴더와 result_netty폴더로 나누었다.
결과들은 각각 tomcat, netty로 들어갈 것이다.
만약에 실행이 터진다면, 다음을 실행해서 HEAP 사이즈를 늘린 다음 실행하면 된다.
C:\Users\thelo>set HEAP=-Xms4g -Xmx4g -XX:MaxMetaspaceSize=256m
C:\Users\thelo>D:\apache-jmeter-5.6.3\bin\jmeter -n -t "D:\apache-jmeter-5.6.3\bin\Netty_vs_Tomcat.jmx" ^
More? -l "D:\apache-jmeter-5.6.3\result_tomcat\scenario1_tomcat.jtl" ^
More? -e -o "D:\apache-jmeter-5.6.3\result_tomcat\report_scenario1_tomcat" ^
More? -Jthreads=10000 -Jramp_up_time=60 -Jduration=120 ^
More? -Jconstant_timer_delay=10000 ^
More? -Jhost=127.0.0.1 -Jport=8080 ^
More? -Jpath=/test/blocking
WARN StatusConsoleListener The use of package scanning to locate plugins is deprecated and will be removed in a future release
WARN StatusConsoleListener The use of package scanning to locate plugins is deprecated and will be removed in a future release
WARN StatusConsoleListener The use of package scanning to locate plugins is deprecated and will be removed in a future release
WARN StatusConsoleListener The use of package scanning to locate plugins is deprecated and will be removed in a future release
Creating summariser <summary>
Created the tree successfully using D:\apache-jmeter-5.6.3\bin\Netty_vs_Tomcat.jmx
Starting standalone test @ 2026 Feb 8 22:30:54 KST (1770557454115)
Waiting for possible Shutdown/StopTestNow/HeapDump/ThreadDump message on port 4445
summary + 1 in 00:00:11 = 0.1/s Avg: 354 Min: 354 Max: 354 Err: 0 (0.00%) Active: 1751 Started: 1751 Finished: 0
summary + 7608 in 00:00:25 = 300.2/s Avg: 309 Min: 300 Max: 374 Err: 0 (0.00%) Active: 5974 Started: 5974 Finished: 0
summary = 7609 in 00:00:36 = 212.3/s Avg: 309 Min: 300 Max: 374 Err: 0 (0.00%)
summary + 19533 in 00:00:30 = 651.4/s Avg: 1133 Min: 0 Max: 2755 Err: 1013 (5.19%) Active: 10000 Started: 10000 Finished: 0
summary = 27142 in 00:01:06 = 412.3/s Avg: 902 Min: 0 Max: 2755 Err: 1013 (3.73%)
summary + 25218 in 00:00:30 = 840.2/s Avg: 2008 Min: 0 Max: 20284 Err: 5847 (23.19%) Active: 10000 Started: 10000 Finished: 0
summary = 52360 in 00:01:36 = 546.3/s Avg: 1434 Min: 0 Max: 20284 Err: 6860 (13.10%)
summary + 22629 in 00:00:27 = 828.4/s Avg: 1985 Min: 0 Max: 3999 Err: 5001 (22.10%) Active: 0 Started: 0 Finished: 0
summary = 74989 in 00:02:03 = 608.9/s Avg: 1601 Min: 0 Max: 20284 Err: 11861 (15.82%)
Tidying up ... @ 2026 Feb 8 22:32:57 KST (1770557577330)
... end of run


SpringBoot + Tomcat 부하테스트 결과(CMD)
C:\Users\thelo>D:\apache-jmeter-5.6.3\bin\jmeter -n -t "D:\apache-jmeter-5.6.3\bin\Netty_vs_Tomcat.jmx" ^
More? -l "D:\apache-jmeter-5.6.3\result_tomcat\scenario1_tomcat.jtl" ^
More? -e -o "D:\apache-jmeter-5.6.3\result_tomcat\report_scenario1_tomcat" ^
More? -Jthreads=10000 -Jramp_up_time=60 -Jduration=120 ^
More? -Jconstant_timer_delay=10000 ^
More? -Jhost=127.0.0.1 -Jport=8080 ^
More? -Jpath=/test/blocking
WARN StatusConsoleListener The use of package scanning to locate plugins is deprecated and will be removed in a future release
WARN StatusConsoleListener The use of package scanning to locate plugins is deprecated and will be removed in a future release
WARN StatusConsoleListener The use of package scanning to locate plugins is deprecated and will be removed in a future release
WARN StatusConsoleListener The use of package scanning to locate plugins is deprecated and will be removed in a future release
Creating summariser <summary>
Created the tree successfully using D:\apache-jmeter-5.6.3\bin\Netty_vs_Tomcat.jmx
Starting standalone test @ 2026 Feb 8 22:30:54 KST (1770557454115)
Waiting for possible Shutdown/StopTestNow/HeapDump/ThreadDump message on port 4445
summary + 1 in 00:00:11 = 0.1/s Avg: 354 Min: 354 Max: 354 Err: 0 (0.00%) Active: 1751 Started: 1751 Finished: 0
summary + 7608 in 00:00:25 = 300.2/s Avg: 309 Min: 300 Max: 374 Err: 0 (0.00%) Active: 5974 Started: 5974 Finished: 0
summary = 7609 in 00:00:36 = 212.3/s Avg: 309 Min: 300 Max: 374 Err: 0 (0.00%)
summary + 19533 in 00:00:30 = 651.4/s Avg: 1133 Min: 0 Max: 2755 Err: 1013 (5.19%) Active: 10000 Started: 10000 Finished: 0
summary = 27142 in 00:01:06 = 412.3/s Avg: 902 Min: 0 Max: 2755 Err: 1013 (3.73%)
summary + 25218 in 00:00:30 = 840.2/s Avg: 2008 Min: 0 Max: 20284 Err: 5847 (23.19%) Active: 10000 Started: 10000 Finished: 0
summary = 52360 in 00:01:36 = 546.3/s Avg: 1434 Min: 0 Max: 20284 Err: 6860 (13.10%)
summary + 22629 in 00:00:27 = 828.4/s Avg: 1985 Min: 0 Max: 3999 Err: 5001 (22.10%) Active: 0 Started: 0 Finished: 0
summary = 74989 in 00:02:03 = 608.9/s Avg: 1601 Min: 0 Max: 20284 Err: 11861 (15.82%)
Tidying up ... @ 2026 Feb 8 22:32:57 KST (1770557577330)
... end of run
이번에는 Netty 를 실행시켜보겠다.

Netty 서버 부하테스트 결과(CMD)
C:\Users\thelo>D:\apache-jmeter-5.6.3\bin\jmeter -n -t "D:\apache-jmeter-5.6.3\bin\Netty_vs_Tomcat.jmx" ^
More? -l "D:\apache-jmeter-5.6.3\result_netty\scenario1_netty.jtl" ^
More? -e -o "D:\apache-jmeter-5.6.3\result_netty\report_scenario1_netty" ^
More? -Jthreads=10000 -Jramp_up_time=60 -Jduration=120 ^
More? -Jconstant_timer_delay=10000 ^
More? -Jhost=127.0.0.1 -Jport=8081 ^
More? -Jpath=
WARN StatusConsoleListener The use of package scanning to locate plugins is deprecated and will be removed in a future release
WARN StatusConsoleListener The use of package scanning to locate plugins is deprecated and will be removed in a future release
WARN StatusConsoleListener The use of package scanning to locate plugins is deprecated and will be removed in a future release
WARN StatusConsoleListener The use of package scanning to locate plugins is deprecated and will be removed in a future release
Creating summariser <summary>
Created the tree successfully using D:\apache-jmeter-5.6.3\bin\Netty_vs_Tomcat.jmx
Starting standalone test @ 2026 Feb 8 22:34:44 KST (1770557684358)
Waiting for possible Shutdown/StopTestNow/HeapDump/ThreadDump message on port 4445
summary + 879 in 00:00:16 = 56.3/s Avg: 311 Min: 301 Max: 373 Err: 0 (0.00%) Active: 2601 Started: 2601 Finished: 0
summary + 12332 in 00:00:30 = 411.2/s Avg: 309 Min: 300 Max: 418 Err: 0 (0.00%) Active: 7598 Started: 7598 Finished: 0
summary = 13211 in 00:00:46 = 289.8/s Avg: 309 Min: 300 Max: 418 Err: 0 (0.00%)
summary + 25978 in 00:00:30 = 865.8/s Avg: 314 Min: 300 Max: 356 Err: 0 (0.00%) Active: 10000 Started: 10000 Finished: 0
summary = 39189 in 00:01:16 = 518.4/s Avg: 312 Min: 300 Max: 418 Err: 0 (0.00%)
summary + 29013 in 00:00:30 = 966.8/s Avg: 318 Min: 300 Max: 362 Err: 0 (0.00%) Active: 10000 Started: 10000 Finished: 0
summary = 68202 in 00:01:46 = 645.8/s Avg: 314 Min: 300 Max: 418 Err: 0 (0.00%)
summary + 14909 in 00:00:16 = 954.7/s Avg: 281 Min: 1 Max: 336 Err: 1901 (12.75%) Active: 0 Started: 0 Finished: 0
summary = 83111 in 00:02:01 = 685.6/s Avg: 308 Min: 1 Max: 418 Err: 1901 (2.29%)
Tidying up ... @ 2026 Feb 8 22:36:45 KST (1770557805631)
... end of run
이제 결과가 나오는것을 확인할 수 있다.

각 파일에서 결과 index.html 을 열어서 비교해보겠다.


평균 처리 시간이 1601.08ms 이다. 최소는 0ms 이지만 최대는 20284ms인데 이는... 20.284초이다 (헉...)
반면 netty의 경우에는 어마어마한 성능이다.
평균 응답시간이 308.96ms인데 최대 응답시간은 418ms밖에 되지 않는다.


재미있는 부분은 Tomcat의 경우 처리량 (Throughput) 이 663.59 transactions/s, Network상에서는 360.07 KB/sec를 받고 70.92 KB/sec를 보낸다.
Netty는748.32 transactions/s이며 Network상에서는 117.43 KB/sec를 받고 92.83 KB/sec를 보낸다.
처리량도 훨씬 많으며 에러문제도 거의 없다.
Tomcat사용시에도 있지만 Netty도 다음과 같은 에러가 있다.
java.net.BindException: Address already in use: connect
이것에 대해서 Gemini에게 물어보니 다음과 같은 답변을 받았다.
작성자님이 보신 그 에러는 Netty가 죽은 게 아니라,
Netty의 처리 속도가 너무 빨라서 JMeter(보내는 쪽 PC)의 체력이 먼저 방전된 상황입니다.
이것이야말로 Netty의 성능이 압도적이라는 가장 확실한 증거입니다.
Tomcat의 경우에는 이 에러 말고도 Connection refused 에러가 난다. 이것은 서버에서 바쁘다고 튕겨낸 것으로, 서버가 이를 감당하지 못한 것이다.

가시적으로도 이를 확인할 수 있는데,
Response Times Over Time 그래프를 확인하면, 선형적으로 응답 시간이 증가하지만, 두 서버의 경우 시작점이 다른 것을 확인할 수 있다.
Tomcat 실행시

Netty 실행시

오히려 netty는 뒤로 갈수록 더 좋은 성능을 보였다.
하지만 Tomcat은 점점 처리량이 늦어지고 응답시간이 길어지는것을 확인할 수 있다.
아 이제는 왜 좋은지 좀 알겠다. 마무리는 제미나이에게 물어보도록 하겠다.
나 : 이러한 차이가 왜 나는거야?
제미나이:
[Tomcat vs Netty: 아키텍처와 성능 차이의 핵심 원리]
이 성능 차이는 단순히 누가 더 빠르다의 문제가 아닙니다. 요청을 처리하는 방식(Architecture) 자체가 완전히 다르기 때문에 발생합니다.
개발자스럽게 '은행 창구'에 비유하여 그 원리를 명확히 설명해 드립니다.
(비유 설정: 손님은 요청, 은행원은 스레드, 업무는 300ms가 소요되는 작업)
(1) Tomcat (Blocking I/O) : 1손님 1은행원 시스템
Tomcat은 Thread-per-Request 모델입니다. 즉, 손님이 오면 은행원이 1:1로 전담 마크합니다.
[상황] 손님 1만 명이 몰려왔는데, 은행원(스레드)은 200명뿐입니다.
[동작 과정]
첫째, 은행원 200명이 손님 200명을 자리에 앉히고 업무를 시작합니다.
둘째, 그런데 업무 내용이 "300ms 동안 멍때리기(Sleep)"입니다.
셋째, 은행원은 300ms 동안 아무것도 안 하고 손님만 쳐다보고 있어야 합니다. (Blocking)
넷째, 나머지 9,800명의 손님은 번호표를 뽑고 하염없이 기다립니다. (Queueing)
[결과]
그래프가 우상향하는 이유 대기열 뒤쪽에 있는 손님은 (앞사람 업무 시간 300ms) 곱하기 (내 대기 순번) 만큼 기다려야 합니다.
그래서 응답 시간이 300ms에서 시작해 1초, 5초, 결국 20초까지 기하급수적으로 늘어납니다.
결국 대기 줄이 은행 밖까지 밀리면 "더 이상 못 받아요(Connection Refused)" 하며 셔터를 내려버립니다.
(2) Netty (Non-Blocking I/O) : 주문 벨 시스템
Netty는 Event Loop 모델입니다. 은행원(스레드)은 소수(보통 코어 수 x 2)지만, 일하는 방식이 완전히 다릅니다.
[상황]
손님 1만 명이 몰려왔는데, 은행원(이벤트 루프)은 단 16명입니다.
[동작 과정]
첫째, 은행원이 손님에게 "300ms 뒤에 오세요" 하고 진동 벨(Callback/Future)을 쥐여줍니다.
둘째, 이 접수 과정은 0.0001초도 안 걸립니다.
셋째, 은행원은 손님을 기다리지 않고, 즉시 다음 손님을 받습니다. (Non-Blocking)
넷째, 300ms가 지나면 알람이 울리고, 은행원은 그제야 "처리 완료되었습니다" 하고 응답을 줍니다.
[결과]
그래프가 일자인 이유 은행원 1명이 기다리는 시간 없이 접수만 계속 받으니, 1만 명을 접수하는 데 시간이 거의 안 걸립니다. 모든 손님은 접수 후 정확히 300ms 뒤에 결과를 받습니다. 아무도 줄을 서지 않으므로 응답 시간은 언제나 300ms로 일정합니다.
(3) 기술적 요약 (면접 및 보고용)
[Tomcat - Blocking 방식]
스레드 모델: One Thread Per Request (요청당 스레드 하나)
스레드 상태: I/O 작업(Sleep) 중 Blocked (멈춤)
병목 지점: 스레드 개수 (Context Switching 비용 과다)
적합한 곳: CPU를 많이 쓰는 복잡한 연산 작업
[Netty - Non-Blocking 방식]
스레드 모델: Event Loop (Reactor Pattern, 소수의 스레드로 순환 처리)
스레드 상태: I/O 작업 중에도 Running (다른 일 처리)
병목 지점: CPU 연산 속도 (로직이 복잡하면 느려짐)
적합한 곳: 대기 시간(DB, API 호출)이 긴 대용량 처리 작업
(4) 결론
이번 테스트(300ms 지연)는 "일은 안 하고 기다리기만 하는 작업(I/O Bound)"이었기 때문에, 기다리는 시간 동안 쉴 새 없이 다른 요청을 처리할 수 있는 Netty(Non-Blocking)가 압도적으로 유리했던 것입니다.
이 원리를 이해하셨다면, 이제 "왜 고성능 처리에 Netty를 써야 하는가?"에 대해 완벽하게 답변하실 수 있을 것입니다.
사실 대부분 성능 테스트에 들어봤음직 하고 그냥 하면 된다는것을 알지만,
필자는 이를 실제로 돌려보고 신뢰할만한 결과인지 확인하고 싶었다.
가장 최근의 핫한 이슈는 Openclaw라는 오픈 소스 프로젝트라고 생각한다.
이제는 나만의 자율형 AI 에이전트를 가질 수 있는 세상에 왔지만, 여전히 해야할건... 변하지 않는 다능.. ㅠㅠ
이 글을 쓰면서도 내가 직접 다 찾아서 했으면 정말 엄청난 시간이 걸렸을 것이다.
하지만 AI를 잘 활용해서 하니 정말... 몇일 걸리던 서핑과 학습이 너무 간단해지고 심지어 코드도 받았다.
너무도 좋은 세상에 살고 있음이 확실히 느껴지지만 이것이 결국 나에게 정말 좋은건지는 미지수다.
우선 해당 글에서는 덕분에 빠르고 쉽게 테스트도 진행해 볼 수 있었다.
출처:
https://www.dawnscapelab.com/what-is-load-testing/
https://jmeter.apache.org/usermanual/get-started.html