콘텐츠로 이동

Reactive (WebFlux + R2DBC) 지원

easy-paging은 두 가지 리액티브 시나리오를 지원합니다:

시나리오 필요한 것 보면 좋은 섹션
WebFlux 앱에서 블로킹 MyBatis 호출 추가 의존성 없음 — ReactivePagingSupport는 core 스타터에 포함 아래 MyBatis on Reactor
WebFlux 앱이 Spring Data R2DBC 사용 (네이티브 논블로킹 SQL) 별도 easy-paging-spring-boot-starter-reactive 아티팩트 추가 아래 네이티브 R2DBC + WebFlux

두 시나리오는 함께 쓸 수 있음 — MyBatis와 R2DBC를 둘 다 쓰는 프로젝트는 양쪽 스타터를 추가하고 쿼리에 맞춰 헬퍼를 선택.


MyBatis on Reactor

WebFlux/Reactor 앱에서 블로킹 MyBatis를 호출해야 하는 경우. core 스타터가 제공하는 ReactivePagingSupportstartPage → 매퍼 호출 → clearPage 전체를 단일 Mono.fromCallable 안에 묶어 블로킹 IO 스케줄러에 올립니다. 덕분에 PageHelper의 ThreadLocal이 호출 전체에서 일관성을 유지합니다.

기본 사용

@RestController
class ReportController {

    private final ReportService reports;

    ReportController(ReportService reports) { this.reports = reports; }

    @GetMapping("/reports")
    public Mono<PageResponse<Report>> list(Pageable pageable) {
        return reports.list(pageable);
    }
}
@Service
class ReportService {

    private final ReportMapper mapper;

    ReportService(ReportMapper mapper) { this.mapper = mapper; }

    public Mono<PageResponse<Report>> list(Pageable pageable) {
        return ReactivePagingSupport.paginate(
            pageable,
            () -> mapper.findAll(),     // 블로킹 MyBatis 호출, 워커 스레드 위로 옮겨짐
            /* maxSize */ 100,
            /* count   */ true);
    }
}

매퍼와 XML은 offset 섹션과 동일 — ReactivePagingSupport는 호출 디스패치 방식만 바꿀 뿐, 쿼리 자체는 그대로.

커스텀 스케줄러

블로킹 작업은 기본 Schedulers.boundedElastic(). DB 전용 스케줄러를 쓰고 싶다면 전달:

return ReactivePagingSupport.paginate(
    pageable,
    () -> mapper.findAll(),
    /* maxSize    */ 100,
    /* count      */ true,
    /* scheduler  */ databaseScheduler);

@AutoPaginate를 직접 쓰지 않나요?

@AutoPaginate는 Spring AOP와 PageHelper의 ThreadLocal을 통해 동작합니다. Reactor의 스케줄러 모델 때문에 메서드를 호출하는 스레드가 매퍼를 실제 실행하는 스레드와 다를 수 있어 — PageHelper의 ThreadLocal이 엉뚱한 스레드에 설정될 수 있습니다.

ReactivePagingSupport.paginate는 pageable + 플래그를 호출 시점에 캡처하고, boundedElastic(또는 커스텀 스케줄러)에서 subscribe한 뒤, 그 스레드 안에서 PageHelper 설정 → 매퍼 호출 → 정리 전체를 수행해 이 문제를 해결합니다.


네이티브 R2DBC + WebFlux

Spring Data R2DBC를 쓰는 앱(요청 경로에 MyBatis 없음). 옵션 스타터 추가:

build.gradle.kts
dependencies {
    implementation("kr.devslab:easy-paging-spring-boot-starter:4.0.0")
    implementation("kr.devslab:easy-paging-spring-boot-starter-reactive:4.0.0")
}

(옵션 reactive 라인은 SB3 maintenance 라인에서도 3.0.0으로 게시 — 라인 선택은 설치 참조.)

본인이 추가(일반적 Spring Data R2DBC 프로젝트와 동일): R2DBC 드라이버, spring-boot-starter-webflux, spring-boot-starter-data-r2dbc.

Offset / limit 페이지네이션

R2dbcOffsetPagingSupport.paginate(...)는 페이지 행 쿼리와 총개수 쿼리 두 개의 R2DBC 호출을 단일 Mono<PageResponse<T>>로 묶습니다. MyBatis 쪽과 같은 응답 봉투 모양이라, 클라이언트는 백엔드와 무관하게 하나의 계약을 봅니다.

@RestController
@RequestMapping("/users")
class UserController {

    private final R2dbcEntityTemplate template;

    UserController(R2dbcEntityTemplate template) { this.template = template; }

    @GetMapping
    public Mono<PageResponse<User>> list(Pageable pageable) {
        return R2dbcOffsetPagingSupport.paginate(
            template,
            User.class,
            Criteria.where("active").isTrue(),   // 추가 필터 (없으면 Criteria.empty())
            pageable);
    }
}

Pageable의 sort가 그대로 적용 — PageRequest.of(0, 20, Sort.by("createdAt").descending())로 정렬 제어. 같은 Criteria가 count 쿼리에도 적용되므로 totalElements가 사용자가 실제로 스크롤할 수 있는 행 수와 일치.

Keyset (커서) 페이지네이션

R2dbcKeysetSupport.paginate(...)는 MyBatis 쪽 KeysetPage.build의 R2DBC 버전. 키 컬럼 메타데이터 + KeysetRequest를 넘기면 사전순(lexicographic) WHERE, 적절한 ORDER BY(역방향 스캔이면 뒤집힌), size + 1 쿼리, 그리고 KeysetPage 조립까지 헬퍼가 처리합니다.

import kr.devslab.easypaging.r2dbc.R2dbcKeysetSupport;
import kr.devslab.easypaging.r2dbc.R2dbcKeysetSupport.KeyColumn;
import kr.devslab.easypaging.r2dbc.R2dbcKeysetSupport.SortDirection;

@RestController
@RequestMapping("/events")
class EventController {

    // 복합 키: 타임스탬프(비-고유) + id 타이브레이커
    private static final List<KeyColumn> KEYS = List.of(
        new KeyColumn("created_at", "createdAt", Instant.class, SortDirection.DESC),
        new KeyColumn("id",         "id",        Long.class,    SortDirection.DESC));

    private final R2dbcEntityTemplate template;
    private final CursorCodec codec;

    EventController(R2dbcEntityTemplate template, CursorCodec codec) {
        this.template = template;
        this.codec = codec;
    }

    @GetMapping
    @KeysetPaginate(keys = {"createdAt", "id"}, direction = "DESC", defaultSize = 50)
    public Mono<KeysetPage<Event>> stream(KeysetRequest request) {
        return R2dbcKeysetSupport.paginate(
            template,
            Event.class,
            Criteria.empty(),
            KEYS,
            request,
            e -> Map.of("createdAt", e.getCreatedAt(), "id", e.getId()),
            codec);
    }
}

KeyColumn 파라미터: column(DB 컬럼명), cursorKey(커서 JSON에서 쓰는 이름), javaType(JSON에서 String/Number로 복원된 커서 값을 다시 올바른 타입으로 강제 변환 — Instant, Long, UUID 등), naturalDirection(컬럼의 의도된 정렬 — WHEREORDER BY 유도에 사용).

커서 토큰 자체에 스캔 방향이 인코딩되므로 클라이언트는 ?cursor=nextCursor / ?cursor=prevCursor만으로 충분 — 별도 ?direction= 파라미터 불필요.

내장 타입 지원

R2dbcKeysetSupport가 자동으로 강제 변환하는 타입: String, primitive wrapper들 (Long, Integer, Double 등), Instant, LocalDateTime, OffsetDateTime, LocalDate, UUID. 그 외 타입은 헬퍼를 우회해서 Criteria를 직접 작성.

WebFlux 인자 리졸버

KeysetRequest 파라미터는 WebFlux 컨트롤러에서 자동 해결 — servlet 측과 동일한 쿼리 파라미터(?cursor=...&size=...&direction=...). 별도 설정 없이 스타터의 auto-config가 WebFlux 클래스패스 발견 시 ReactiveKeysetRequestArgumentResolver를 등록.

커서 서명 (프로덕션)

servlet 측과 동일 — 프로덕션에서는 easy-paging.keyset.cursor-secret 설정. 시크릿 없으면 Base64만 적용되어 악의적 클라이언트가 커서 위조 가능. 자세한 내용은 Keyset 가이드 참조.


함께 보기