From 64bc48552f51bbf70c54d40e60a9f7a2172cbee5 Mon Sep 17 00:00:00 2001 From: Sin-Kang Date: Sat, 23 May 2026 15:42:07 +0900 Subject: [PATCH] docs(easy-paging demos): add Korean READMEs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bilingual coverage for the 4 easy-paging demos — same convention as the ssrf-guard demos (PR #11) and the libraries themselves: README.md + README.ko.md with a language-picker line at the top. Affected demos: - easy-paging-demo (offset pagination + envelope replacement) - easy-paging-keyset-demo (cursor pagination, size+1 trick, signed cursors) - easy-paging-postgres-demo (real PG via Docker Compose + Testcontainers) - easy-paging-reactive-demo (WebFlux + R2DBC, identical wire contract) All 4 demos now have: - README.md primary English content + `**English** · [한국어](README.ko.md)` - README.ko.md full Korean translation, kept in lockstep with English Closes the gap noted in PR #11 — all 9 demos in this repo are now bilingual. Top-level README + maintenance posts in Discussions already were. --- easy-paging-demo/README.ko.md | 135 +++++++++++++++++++++++++ easy-paging-demo/README.md | 2 + easy-paging-keyset-demo/README.ko.md | 113 +++++++++++++++++++++ easy-paging-keyset-demo/README.md | 2 + easy-paging-postgres-demo/README.ko.md | 104 +++++++++++++++++++ easy-paging-postgres-demo/README.md | 2 + easy-paging-reactive-demo/README.ko.md | 86 ++++++++++++++++ easy-paging-reactive-demo/README.md | 2 + 8 files changed, 446 insertions(+) create mode 100644 easy-paging-demo/README.ko.md create mode 100644 easy-paging-keyset-demo/README.ko.md create mode 100644 easy-paging-postgres-demo/README.ko.md create mode 100644 easy-paging-reactive-demo/README.ko.md diff --git a/easy-paging-demo/README.ko.md b/easy-paging-demo/README.ko.md new file mode 100644 index 0000000..6a4fba6 --- /dev/null +++ b/easy-paging-demo/README.ko.md @@ -0,0 +1,135 @@ +# easy-paging-demo + +[English](README.md) · **한국어** + +[`easy-paging-spring-boot-starter`](https://github.com/devslab-kr/easy-paging-spring-boot-starter) — Spring Boot + MyBatis용 어노테이션 기반 페이지네이션의 실행 가능한 예제. + +이 데모는 스타터를 최소 Spring Boot 앱에 연결한 형태로, 인메모리 H2 데이터베이스, 137개의 시드 report 행, 그리고 단 하나의 `@AutoPaginate` 어노테이션이 붙은 컨트롤러로 구성. 외부 서비스도, DB 설정도 없음 — clone, run, curl 끝. + +## 전제조건 + +- JDK 21+ +- 그 외 없음. H2는 인메모리, 의존성은 최초 빌드 시 다운로드됨. + +## 실행 + +```bash +cd easy-paging-demo +./gradlew bootRun +``` + +앱은 `http://localhost:8080`에 뜸. 시작할 때마다 H2 DB가 인메모리로 생성되고 `reports` 테이블에 137행이 시드됨. + +## 시험해보기 + +### 첫 페이지 (Spring Data 기본 0-based) +```bash +curl 'http://localhost:8080/reports?page=0&size=5' +``` +```json +{ + "content": [ { "id": 1, "title": "Report #1", "createdAt": "..." }, ... ], + "page": 0, + "size": 5, + "totalElements": 137, + "totalPages": 28, + "first": true, + "last": false, + "empty": false +} +``` + +### 정렬 (SQL 인젝션 방어 검증됨) +```bash +# 다중 컬럼 정렬, createdAt 내림차순 후 id 오름차순 +curl 'http://localhost:8080/reports?page=0&size=5&sort=createdAt,desc&sort=id,asc' + +# 인젝션 시도는 HTTP 400으로 거절됨 +curl -i 'http://localhost:8080/reports?sort=id;DROP%20TABLE%20reports' +``` + +### 페이지 크기 clamping +```bash +# 컨트롤러가 @AutoPaginate(maxSize = 50)을 선언 +# 9999 요청 → 조용히 50으로 clamping +curl 'http://localhost:8080/reports?page=0&size=9999' | jq '.size, .content | length' +# → 50 +# → 50 +``` + +### 범위 초과 페이지 +```bash +# Page 999는 존재 안 함. reasonable=true (기본값)에서 스타터가 마지막 페이지로 clamping +curl 'http://localhost:8080/reports?page=999&size=20' | jq '.page, .empty' +# → 6 (137/20의 마지막 페이지 인덱스) +# → false +``` + +## 읽을 만한 파일 + +흥미로운 부분, 순서대로: + +| 파일 | 왜 | +| --- | --- | +| `build.gradle.kts` | `spring-boot-starter-web` 외 추가하는 의존성은 `kr.devslab:easy-paging-spring-boot-starter:0.4.0` 하나뿐 | +| `report/ReportController.java` | 전체 페이지네이션 계약은 `@AutoPaginate(maxSize = 50)` 어노테이션 + `PageResponse` 반환 타입 | +| `report/ReportMapper.java` + `resources/mapper/ReportMapper.xml` | 평범한 `SELECT` — `LIMIT`, `OFFSET`, `COUNT` 모두 없음. 런타임에 aspect가 주입 | +| `resources/application.yml` | `easy-paging` 전역 cap + 기본값 | + +## 고급: 응답 봉투 교체 + +많은 팀이 전사 표준 봉투 형식 — `{ ok, data, meta: { page, size, total, pages } }` 같은 — 을 가지고 모든 paginated 엔드포인트가 그 형식을 반환하도록 합니다. 스타터는 기본 `PageResponse`를 자기 형식으로 교체할 2가지 방법을 지원. 이 데모는 둘 다 나란히 포함하므로 wire JSON을 비교해서 자기 코드베이스에 맞는 패턴을 고를 수 있어요. + +여기서 사용하는 커스텀 봉투는 [`CompanyPage`](src/main/java/kr/devslab/examples/easypaging/envelope/CompanyPage.java) record: + +```json +{ + "ok": true, + "data": [ { "id": 1, "title": "Report #1", "createdAt": "..." }, ... ], + "meta": { "page": 0, "size": 5, "total": 137, "pages": 28 } +} +``` + +### 패턴 1 — 커스텀 반환 타입 + 정적 팩토리 (권장) + +컨트롤러가 반환 타입을 `CompanyPage`로 선언하고 `CompanyPage.from(...)`을 명시적으로 호출: + +```bash +curl 'http://localhost:8080/reports/company?page=0&size=5' +``` + +[`CompanyPageReportController`](src/main/java/kr/devslab/examples/easypaging/report/CompanyPageReportController.java) 참고 — 메서드 본문은 `CompanyPage.from(reports.findAll(), pageable)` 한 줄. `@AutoPaginate` aspect는 여전히 PageHelper 셋업, 정렬 검증, 크기 clamping 담당; 봉투 생성만 안 함. + +### 패턴 2 — `Object` 반환 + `PageResponseFactory` 빈 + +컨트롤러가 반환 타입을 `Object`로 선언하고 raw `List`를 돌려줌. [`PageResponseFactory`](src/main/java/kr/devslab/examples/easypaging/envelope/CompanyEnvelopeConfig.java) 빈이 aspect에게 그 리스트를 회사 봉투로 wrap하는 방법을 알려줌: + +```bash +curl 'http://localhost:8080/reports/auto-envelope?page=0&size=5' +``` + +`/reports/company`와 동일한 JSON 출력 — wire 형식은 같고 코드 경로만 다름. [`AutoEnvelopeReportController`](src/main/java/kr/devslab/examples/easypaging/report/AutoEnvelopeReportController.java) 참고. + +### 하나 고르기 — 트레이드오프 + +| | 패턴 1 (커스텀 타입 + `.from()`) | 패턴 2 (`Object` + 팩토리 빈) | +| --- | --- | --- | +| 타입 안전성 | 완전함 — 반환 타입이 `CompanyPage` | 없음 — 반환 타입이 `Object` | +| 엔드포인트별 wiring | 컨트롤러마다 `CompanyPage.from(...)` 호출 | 없음 — 팩토리는 한 번만 정의 | +| 같은 앱에서 봉투 섞기 | 쉬움 — 엔드포인트별 opt-in | 어려움 — 팩토리가 모든 `Object`/`List` 반환에 영향 | +| 테스트 | 정적 메서드, Spring 불필요 | `@SpringBootTest` 필요 (빈이 컨텍스트에 있어야) | +| 적합한 경우 | 한두 개 엔드포인트만 커스텀 형식 | 모든 paginated 엔드포인트가 같은 형식 사용 | + +기본 `/reports` 엔드포인트는 여전히 스타터의 `PageResponse`를 반환 — 명시적 `PageResponse` (그리고 `CompanyPage`) 반환 타입은 aspect를 통과하지 않으므로, 팩토리 빈 등록이 영향 미치지 않음. [`CustomEnvelopeTest`](src/test/java/kr/devslab/examples/easypaging/CustomEnvelopeTest.java)가 이 속성을 end-to-end로 검증. + +스타터의 전체 기능 (keyset 페이지네이션, WebFlux/R2DBC 지원)은 이 repo의 자매 데모들이 다룸 — 인덱스는 [최상위 README](../README.md) 참고. + +## 빌드 검증 + +```bash +./gradlew build +``` + +두 테스트 클래스 실행: +- `ReportControllerTest` — 앱 부팅 후 `/reports` 호출, 기본 페이지네이션 봉투 형식 + `maxSize` clamping 검증 +- `CustomEnvelopeTest` — `/reports/company`와 `/reports/auto-envelope` 호출해서 둘 다 같은 `CompanyPage` JSON 형식을 생성하는지, `PageResponseFactory` 빈이 `/reports` (여전히 기본 `PageResponse` 반환)에 새지 않는지 검증 diff --git a/easy-paging-demo/README.md b/easy-paging-demo/README.md index 3b0c386..51fcf3d 100644 --- a/easy-paging-demo/README.md +++ b/easy-paging-demo/README.md @@ -1,5 +1,7 @@ # easy-paging-demo +**English** · [한국어](README.ko.md) + Runnable example for [`easy-paging-spring-boot-starter`](https://github.com/devslab-kr/easy-paging-spring-boot-starter) — annotation-driven pagination for Spring Boot + MyBatis. This demo wires the starter into a minimal Spring Boot app with an in-memory H2 database, 137 seeded report rows, and a single `@AutoPaginate`-annotated controller. No external services, no database setup — clone, run, curl. diff --git a/easy-paging-keyset-demo/README.ko.md b/easy-paging-keyset-demo/README.ko.md new file mode 100644 index 0000000..5285146 --- /dev/null +++ b/easy-paging-keyset-demo/README.ko.md @@ -0,0 +1,113 @@ +# easy-paging-keyset-demo + +[English](README.md) · **한국어** + +[`easy-paging-spring-boot-starter`](https://github.com/devslab-kr/easy-paging-spring-boot-starter)의 커서(keyset) 페이지네이션 예제 — `OFFSET`이 깊이에 따라 느려지고 `COUNT(*)`도 낭비인 무한 시계열 스트림 (로그, audit 이벤트, location ping)에 쓰는 전략. + +전통적인 offset 페이지네이션을 다루는 [`easy-paging-demo`](../easy-paging-demo/)의 자매 데모 — 어노테이션, 반환 타입, `WHERE` 절 패턴이 다름. 동일한 H2 인메모리 DB, 외부 서비스 없음. + +## 전제조건 + +- JDK 21+ +- 그 외 없음. + +## 실행 + +```bash +cd easy-paging-keyset-demo +./gradlew bootRun +``` + +앱은 `http://localhost:8080`에 뜸. H2는 고정 worker UUID (`00000000-0000-0000-0000-000000000001`)에 대한 **300개 location ping**으로 시드됨. + +## 시험해보기 + +### 첫 페이지 (커서 없음) + +```bash +curl 'http://localhost:8080/locations?workerId=00000000-0000-0000-0000-000000000001&size=10' +``` + +```json +{ + "content": [ + { "id": 300, "workerId": "00000000-...", "time": "2026-05-23T05:00:00Z", "lat": 37.5965, "lng": 127.0080 }, + { "id": 299, ... }, + ... + ], + "size": 10, + "nextCursor": "eyJrIjp7InRpbWUiOiIyMDI2LTA1LTIzVDA0OjUxOjAwWiIsImlkIjoyOTF9LCJkIjoiRk9SV0FSRCJ9", + "prevCursor": null, + "hasNext": true, + "hasPrev": false +} +``` + +`content`는 `(time DESC, id DESC)` 순서 — 가장 최근 행이 맨 위. `nextCursor`는 Base64 인코딩된 JSON payload — 서명 시크릿이 없으면 디코딩해서 내용 확인 가능 (아래 참고). + +### 스트림 walking + +`nextCursor`를 `?cursor=…`로 넘기기: + +```bash +curl 'http://localhost:8080/locations?workerId=00000000-0000-0000-0000-000000000001&size=10&cursor=<이전 응답의 nextCursor>' +``` + +`hasNext`가 `false`가 될 때까지 반복. 300행 / `size=10`이면 정확히 30페이지 — walk 동안 1~300 모든 `id`가 정확히 한 번씩 등장. (`LocationControllerTest`가 이 property를 프로그래매틱하게 검증.) + +### 크기 clamping + +```bash +# 컨트롤러가 @KeysetPaginate(maxSize = 200) — ?size=9999 → 200으로 clamping +curl 'http://localhost:8080/locations?workerId=00000000-0000-0000-0000-000000000001&size=9999' | jq '.size' +# → 200 +``` + +## 커서 서명 (프로덕션 가기 전에 읽기) + +데모는 기본적으로 **커서 서명 없이** 실행: + +```yaml +easy-paging: + keyset: + cursor-secret: ${EASY_PAGING_CURSOR_SECRET:} +``` + +로컬 탐색에는 OK — `base64 -d`로 커서 내용을 그대로 볼 수 있어서 동작 원리가 명확해짐. **하지만 프로덕션에서 시크릿 누락은 취약점**: 악의적 클라이언트가 커서를 조작해서 (커서에 박힌 tenant key 등을 변조해서) 보면 안 되는 행을 seek 가능. + +서명된 커서로 실행: + +```bash +EASY_PAGING_CURSOR_SECRET='a-long-random-string-from-your-secrets-manager' ./gradlew bootRun +``` + +정상 사용에선 동일해 보이지만 서명된 커서는 `.` 형태가 되고 변조된 커서는 거부됨. + +## 읽을 만한 파일 + +| 파일 | 왜 | +| --- | --- | +| `build.gradle.kts` | `spring-boot-starter-web` 외 추가 의존성은 `kr.devslab:easy-paging-spring-boot-starter:0.4.0` (offset 데모와 동일) | +| `location/LocationController.java` | 계약: `@KeysetPaginate(keys = {"time", "id"}, ...)` + 스타터가 자동 resolve하는 `KeysetRequest` 파라미터 | +| `location/LocationService.java` | **size + 1** 트릭 (매퍼가 한 행 더 가져와서 `KeysetPage.build`가 `hasNext`를 정확히 설정) + 다음 커서가 되는 `keyExtractor` 람다 | +| `location/LocationMapper.java` + `resources/mapper/LocationMapper.xml` | 복합 키 seek predicate — `time < ? OR (time = ? AND id < ?)`. 타임스탬프가 겹쳐도 walk를 안정적으로 만드는 핵심 | +| `resources/schema.sql` | 쿼리의 ORDER BY와 일치하는 covering 인덱스 `(worker_id, time DESC, id DESC)`. 실제 DB에서 페이지당 O(log N) 유지 | + +## 빌드 검증 + +```bash +./gradlew build +``` + +스모크 테스트가 앱 부팅 후 전체 커서 체인을 4페이지 walk하면서 **중복/누락 없음** 불변식 — keyset 페이지네이션이 실제로 보장해야 하는 property — 검증. + +## `easy-paging-demo`와의 차이 + +| | `easy-paging-demo` (offset) | `easy-paging-keyset-demo` (이거) | +|---|---|---| +| 어노테이션 | `@AutoPaginate` | `@KeysetPaginate(keys = {...})` | +| 반환 타입 | `PageResponse` | `KeysetPage` | +| 매퍼 SQL | 평범한 `SELECT` (LIMIT/OFFSET 없음 — aspect가 주입) | 명시적 `WHERE` seek 절 + `LIMIT size+1` | +| `totalElements` | 있음 | 없음 (`COUNT(*)` 피하는 게 keyset의 핵심) | +| 적합한 경우 | totals가 있는 유한 paginated 리스트 | 무한 스트림, append-only 테이블 | +| 깊이별 성능 | 페이지 번호에 따라 느려짐 | 페이지마다 일정 | diff --git a/easy-paging-keyset-demo/README.md b/easy-paging-keyset-demo/README.md index 43f0a76..75448d4 100644 --- a/easy-paging-keyset-demo/README.md +++ b/easy-paging-keyset-demo/README.md @@ -1,5 +1,7 @@ # easy-paging-keyset-demo +**English** · [한국어](README.ko.md) + Cursor (keyset) pagination example for [`easy-paging-spring-boot-starter`](https://github.com/devslab-kr/easy-paging-spring-boot-starter) — the strategy you want for unbounded time-series streams (logs, audit events, location pings) where `OFFSET` would slow down with depth and `COUNT(*)` is wasted work. Companion to [`easy-paging-demo`](../easy-paging-demo/) — which covers traditional offset pagination — but with a different annotation, return type, and `WHERE`-clause pattern. Same H2 in-memory database, no external services. diff --git a/easy-paging-postgres-demo/README.ko.md b/easy-paging-postgres-demo/README.ko.md new file mode 100644 index 0000000..9c33b58 --- /dev/null +++ b/easy-paging-postgres-demo/README.ko.md @@ -0,0 +1,104 @@ +# easy-paging-postgres-demo + +[English](README.md) · **한국어** + +[`easy-paging-spring-boot-starter`](https://github.com/devslab-kr/easy-paging-spring-boot-starter)의 프로덕션 풍 실행 가능한 예제 — H2 대신 **실제 PostgreSQL** 상대. + +스타터는 PageHelper가 지원하는 어떤 JDBC DB든 동작 (Postgres, MySQL, MariaDB, Oracle, ...) — 이 데모는 그걸 실제 팀이 가장 많이 쓰는 DB로 end-to-end 검증하고, 이 repo가 앞으로 모든 "외부 DB" 데모에 쓸 Docker Compose + Testcontainers 패턴을 보여줌. + +## 전제조건 + +- JDK 21+ +- **Docker** (Docker Desktop 또는 호환 런타임) +- 그 외 없음. 로컬 Postgres 설치 불필요. psql 클라이언트 불필요. + +## 실행 + +```bash +cd easy-paging-postgres-demo + +# Postgres 백그라운드로 시작. compose 파일이 localhost:5432에 바인드 +docker compose up -d db + +# 앱 부팅. spring.sql.init가 매 시작마다 스키마 재생성 + 500개 product 시드 +# → 항상 알려진 상태로 부팅 +./gradlew bootRun +``` + +앱은 `http://localhost:8080`에 뜸. 끝나면: + +```bash +docker compose down # 컨테이너 중지 +docker compose down -v # ... 그리고 볼륨 삭제 (다음에 clean slate) +``` + +## 시험해보기 + +```bash +# 기본 offset 페이지네이션 +curl 'http://localhost:8080/products?page=0&size=10' + +# 정렬 +curl 'http://localhost:8080/products?page=0&size=10&sort=price,desc' +curl 'http://localhost:8080/products?page=0&size=10&sort=createdAt,desc&sort=id,asc' + +# 카테고리 필터 — 데이터는 5개 카테고리에 100개씩 시드됨 +curl 'http://localhost:8080/products?category=books&page=0&size=20' | jq '.totalElements' +# → 100 + +# 페이지 크기 clamping (컨트롤러가 @AutoPaginate(maxSize = 100) 선언) +curl 'http://localhost:8080/products?size=9999' | jq '.size, (.content | length)' +# → 100 +# → 100 + +# 정렬 인젝션 시도는 DB에 닿기 전에 HTTP 400 +curl -i 'http://localhost:8080/products?sort=name;DROP%20TABLE%20products' +``` + +## 테스트 동작 방식 (Docker Compose vs Testcontainers) + +이 데모엔 두 Docker 경로가 의도적으로 분리되어 있음: + +| 경로 | 언제 실행 | 무엇 사용 | +| --- | --- | --- | +| `docker compose up -d db` | **사람**이 장수명 DB 상대 `bootRun` 할 때 | 이 디렉토리의 `docker-compose.yml` — 5432 포트 publish, named volume `pgdata` | +| `ProductControllerIT`의 Testcontainers | `./gradlew test` 실행 시 (로컬/CI) | **단명** `postgres:16-alpine` 컨테이너, 임의 포트, 테스트 클래스마다 시작/종료 | + +통합 테스트는 Spring Boot 3.1+의 [`@ServiceConnection`](https://docs.spring.io/spring-boot/reference/testing/testcontainers.html#testing.testcontainers.service-connections)을 사용 — Testcontainers 인스턴스로 `spring.datasource.url`을 자동 재배선. `application-test.yml` 없음, `@DynamicPropertySource` 없음. 테스트가 `docker-compose.yml`을 읽지 않으므로 compose의 포트와 테스트의 포트가 충돌 안 함. + +즉 CI는 Postgres 사전 설치 없이 깨끗한 Ubuntu 러너에서 `./gradlew build` 실행 가능. 러너엔 이미 Docker가 있고, 나머지는 Testcontainers가 처리. + +## 읽을 만한 파일 + +흥미로운 부분, 순서대로: + +| 파일 | 왜 | +| --- | --- | +| `docker-compose.yml` | healthcheck 포함된 최소 PG 서비스 — `docker compose up -d`가 한 줄짜리 | +| `build.gradle.kts` | `org.postgresql:postgresql` (드라이버) + `spring-boot-testcontainers` / `org.testcontainers:postgresql` 테스트 의존성 추가. 그 외는 H2 데모와 동일 | +| `src/test/.../ProductControllerIT.java` | `@Testcontainers` + `@ServiceConnection` 두 줄로 wiring 완성. 정적 초기화 블록 없음, `@DynamicPropertySource` 없음 | +| `src/main/resources/schema.sql` | `BIGSERIAL`과 복합 인덱스 — PG 네이티브, H2 비호환 — 사용해서 스타터가 실제 PG 스키마 기능과 잘 동작함을 보여줌 | +| `src/main/resources/data.sql` | 결정론적 시딩을 위한 `generate_series(1, 500)` (테스트가 검증하는 필드엔 `random()` 안 씀) | +| `product/ProductController.java` | 계약은 어노테이션 하나 + `Pageable` — H2 데모와 동일. 스타터는 밑이 어떤 DB인지 신경 안 씀 | + +## 마이그레이션 (실제 앱으로 복붙할 거면 읽기) + +이 데모는 매 부팅마다 `DROP TABLE IF EXISTS` + `CREATE TABLE` 스크립트를 실행하는 `spring.sql.init`을 사용. **프로덕션에선 그러지 마세요.** [Flyway](https://flywaydb.org/)나 [Liquibase](https://www.liquibase.org/) 사용 — 둘 다 Spring Boot 스타터 있고 `easy-paging-spring-boot-starter`와 별도 설정 없이 공존. `spring.sql.init`은 "셋업 단계 없이 항상 알려진 상태로 부팅"이 목적인 학습용 데모에서만 OK. + +## 빌드 검증 + +```bash +./gradlew build +``` + +`ProductControllerIT`가 단명 Testcontainers Postgres 상대로 실행됨. 첫 실행은 `postgres:16-alpine` 이미지 (~80MB) 풀; 이후 실행은 캐시된 이미지로 몇 초 안에 완료. + +## `easy-paging-demo`와의 차이 + +| | `easy-paging-demo` | `easy-paging-postgres-demo` (이거) | +|---|---|---| +| DB | H2 인메모리 (외부 런타임 없음) | 실제 PostgreSQL (`bootRun`은 Docker Compose, 테스트는 Testcontainers) | +| 스키마 기능 | 기본 테이블 | `BIGSERIAL`, `NUMERIC(10,2)`, 복합 인덱스, `generate_series` | +| 매퍼 로직 | 항상 `SELECT *` | `` 통해 옵셔널 `WHERE category = ?` | +| 테스트 인프라 | MockMvc만 | Testcontainers + `@ServiceConnection` | +| 적합한 경우 | "30초 안에 스타터 보여주기" | "프로덕션 DB 상대로 동작하고 테스트가 깔끔히 통합되는지 보여주기" | diff --git a/easy-paging-postgres-demo/README.md b/easy-paging-postgres-demo/README.md index 7aca9ac..b2c481a 100644 --- a/easy-paging-postgres-demo/README.md +++ b/easy-paging-postgres-demo/README.md @@ -1,5 +1,7 @@ # easy-paging-postgres-demo +**English** · [한국어](README.ko.md) + Production-flavoured runnable example for [`easy-paging-spring-boot-starter`](https://github.com/devslab-kr/easy-paging-spring-boot-starter) against a **real PostgreSQL** instead of H2. The starter works on whatever JDBC database PageHelper supports (Postgres, MySQL, MariaDB, Oracle, …) — this demo proves it end-to-end on the database most teams actually ship with, and shows the Docker Compose + Testcontainers pattern this repo will use for every "external DB" demo from here on. diff --git a/easy-paging-reactive-demo/README.ko.md b/easy-paging-reactive-demo/README.ko.md new file mode 100644 index 0000000..43b10bf --- /dev/null +++ b/easy-paging-reactive-demo/README.ko.md @@ -0,0 +1,86 @@ +# easy-paging-reactive-demo + +[English](README.md) · **한국어** + +[`easy-paging-spring-boot-starter`](https://github.com/devslab-kr/easy-paging-spring-boot-starter)의 Reactive (WebFlux + R2DBC) 실행 가능한 예제. 자매 아티팩트 [`easy-paging-spring-boot-starter-reactive`](https://central.sonatype.com/artifact/kr.devslab/easy-paging-spring-boot-starter-reactive) 사용. + +MVC + MyBatis 데모들 (`easy-paging-demo`, `easy-paging-postgres-demo`)은 AOP aspect로 페이지네이션을 wiring. 그런데 reactive 스택엔 hook할 thread-per-request 컨텍스트가 없어서 reactive 스타터는 다른 형태를 취함: 서비스가 명시적으로 호출하는 헬퍼 (`R2dbcOffsetPagingSupport`). 하지만 **wire 계약은 동일** — 동일한 `PageResponse` JSON 봉투, 동일한 `?page=`/`?size=`/`?sort=` 시맨틱 — 그래서 클라이언트는 엔드포인트 뒤에 어떤 스택이 있는지 알 수 없음. + +## 전제조건 + +- JDK 21+ +- **Docker** (`bootRun`과 `./gradlew test` 둘 다) +- 로컬 Postgres 설치 불필요. + +## 실행 + +```bash +cd easy-paging-reactive-demo + +# PostgreSQL을 호스트 5433 포트에 — easy-paging-postgres-demo(5432)와 +# 동시에 실행 가능하도록 일부러 다른 포트. +docker compose up -d db + +# 앱 부팅. spring.sql.init가 매 시작마다 스키마 재생성 + 500개 article 시드, +# postgres 데모와 동일. +./gradlew bootRun +``` + +앱은 `http://localhost:8080`에 뜸. `docker compose down` (또는 `docker compose down -v`로 볼륨 삭제)으로 정리. + +## 시험해보기 + +```bash +# 기본 offset 페이지네이션 — totalElements=500, totalPages=50 +curl 'http://localhost:8080/articles?page=0&size=10' + +# 정렬 +curl 'http://localhost:8080/articles?page=0&size=10&sort=publishedAt,desc' +curl 'http://localhost:8080/articles?page=0&size=5&sort=viewCount,desc&sort=id,asc' + +# author 필터 — 5명 author에 각 100개씩 +curl 'http://localhost:8080/articles?author=alice&page=0&size=20' | jq '.totalElements' +# → 100 + +# 정렬 인젝션 시도는 여전히 HTTP 400 (검증 로직은 core 스타터에 있고 reactive 쪽이 재사용) +curl -i 'http://localhost:8080/articles?sort=title;DROP%20TABLE%20articles' +``` + +## postgres 데모와의 차이 + +wire 동작은 동일. 코드 차이는 모두 reactive 스택과 관련된 것: + +| 레이어 | Postgres 데모 | Reactive 데모 (이거) | +| --- | --- | --- | +| Web | `spring-boot-starter-web` (서블릿) | `spring-boot-starter-webflux` | +| DB 접근 | MyBatis + JDBC | Spring Data R2DBC (`R2dbcEntityTemplate`) | +| 페이지네이션 wiring | 컨트롤러의 `@AutoPaginate` aspect | 서비스에서 `R2dbcOffsetPagingSupport.paginate(...)` 명시적 호출 | +| 컨트롤러 반환 타입 | `PageResponse` | `Mono>` | +| 엔티티 매핑 | 수동 `Product` POJO + MyBatis XML resultType | Spring Data Relational `@Table` / `@Column` / `@Id` | +| 매퍼 SQL | XML의 손으로 쓴 `