
Netty는 자바 기반의 비동기,논블로킹 네트워크 프레임워크이다.
TCP/HTTP 요청을 수신하고 응답을 반환하며,
EventLoop 기반의 I/O 모델을 사용해 적은 수의 쓰레드로 많은 연결을 처리한다.
그 외에 Netty가 가지는 특성은 다음과 같다.
I/O 작업에는 다음과 같은 작업이 있다.
위의 작업들 중 블로킹이 될 수 있는 작업 ( 파일 I/O , DB I/O , 그 외 오래 걸리는 작업 ) 은 EventLoop 에서 작업 하면 성능 저하가 발생하기 때문에 블로킹 가능성이 있는 작업은 Reactor Scheduler를 통해 별도의 워커 스레드로 넘겨야 한다.
💡 Netty 에서의 I/O의 Input/Output 은 바이트 단위의 읽기(read) 와 쓰기(write) 이다.
Netty에서 하나의 EventLoop 쓰레드로 여러 TCP 연결 ( Channel ) 을 다룰 수 있다.
EventLoop 1개는 JVM 쓰레드 하나에 바인딩 되며, EventLoopGroup 가 생명주기를 관리한다.
Netty 는 서버가 시작될 때 EventLoopGroup 라는 자체 쓰레드 그룹을 만들고, 그 안에서 고정 개수의 JVM 쓰레드를 생성한다. ( new Thread() 로 직접 생성 )
이때 생성된 각각의 쓰레드는 EventLoop 라고 한다.
EventLoopGroup은 기본적으로 CPU 코어 수 x 2 정도의 ( 사용자 설정에 따라 달라질 수 있다. ) 쓰레드를 생성한다.
여기서 쓰레드의 개수와 생성, 역할, 바인딩은 Netty가 담당하고, 실제 CPU 실행과 컨텍스트 스위칭은 CPU 와 JVM 이 담당한다.
EventLoop는 단 하나의 JVM 쓰레드인데, 왜 EventLoop 라고 불리는 이유가 무엇일까?
일반 JVM 쓰레드의 경우 단순히 코드를 실행하는 주체이다.
그에 반에 EventLoop는 해당 연결들과 작업들을 끝까지 책임지는 실행 책임(Execution Ownership)을 묶어 부르는 이름이다.
즉, JVM은 그저 시키는 대로 수행한다면, EventLoop는 이벤트 수집, 큐잉, 실행, 재스케줄링까지 책임진다.
여기서 EventLoop가 책임지는 범위는 다음과 같다.
Channel 은 네트워크 I/O 대상을 객체로 추상화 한 것을 말한다.
즉, TCP 연결이나 UDP, FILE , HTTP 등 I/O 자원을 추상화 한것이며, 연결은 channel 의 한 형태일 뿐이다.
하나의 연결 ( Channel ) 은 생성 시점에 하나의 EventLoop 에 귀속된다.
그리고 연결이 살아있는 동안 다른 EventLoop 로 바뀌지 않는다.
처음 연결( Channel )이 생성되었을 때, Round-robin 등으로 EventLoop 중 하나를 배정하게 된다.
이때 다른 EventLoop 로 이동하지 않는 이유는 다음과 같다.
하나의 Channel 에 대해 같은 EventLoop 에서만 실행되면 동기화가 필요 없기 때문에 Netty의 성능이 높다.
같은 연결에서 발생하는 이벤트들 ( read , write , flush , clsoe ) 은 항상 순서대로 처리가 된다.
이때 각각의 순서들은 항상 순차적으로 실행된다. 그 이유는 EventLoop는 단일 쓰레드이면서 큐 기반의 순차 실행이기 때문이다.
같은 쓰레드가 같은 Channel 을 다룰 경우 캐시 히트율이 높아진다.
해당 사항은 고성능 서버에서는 효율적이다.
Channel
└─ Pipeline
├─ Handler1
├─ Handler2
├─ Handler3
Netty는 요청에 대한 처리 과정을 단계 별로 분리하고, 이들을 체인 형태로 연결한 구조이다.
이는 이벤트 흐름과 책임 분리를 위해서이다. Pipeline 은 Channel 에서 발생한 이벤트가 흘러가는 경로로
실제 비즈니스 로직을 처리하는 부분을 분리하여 관심사를 분리할 수 있다.
여기서 Pipeline 은 Channel 에 소속되어 있으며 Channel 에서 발생한 I/O 이벤트가 여러 Handler 를 순차적으로 거치며 처리가 된다.
여기서 Pipeline 과 Channel 은 1대1로 귀속이 되며, 동일한 Handler 구성과 로직은 재활용 될 수 있다.
💡 Pipeline 은 I/O 이벤트가 Channel 을 통해 들어올 때, 그것을 처리하는 Handler 들을 순서대로 연결한 것 이다.
이는 I/O 이벤트를 발행시키는 주체인 Channel 과 실제 비즈니스 로직을 처리하는 Handler를 분리하고, Handler 체인을 Channel 단위로 조합함으로써 다양한 프로토콜과 비즈니스 흐름을 유연하게 처리할 수 있다.
이렇게 이벤트 I/O 처리와 비즈니스 로직을 분리함으로써 확장성과 재활용성, 안정성을 얻는 효과를 얻는다.
구제적으로 관심사를 분리함으로써 얻는 효과는 다음과 같다.
Netty 는 고성능 네트워크 서버를 위해 메모리 복사 최소화, 힙/다이렉트 메모리 관리 , GC 부담 감소를 한다.
Zero-copy 는 메모리 복사를 최소화 하고 참조만 전달하여 CPU 사용량을 낮추고 I/O 성능을 향상시킨다.
메모리 복사라 함은 이미 존재하는 데이터를 새로운 메모리에 복사하지 않고, 해당 데이터의 메모리에 대한 참조를 다음 단계로 넘긴다.
이를 통해 CPU 복사 비용을 아낄 수 있다.
ByteBuf 는 Netty가 사용하는 고성능 바이트 버퍼 추상화로 zero-copy , 참조 전달 , off heap , 메모리 관리를 위해 설계된 자료구조이다.
ByteBuf의 특징은 다음과 같다.
Netty 에서는 Backpressure 프로토콜을 제공하지는 않지만, 저 수준 I/O 기반의 backpressure 메커니즘을 제공한다. Backpressure 는 소비자가 감당할 수 있는 속도 만큼 생산자가 데이터를 보낸다. 즉, 생산자가 빠르고 소비자가 느릴 때 둘 간에 속도를 조절한다.
만약 Backpressure 가 없다면 큐가 무한히 증가하고, 메모리가 폭증하여 Out of Memory 가 발생할 수 있다.
Netty 에서는 Channel writability , watermarks , AutoRead 등을 통해 I/O 레벨 backpressure 를 제공한다.
이는 스트림 레벨 backpressure 를 제공하지 않고, 네트워크 레벨 backpressure 를 제공한다.
Reactor는 반응형 프로그래밍을 구현하기 위한 Reactive 라이브러리 중 하나로 발행자 - 구독자 패턴을 중심으로 동작한다.
Reactor 스케줄링은 EventLoop 에서 처리되던 작업을 Reactor 가 관리하는 Scheduler 의 워커 쓰레드 풀로 전달하는 것이다.
위의 Netty I/O 작업 중 블로킹 가능성이 있는 작업을 워커 쓰레드 풀로 넘겨야 할 때 스케줄링을 해야 하는데, 이런 스케줄링은 Reactor가 하고, 실제 실행은 Reactor가 관리하는 JVM 쓰레드 풀에서 작업이 진행된다.
여기서 Netty EventLoop에 있는 작업을 Reactor 내부 쓰레드 큐에 넣는 작업을 Reactor 연산자(
subscribeOn,publishOn) 가 한다.
여기서 JVM 쓰레드 풀은 Reactor Scheduler 내부에 있는 Thread 풀이 된다.
.subscribeOn(Schedulers.boundedElastic())
위의 코드와 같이 스케줄링이 되면 Scheduler가 가진 Executor(ThreadPool)에 작업을 실행한다.
스케줄링은 .subscribeOn() 와 .publishOn() 메서드를 통해 스케줄링 시점을 정하고, Schedulers.xxx() 메서드를 통해 어떤 쓰레드 풀에서 실행할 지 정한다.
Mono.fromCallable(() -> blockingCall())
.subscribeOn(Schedulers.boundedElastic())
.subscribe();
subscribeOn 은 구독 ( subscribe ) 이 시작되는 실행 컨텍스트를 정한다.
해당 메서드는 파이프라인을 어떤 스케줄러가 관리하는 워커 쓰레드에서 실행할지 결정한다. ( 스케줄러 정보는 인자로 넘겨주어야 한다. )
upstream ( 위쪽 코드 ) 연산자들로 전파된다. 다만 컨텍스트를 바꾸지 않으면 같은 설정을 타고 내려간다. ( 이후 연산자들도 같은 설정을 공유 한다. )
보통 블로킹 I/O , DB , 파일 I/O 와 같이 블로킹 작업을 EventLoop와 떼어낼 때 사용한다.
그 이유는 블로킹 작업으로 EventLoop 가 막히는 현상을 방지하기 위해서이다.
컨텍스트 > 특정 Scheduler가 관리하는 쓰레드 환경
Mono.just("data")
.map(this::fastLogic) // Netty EventLoop
.publishOn(Schedulers.parallel())
.map(this::cpuHeavyLogic) // Reactor worker
.subscribe();
해당 메서드는 해당 시점 이후 어떤 스케줄러가 관리하는 워커 쓰레드에서 작업할지 결정한다.
해당 연산 이후의 downstream 연산자들에 대해, 실행 컨텍스트를 설정한다.
CPU 연산을 분리할 때 사용한다.
| 구분 | subscribeOn | publishOn |
|---|---|---|
| 기준 | subscribe 시점 | 연산자 위치 |
| 영향 범위 | upstream | downstream |
| 역할 | 시작 쓰레드 결정 | 중간부터 쓰레드 전환 |
| 위치 중요 | ❌ | ✅ |
즉, subscribeOn 은 subscribe() 시점에 파이프라인을 어떤 스케줄러가 관리하는 워커 쓰레드에서 실행할지 결정하고, publishOn 은 해당 시점 이후 어떤 스케줄러가 관리하는 워커 쓰레드로 할당할 지 변경한다.
이를 활용하여 .subscribeOn() 으로 시작 쓰레드를 설정하고, .publishOn() 으로 쓰레드를 변경하는 로직을 구현할 수 있다.
Mono.fromCallable(this::blockingCall)
.subscribeOn(Schedulers.boundedElastic()) // 시작
.map(this::step1) // boundedElastic
.publishOn(Schedulers.parallel()) // 전환
.map(this::cpuWork) // parallel
.publishOn(Schedulers.single()) // 또 전환
.map(this::orderedWork) // single
.subscribe();
Schedulers.immediate() // 현재 쓰레드에서 수행 ( 큐를 안거치고 그대로 실행 )
Schedulers.single() // 단일 쓰레드
Schedulers.parallel() // CPU 코어 수 기반
Schedulers.boundedElastic() // 블로킹 작업용 (확장 가능)
위의 메서드들은 실제로 작업을 수행할 쓰레드 풀을 말하며 내부에는 JVM 쓰레드 풀 , 큐 , Worker 를 보유하고 있다.
여기서 쓰레드 풀은 Reactor가 직접 생산 및 관리하는 전용 JVM 쓰레드 풀이다.
Scheduler는 Reactor가 관리하는 실행 컨텍스트이며, 내부적으로 ExecutorService를 가지고 해당 컨텍스트에서 작업을 실행한다.
현재 쓰레드에서 즉시 실행한다. 그렇기 때문에 큐를 거치지 않고 바로 실행이 된다.
즉시 실행되기 때문에 오버 헤드가 없지만 블로킹 작업은 절대로 피해야 한다.
단일 JVM 쓰레드를 사용하는 스케줄러로 Executor 큐를 통해 작업을 순차적으로 처리한다.
동일한 single Scheduler 를 사용할 경우 작업의 순서가 보장되며, 상태 공유나 직렬 처리에 적합하다.
Schedulers.single() 는 싱글톤 스케줄러이며 같은 쓰레드가 하지만, Schedulers.newSingle() 는 새로운 쓰레드를 하나 생성한다.
CPU 코어 기반 고정 쓰레드 풀이며, 쓰레드는 CPU 코어 수 만큼 고정되어 있으며 CPU-Bound 작업을 병렬로 처리할 때 적합하다. 작업 할당 시 라운드 로빈을 통해 워커 쓰레드에게 작업을 분산시켜 병렬 실행하지만 블로킹 작업에서는 사용하면 성능이 저하될 수 있다.
그 이유는 제한된 워커 쓰레드가 블로킹되어 전체 병렬 처리의 성능이 떨어질 수 있기 때문이다.
이는 Reactor 가 관리하는 확장 가능한 쓰레드 풀이다. 필요에 따라 새로운 쓰레드를 생성할 수 있지만 최대 개수는 CPU * 10 개이다. ( 사용자 설정으로 확장 가능 ) 반대로 일정 시간동안 사용되지 않은 쓰레드는 자동 종료가 된다.
해당 설정은 블로킹 I/O 작업에 최적화되어 있다. 물론 너무 많은 블로킹 작업은 성능을 지연시킬 수 있다.
ExecutorService는 작업을 제출하면 내부 큐와 쓰레드를 활용하여 비동기적으로 작업을 실행하는 엔진이다.
즉, 작업을 쓰레드로 실행시키기 위한 실행 엔진으로, 작업 제출과 실제 실행을 분리 시켜준다.
ExecutorService 내부에서는 큐를 관리하고 쓰레드를 관리한다.
ExecutorService가 관리하는 부분을 조금 더 구체적으로 알아보자.
executor.submit(runnable);
스케줄러가 위의 코드를 호출하면 ExecutorService 내부 작업 큐에 적재가 된다.
여기서 사용되는 큐는 ThreadPoolExecutor 기준으로 달라지며 보통 다음과 같다.
→ 큐 크기가 크며 작업이 순차적으로 처리된다. 이는 순차 처리와 안정성이 좋다는 특징이 있다.
→ 크기가 고정되어 있으며, 큐가 가득 찼을 경우 거절 정책이 발동된다. Reactor 기본 Scheduler 에서는 잘 사용되지 않으며 커스텀 Executor를 만들 때 사용된다.
→ 저장 공간이 없으며 곧바로 쓰레드에게 작업이 전달된다. 이때 쓰레드가 존재하지 않으면 새로운 쓰레드를 생성한다. 블로킹 I/O 작업에 어울린다.
| Scheduler | 내부 큐 성격 | 이유 |
|---|---|---|
| immediate | ❌ 없음 | 그냥 현재 쓰레드 |
| single | LinkedBlockingQueue 계열 | 순차 처리 |
| parallel | LinkedBlockingQueue 계열 | CPU 고정 병렬 |
| boundedElastic | SynchronousQueue 계열 | 블로킹 대응 |
큐는 작업 순서를 보관하며, 대기 상태를 유지하고, 큐가 가득 찼을 경우 거절할지, 예외를 발생할지 선택한다.
스케줄러가 작업을 큐에 삽입하면, 워커 쓰레드가 큐에서 작업을 가져가 수행한다.
아래 코드와 같이 큐에 들어간 작업은 Reactor가 관리하고 있는 워커 쓰레드가 큐에서 작업을 빼서 수행한다.
while (!shutdown) {
Runnable task = queue.take();
task.run();
}
ExecutorService 는 여러가지 내부 큐 중에서 Scheduler 의 설정에 따라 다른 큐를 가지게 된다.
즉, ExecutorService 는 Scheduler 타입마다 다르며 내부에서 사용하는 로직과 쓰레드 정책과 큐는 다르다.
[ Scheduler 타입 ]
├─ ParallelScheduler
│ └─ ExecutorService (고정 풀 + LinkedBlockingQueue)
│
├─ SingleScheduler
│ └─ ExecutorService (단일 쓰레드 + LinkedBlockingQueue)
│
├─ BoundedElasticScheduler
│ └─ ExecutorService (가변 풀 + SynchronousQueue 계열)
위와 같이 각각의 Scheduler 타입 별로 ExecutorService 구성은 다르게 구현되어 있다.
물론 Scheduler 타입마다 다르게 구현되어 있지만, Scheduler 구현체는 싱글톤이기 때문에 호출할 때 마다 기존의 ExecutorService 를 반환한다. 따라서 아래와 같이 동일성 체크는 true 값을 반환한다.
Schedulers.parallel() == Schedulers.parallel() // true
위의 정리 내용을 보면 Scheduler 구현 체는 싱글톤이다. 그렇기 때문에 Schedulers.parallel() 를 통해 Scheduler 구현체를 여러번 가져와도 동일한 객체를 반환하게 될 것 이다.
그렇기 때문에 SingleScheduler 의 순차 실행 보장 범위는 Scheduler 범위이다.
만약에 싱글톤 패턴이 아니였다면 리소스 폭증 문제뿐만 아니라 SingleScheduler의 특징인 전역 순차 실행도 퇴색하게 된다.
ExecutorService 는 쓰레드의 생명주기를 관리한다.
→ 최초 작업 시 쓰레드를 생성하며 maxPoolSize 가 넘지 않게 생성한다.
→ 작업이 끝났다고 죽이진 않고, 다음 작업을 이어서 실행한다. 이는 쓰레드가 생성되는 비용을 줄이고자 한다.
→ parallel ( 고정 풀 ) / boundedElastic ( 가변 풀 ) 에 따라 쓰레드의 수를 조절한다.
→ 일정 시간이 지나도 사용하지 않는 풀의 경우 쓰레드를 제거한다. boundedElastic 에서 중요하다.
→ shutdown() , shutdownNow() 를 통해서 쓰레드를 종료할 수 있다.
Reactor가 스케줄링하면 Reactor 가 관리하는 Scheduler 내부에 있는 Thread 풀에 ExecutorService 라는 큐를 통해 전달 된다.
여기서 Reactor 는 ExecutorService 를 생성하고 관리하며, ExecutorService 가 JVM 쓰레드를 생성하고 관리한다.
-- 각 계층간의 관계
JVM
└─ Thread (실제 실행 단위)
↑
ExecutorService
└─ Thread 생성 / 보관 / 큐 관리
↑
Reactor Scheduler
└─ ExecutorService 생성·선택·라이프사이클 관리
↑
Reactor (subscribeOn / publishOn)
Reactor
└─ Scheduler (boundedElastic)
└─ ExecutorService
└─ JVM Threads (worker-1, worker-2, ...)
ExecutorService → JVM 레벨
Thread → 그냥 일반 자바 스레드
.publishOn(Schedulers.boundedElastic()) 를 통해 Reactor 연산자가 Netty EventLoop 에 있는 작업을 Scheduler 에게 위임하면, 해당 작업은 ExecutorService.submit(Runnable) 메서드를 통해 Executor 큐에 들어가게 된다.
이때 작업이 없는 워커 쓰레드 풀에 있는 쓰레드가 작업을 가져가 수행하게 된다.
여기서 워커 쓰레드 풀은 JVM에 존재하는 수많은 쓰레드 풀 중 하나이며, Reactor가 직접 관리하는 전용 쓰레드 풀이다.
`[JVM 전체 쓰레드들]
│
├─ ForkJoinPool
├─ 다른 Executor
└─ Reactor Scheduler Pools
├─ single pool
├─ parallel pool
└─ boundedElastic pool
위의 구조와 같이 Reactor Scheduler Pools 은 Reactor 가 자체적으로 관리하는 쓰레드 풀로 Reactor 연산자에 의해 ExecutorService 큐에 들어온 작업은 해당 쓰레드 들이 dequeue 해서 작업을 수행한다.