리액티브 프로그래밍 시작하기
Spring Web MVC는 요청 하나당 스레드 하나를 할당합니다. 동시 요청이 수천 개로 치솟으면 스레드 풀이 고갈되고 응답 시간이 폭증하는데, 이 구조 자체를 바꿀 수는 없을까요?
리액티브 시스템과 리액티브 프로그래밍
리액티브 시스템(Reactive System) 은 동시에 많은 요청이 몰리더라도 응답 시간을 일정하게 유지하도록 설계한 시스템입니다. ** 리액티브 프로그래밍(Reactive Programming)** 은 이런 시스템을 구현하기 위한 프로그래밍 패러다임입니다.
전통적인 Spring Web MVC 서버가 요청마다 스레드를 할당하는 이유는 블로킹 I/O 때문입니다.
- 요청이 들어오면 ** 스레드 하나가 할당 **됩니다.
- DB 조회나 외부 API 호출 같은 I/O가 발생하면, 그 ** 스레드는 응답이 올 때까지 대기 **합니다.
- 동시 요청이 늘어나면 대기 중인 스레드가 쌓이고, ** 스레드 풀이 고갈되면 새 요청을 처리할 수 없게** 됩니다.
리액티브 프로그래밍은 이 문제를 근본적으로 뒤집습니다. 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까지의 짝수를 출력하는 코드를 두 방식으로 비교하면 차이가 명확합니다.
- 명령형 프로그래밍 — 반복·분기를 직접 제어합니다.
for (int i = 1; i <= 100; i++) {
if (i % 2 == 0) {
System.out.println(i);
}
}
- ** 선언형 프로그래밍** — “무엇을 할지”만 선언합니다.
IntStream.rangeClosed(1, 100)
.filter(i -> i % 2 == 0)
.forEach(System.out::println);
명령형에서는 for와 if로 절차를 상세하게 기술해야 하지만, 선언형에서는 filter, forEach 같은 연산자를 조합해 ** 의도만 표현 **합니다. 리액티브 프로그래밍은 이 선언형 방식을 비동기 데이터 흐름에 적용한 것입니다.
데이터 스트림
** 데이터 스트림(Data Stream)** 은 시간에 따라 순서대로 흘러오는 데이터(이벤트)의 연속적인 흐름입니다. 값 하나를 다루는 것이 아니라, ** 흐름 전체를 하나의 단위 **로 다룹니다.
- 초당 수백~수천 건씩 발생하는 HTTP 요청
- Kafka, RabbitMQ 등 ** 메시지 큐 **에서 전달되는 메시지
이런 데이터 스트림 위에 map, flatMap, filter 같은 연산자를 선언적으로 조합하여 데이터를 가공하고, 그 결과를 다음 스트림으로 전달하는 것이 리액티브 프로그래밍의 핵심 동작입니다.
변경 전파
** 변경 전파(The propagation of change)** 는 상위 연산에서 값이 변경되면, 그 변경이 하위 연산까지 자동으로 전달·반영되는 것을 의미합니다.
아래 코드로 동작을 확인해 보겠습니다.
IntStream.rangeClosed(1, 10)
.map(i -> i * i) // 제곱: 1, 4, 9, …, 100
.mapToObj(i -> “제곱한 값은 “ + i + “입니다.”) // 변경된 값을 문자열로 변환
.forEach(System.out::println);
위 코드에서 핵심은 앞 단계 map에서 값이 한 번 바뀌면, 그 바뀐 값이 자동으로 다음 연산으로 전달 된다는 점입니다.
rangeClosed(1, 10)에서 1~10이 순서대로 흘러옵니다.- 첫 번째
map에서 각 숫자가 제곱 되어1, 4, 9, …, 100으로 변경됩니다. mapToObj는 이 변경된 값 을 받아 문자열로 다시 변환합니다.
이처럼 상위 연산의 변화가 하위 연산들로 자연스럽게 흘러 내려가는 것을 변경 전파 라고 합니다.
비동기·논블로킹 I/O
비동기·논블로킹 I/O 는 I/O 작업을 요청한 뒤 스레드가 멈추지 않고, 그 사이에 다른 작업을 계속 처리할 수 있게 해 주는 방식입니다.
| 방식 | 동작 | 스레드 상태 |
|---|---|---|
| 블로킹 I/O | I/O 응답이 올 때까지 스레드가 대기 | 유휴 상태로 자원 낭비 |
| ** 논블로킹 I/O** | I/O 요청 후 즉시 제어권 반환 | 다른 요청 처리 가능 |
리액티브 프로그래밍은 논블로킹 I/O 위에서 데이터 흐름을 이벤트 스트림으로 다루면서, ** 적은 스레드로 많은 동시 요청을 처리 **하는 것을 목표로 합니다.
주의할 점
리액티브가 항상 빠른 것은 아니다
리액티브 프로그래밍은 “빠른 응답”이 아니라 ”일정한 응답” 을 목표로 합니다. 동시 요청이 적은 환경에서는 MVC가 오히려 더 빠를 수 있습니다. 리액티브의 진가는 ** 동시 요청이 폭증하는 상황에서 응답 시간이 급격히 치솟지 않는다 **는 점에 있습니다.
선언형이라고 디버깅이 쉬운 것은 아니다
선언형 코드는 의도를 명확히 표현하지만, 스택 트레이스가 연산자 내부로 들어가면서 ** 디버깅이 명령형보다 어려워질 수 있습니다 **. Reactor의 checkpoint(), log() 연산자를 활용하면 디버깅에 도움이 됩니다.
정리
| 항목 | 설명 |
|---|---|
| 리액티브 시스템 | 동시 요청이 몰려도 응답 시간을 일정하게 유지하는 설계 |
| 선언형 | “무엇을 할지”만 선언, 실행은 라이브러리에 위임 |
| 데이터 스트림 | 시간에 따라 흘러오는 데이터의 연속적 흐름 |
| 변경 전파 | 상위 연산의 변경이 하위 연산으로 자동 전달 |
| 논블로킹 I/O | I/O 대기 없이 제어권 즉시 반환, 적은 스레드로 높은 동시성 |