유성

WebFlux의 내부 파이프라인, Publisher의 구성과 내부 작동 방식 본문

Architecture

WebFlux의 내부 파이프라인, Publisher의 구성과 내부 작동 방식

백엔드 유성 2026. 1. 2. 17:41

글을 쓰기 앞서 지연로딩과 파이프라인에 대해서 알아야 하므로 필요하면 아래 글을 참고하자.

https://youseong.tistory.com/97

 

Java Stream API의 핵심 개념과 병렬 처리의 함정

Java 8에 도입된 Stream API는 반복문 위주의 명령형 코드를 선언형 스타일로 바꾸고,더 나아가 병렬 처리를 손쉽게 구현할 수 있도록 도와주는 강력한 도구입니다. 이 글에서는 기본적인 사용법은

youseong.tistory.com

 

1. WebFlux 코딩은 "로직의 조립" 이다

WebFlux 코드를 처음 접하면 "왜 바로 실행되지 않지?" 라는 의문이 든다.

그 이유는 코드가 실행되는것은 맞으나, WebFlux 관점에서는 조립이라는 개념으로 등장하기 때문이다.

 

예를 들어 코드를 하나 보자.

@GetMapping
public Mono<Void> monoTest() {
    log.info("Outside Mono.defer"); // 1
    Mono<Object> objectMono = Mono.fromRunnable(() -> log.info("Inside Mono.defer")); // 2
    return Mono.empty();
}

 

이 코드를 실행하면 "Outside Mono.defer" 이라는 로그 하나만 찍히게 된다. 그럼 2번째 코드가 실행되지 않는것일까?

결론은 모두 실행이 된다.

 

이렇게 구성된 WebFlux 코드를 하나씩 뜯어보면 아래 순서대로 동작한다.

  1. log.info("Outside Mono.defer") 로그가 수행되어 화면에 출력 된다.
  2. Mono.fromRunnable(() -> log.info("Inside Mono.defer")) 코드가 수행되어 객체(파이프라인)에 담긴다.

2번째 로그가 출력되지 않는 이유는 파이프라인에 담겨있을 뿐 내부적인 코드는 실행되지 않는 것과 같다.

 

좀 풀어서 Tomcat 구조로 기능을 동일하게 작성하면 다음과 같다.

@GetMapping
public void monoTest() {
    log.info("Outside Mono.defer");
    LinkedList<Runnable> tasks = new LinkedList<>();
    tasks.add(() -> log.info("Inside Mono.defer"));
}

 

 

LinkedList에 로그를 찍는 로직을 담아두기만 했을 뿐, tasks를 실행하는 코드가 없어서 표출이 안된것이다.

 

이것을 WebFlux에서는 로직을 "조립(Assembly)" 했다 라고 표현한다.

사실상 "실행" 한것은 맞으나, 지연처리를 위해 "체인(LinkedList)" 에 담아두는 실행 을 "조립"했다 라고 표현하는 것일 뿐이다.

 

그 "체인"을 구성하기 위해서 Java는 람다식을 사용할 수 있다.

 

그러면 just는 왜 먼저 실행될까? (왜 먼저 실행되는 것 처럼 보일까)

WebFlux를 사용하면서 문제가 발생할 수 있는 코드가 있다.

Mono<Void> pipeline = userRepsitory.findById(1L)
    .then(Mono.just(orderRepository.findByUserId(1L)))
    .flatMap(orders -> {

 

기능을 작성할 때 userId가 1L 인 유저를 가져오고, 이후에 order 테이블에서 유저Id가 1L인 정보를 가져올 것으로 예상할 수 있다.

 

그러나 실행을 해보면 order 테이블의 데이터를 (가장)먼저 가져오게 된다.

그 이유는 위에서 설명한 것과 동일하게 해석이 가능하다.

  1. 유저 정보를 가져오는 로직을 파이프라인에 추가한다. (로직을 추가)
  2. 주문 정보를 가져와 다음 파이프라인에 추가한다. (값을 추가)
  3. 이후 처리를 파이프라인에 추가한다.

 

조금만 더 쉽게 설명하기 위해 Tomcat 구조를 또 가져오자.

LinkedList<Object> tasks = new LinkedList<>();
tasks.add(() -> userRepsitory.findById(1L)); //로직을 파이프라인에 추가
tasks.add(orderRepository.findByUserId(1L)); //계산된 값을 파이프라인에 추가
tasks.add(이후 처리);

 

이렇게 보면 아주 명확하게 보인다. 코드는 분명히 위에서 아래로 순차적으로 실행되었다.

그러나 주문 정보를 가져오는 메서드가 (메서드 호출로) 중간에 실행이 되어,

WebFlux에서는 주문 데이터 검색이 먼저 수행된것으로 나타난 것이다.

 

이게 처리상 문제는 없을 수 있으나, 조립 시점에 수행되므로 I/O 병목을 발생시키고 이벤트 루프 스레드를 WAIT 상태로 점유하기에 큰 문제를 발생시킬수도 있다.

 

2. 로직의 수행

WebFlux로 코드를 작성하면 마지막에 subscribe() 가 수행되어야 한다.

이는 조립된 파이프라인을 동작하게 하는 "시작 버튼" 이다.

 

우리가 작성한 Controller의 return 단에는 이 버튼이 숨겨져있으며, 서버 엔진의 Netty가 이 설계도를 받아 비로소 버튼을 누르게 된다.

버튼이 눌리면 체이닝된 로직들이 여러 개의 Publisher를 통해 비동기적으로 수행된다.

여기서 중요한 점은 "이전 단계의 결과를 어떻게 기다리는가" 이다.

 

파이프라인에 3개의 로직이 체이닝되어있고, 각각의 결과를 사용해야한다고 했을 때 Java는 두 가지 방식을 선택할 수 있다.

 

동기적 기다림 (Future 방식)

전통적인 JPA나 JDBC 환경에서 주로 사용되는 방식이다. 비동기처럼 보여도 내부적으로는 Future를 사용하면서 결과가 생성될 때까지 기다린다. 이때 데이터를 기다리는 스레드는 WAIT 상태가 되며, 응답이 올 때까지 아무 일도 못 하고 발이 묶이다.

비동기적 대응 (Selector 방식)

WebFlux와 R2DBC의 핵심으로 내부적으로 Selector를 사용하여 데이터 생성을 감시한다.

데이터를 기다리는 동안 그 어떤 스레드도 발이 묶이지 않는다. 스레드는 "데이터가 도착하면 나한테 알려줘" 라는 예약만 남기고 다른 요청을 처리하러 떠나버린다.

 

Selector를 관리하는 Publisher

여기서 Publisher라는 Webflux의 핵심 역할이 등장한다. Publisher는 단순한 데이터 묶음이 아니라 OS(Selector)가 던져주는 비동기 이벤트를 관리하는 주체이다.

  • OS가 "데이터 중비 완료" 이벤트를 발생시키면, Publisher가 이를 감지하여 우리가 조립해둔 파이프라인 으로 데이터를 주입한다.
  • 하나의 Controller에 몇 개의 Publisher가 붙느냐에 따라, 이벤트 루프 스레드가 관리해야 할 Event의 단위가 정의된다.

 

3. 그럼 몇개의 Publisher가 이를 관리하고 내부적으로 어떻게 처리되나.

단순히 로직만 만들어서 보면 몇개의 로직이 몇개의 Publisher로 쪼개지고 처리될지 감을 잡기란 쉽지 않다.

 

다행이도 WebFlux 의존성에 "마블 다이어그램" 형태로 그림을 그려놓았기에 쉽게 이를 파악할 수 있다.

Mono class에서 javadoc을 확인해보면 이와 같은 그림이 있다.

 

flatMap에 대한 마블 다이어그램

흰색 영역은 flatMap을 처리하는 내부동작을 뜻한다.

 

세로선을 하나씩 뜯어보면 다음과 같다.

  • 세로선 1: 상위 데이터의 도착과 내부 파이프라인 조립
    • 상태: 메인 파이프라인에서 데이터와 함께 이를 처리할 로직(설계도)이 내려온 시점
    • 동작: flatMap은 이 데이터를 받자마자 람다를 풀어, 해당 데이터를 처리할 전용 내부 Publisher를 생성
  • 세로선 2: 내부 Publisher의 구독 및 감시 등록
    • 상태: 파란색 점선이 위로 올라가며 내부 구독(Subscribe)이 시작되는 시점
    • 동작: 비동기 엔진(Netty/NIO)의 Selector에게 "데이터가 준비되면 알려달라"고 감시를 등록
  • 세로선 3: 상위 Publisher의 공급 종료 (OnComplete)
    • 상태: 상단 라인에서 "더 이상 새로 줄 재료는 없다"는 시그널(이벤트)이 전달된 시점
    • 동작: 이제 flatMap은 새로운 내부 파이프라인을 더 만들지 않고, 이미 가동 중인 내부 처리들이 끝나기만을 기다리는 상태가 됨
  • 세로선 4: 데이터 주입 및 하위 파이프라인 전파
    • 상태: Selector가 데이터를 가져와 내부 라인에 데이터가 나타난 시점
    • 동작: 데이터를 받은 flatMap은 하위 파이프라인에게 "이제 네가 Selector를 등록하고 작업을 이어가라"고 요청하며 실제 로직을 수행
  • 세로선 5: 최종 완료 시그널 및 이벤트 전파
    • 상태: 내부 작업이 모두 끝나고 외부 라인에 결과 데이터가 나타난 시점
    • 동작: 하위 파이프라인에게 "나의 모든 작업이 정말로 끝났다"는 종료 시그널(OnComplete 이벤트)을 전달

 

4. WebFlux 개발에 대해서

WebFlux 기반의 개발을 진행할 때 가장 신경써야 하는 부분은 '실행할 코드를 작성하는지' 아니면 '조립할 코드를 작성하는지' 명확하게 분리하고 코드 설계를 진행해야 하는 것이다.

 

조립 시점의 코드 작성

  • 조립 시점에는 파이프라인의 연결 구조(LinkedList 구조)만 형성됨을 인지해야 한다.
  • 이 단계에서 Mono.just()와 같은 메서드를 I/O 작업과 함께 사용한다면 조립 중간에 실행을 하고있다는 것을 파악해야 한다.
  • 따라서 모든 비동기 작업과 I/O는 람다식 안에 패키징하여, 조립 시점에는 그저 "실행 가능한 상태"로만 존재하게 만들어야 한다.

실행 시점의 코드 작성

  • 실행은 Netty가 구독 버튼을 눌러야지만 시작이 가능하다.
  • 실행 시점에는 Selector가 OS 이벤트를 감지하여 데이터를 주입하고, 우리가 조립해둔 람다들을 순차적으로 실행시키다.
  • 데이터가 흐르지 않는 공백기 동안 스레드는 꼭 반납 되어야 하며, 오직 '완료 시그널'이나 '데이터 시그널'이 올 때만 스레드가 개입하여 로직을 수행해야 한다.

글을 마치며, 이 두가지를 충분히 고려하고 설계 해야만 하며, 혹시라도 이벤트 루프 스레드를 WAIT로 묶어두는 상태가 아닌지 더블체크가 필요하다.