English · 한국어
Annotation-driven pagination for Spring Boot + MyBatis. Offset and keyset/cursor in one starter.
📖 Documentation → easy-paging.devslab.kr
💬 Questions, ideas, sharing your application? Head to devslab-examples Discussions — bilingual, maintained by the same folks who write the libraries.
Drop one annotation on a controller method and get a JSON-ready paginated response. With the standard Controller → Service → Mapper layering, the controller stays thin:
// Controller
@RestController
class ReportController {
private final ReportService reports;
ReportController(ReportService reports) {
this.reports = reports;
}
@GetMapping("/reports")
@AutoPaginate(maxSize = 50)
public PageResponse<Report> list(Pageable pageable) {
return PageResponse.from(reports.findAll(), pageable);
}
}
// Service
@Service
class ReportService {
private final ReportMapper mapper;
ReportService(ReportMapper mapper) {
this.mapper = mapper;
}
public List<Report> findAll() {
return mapper.findAll(); // pagination is injected by the aspect at the controller level
}
}(Imports omitted for brevity — the next section shows the full file with every import, plus the MyBatis mapper.)
A request to GET /reports?page=0&size=20&sort=createdAt,desc returns:
{
"content": [ /* 20 rows */ ],
"page": 0,
"size": 20,
"totalElements": 137,
"totalPages": 7,
"first": true,
"last": false,
"empty": false
}The same endpoint, written by hand against PageHelper, looks roughly like this:
@GetMapping("/reports")
public Map<String, Object> list(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size,
@RequestParam(required = false) String sort
) {
// 1. Validate page size — clients can otherwise DoS you with ?size=999999.
if (size <= 0 || size > 100) size = 20;
// 2. Parse the ?sort= parameter and reject SQL-injection attempts
// (?sort=name;DROP TABLE users would otherwise reach the database).
String orderBy = parseAndValidateSort(sort); // ~30 lines of code somewhere
// 3. Push pagination state onto PageHelper's per-thread stack.
PageHelper.startPage(page + 1, size); // 1-indexed, not 0-indexed
if (!orderBy.isEmpty()) {
PageHelper.orderBy(orderBy);
}
try {
// 4. Run the query — PageHelper now intercepts and rewrites the SQL.
PageInfo<Report> info = new PageInfo<>(reportMapper.findAll());
// 5. Hand-build the response JSON.
return Map.of(
"content", info.getList(),
"page", page,
"size", size,
"totalElements", info.getTotal(),
"totalPages", info.getPages(),
"first", info.isIsFirstPage(),
"last", info.isIsLastPage()
);
} finally {
// 6. Critical: clear the per-thread state. Forget this and the next
// request that lands on the same thread (or Virtual Thread carrier)
// will inherit stale pagination settings, paginating queries that
// were never meant to be paginated.
PageHelper.clearPage();
}
}The starter collapses all six concerns into the four-line controller at the top of this section. Steps 1, 2, 5, and 6 disappear entirely; step 3 becomes the annotation; step 4 stays the same plain mapper call.
- Spring Data-shaped JSON out of the box (or override the response format if you have a company standard).
- 0-based pagination following Spring Data convention (
?page=0is the first page); PageHelper's 1-based indexing is handled transparently underneath. - Safe
?sort=…— the sort parameter is validated before it reaches the database; injection attempts are rejected with HTTP 400. - Page-size clamping at both the per-endpoint and global level — clients can't ask for
?size=999999. - Sensible defaults — page size, max size, and "reasonable" out-of-range handling are all configurable.
- Keyset (cursor) pagination for time-series or unbounded tables where
OFFSETandCOUNT(*)become slow (details). - WebFlux/Reactor support for blocking MyBatis on
Schedulers.boundedElastic()(details). - Native R2DBC + WebFlux via the optional
…-starter-reactivecompanion artifact —Mono<PageResponse<T>>fromR2dbcEntityTemplate, lexicographic keysetWHEREbuilder, reactiveKeysetRequestargument resolver. Same envelope shape as the MyBatis side so clients see one contract. - Safe under Virtual Threads — internal state is cleaned up on every request, no leaks.
// build.gradle.kts
dependencies {
implementation("kr.devslab:easy-paging-spring-boot-starter:4.0.0")
// Optional — only if you also need native R2DBC + WebFlux helpers:
// implementation("kr.devslab:easy-paging-spring-boot-starter-reactive:4.0.0")
}You bring:
- Spring Boot 4.0+ on Java 21+ (built/tested against 4.0.6)
- a JDBC driver (or an R2DBC driver if using the reactive starter)
Still on Spring Boot 3.3–3.5? Use the
3.xmaintenance line (kr.devslab:easy-paging-spring-boot-starter:3.0.0). Same API, certified against the SB3 BOM, still receives security patches. The library major matches the Spring Boot major — see the versioning policy.
(MyBatis Spring Boot Starter is provided transitively — see the installation guide if you need a different MyBatis line.)
The core starter pulls in spring-boot-starter-aspectj (renamed from spring-boot-starter-aop in SB4), spring-data-commons, pagehelper-spring-boot-starter, and mybatis-spring-boot-starter. You do not need Spring Data JPA — only the lightweight spring-data-commons (which provides Pageable, Page, Sort) comes along automatically.
The reactive starter declares spring-boot-starter-webflux and spring-boot-starter-data-r2dbc as compileOnly, so you only pay for what you actually use.
Standalone Spring Boot projects that exercise every feature documented below — clone, ./gradlew bootRun, curl. No copy-paste from this README into your own project; the examples are already wired up end to end (test classes included).
| Demo | Showcases |
|---|---|
easy-paging-demo |
@AutoPaginate against H2 — also covers the advanced custom envelope section (/reports/company and /reports/auto-envelope) |
easy-paging-keyset-demo |
@KeysetPaginate cursor pagination over a 300-row time-series (H2). Test asserts the cursor walk covers every row exactly once |
easy-paging-postgres-demo |
Same starter against real PostgreSQL — Docker Compose for bootRun, Testcontainers + @ServiceConnection for tests, no local Postgres install |
easy-paging-reactive-demo |
The reactive companion artifact via R2dbcOffsetPagingSupport (WebFlux + R2DBC + Docker PostgreSQL) |
Full index at github.com/devslab-kr/devslab-examples.
The default strategy. Best for traditional paginated lists where you want a total count and the data fits comfortably in LIMIT/OFFSET.
A complete, layered implementation:
// src/main/java/com/example/report/ReportController.java
package com.example.report;
import kr.devslab.easypaging.annotation.AutoPaginate;
import kr.devslab.easypaging.core.PageResponse;
import org.springframework.data.domain.Pageable;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/reports")
class ReportController {
private final ReportService reports;
ReportController(ReportService reports) {
this.reports = reports;
}
@GetMapping
@AutoPaginate(maxSize = 50)
public PageResponse<Report> list(Pageable pageable) {
// The aspect calls PageHelper.startPage(...) before this body runs,
// so the mapper call inside reports.findAll() is automatically paginated.
return PageResponse.from(reports.findAll(), pageable);
}
}// src/main/java/com/example/report/ReportService.java
package com.example.report;
import java.util.List;
import org.springframework.stereotype.Service;
@Service
class ReportService {
private final ReportMapper mapper;
ReportService(ReportMapper mapper) {
this.mapper = mapper;
}
public List<Report> findAll() {
// A real service typically applies authorization, tenant filtering,
// and domain rules here before reaching the mapper.
return mapper.findAll();
}
}The MyBatis mapper stays a plain List query — no pagination logic, no LIMIT/OFFSET. With the XML mapping style typical in enterprise codebases, the interface is just a method signature:
// src/main/java/com/example/report/ReportMapper.java
package com.example.report;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ReportMapper {
List<Report> findAll(); // aspect injects pagination at runtime
}<!-- src/main/resources/mapper/ReportMapper.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.report.ReportMapper">
<select id="findAll" resultType="com.example.report.Report">
SELECT id, title, created_at AS createdAt
FROM reports
</select>
</mapper>Point MyBatis at the XML file via application.yml:
mybatis:
mapper-locations: classpath:mapper/**/*.xml
configuration:
map-underscore-to-camel-case: trueThe @AutoPaginate aspect intercepts the controller call, sets up PageHelper's per-thread state, and the very next MyBatis query runs with the correct LIMIT/OFFSET and ORDER BY injected — your XML stays mercifully boring.
| Attribute | Default | Meaning |
|---|---|---|
count |
true |
Run the COUNT(*) query for totalElements/totalPages. Disable for time-series or log tables where the count would dominate query time. |
maxSize |
100 |
Upper bound on caller-supplied page size. |
reasonable |
true |
When true, out-of-range page numbers are silently clamped instead of returning empty. |
// Controller
@GetMapping("/audit-events")
@AutoPaginate(
count = false, // audit log has 100M+ rows; COUNT(*) would dominate
maxSize = 200, // power users need bigger pages for export
reasonable = false // strict mode: page > totalPages returns empty
)
public PageResponse<AuditEvent> events(Pageable pageable) {
return PageResponse.from(auditEvents.findAll(), pageable);
}
// Service
@Service
class AuditEventService {
private final AuditEventMapper mapper;
// ... constructor omitted
public List<AuditEvent> findAll() {
return mapper.findAll();
}
}| Declared return type | Behavior |
|---|---|
PageResponse<T> |
Wrapped envelope with content + pagination metadata. Recommended for REST. |
Object |
Wrapped envelope — also routes through a custom factory if one is registered. Use this when you've replaced the default response shape. |
List<T> |
Plain list (sliced and sorted, but no envelope or totals). |
Page numbers are 0-based throughout — request, response, and your Pageable-bound code all use the Spring Data convention. PageHelper internally uses 1-based numbering, but the aspect translates between them transparently so your mapper SQL and the rest of your code only ever see 0-based values.
GET /reports?page=0&size=20 → first page
GET /reports?page=1&size=20 → second page
For APIs that want to expose 1-based numbering to clients (a common preference in some teams), set easy-paging.one-indexed-pages: true — ?page=1 then becomes the first page and the response's page field starts at 1. Keyset endpoints are unaffected.
Pageable picks up Spring Data's standard sort syntax. Multi-column sort works out of the box:
GET /reports?page=0&size=20&sort=createdAt,desc&sort=name,asc
The aspect translates this to ORDER BY created_at desc, name asc and hands it to PageHelper. Property names are validated against [A-Za-z_][A-Za-z0-9_.]* — semicolons, parentheses, and spaces are rejected with IllegalArgumentException, so a malicious ?sort=…;DROP TABLE… never reaches your database.
For null handling, configure it programmatically:
import org.springframework.data.domain.Sort;
Pageable pageable = PageRequest.of(0, 20, Sort.by(
Sort.Order.desc("createdAt").with(Sort.NullHandling.NULLS_LAST),
Sort.Order.asc("name")
));
// Translates to: ORDER BY created_at desc nulls last, name ascFor unbounded streams — logs, location tracks, audit events — where both COUNT(*) and large OFFSETs start to hurt.
// src/main/java/com/example/location/LocationController.java
package com.example.location;
import java.util.UUID;
import kr.devslab.easypaging.annotation.KeysetPaginate;
import kr.devslab.easypaging.core.KeysetPage;
import kr.devslab.easypaging.core.KeysetRequest;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/locations")
class LocationController {
private final LocationService locations;
LocationController(LocationService locations) {
this.locations = locations;
}
@GetMapping
@KeysetPaginate(
keys = {"time", "id"}, // composite key — timestamp + id tiebreaker
direction = "DESC", // newest first
defaultSize = 50,
maxSize = 200
)
public KeysetPage<Location> stream(KeysetRequest req, @RequestParam UUID workerId) {
// KeysetRequest is filled by the argument resolver from
// ?cursor=…&size=…&direction=… — see the @KeysetPaginate annotation above
// for defaults. The controller delegates straight to the service.
return locations.stream(workerId, req);
}
}// src/main/java/com/example/location/LocationService.java
package com.example.location;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import kr.devslab.easypaging.core.CursorCodec;
import kr.devslab.easypaging.core.KeysetPage;
import kr.devslab.easypaging.core.KeysetRequest;
import org.springframework.stereotype.Service;
@Service
class LocationService {
private final LocationMapper mapper;
private final CursorCodec codec;
LocationService(LocationMapper mapper, CursorCodec codec) {
this.mapper = mapper;
this.codec = codec;
}
public KeysetPage<Location> stream(UUID workerId, KeysetRequest req) {
// Fetch size + 1 rows so we can detect whether a next page exists.
List<Location> rows = mapper.findAfter(
workerId,
req.keyAsInstant("time"),
req.keyAsLong("id"),
req.size() + 1);
// The keyExtractor lambda tells the helper which fields of the last
// visible row to encode as the next cursor.
return KeysetPage.build(rows, req, r -> Map.of(
"time", r.getTime(),
"id", r.getId()
), codec);
}
}The corresponding MyBatis mapper writes the keyset WHERE clause explicitly — the cursor values flow in as parameters:
// src/main/java/com/example/location/LocationMapper.java
import java.time.Instant;
import java.util.List;
import java.util.UUID;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface LocationMapper {
List<Location> findAfter(
@Param("workerId") UUID workerId,
@Param("time") Instant time, // null for the first page
@Param("id") Long id, // null for the first page
@Param("limit") int limit);
}<!-- src/main/resources/mapper/LocationMapper.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.location.LocationMapper">
<select id="findAfter" resultType="com.example.location.Location">
SELECT id, time, lat, lng
FROM locations
WHERE worker_id = #{workerId}
AND (
#{time} IS NULL
OR time < #{time}
OR (time = #{time} AND id < #{id})
)
ORDER BY time DESC, id DESC
LIMIT #{limit}
</select>
</mapper>XML escaping reminder:
<must be written as<inside MyBatis XML — using a raw<makes the file fail to parse. (Some teams wrap the whole<select>body in<![CDATA[ ... ]]>to avoid this.)
A request to GET /locations?cursor=<token>&size=50 returns:
{
"content": [ /* up to 50 rows */ ],
"size": 50,
"nextCursor": "eyJrIjp7InRpbWUiOi...",
"prevCursor": null,
"hasNext": true,
"hasPrev": false
}The client passes nextCursor back as ?cursor=… for the next page. No OFFSET, no COUNT(*).
Set easy-paging.keyset.cursor-secret in production. Without a secret, cursors are Base64-encoded but not authenticated — a malicious client can forge a cursor that targets rows they shouldn't see (e.g. via tenant-key tampering). With a secret, every cursor is HMAC-SHA256 signed and forgeries are rejected.
For WebFlux / Reactor apps that need to call blocking MyBatis. The same layered structure applies — the only change from the offset section is the return type and that the service is what calls ReactivePagingSupport:
// src/main/java/com/example/report/ReportController.java
package com.example.report;
import kr.devslab.easypaging.core.PageResponse;
import org.springframework.data.domain.Pageable;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Mono;
@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);
}
}// src/main/java/com/example/report/ReportService.java
package com.example.report;
import kr.devslab.easypaging.core.PageResponse;
import kr.devslab.easypaging.reactive.ReactivePagingSupport;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono;
@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(), // blocking MyBatis call, wrapped onto a worker thread
/* maxSize */ 100,
/* count */ true);
}
}The mapper and XML are identical to the offset section — ReactivePagingSupport only changes how the call is dispatched, not what the query looks like.
The blocking work runs on Schedulers.boundedElastic() by default. If you already have a dedicated database scheduler, pass it in:
import reactor.core.scheduler.Scheduler;
return ReactivePagingSupport.paginate(
pageable,
() -> mapper.findAll(),
/* maxSize */ 100,
/* count */ true,
/* scheduler */ databaseScheduler);The recommended pattern is to define your own response type with a static factory method, mirroring how the built-in PageResponse.from() works. The aspect handles PageHelper setup; your type owns the shape:
// Your company's standard paginated response shape — type-safe, reused
// across every paginated endpoint.
public record CompanyPage<T>(
boolean ok,
List<T> data,
PageMeta meta) {
/** Build a CompanyPage from a mapper result + the request pageable. */
public static <T> CompanyPage<T> from(List<T> list, Pageable pageable) {
// Reuse the starter's metadata extraction; remap into your shape.
PageResponse<T> p = PageResponse.from(list, pageable);
return new CompanyPage<>(
true,
p.content(),
new PageMeta(p.page(), p.size(), p.totalElements(), p.totalPages())
);
}
}
public record PageMeta(int page, int size, long total, int pages) {}Use it directly from the controller — no Object, no special return type, no annotation:
@RestController
class ReportController {
private final ReportService reports;
ReportController(ReportService reports) {
this.reports = reports;
}
@GetMapping("/reports")
@AutoPaginate(maxSize = 50)
public CompanyPage<Report> list(Pageable pageable) {
return CompanyPage.from(reports.findAll(), pageable);
}
}You get full type safety, the JSON shape is whatever CompanyPage serializes to, and the aspect is still responsible for PageHelper lifecycle. The starter doesn't need to know about your type at all.
If you want the response shape applied automatically — without every controller calling CompanyPage.from(...) explicitly — register a PageResponseFactory bean and use Object as the controller's return type:
import java.util.List;
import kr.devslab.easypaging.spi.PageResponseFactory;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
class PagingConfig {
@Bean
PageResponseFactory companyEnvelope() {
return (content, pageable, totalElements, totalPages) ->
new CompanyPage<>(
true,
content,
new PageMeta(
pageable.getPageNumber(),
pageable.getPageSize(),
totalElements,
totalPages));
}
}@GetMapping("/reports")
@AutoPaginate(maxSize = 50)
public Object list(Pageable pageable) {
return reports.findAll(); // aspect routes the List through the factory
}Trade-offs:
Custom type + from() (recommended) |
Object + factory bean |
|
|---|---|---|
| Type safety | full — return type is CompanyPage<Report> |
none — return type is Object |
| DRY | each controller calls .from(...) |
factory is defined once |
| Mocking in tests | trivial — pure static method | requires factory bean in context |
| Best when | you only have one or two response shapes | every endpoint must use the same shape |
Both patterns coexist — pick per endpoint as needed. The factory only fires when the aspect itself is building the response (i.e. controller returned List or Object). Explicit PageResponse<T> or CompanyPage<T> values pass through untouched.
easy-paging:
enabled: true # master switch
default-page-size: 20 # used when caller omits ?size=
max-page-size: 500 # global cap (never exceeded, even if @AutoPaginate maxSize is higher)
auto-wrap-list: true # set false to disable PageResponse wrapping globally
keyset:
cursor-secret: ${EASY_PAGING_CURSOR_SECRET:} # HMAC secret; empty = unsigned (dev only)
max-cursor-bytes: 2048 # anti-DoS cap on decoded cursor payloadApache License 2.0. Bug reports and pull requests welcome — see CONTRIBUTING.md.