한 줄 정의

리액티브 프로그래밍은 비동기 데이터 스트림과 변경의 자동 전파를 중심으로 하는 선언적 패러다임으로, 가상 스레드 이전 시대에 논블로킹 I/O로 대규모 동시성 문제를 풀던 대표 기술입니다.

쉽게 말하면

식당 홀 서빙으로 비유해 봅시다.

  • 단일 스레드 블로킹 서버: 웨이터가 1명뿐인데, 한 테이블이 식사를 마칠 때까지 그 옆에 서서 기다립니다. 다른 손님은 입장조차 못 합니다.
  • 멀티스레드 서버: 테이블마다 웨이터를 1명씩 고용합니다. 돌아가긴 하지만 웨이터(플랫폼 스레드) 인건비가 비싸 무한정 늘릴 수 없습니다.
  • 리액티브(이벤트 루프): 아주 유능한 웨이터 1명이 절대 기다리지 않습니다. 주문을 주방에 넘기고 바로 다음 테이블로 가며, 요리가 완성되면 벨(이벤트)이 울릴 때 가져다줍니다. 단, 이 웨이터가 주방에서 직접 요리(블로킹 작업)를 시작하는 순간 홀 전체가 마비됩니다.
  • 가상 스레드: 인건비가 거의 0인 웨이터를 테이블마다 배치합니다. 각자 기다려도 비용이 들지 않으니, “기다리지 않는 기술”을 따로 배울 필요 없이 익숙한 방식 그대로 일하면 됩니다.

이 장의 질문은 하나입니다. 인건비 공짜 웨이터(가상 스레드)가 생긴 지금, 기다리지 않는 기술(리액티브)을 여전히 배워야 하는가?

왜 중요한가?

Quote

가상 스레드가 리액티브 프로그래밍을 사라지게 할 거라고 생각한다. … 리액티브 프로그래밍은 과도기적 기술이었다. — 브라이언 괴츠(Brian Goetz)

블로킹 I/O에서는 요청마다 스레드가 묶이고, 플랫폼 스레드는 비싸서 이 방식으로는 확장에 한계가 있습니다. 가상 스레드가 등장하기 전까지 이 문제의 주류 해법이 리액티브 프로그래밍(프로젝트 리액터, RxJava, 이클립스 버트엑스)이었고, 지금도 실무 코드베이스(Spring WebFlux 등)에 광범위하게 남아 있습니다.

리액티브가 어떤 문제를 어떻게 풀었는지 이해해야, 기존 리액티브 코드를 다룰 수 있고 새 시스템에서 가상 스레드와 리액티브 중 무엇을 선택할지 근거 있게 판단할 수 있습니다.

리액티브 시스템의 뿌리는 2013년 리액티브 선언문(Reactive Manifesto)입니다. 응답성(responsive), 회복성(resilient), 신축성(elastic), 메시지 기반(message-driven)이라는 네 원칙을 제시했고, 기술적 구현에서는 논블로킹·이벤트 기반·비동기라는 세 측면으로 귀결됩니다.

핵심 내용

블로킹 서버에서 논블로킹 서버까지

같은 HTTP/1.1 파이프라이닝 서버를 세 가지 방식으로 구현하며 차이를 보여줍니다.

구현방식한계·특징
BlockingHttpServer단일 스레드. accept()·parseRequest()·처리 모두 블로킹/slow 요청 하나(30초)가 서버 전체를 멈춤. fast-slow-fast 파이프라인 전체가 약 48초
MultithreadedHttpServer고정 스레드 풀(10), 연결마다 별도 스레드동시 처리는 되지만 플랫폼 스레드 비용 탓에 수를 늘려도 성능 한계
NonBlockingHttpServer (NIO)단일 스레드 이벤트 루프 + Selector스레드 하나로 수천 연결 처리. 부하 테스트(연결 10개 × 요청 100개): 2,086ms, 약 479 req/s

가상 스레드라면 멀티스레드 서버에서 executor 한 줄만 Executors.newVirtualThreadPerTaskExecutor()로 바꾸면 확장성이 해결됩니다. 이 “한 줄”이 이 장 전체를 관통하는 복선입니다.

NIO의 세 가지 축: Channel, Buffer, Selector

JDK 1.4의 NIO는 준비 상태(readiness) 기반 논블로킹 API로, 스트림 중심 java.io를 대체합니다.

구성요소역할
ChannelSocketChannel·FileChannel 등 양방향 통신 단위. configureBlocking(false)가 필수
Buffer데이터를 배치로 담는 임시 공간. 시스템 콜 횟수를 줄이고, 다이렉트 버퍼·메모리 매핑으로 사용자-커널 공간 간 제로 카피 전송 가능
Selector단일 스레드로 여러 채널의 준비 이벤트(OP_ACCEPT/OP_READ/OP_WRITE/OP_CONNECT)를 감시하는 교통 관제사. OS별 구현(맥OS kqueue 등)은 다르지만 개념은 동일

논블로킹 read()/write()는 호출 즉시 반환되며 전송 바이트가 0일 수도 있습니다. 부분 읽기·부분 쓰기를 애플리케이션이 직접 다뤄야 한다는 뜻이고, 이것이 NIO 코드가 길고 어려워지는 근본 이유입니다.

SelectionKey는 셀렉터 스레드에서만 수정합니다

자바 문서의 “셀렉션 키는 여러 동시 스레드에서 안전하게 사용할 수 있다”는 읽기가 안전하다는 뜻일 뿐, interestOps() 수정이 원자적이라는 뜻이 아닙니다. select() 실행 중에 다른 스레드가 interestOps를 수정하면 변경 유실, I/O 이벤트 누락, CancelledKeyException, 고부하에서만 간헐적으로 나타나는 이상 동작이 생깁니다.

해법이 pendingUpdates 큐 패턴입니다. 비동기 스레드는 스레드 안전한 큐에 변경 요청을 넣고 selector.wakeup()만 호출하며, 실제 수정은 셀렉터 루프 스레드가 수행합니다.

이벤트 기반 아키텍처와 황금률

NIO를 직접 다루는 대신, 네티·버트엑스 같은 프레임워크가 이벤트 루프 기반 동시성 모델을 제공합니다. 소수의 이벤트 루프 스레드가 I/O 이벤트를 대기·처리하고, 애플리케이션은 짧고 논블로킹인 이벤트 핸들러만 등록합니다.

flowchart TB
    C1[클라이언트] & C2[클라이언트] & C3[클라이언트] -->|메시지| EL[싱글 스레드 논블로킹 이벤트 루프]
    EL --> H1[이벤트 핸들러] & H2[이벤트 핸들러] & BH[블로킹 이벤트 핸들러]
    BH -->|위임| W[워커 스레드]

황금률은 하나입니다. 이벤트 루프를 절대 블로킹하지 않는다. 핸들러는 I/O 스레드에서 실행되므로, 여기서 블로킹하면 다른 모든 이벤트 처리가 멈추고 시스템 전체의 응답성이 무너집니다. 버트엑스 예제에서 느린 응답을 Thread.sleep()이 아니라 vertx.setTimer(2000, ...)로 예약하는 이유입니다.

블로킹 작업은 워커 스레드 풀로 위임할 수 있지만, I/O 스레드 ↔ 워커 스레드 컨텍스트 스위칭이 반복되면 기대했던 효율을 잠식하므로 일반 해법이 되어서는 안 됩니다.

비동기 API의 진화

논블로킹 코드를 쓰려면 API 자체가 비동기여야 합니다. 같은 chat() 메서드로 단계별 진화를 보여줍니다.

단계형태남는 문제
동기 APIString chat(String msg)호출 스레드가 응답까지 블로킹
콜백chat(msg, Consumer<String>)연쇄 호출 시 중첩이 깊어지는 콜백 지옥(callback hell)
CompletableFuturethenCompose/thenAccept 조합단일 결과에는 강력하지만, 연속적인 데이터 흐름은 다루지 못함
리액티브 스트림Flux 파이프라인데이터 스트림 + 배압까지 해결 (대신 학습 곡선)

리액티브 스트림의 구성요소

리액티브 스트림은 배압(backpressure)을 사용해 비동기 데이터 흐름을 관리하는 표준(리액티브 스트림 명세)입니다.

  • 4가지 구성요소: 발행자(publisher, 데이터 생산) · 구독자(subscriber, 수신·처리) · 구독(subscription, 둘을 연결하며 배압 제어) · 프로세서(processor, 구독자이자 발행자)
  • 3가지 시그널: 데이터 항목, 에러 신호(스트림 종료), 완료 신호(정상 종료)
  • 스트림은 지연 실행됩니다. 구독자가 생기기 전까지 아무 처리도 시작되지 않습니다.
라이브러리타입
프로젝트 리액터Flux<T> (0~N개), Mono<T> (0~1개)
RxJavaObservable<T> (0~N개), Single<T> (정확히 1개), Maybe<T> (0~1개)

암호화폐 가격 모니터링 예제에서 쓰인 핵심 연산자들이 리액티브의 표현력을 보여줍니다.

연산자역할
merge여러 거래소 스트림을 도착 순서대로 하나로 결합
groupBy심볼별 서브 스트림으로 분할해 명시적 스레드 관리 없이 병렬 처리
window(시간)시간 기반 구간 묶음 — 이동 평균 계산
buffer(2, 1)슬라이딩 윈도 — 연속 두 값 비교로 급등락 감지
Sinks명령형 코드와 리액티브 코드를 잇는 다리. 수동으로 항목 방출
doOnNext데이터 흐름에 영향 없이 로깅 같은 부수 효과 수행

Note

리액티브 스트림은 자바 스트림(java.util.stream)과 다릅니다. 자바 스트림은 컬렉션을 메모리 내에서 동기적으로 처리하는 API이고, 리액티브 스트림은 비동기·이벤트 기반·논블로킹 데이터 처리에 배압까지 지원합니다.

배압: 소비자가 생산자에게 “천천히!”

배압은 다운스트림 구독자가 더 이상 처리할 수 없을 때 업스트림 발행자에게 속도를 줄이라고 신호를 보내는 메커니즘입니다. 초당 10,000건의 초고빈도 시세 피드 시나리오로 리액터의 전략들을 비교합니다.

전략동작적합한 상황
onBackpressureBuffer()소비자가 따라올 때까지 전부 버퍼링데이터 손실 불가, 메모리 충분
onBackpressureBuffer(maxSize)제한 버퍼, 초과 시 실패메모리 고갈 방지 안전장치
onBackpressureDrop()초과분을 조용히 폐기완전성보다 최신성이 중요한 실시간 데이터
onBackpressureLatest()최신 1개만 유지중간 값이 무의미한 상태 업데이트
onBackpressureError()배압 발생 시 즉시 실패배압 자체가 설계 결함인 경우
sample(주기)주기적 스냅샷만 취함정기 업데이트만 필요할 때

publishOn(Schedulers.boundedElastic())으로 생산과 소비를 다른 스레드 풀로 분리할 수 있습니다.

같은 시스템, 두 가지 구현

같은 가격 모니터링 시스템을 가상 스레드로 다시 구현해 나란히 비교하면 두 패러다임의 차이가 선명해집니다.

관심사리액티브 (리액터)가상 스레드
주기 작업Flux.intervalwhile + Thread.sleep 명시적 루프
동시성groupBy 등 연산자가 암묵적으로 처리Thread.startVirtualThread() 명시적 생성
공유 상태불변 변환 파이프라인AtomicReference·synchronized 수동 동기화
배압onBackpressureDrop() 등 내장BlockingQueue로 직접 구현
코드 성격데이터 변환 흐름의 선언 — 설명문처럼 읽힘명령의 나열 — 익숙하지만 동기화·상태 관리에 더 주의 필요

비교 / 트레이드오프

리액티브 프로그래밍의 장단점

장점
  • 논블로킹 연산과 비동기 실행에 기반해 최소 자원으로 대규모 동시 요청 처리 — I/O 집중 작업에서 확장성·성능 우위
  • 함수형·선언적 스타일과 풍부한 연산자 — 잘 쓰면 데이터 스트림 조합의 가독성·유지보수성이 좋음
  • 분산 컴포넌트 간 데이터 흐름 처리에 강해 클라우드 네이티브·마이크로서비스 아키텍처와 잘 맞음
단점
  • 명령형에 익숙한 개발자에게 가파른 학습 곡선 — 사고방식 전환이 필요
  • 디버깅이 어렵기로 악명 높음. 스택 트레이스가 리액터 내부 호출로 가득 차고, 코드의 논리적 흐름과 실제 실행 흐름이 완전히 다름
  • 보기에는 우아하지만 수정 시 의도치 않은 부작용을 예측하기 어려움
  • 가상 스레드와 마찬가지로 CPU 집중 작업에는 부적합

가상 스레드냐 리액티브냐

두 패러다임의 목표는 같습니다. I/O 집중 작업의 효율적 관리.

  • 가상 스레드가 유리한 경우: 높은 동시성의 I/O 집중 애플리케이션, 기존 동기식 코드와의 연동. 논블로킹 API로 재작성하지 않고 OS 스레드를 가상 스레드로 대체할 수 있고, 익숙한 명령형 스타일이라 진입 장벽과 인지 부담이 낮습니다.
  • 리액티브가 여전히 유효한 경우: 세밀한 배압 제어, 복잡한 데이터 변환, 이벤트 기반 마이크로서비스. 가상 스레드에는 직관적인 내장 배압 메커니즘이 없어, 직접 구현하면 그 책임이 전적으로 개발자에게 돌아갑니다.

둘 중 하나를 골라야 한다면 가상 스레드 쪽으로 기울지만, 당분간은 공존이 유력합니다. 수렴의 초기 징후도 있습니다 — 버트엑스 4.5는 가상 스레드 실험을 시작했고, 리액티브 라이브러리가 가상 스레드를 활용하거나 구조적 동시성 원칙이 리액티브 모델에 영향을 줄 가능성이 있습니다.

내 생각

  • “리액티브가 죽는다”가 아니라 “리액티브를 기본값으로 쓸 이유가 사라진다”에 가깝습니다. WebFlux를 도입한 이유가 단지 “블로킹 스레드가 아까워서”였다면 가상 스레드 + 익숙한 MVC로 돌아가는 것이 합리적이고, 스트림 변환·배압이 도메인 요구 자체(실시간 시세, 이벤트 파이프라인)라면 여전히 리액터가 맞는 도구입니다.
  • pendingUpdates 큐 패턴은 NIO 밖에서도 재사용되는 지식입니다. “공유 자원 수정은 소유 스레드로 큐잉해 위임한다”는 원칙은 네티의 EventLoop.execute(), 카프카 클라이언트 내부 등 이벤트 루프 계열 코드에서 반복해서 만나게 됩니다.
  • 디버깅 비용은 기술 선택의 실질적 기준입니다. 장애 상황에서 리액터 내부 호출로 가득한 스택 트레이스를 읽어내는 비용은 코드 몇 줄의 우아함보다 비쌉니다. 팀 전체의 리액티브 숙련도가 담보되지 않으면 유지보수 부채가 됩니다.

관련 개념