Spring Data R2DBC 완전 정복 — 리액티브 DB 연동

2026-05-03AWS SAA-C03 스터디

Spring WebFlux 핵심 정리 시리즈 2편. R2DBC가 무엇인지, ReactiveCrudRepository와 R2dbcEntityTemplate의 차이, 파생 쿼리 메서드, @Query, 페이징·정렬, 트랜잭션 관리까지 — 비동기 우체국 비유로 처음부터 풀어가는 R2DBC 핵심 가이드.

📚 Spring WebFlux 핵심 정리 · 2편 / 14편 — 리액티브 DB 연동

이 글은 Spring WebFlux 핵심 정리 시리즈의 두 번째 편입니다. 1편에서 "왜 WebFlux가 필요한가, 이벤트 루프는 어떻게 동작하는가"를 잡았다면, 이번 편에서는 드디어 데이터베이스 연동으로 내려갑니다. 논블로킹 웹 계층을 만들었는데 데이터베이스 조회에서 블로킹이 발생하면 이벤트 루프가 막혀버리기 때문에, WebFlux를 제대로 쓰려면 R2DBC를 같이 배우는 게 사실상 필수예요.

이번 편 핵심 질문은 딱 하나입니다. "JPA 대신 R2DBC를 쓰면 실제로 코드가 어떻게 달라지는가?" — Repository 선언, 엔티티 매핑, 쿼리 작성, 트랜잭션까지 실제 코드를 따라가며 익혀 가겠습니다.

📚 학습 노트

이 시리즈는 Spring 공식 문서, Project Reactor 공식 문서, R2DBC 명세, 여러 리액티브 백엔드 학습 자료를 참고해 한국어 학습 노트로 풀어쓴 자료입니다.

코드 예제는 PostgreSQL + Spring Boot 환경 기준이에요. H2 인메모리로도 바로 실습할 수 있습니다.

왜 R2DBC가 처음엔 낯설게 느껴질까요

이유는 네 가지예요.

첫째, JPA가 워낙 편해서 불편함을 못 느낍니다. @Entity, @OneToMany, save() 한 줄 — JPA가 해결해 주던 것들이 R2DBC에서는 다 사라집니다. 연관 매핑도 없고 Lazy Loading도 없어요.

둘째, URL 형식부터 다릅니다. JDBC는 jdbc:postgresql://... 인데 R2DBC는 r2dbc:postgresql://...입니다. 이 한 글자 차이 때문에 연결이 아예 안 되는 상황을 처음에 꼭 만납니다.

셋째, 모든 메서드가 Mono/Flux를 반환합니다. findById(1)Mono 를 돌려줘요. 비어있을 수도 있는 비동기 컨테이너를 다루는 패턴 — 직전 편까지 익혀온 것들이 여기서 드디어 연결됩니다.

넷째, JPA의 Dirty Checking이 없습니다. 객체를 꺼내서 setName("새이름") 한 뒤 아무것도 안 하면 저장이 안 돼요. 명시적으로 save() 를 다시 호출해야 합니다.

해결법은 비유 하나입니다. R2DBC = "비동기 우체국" — 편지(쿼리)를 창구에 맡기고 내 볼일을 보러 가면, 답장(결과)이 오면 알림이 옵니다. JPA는 창구 앞에 줄 서서 기다리는 방식이고요. 이 그림 하나 잡고 시작하겠습니다.

R2DBC가 무엇인가 — 개념과 의존성

R2DBC(Reactive Relational Database Connectivity)는 관계형 데이터베이스를 논블로킹 방식으로 연결하기 위한 리액티브 드라이버 명세(Specification)입니다. JDBC가 1990년대 동기·블로킹 모델로 설계된 반면, R2DBC는 처음부터 리액티브로 설계됐어요.

Spring Data R2DBC는 이 R2DBC 명세 위에 Spring Data 패턴을 씌운 것입니다. Repository 인터페이스 자동 구현, 파생 쿼리 메서드, @Query 어노테이션 같은 편의 기능을 제공해요.

의존성 설정부터 보겠습니다.

<!-- pom.xml -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webflux</artifactId>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-r2dbc</artifactId>
</dependency>

<!-- PostgreSQL R2DBC 드라이버 -->
<dependency>
    <groupId>org.postgresql</groupId>
    <artifactId>r2dbc-postgresql</artifactId>
    <scope>runtime</scope>
</dependency>

<!-- 리액터 테스트 (StepVerifier) -->
<dependency>
    <groupId>io.projectreactor</groupId>
    <artifactId>reactor-test</artifactId>
    <scope>test</scope>
</dependency>

다음은 application.properties:

# R2DBC 연결 URL
spring.r2dbc.url=r2dbc:postgresql://localhost:5432/mydb
spring.r2dbc.username=postgres
spring.r2dbc.password=password

# R2DBC 쿼리 로그 (개발용, 운영에서는 끔)
logging.level.org.springframework.r2dbc=DEBUG

여기서 시험 함정이 하나 있어요. spring.r2dbc.urljdbc: 로 시작하는 JDBC URL을 그대로 복붙하면 애플리케이션이 연결 자체를 못 합니다. r2dbc: 프리픽스로 시작해야 R2DBC 드라이버가 인식해요. JDBC URL과 R2DBC URL의 형식이 다르다는 것, 꼭 기억해 두세요.

한 줄 정리 — R2DBC는 "WebFlux 전용 비동기 DB 드라이버 명세". JPA와 공존할 수 없는 게 아니라 역할이 다른 것.

엔티티 매핑 — JPA와 무엇이 다른가

R2DBC 엔티티는 JPA 엔티티와 비슷해 보이지만 중요한 차이가 있어요.

@Table("customer")
public class Customer {

    // @Id: Primary Key 지정 (필수)
    // JPA와 달리 @GeneratedValue 없음 — DB의 SERIAL/AUTO_INCREMENT가 자동 처리
    @Id
    private Integer id;

    @Column("name")
    private String name;

    private String email;

    // 기본 생성자 필수 (R2DBC가 리플렉션으로 객체 생성)
    public Customer() {}

    public Customer(String name, String email) {
        this.name = name;
        this.email = email;
    }

    // getter/setter 또는 Lombok @Data
}

여기서 시험 함정이 하나 있어요. R2DBC 엔티티에는 @OneToMany, @ManyToOne, @JoinColumn 같은 JPA 관계 매핑 어노테이션이 동작하지 않습니다. 이 어노테이션들은 JPA(Hibernate) 전용이에요. R2DBC 엔티티에 붙여도 컴파일은 통과하지만 아무 효과가 없어요. 연관된 데이터가 필요하면 @QueryDatabaseClient로 JOIN 쿼리를 직접 작성해야 합니다.

ReactiveCrudRepository — 고수준 접근법

R2DBC의 핵심 인터페이스예요. 인터페이스 선언만 하면 Spring Data가 구현체를 자동 생성해 줍니다.

public interface CustomerRepository extends ReactiveCrudRepository<Customer, Integer> {

    // ===== 파생 쿼리 메서드 =====
    Mono<Customer> findByName(String name);
    Mono<Customer> findByEmail(String email);
    Flux<Customer> findByEmailEndingWith(String domain);
    Flux<Customer> findByEmailContaining(String keyword);
    Mono<Customer> findByNameAndEmail(String name, String email);

    // ===== @Query: 커스텀 네이티브 SQL =====
    @Query("""
        SELECT p.*
        FROM product p
        JOIN customer_order co ON p.id = co.product_id
        JOIN customer c ON c.id = co.customer_id
        WHERE c.name = :name
        """)
    Flux<Product> findProductsByCustomerName(String name);

    @Query("SELECT COUNT(*) FROM customer WHERE email LIKE '%@gmail.com'")
    Mono<Long> countGmailCustomers();
}

ReactiveCrudRepository가 기본으로 제공하는 메서드들은 모두 Mono 또는 Flux를 반환합니다. JPA의 Optional, List 와 달리요.

Mono<T> findById(ID id);          // 없으면 빈 Mono
Flux<T> findAll();                // 전체 조회
Mono<S> save(S entity);           // id 없으면 INSERT, 있으면 UPDATE
Mono<Void> deleteById(ID id);     // 삭제
Mono<Long> count();               // 건수
Mono<Boolean> existsById(ID id);  // 존재 여부

여기서 시험 함정이 하나 있어요. findById()는 존재하지 않는 ID를 주면 null이 아니라 빈 Mono를 반환합니다. if (result == null) 로 체크해도 result는 항상 Mono 객체이기 때문에 null이 아니에요. 빈 Mono 처리는 .switchIfEmpty(Mono.error(...)) 패턴을 써야 합니다.

파생 쿼리 메서드 키워드 요약:

키워드SQL 변환
findBy[Field]WHERE field = ?
findBy[Field]ContainingWHERE field LIKE '%?%'
findBy[Field]EndingWithWHERE field LIKE '%?'
findBy[Field]BetweenWHERE field BETWEEN ? AND ?
findBy[Field]GreaterThanWHERE field > ?
findAllBy[Field]OrderBy[Field2]AscWHERE ... ORDER BY ... ASC

한 줄 정리 — ReactiveCrudRepository는 JPA의 JpaRepository에 해당. 단, 반환 타입은 전부 Mono/Flux.

DatabaseClient — 저수준 완전 제어

파생 쿼리나 @Query로 표현하기 어려운 복잡한 동적 쿼리, JOIN 결과를 커스텀 DTO에 매핑해야 할 때 DatabaseClient를 씁니다.

@Service
@RequiredArgsConstructor
public class OrderQueryService {

    private final DatabaseClient databaseClient;

    public Flux<OrderDetails> findOrderDetails(String customerName) {
        String sql = """
            SELECT
                c.name AS customer_name,
                p.description AS product_description,
                p.price AS product_price,
                co.amount AS order_amount,
                (p.price * co.amount) AS total_price
            FROM customer c
            JOIN customer_order co ON c.id = co.customer_id
            JOIN product p ON p.id = co.product_id
            WHERE c.name = :customerName
            ORDER BY total_price DESC
            """;

        return databaseClient.sql(sql)
                // 네임드 파라미터 바인딩 (SQL Injection 방지)
                .bind("customerName", customerName)
                .map((row, metadata) -> new OrderDetails(
                    row.get("customer_name", String.class),
                    row.get("product_description", String.class),
                    row.get("product_price", Integer.class),
                    row.get("order_amount", Integer.class),
                    row.get("total_price", Integer.class)
                ))
                .all(); // 다건이면 .all(), 단건이면 .one()
    }
}

DatabaseClient vs @Query vs 파생 쿼리 비교:

항목파생 쿼리 메서드@QueryDatabaseClient
복잡도단순 조건고정된 복잡한 SQL동적 쿼리, 집계
동적 쿼리불가불가가능
JOIN불가가능가능
코드 가독성높음중간낮음 (장황)

한 줄 정리 — 단순 CRUD는 ReactiveCrudRepository, 복잡한 JOIN/집계는 @Query 또는 DatabaseClient.

트랜잭션 — @Transactional 그대로 쓰면 된다

R2DBC를 처음 배울 때 "트랜잭션은 어떻게 처리하지?" 하고 걱정하는 경우가 많아요. 결론부터 말하면 @Transactional 어노테이션을 그대로 사용하면 됩니다. Spring Boot가 R2DBC용 ReactiveTransactionManager를 자동 구성해 줘요.

@Service
public class CustomerTransactionService {

    @Transactional
    public Mono<Void> createCustomerWithOrder(CustomerDto dto) {
        // 이 메서드가 반환하는 Mono가 구독될 때 트랜잭션 시작
        // Mono onComplete → COMMIT
        // Mono onError → ROLLBACK
        return customerRepository.save(toEntity(dto))
                .flatMap(customer -> orderRepository.save(
                        Order.builder().customerId(customer.getId()).build()
                ))
                .then();
    }

    @Transactional(readOnly = true)
    public Flux<Customer> findAll() {
        return customerRepository.findAll();
    }
}

여기서 시험 함정이 하나 있어요. JPA의 @Transactional은 스레드 로컬(ThreadLocal)에 트랜잭션을 저장합니다. 리액티브에서는 스레드가 자유롭게 전환되기 때문에 ThreadLocal이 동작하지 않아요. R2DBC의 @Transactional은 Reactor Context에 트랜잭션을 저장합니다. 사용자 입장에서 어노테이션 문법은 같지만 내부 동작 방식이 완전히 다릅니다.

StepVerifier — 리액티브 테스트의 핵심

R2DBC 코드를 테스트할 때는 StepVerifier를 씁니다. .block()으로 강제 동기화하는 방법도 있지만, 테스트 코드에서도 리액티브 파이프라인을 그대로 검증하는 게 더 올바른 방식이에요.

@SpringBootTest
class CustomerRepositoryTest {

    @Autowired
    private CustomerRepository repository;

    @Test
    void testFindAll() {
        repository.findAll()
            .as(StepVerifier::create)
            .expectNextCount(10)          // 10개 아이템 기대
            .expectComplete()             // 정상 완료 기대
            .verify();                    // 실제 구독 시작
    }

    @Test
    void testFindByIdExists() {
        repository.findById(2)
            .as(StepVerifier::create)
            .assertNext(c -> {
                Assertions.assertEquals("Mike", c.getName());
            })
            .expectComplete()
            .verify();
    }

    @Test
    void testFindByIdNotExists() {
        repository.findById(999)
            .as(StepVerifier::create)
            // 빈 Mono: 아무것도 방출 안 하고 즉시 완료
            .expectComplete()
            .verify();
    }

    @Test
    void testSaveAndUpdate() {
        repository.findById(1)
            .doOnNext(c -> c.setName("Updated"))
            .flatMap(c -> repository.save(c))
            .as(StepVerifier::create)
            .assertNext(saved -> Assertions.assertEquals("Updated", saved.getName()))
            .expectComplete()
            .verify();
    }
}

업데이트 패턴 doOnNext + flatMap이 핵심이에요. map을 쓰면 save()Mono를 반환하기 때문에 Mono>가 돼버립니다. flatMap으로 한 겹 벗겨야 해요.

자주 만나는 함정 — 시험 직전 압축 노트

여기까지가 Spring Data R2DBC 2편의 핵심입니다.

  • R2DBC = 리액티브 관계형 DB 드라이버 명세 — 처음부터 논블로킹으로 설계됨
  • R2DBC URL: r2dbc:postgresql://... — JDBC URL(jdbc:...)과 다름, 가장 흔한 실수
  • spring-boot-starter-data-r2dbc 의존성 + DB 전용 R2DBC 드라이버(e.g. r2dbc-postgresql)
  • ReactiveCrudRepository — 인터페이스 선언만으로 CRUD 자동. 모든 메서드 반환 타입 Mono/Flux
  • 파생 쿼리 메서드findByName, findByEmailContaining 등 메서드 이름으로 SQL 자동 생성
  • @Query — 고정된 복잡한 JOIN SQL 직접 작성, :name 형식 네임드 파라미터
  • DatabaseClient — 동적 쿼리, 복잡한 집계. .bind("param", value).all()
  • @Id 필수, @GeneratedValue 불필요 — DB SERIAL이 자동 처리
  • @OneToMany, @ManyToOne 동작 안 함 — JPA 어노테이션, R2DBC에서 무효
  • Dirty Checking 없음 — 객체 수정 후 명시적 save() 호출 필요
  • save() 동작id == null → INSERT, id != null → UPDATE
  • findById() 빈 결과null이 아닌 빈 Mono 반환. .switchIfEmpty(Mono.error(...)) 처리
  • map vs flatMapsave()처럼 Mono를 반환하는 함수는 반드시 flatMap
  • @Transactional 그대로 OK — R2DBC용 ReactiveTransactionManager 자동 구성
  • 트랜잭션 내부 차이 — JPA는 ThreadLocal, R2DBC는 Reactor Context에 저장
  • StepVerifier.as(StepVerifier::create).expectNextCount(N).expectComplete().verify()
  • then() — 이전 Mono 완료 후 새 Publisher 시작, 결과 버림
  • flatMap() — Publisher를 반환하는 함수를 체인으로 이어 결과 활용
  • schema.sqlsrc/main/resources/schema.sql 에 DDL 작성하면 앱 시작 시 자동 실행
  • 다중 패키지 충돌 방지@EnableR2dbcRepositories(basePackages = "...")로 스캔 범위 제한
  • 백프레셔 내장 — R2DBC는 DB에서 한 번에 다 가져오지 않고 소비자 속도에 맞춰 스트리밍

시리즈 다른 편

같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.

※ 이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.

답글 남기기

error: Content is protected !!