| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | 7 |
| 8 | 9 | 10 | 11 | 12 | 13 | 14 |
| 15 | 16 | 17 | 18 | 19 | 20 | 21 |
| 22 | 23 | 24 | 25 | 26 | 27 | 28 |
- monitoring
- 성능 최적화
- 데이터베이스
- NIO
- SpringBoot
- RDBMS
- 백엔드개발
- 동시성제어
- docker
- prometheus
- grafana
- JPA
- GitOps
- DevOps
- netty
- Java
- webflux
- helm
- 트랜잭션
- redis
- mysql
- 백엔드
- Kubernetes
- kafka
- 성능최적화
- spring boot
- jvm
- selector
- Kotlin
- CloudNative
- Today
- Total
유성
[WebFlux] R2DBC는 왜 Page를 지원하지 않을까? 본문
Spring Data JPA를 사용하다가 R2DBC로 넘어오면 가장 먼저 마주치는 당황스러운 지점이 있다.
바로 Page<T>를 반환하는 메서드를 만들 수 없다는 것이다.
왜 R2DBC는 그 편리한 페이지네이션을 공식적으로 지원하지 않을까?
1. 근본적인 원인: DB는 'Page'라는 개념이 없다.
우리가 SQL에서 사용하는 LIMIT과 OFFSET은 데이터를 끊어서 가져오는 도구일 뿐, DB 엔진 자체가 "전체 10페이지 중 1페이지" 라는 객체를 만들어 주지는 않는다.
(만약 이러한 객체가 있었다면 충분히 리액티브 스타일이 가능했다)
Page<T> 객체를 만들기 위해서는 반드시 두 가지 정보가 필요하다.
- 조회 데이터: LIMIT, OFFSET으로 잘라낸 데이터 뭉치
- 전체 카운트: SELECT COUNT(*)로 계산한 전체 레코드 수
2. R2DBC에서 Page가 독이 되는 이유
JPA(JDBC)에서는 이 두작업을 하나의 트랜잭션 안에서 처리하고 Page 객체에 담아 반환한다.
하지만 비동기 논블로킹 환경인 R2DBC에서는 이 과정이 치명적인 약점이 된다.
- 결과 대기(Blocking): 전체 페이지 수를 알기 전까지는 Page 객체를 완성할 수 없다. 즉, 데이터가 다 나오고 COUNT 쿼리 결과가 도착할 때까지 흐름을 멈추고 기다려야 한다.
- 리소스 낭비: 리액티브의 목적은 "데이터가 준비되는 대로 흘려보내는 것"인데, Page를 만드는 순간 모든 데이터를 메모리에 쌓아두어야 한다. 이는 적은 메모리로 대용량 데이터를 처리하려는 R2DBC의 철학과 정면 충돌한다.
3. 로우 레벨에서 본 데이터의 흐름
그렇다면 R2DBC는 데이터를 어떻게 가져올까? 여기서 우리가 지난번에 배운 Selector와 네트워크 패킷의 원리가 등장한다.
MySQL 같은 DB에서 300개를 조회한다고 가정해 보자.
- 패킷 단위 전송: MySQL은 300개를 다 모아서 주지 않는다. 네트워크 MTU(약 1.5KB) 단위로 데이터를 쪼개서 보낸다.
- 수신: 한 패킷에는 보통 Row가 10~20개 정도 담겨서 들어온다.
- Selector의 감지: Selector는 이 패킷(10개 분량)이 도착할 때마다 이벤트를 터뜨리고, R2DBC 드라이버는 이를 즉시 해석해 Flux로 애플리케이션에 전달한다.
결과적으로 데이터는 10개, 10개, 10개... 이런 식으로 끊임없이 흐른다. 300개가 다 오지 않아도 첫 번째 10개는 이미 우리 비즈니스 로직에 도착해 처리되고 있다.
만약 Page 없이 이런 데이터를 클라이언트에게 전달해야 한다면, 10개씩 도착할 때마다 멈춤없이 클라이언트에게 전달하면 된다.
이런 경우 메모리 사용량 0에 수렴한다.
그러나 Page의 경우 전체 데이터 개수를 확인하고, 데이터와 함께 제공해야 하기에 300개를 모두 메모리에 올려놓고 대기를 시켜야 한다.
이렇게 되면 Flux<T>의 효율성은 사라지고, Mono<Page<T>> 형태가 되어 약간은 비효율적으로 클라이언트에게 전달되게 된다.
4. R2DBC 스타일의 해결책
R2DBC에서 Page를 효율화하는 완벽한 은탄환은 없으나, 시스템의 성격에 따라 다음과 같은 대안을 선택할 수 있다.
1) Slice 활용: Fetch One More
전체 카운트를 과감히 포기하고, "다음 페이지가 있는가?" 라는 상태만 제공하여 '무한 스크롤' 환경에 최적화하는 방식이다.
- 동작 원리: 요청받은 pageSize가 20개라면, 쿼리에는 LIMIT 21을 적용한다.
- 판단 로직: 실제 데이터가 21개 돌아왔다면 "다음 페이지가 있음"으로 판단하고, 클라이언트에게는 마지막 데이터를 제외한 20개와 '다음 페이지 있음' 값을 전달한다.
- 한계: 결국 서버 단에서 21번째 데이터를 확인한 뒤 응답 객체(Slice<T>)를 만들어야 하므로, 내부적으로는 collectList() 등을 호출하게 되어 완벽한 스트리밍이 아닌 '데이터 조합'을 위한 일시적임 멈춤이 발생한다.
2) Slice 클라이언트 판단 위임: 리액티브 최대 활용
개인적으로 고안한 방식이다. 서버에 데이터를 묶지 않고, 21개의 데이터를 Flux 그대로 클라이언트에게 쏘아 올리는 방식이다.
- 서버의 역할: "나는 판단하지 않는다. 오는 대로 그냥 던진다" 서버는 collectList()라는 메서드를 치우고, DB에서 패킷이 오는대로 클라이언트에게 던진다.
- 클라이언트 역할: 데이터를 수신하는 족족 사용자에게 보여준다. 들어오는 데이터 20개 까지는 보여지는 데이터로 사용하고, 21번째 데이터는 "다음 페이지 버튼"을 활성화하는 용도로만 사용한다.
- 장점: 리액티브 효율성을 극한까지 사용한 방식으로 서버 메모리 점유율을 0에 가깝게 만들수 있고, 클라이언트에서 첫 데이터를 더 빠르게 볼수 있기 때문에 사용자 입장에서 더 쾌적한 환경을 제공한다.
- 단점: 프론트엔드와 백엔드 사이의 정교한 프로토콜 협의 비용이 발생한다. 프론트엔드 개발범위가 늘어난다.
(21번째 데이터를 사용자에게 보여주면 데이터 중복 표출 문제가 발생)
3) Zip 연산자
UI 요건상 반드시 페이지 번호가 필요하다면, 데이터 조회(Flux)와 전체 카운트 조회(Mono)를 각각 실행한 후 Zip으로 결합한다.
- 특징: 두 쿼리가 비동기적으로 실행되므로 동기 방식보다는 빠를 수 있다.
- 한계: 결국 두 결과가 모두 도착할 때까지 기다려야 하므로, 데이터가 준비되는 대로 즉시 응답하는 리액티브의 속도감은 희생된다.
5. 마치며
R2DBC가 데이터가 계속 흐르는 '강'이라고 한다면 Page는 데이터를 모아두는 '댐'에 가깝다.
둘간의 관계를 표현하면 R2DBC와 Page는 상극에 해당한다.
결국 R2DBC의 비효율을 만들거나, 프론트엔드와 함께 효율성을 추구해야만 한다.
그럼에도 불구하고, Spring Data팀에서 간단하게 Mono<Page>하나 정도는 넣어주었으면 하는 개발자로써의 아쉬움이 남긴 한다.
Spring Data 팀은 편리함이라는 이름으로 개발자들이 비효율적인 댐을 짓는 것을 권장하고 싶지 않았을지도 모르겠다.
'Architecture' 카테고리의 다른 글
| [백프레셔 전략] 엔진 내부 TCP 수신 성능 개선 (0) | 2026.01.07 |
|---|---|
| 조회수 증가 API: 왜 RDBMS에 값을 넣는 것이 불가능에 가까울까? (0) | 2026.01.06 |
| WebFlux 의 중심, Netty 이벤트 루프는 누가 깨우는가? (0) | 2026.01.03 |
| WebFlux의 내부 파이프라인, Publisher의 구성과 내부 작동 방식 (1) | 2026.01.02 |
| 네트워크/IO 퍼포먼스 (Zero-Copy) (3) | 2025.12.21 |