관계형 데이터베이스를 논블로킹으로 접근하기 위한 별도 스펙. 결과 행을 한 번에 반환하지 않고 Publisher(값을 흘려보내는 발행자)로 내보낸다.
왜 만들어졌나
JDBC는 태생이 블로킹 API다. resultSet.next()는 데이터가 올 때까지 호출한 스레드를 붙잡는다. 리액티브 파이프라인은 "이벤트 루프 스레드를 절대 붙잡지 않는다"는 전제 위에 서 있으므로, JDBC를 그 안에 넣으면 전제가 무너진다. 그래서 프로토콜 수준부터 논블로킹으로 다시 만든 것이 R2DBC다.
결정적인 오해 — "JDBC의 리액티브 버전"이 아니다
이름이 비슷해서 갈아 끼우면 될 것 같지만, 완전히 다른 생태계다.
| 잃는 것 | 구체적으로 |
|---|---|
| JPA/Hibernate 전체 | R2DBC에는 JPA 구현이 없다. 영속성 컨텍스트(같은 트랜잭션 안에서 엔티티를 캐싱·추적하는 공간)·변경 감지·지연 로딩·연관관계 매핑이 전부 사라진다 |
| 마이그레이션 도구 | Flyway·Liquibase가 JDBC 기반이라 별도 설정이나 우회가 필요하다 |
| 혼용의 편의 | 한 앱에서 JPA와 R2DBC를 같이 쓰면 자동 설정이 서로를 비활성화해 명시적으로 풀어야 한다 |
| 익숙한 트랜잭션 감각 | 트랜잭션이 스레드가 아니라 리액터 컨텍스트를 타고 흐른다. @Transactional의 동작 방식 자체가 다르다 |
마지막 항목을 조금 더 보면 — 전통 방식에서 트랜잭션은 ThreadLocal에 담겨 "이 스레드가 곧 이 트랜잭션"이었다. 리액티브에서는 한 요청이 여러 스레드를 옮겨 다니므로 그 전제가 성립하지 않고, 대신 파이프라인을 따라 흐르는 컨텍스트 객체에 담긴다.
지금 위치
Java 24의 JEP 491이 가상 스레드의 pinning 문제를 없애면서, "블로킹 JDBC는 확장이 안 된다"는 R2DBC의 존재 이유가 크게 약해졌다. 가상 스레드 + JDBC + JPA 조합이 같은 처리량을 훨씬 단순한 코드로 내기 때문이다.
반대로 스트리밍이 본질이고 스택 전체가 이미 논블로킹이며 ORM 없이 살 수 있는 단순한 매핑이라면 여전히 제 값을 한다.
면접 포인트
"WebFlux를 쓰면 DB는 어떻게 접근하나요?"는 리액티브를 실제로 써 봤는지 가르는 질문이다. "R2DBC이고, 그 순간 JPA를 포기하게 된다"까지 답해야 완성된다.
코드로 보는 차이
// JDBC + JPA — 값을 반환한다. 호출한 스레드가 결과를 기다린다
public User find(Long id) {
return userRepository.findById(id).orElseThrow(NotFoundException::new);
}
// R2DBC — 값이 아니라 "값이 올 파이프라인"을 반환한다
public Mono<User> find(Long id) {
return userRepository.findById(id) // Mono<User>
.switchIfEmpty(Mono.error(new NotFoundException()));
}
반환 타입이 User에서 Mono<User>로 바뀌면 그 위의 서비스·컨트롤러가 전부 따라 바뀐다. 일부만 리액티브로 만들 수 없다는 것이 도입 비용의 실체다.
한 앱에서 둘을 같이 쓰기
spring-boot-starter-data-r2dbc 를 추가하면
→ R2DBC 자동 설정이 켜지고
→ JDBC·JPA 자동 설정이 조건에서 탈락한다
→ 둘 다 쓰려면 필요한 설정을 명시적으로 되살려야 한다
마이그레이션 도구도 갈린다 — Flyway·Liquibase는 JDBC 기반이라, R2DBC만 쓰는 앱에서도 스키마 관리용 JDBC 커넥션을 따로 둬야 한다.
트랜잭션이 스레드를 안 탄다
// 전통 방식: 트랜잭션이 ThreadLocal 에 담긴다 = "이 스레드가 곧 이 트랜잭션"
@Transactional
public void transfer(Long from, Long to, long amount) { ... }
// 리액티브: 한 요청이 여러 스레드를 옮겨 다니므로 그 전제가 성립하지 않는다
// 트랜잭션은 파이프라인을 따라 흐르는 Reactor 컨텍스트에 담긴다
@Transactional
public Mono<Void> transfer(Long from, Long to, long amount) {
return accountRepo.withdraw(from, amount)
.then(accountRepo.deposit(to, amount))
.then(); // 체인을 끊고 블로킹하면 트랜잭션 경계가 깨진다
}
체인 중간에서 block()을 부르거나 subscribe()를 따로 호출하면 그 작업은 트랜잭션 밖에서 돈다. 컴파일도 되고 예외도 안 나므로, 롤백이 안 되는 걸 운영에서야 발견하게 되는 유형의 사고다.