Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
135 changes: 135 additions & 0 deletions easy-paging-demo/README.ko.md
Original file line number Diff line number Diff line change
@@ -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>` 반환 타입 |
| `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<Report>`로 선언하고 `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<Report>`를 돌려줌. [`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<Report>` | 없음 — 반환 타입이 `Object` |
| 엔드포인트별 wiring | 컨트롤러마다 `CompanyPage.from(...)` 호출 | 없음 — 팩토리는 한 번만 정의 |
| 같은 앱에서 봉투 섞기 | 쉬움 — 엔드포인트별 opt-in | 어려움 — 팩토리가 모든 `Object`/`List` 반환에 영향 |
| 테스트 | 정적 메서드, Spring 불필요 | `@SpringBootTest` 필요 (빈이 컨텍스트에 있어야) |
| 적합한 경우 | 한두 개 엔드포인트만 커스텀 형식 | 모든 paginated 엔드포인트가 같은 형식 사용 |

기본 `/reports` 엔드포인트는 여전히 스타터의 `PageResponse<Report>`를 반환 — 명시적 `PageResponse<T>` (그리고 `CompanyPage<T>`) 반환 타입은 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` 반환)에 새지 않는지 검증
2 changes: 2 additions & 0 deletions easy-paging-demo/README.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
113 changes: 113 additions & 0 deletions easy-paging-keyset-demo/README.ko.md
Original file line number Diff line number Diff line change
@@ -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
```

정상 사용에선 동일해 보이지만 서명된 커서는 `<payload>.<hmac>` 형태가 되고 변조된 커서는 거부됨.

## 읽을 만한 파일

| 파일 | 왜 |
| --- | --- |
| `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<T>` | `KeysetPage<T>` |
| 매퍼 SQL | 평범한 `SELECT` (LIMIT/OFFSET 없음 — aspect가 주입) | 명시적 `WHERE` seek 절 + `LIMIT size+1` |
| `totalElements` | 있음 | 없음 (`COUNT(*)` 피하는 게 keyset의 핵심) |
| 적합한 경우 | totals가 있는 유한 paginated 리스트 | 무한 스트림, append-only 테이블 |
| 깊이별 성능 | 페이지 번호에 따라 느려짐 | 페이지마다 일정 |
2 changes: 2 additions & 0 deletions easy-paging-keyset-demo/README.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
104 changes: 104 additions & 0 deletions easy-paging-postgres-demo/README.ko.md
Original file line number Diff line number Diff line change
@@ -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 *` | `<if>` 통해 옵셔널 `WHERE category = ?` |
| 테스트 인프라 | MockMvc만 | Testcontainers + `@ServiceConnection` |
| 적합한 경우 | "30초 안에 스타터 보여주기" | "프로덕션 DB 상대로 동작하고 테스트가 깔끔히 통합되는지 보여주기" |
2 changes: 2 additions & 0 deletions easy-paging-postgres-demo/README.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
Loading
Loading