Spring Boot로 간단한 CRUD API를 만드는 데 익숙해질 무렵, 저는 한계와 마주했습니다. 제가 가고 싶은 기업들의 채용 공고나 기술 면접 후기에는 늘 '대용량 트래픽 처리', 'MSA 환경에서의 통신', '비동기/논블로킹' 같은 키워드가 있었습니다. 이 단어들은 제 지식의 지도에서 아직 가보지 못한 미지의 영역처럼 느껴졌습니다.
이 글은 그 미지의 영역을 탐험하기 위해 제가 '비동기 프로그래밍'이라는 주제를 깊게 파고들었던 학습 여정을 상세히 정리한 기록입니다. "왜?"라는 질문에서 시작해, 개념을 익히고, 코드를 이해하며, 실제 사례를 통해 시야를 넓혀간 저의 고민과 깨달음이 담겨 있습니다.
제가 처음 배운 Spring MVC의 '요청당 스레드(Thread-per-Request)' 모델은 참으로 직관적이었습니다. 클라이언트 요청이 오면, 톰캣(Tomcat) 같은 서블릿 컨테이너가 스레드 풀(Thread Pool)에서 스레드 하나를 꺼내 요청 처리가 끝날 때까지 1:1로 할당해주는 방식. 코드가 위에서 아래로 순차적으로 흐르니 이해하기 쉬웠죠.
하지만 여기서 저의 성장을 위한 가정을 시작했습니다.
"만약 내가 만든 서비스가 대박이 나서 동시 접속자가 수천, 수만 명으로 폭주한다면? MSA 구조라서 하나의 요청을 처리하기 위해 다른 서비스의 API를 여러 개 호출해야 한다면?"
이 가정을 구체화하는 순간, 제가 알던 편안한 세상의 문제점들이 보이기 시작했습니다.
결론은 명확했습니다. 이 문제는 단순히 코드의 속도 문제가 아니라, 한정된 서버 '자원', 특히 '스레드'를 얼마나 효율적으로 사용하느냐에 대한 '자원 관리'의 문제라는 것을요.
본격적으로 공부를 시작하자, '동기', '비동기', '블로킹', '논블로킹'이라는 네 용어가 저를 혼란에 빠뜨렸습니다. 이들은 서로 다른 관점의 개념이라는 것을 이해하는 데 꽤 오랜 시간이 걸렸습니다. 제가 이해한 바를 표로 정리해 보았습니다.
동기 (Synchronous)(호출한 쪽이 결과까지 직접 챙김) | 비동기 (Asynchronous)(호출된 쪽이 알아서 결과를 알려줌) | |
---|---|---|
블로킹 (Blocking)(제어권을 넘기고 실행을 멈춤) | (가장 흔한 모델)식당에서 주문하고, 음식이 나올 때까지 카운터 앞에서 꼼짝 않고 기다림. 내 스레드는 멈춰있다. | (거의 안 쓰는 안티패턴)진동벨을 받고, 자리에서 진동벨만 뚫어져라 쳐다보며 기다림. 다른 일을 할 수 없다. |
논블로킹 (Non-Blocking)(제어권을 유지하고 계속 실행) | 주문하고 자리로 돌아와 "제 거 나왔나요?" 계속 물어본다 (Polling). 스레드가 멈추진 않지만, 계속 확인해야 해서 비효율적이다. | (리액티브의 핵심) ✨진동벨을 받고, 자리에서 폰 보며 놀다가 벨 울리면 음식을 찾으러 간다. 작업을 시켜놓고 즉시 다른 일을 하다가, 알림(콜백)이 오면 결과를 처리한다. |
이 표를 그리고 나서야 머릿속이 정리되었습니다. 제가 가야 할 길은 시스템 자원을 가장 효율적으로 사용하는 비동기 + 논블로킹 모델이라는 것을요.
그렇다면 스프링에서는 어떻게 이 모델을 구현할 수 있을까? 저의 탐색은 두 가지 선택지로 좁혀졌습니다.
@Async
: 기존 MVC 프로젝트에 가장 쉽게 비동기를 적용하는 방법이었습니다. @Async
어노테이션을 메서드에 붙이고, CompletableFuture
와 함께 사용하면 특정 오래 걸리는 작업을 별도의 스레드로 보낼 수 있었죠. 하지만 더 깊이 파고들면서 몇 가지 명확한 한계를 발견했습니다.this
로 다른 @Async
메서드를 호출하면 비동기로 동작하지 않는 함정이 있었습니다.결론적으로 @Async
는 기존 동기 아키텍처 위에 덧대는 일종의 '개선책(Patch)'이었고, 제가 원하는 근본적인 체질 개선과는 거리가 있었습니다.
Spring WebFlux
: Spring 5부터 등장한 WebFlux는 태생부터 '비동기 + 논블로킹'을 위해 모든 것을 다시 설계한 '재작성(Rewrite)'이었습니다. Servlet API가 아닌 Netty 위에서 동작하고, Project Reactor라는 리액티브 라이브러리를 핵심 엔진으로 사용한다는 것을 알게 되었습니다. "이왕 공부할 거, 제대로 파보자!"라는 생각에 저는 WebFlux의 세계로 뛰어들기로 결심했습니다.WebFlux를 파고들면서, 그 심장에는 Node.js에서 들어봤던 '이벤트 루프(Event Loop)' 모델이 있다는 걸 알게 됐습니다. 동작 방식은 이렇습니다.
이 모델의 핵심 엔진인 Project Reactor를 공부하면서, 저는 비동기 데이터 스트림을 표현하는 두 가지 기본 단위를 만났습니다.
Mono<T>
: 0 또는 1개의 데이터를 처리하는 비동기 스트림. (ex: ID로 사용자 1명 조회)Flux<T>
: 0개 이상의 여러 데이터를 처리하는 비동기 스트림. (ex: 여러 개의 게시글 목록 조회)이들은 onNext
(데이터 전달), onComplete
(정상 종료), onError
(에러 발생)라는 신호(Signal)로 상태를 전파한다는 것을 알게 되었습니다. 그리고 "구독(subscribe()
)하기 전까지는 아무 일도 일어나지 않는다"는 리액티브의 제1원칙은, 모든 로직은 실행 계획일 뿐이며 구독 시점에 비로소 데이터가 흐르기 시작한다는 중요하다고 느껴졌습니다.
flatMap
과의 사투, 그리고 깨달음리액티브 연산자(Operator)를 공부하며 가장 어려웠던 것은 map
과 flatMap
의 차이였습니다.
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를 통해 데이터 접근 계층까지 완전한 논블로킹 파이프라인을 구축해야 진정한 리액티브 애플리케이션이 완성된다는 것을 배웠습니다.
이론들을 실제 세상과 연결하기 위해 '배달의민족'의 '가게노출 시스템' 개선 사례를 깊이 분석했습니다.
flatMap
과 zip
을 활용해 수십 개의 외부 API를 병렬로 호출하여 응답 시간을 획기적으로 단축.이 사례를 통해 저는 WebFlux가 단순한 기술이 아닌, 시스템의 성능과 안정성을 보장하는 핵심 아키텍처 전략이라는 것을 확신하게 되었습니다.
마지막으로, Java 21의 Project Loom(가상 스레드)이라는 새로운 패러다임을 마주했습니다. 가상 스레드는 개발자가 익숙한 동기/블로킹 코드를 작성해도, JVM이 알아서 논블로킹처럼 동작시켜주는 혁신적인 기술이라고 생각해요. application.properties
에 단 한 줄(spring.threads.virtual.enabled=true
)만 추가하면 되니 개발자 경험 측면에서 매우 매력적인 것 같습니다.
이 시점에서 저는 '리액티브와 Loom 중 무엇이 더 우월한가?'를 논하는 것은 의미가 없다고 결론 내렸습니다. 대신 '어떤 문제 상황에서 어떤 도구를 선택해야 하는가?'를 고민하는 개발자가 되어야겠다고 다짐했습니다.