Spring WebFlux 핵심 정리 시리즈 2편. R2DBC가 무엇인지, ReactiveCrudRepository와 R2dbcEntityTemplate의 차이, 파생 쿼리 메서드, @Query, 페이징·정렬, 트랜잭션 관리까지 — 비동기 우체국 비유로 처음부터 풀어가는 R2DBC 핵심 가이드.
이 글은 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.url에 jdbc: 로 시작하는 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 엔티티에 붙여도 컴파일은 통과하지만 아무 효과가 없어요. 연관된 데이터가 필요하면 @Query나 DatabaseClient로 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]Containing | WHERE field LIKE '%?%' |
findBy[Field]EndingWith | WHERE field LIKE '%?' |
findBy[Field]Between | WHERE field BETWEEN ? AND ? |
findBy[Field]GreaterThan | WHERE field > ? |
findAllBy[Field]OrderBy[Field2]Asc | WHERE ... 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 파생 쿼리 비교:
| 항목 | 파생 쿼리 메서드 | @Query | DatabaseClient |
|---|---|---|---|
| 복잡도 | 단순 조건 | 고정된 복잡한 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→ UPDATEfindById()빈 결과 —null이 아닌 빈Mono반환..switchIfEmpty(Mono.error(...))처리mapvsflatMap—save()처럼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.sql —
src/main/resources/schema.sql에 DDL 작성하면 앱 시작 시 자동 실행 - 다중 패키지 충돌 방지 —
@EnableR2dbcRepositories(basePackages = "...")로 스캔 범위 제한 - 백프레셔 내장 — R2DBC는 DB에서 한 번에 다 가져오지 않고 소비자 속도에 맞춰 스트리밍
시리즈 다른 편
같은 시리즈의 다른 글들도 같은 친절 톤으로 묶어 정리되어 있어요.