"리액티브 스택을 도입하면 전부 다 좋아지는 거 아닌가요?" — R2DBC를 써보기 전까지는 저도 그렇게 생각했습니다.

R2DBC의 약속과 현실

R2DBC는 관계형 DB를 논블로킹으로 접근하겠다는 멋진 약속을 합니다. WebFlux + R2DBC 조합이면 완전한 논블로킹 파이프라인이 완성되니까요. 하지만 실제로 프로젝트에 적용해보면, JPA에서 당연하게 쓰던 기능들이 없어서 당황하는 순간이 빈번합니다.

R2DBC는 **ORM이 아닙니다 **. 이 한 문장이 모든 한계의 출발점입니다. JDBC의 리액티브 대안이지, JPA의 리액티브 대안이 아닌 거죠.

R2DBC를 쓰기로 했다면, JPA에서 누리던 편의 기능 상당수를 직접 구현해야 한다는 사실을 먼저 받아들여야 합니다.

R2DBC에 없는 것들 — JPA와의 갭

한눈에 비교해보겠습니다.

기능JPA (Hibernate)R2DBC
Lazy Loading@ManyToOne(fetch = LAZY)없음
연관관계 매핑@OneToMany, @ManyToOne없음 — 수동 조합
Cascadingcascade = CascadeType.ALL없음 — 수동 저장/삭제
Dirty Checking영속성 컨텍스트가 자동 감지없음 — 명시적 save 필요
1차 캐시영속성 컨텍스트 내없음
2차 캐시EHCache, Redis 등 연동없음
자동 DDLspring.jpa.hibernate.ddl-auto없음 — Flyway/Liquibase 직접
JPQL/HQL객체 기반 쿼리 언어없음 — SQL 직접 작성
Specification동적 쿼리 조합없음 — 직접 구현

빠진 게 이렇게 많으면 "그럼 뭘 해주는 건데?"라는 생각이 들 수 있습니다. R2DBC(Spring Data R2DBC)가 해주는 건 ** 논블로킹 DB 접근 , ** 기본 CRUD Repository, **@Query를 통한 커스텀 쿼리 , ** 리액티브 트랜잭션 정도입니다. 나머지는 개발자의 몫입니다.

Lazy Loading이 없다는 것

JPA에서는 연관 엔티티를 실제로 접근할 때까지 로딩을 미룰 수 있습니다.

JAVA
// JPA — order.getItems()를 호출하는 시점에 쿼리 실행
@Entity
public class Order {
    @OneToMany(fetch = FetchType.LAZY, mappedBy = "order")
    private List<OrderItem> items; // 프록시 객체, 접근 시 로딩
}

R2DBC에서는 이런 게 불가능합니다. 프록시 객체도 없고, 영속성 컨텍스트도 없으니까요. 연관 데이터가 필요하면 ** 직접 쿼리를 한 번 더 날려야** 합니다.

JAVA
// R2DBC — 주문과 주문 항목을 별도 조회 후 조합
public Mono<OrderWithItems> findOrderWithItems(Long orderId) {
    Mono<Order> orderMono = orderRepository.findById(orderId);
    Flux<OrderItem> itemsFlux = orderItemRepository.findByOrderId(orderId);

    return orderMono.zipWith(itemsFlux.collectList())
        .map(tuple -> new OrderWithItems(
            tuple.getT1(),  // 주문
            tuple.getT2()   // 주문 항목 리스트
        ));
}

코드량이 늘어나는 건 사실이지만, 반대로 ** 어떤 쿼리가 언제 실행되는지 명확하게 보인다 **는 장점도 있습니다. JPA의 N+1 문제 같은 건 구조적으로 발생하지 않죠.

Cascading이 없다는 것

JPA에서는 부모 엔티티를 저장하면 자식도 함께 저장됩니다.

JAVA
// JPA — 부모 저장 시 자식도 자동 저장
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items;

// 이렇게만 하면 items도 함께 INSERT
orderRepository.save(order);

R2DBC에서는 이걸 전부 수동으로 해야 합니다.

JAVA
// R2DBC — 부모 저장 후 자식을 개별적으로 저장
public Mono<Order> saveOrderWithItems(Order order, List<OrderItem> items) {
    return orderRepository.save(order)
        .flatMap(savedOrder -> {
            // 각 항목에 주문 ID 설정
            items.forEach(item -> item.setOrderId(savedOrder.getId()));
            return orderItemRepository.saveAll(items)
                .collectList()
                .thenReturn(savedOrder);
        });
}

삭제도 마찬가지입니다. orphanRemoval = true 같은 건 없으니 직접 자식부터 삭제하고 부모를 삭제해야 합니다.

JAVA
// R2DBC — 삭제 순서를 직접 관리
public Mono<Void> deleteOrderWithItems(Long orderId) {
    return orderItemRepository.deleteByOrderId(orderId) // 자식 먼저 삭제
        .then(orderRepository.deleteById(orderId));      // 그 다음 부모 삭제
}

이 부분은 귀찮긴 하지만, 실무에서 Cascading이 의도치 않게 대량 삭제를 일으키는 사고를 생각하면 명시적인 게 오히려 안전할 수도 있습니다.

Dirty Checking이 없다는 것

JPA의 영속성 컨텍스트는 엔티티의 변경을 자동으로 감지합니다.

JAVA
// JPA — 별도 save 없이 트랜잭션 종료 시 자동 UPDATE
@Transactional
public void updateOrderStatus(Long orderId, OrderStatus status) {
    Order order = orderRepository.findById(orderId).orElseThrow();
    order.setStatus(status); // 변경 감지 → 트랜잭션 커밋 시 UPDATE 실행
}

R2DBC에서는 변경 후 ** 반드시 명시적으로 save를 호출 **해야 합니다.

JAVA
// R2DBC — 명시적으로 save 호출 필수
@Transactional
public Mono<Order> updateOrderStatus(Long orderId, OrderStatus status) {
    return orderRepository.findById(orderId)
        .map(order -> {
            order.setStatus(status);
            return order;
        })
        .flatMap(orderRepository::save); // 반드시 save 호출
}

save를 빼먹으면 변경사항이 그냥 사라집니다. JPA에 익숙한 개발자가 R2DBC로 넘어올 때 가장 자주 겪는 실수 중 하나입니다.

쿼리 추상화의 한계

Spring Data R2DBC도 메서드 이름 기반 쿼리 생성을 지원합니다.

JAVA
public interface OrderRepository extends ReactiveCrudRepository<Order, Long> {
    Flux<Order> findByStatus(OrderStatus status);           // 지원
    Flux<Order> findByCreatedDateAfter(LocalDateTime date); // 지원
}

하지만 조인이 들어가면 @Query로 직접 SQL을 작성해야 합니다.

JAVA
public interface OrderRepository extends ReactiveCrudRepository<Order, Long> {

    // 조인 쿼리 — SQL 직접 작성
    @Query("""
        SELECT o.*, u.name as user_name
        FROM orders o
        JOIN users u ON o.user_id = u.id
        WHERE o.status = :status
        """)
    Flux<OrderWithUserName> findOrdersWithUserByStatus(
        @Param("status") String status
    );
}

JPQL처럼 엔티티 기반 쿼리 언어가 없으므로, DB 종류에 따라 SQL 문법이 달라질 수 있다는 점도 고려해야 합니다. QueryDSL 같은 타입 안전 쿼리 빌더도 R2DBC에서는 공식 지원이 제한적입니다.

1:N 관계 조회 패턴

R2DBC에서 가장 번거로운 부분이 연관관계 조회입니다. 실무에서 자주 쓰는 패턴을 정리해보겠습니다.

패턴 1: 별도 조회 후 조합

JAVA
// 가장 기본적인 방법 — 두 번의 쿼리
public Mono<TeamWithMembers> findTeamWithMembers(Long teamId) {
    Mono<Team> teamMono = teamRepository.findById(teamId);
    Flux<Member> membersFlux = memberRepository.findByTeamId(teamId);

    return Mono.zip(teamMono, membersFlux.collectList())
        .map(tuple -> new TeamWithMembers(tuple.getT1(), tuple.getT2()));
}

패턴 2: 리스트 조회 시 N+1 방지

JAVA
// 팀 목록 조회 시, 각 팀의 멤버를 한 번에 가져오기
public Flux<TeamWithMembers> findAllTeamsWithMembers() {
    return teamRepository.findAll()
        .collectList()
        .flatMapMany(teams -> {
            // 모든 팀 ID를 모아서 한 번에 멤버 조회
            List<Long> teamIds = teams.stream()
                .map(Team::getId)
                .toList();

            return memberRepository.findByTeamIdIn(teamIds)
                .collectMultimap(Member::getTeamId) // teamId별로 그룹핑
                .flatMapMany(memberMap ->
                    Flux.fromIterable(teams)
                        .map(team -> new TeamWithMembers(
                            team,
                            memberMap.getOrDefault(team.getId(), List.of())
                        ))
                );
        });
}

더 복잡한 케이스에서는 DatabaseClient로 조인 SQL을 직접 실행하고 bufferUntilChanged로 그룹핑하는 패턴도 있지만, 코드가 상당히 길어집니다. JPA라면 @OneToMany 하나와 fetch join 하나로 끝날 일이죠.

연관관계가 3~4단계로 깊어지면 R2DBC 코드의 복잡도는 기하급수적으로 증가합니다. 이 시점이 "JPA가 맞았나?" 하고 되돌아보게 되는 순간입니다.

Virtual Threads — 판을 흔드는 대안

Java 21에서 등장한 Virtual Threads는 R2DBC의 존재 이유를 근본적으로 흔듭니다.

R2DBC를 선택하는 핵심 이유가 "논블로킹으로 스레드를 효율적으로 사용하기 위해서"였는데, Virtual Threads가 이 문제를 블로킹 코드 그대로 해결해버리기 때문입니다.

JAVA
// Virtual Threads + JPA — 블로킹 코드지만 스레드가 가볍다
@RestController
public class OrderController {

    @GetMapping("/orders/{id}")
    public OrderDto getOrder(@PathVariable Long id) {
        // 블로킹 JPA 호출이지만, Virtual Thread에서 실행되므로
        // 플랫폼 스레드를 점유하지 않는다
        Order order = orderRepository.findById(id).orElseThrow();
        return OrderDto.from(order);
    }
}
YAML
# application.yml — Virtual Threads 활성화 (Spring Boot 3.2+)
spring:
  threads:
    virtual:
      enabled: true

이 설정 하나면 톰캣이 Virtual Threads를 사용합니다. JPA의 모든 편의 기능을 그대로 쓰면서도, 동시성 측면에서 리액티브 스택에 근접한 성능을 낼 수 있습니다.

물론 Virtual Threads가 만능은 아닙니다. CPU 바운드 작업에서는 이점이 없고, synchronized 블록에서 pinning이 발생할 수 있습니다. 하지만 대부분의 웹 애플리케이션은 I/O 바운드이므로, 이 조합이 실용적인 대안이 됩니다.

선택 기준 — 언제 JPA, 언제 R2DBC

JPA (+ MVC)를 선택해야 할 때

  • 도메인 모델이 복잡하고, 엔티티 간 관계가 깊다
  • 팀에 JPA 경험이 충분하다
  • CRUD 위주 비즈니스 로직이 많다
  • Virtual Threads로 동시성 문제를 충분히 해결할 수 있다

R2DBC (+ WebFlux)를 선택해야 할 때

  • 극단적인 동시성이 필요하다 (수만 이상의 동시 커넥션)
  • 이미 WebFlux 기반 프로젝트다
  • 엔티티 관계가 단순하고 flat한 구조다
  • 스트리밍 처리가 핵심이다 (SSE, WebSocket 등)

둘 다 쓸 수 있을까?

가능합니다. 하나의 프로젝트에서 모듈별로 다른 스택을 쓸 수 있습니다.

PLAINTEXT
┌─────────────────────────────────────────┐
│              API Gateway                │
├──────────────────┬──────────────────────┤
│   주문 서비스     │    알림 서비스        │
│   MVC + JPA      │    WebFlux + R2DBC   │
│   (복잡한 도메인) │    (높은 동시성)     │
└──────────────────┴──────────────────────┘

MSA 환경이라면 서비스 특성에 맞게 기술을 선택하면 됩니다. 복잡한 비즈니스 로직은 JPA로, 단순하지만 높은 동시성이 필요한 서비스는 R2DBC로 나누는 전략이 실용적입니다.

결정 매트릭스

결정이 어려울 때 참고할 수 있는 체크리스트입니다.

질문JPA 쪽R2DBC 쪽
엔티티 관계가 3단계 이상인가?O
동시 커넥션이 수만 이상인가?O
팀이 리액티브에 익숙한가?O
기존 코드가 MVC 기반인가?O
실시간 스트리밍이 핵심인가?O
Virtual Threads를 쓸 수 있는가? (Java 21+)O
ORM 편의 기능이 생산성에 큰 영향을 주는가?O

미래 전망

Java 21+ 환경이 보편화되면 "논블로킹을 위해 리액티브를 선택한다"는 동기가 약해집니다. Spring 팀도 Virtual Threads를 적극 지원하면서 블로킹 JPA + Virtual Threads를 현실적 대안으로 제시하고 있습니다. 반면 R2DBC 생태계도 계속 성숙 중이고, ORM 계층을 올리려는 커뮤니티 시도도 이어지고 있습니다.

새 프로젝트를 시작한다면, 먼저 MVC + JPA + Virtual Threads 조합을 검토하세요. 이 조합으로 충분하지 않은 구체적인 이유가 있을 때 R2DBC를 고려하는 게 합리적입니다. "리액티브가 최신이니까"보다는 "우리 서비스에 정말 필요한가"가 기준이 되어야 합니다.

정리

  • R2DBC는 ORM이 아니라 ** 리액티브 DB 접근 기술 **이다
  • JPA의 Lazy Loading, Cascading, Dirty Checking, 1차 캐시 등이 ** 전부 없다**
  • 연관관계 조회는 별도 쿼리 + 코드 조합으로 직접 구현해야 한다
  • Virtual Threads가 등장하면서 논블로킹을 위해 리액티브를 선택해야 할 이유가 줄었다
  • 기술 선택은 "최신인가"가 아니라 "우리 상황에 맞는가" 로 판단해야 한다
댓글 로딩 중...