R2DBC vs JPA 완전 비교 — 성능·메모리·선택 기준

2026-05-03AWS SAA-C03 스터디

Spring WebFlux 핵심 정리 시리즈 3편. R2DBC vs JPA 처리량·메모리 벤치마크 수치 비교, JPA 1차 캐시·Dirty Checking 부재, N+1 수동 해결, 리액티브 선언문 4원칙 — 가사도우미 vs 셀프서비스 비유로 선택 기준까지 풀어가는 실전 가이드.

📚 Spring WebFlux 핵심 정리 · 3편 / 14편 — 성능·메모리·선택 기준

이 글은 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
}

벤치마크 결과:

측정 항목R2DBCJPAJPA + 가상 스레드
완료 시간약 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 초과
}

결과 비교:

측정 항목R2DBCJPA
최소 필요 메모리200MB4GB 이상 (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로 별도 조회하거나 @Query JOIN 직접 작성
  • N+1 문제: JPA처럼 @FetchType.LAZY/EAGER 없음. 처음부터 JOIN 쿼리 설계
  • Optimistic Lock: @Version 어노테이션 R2DBC에서 동작
  • Pessimistic Lock: FOR UPDATE SQL 직접 작성 (@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 전체 전환

시리즈 다른 편

같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!