본문 바로가기
Framework/Spring Boot

[Spring Boot] Spring WebFlux 한계 돌파 - 비동기 논블로킹(Non Blocking)

JMeter로 1만 명의 부하 테스트를 때리다 보면, 평범한 Spring MVC(톰캣 기반) 서버가 어느 순간 맥없이 무너지는 것을 보게 됩니다. 이유가 뭘까요? 기존 방식은 '1 요청 = 1 쓰레드(Thread)'라는 치명적인 룰을 가집니다. 유저가 API를 호출하면 쓰레드 하나가 배정되고, 그 쓰레드는 데이터베이스에서 쿼리 결과를 가져오는 2초 동안 '대기(Blocking)' 상태에 빠집니다.
아무 일도 안 하면서 쓰레드를 꽉 쥐고 있는 거죠. 이렇게 200명의 유저가 동시에 접속해 쓰레드 200개가 전부 DB 응답을 기다리며 멈춰버리면, 유저부터는 하얀 화면만 보며 에러를 뱉게 됩니다(Thread Pool 고갈).
"아니, DB 기다리는 동안 쓰레드가 놀지 말고, 돌아가서 다른 유저 요청을 받아오면 안 돼?"
이 상식적이고도 천재적인 발상에서 탄생한 차세대 기술이 바로 Spring WebFlux(웹플럭스)와 리액티브 프로그래밍(Reactive Programming)입니다.

.

1. 블로킹(Blocking) vs 논블로킹(Non-Blocking)의 차이

스타벅스 커피 주문으로 비유해 봅시다.

  • 기존 Spring MVC (블로킹): 알바생(쓰레드)이 손님(요청)에게 주문을 받습니다. 바리스타(DB)에게 "아아 1잔"을 외칩니다. 그리고 바리스타가 커피를 다 만들어서 줄 때까지 알바생은 계산대 앞에서 가만히 멍 때리고 서 있습니다. 뒤에 줄 선 100명의 손님은 분노합니다.
  • Spring WebFlux (논블로킹): 알바생이 주문을 받자마자 바리스타에게 넘기고, 진동벨(Callback)을 손님에게 쥐여준 뒤 곧바로 뒤에 있는 다음 손님의 주문을 연달아 받습니다. 바리스타가 커피를 다 만들고 "띠동~" 호출하면, 그때서야 커피를 픽업해 전달합니다. 알바생 1명으로 수백 명의 손님을 커버할 수 있습니다!

WebFlux는 무거운 톰캣(Tomcat) 대신 Netty(네티)라는 비동기 전용 이벤트 루프(Event Loop) 웹 서버를 사용하여 이 기적을 구현합니다.

 

2. 리액티브 타입의 등장: Mono와 Flux

논블로킹을 구현하기 위해 자바 코딩 패러다임이 통째로 바뀝니다. 더 이상 User나 List<User>를 직접 리턴하지 않고, 미래에 데이터가 도착할 것이라는 '약속(Publisher)' 상자로 감싸서 던집니다.

// 과거 MVC 방식 (데이터가 나올 때까지 2초간 멈춤 - Blocking)
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
    return userRepository.findById(id); 
}

//  차세대 WebFlux 방식 (멈추지 않고 즉시 리턴함 - Non-Blocking)
@GetMapping("/users/{id}")
public Mono<User> getUserReactive(@PathVariable Long id) {
    // 0~1개의 데이터를 담는 약속 상자는 Mono, 0~N개의 리스트를 담는 상자는 Flux라고 부릅니다.
    // DB 조회 명령만 던져놓고, 데이터가 준비되면 알아서 유저에게 쏴주라고 약속 상자만 툭 던지고 끝냅니다!
    return userReactiveRepository.findById(id); 
}

 

3. WebFlux 도입의 치명적인 함정 (R2DBC)

"와! 당장 내 프로젝트 WebFlux로 다 바꿔야지!"라고 생각하셨다면 잠시 멈추셔야 합니다. 엄청난 함정이 있습니다. 컨트롤러와 서비스를 아무리 WebFlux로 비동기로 짰더라도, 맨 마지막에 데이터베이스에 붙는 JPA(Hibernate)나 JDBC가 동기식(Blocking) 기술이라면 어떻게 될까요? 알바생이 논블로킹으로 주문을 백날 빨리 받아도, 바리스타(JPA)가 커피 머신 앞을 꽉 막고 있다면 결국 똑같이 멈춰버립니다.

따라서 WebFlux의 성능을 100% 뽑아내려면 JPA를 갖다 버리고, 비동기 논블로킹 전용 DB 드라이버인
R2DBC (Reactive Relational Database Connectivity)나 Spring Data Reactive MongoDB 등을 사용해야 합니다. 문제는 R2DBC가 JPA만큼 기능(연관관계, 지연로딩 등)이 풍부하지 않고 학습 곡선이 에베레스트산 급이라는 것입니다.

실무 결론: 일반적인 쇼핑몰이나 게시판(CRUD 위주)은 그냥 Spring MVC로 만들고 서버(EC2) 대수를 늘리는 것이 정신 건강에 이롭습니다. 하지만 채팅 서버, 주식 실시간 호가창, 또는 수많은 마이크로서비스(API)를 중간에서 취합해 주는 API Gateway 같은 'I/O 대기시간이 극도로 긴 특수 환경'에서는 WebFlux가 서버 비용을 10분의 1로 줄여주는 구원자가 됩니다.

 

4. 마무리 및 주요 발전 방향

지금까지 쓰레드(Thread) 고갈의 한계를 극복하고, 단 몇 개의 쓰레드만으로 엄청난 동시 접속자를 감당해 내는 현대 백엔드의 최전선 패러다임, Spring WebFlux와 Mono/Flux의 리액티브(Reactive) 아키텍처에 대해 다루어 보았습니다. 이제 여러분은 아키텍처를 결정할 때 "이 서비스에 정말 논블로킹이 필요한가?"를 판단할 수 있는 통찰력을 얻었습니다.

 

자, 드디어 . 최근 AWS Lambda 같은 서버리스(Serverless) 환경이 뜨면서 자바(Java) 진영에 거대한 위기가 닥쳤습니다. 자바는 무거운 JVM(가상머신)을 띄우느라 서버 부팅에만 5~10초가 걸리는데, Go나 Node.js는 0.1초 만에 켜지기 때문입니다. "자바는 뚱뚱하고 느려서 서버리스 시대엔 끝났다"는 조롱을 잠재우기 위해, "JVM을 통째로 갈아버리고 자바 코드를 C언어 같은 순수 기계어(Native)로 바꿔치기해서 0.05초 만에 서버를 띄워버리는" 초특급 혁명 기술이 스프링 부트 3.0부터 전면 도입되었습니다.

"자바의 한계를 찢다! 서버 부팅 0.05초, 메모리 사용량 1/5! 차세대 백엔드의 궁극기"
GraalVM Native Image 아키텍처와 Spring Boot 3 AOT 컴파일 실무 가이드
에 대해 알아보겠습니다.


현대 클라우드 생태계는 요금이 저렴하고 유연한 AWS Lambda 같은 서버리스(Serverless)나 쿠버네티스(K8s) 오토스케일링 환경으로 빠르게 넘어가고 있습니다. 이런 환경에서는 트래픽이 몰릴 때 서버(컨테이너)가 1초 만에 수십 대씩 팟! 팟! 하고 순식간에 떠야 합니다. 그런데 여기서 자바(Java) 진영에 치명적인 위기가 찾아옵니다. 자바는 .jar를 실행하면 무겁고 거대한 JVM(자바 가상머신)을 먼저 띄운 뒤, 그 위에서 바이트코드를 통역(JIT)하며 스프링의 수많은 빈(Bean)들을 메모리에 올리느라 서버 부팅에만 최소 5초~15초가 걸립니다. Go나 Node.js가 0.1초 만에 켜지는 것에 비하면 너무나 뚱뚱하고 둔한 것이죠.

"이제 클라우드 시대에 자바는 끝났다"는 조롱이 쏟아지던 찰나, 스프링 진영은 Spring Boot 3.0 업데이트와 함께 판을 완전히 뒤집어버리는 궁극의 병기를 뽑아 들었습니다. 바로 GraalVM Native Image (그랄VM 네이티브 이미지)입니다.

.05초의 기적을 이루는" GraalVM 아키텍처와 AOT 컴파일에 대하여 다뤄보겠습니다.

1. AOT(Ahead-of-Time) 컴파일러의 기적: JVM을 버리다

기존 자바의 JIT(Just-In-Time) 컴파일은 "일단 앱을 켜놓고, 돌리면서 많이 쓰는 코드를 서서히 기계어로 번역하자"는 주의였습니다. 그래서 부팅이 느리고, 처음 접속한 유저들은 느린 응답(Warm-up 문제)을 겪어야 했습니다.

GraalVM의 Native Image(AOT 컴파일)는 "앱을 켜기 전(Build 시점)에, 애플리케이션에 필요한 모든 클래스와 스프링 Bean 설정들을 모조리 긁어모아서 C언어나 Go 언어처럼 완벽한 '운영체제 전용 기계어(바이너리 실행 파일)'로 깎아버리자!"는 철학입니다. 결과물은 .jar가 아니라 윈도우의 .exe나 리눅스의 실행 파일(ELF) 하나로 툭 튀어나옵니다.

결과는 충격적입니다.
- 부팅 시간: 10초 -> 0.05초 (약 200배 빠름!)
- 메모리 사용량: 500MB -> 50MB (JVM이 없어지면서 1/10로 다이어트!)


2. Spring Boot 3.0 네이티브 빌드 실전 체험

Spring Boot 3.0부터는 별도의 무거운 설정 없이 GraalVM 네이티브 빌드를 공식 지원합니다. 백문이 불여일견, 빌드를 돌려봅시다.

터미널을 열고 리눅스 환경(또는 도커)에서 아래 명령어를 딱 한 줄 칩니다.

./gradlew nativeCompile

(주의!) 빌드를 누르면 평소 5초면 끝나던 빌드가 갑자기 3분~10분 넘게 걸리며 컴퓨터 CPU가 미친 듯이 윙윙거립니다. AOT 컴파일러가 애플리케이션의 모든 소스코드, 라이브러리(Spring, Hibernate 등)의 의존성 트리를 샅샅이 뒤져가며 "런타임에 진짜로 쓰이는 코드만 추려내어 기계어로 번역하는 엄청난 연산 작업"을 수행하기 때문입니다.

긴 인고의 시간이 끝나고 build/native/nativeCompile 폴더에 가보면 .jar가 아닌 myapp 이라는 단일 실행 파일이 만들어져 있습니다. 실행해 봅시다!

$ ./myapp
...
Started DemoApplication in 0.043 seconds (process running for 0.047)

엔터를 치자마자 0.04초 만에 스프링 부트 로고가 뜨며 톰캣 서버가 기동 된 것을 볼 수 있습니다. 소름이 돋는 속도입니다.


3. 아직은 시기상조? 리플렉션(Reflection)의 저주

이렇게 완벽해 보이는 기술인데 왜 모든 기업이 당장 안 바꿀까요? 치명적인 단점이 있습니다. '리플렉션(Reflection)'을 사용하는 기존의 수많은 레거시 라이브러리들과 궁합이 최악이라는 것입니다.

AOT 컴파일러는 빌드 시점에 코드를 싹 다 분석해서 안 쓰이는 클래스를 '삭제'해 버립니다. 그런데 스프링 생태계는 런타임(서버가 켜졌을 때)에 리플렉션 기술로 마법처럼 클래스(클래스명 문자열)를 찾아내어 동적으로 주입하는 기능이 아주 많습니다.

 

빌드할 땐 안 쓰이는 줄 알고 삭제해 버렸는데, 막상 서버를 켜니까 리플렉션으로 호출하려 해서 ClassNotFoundException이 터지며 서버가 뻗어버리는 현상이 빈번하게 발생합니다.
이를 고치려면 개발자가 직접 "이 클래스들은 지우지 말고 남겨둬(Hints)!"라고 수동으로 설정을 다 잡아줘야 하는 끔찍한 노가다가 수반됩니다. 이에 관련된 방법에 대해서는 다음 포스트에서 이어가도록 하겠습니다!