Spring Web MVC는 요청 하나당 스레드 하나를 할당합니다. 동시 요청이 수천 개로 치솟으면 스레드 풀이 고갈되고 응답 시간이 폭증하는데, 이 구조 자체를 바꿀 수는 없을까요?

리액티브 시스템과 리액티브 프로그래밍

리액티브 시스템(Reactive System) 은 동시에 많은 요청이 몰리더라도 응답 시간을 일정하게 유지하도록 설계한 시스템입니다. ** 리액티브 프로그래밍(Reactive Programming)** 은 이런 시스템을 구현하기 위한 프로그래밍 패러다임입니다.

전통적인 Spring Web MVC 서버가 요청마다 스레드를 할당하는 이유는 블로킹 I/O 때문입니다.

  1. 요청이 들어오면 ** 스레드 하나가 할당 **됩니다.
  2. DB 조회나 외부 API 호출 같은 I/O가 발생하면, 그 ** 스레드는 응답이 올 때까지 대기 **합니다.
  3. 동시 요청이 늘어나면 대기 중인 스레드가 쌓이고, ** 스레드 풀이 고갈되면 새 요청을 처리할 수 없게** 됩니다.

리액티브 프로그래밍은 이 문제를 근본적으로 뒤집습니다. I/O를 기다리지 않고 제어권을 즉시 반환하므로, ** 소수의 스레드로 수천 개의 동시 요청을 처리 **할 수 있습니다.

Reactive Manifesto에서 리액티브 시스템의 4가지 설계 원칙(Responsive, Resilient, Elastic, Message Driven)을 정의하고 있습니다.


리액티브 프로그래밍의 핵심 개념

위키백과에서는 리액티브 프로그래밍을 이렇게 정의합니다.

Reactive programming is a declarative programming paradigm concerned with data streams and the propagation of change.

이 정의에서 세 가지 핵심 키워드를 뽑을 수 있습니다: ** 선언형 **, ** 데이터 스트림 **, ** 변경 전파 **. 하나씩 살펴보겠습니다.


선언형 프로그래밍

** 선언형 프로그래밍 **은 “어떻게 할지”가 아니라 ”무엇을 할지”를 선언하고, 실행 방법은 라이브러리에 위임하는 방식 입니다.

1부터 100까지의 짝수를 출력하는 코드를 두 방식으로 비교하면 차이가 명확합니다.

  • 명령형 프로그래밍 — 반복·분기를 직접 제어합니다.
JAVA
for (int i = 1; i <= 100; i++) {
    if (i % 2 == 0) {
        System.out.println(i);
    }
}
  • ** 선언형 프로그래밍** — “무엇을 할지”만 선언합니다.
JAVA
IntStream.rangeClosed(1, 100)
    .filter(i -> i % 2 == 0)
    .forEach(System.out::println);

명령형에서는 forif로 절차를 상세하게 기술해야 하지만, 선언형에서는 filter, forEach 같은 연산자를 조합해 ** 의도만 표현 **합니다. 리액티브 프로그래밍은 이 선언형 방식을 비동기 데이터 흐름에 적용한 것입니다.


데이터 스트림

** 데이터 스트림(Data Stream)** 은 시간에 따라 순서대로 흘러오는 데이터(이벤트)의 연속적인 흐름입니다. 값 하나를 다루는 것이 아니라, ** 흐름 전체를 하나의 단위 **로 다룹니다.

  • 초당 수백~수천 건씩 발생하는 HTTP 요청
  • Kafka, RabbitMQ 등 ** 메시지 큐 **에서 전달되는 메시지

이런 데이터 스트림 위에 map, flatMap, filter 같은 연산자를 선언적으로 조합하여 데이터를 가공하고, 그 결과를 다음 스트림으로 전달하는 것이 리액티브 프로그래밍의 핵심 동작입니다.


변경 전파

** 변경 전파(The propagation of change)** 는 상위 연산에서 값이 변경되면, 그 변경이 하위 연산까지 자동으로 전달·반영되는 것을 의미합니다.

아래 코드로 동작을 확인해 보겠습니다.

JAVA
IntStream.rangeClosed(1, 10)
    .map(i -> i * i)                                 // 제곱: 1, 4, 9, …, 100
    .mapToObj(i -> “제곱한 값은 “ + i + “입니다.”)     // 변경된 값을 문자열로 변환
    .forEach(System.out::println);

위 코드에서 핵심은 앞 단계 map에서 값이 한 번 바뀌면, 그 바뀐 값이 자동으로 다음 연산으로 전달 된다는 점입니다.

  1. rangeClosed(1, 10)에서 1~10이 순서대로 흘러옵니다.
  2. 첫 번째 map에서 각 숫자가 제곱 되어 1, 4, 9, …, 100으로 변경됩니다.
  3. mapToObj는 이 변경된 값 을 받아 문자열로 다시 변환합니다.

이처럼 상위 연산의 변화가 하위 연산들로 자연스럽게 흘러 내려가는 것을 변경 전파 라고 합니다.


비동기·논블로킹 I/O

비동기·논블로킹 I/O 는 I/O 작업을 요청한 뒤 스레드가 멈추지 않고, 그 사이에 다른 작업을 계속 처리할 수 있게 해 주는 방식입니다.

방식동작스레드 상태
블로킹 I/OI/O 응답이 올 때까지 스레드가 대기유휴 상태로 자원 낭비
** 논블로킹 I/O**I/O 요청 후 즉시 제어권 반환다른 요청 처리 가능

리액티브 프로그래밍은 논블로킹 I/O 위에서 데이터 흐름을 이벤트 스트림으로 다루면서, ** 적은 스레드로 많은 동시 요청을 처리 **하는 것을 목표로 합니다.


주의할 점

리액티브가 항상 빠른 것은 아니다

리액티브 프로그래밍은 “빠른 응답”이 아니라 ”일정한 응답” 을 목표로 합니다. 동시 요청이 적은 환경에서는 MVC가 오히려 더 빠를 수 있습니다. 리액티브의 진가는 ** 동시 요청이 폭증하는 상황에서 응답 시간이 급격히 치솟지 않는다 **는 점에 있습니다.

선언형이라고 디버깅이 쉬운 것은 아니다

선언형 코드는 의도를 명확히 표현하지만, 스택 트레이스가 연산자 내부로 들어가면서 ** 디버깅이 명령형보다 어려워질 수 있습니다 **. Reactor의 checkpoint(), log() 연산자를 활용하면 디버깅에 도움이 됩니다.


정리

항목설명
리액티브 시스템동시 요청이 몰려도 응답 시간을 일정하게 유지하는 설계
선언형“무엇을 할지”만 선언, 실행은 라이브러리에 위임
데이터 스트림시간에 따라 흘러오는 데이터의 연속적 흐름
변경 전파상위 연산의 변경이 하위 연산으로 자동 전달
논블로킹 I/OI/O 대기 없이 제어권 즉시 반환, 적은 스레드로 높은 동시성
댓글 로딩 중...