본 포스팅은 패스트캠퍼스 환급 챌린지 참여를 위해 작성하였습니다.

 

 

 

강의 요약

오늘 강의에서는 Spring WebFlux의 컨트롤러 작성 방식과 서비스, 레파지토리 코드 실습을 진행했다. WebFlux는 Annotation 기반 컨트롤러와 Functional Endpoint 두 가지 방식을 제공하며, 각각의 장단점이 존재한다. Annotation 방식은 기존 Spring MVC와 유사하여 익숙하고, Functional Endpoint는 RouterFunction과 HandlerFunction을 조합한 함수형 스타일로 더 명시적인 라우팅을 제공한다. 그런데 컨트롤러에서 Mono, Flux를 응답하는데 응답값이 반환되는 것일까? 알아보도록 하자.

 

WebFlux 컨트롤러와 구독의 시작점

WebFlux를 처음 접하면 가장 궁금한 점이 있다. 컨트롤러에서 Mono나 Flux를 반환하면 누가 subscribe를 호출하는가?

 

컨트롤러에서 Mono 반환

@RestController
public class UserController {

    @GetMapping("/user/{id}")
    public Mono<User> getUser(@PathVariable Long id) {
        return userRepository.findById(id);
    }
}

  • 위 코드에서 userRepository.findById(id)는 Mono를 반환하지만, 실제 데이터베이스 조회는 아직 시작되지 않았다.
  • Mono는 단지 "나중에 실행될 작업의 명세"일 뿐이다.

 

구독의 주체: DispatcherHandler

Spring WebFlux의 DispatcherHandler가 컨트롤러가 반환한 Mono/Flux를 구독한다.

HTTP 요청 도착
    ↓
Netty EventLoop Thread가 요청 수신
    ↓
DispatcherHandler 실행
    ↓
컨트롤러 메서드 호출 → Mono<User> 반환
    ↓
DispatcherHandler가 Mono.subscribe() 호출
    ↓
실제 작업 시작 (DB 조회 등)

구독이 발생하는 시점의 스레드가 중요하다. 기본적으로 Netty의 EventLoop Thread에서 구독이 발생한다.

 

구독 이후의 실행 흐름

@GetMapping("/user/{id}")
public Mono<User> getUser(@PathVariable Long id) {
    return userRepository.findById(id);  // R2DBC 사용
}

 

  1. EventLoop Thread가 HTTP 요청 수신
  2. EventLoop Thread가 DispatcherHandler 실행
  3. EventLoop Thread가 컨트롤러 호출
  4. EventLoop Thread가 반환된 Mono를 subscribe
  5. R2DBC 드라이버가 DB에 논블로킹 쿼리 전송
  6. EventLoop Thread는 즉시 다른 요청 처리로 이동
  7. DB 응답 도착 시 EventLoop에 이벤트 발생
  8. EventLoop Thread가 결과 처리 및 HTTP 응답 전송

모든 과정이 동일한 EventLoop Thread에서 실행된다. 별도의 스레드 전환 없이 하나의 스레드가 처음부터 끝까지 처리하지만, I/O 대기 시간에는 다른 요청을 처리한다.

 

Netty와 EventLoop 아키텍처

WebFlux의 성능 비밀은 Netty의 EventLoop 구조에 있다. 먼저 Java NIO의 기본 개념부터 이해해보자.

  • Java NIO는 논블로킹 I/O를 지원하는 핵심 API다.

 

Channel

  • 양방향 통신 채널 (읽기/쓰기 동시 가능)
  • 논블로킹 모드 설정 가능
  • SocketChannel, ServerSocketChannel 등

Selector

  • 여러 Channel을 하나의 스레드로 관리
  • 준비된 I/O 이벤트만 선택적으로 처리
  • I/O Multiplexing의 핵심
// Selector 기반 논블로킹 서버 (단순화)
Selector selector = Selector.open();
serverChannel.configureBlocking(false);
serverChannel.register(selector, SelectionKey.OP_ACCEPT);

while (true) {
    selector.select();  // 준비된 이벤트 대기

    Iterator<SelectionKey> keys = selector.selectedKeys().iterator();
    while (keys.hasNext()) {
        SelectionKey key = keys.next();
        keys.remove();

        if (key.isAcceptable()) {
            SocketChannel client = serverChannel.accept();
            client.configureBlocking(false);
            client.register(selector, SelectionKey.OP_READ);
        } else if (key.isReadable()) {
            SocketChannel client = (SocketChannel) key.channel();
            ByteBuffer buffer = ByteBuffer.allocate(1024);
            client.read(buffer);  // 논블로킹 읽기
        }
    }
}

  • 하나의 스레드가 Selector를 통해 여러 Channel의 이벤트를 처리한다.
  • 이를 I/O Multiplexing이라 한다.

 

Netty: NIO 위의 추상화

Netty는 Java NIO를 더 쉽게 사용할 수 있도록 추상화한 프레임워크다. Spring WebFlux의 기본 웹 서버가 바로 Netty다.

 

BossGroup과 WorkerGroup

  • Netty는 두 가지 EventLoopGroup을 사용한다.
클라이언트 연결 요청
    ↓
[BossGroup - 1개 스레드]
  Boss EventLoop (ServerSocketChannel 관리)
    ↓ (연결 수락 후 할당)
[WorkerGroup - CPU 코어 수 스레드]
  ├─ Worker EventLoop 1 → Channel 1, 2, 3, 4...
  ├─ Worker EventLoop 2 → Channel 101, 102, 103...
  ├─ Worker EventLoop 3 → Channel 201, 202, 203...
  └─ Worker EventLoop 4 → Channel 301, 302, 303...

 

BossGroup

  • 역할: 클라이언트 연결 요청 수락 (OP_ACCEPT)
  • 스레드 개수: 1개
  • 작업: ServerSocketChannel에서 accept() 수행
  • 스레드 타입: Non-Daemon (JVM 유지)

WorkerGroup

  • 역할: 실제 I/O 작업 처리 (OP_READ, OP_WRITE)
  • 스레드 개수: CPU 코어 수 (기본값)
  • 작업: 읽기, 쓰기, 비즈니스 로직 실행
  • 스레드 타입: Daemon

4코어 시스템이라면 1개의 Boss Thread와 4개의 Worker Thread가 생성된다.

 

EventLoop의 무한 루프

EventLoop는 단일 스레드가 무한 루프를 돌며 이벤트를 처리한다.

// NioEventLoop 내부 동작 (단순화)
protected void run() {
    for (;;) {  // 무한 루프
        // 1. Selector를 통해 준비된 I/O 이벤트 확인
        int readyChannels = selector.select();

        if (readyChannels > 0) {
            // 2. I/O 이벤트 처리
            processSelectedKeys();
        }

        // 3. 태스크 큐의 작업 실행
        runAllTasks();
    }
}

하나의 EventLoop가 반복하는 작업:

  1. Selector.select(): 준비된 I/O 이벤트 대기
  2. processSelectedKeys(): 준비된 Channel의 I/O 작업 처리
  3. runAllTasks(): 태스크 큐에 쌓인 작업 실행

 

EventLoop와 Channel의 관계

EventLoop 1 (단일 스레드)
    ├─ Channel A: 읽기 → 처리
    ├─ Channel B: I/O 대기 중 (논블로킹이므로 스킵)
    ├─ Channel C: 쓰기 → 처리
    ├─ Channel D: I/O 대기 중 (논블로킹이므로 스킵)
    └─ Channel E: 읽기 → 처리

핵심은 하나의 EventLoop가 여러 Channel을 순차적으로 처리하지만, I/O 작업이 논블로킹이라는 점이다. Channel B가 I/O를 기다리는 동안 EventLoop는 멈추지 않고 다른 Channel을 처리한다.

 

제약사항

  • 하나의 EventLoop에서 블로킹 작업이 발생하면 해당 EventLoop가 관리하는 모든 Channel이 영향을 받는다
  • 따라서 블로킹 작업은 반드시 별도 스레드 풀(Scheduler)로 오프로드해야 한다.

 

참고 출처

 

 

시작 시간
종료 시간
학습 인증 - 디지털 필기
수강 인증

 

 

 

https://fastcampus.info/4oKQD6b

+ Recent posts