이전 글에서 EventLoop가 단일 스레드로 동작한다는 건 정리했다. 그런데 "단일 스레드니까 동기화가 필요 없다"는 말이 정확히 어떤 범위까지 적용되는 걸까? 핸들러에서 DB를 조회하면 어떻게 되고, 여러 파이프라인에서 핸들러를 공유하면 안전한 걸까?

이번 글에서는 Netty의 스레드 모델을 더 깊이 들여다봅니다. Channel과 EventLoop의 바인딩이 왜 스레드 안전성을 보장하는지, inEventLoop() 패턴이 내부적으로 어떻게 작동하는지, @Sharable 핸들러의 함정은 무엇인지, 블로킹 작업을 별도 스레드 풀로 분리하는 방법, 그리고 Netty가 채택한 Reactor 패턴까지 정리합니다.


Netty의 스레드 안전성 보장

Netty에서 Channel은 등록 시 하나의 EventLoop에 바인딩되고, 이후 모든 I/O 이벤트와 핸들러 호출이 그 EventLoop의 단일 스레드에서 실행됩니다.

이것이 Netty 스레드 모델의 핵심입니다. 한 Channel에 대한 모든 작업이 항상 같은 스레드에서 순차적으로 실행되니, synchronizedLock 같은 동기화 메커니즘이 필요 없습니다.

PLAINTEXT
Channel 등록 이후의 스레드 모델

Channel-A ──▶ EventLoop-1 (Thread-1)
Channel-B ──▶ EventLoop-1 (Thread-1)   ← 같은 EventLoop 공유 가능
Channel-C ──▶ EventLoop-2 (Thread-2)

규칙:
- Channel은 항상 하나의 EventLoop에 고정 (변경 불가)
- EventLoop 하나가 여러 Channel을 담당할 수 있음
- 한 Channel의 모든 이벤트는 같은 스레드에서 순차 실행

이 구조 덕분에 핸들러 안에서 ChannelHandlerContextChannel의 상태를 읽고 쓸 때 별도 동기화가 불필요합니다.

JAVA
// 핸들러 안에서는 동기화 없이 안전하게 상태 관리 가능
public class MyHandler extends ChannelInboundHandlerAdapter {
    private int messageCount = 0;  // 이 핸들러가 파이프라인마다 새로 생성된다면 안전

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        messageCount++;  // 같은 EventLoop 스레드에서만 호출됨 → 동기화 불필요
        ctx.writeAndFlush("받은 메시지 수: " + messageCount);
    }
}

핵심은 "Channel 단위의 스레드 격리"다. 핸들러가 파이프라인마다 새로 생성되는 한, 인스턴스 필드에 상태를 저장해도 안전하다. 문제가 되는 건 하나의 핸들러 인스턴스를 여러 파이프라인에서 공유할 때다.


inEventLoop() 체크 패턴

Netty 내부 코드를 읽다 보면 inEventLoop() 체크가 정말 자주 등장합니다. 이 패턴은 "현재 호출한 스레드가 EventLoop 스레드인지 확인하고, 아니면 작업을 큐잉한다" 는 의미입니다.

JAVA
// EventLoop.inEventLoop() 의 구현 (단순화)
public boolean inEventLoop() {
    return Thread.currentThread() == this.thread;
    // 현재 스레드와 EventLoop에 배정된 스레드가 같은지 비교
}

내부 코드에서의 활용

AbstractChannelHandlerContextwrite() 메서드를 보면 이 패턴이 명확하게 드러납니다.

JAVA
// AbstractChannelHandlerContext.write() 내부 (단순화)
private void write(Object msg, boolean flush, ChannelPromise promise) {
    EventExecutor executor = next.executor();

    if (executor.inEventLoop()) {
        // 이미 EventLoop 스레드에 있음 → 바로 실행
        if (flush) {
            next.invokeWriteAndFlush(msg, promise);
        } else {
            next.invokeWrite(msg, promise);
        }
    } else {
        // 외부 스레드 → Runnable로 감싸서 큐에 제출
        executor.execute(() -> {
            if (flush) {
                next.invokeWriteAndFlush(msg, promise);
            } else {
                next.invokeWrite(msg, promise);
            }
        });
    }
}

이 패턴이 반복되는 이유

상황동작
EventLoop 스레드에서 호출즉시 실행 (오버헤드 없음)
외부 스레드에서 호출execute()로 태스크 큐에 넣고, EventLoop 스레드가 처리

이 패턴 덕분에 Netty의 API는 ** 어떤 스레드에서 호출해도 안전합니다 **. channel.writeAndFlush()를 비즈니스 스레드에서 호출해도 문제없는 이유가 바로 이것입니다. 내부적으로 inEventLoop() 체크 후 자동으로 큐잉되기 때문입니다.

JAVA
// 어떤 스레드에서든 안전하게 호출 가능
CompletableFuture.supplyAsync(() -> {
    String result = callExternalApi();
    return result;
}).thenAccept(result -> {
    // CompletableFuture의 스레드에서 호출해도 안전
    // 내부적으로 inEventLoop() 체크 → 자동 큐잉
    channel.writeAndFlush(result);
});

ChannelHandler의 스레드 안전성

핸들러별 인스턴스 vs 공유 인스턴스

핸들러를 파이프라인에 추가하는 방식에 따라 스레드 안전성이 달라집니다.

JAVA
// 방식 1: 매번 새로운 인스턴스 생성 — 안전
ch.pipeline().addLast(new MyHandler());  // Channel마다 별도 인스턴스

// 방식 2: 하나의 인스턴스를 공유 — @Sharable 필요
MyHandler shared = new MyHandler();
ch1.pipeline().addLast(shared);  // Channel-1에서 사용
ch2.pipeline().addLast(shared);  // Channel-2에서도 같은 인스턴스 사용

방식 1은 각 Channel(각 EventLoop 스레드)이 자기만의 핸들러 인스턴스를 갖기 때문에 인스턴스 필드를 자유롭게 써도 안전합니다.

방식 2는 여러 Channel이 같은 핸들러 객체를 공유합니다. 이때 각 Channel은 서로 다른 EventLoop 스레드에서 실행될 수 있으므로, ** 여러 스레드가 동시에 같은 핸들러의 메서드를 호출합니다.**

@Sharable 어노테이션

JAVA
// @Sharable 핸들러 — 올바른 예: 무상태
@ChannelHandler.Sharable
public class LoggingHandler extends ChannelInboundHandlerAdapter {
    // 인스턴스 필드 없음 → 무상태

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        // ctx는 Channel별로 다르므로 안전
        System.out.println(ctx.channel().remoteAddress() + " → " + msg);
        ctx.fireChannelRead(msg);
    }
}
JAVA
// @Sharable 핸들러 — 잘못된 예: 상태 있음
@ChannelHandler.Sharable
public class BadCountHandler extends ChannelInboundHandlerAdapter {
    private int count = 0;  // 여러 스레드가 동시에 접근!

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        count++;  // race condition 발생!
        ctx.writeAndFlush("총 " + count + "개 메시지 수신");
        ctx.fireChannelRead(msg);
    }
}

@Sharable을 쓰는 조건 정리

조건@Sharable 가능?
인스턴스 필드 없음 (무상태)O
인스턴스 필드가 불변 (final, 읽기만)O
인스턴스 필드를 읽고 쓰지만 AtomicInteger 등 thread-safe 자료구조 사용O (주의 필요)
인스턴스 필드를 동기화 없이 읽고 씀X — race condition

@Sharable을 붙이려면 핸들러가 무상태이거나, 상태가 있더라도 thread-safe하게 관리해야 한다. 확신이 없다면 매번 새 인스턴스를 만드는 게 안전하다.

상태 있는 핸들러의 올바른 설계

상태가 필요하면서도 공유가 필요한 상황이라면, 핸들러 인스턴스 필드 대신 ChannelAttribute를 사용합니다.

JAVA
// Channel Attribute를 사용하는 안전한 패턴
@ChannelHandler.Sharable
public class SafeCountHandler extends ChannelInboundHandlerAdapter {
    // Channel별 상태를 Attribute로 저장
    private static final AttributeKey<Integer> COUNT =
        AttributeKey.valueOf("messageCount");

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        // Attribute는 Channel별로 독립 → 같은 EventLoop 스레드에서만 접근
        Integer count = ctx.channel().attr(COUNT).get();
        if (count == null) count = 0;
        count++;
        ctx.channel().attr(COUNT).set(count);

        ctx.writeAndFlush("이 채널의 메시지 수: " + count);
        ctx.fireChannelRead(msg);
    }
}

DefaultEventExecutorGroup — 블로킹 작업 분리

이전 글에서 EventLoop 스레드에서 블로킹 호출은 절대 금지라고 했습니다. 그런데 실무에서는 DB 쿼리나 외부 API 호출 같은 블로킹 작업이 필연적입니다. DefaultEventExecutorGroup은 이런 블로킹 작업을 EventLoop와 분리된 별도 스레드 풀에서 실행하는 네티의 공식 해법 입니다.

기본 사용법

JAVA
// DefaultEventExecutorGroup으로 블로킹 핸들러 분리
EventExecutorGroup blockingGroup = new DefaultEventExecutorGroup(16);

ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)
 .channel(NioServerSocketChannel.class)
 .childHandler(new ChannelInitializer<SocketChannel>() {
     @Override
     protected void initChannel(SocketChannel ch) {
         ch.pipeline().addLast(new HttpServerCodec());
         ch.pipeline().addLast(new HttpObjectAggregator(65536));

         // 이 핸들러는 blockingGroup의 스레드에서 실행
         ch.pipeline().addLast(blockingGroup, new DatabaseHandler());

         // 이 핸들러는 다시 EventLoop 스레드에서 실행
         ch.pipeline().addLast(new ResponseEncoder());
     }
 });

내부 동작 원리

addLast(EventExecutorGroup, ChannelHandler) 로 추가하면, 해당 핸들러의 ChannelHandlerContext에 EventExecutorGroup의 스레드가 배정됩니다. 이벤트가 이 핸들러에 도달하면 EventLoop 스레드가 아닌 배정된 스레드에서 실행 됩니다.

PLAINTEXT
이벤트 흐름과 스레드 전환

  EventLoop 스레드          blockingGroup 스레드          EventLoop 스레드
  ┌──────────────┐        ┌──────────────────┐        ┌───────────────┐
  │ HttpCodec    │──▶     │ DatabaseHandler  │──▶     │ ResponseEncoder│
  │ Aggregator   │        │ (DB 쿼리 실행)     │        │               │
  └──────────────┘        └──────────────────┘        └───────────────┘

  ※ DatabaseHandler 앞뒤로 자동으로 스레드 전환이 일어남

핵심 포인트는 Channel별로 DefaultEventExecutorGroup 내의 같은 스레드가 배정 된다는 것입니다. 한 Channel의 이벤트가 서로 다른 스레드에서 동시에 실행되지 않기 때문에, 핸들러 안에서의 순서가 보장됩니다.

JAVA
// DefaultEventExecutorGroup의 스레드 배정 로직 (단순화)
// Channel이 등록될 때 해시를 기반으로 고정 스레드를 배정
EventExecutor chooser.next();  // Round-robin으로 선택
// 이후 같은 Channel의 이벤트는 항상 같은 스레드에서 실행

언제 별도 스레드 풀을 쓰는가

모든 핸들러를 별도 스레드 풀에서 돌릴 필요는 없습니다. EventLoop를 블로킹할 가능성이 있는 작업만 분리 하면 됩니다.

별도 스레드 풀이 필요한 경우

작업이유예시
DB 쿼리 (JDBC)네트워크 왕복 + 쿼리 실행 시간만큼 블로킹statement.executeQuery()
외부 API 호출 (동기 HTTP)외부 서버 응답 대기httpClient.execute()
파일 I/O디스크 읽기/쓰기 대기Files.readAllBytes()
무거운 연산CPU 점유 시간이 길어 다른 Channel 처리 지연이미지 리사이징, 암호화

별도 스레드 풀이 불필요한 경우

  • 단순한 프로토콜 디코딩/인코딩
  • 메시지 변환, 유효성 검사
  • 인메모리 캐시 조회
  • 다른 Channel로의 메시지 포워딩
JAVA
// 실무에서 흔한 구조: 블로킹 핸들러만 분리
EventExecutorGroup dbGroup = new DefaultEventExecutorGroup(16);
EventExecutorGroup apiGroup = new DefaultEventExecutorGroup(8);

ch.pipeline().addLast(new ProtocolDecoder());          // EventLoop — 빠름
ch.pipeline().addLast(new RequestValidator());          // EventLoop — 빠름
ch.pipeline().addLast(dbGroup, new UserLookupHandler());// 별도 풀 — DB 쿼리
ch.pipeline().addLast(apiGroup, new PaymentHandler());  // 별도 풀 — 외부 API
ch.pipeline().addLast(new ResponseEncoder());           // EventLoop — 빠름

스레드 풀 크기 가이드

JAVA
// I/O 바운드 작업: 스레드를 넉넉하게
// DB 쿼리 평균 50ms, 목표 처리량 초당 1000건이라면
// 필요 스레드 ≈ 1000 × 0.05 = 50개
EventExecutorGroup dbGroup = new DefaultEventExecutorGroup(50);

// CPU 바운드 작업: CPU 코어 수 기준
EventExecutorGroup cpuGroup = new DefaultEventExecutorGroup(
    Runtime.getRuntime().availableProcessors()
);

UnorderedThreadPoolEventExecutor

DefaultEventExecutorGroup은 Channel별로 스레드를 고정하여 이벤트 순서를 보장합니다. 하지만 순서가 중요하지 않은 작업 이라면, 이 제약이 오히려 처리량을 제한합니다.

UnorderedThreadPoolEventExecutor는 이벤트 순서를 보장하지 않는 대신, 풀의 모든 스레드를 자유롭게 활용하여 더 높은 처리량을 제공합니다.

JAVA
// 순서 보장 O — DefaultEventExecutorGroup
EventExecutorGroup ordered = new DefaultEventExecutorGroup(16);
// Channel-A의 이벤트: 항상 Thread-3에서 실행 (고정)
// → 이벤트 1 완료 후 이벤트 2 실행 (순서 보장)

// 순서 보장 X — UnorderedThreadPoolEventExecutor
EventExecutorGroup unordered = new UnorderedThreadPoolEventExecutor(16);
// Channel-A의 이벤트: Thread-1, Thread-5, Thread-12 등 아무 스레드
// → 이벤트 1과 이벤트 2가 동시에 다른 스레드에서 실행될 수 있음

비교 표

구분DefaultEventExecutorGroupUnorderedThreadPoolEventExecutor
순서 보장O (Channel별 고정 스레드)X (아무 스레드)
동시 실행Channel별 직렬Channel 이벤트도 병렬 가능
처리량상대적으로 낮음상대적으로 높음
적합한 작업DB 쿼리, 상태 변경독립적인 로깅, 통계, 알림

사용 시 주의점

JAVA
// UnorderedThreadPoolEventExecutor 사용 예시
EventExecutorGroup statsGroup = new UnorderedThreadPoolEventExecutor(4);

ch.pipeline().addLast(statsGroup, new StatsHandler());

// StatsHandler 내부 — 순서 무관한 작업만 해야 함
public class StatsHandler extends ChannelInboundHandlerAdapter {
    private final AtomicLong totalBytes = new AtomicLong();  // thread-safe 자료구조 필수

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        if (msg instanceof ByteBuf buf) {
            // 여러 스레드에서 동시 호출 가능 → AtomicLong 사용
            totalBytes.addAndGet(buf.readableBytes());
        }
        ctx.fireChannelRead(msg);
    }
}

UnorderedThreadPoolEventExecutor를 쓸 때는 핸들러 내부의 모든 상태 접근이 thread-safe해야 한다. "순서 보장 안 함"은 곧 "같은 Channel의 이벤트도 동시에 실행될 수 있음"을 의미하기 때문이다.


Reactor 패턴 정리

Netty의 스레드 모델은 Reactor 패턴 의 변형입니다. Reactor 패턴은 I/O 이벤트를 감지(demultiplex)하고 적절한 핸들러에 분배(dispatch)하는 구조인데, 구현 방식에 따라 세 가지 변형이 있습니다.

1. Single Reactor Single Thread

PLAINTEXT
Reactor 스레드 1개가 accept + I/O + 비즈니스 로직을 전부 처리

┌─────────────────────────────────────┐
│            Reactor (단일 스레드)       │
│                                     │
│   Selector                          │
│     │                               │
│     ├── accept → Handler(처리)       │
│     ├── read  → Handler(처리)        │
│     └── write → Handler(처리)        │
└─────────────────────────────────────┘
  • 구현이 가장 단순하다
  • 한 스레드가 모든 걸 처리하므로 CPU 코어를 활용하지 못한다
  • 핸들러에서 블로킹이 발생하면 모든 연결이 멈춘다
  • **적합 **: Redis처럼 처리가 매우 빠른 인메모리 작업

2. Single Reactor Multi Thread

PLAINTEXT
Reactor 1개가 이벤트를 감지하고, 워커 스레드 풀이 비즈니스 로직을 처리

┌──────────────────┐    ┌──────────────────┐
│  Reactor (1스레드) │    │  Worker Pool     │
│                  │    │  ┌────────────┐  │
│  Selector        │───▶│  │ Thread-1   │  │
│    │             │    │  │ Thread-2   │  │
│    ├── accept    │    │  │ Thread-3   │  │
│    ├── read      │    │  │ ...        │  │
│    └── write     │    │  └────────────┘  │
└──────────────────┘    └──────────────────┘
  • Reactor가 I/O 이벤트를 감지하고, 비즈니스 로직은 워커 스레드 풀에 위임
  • 멀티코어를 활용할 수 있지만, Reactor가 1개이므로 연결 수가 많아지면 병목
  • ** 적합 **: 연결 수가 적고, 비즈니스 로직이 무거운 경우

3. Multi Reactor Multi Thread (Netty)

PLAINTEXT
Boss Reactor가 연결을 수락하고, Worker Reactor가 I/O를 처리

┌────────────────┐    ┌──────────────────────────┐
│  Boss Reactor   │    │  Worker Reactors          │
│  (BossGroup)   │    │                          │
│                │    │  EventLoop-1 ── Channel-A │
│  Selector      │───▶│  EventLoop-2 ── Channel-B │
│    │           │    │  EventLoop-3 ── Channel-C │
│    └── accept  │    │  EventLoop-4 ── Channel-D │
│                │    │  ...                      │
└────────────────┘    └──────────────────────────┘

           필요 시 별도 스레드 풀 (DefaultEventExecutorGroup)
                    ┌────────────────────┐
                    │  Thread-1 (DB 쿼리) │
                    │  Thread-2 (API 호출) │
                    │  ...               │
                    └────────────────────┘

** 이것이 Netty의 기본 구조입니다.** ServerBootstrap에서 group(bossGroup, workerGroup)으로 설정하는 것이 바로 이 패턴입니다.

JAVA
// Netty의 Multi Reactor Multi Thread 구현
EventLoopGroup bossGroup = new NioEventLoopGroup(1);    // Boss Reactor (보통 1스레드)
EventLoopGroup workerGroup = new NioEventLoopGroup();   // Worker Reactors (기본: CPU 코어 수 × 2)
EventExecutorGroup dbGroup = new DefaultEventExecutorGroup(16); // 블로킹용 (선택)

ServerBootstrap b = new ServerBootstrap();
b.group(bossGroup, workerGroup)                        // Boss + Worker 구성
 .channel(NioServerSocketChannel.class)
 .childHandler(new ChannelInitializer<SocketChannel>() {
     @Override
     protected void initChannel(SocketChannel ch) {
         ch.pipeline().addLast(new MyCodec());          // Worker EventLoop에서 실행
         ch.pipeline().addLast(dbGroup, new DbHandler());// 별도 스레드 풀에서 실행
         ch.pipeline().addLast(new ResponseHandler());   // Worker EventLoop에서 실행
     }
 });

세 가지 패턴 비교

구분Single-SingleSingle-MultiMulti-Multi (Netty)
Reactor 수11N (Boss + Worker)
I/O 처리 스레드11Worker 수만큼
비즈니스 스레드같은 스레드별도 풀별도 풀 (선택)
연결 수락 병목있음있음없음 (Boss 분리)
복잡도낮음중간높음
대표 사례Redis일부 RPC 프레임워크Netty, Nginx

정리

  • Channel은 하나의 EventLoop에 고정 바인딩 되어, 동기화 없이 스레드 안전하다.
  • inEventLoop() 패턴 이 Netty 내부 곳곳에 있어서, 어떤 스레드에서 API를 호출해도 안전하게 동작한다.
  • @Sharable 핸들러 는 무상태이거나 thread-safe해야 한다. 확신이 없으면 매번 새 인스턴스를 생성하는 것이 안전하다.
  • DefaultEventExecutorGroup 으로 블로킹 핸들러를 별도 스레드 풀에서 실행할 수 있다. Channel별로 스레드가 고정되어 순서가 보장된다.
  • UnorderedThreadPoolEventExecutor 는 순서 보장 없이 더 높은 처리량을 제공한다. 독립적인 작업에 적합하다.
  • Netty는 Multi Reactor Multi Thread 패턴을 채택했다. Boss EventLoopGroup이 연결을 수락하고, Worker EventLoopGroup이 I/O를 처리한다.
댓글 로딩 중...