스프링 비동기 처리와 Project Reactor

Jayson·2025년 7월 22일
0
post-thumbnail

비동기 프로그래밍 탐험기: 왜 저는 Spring WebFlux를 깊게 파고들었을까요?

Spring Boot로 간단한 CRUD API를 만드는 데 익숙해질 무렵, 저는 한계와 마주했습니다. 제가 가고 싶은 기업들의 채용 공고나 기술 면접 후기에는 늘 '대용량 트래픽 처리', 'MSA 환경에서의 통신', '비동기/논블로킹' 같은 키워드가 있었습니다. 이 단어들은 제 지식의 지도에서 아직 가보지 못한 미지의 영역처럼 느껴졌습니다.

이 글은 그 미지의 영역을 탐험하기 위해 제가 '비동기 프로그래밍'이라는 주제를 깊게 파고들었던 학습 여정을 상세히 정리한 기록입니다. "왜?"라는 질문에서 시작해, 개념을 익히고, 코드를 이해하며, 실제 사례를 통해 시야를 넓혀간 저의 고민과 깨달음이 담겨 있습니다.


모든 고민의 시작: "만약 내 서비스가 대박난다면?" 이라는 가정

제가 처음 배운 Spring MVC의 '요청당 스레드(Thread-per-Request)' 모델은 참으로 직관적이었습니다. 클라이언트 요청이 오면, 톰캣(Tomcat) 같은 서블릿 컨테이너가 스레드 풀(Thread Pool)에서 스레드 하나를 꺼내 요청 처리가 끝날 때까지 1:1로 할당해주는 방식. 코드가 위에서 아래로 순차적으로 흐르니 이해하기 쉬웠죠.

하지만 여기서 저의 성장을 위한 가정을 시작했습니다.

"만약 내가 만든 서비스가 대박이 나서 동시 접속자가 수천, 수만 명으로 폭주한다면? MSA 구조라서 하나의 요청을 처리하기 위해 다른 서비스의 API를 여러 개 호출해야 한다면?"

이 가정을 구체화하는 순간, 제가 알던 편안한 세상의 문제점들이 보이기 시작했습니다.

  • 병목 지점, 블로킹 I/O: 외부 API를 호출하거나 데이터베이스를 조회하는 I/O 작업이 발생하면, 그 작업을 요청한 스레드는 결과가 올 때까지 아무것도 못 하고 멈춰 서서 기다립니다(Blocking). 외부 API 응답이 1초 걸리면, 귀한 스레드 하나가 1초 동안 CPU를 점유만 한 채 그대로 낭비되는 것이죠.
  • 자원의 한계, 스레드 지옥: 이런 비효율이 쌓이면 어떻게 될까요? 동시 요청이 몰리면 스레드 풀의 모든 스레드가 각자 I/O를 기다리며 블로킹 상태에 빠집니다. 스레드 풀이 고갈되면 새로운 요청은 하염없이 대기 큐에 쌓이고, 시스템 전체는 느려지다가 결국 멈춰버립니다. 이것이 바로 '스레드 지옥(Thread Hell)'이었습니다.
  • 메모리 문제: 스레드는 공짜가 아니라는 사실도 알게 되었습니다. 64비트 JVM에서 스레드 하나는 약 1MB의 스택 메모리를 차지합니다. 만약 동시 요청 1만 개를 처리하기 위해 스레드를 1만 개 만든다면? 스레드 스택만으로 거의 10GB에 가까운 메모리가 필요하다는 계산이 나왔습니다. 무작정 스레드를 늘리는 것은 해결책이 될 수 없었습니다.

결론은 명확했습니다. 이 문제는 단순히 코드의 속도 문제가 아니라, 한정된 서버 '자원', 특히 '스레드'를 얼마나 효율적으로 사용하느냐에 대한 '자원 관리'의 문제라는 것을요.


가장 헷갈렸던 개념: 4가지 용어의 관계 정립하기

본격적으로 공부를 시작하자, '동기', '비동기', '블로킹', '논블로킹'이라는 네 용어가 저를 혼란에 빠뜨렸습니다. 이들은 서로 다른 관점의 개념이라는 것을 이해하는 데 꽤 오랜 시간이 걸렸습니다. 제가 이해한 바를 표로 정리해 보았습니다.

동기 (Synchronous)(호출한 쪽이 결과까지 직접 챙김)비동기 (Asynchronous)(호출된 쪽이 알아서 결과를 알려줌)
블로킹 (Blocking)(제어권을 넘기고 실행을 멈춤)(가장 흔한 모델)식당에서 주문하고, 음식이 나올 때까지 카운터 앞에서 꼼짝 않고 기다림. 내 스레드는 멈춰있다.(거의 안 쓰는 안티패턴)진동벨을 받고, 자리에서 진동벨만 뚫어져라 쳐다보며 기다림. 다른 일을 할 수 없다.
논블로킹 (Non-Blocking)(제어권을 유지하고 계속 실행)주문하고 자리로 돌아와 "제 거 나왔나요?" 계속 물어본다 (Polling). 스레드가 멈추진 않지만, 계속 확인해야 해서 비효율적이다.(리액티브의 핵심) ✨진동벨을 받고, 자리에서 폰 보며 놀다가 벨 울리면 음식을 찾으러 간다. 작업을 시켜놓고 즉시 다른 일을 하다가, 알림(콜백)이 오면 결과를 처리한다.

이 표를 그리고 나서야 머릿속이 정리되었습니다. 제가 가야 할 길은 시스템 자원을 가장 효율적으로 사용하는 비동기 + 논블로킹 모델이라는 것을요.


스프링에서의 해법 찾기: @Async vs. WebFlux

그렇다면 스프링에서는 어떻게 이 모델을 구현할 수 있을까? 저의 탐색은 두 가지 선택지로 좁혀졌습니다.

  1. 부분적인 개선책, @Async: 기존 MVC 프로젝트에 가장 쉽게 비동기를 적용하는 방법이었습니다. @Async 어노테이션을 메서드에 붙이고, CompletableFuture와 함께 사용하면 특정 오래 걸리는 작업을 별도의 스레드로 보낼 수 있었죠. 하지만 더 깊이 파고들면서 몇 가지 명확한 한계를 발견했습니다.
    • 프록시 기반 동작: AOP 프록시를 통해 동작하기 때문에, 클래스 내부에서 this로 다른 @Async 메서드를 호출하면 비동기로 동작하지 않는 함정이 있었습니다.
    • 부분적인 비동기: 컨트롤러 진입점부터 서블릿 필터까지, 요청 처리의 큰 흐름은 여전히 동기/블로킹 방식이었습니다. 완전한 성능 향상을 기대하긴 어려웠습니다.
    • 스레드 풀 의존: 결국 또 다른 스레드 풀에 의존하는 방식이라, 근본적인 스레드 고갈 문제에서 자유롭지 못했습니다.

결론적으로 @Async는 기존 동기 아키텍처 위에 덧대는 일종의 '개선책(Patch)'이었고, 제가 원하는 근본적인 체질 개선과는 거리가 있었습니다.

  1. 근본적인 패러다임 전환, Spring WebFlux: Spring 5부터 등장한 WebFlux는 태생부터 '비동기 + 논블로킹'을 위해 모든 것을 다시 설계한 '재작성(Rewrite)'이었습니다. Servlet API가 아닌 Netty 위에서 동작하고, Project Reactor라는 리액티브 라이브러리를 핵심 엔진으로 사용한다는 것을 알게 되었습니다. "이왕 공부할 거, 제대로 파보자!"라는 생각에 저는 WebFlux의 세계로 뛰어들기로 결심했습니다.

동작 원리: 이벤트 루프, 그리고 Reactor

WebFlux를 파고들면서, 그 심장에는 Node.js에서 들어봤던 '이벤트 루프(Event Loop)' 모델이 있다는 걸 알게 됐습니다. 동작 방식은 이렇습니다.

  1. 이벤트 큐: 모든 요청은 '이벤트'가 되어 큐에 차곡차곡 쌓입니다.
  2. 이벤트 루프: CPU 코어 수만큼의 적은 수의 스레드가 큐에서 이벤트를 하나씩 꺼내 처리합니다.
  3. 논블로킹 위임: 이 스레드는 절대 멈추지 않습니다. DB 조회 같은 I/O 작업이 생기면 OS 커널이나 워커 스레드에게 위임하고 "일 끝나면 알려줘!(콜백 등록)"라고 말한 뒤, 즉시 다음 이벤트를 처리하러 갑니다.
  4. 콜백 실행: 위임했던 작업이 완료되면, '작업 완료'라는 새 이벤트가 큐에 들어오고, 이벤트 루프가 이를 받아 등록된 콜백 함수를 실행합니다.

이 모델의 핵심 엔진인 Project Reactor를 공부하면서, 저는 비동기 데이터 스트림을 표현하는 두 가지 기본 단위를 만났습니다.

  • Mono<T>: 0 또는 1개의 데이터를 처리하는 비동기 스트림. (ex: ID로 사용자 1명 조회)
  • Flux<T>: 0개 이상의 여러 데이터를 처리하는 비동기 스트림. (ex: 여러 개의 게시글 목록 조회)

이들은 onNext (데이터 전달), onComplete (정상 종료), onError (에러 발생)라는 신호(Signal)로 상태를 전파한다는 것을 알게 되었습니다. 그리고 "구독(subscribe())하기 전까지는 아무 일도 일어나지 않는다"는 리액티브의 제1원칙은, 모든 로직은 실행 계획일 뿐이며 구독 시점에 비로소 데이터가 흐르기 시작한다는 중요하다고 느껴졌습니다.

flatMap과의 사투, 그리고 깨달음

리액티브 연산자(Operator)를 공부하며 가장 어려웠던 것은 mapflatMap의 차이였습니다.

  • map(Function<T, U>): 스트림의 각 데이터 T를 동기적으로 U로 1:1 변환합니다.
  • flatMap(Function<T, Publisher<V>>): 각 데이터 T를 가지고 새로운 비동기 작업(Publisher)을 실행하고, 그 결과를 다시 하나의 스트림으로 평탄화해주는 '비동기 작업의 연결고리'입니다.

만약 userFlux.map(user -> orderService.getOrders(user.getId()))처럼 map으로 비동기 API를 호출했다면, 결과는 Flux<Mono<List<Order>>>라는 끔찍한 'Publisher 중첩' 구조가 되었을 겁니다. 하지만 flatMap은 이 내부의 Mono를 자동으로 구독해서 내용물(List<Order>)만 꺼내어 외부의 Flux에 흘려보내 줍니다. flatMap의 함수 시그니처 Function<T, Publisher<V>>를 이해하는 순간, 비로소 비동기 로직을 체인으로 엮는 방법을 터득할 수 있었습니다.


스케줄러, 에러 처리, 그리고 완전한 리액티브 스택

공부할수록 알아야 할 개념들이 더 보였습니다.

  • 스레드 관리 (Scheduler): 비동기 코드에서는 '어느 스레드에서 실행되는지'가 매우 중요했습니다. subscribeOn은 구독이 시작되는 스레드(스트림 전체에 영향)를, publishOn은 데이터가 흐르는 스레드(자신보다 아래쪽에 영향)를 지정하여 교통정리를 할 수 있다는 것을 배웠습니다. 특히 블로킹 I/O 코드는 반드시 Schedulers.boundedElastic()을 통해 별도 스레드로 격리해야 한다는 것이 핵심이었습니다.

  • 견고한 에러 처리: try-catch 대신 연산자로 에러를 다루는 방식이 신선했습니다.

    • onErrorReturn: 간단한 기본값으로 대체할 때
    • onErrorResume: 다른 API 호출 등 복잡한 대체 로직이 필요할 때
    • retryWhen: 일시적인 네트워크 오류처럼 재시도가 의미 있을 때
      실패의 성격에 따라 적절한 전략을 선택하는 것의 중요성을 깨달았습니다.
  • 흐름 제어 (Backpressure): 생산자가 너무 빨라 소비자가 터져버리는 것을 막기 위해, 소비자가 request(n)을 통해 "n개만 줘!"라고 요청하는 백프레셔 메커니즘은 리액티브 시스템의 안정성을 보장해줬습니다.

  • 완전한 리액티브 스택 (R2DBC): WebFlux를 쓰면서 JDBC를 그대로 쓴다면, DB 호출에서 다시 스레드가 블로킹되어 '반쪽짜리 리액티브'가 된다는 것을 알게 되었습니다. R2DBC(Reactive Relational Database Connectivity)와 Spring Data R2DBC를 통해 데이터 접근 계층까지 완전한 논블로킹 파이프라인을 구축해야 진정한 리액티브 애플리케이션이 완성된다는 것을 배웠습니다.


'배달의민족' 사례 분석

이론들을 실제 세상과 연결하기 위해 '배달의민족'의 '가게노출 시스템' 개선 사례를 깊이 분석했습니다.

  • WebFlux와 Reactor: flatMapzip을 활용해 수십 개의 외부 API를 병렬로 호출하여 응답 시간을 획기적으로 단축.
  • 회복탄력성: 외부 시스템 장애에 대비해 서킷 브레이커(Resilience4j)를 적용하고, 폴백 로직으로 안정성 확보.
  • 총체적 접근: Redis를 활용한 다층 캐싱, 메시지 큐를 통한 비동기 이벤트 통신 등, 단순히 웹 프레임워크 전환이 아닌 시스템 전체를 리액티브 철학에 맞게 재설계.

이 사례를 통해 저는 WebFlux가 단순한 기술이 아닌, 시스템의 성능과 안정성을 보장하는 핵심 아키텍처 전략이라는 것을 확신하게 되었습니다.


나의 결론과 앞으로의 다짐

마지막으로, Java 21의 Project Loom(가상 스레드)이라는 새로운 패러다임을 마주했습니다. 가상 스레드는 개발자가 익숙한 동기/블로킹 코드를 작성해도, JVM이 알아서 논블로킹처럼 동작시켜주는 혁신적인 기술이라고 생각해요. application.properties에 단 한 줄(spring.threads.virtual.enabled=true)만 추가하면 되니 개발자 경험 측면에서 매우 매력적인 것 같습니다.

이 시점에서 저는 '리액티브와 Loom 중 무엇이 더 우월한가?'를 논하는 것은 의미가 없다고 결론 내렸습니다. 대신 '어떤 문제 상황에서 어떤 도구를 선택해야 하는가?'를 고민하는 개발자가 되어야겠다고 다짐했습니다.

  • 리액티브 프로그래밍 (WebFlux): 데이터의 '흐름' 자체를 정교하게 제어해야 하거나, 무한 스트림 처리, 정교한 백프레셔 제어가 필수적인 데이터 파이프라인, 실시간 이벤트 스트리밍 애플리케이션에서는 여전히 가장 강력한 해법 같아요
  • Project Loom (가상 스레드): 대부분의 일반적인 I/O 집약적 웹 애플리케이션에서는 개발 생산성과 유지보수성을 크게 높이면서 높은 확장성을 확보할 수 있는, 매우 실용적이고 강력한 대안이라고 생각합니다.
profile
Small Big Cycle

0개의 댓글