Spring WebFlux로 논블로킹 웹 서버를 만들었는데, DB 접근만 JDBC로 하고 있다면 정말 리액티브한 걸까요?

R2DBC란

R2DBC(Reactive Relational Database Connectivity)는 관계형 데이터베이스를 논블로킹으로 접근하기 위한 SPI(Service Provider Interface)입니다. JDBC가 블로킹 I/O 기반인 것과 달리, R2DBC는 리액티브 스트림을 기반으로 DB 연산의 결과를 MonoFlux로 반환합니다.

왜 필요한가

리액티브 스택에서 JDBC를 사용하면 이런 문제가 생깁니다.

PLAINTEXT
[요청] → [Netty 이벤트 루프] → [WebFlux 핸들러] → [JDBC 호출 ❌ 블로킹]

WebFlux는 소수의 이벤트 루프 스레드로 동작하는데, JDBC 호출이 스레드를 블로킹하면 전체 처리량이 급격히 떨어집니다. R2DBC를 사용하면 DB I/O도 논블로킹으로 처리되어 스레드가 낭비되지 않습니다.

PLAINTEXT
[요청] → [Netty 이벤트 루프] → [WebFlux 핸들러] → [R2DBC ✅ 논블로킹]

R2DBC vs JDBC

기준JDBCR2DBC
I/O 모델블로킹논블로킹
반환 타입직접 결과 반환Mono/Flux
스레드 모델요청당 스레드이벤트 루프
ORM 지원JPA/Hibernate없음 (매핑만 제공)
지원 DB거의 모든 RDBMSPostgreSQL, MySQL, H2, MSSQL 등
적합한 환경Spring MVCSpring WebFlux

의존성과 설정

JAVA
// build.gradle
implementation 'org.springframework.boot:spring-boot-starter-data-r2dbc'
runtimeOnly 'org.postgresql:r2dbc-postgresql'  // DB 드라이버
YAML
# 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를 사용합니다.

JAVA
@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

JAVA
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);
}

서비스 계층

JAVA
@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으로 관련 데이터를 수동 조합해야 합니다.

JAVA
    // 연관 데이터를 조합하는 패턴
    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을 직접 작성하고 결과를 세밀하게 제어할 수 있습니다.

JAVA
@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에 바인딩합니다.

JAVA
        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로 결과를 도메인 객체로 변환합니다.

JAVA
        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()를 사용합니다.

JAVA
    // 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를 자동으로 등록합니다.

JAVA
@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);

주문 저장 후 각 아이템에 대해 재고 차감과 주문 아이템 저장을 리액티브 체인으로 연결합니다.

JAVA
        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))
            );
        // 예외 발생 시 자동 롤백
    }
}

프로그래밍 방식 트랜잭션

JAVA
@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에서는 지원되지 않습니다.

JAVA
// 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으로 직접 조합해야 합니다.

JAVA
// 서비스에서 직접 조합
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 생성을 지원하지 않습니다. FlywayLiquibase를 사용해야 합니다.

YAML
# Flyway와 함께 사용 (JDBC 드라이버 필요)
spring:
  flyway:
    url: jdbc:postgresql://localhost:5432/mydb
    user: postgres
    password: secret
    locations: classpath:db/migration
SQL
-- 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 로 복잡한 쿼리를 처리합니다.
  • @TransactionalR2dbcTransactionManager와 함께 동작합니다.
  • JPA의 연관관계 매핑, 지연 로딩, 자동 DDL은 지원하지 않습니다. 스키마 관리에 Flyway를 사용하세요.
  • 모든 프로젝트에 R2DBC가 필요한 것은 아닙니다. 리액티브 스택의 일관성이 필요할 때 선택하세요.
댓글 로딩 중...