백엔드 면접 용어 사전
Spring·JPAReactive Relational Database Connectivity

R2DBC

관계형 DB를 논블로킹으로 접근하는 스펙. JDBC의 리액티브 버전이 아니라 별도 생태계이며, JPA가 존재하지 않는다.

관계형 데이터베이스를 논블로킹으로 접근하기 위한 별도 스펙. 결과 행을 한 번에 반환하지 않고 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()를 따로 호출하면 그 작업은 트랜잭션 밖에서 돈다. 컴파일도 되고 예외도 안 나므로, 롤백이 안 되는 걸 운영에서야 발견하게 되는 유형의 사고다.

함께 보면 좋은 용어

노트에서 맥락과 함께 보기 — 모던 자바·스프링 — 가상 스레드·JEP 491·리액티브·jOOQ·Boot 4