{"id":50100715,"url":"https://github.com/devslab-kr/easy-paging-spring-boot-starter","last_synced_at":"2026-05-23T07:15:01.573Z","repository":{"id":357177534,"uuid":"1235727469","full_name":"devslab-kr/easy-paging-spring-boot-starter","owner":"devslab-kr","description":"Annotation-driven pagination for Spring Boot + MyBatis (offset \u0026 keyset)","archived":false,"fork":false,"pushed_at":"2026-05-20T15:01:22.000Z","size":1135,"stargazers_count":0,"open_issues_count":4,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-20T19:09:52.524Z","etag":null,"topics":["aop","cursor-pagination","java","keyset-pagination","mybatis","pagehelper","pagination","spring-boot","spring-boot-starter"],"latest_commit_sha":null,"homepage":"https://easy-paging.devslab.kr","language":"Java","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"apache-2.0","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/devslab-kr.png","metadata":{"files":{"readme":"README.ko.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.ko.md","funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":"ROADMAP.md","authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":"NOTICE","maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-05-11T15:42:24.000Z","updated_at":"2026-05-20T15:09:06.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/devslab-kr/easy-paging-spring-boot-starter","commit_stats":null,"previous_names":["devslab-kr/easy-paging-spring-boot-starter"],"tags_count":6,"template":false,"template_full_name":null,"purl":"pkg:github/devslab-kr/easy-paging-spring-boot-starter","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/devslab-kr%2Feasy-paging-spring-boot-starter","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/devslab-kr%2Feasy-paging-spring-boot-starter/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/devslab-kr%2Feasy-paging-spring-boot-starter/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/devslab-kr%2Feasy-paging-spring-boot-starter/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/devslab-kr","download_url":"https://codeload.github.com/devslab-kr/easy-paging-spring-boot-starter/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/devslab-kr%2Feasy-paging-spring-boot-starter/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33386315,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-23T04:15:53.637Z","status":"ssl_error","status_checked_at":"2026-05-23T04:15:53.242Z","response_time":53,"last_error":"SSL_read: unexpected eof while reading","robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":false,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["aop","cursor-pagination","java","keyset-pagination","mybatis","pagehelper","pagination","spring-boot","spring-boot-starter"],"created_at":"2026-05-23T07:15:00.094Z","updated_at":"2026-05-23T07:15:01.566Z","avatar_url":"https://github.com/devslab-kr.png","language":"Java","funding_links":[],"categories":[],"sub_categories":[],"readme":"# easy-paging-spring-boot-starter\n\n[English](README.md) · **한국어**\n\n\u003e Spring Boot + MyBatis를 위한 어노테이션 기반 페이지네이션 스타터.\n\u003e Offset 방식과 Keyset/Cursor 방식을 하나로 제공합니다.\n\n[![Maven Central](https://img.shields.io/maven-central/v/kr.devslab/easy-paging-spring-boot-starter.svg?label=Maven%20Central)](https://central.sonatype.com/artifact/kr.devslab/easy-paging-spring-boot-starter)\n[![CI](https://github.com/devslab-kr/easy-paging-spring-boot-starter/actions/workflows/ci.yml/badge.svg)](https://github.com/devslab-kr/easy-paging-spring-boot-starter/actions/workflows/ci.yml)\n[![codecov](https://codecov.io/gh/devslab-kr/easy-paging-spring-boot-starter/branch/main/graph/badge.svg)](https://codecov.io/gh/devslab-kr/easy-paging-spring-boot-starter)\n[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](LICENSE)\n\n📖 **[문서 보기 → easy-paging.devslab.kr](https://easy-paging.devslab.kr/ko/)**\n\n\u003e 💬 질문, 아이디어, 사용 사례 공유는 [**devslab-examples Discussions**](https://github.com/devslab-kr/devslab-examples/discussions)에서 — 영/한 둘 다 OK, 라이브러리 만든 메인테이너가 직접 답변.\n\n## 한눈에 보기\n\n컨트롤러 메서드에 어노테이션 하나만 붙이면 JSON 응답이 바로 나옵니다. Spring MVC 구조에서 컨트롤러는 서비스에 위임만 하고, 실제 로직은 서비스가 담당합니다:\n\n```java\n// Controller\n@RestController\nclass ReportController {\n\n    private final ReportService reports;\n\n    ReportController(ReportService reports) {\n        this.reports = reports;\n    }\n\n    @GetMapping(\"/reports\")\n    @AutoPaginate(maxSize = 50)\n    public PageResponse\u003cReport\u003e list(Pageable pageable) {\n        return PageResponse.from(reports.findAll(), pageable);\n    }\n}\n\n// Service\n@Service\nclass ReportService {\n\n    private final ReportMapper mapper;\n\n    ReportService(ReportMapper mapper) {\n        this.mapper = mapper;\n    }\n\n    public List\u003cReport\u003e findAll() {\n        return mapper.findAll();   // 페이지네이션은 컨트롤러 레벨의 aspect가 주입함\n    }\n}\n```\n\n(임포트는 생략 — 다음 섹션에서 import 포함한 전체 파일과 MyBatis 매퍼까지 함께 보여줍니다.)\n\n`GET /reports?page=0\u0026size=20\u0026sort=createdAt,desc` 요청에 대한 응답:\n\n```json\n{\n  \"content\": [ /* 20개의 행 */ ],\n  \"page\": 0,\n  \"size\": 20,\n  \"totalElements\": 137,\n  \"totalPages\": 7,\n  \"first\": true,\n  \"last\": false,\n  \"empty\": false\n}\n```\n\n### 라이브러리 없이 직접 작성하면\n\n같은 엔드포인트를 PageHelper만으로 직접 구현하면 대략 이런 모양이 됩니다:\n\n```java\n@GetMapping(\"/reports\")\npublic Map\u003cString, Object\u003e list(\n    @RequestParam(defaultValue = \"0\") int page,\n    @RequestParam(defaultValue = \"20\") int size,\n    @RequestParam(required = false) String sort\n) {\n    // 1. 페이지 크기 검증 — 안 하면 ?size=999999로 DoS 공격당함\n    if (size \u003c= 0 || size \u003e 100) size = 20;\n\n    // 2. ?sort 파라미터 파싱 + SQL 인젝션 차단\n    //    (?sort=name;DROP TABLE users 같은 입력을 그냥 두면 DB까지 도달)\n    String orderBy = parseAndValidateSort(sort);   // 별도 어딘가 ~30줄짜리 메서드\n\n    // 3. PageHelper의 스레드별 스택에 페이지 정보 push\n    PageHelper.startPage(page + 1, size);          // 0-인덱스 아니라 1-인덱스\n    if (!orderBy.isEmpty()) {\n        PageHelper.orderBy(orderBy);\n    }\n\n    try {\n        // 4. 쿼리 실행 — PageHelper가 SQL을 가로채서 LIMIT/OFFSET 주입\n        PageInfo\u003cReport\u003e info = new PageInfo\u003c\u003e(reportMapper.findAll());\n\n        // 5. 응답 JSON을 직접 조립\n        return Map.of(\n            \"content\",       info.getList(),\n            \"page\",          page,\n            \"size\",          size,\n            \"totalElements\", info.getTotal(),\n            \"totalPages\",    info.getPages(),\n            \"first\",         info.isIsFirstPage(),\n            \"last\",          info.isIsLastPage()\n        );\n    } finally {\n        // 6. 매우 중요: 스레드별 상태를 정리. 빠뜨리면 같은 스레드(또는\n        //    Virtual Thread carrier)에서 처리되는 다음 요청이 이전의\n        //    페이지네이션 설정을 그대로 물려받아, 페이지네이션이 필요 없는\n        //    쿼리까지 잘못 페이지네이션됨.\n        PageHelper.clearPage();\n    }\n}\n```\n\n이 스타터는 위 6단계를 섹션 첫머리의 4줄짜리 컨트롤러로 압축합니다. 1·2·5·6번이 통째로 사라지고, 3번은 어노테이션 한 줄로 대체되며, 4번은 평범한 매퍼 호출 그대로 유지됩니다.\n\n## 무엇을 제공하는가\n\n- **Spring Data 호환 JSON** 응답을 기본 제공 (회사 표준 래퍼가 따로 있다면 [응답 형식 커스터마이징](#커스텀-응답-형식) 가능)\n- **0-based 페이지 번호** — Spring Data 컨벤션 (`?page=0`이 첫 페이지). PageHelper의 1-based 인덱싱은 내부에서 자동 변환됨\n- **안전한 `?sort=…`** — sort 파라미터는 DB에 도달하기 전 검증되어 인젝션 시도를 HTTP 400으로 거부\n- **페이지 크기 클램핑** — 엔드포인트별 + 전역 상한 이중 적용. 클라이언트가 `?size=999999`로 요청해도 막힘\n- **합리적인 기본값** — 기본 페이지 크기, 최대 크기, 범위 밖 페이지 처리 모두 설정 가능\n- **Keyset(커서) 페이지네이션** — 시계열이나 무한 스트림 테이블처럼 `OFFSET`과 `COUNT(*)`가 부담스러운 경우 ([자세히](#keyset--cursor-페이지네이션--keysetpaginate))\n- **WebFlux/Reactor 지원** — `Schedulers.boundedElastic()` 위에서 블로킹 MyBatis 호출 ([자세히](#reactive-webflux-지원))\n- **네이티브 R2DBC + WebFlux** — 옵션 `…-starter-reactive` 동반 아티팩트로 제공. `R2dbcEntityTemplate` → `Mono\u003cPageResponse\u003cT\u003e\u003e`, 사전순(lexicographic) keyset `WHERE` 빌더, 리액티브 `KeysetRequest` 인자 리졸버. MyBatis 쪽과 동일한 봉투 모양이라 클라이언트는 단일 계약을 봄.\n- **Virtual Threads 안전** — 매 요청 종료 시 내부 상태가 자동 정리됨\n\n## 설치\n\n```kotlin\n// build.gradle.kts\ndependencies {\n    implementation(\"kr.devslab:easy-paging-spring-boot-starter:0.4.0\")\n    // 옵션 — 네이티브 R2DBC + WebFlux 헬퍼가 필요한 경우만:\n    // implementation(\"kr.devslab:easy-paging-spring-boot-starter-reactive:0.4.0\")\n}\n```\n\n여러분이 추가:\n- Spring Boot 3.3+ / Java 21+ (빌드·테스트는 3.5 기준)\n- JDBC 드라이버 (reactive 스타터 사용 시에는 R2DBC 드라이버도)\n\ncore 스타터가 자동으로 가져옴: `spring-boot-starter-aop`, `spring-data-commons`, `pagehelper-spring-boot-starter`, `mybatis-spring-boot-starter` 3.x. **Spring Data JPA는 필요 없습니다** — 가벼운 `spring-data-commons` (`Pageable`, `Page`, `Sort` 제공)만 transitively 따라옵니다.\n\nreactive 스타터는 `spring-boot-starter-webflux`와 `spring-boot-starter-data-r2dbc`를 `compileOnly`로 선언하므로, 실제로 사용하는 것에 대해서만 비용을 지불.\n\n\u003e 다른 MyBatis 라인이 필요하다면 `exclude(group = \"org.mybatis.spring.boot\")` 후 원하는 버전 직접 선언 — [설치 가이드](https://easy-paging.devslab.kr/ko/getting-started/installation/) 참조.\n\n## 실행 가능한 예제\n\n이 README의 모든 기능을 그대로 돌려볼 수 있는 standalone Spring Boot 프로젝트 — clone → `./gradlew bootRun` → curl. README 코드를 자기 프로젝트에 복붙할 필요 없이 이미 end-to-end로 wired up (테스트 클래스 포함).\n\n| 데모 | 보여주는 것 |\n| --- | --- |\n| [`easy-paging-demo`](https://github.com/devslab-kr/devslab-examples/tree/main/easy-paging-demo) | `@AutoPaginate` (H2) — [커스텀 응답 형식](#커스텀-응답-형식) 고급 섹션 (`/reports/company`, `/reports/auto-envelope`)도 포함 |\n| [`easy-paging-keyset-demo`](https://github.com/devslab-kr/devslab-examples/tree/main/easy-paging-keyset-demo) | `@KeysetPaginate` 커서 페이지네이션 (300건 time-series, H2). 테스트가 커서 walk이 모든 row를 정확히 한 번씩 cover하는지 검증 |\n| [`easy-paging-postgres-demo`](https://github.com/devslab-kr/devslab-examples/tree/main/easy-paging-postgres-demo) | 실제 PostgreSQL — `bootRun`은 Docker Compose, 테스트는 Testcontainers + `@ServiceConnection`. 로컬 Postgres 설치 불필요 |\n| [`easy-paging-reactive-demo`](https://github.com/devslab-kr/devslab-examples/tree/main/easy-paging-reactive-demo) | reactive 컴패니언 아티팩트를 `R2dbcOffsetPagingSupport`로 사용 (WebFlux + R2DBC + Docker PostgreSQL) |\n\n전체 인덱스: [github.com/devslab-kr/devslab-examples](https://github.com/devslab-kr/devslab-examples).\n\n## Offset 페이지네이션 — `@AutoPaginate`\n\n가장 일반적인 페이지네이션. 총 개수가 필요하고 데이터가 `LIMIT/OFFSET`으로 무난히 처리되는 리스트 화면에 적합합니다.\n\n전체 레이어드 구현:\n\n```java\n// src/main/java/com/example/report/ReportController.java\npackage com.example.report;\n\nimport kr.devslab.easypaging.annotation.AutoPaginate;\nimport kr.devslab.easypaging.core.PageResponse;\nimport org.springframework.data.domain.Pageable;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RestController;\n\n@RestController\n@RequestMapping(\"/reports\")\nclass ReportController {\n\n    private final ReportService reports;\n\n    ReportController(ReportService reports) {\n        this.reports = reports;\n    }\n\n    @GetMapping\n    @AutoPaginate(maxSize = 50)\n    public PageResponse\u003cReport\u003e list(Pageable pageable) {\n        // 메서드 본문이 실행되기 전에 aspect가 PageHelper.startPage(...)를 호출하므로,\n        // reports.findAll() 안에서 일어나는 매퍼 호출이 자동으로 페이지네이션됩니다.\n        return PageResponse.from(reports.findAll(), pageable);\n    }\n}\n```\n\n```java\n// src/main/java/com/example/report/ReportService.java\npackage com.example.report;\n\nimport java.util.List;\nimport org.springframework.stereotype.Service;\n\n@Service\nclass ReportService {\n\n    private final ReportMapper mapper;\n\n    ReportService(ReportMapper mapper) {\n        this.mapper = mapper;\n    }\n\n    public List\u003cReport\u003e findAll() {\n        // 실제 서비스에서는 권한 검증, 테넌트 필터링, 도메인 규칙 등을 처리한 다음\n        // 마지막에 매퍼를 호출합니다.\n        return mapper.findAll();\n    }\n}\n```\n\nMyBatis 매퍼는 페이지네이션 로직 없는 평범한 `List` 쿼리로 둡니다 — `LIMIT/OFFSET` 직접 쓸 필요 없습니다. 국내 엔터프라이즈에서 표준인 XML 매핑 방식 기준으로, 인터페이스는 메서드 시그니처만:\n\n```java\n// src/main/java/com/example/report/ReportMapper.java\npackage com.example.report;\n\nimport java.util.List;\nimport org.apache.ibatis.annotations.Mapper;\n\n@Mapper\npublic interface ReportMapper {\n    List\u003cReport\u003e findAll();   // 런타임에 aspect가 페이지네이션을 주입\n}\n```\n\n```xml\n\u003c!-- src/main/resources/mapper/ReportMapper.xml --\u003e\n\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\n\u003c!DOCTYPE mapper PUBLIC \"-//mybatis.org//DTD Mapper 3.0//EN\"\n    \"https://mybatis.org/dtd/mybatis-3-mapper.dtd\"\u003e\n\u003cmapper namespace=\"com.example.report.ReportMapper\"\u003e\n\n    \u003cselect id=\"findAll\" resultType=\"com.example.report.Report\"\u003e\n        SELECT id, title, created_at AS createdAt\n        FROM reports\n    \u003c/select\u003e\n\n\u003c/mapper\u003e\n```\n\n`application.yml`에서 XML 위치 지정:\n\n```yaml\nmybatis:\n  mapper-locations: classpath:mapper/**/*.xml\n  configuration:\n    map-underscore-to-camel-case: true\n```\n\n`@AutoPaginate` aspect가 컨트롤러 호출을 가로채서 PageHelper의 스레드별 상태를 설정하면, 직후의 MyBatis 쿼리가 자동으로 `LIMIT/OFFSET`과 `ORDER BY`를 받아 실행됩니다. XML은 깔끔한 상태로 유지됩니다.\n\n### 어노테이션 옵션\n\n| 속성          | 기본값  | 의미                                                                          |\n|---------------|---------|-------------------------------------------------------------------------------|\n| `count`       | `true`  | `totalElements`/`totalPages`를 위한 `COUNT(*)` 쿼리 실행 여부. 시계열·로그 테이블에서는 비활성화 권장. |\n| `maxSize`     | `100`   | 호출자가 요청 가능한 페이지 크기의 절대 상한.                                  |\n| `reasonable`  | `true`  | `true`일 때, 범위 밖 페이지 번호를 자동으로 보정 (빈 결과 대신).               |\n\n### 모든 옵션을 함께 사용\n\n```java\n// Controller\n@GetMapping(\"/audit-events\")\n@AutoPaginate(\n    count       = false,    // 감사 로그는 1억 행 이상 — COUNT(*) 부담이 너무 큼\n    maxSize     = 200,      // 데이터 내보내기용 사용자에게는 큰 페이지 허용\n    reasonable  = false     // 엄격 모드: page \u003e totalPages이면 빈 결과 반환\n)\npublic PageResponse\u003cAuditEvent\u003e events(Pageable pageable) {\n    return PageResponse.from(auditEvents.findAll(), pageable);\n}\n\n// Service\n@Service\nclass AuditEventService {\n    private final AuditEventMapper mapper;\n    // ... 생성자 생략\n\n    public List\u003cAuditEvent\u003e findAll() {\n        return mapper.findAll();\n    }\n}\n```\n\n### 반환 타입 선택\n\n| 선언된 반환 타입          | 동작                                                                              |\n|---------------------------|-----------------------------------------------------------------------------------|\n| `PageResponse\u003cT\u003e`         | 페이지네이션 메타데이터를 포함한 응답 래퍼. **REST 엔드포인트에 권장.**            |\n| `Object`                  | 응답 래퍼 — [커스텀 응답 형식](#커스텀-응답-형식)을 등록했다면 그쪽으로 라우팅됨. 기본 응답 형식을 바꿔 쓰는 경우 사용. |\n| `List\u003cT\u003e`                 | 평범한 리스트 (슬라이스·정렬은 적용되지만 메타데이터·총개수 없음).                  |\n\n### 페이지 번호\n\n페이지 번호는 **요청·응답·`Pageable` 코드 전반에서 0-based** — Spring Data 컨벤션을 그대로 따릅니다. PageHelper는 내부적으로 1-based지만, aspect가 자동 변환해주므로 매퍼 SQL이나 나머지 코드는 항상 0-based만 마주합니다.\n\n```\nGET /reports?page=0\u0026size=20  →  첫 페이지\nGET /reports?page=1\u0026size=20  →  두 번째 페이지\n```\n\n클라이언트에 1-based 페이지 번호를 노출하고 싶다면 (일부 팀에서 선호) `easy-paging.one-indexed-pages: true` 설정 — `?page=1`이 첫 페이지가 되고 응답의 `page` 필드도 `1`부터 시작합니다. Keyset 엔드포인트는 영향 없음.\n\n### 정렬\n\nPageable이 Spring Data의 표준 정렬 문법을 자동으로 인식합니다. 다중 컬럼 정렬도 그대로 지원됩니다:\n\n```\nGET /reports?page=0\u0026size=20\u0026sort=createdAt,desc\u0026sort=name,asc\n```\n\nAspect는 이를 `ORDER BY created_at desc, name asc`로 변환해서 PageHelper에 전달합니다. 컬럼명은 `[A-Za-z_][A-Za-z0-9_.]*` 패턴으로 검증되어, 세미콜론·괄호·공백이 포함된 입력은 `IllegalArgumentException`으로 거부됩니다. 즉 `?sort=…;DROP TABLE…` 같은 공격은 DB에 도달조차 못 합니다.\n\nNULL 값 처리 순서를 정하려면 프로그래밍 방식으로 설정하세요:\n\n```java\nimport org.springframework.data.domain.PageRequest;\nimport org.springframework.data.domain.Sort;\n\nPageable pageable = PageRequest.of(0, 20, Sort.by(\n    Sort.Order.desc(\"createdAt\").with(Sort.NullHandling.NULLS_LAST),\n    Sort.Order.asc(\"name\")\n));\n// 변환 결과: ORDER BY created_at desc nulls last, name asc\n```\n\n## Keyset / Cursor 페이지네이션 — `@KeysetPaginate`\n\n무한 스트림 데이터 — 로그, 위치 추적, 감사 이력 등 — 에서 `COUNT(*)`와 큰 `OFFSET`이 모두 부담스러운 경우에 사용합니다.\n\n```java\n// src/main/java/com/example/location/LocationController.java\npackage com.example.location;\n\nimport java.util.UUID;\nimport kr.devslab.easypaging.annotation.KeysetPaginate;\nimport kr.devslab.easypaging.core.KeysetPage;\nimport kr.devslab.easypaging.core.KeysetRequest;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.RequestMapping;\nimport org.springframework.web.bind.annotation.RequestParam;\nimport org.springframework.web.bind.annotation.RestController;\n\n@RestController\n@RequestMapping(\"/locations\")\nclass LocationController {\n\n    private final LocationService locations;\n\n    LocationController(LocationService locations) {\n        this.locations = locations;\n    }\n\n    @GetMapping\n    @KeysetPaginate(\n        keys        = {\"time\", \"id\"},   // 복합 키 — 시간 + id 동률 처리용\n        direction   = \"DESC\",            // 최신순\n        defaultSize = 50,\n        maxSize     = 200\n    )\n    public KeysetPage\u003cLocation\u003e stream(KeysetRequest req, @RequestParam UUID workerId) {\n        // KeysetRequest는 argument resolver가 ?cursor=…\u0026size=…\u0026direction=… 에서 채워줍니다\n        // (기본값은 위 @KeysetPaginate 어노테이션 참조). 컨트롤러는 서비스에 위임만 합니다.\n        return locations.stream(workerId, req);\n    }\n}\n```\n\n```java\n// src/main/java/com/example/location/LocationService.java\npackage com.example.location;\n\nimport java.util.List;\nimport java.util.Map;\nimport java.util.UUID;\nimport kr.devslab.easypaging.core.CursorCodec;\nimport kr.devslab.easypaging.core.KeysetPage;\nimport kr.devslab.easypaging.core.KeysetRequest;\nimport org.springframework.stereotype.Service;\n\n@Service\nclass LocationService {\n\n    private final LocationMapper mapper;\n    private final CursorCodec codec;\n\n    LocationService(LocationMapper mapper, CursorCodec codec) {\n        this.mapper = mapper;\n        this.codec = codec;\n    }\n\n    public KeysetPage\u003cLocation\u003e stream(UUID workerId, KeysetRequest req) {\n        // size + 1행을 조회해서 다음 페이지 존재 여부 판단\n        List\u003cLocation\u003e rows = mapper.findAfter(\n            workerId,\n            req.keyAsInstant(\"time\"),\n            req.keyAsLong(\"id\"),\n            req.size() + 1);\n\n        // keyExtractor 람다는 마지막 행의 어느 필드를 다음 커서로 인코딩할지 헬퍼에게 알려줍니다.\n        return KeysetPage.build(rows, req, r -\u003e Map.of(\n            \"time\", r.getTime(),\n            \"id\",   r.getId()\n        ), codec);\n    }\n}\n```\n\n대응되는 MyBatis 매퍼는 keyset `WHERE` 절을 명시적으로 작성합니다 — 커서 값이 파라미터로 들어옵니다:\n\n```java\n// src/main/java/com/example/location/LocationMapper.java\nimport java.time.Instant;\nimport java.util.List;\nimport java.util.UUID;\nimport org.apache.ibatis.annotations.Mapper;\nimport org.apache.ibatis.annotations.Param;\n\n@Mapper\npublic interface LocationMapper {\n    List\u003cLocation\u003e findAfter(\n        @Param(\"workerId\") UUID workerId,\n        @Param(\"time\")     Instant time,    // 첫 페이지에서는 null\n        @Param(\"id\")       Long id,         // 첫 페이지에서는 null\n        @Param(\"limit\")    int limit);\n}\n```\n\n```xml\n\u003c!-- src/main/resources/mapper/LocationMapper.xml --\u003e\n\u003c?xml version=\"1.0\" encoding=\"UTF-8\"?\u003e\n\u003c!DOCTYPE mapper PUBLIC \"-//mybatis.org//DTD Mapper 3.0//EN\"\n    \"https://mybatis.org/dtd/mybatis-3-mapper.dtd\"\u003e\n\u003cmapper namespace=\"com.example.location.LocationMapper\"\u003e\n\n    \u003cselect id=\"findAfter\" resultType=\"com.example.location.Location\"\u003e\n        SELECT id, time, lat, lng\n        FROM locations\n        WHERE worker_id = #{workerId}\n          AND (\n              #{time} IS NULL\n              OR time \u0026lt; #{time}\n              OR (time = #{time} AND id \u0026lt; #{id})\n          )\n        ORDER BY time DESC, id DESC\n        LIMIT #{limit}\n    \u003c/select\u003e\n\n\u003c/mapper\u003e\n```\n\n\u003e **XML 이스케이프 주의**: MyBatis XML 안에서 `\u003c`는 반드시 `\u0026lt;`로 작성해야 합니다 — raw `\u003c`를 쓰면 XML 파싱에 실패합니다. (`\u003cselect\u003e` 본문 전체를 `\u003c![CDATA[ ... ]]\u003e`로 감싸는 팀도 많습니다.)\n\n`GET /locations?cursor=\u003c토큰\u003e\u0026size=50` 요청에 대한 응답:\n\n```json\n{\n  \"content\": [ /* 최대 50개의 행 */ ],\n  \"size\": 50,\n  \"nextCursor\": \"eyJrIjp7InRpbWUiOi...\",\n  \"prevCursor\": null,\n  \"hasNext\": true,\n  \"hasPrev\": false\n}\n```\n\n클라이언트는 다음 페이지를 요청할 때 `nextCursor`를 `?cursor=…`로 다시 보내면 됩니다. `OFFSET`도 `COUNT(*)`도 없습니다.\n\n### 커서 서명 (운영 환경 필수)\n\n운영 환경에서는 반드시 `easy-paging.keyset.cursor-secret`을 설정하세요. 시크릿이 없으면 커서는 Base64로 인코딩만 될 뿐 **인증되지 않습니다** — 악의적인 클라이언트가 위조한 커서로 봐서는 안 되는 행을 노출시킬 수 있습니다 (예: 멀티테넌트에서 다른 테넌트의 키 위조). 시크릿이 설정되면 모든 커서는 HMAC-SHA256으로 서명되고 위조된 커서는 거부됩니다.\n\n## Reactive (WebFlux) 지원\n\nWebFlux/Reactor 앱에서 블로킹 MyBatis를 호출해야 하는 경우. 레이어 구조는 Offset 섹션과 동일하고, 반환 타입이 `Mono\u003c...\u003e`로 바뀌고 서비스가 `ReactivePagingSupport`를 호출한다는 점만 다릅니다:\n\n```java\n// src/main/java/com/example/report/ReportController.java\npackage com.example.report;\n\nimport kr.devslab.easypaging.core.PageResponse;\nimport org.springframework.data.domain.Pageable;\nimport org.springframework.web.bind.annotation.GetMapping;\nimport org.springframework.web.bind.annotation.RestController;\nimport reactor.core.publisher.Mono;\n\n@RestController\nclass ReportController {\n\n    private final ReportService reports;\n\n    ReportController(ReportService reports) {\n        this.reports = reports;\n    }\n\n    @GetMapping(\"/reports\")\n    public Mono\u003cPageResponse\u003cReport\u003e\u003e list(Pageable pageable) {\n        return reports.list(pageable);\n    }\n}\n```\n\n```java\n// src/main/java/com/example/report/ReportService.java\npackage com.example.report;\n\nimport kr.devslab.easypaging.core.PageResponse;\nimport kr.devslab.easypaging.reactive.ReactivePagingSupport;\nimport org.springframework.data.domain.Pageable;\nimport org.springframework.stereotype.Service;\nimport reactor.core.publisher.Mono;\n\n@Service\nclass ReportService {\n\n    private final ReportMapper mapper;\n\n    ReportService(ReportMapper mapper) {\n        this.mapper = mapper;\n    }\n\n    public Mono\u003cPageResponse\u003cReport\u003e\u003e list(Pageable pageable) {\n        return ReactivePagingSupport.paginate(\n            pageable,\n            () -\u003e mapper.findAll(),     // 블로킹 MyBatis 호출, 워커 스레드 위로 옮겨짐\n            /* maxSize */ 100,\n            /* count   */ true);\n    }\n}\n```\n\n매퍼와 XML은 Offset 섹션과 동일합니다 — `ReactivePagingSupport`는 호출이 디스패치되는 방식만 바꿀 뿐, 쿼리 자체는 그대로입니다.\n\n블로킹 작업은 기본적으로 `Schedulers.boundedElastic()` 위에서 실행됩니다. 별도의 DB 전용 스케줄러가 있다면 직접 전달할 수 있습니다:\n\n```java\nimport reactor.core.scheduler.Scheduler;\n\nreturn ReactivePagingSupport.paginate(\n    pageable,\n    () -\u003e mapper.findAll(),\n    /* maxSize    */ 100,\n    /* count      */ true,\n    /* scheduler  */ databaseScheduler);\n```\n\n## 커스텀 응답 형식\n\n권장하는 패턴은 **본인의 응답 타입을 정의하고 정적 팩토리 메서드를 두는 것**입니다 — 기본 제공되는 `PageResponse.from()`과 똑같은 패턴이에요. Aspect는 PageHelper 처리만 책임지고, 응답 형태는 본인의 타입이 직접 소유합니다:\n\n```java\n// 회사 표준 페이지네이션 응답 — 타입 안전하고, 모든 페이지네이션 엔드포인트에서 재사용\npublic record CompanyPage\u003cT\u003e(\n        boolean ok,\n        List\u003cT\u003e data,\n        PageMeta meta) {\n\n    /** 매퍼 결과 + Pageable로부터 CompanyPage 생성. */\n    public static \u003cT\u003e CompanyPage\u003cT\u003e from(List\u003cT\u003e list, Pageable pageable) {\n        // 스타터의 메타데이터 추출 로직을 재사용한 다음, 본인 타입에 맞게 매핑.\n        PageResponse\u003cT\u003e p = PageResponse.from(list, pageable);\n        return new CompanyPage\u003c\u003e(\n            true,\n            p.content(),\n            new PageMeta(p.page(), p.size(), p.totalElements(), p.totalPages())\n        );\n    }\n}\n\npublic record PageMeta(int page, int size, long total, int pages) {}\n```\n\n컨트롤러에서 그대로 사용 — `Object` 반환 불필요, 특별한 어노테이션 불필요:\n\n```java\n@RestController\nclass ReportController {\n\n    private final ReportService reports;\n\n    ReportController(ReportService reports) {\n        this.reports = reports;\n    }\n\n    @GetMapping(\"/reports\")\n    @AutoPaginate(maxSize = 50)\n    public CompanyPage\u003cReport\u003e list(Pageable pageable) {\n        return CompanyPage.from(reports.findAll(), pageable);\n    }\n}\n```\n\n완전한 타입 안전성을 얻고, JSON 형태는 `CompanyPage`가 직렬화되는 그대로이며, aspect는 여전히 PageHelper 생명주기만 책임집니다. 스타터는 본인의 타입에 대해 아무것도 알 필요가 없습니다.\n\n### 대안: 중앙화된 팩토리 빈\n\n모든 컨트롤러가 `CompanyPage.from(...)`을 명시적으로 호출하지 않고 **응답 형식이 자동 적용**되기를 원한다면, `PageResponseFactory` 빈을 등록하고 컨트롤러 반환 타입을 `Object`로 선언하면 됩니다:\n\n```java\nimport java.util.List;\nimport kr.devslab.easypaging.spi.PageResponseFactory;\nimport org.springframework.context.annotation.Bean;\nimport org.springframework.context.annotation.Configuration;\n\n@Configuration\nclass PagingConfig {\n\n    @Bean\n    PageResponseFactory companyEnvelope() {\n        return (content, pageable, totalElements, totalPages) -\u003e\n            new CompanyPage\u003c\u003e(\n                true,\n                content,\n                new PageMeta(\n                    pageable.getPageNumber(),\n                    pageable.getPageSize(),\n                    totalElements,\n                    totalPages));\n    }\n}\n```\n\n```java\n@GetMapping(\"/reports\")\n@AutoPaginate(maxSize = 50)\npublic Object list(Pageable pageable) {\n    return reports.findAll();   // aspect가 List를 팩토리에 라우팅\n}\n```\n\n선택 가이드:\n\n| | 커스텀 타입 + `from()` (권장) | `Object` + 팩토리 빈 |\n|---|---|---|\n| 타입 안전성 | 완전 — 반환 타입이 `CompanyPage\u003cReport\u003e` | 없음 — 반환 타입이 `Object` |\n| DRY | 컨트롤러마다 `.from(...)` 호출 | 팩토리 한 번만 정의 |\n| 테스트 모킹 | 단순 — 순수 정적 메서드 | 팩토리 빈을 컨텍스트에 띄워야 함 |\n| 적합한 경우 | 응답 형태가 1~2개일 때 | 모든 엔드포인트가 동일한 형태를 강제할 때 |\n\n두 패턴은 공존 가능 — 엔드포인트별로 선택하세요. 팩토리는 **aspect가 직접 응답을 만들 때만** 동작합니다 (컨트롤러가 `List` 또는 `Object`를 반환한 경우). `PageResponse\u003cT\u003e`나 `CompanyPage\u003cT\u003e` 같은 명시적 값을 반환했다면 가공 없이 그대로 응답됩니다.\n\n## 설정\n\n```yaml\neasy-paging:\n  enabled: true                # 마스터 스위치\n  default-page-size: 20        # 호출자가 ?size를 생략했을 때 사용\n  max-page-size: 500           # 전역 절대 상한 (@AutoPaginate maxSize가 더 크더라도 절대 초과 못 함)\n  auto-wrap-list: true         # false로 두면 PageResponse 자동 래핑을 전역 비활성화\n  keyset:\n    cursor-secret: ${EASY_PAGING_CURSOR_SECRET:}   # HMAC 시크릿; 비어 있으면 서명 없음 (개발용만)\n    max-cursor-bytes: 2048                          # 디코딩 후 커서 페이로드 크기 상한 (DoS 방지)\n```\n\n## 라이선스\n\n[Apache License 2.0](LICENSE). 버그 리포트와 PR 환영합니다 — [CONTRIBUTING.md](CONTRIBUTING.md)를 참조하세요.\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdevslab-kr%2Feasy-paging-spring-boot-starter","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fdevslab-kr%2Feasy-paging-spring-boot-starter","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fdevslab-kr%2Feasy-paging-spring-boot-starter/lists"}