Spring WebFlux 핵심 정리 시리즈 3편. R2DBC vs JPA 처리량·메모리 벤치마크 수치 비교, JPA 1차 캐시·Dirty Checking 부재, N+1 수동 해결, 리액티브 선언문 4원칙 — 가사도우미 vs 셀프서비스 비유로 선택 기준까지 풀어가는 실전 가이드.
이 글은 Spring WebFlux 핵심 정리 시리즈의 세 번째 편입니다. 2편에서 R2DBC의 기본 사용법을 익혔다면, 이번 편에서는 "그래서 R2DBC가 JPA보다 얼마나 빠른가?" 라는 질문에 실제 수치로 답해 봅니다. 단순한 이론 비교가 아니라 1,000만 건 데이터를 대상으로 처리량과 메모리 효율을 직접 측정한 결과예요.
핵심 질문은 두 가지입니다. "R2DBC가 JPA 대비 처리량이 얼마나 다른가" 와 "대용량 데이터를 JPA가 왜 처리 못 하는가" — 이 두 가지만 이해해도 기술 선택 기준이 명확해집니다.
이 시리즈는 Spring 공식 문서, Project Reactor 공식 문서, R2DBC 명세, 여러 리액티브 백엔드 학습 자료를 참고해 한국어 학습 노트로 풀어쓴 자료입니다.
벤치마크 환경: PostgreSQL (Docker), 1,000만 건 레코드, `findById` 10만 번 반복 기준이에요.
왜 이 비교가 처음엔 헷갈릴까요
이유는 네 가지예요.
첫째, "비동기니까 당연히 빠르다"는 오해가 있습니다. 단순 CRUD 하나의 응답 시간은 R2DBC와 JPA가 거의 같아요. 차이가 나는 건 동시 요청이 많고 I/O 대기가 길 때입니다.
둘째, JPA가 워낙 성숙해서 R2DBC 장점이 작아 보입니다. 오랜 생태계, 풍부한 문서, IDE 지원 — JPA 쪽이 훨씬 두터워요. R2DBC는 아직 발전 중인 기술입니다.
셋째, Dirty Checking이 없어서 실수하기 쉽습니다. JPA에서는 객체를 꺼내 값을 바꾸면 트랜잭션 종료 시 자동 UPDATE가 됐는데, R2DBC는 save()를 명시적으로 호출해야 해요.
넷째, 연관 매핑이 없어서 N+1 해결법이 다릅니다. JPA의 @OneToMany(fetch = FetchType.EAGER) 같은 자동화가 없고, JOIN을 직접 써야 합니다.
비유 두 개로 풀어 가겠습니다. JPA = "객체 그래프를 자동으로 관리해 주는 가사도우미" — 방(객체)에 들어가서 물건(연관 객체)을 알아서 가져다 놓고, 바뀐 것도 알아서 정리합니다. R2DBC = "필요한 것만 직접 가지러 가는 셀프서비스" — 원하는 걸 정확히 요청하면 딱 그것만 빠르게 줍니다. 자동 관리는 없지만 불필요한 작업도 없어요.
처리량 테스트 — 10만 번 findById 비교
공정한 비교를 위해 복잡한 쿼리는 배제하고, 가장 단순한 findById를 10만 번 반복했습니다. R2DBC는 flatMap 동시성 256, JPA는 동일 조건인 256개 스레드 풀을 사용했어요.
R2DBC 구현:
@Test
void throughputTest_R2DBC() {
long start = System.currentTimeMillis();
Flux.range(1, 100_000)
.flatMap(
i -> repository.findById(i),
256 // 최대 동시 256개 DB 요청
)
.as(StepVerifier::create)
.expectNextCount(100_000)
.expectComplete()
.verify();
System.out.println("R2DBC: " + (System.currentTimeMillis() - start) + "ms");
// 결과: 약 2,000ms
}
JPA 구현 (공정한 비교를 위해 256 스레드 병렬):
@Test
void throughputTest_JPA() throws InterruptedException {
long start = System.currentTimeMillis();
ExecutorService executor = Executors.newFixedThreadPool(256);
List<CompletableFuture<Void>> futures = new ArrayList<>();
for (int i = 1; i <= 100_000; i++) {
final int id = i;
futures.add(CompletableFuture.runAsync(
() -> jpaRepository.findById(id), executor
).thenRun(() -> {}));
}
CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
executor.shutdown();
System.out.println("JPA: " + (System.currentTimeMillis() - start) + "ms");
// 결과: 약 4,000ms
}
벤치마크 결과:
| 측정 항목 | R2DBC | JPA | JPA + 가상 스레드 |
|---|---|---|---|
| 완료 시간 | 약 2초 | 약 4초 | 약 4초 (차이 없음) |
| 초당 처리량 | 약 50,000건/초 | 약 25,000건/초 | 약 25,000건/초 |
| 스레드 수 | 이벤트 루프 (소수) | 256개 스레드 풀 | 가상 스레드 |
여기서 시험 함정이 하나 있어요. "Java 21 가상 스레드를 JPA에 붙이면 R2DBC 수준으로 빨라진다"는 것은 사실이 아닙니다. findById 같은 짧은 I/O 작업에서 가상 스레드는 기존 스레드 풀과 성능 차이가 거의 없어요. 가상 스레드의 이점은 더 오래 블로킹되는 파일 I/O 같은 작업에서 나타납니다.
한 줄 정리 — 동시 DB 조회 처리량에서 R2DBC가 JPA 대비 약 2배 우위. 단일 요청은 거의 동일.
메모리 효율 테스트 — 1,000만 건 전체 조회
처리량보다 더 극적인 차이는 메모리 효율에서 나옵니다.
R2DBC — 200MB로 성공:
// JVM 힙 메모리를 -Xmx200m (200MB)으로 제한해도 성공
@Test
void efficiencyTest_R2DBC() {
repository.findAll() // Flux<Customer> — 스트리밍, 메모리에 일부만 유지
.doOnNext(c -> {}) // 각 레코드 처리
.as(StepVerifier::create)
.expectNextCount(10_000_000) // 1,000만 건 성공
.expectComplete()
.verify();
}
JPA — 4GB에서도 OutOfMemoryError:
// JVM 힙 메모리를 -Xmx4096m (4GB)로 설정해도 실패
@Test
void efficiencyTest_JPA() {
List<Customer> customers = jpaRepository.findAll(); // 전체를 List에 담으려 시도
// → OutOfMemoryError 발생! 1,000만 × 객체 크기 = 4GB 초과
}
결과 비교:
| 측정 항목 | R2DBC | JPA |
|---|---|---|
| 최소 필요 메모리 | 200MB | 4GB 이상 (OOM) |
| 처리 방식 | 스트리밍 (백프레셔) | 전체 List 메모리 로드 |
| 데이터 크기 의존성 | 없음 | 데이터 크기에 정비례 |
이게 가능한 이유는 백프레셔(Backpressure) 때문이에요.
DB ←── "잠깐 멈춰" ── TCP 버퍼 ←── R2DBC 내부 큐 ←── Flux 소비자
동작 순서:
1. findAll() → Flux<Customer> 반환 (파이프라인만, 아직 데이터 없음)
2. 구독 시작 → R2DBC가 DB에 첫 배치 요청 (예: 256개)
3. 256개 처리 완료 → 다음 256개 요청
4. 소비자가 느려지면 → TCP 수신 버퍼 가득 → DB에 "잠시 멈춰" 신호
5. 소비자가 빨라지면 → DB 전송 재개
메모리에는 항상 수백~수천 개만 유지됩니다. 1억 건도, 1,000만 건도 동일한 메모리로 처리해요.
여기서 시험 함정이 하나 있어요. R2DBC Dirty Checking이 없다는 것은 단순히 "불편함"이 아니라 의도적 설계입니다. Dirty Checking은 객체 상태를 1차 캐시에 저장하고 변경을 추적해야 하는데, 이 과정이 대용량에서 메모리 부담이 됩니다. R2DBC는 이 자동 관리를 과감히 버리고 성능과 메모리 효율을 얻었어요. 따라서 R2DBC에서 엔티티 수정 후 save() 호출을 빠뜨리면 저장이 되지 않습니다.
JPA와 R2DBC 핵심 차이 5가지
1. Dirty Checking — 없다
// JPA: 트랜잭션 내에서 자동 UPDATE
@Transactional
public void updateJPA(Integer id) {
Customer c = jpaRepository.findById(id).orElseThrow();
c.setName("새 이름"); // save() 호출 없어도 트랜잭션 종료 시 자동 UPDATE
}
// R2DBC: 명시적 save() 필수
public Mono<Customer> updateR2DBC(Integer id, String newName) {
return customerRepository.findById(id)
.doOnNext(c -> c.setName(newName))
.flatMap(customerRepository::save); // save() 반드시 호출
}
2. 연관 매핑 — 없다, ID로 따로 조회
// JPA: @OneToMany로 자동
@Entity
public class Customer {
@OneToMany(mappedBy = "customer")
private List<Order> orders; // 자동으로 가져옴
}
// R2DBC: @Query로 직접 JOIN
@Query("""
SELECT o.* FROM customer_order o
WHERE o.customer_id = :customerId
""")
Flux<CustomerOrder> findOrdersByCustomerId(Integer customerId);
3. Optimistic Lock — @Version 동작
// R2DBC에서 낙관적 잠금은 @Version으로 동작
@Table("customer")
public class Customer {
@Id
private Integer id;
private String name;
@Version
private Long version; // R2DBC @Version 지원
}
4. Pessimistic Lock — 직접 SQL
// R2DBC에서 비관적 잠금은 직접 SQL로
@Query("SELECT * FROM customer WHERE id = :id FOR UPDATE")
Mono<Customer> findByIdForUpdate(Integer id);
5. 1차 캐시 — 없다
JPA는 같은 트랜잭션 내에서 findById(1)을 두 번 호출하면 DB를 한 번만 조회합니다. R2DBC는 매번 DB를 조회해요. 필요하면 애플리케이션 레벨 캐시를 직접 구성해야 합니다.
한 줄 정리 — R2DBC vs JPA 핵심: Dirty Checking·연관 매핑·1차 캐시 없음. 대신 논블로킹·백프레셔·메모리 효율.
리액티브 선언문 4원칙
공부하다 보면 "리액티브 시스템"이라는 말을 자주 만납니다. 이걸 공식적으로 정의한 것이 Reactive Manifesto입니다. 4가지 원칙이에요.
| 원칙 | 의미 | R2DBC/WebFlux에서 |
|---|---|---|
| 반응성 (Responsive) | 빠르고 일관된 응답 | findAll() 즉시 Flux 반환, 1초 안에 스트림 시작 |
| 회복탄력성 (Resilient) | 부분 장애에도 동작 | onErrorComplete()·onErrorResume()으로 부분 성공 |
| 탄력성 (Elastic) | 부하에 따른 자원 조절 | 200MB로 1,000만 건 처리, 적은 스레드로 높은 처리량 |
| 메시지 주도 (Message Driven) | 비동기 메시지 통신 | 백프레셔 신호 — 소비자가 생산자 속도 제어 |
4원칙은 서로를 지원합니다. 메시지 주도 통신이 있어야 탄력성·회복탄력성이 가능하고, 그래야 최종적으로 반응성이 보장됩니다.
WebFlux 환경에서 JPA 안전하게 쓰기
R2DBC로 전환이 당장 어렵거나, 특정 기능만 JPA를 유지해야 할 때는 boundedElastic 스케줄러를 씁니다.
@Service
public class LegacyIntegrationService {
private final CustomerJpaRepository jpaRepository;
public Mono<Customer> findById(Integer id) {
// Mono.fromCallable: 블로킹 코드를 Mono로 래핑
return Mono.fromCallable(() -> jpaRepository.findById(id).orElse(null))
// subscribeOn: 블로킹 I/O 전용 스레드 풀에서 실행
// 이벤트 루프 스레드를 블로킹하지 않도록 격리!
.subscribeOn(Schedulers.boundedElastic());
}
public Flux<Customer> findAll() {
return Mono.fromCallable(() -> jpaRepository.findAll())
.subscribeOn(Schedulers.boundedElastic())
.flatMapMany(Flux::fromIterable);
}
}
boundedElastic은 블로킹 I/O 전용 스레드 풀입니다. 최대 10 × CPU 코어 수 스레드를 쓰고, 이 풀도 한계가 있기 때문에 근본적으로는 R2DBC로 전환하는 게 좋아요.
여기서 시험 함정이 하나 있어요. Mono.fromCallable(() -> jpaRepository.findById(id)) 에서 .subscribeOn(Schedulers.boundedElastic())을 빠뜨리면 이벤트 루프 스레드에서 JPA 블로킹 호출이 실행됩니다. 겉으로 동작은 하지만 이벤트 루프 스레드를 점유해 다른 요청을 막아요. 반드시 subscribeOn으로 스케줄러를 지정해야 합니다.
자주 만나는 함정 — 시험 직전 압축 노트
- 처리량 비교: R2DBC ~50,000건/초 vs JPA ~25,000건/초 (10만
findById기준) - 메모리 효율: R2DBC 200MB로 1,000만 건 성공 / JPA 4GB에서도 OOM
- JPA + 가상 스레드: 짧은 I/O에서 성능 개선 없음 — 긴 블로킹 작업에서 이점
- 백프레셔의 핵심: 소비자 속도에 맞춰 생산자(DB) 제어 → 일정 메모리 유지
- R2DBC Dirty Checking 없음 — 수정 후 반드시
save()명시적 호출 - 연관 객체:
@OneToMany없음. ID로 별도 조회하거나@QueryJOIN 직접 작성 - N+1 문제: JPA처럼
@FetchType.LAZY/EAGER없음. 처음부터 JOIN 쿼리 설계 - Optimistic Lock:
@Version어노테이션 R2DBC에서 동작 - Pessimistic Lock:
FOR UPDATESQL 직접 작성 (@Query이용) - 1차 캐시 없음: 같은 ID로 두 번 조회하면 DB 두 번 조회
- R2DBC
@Transactional: JPA와 어노테이션 동일, 내부는 Reactor Context 사용 - 리액티브 선언문 4원칙: 반응성 → 회복탄력성 → 탄력성 → 메시지 주도
- WebFlux + JPA:
Mono.fromCallable(...).subscribeOn(Schedulers.boundedElastic())패턴 subscribeOn빠뜨리면 위험: JPA 블로킹이 이벤트 루프 점유- R2DBC가 항상 빠르진 않음: 낮은 동시성·CPU 집약 작업은 JPA가 단순하고 빠를 수 있음
- 마이그레이션 전략: Phase 1 신규 서비스 R2DBC → Phase 2 고처리량 서비스 → Phase 3 복잡한 비즈니스 → Phase 4 전체 전환
시리즈 다른 편
같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.