리액티브 스트림즈 9편 - 가상 스레드와 리액티브의 미래

2024-01-14 · 엔지니어링 · 시리즈 · 리액티브 스트림즈
리액티브 스트림즈 9편 - 가상 스레드와 리액티브의 미래

벌써.. 리액티브 스트림즈를 1편 작성한 지 시간이 꽤 흐른 것 같습니다.. 한편 한편 정성스럽게 작성했다고 생각했는데 보시는 분들은 어떻게 보셨을지 모르겠습니다 ㅎㅎ 🙈

실은 이 시리즈를 모두 작성하고 이걸 인프런 같은 강의 플랫폼에 강의를 내보려고 이것저것 준비를 해보고 있었는데.. 가상 스레드(Virtual Threads)라는 친구가 나와서 조금 망설여졌습니다. 이 포스팅에서 다루겠지만 가상 스레드가 제대로 자리 잡히면 리액티브 스트림즈, 아니 WebFlux의 실효성이 많이 옅어질 것 같다고 느꼈기 때문입니다. 그럼에도 불구하고 WebFlux가 아닌 리액티브 스트림즈가 주는 가치는 단순히 비동기 그 이상의 가치가 있다고 누누이! 제가 1편부터 강조해온 만큼, 가상 스레드와 리액티브의 미래는 앞으로 어떻게 될지? 감히 한번 예상해보는 글이라고 봐주시면 좋을 것 같습니다.

가상 스레드가 바꾼 전제

리액티브가 출발한 가장 큰 동기는 1편에서 본 “블로킹이 비싸다”였습니다. 요청마다 OS 스레드를 붙이면 I/O 대기 동안 그 비싼 스레드가 놀면서 자리만 차지하니까요.

가상 스레드(Java 21에서 정식 도입)는 이 전제를 흔듭니다. 가상 스레드는 OS 스레드가 아니라 JVM이 관리하는 아주 가벼운 스레드라, 수만 개를 만들어도 부담이 적습니다. 블로킹 호출을 만나면 그 가상 스레드는 바탕의 캐리어 스레드에서 잠깐 내려왔다가(unmount), 응답이 오면 다시 올라탑니다. 즉 블로킹을 해도 OS 스레드가 묶이지 않습니다.

가상 스레드는 수많은 가벼운 스레드를 소수 캐리어 위에 얹어 블로킹 코드를 그대로 활용할 수 있고, 리액티브는 이벤트 루프 위에서 흐름 제어와 스트림 합성 영역을 여전히 담당하는 비교

이게 왜 중요하냐면, 그동안 “블로킹 비용을 피하려고” 감수했던 리액티브의 러닝 커브와 디버깅 난이도를, 평범한 동기 코드 스타일로도 상당 부분 우회할 수 있게 되기 때문입니다. 익숙한 블로킹 코드를 그대로 쓰면서도 높은 동시성을 얻는 길이 열린 셈입니다.

만약 다음과 같은 블로킹 호출 코드를 가정해서 살펴보겠습니다. “0.1초 걸리는 블로킹 호출 10개를 동시에” 처리하는 작업입니다. 먼저 가상 스레드입니다.

long start = System.currentTimeMillis();

try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
    List<Future<Integer>> futures = IntStream.rangeClosed(1, 10)
            .mapToObj(i -> executor.submit(() -> blockingCall(i)))  // 가상 스레드 하나씩
            .toList();

    for (Future<Integer> future : futures) {
        future.get();   // 블로킹으로 결과를 기다린다 (평소 쓰던 코드 그대로)
    }
}

System.out.println("걸린 시간: " + (System.currentTimeMillis() - start) + "ms");

blockingCall은 그냥 Thread.sleep(100) 하는 평범한 블로킹 메소드입니다. submit 하고 future.get()으로 기다리는, 자바 개발자라면 늘상 사용하는 멀티스레드 활용 방식입니다. 스레드만 가상 스레드로 바꿨을 뿐인데 10개가 동시에 돌아 이렇게 0.1초대에 끝납니다. 같은 코드를 일반 스레드 풀로 돌렸다면 풀 크기만큼만 동시에 처리됐을 겁니다.

걸린 시간: 115ms

같은 일을 리액티브로 짜면 이렇습니다.

long start = System.currentTimeMillis();

Flux.range(1, 10)
    .flatMap(i -> Mono.fromCallable(() -> blockingCall(i))
            .subscribeOn(Schedulers.boundedElastic()))
    .blockLast();

System.out.println("걸린 시간: " + (System.currentTimeMillis() - start) + "ms");

이쪽도 동시에 처리돼 비슷하게 0.1초대로 끝납니다.

걸린 시간: 179ms

둘 다 순차로 하면 1초(0.1초 × 10)가 걸릴 일을 동시에 처리해 0.1초대로 끝냈습니다. 리액티브가 조금 더 나온 건 Reactor와 스케줄러를 처음 띄우는 오버헤드 때문이지, 본질적인 속도 차이는 아닙니다. 여기서 핵심은 속도가 아니라 코드 모양입니다. 가상 스레드 쪽은 submit/get이라는 익숙한 블로킹 스타일 그대로인 반면, 리액티브 쪽은 flatMap과 subscribeOn이라는 리액티브 프로그래밍적인 사고방식을 요구합니다. “그냥 동시에 여러 개의 블로킹 코드를 호출하고 싶을 뿐”이라면, 이제 가상 스레드로 훨씬 단순하게 갈 수 있게 된 겁니다.

그럼 리액티브는 사라질까?

그런데 정말 그럴까요? 여기서 성급하게 “리액티브 끝났다”로 가면 안 됩니다. 가상 스레드가 지운 건 정확히 “블로킹의 비용”이지, 리액티브가 하던 모든 일이 아니기 때문입니다.

리액티브에는 가상 스레드가 대신해주지 않는 영역이 남습니다.

  • 백프레셔(흐름 제어): 빠른 생산자와 느린 소비자를 화해시키는 4편의 그 메커니즘. 가상 스레드는 스레드를 저렴하게 이용할 수 있게 하고, 기존 블로킹 코드와 같이 활용할 수 있을 뿐, 흐름의 양을 소비자가 조절하는 문제는 풀어주지 않습니다.
  • 스트림 합성과 연산자: 여러 비동기 흐름을 merge·zip·flatMap으로 엮고 변환하는 선언적 표현. 이건 여전히 리액티브가 더 자연스럽습니다.
  • 무한히 흐르는 데이터(이벤트 스트림, 실시간 구독)처럼 애초에 “값 하나”가 아니라 “흐름”인 도메인.

그래서 제 잠정적인 결론은 “대체”가 아니라 “역할 분담”에 가깝습니다. 평범한 요청-응답 위주의 서비스라면, 가상 스레드로 단순하게 가는 편이 앞으로 점점 더 합리적입니다. 반대로 백프레셔가 중요한 스트리밍이나 복잡한 비동기 합성이 핵심이라면, 리액티브의 가치는 여전히 남을 것이라 생각합니다.

남겨두는 질문

그럼 이 경계가 영원할까요? 물론 이건 2024년 초 시점의 제 생각이고, 가상 스레드가 더 무르익으면 경계가 또 달라질 수 있습니다. 가상 스레드에도 아직 다듬어지는 부분(예: 특정 상황에서 캐리어 스레드가 고정되는 피닝(pinning) 같은 이슈)이 있어서, 단정하기엔 이른 것 같습니다.

가상 스레드 피닝: JEP 444: Virtual Threads · Oracle: Virtual Threads (Java 21)

가상 스레드 자체는 워낙 큰 주제라, 동작 원리와 피닝, 적용 시 주의점은 따로 한 편으로 다뤄보려 합니다. 저는 개인적으로 이전에 코루틴을 학습한 적이 있는데, 가상 스레드에서도 비슷하게 생각할 구석이 많았던 것 같습니다.

시리즈를 마치며

이렇게 아홉 편에 걸쳐 리액티브 스트림즈를 정리해봤습니다. 리액티브는 “값이 흘러오면 반응한다”는 발상에서 출발해, 명세와 백프레셔, 구현체와 Reactor, 웹 스택(WebFlux), 그리고 전 구간 논블로킹일 때 빛을 발한다는 것까지 이어졌습니다. 마지막에 가상 스레드가 그 전제를 어떻게 흔들어놓는지 한번 보았고, 리액티브의 쓸모를 “동시성 효율”에서 “흐름 제어와 스트림 합성”으로 다시 정의하게 됐습니다.

솔직히 이 시리즈를 준비하는 건 생각보다 훨씬 어려웠습니다. 개념 하나를 제대로 짚으려고 직접 구현해보고 테스트하고, 틀린 부분을 다시 고치기를 여러 번 반복했습니다. 그런데 그 과정에서 오히려 깨닫는 게 많았습니다. 막연히 “비동기니까 좋다”고 넘겼던 것들이 왜 그렇게 설계됐고 어디서 값어치가 나는지, 조금은 저의 언어로 말할 수 있게 된 것 같습니다.

결국 리액티브도 만능이 아니라 트레이드오프였습니다. 무엇이 더 우월하냐가 아니라, 우리 도메인이 백프레셔와 스트리밍을 정말 필요로 하는가. 이 질문 하나를 남기며 긴 시리즈를 마칩니다. 여기까지 읽어주셔서 감사합니다.

참고

  • 김중철 역, 실전! 스프링 5를 활용한 리액티브 프로그래밍 (원서: Hands-On Reactive Programming in Spring 5)
  • JEP 444: Virtual Threads

시리즈 리액티브 스트림즈 전 9편

  1. 01 리액티브란 무엇인가, 옵저버 패턴에서 백프레셔로
  2. 02 명세와 백프레셔, 네 개의 인터페이스
  3. 03 직접 만들어보기, pull에서 push까지
  4. 04 백프레셔, 흐름을 누가 통제하는가
  5. 05 구현체들 둘러보기
  6. 06 Project Reactor 입문, Flux와 Mono
  7. 07 Spring WebFlux와 논블로킹 스택
  8. 08 끝까지 논블로킹, 그리고 한계 이전
  9. 09 가상 스레드와 리액티브의 미래 현재 글

댓글

GitHub(giscus) 댓글은 설정 완료 후 활성화됩니다.