R2DBC — 관계형 DB를 리액티브하게 접근하는 방법
Spring WebFlux로 논블로킹 웹 서버를 만들었는데, DB 접근만 JDBC로 하고 있다면 정말 리액티브한 걸까요?
R2DBC란
R2DBC(Reactive Relational Database Connectivity)는 관계형 데이터베이스를 논블로킹으로 접근하기 위한 SPI(Service Provider Interface)입니다. JDBC가 블로킹 I/O 기반인 것과 달리, R2DBC는 리액티브 스트림을 기반으로 DB 연산의 결과를 Mono와 Flux로 반환합니다.
왜 필요한가
리액티브 스택에서 JDBC를 사용하면 이런 문제가 생깁니다.
[요청] → [Netty 이벤트 루프] → [WebFlux 핸들러] → [JDBC 호출 ❌ 블로킹]
WebFlux는 소수의 이벤트 루프 스레드로 동작하는데, JDBC 호출이 스레드를 블로킹하면 전체 처리량이 급격히 떨어집니다. R2DBC를 사용하면 DB I/O도 논블로킹으로 처리되어 스레드가 낭비되지 않습니다.
[요청] → [Netty 이벤트 루프] → [WebFlux 핸들러] → [R2DBC ✅ 논블로킹]
R2DBC vs JDBC
| 기준 | JDBC | R2DBC |
|---|---|---|
| I/O 모델 | 블로킹 | 논블로킹 |
| 반환 타입 | 직접 결과 반환 | Mono/Flux |
| 스레드 모델 | 요청당 스레드 | 이벤트 루프 |
| ORM 지원 | JPA/Hibernate | 없음 (매핑만 제공) |
| 지원 DB | 거의 모든 RDBMS | PostgreSQL, MySQL, H2, MSSQL 등 |
| 적합한 환경 | Spring MVC | Spring WebFlux |
의존성과 설정
// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-data-r2dbc'
runtimeOnly 'org.postgresql:r2dbc-postgresql' // DB 드라이버
# application.yml
spring:
r2dbc:
url: r2dbc:postgresql://localhost:5432/mydb
username: postgres
password: secret
pool:
initial-size: 5
max-size: 20
max-idle-time: 30m
도메인 모델
R2DBC는 JPA와 달리 엔티티 매핑이 단순합니다. @Entity 대신 일반 클래스에 @Table과 @Id를 사용합니다.
@Table("orders")
@Getter
@NoArgsConstructor
public class Order {
@Id
private Long id;
@Column("customer_id")
private Long customerId;
private String status;
private BigDecimal totalAmount;
private LocalDateTime createdAt;
// R2DBC는 연관관계 매핑이 없음
// @OneToMany 같은 어노테이션 사용 불가
}
중요한 차이점은 R2DBC가 연관관계 매핑을 지원하지 않는다는 것입니다. @OneToMany, @ManyToOne 같은 JPA 어노테이션은 사용할 수 없고, 관련 데이터는 별도 쿼리로 조회해야 합니다.
ReactiveCrudRepository
public interface OrderRepository extends ReactiveCrudRepository<Order, Long> {
Flux<Order> findByCustomerId(Long customerId);
Flux<Order> findByStatus(String status);
Flux<Order> findByCreatedAtBetween(
LocalDateTime start, LocalDateTime end);
@Query("SELECT * FROM orders WHERE total_amount > :amount ORDER BY created_at DESC")
Flux<Order> findExpensiveOrders(@Param("amount") BigDecimal amount);
@Modifying
@Query("UPDATE orders SET status = :status WHERE id = :id")
Mono<Integer> updateStatus(@Param("id") Long id,
@Param("status") String status);
Mono<Long> countByStatus(String status);
}
서비스 계층
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository;
public Mono<Order> createOrder(Order order) {
return orderRepository.save(order);
}
public Flux<Order> getCustomerOrders(Long customerId) {
return orderRepository.findByCustomerId(customerId);
}
R2DBC는 연관관계 자동 로딩이 없으므로 flatMap으로 관련 데이터를 수동 조합해야 합니다.
// 연관 데이터를 조합하는 패턴
public Flux<OrderWithItems> getOrdersWithItems(Long customerId) {
return orderRepository.findByCustomerId(customerId)
.flatMap(order ->
orderItemRepository.findByOrderId(order.getId())
.collectList()
.map(items -> new OrderWithItems(order, items))
);
}
}
DatabaseClient — 저수준 API
DatabaseClient는 JDBC의 JdbcTemplate에 해당하는 저수준 API입니다. SQL을 직접 작성하고 결과를 세밀하게 제어할 수 있습니다.
@Service
@RequiredArgsConstructor
public class OrderQueryService {
private final DatabaseClient databaseClient;
public Flux<Order> searchOrders(String status, BigDecimal minAmount) {
// 동적 쿼리 구성
StringBuilder sql = new StringBuilder("SELECT * FROM orders WHERE 1=1");
Map<String, Object> params = new HashMap<>();
if (status != null) {
sql.append(" AND status = :status");
params.put("status", status);
}
동적으로 조립한 SQL과 파라미터를 DatabaseClient에 바인딩합니다.
if (minAmount != null) {
sql.append(" AND total_amount >= :minAmount");
params.put("minAmount", minAmount);
}
DatabaseClient.GenericExecuteSpec spec =
databaseClient.sql(sql.toString());
for (Map.Entry<String, Object> entry : params.entrySet()) {
spec = spec.bind(entry.getKey(), entry.getValue());
}
조립한 SQL을 실행하고 row mapper로 결과를 도메인 객체로 변환합니다.
return spec.map((row, metadata) -> Order.builder()
.id(row.get("id", Long.class))
.customerId(row.get("customer_id", Long.class))
.status(row.get("status", String.class))
.totalAmount(row.get("total_amount", BigDecimal.class))
.createdAt(row.get("created_at", LocalDateTime.class))
.build())
.all();
}
INSERT 후 생성된 키를 반환하려면 returnGeneratedValues()를 사용합니다.
// INSERT 후 생성된 ID 반환
public Mono<Long> insertOrder(Order order) {
return databaseClient.sql("""
INSERT INTO orders (customer_id, status, total_amount, created_at)
VALUES (:customerId, :status, :totalAmount, :createdAt)
""")
.bind("customerId", order.getCustomerId())
.bind("status", order.getStatus())
.bind("totalAmount", order.getTotalAmount())
.bind("createdAt", LocalDateTime.now())
.filter(statement -> statement.returnGeneratedValues("id"))
.map(row -> row.get("id", Long.class))
.one();
}
}
리액티브 트랜잭션
R2DBC에서도 @Transactional을 사용할 수 있습니다. Spring Boot가 R2dbcTransactionManager를 자동으로 등록합니다.
@Service
@RequiredArgsConstructor
public class OrderTransactionService {
private final OrderRepository orderRepository;
private final OrderItemRepository orderItemRepository;
private final InventoryRepository inventoryRepository;
@Transactional
public Mono<Order> placeOrder(OrderRequest request) {
Order order = Order.from(request);
주문 저장 후 각 아이템에 대해 재고 차감과 주문 아이템 저장을 리액티브 체인으로 연결합니다.
return orderRepository.save(order)
.flatMap(savedOrder ->
Flux.fromIterable(request.getItems())
.flatMap(item -> {
// 재고 차감
return inventoryRepository.decreaseStock(
item.getProductId(), item.getQuantity())
.then(orderItemRepository.save(
OrderItem.of(savedOrder.getId(), item)));
})
.then(Mono.just(savedOrder))
);
// 예외 발생 시 자동 롤백
}
}
프로그래밍 방식 트랜잭션
@Service
@RequiredArgsConstructor
public class ManualTransactionService {
private final TransactionalOperator transactionalOperator;
private final OrderRepository orderRepository;
public Mono<Order> createWithManualTx(Order order) {
return orderRepository.save(order)
.as(transactionalOperator::transactional);
}
}
R2DBC vs JPA — 연관관계 처리 차이
JPA에서 당연하게 사용하던 기능들이 R2DBC에서는 지원되지 않습니다.
// JPA — 연관관계 자동 로딩
@Entity
public class Order {
@OneToMany(mappedBy = "order", fetch = FetchType.LAZY)
private List<OrderItem> items; // 자동으로 쿼리 실행
}
// R2DBC — 수동으로 조합
@Table("orders")
public class Order {
@Id private Long id;
// items 필드 없음 — 별도 쿼리로 조회
}
R2DBC에서는 연관 데이터를 zipWith이나 flatMap으로 직접 조합해야 합니다.
// 서비스에서 직접 조합
public Mono<OrderDetail> getOrderDetail(Long orderId) {
Mono<Order> orderMono = orderRepository.findById(orderId);
Flux<OrderItem> itemsFlux = orderItemRepository.findByOrderId(orderId);
return orderMono.zipWith(itemsFlux.collectList(),
(order, items) -> new OrderDetail(order, items));
}
스키마 관리
R2DBC는 JPA의 spring.jpa.hibernate.ddl-auto 같은 자동 DDL 생성을 지원하지 않습니다. Flyway나 Liquibase를 사용해야 합니다.
# Flyway와 함께 사용 (JDBC 드라이버 필요)
spring:
flyway:
url: jdbc:postgresql://localhost:5432/mydb
user: postgres
password: secret
locations: classpath:db/migration
-- V1__create_orders.sql
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
total_amount DECIMAL(10, 2) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX idx_orders_customer ON orders (customer_id);
CREATE INDEX idx_orders_status ON orders (status);
언제 R2DBC를 선택할까
R2DBC가 적합한 경우
- Spring WebFlux 기반 애플리케이션
- 높은 동시성, 적은 DB 커넥션으로 많은 요청 처리
- 단순한 쿼리 패턴, 복잡한 연관관계가 적은 경우
JPA/JDBC가 적합한 경우
- Spring MVC 기반 애플리케이션
- 복잡한 연관관계와 지연 로딩이 필요한 경우
- 풍부한 ORM 기능(캐시, dirty checking 등)이 필요한 경우
주의할 점
1. 리액티브 체인에서 .block()을 호출하면 이벤트 루프 스레드가 블로킹된다
WebFlux 환경에서 Mono/Flux에 .block()을 사용하면 Netty 이벤트 루프 스레드가 멈추면서 전체 처리량이 급격히 떨어집니다. 심한 경우 IllegalStateException: block()/blockFirst()/blockLast() are blocking 에러가 발생합니다. 리액티브 체인 안에서는 반드시 flatMap, map, zipWith 등으로 데이터를 조합해야 합니다.
2. R2DBC는 연관관계 자동 로딩이 없어 N+1 문제를 직접 관리해야 한다
JPA처럼 @OneToMany로 연관 데이터를 자동 로딩하는 기능이 없으므로, 각 주문의 아이템을 조회하려면 별도 쿼리를 직접 작성해야 합니다. flatMap으로 주문마다 아이템을 조회하면 N+1이 발생하므로, WHERE order_id IN (:ids) 같은 배치 조회 패턴을 사용해야 합니다.
3. 스키마 마이그레이션에 JDBC 드라이버가 별도로 필요하다
R2DBC만으로는 Flyway나 Liquibase를 실행할 수 없습니다. 스키마 마이그레이션을 위해 JDBC 드라이버를 별도로 추가해야 하며, spring.flyway.url에 JDBC URL을 따로 지정해야 합니다. R2DBC 프로젝트인데 JDBC 의존성이 왜 필요한지 혼란스러울 수 있으니 미리 인지하고 있어야 합니다.
정리
- R2DBC 는 관계형 DB를 논블로킹으로 접근하기 위한 표준입니다. WebFlux와 함께 사용할 때 진정한 리액티브 스택을 구성할 수 있습니다.
- ReactiveCrudRepository 로 간단한 CRUD를, DatabaseClient 로 복잡한 쿼리를 처리합니다.
@Transactional은R2dbcTransactionManager와 함께 동작합니다.- JPA의 연관관계 매핑, 지연 로딩, 자동 DDL은 지원하지 않습니다. 스키마 관리에 Flyway를 사용하세요.
- 모든 프로젝트에 R2DBC가 필요한 것은 아닙니다. 리액티브 스택의 일관성이 필요할 때 선택하세요.