R2DBC의 한계 — JPA와 비교한 트레이드오프와 선택 기준
"리액티브 스택을 도입하면 전부 다 좋아지는 거 아닌가요?" — 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 등 | 없음 — 수동 조합 |
| Cascading | cascade = CascadeType.ALL | 없음 — 수동 저장/삭제 |
| Dirty Checking | 영속성 컨텍스트가 자동 감지 | 없음 — 명시적 save 필요 |
| 1차 캐시 | 영속성 컨텍스트 내 | 없음 |
| 2차 캐시 | EHCache, Redis 등 연동 | 없음 |
| 자동 DDL | spring.jpa.hibernate.ddl-auto | 없음 — Flyway/Liquibase 직접 |
| JPQL/HQL | 객체 기반 쿼리 언어 | 없음 — SQL 직접 작성 |
| Specification | 동적 쿼리 조합 | 없음 — 직접 구현 |
빠진 게 이렇게 많으면 "그럼 뭘 해주는 건데?"라는 생각이 들 수 있습니다. R2DBC(Spring Data R2DBC)가 해주는 건 ** 논블로킹 DB 접근 , ** 기본 CRUD Repository, **@Query를 통한 커스텀 쿼리 , ** 리액티브 트랜잭션 정도입니다. 나머지는 개발자의 몫입니다.
Lazy Loading이 없다는 것
JPA에서는 연관 엔티티를 실제로 접근할 때까지 로딩을 미룰 수 있습니다.
// JPA — order.getItems()를 호출하는 시점에 쿼리 실행
@Entity
public class Order {
@OneToMany(fetch = FetchType.LAZY, mappedBy = "order")
private List<OrderItem> items; // 프록시 객체, 접근 시 로딩
}
R2DBC에서는 이런 게 불가능합니다. 프록시 객체도 없고, 영속성 컨텍스트도 없으니까요. 연관 데이터가 필요하면 ** 직접 쿼리를 한 번 더 날려야** 합니다.
// 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에서는 부모 엔티티를 저장하면 자식도 함께 저장됩니다.
// JPA — 부모 저장 시 자식도 자동 저장
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items;
// 이렇게만 하면 items도 함께 INSERT
orderRepository.save(order);
R2DBC에서는 이걸 전부 수동으로 해야 합니다.
// 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 같은 건 없으니 직접 자식부터 삭제하고 부모를 삭제해야 합니다.
// R2DBC — 삭제 순서를 직접 관리
public Mono<Void> deleteOrderWithItems(Long orderId) {
return orderItemRepository.deleteByOrderId(orderId) // 자식 먼저 삭제
.then(orderRepository.deleteById(orderId)); // 그 다음 부모 삭제
}
이 부분은 귀찮긴 하지만, 실무에서 Cascading이 의도치 않게 대량 삭제를 일으키는 사고를 생각하면 명시적인 게 오히려 안전할 수도 있습니다.
Dirty Checking이 없다는 것
JPA의 영속성 컨텍스트는 엔티티의 변경을 자동으로 감지합니다.
// JPA — 별도 save 없이 트랜잭션 종료 시 자동 UPDATE
@Transactional
public void updateOrderStatus(Long orderId, OrderStatus status) {
Order order = orderRepository.findById(orderId).orElseThrow();
order.setStatus(status); // 변경 감지 → 트랜잭션 커밋 시 UPDATE 실행
}
R2DBC에서는 변경 후 ** 반드시 명시적으로 save를 호출 **해야 합니다.
// 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도 메서드 이름 기반 쿼리 생성을 지원합니다.
public interface OrderRepository extends ReactiveCrudRepository<Order, Long> {
Flux<Order> findByStatus(OrderStatus status); // 지원
Flux<Order> findByCreatedDateAfter(LocalDateTime date); // 지원
}
하지만 조인이 들어가면 @Query로 직접 SQL을 작성해야 합니다.
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: 별도 조회 후 조합
// 가장 기본적인 방법 — 두 번의 쿼리
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 방지
// 팀 목록 조회 시, 각 팀의 멤버를 한 번에 가져오기
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가 이 문제를 블로킹 코드 그대로 해결해버리기 때문입니다.
// 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);
}
}
# 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 등)
둘 다 쓸 수 있을까?
가능합니다. 하나의 프로젝트에서 모듈별로 다른 스택을 쓸 수 있습니다.
┌─────────────────────────────────────────┐
│ 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가 등장하면서 논블로킹을 위해 리액티브를 선택해야 할 이유가 줄었다
- 기술 선택은 "최신인가"가 아니라 "우리 상황에 맞는가" 로 판단해야 한다