Skip to content

목록과 페이징

목록 화면 하나를 만들려면 보통 이만큼이 필요합니다 — 조건 조립, 정렬, LIMIT, 전체 건수 카운트, 행 번호, 응답 헤더. iFlex 는 이 반복을 세 조각으로 정리했습니다.

3줄 요약

  1. Repository 가 SELECT 를 만든다 (list{X}Provider)
  2. Repository 가 결과를 DTO 로 매핑한다 (_{x}DtoMapper)
  3. Service 가 둘을 InPagination.withRequestDsl 에 넘긴다 — 페이징·카운트는 여기서 자동

전체 그림

[브라우저] GET /board?q={...}&_usePaging=true&currentPage=2&dataPerPage=20&sortBy=createdDt:desc


Resource   @RequestParam("q") NvQuery q          ← 검색 조건 트리


Service    InPagination.withRequestDsl(job, fetcher)
     │        │
     │        ├─ ① job     : provider 를 만든다 (아직 실행 안 함)
     │        └─ ② fetcher : provider 를 실행해 DTO 로 매핑


Repository ① list{X}Provider(q, sortModel) → SelectStatementProvider
           ② _{x}DtoMapper(provider)      → List<{X}Dto>


[응답] 본문 = List<{X}Dto>
       헤더 = x-nv-total-count / x-nv-page / x-nv-data-count

withRequestDsl 안에서 일어나는 일:

  1. 요청에서 페이징·정렬 정보를 읽음
  2. provider 를 만들고 DBMS 방언에 맞는 LIMIT 을 붙임
  3. fetcher 실행
  4. 전체 건수 카운트 (필요할 때만)
  5. 각 행에 행 번호(positionIdx) 부여
  6. 응답 헤더에 페이징 정보 기록

기준 구현 — Board

실제 코드 그대로입니다. 새 목록을 만들 때 이 구조를 복사하세요.

① Repository — SELECT 를 만든다

java
@Repository
@DefaultMapper
@TableInfo(dsl = BoardDsl.class, searchSchema = BoardSearchSchema.class)
public interface BoardRepository extends NvRepositoryDynamic<Board, Long> {

    /** 목록·페이징이 공유하는 SELECT */
    default SelectStatementProvider listBoardProvider(NvQuery q, SortModel sortModel) {
        return defaultListProvider(
            q,                                     // 검색 조건 트리
            getDatabaseSupport().getAudit(),       // 등록자/수정자 조인
            sortModel);                            // 정렬
    }

    /** provider 실행 → DTO 직접 매핑 (목록·페이징·상세 공용) */
    @ResultMap("boardDtoResult")
    @SelectProvider(type = SqlProviderAdapter.class, method = "select")
    List<BoardDto> _boardDtoMapper(SelectStatementProvider selectStatement);
}

@TableInfo 가 이 리포지토리의 메타 선언입니다.

속성역할
dsl이 리포지토리가 다루는 테이블 (컬럼 참조·경로 해석의 기준)
searchSchema검색 화이트리스트 — 어떤 경로·연산자를 허용할지

provider 인자는 셋입니다.

인자역할
q클라이언트가 보낸 검색 조건 트리 (신뢰할 수 없는 입력)
getDatabaseSupport().getAudit()등록자/수정자 조인 메타 (null 이면 조인 안 함)
sortModel정렬. 비어 있으면 provider 의 폴백 정렬

searchSchema 를 왜 선언하나

이게 없으면 모든 컬럼이 검색 가능해집니다

q클라이언트가 만든 JSON 입니다. 서버가 "어떤 필드를 조건에 넣어도 되는지" 를 알아야 하는데, 그 정보가 바로 스키마입니다.

스키마 없이 경로를 해석하는 방법도 있습니다 — QueryBuilder.resolveColumn(path)Dsl 의 fieldMap 전체를 해석합니다. 하지만 그러면 acnt.loginPwd 같은 컬럼까지 열립니다.

json
{"conds":[{"field":"acnt.loginPwd","op":"lf","val":"$2a$10$a"}]}

응답 건수만 봐도 비밀번호 해시를 한 글자씩 알아낼 수 있습니다. "어차피 해시니까" 가 아니라 애초에 조건에 넣을 수 없어야 합니다.

바인딩 시점에 이렇게 쓰입니다.

NvQueryBinder.bind(q, schema, ...)
  └─ NvSearchSchema.index(schema)      // 경로 → 스키마 맵
       ├─ 맵에 없는 field       → 드롭 (warn 로그)
       ├─ allows(op) 가 false  → 드롭
       └─ coerce(val) 실패     → 드롭

전부 드롭입니다. 조건이 빠지는 방향(= 결과가 좁아지는 방향)으로 실패하므로, 잘못된 입력이 보이면 안 되는 데이터를 노출시키지 않습니다 → fail-closed 원칙

서버 강제조건은 스키마에 넣지 않습니다

deleted = false 같은 조건을 스키마에 선언하면 클라이언트도 그 경로를 쓸 수 있게 되어deleted = true 로 뒤집을 수 있습니다. 이런 불변식은 스키마에 없는 채로 repository 가 붙입니다 → 두 종류의 서버 조건

선언을 빠뜨리면 기동 시 실패합니다

searchSchema 없이 스키마 생략형(defaultListProvider(q, audit, sortModel) 등)을 쓰면 예외가 납니다.

@TableInfo(searchSchema = {X}SearchSchema.class) 선언이 필요합니다 — BoardDsl

의도적인 fail-fast 입니다 — 스키마 없이 조용히 통과시키면 화이트리스트가 사라져 모든 컬럼이 열리기 때문입니다. 안전하지 않은 상태로 도는 것보다 기동을 막는 편이 낫습니다.

다른 스키마를 써야 한다면

스키마를 인자로 받는 시그니처도 그대로 남아 있습니다.

java
defaultListProvider(q, SomeOtherSchema.values(), audit, sortModel);

같은 테이블을 다른 화이트리스트로 노출해야 할 때(예: 관리자용/공개용 목록) 씁니다.

defaultListProvider 가 만드는 SQL:

sql
SELECT b.*, ins.acnt_login_id AS ins_user_acnt_login_id, upt.acnt_login_id AS upt_user_...
  FROM nv_board b
  LEFT JOIN nv_account ins ON b.board_created_by = ins.acnt_id
  LEFT JOIN nv_account upt ON b.board_modified_by = upt.acnt_id
 WHERE <조건 트리에서 조립된 WHERE>
 ORDER BY <정렬>

② Service — 실행

java
@Service
public class BoardService extends NvServiceDynamicSupport<Board, Long, BoardRepository> {

    /** 페이징 목록 */
    public List<BoardDto> pageBoardDto(NvQuery q) {
        return InPagination.withRequestDsl(
            (p) -> repository.listBoardProvider(q, Pagination.sortModel.get()),
            repository::_boardDtoMapper);
    }

    /** 비페이징 목록 — LIMIT·카운트 없이 전체 */
    public List<BoardDto> listBoardDto(NvQuery q) {
        Pagination.primary();
        try {
            return repository._boardDtoMapper(
                repository.listBoardProvider(q, Pagination.sortModel.get()));
        } finally {
            Pagination.reset();
        }
    }

    /** 상세 */
    public BoardDto getBoardDto(Long id) {
        return repository._boardDtoMapper(repository.getBoardProvider(id))
            .stream().findFirst().orElse(null);
    }
}

③ Resource — 요청 처리

java
@GetMapping("/board")
public ResponseEntity<List<BoardDto>> listBoard(
        @RequestParam(value = "q", required = false) NvQuery q) {

    if (q == null) {
        q = NvQuery.empty();
    }

    if (WebUtil.isPagingRequest()) {
        return new ResponseEntity<>(
            boardService.pageBoardDto(q),
            PaginationHeaderUtil.generatePaginationHttpHeaders(),   // 페이징 헤더
            HttpStatus.OK);
    }
    return ResponseEntity.ok(boardService.listBoardDto(q));
}

요청 파라미터

프론트가 보내는 값입니다. nv-pagination 컴포넌트가 자동으로 붙여줍니다.

파라미터설명기본값
_usePagingtrue 면 페이징 적용없으면 비페이징
currentPage현재 페이지 (1부터)1
dataPerPage페이지당 건수40
linkPerPage페이지 링크 개수
skipCounttrue 면 전체 건수 카운트를 생략false
sortBy정렬 — 컬럼:방향, 콤마로 다중
q검색 조건 트리 → 검색 조건
GET /board?_usePaging=true&currentPage=2&dataPerPage=20&sortBy=createdDt:desc,name:asc

skipCount 는 언제 쓰나

전체 건수 카운트는 별도 쿼리입니다. 무한 스크롤처럼 "총 몇 건인지" 를 표시하지 않는 화면은 skipCount=true 로 카운트 쿼리를 아낄 수 있습니다.

응답

본문은 DTO 배열, 페이징 정보는 헤더로 나갑니다.

헤더내용
x-nv-total-count조건에 맞는 전체 건수
x-nv-page현재 페이지
x-nv-data-count페이지당 건수

행 번호 — positionIdx

목록에 "번호" 컬럼을 표시할 때, 2페이지 첫 행이 21번이어야 합니다. DTO 가 Countable 을 구현하면 이 값을 자동으로 채워줍니다.

java
public class BoardDto implements Countable {
    private long positionIdx;

    @Override public long getPositionIdx()          { return positionIdx; }
    @Override public void setPositionIdx(long idx)  { this.positionIdx = idx; }
}
vue
<el-table-column label="번호" width="80">
  <template #default="{ row }">{{ row.positionIdx }}</template>
</el-table-column>

정렬

요청 정렬

프론트가 sortBy=createdDt:desc 로 보내면 Pagination.sortModel.get() 에 담깁니다. 이 값은 화이트리스트 검증을 거칩니다 — Dsl 에 없는 컬럼명은 조용히 무시되고 폴백 정렬이 적용됩니다.

기본 정렬 (폴백)

요청에 정렬이 없을 때 쓸 정렬은 provider 가 소유합니다. 서비스가 매번 지정할 필요가 없습니다.

java
default SelectStatementProvider listRoleProvider(NvQuery q, SortModel sortModel) {
    RoleDsl r = RoleDsl.defaultAlias();
    return defaultListProvider(q, getDatabaseSupport().getAudit(),
        sortModelOrDefault(sortModel,
            r.asc(r.name),
            r.asc(r.id)));        // ← 타이브레이커
}

defaultListProvider 는 아무것도 지정하지 않으면 PK DESC 로 폴백합니다.

폴백 정렬은 반드시 유일해야 합니다

엑셀 다운로드처럼 페이지를 반복 조회하는 경우, ORDER BY 가 유일하지 않으면 페이지 경계에서 행이 중복되거나 누락됩니다.

role_name 처럼 중복 가능한 컬럼으로 정렬한다면 PK 를 타이브레이커로 붙이세요.

컬럼은 문자열이 아니라 Dsl 로 참조하세요

java
BoardDsl b = BoardDsl.defaultAlias();

b.desc(b.createdDt)                            // ✅ 컴파일 안전
new SortBy("createdDt", SortDirection.DESC)    // ❌ 리네임 시 조용히 폴백

문자열 정렬 키가 위험한 이유

컬럼을 리네임하면 b.createdDt컴파일 에러로 즉시 잡힙니다. 반면 문자열 "createdDt" 는 매칭에 실패해도 예외도 로그도 없이 폴백 정렬로 넘어갑니다. 정렬만 슬그머니 바뀌어 있고 아무도 모릅니다.

문자열 경로는 클라이언트가 보낸 정렬(와이어 입력) 전용입니다 — 그 경우엔 화이트리스트 + 조용한 드롭이 올바른 동작입니다.

서비스가 정렬을 지정하는 경우

같은 provider 를 정렬만 바꿔 여러 번 부를 수 있습니다.

java
// 한 화면에 "최근 등록 5건" + "최근 수정 5건"
BoardDsl b = BoardDsl.defaultAlias();

var latest = repository._boardDtoMapper(
    repository.listBoardProvider(q, new SortModel().setSortBy(b.desc(b.createdDt))));

var modified = repository._boardDtoMapper(
    repository.listBoardProvider(q, new SortModel().setSortBy(b.desc(b.modifiedDt))));

서비스가 sortModel 로 넘기는 값은 셋 중 하나입니다.

의미
Pagination.sortModel.get()요청 정렬 그대로 (일반 목록) — 없으면 provider 폴백
null모듈 기본 정렬 사용 (엑셀 등 반복조회)
new SortModel().setSortBy(...)업무가 요구하는 정렬을 직접 지정

provider 를 직접 작성하기

defaultListProvider 로 안 되는 경우 — 조인을 추가하거나 프로젝션을 바꿔야 할 때 — provider 본문을 직접 씁니다.

java
default SelectStatementProvider listBoardProvider(NvQuery q, SortModel sortModel) {
    BoardDsl b = BoardDsl.defaultAlias();
    AccountDsl ins = AccountDsl.as("_createdBy");

    QueryBuilder qb = searchQuery(q, b);   // @TableInfo 의 searchSchema 를 화이트리스트로

    var where = select(b.allColumns(), ins.loginId.as("ins_user_acnt_login_id"))
        .from(b, b.getAlias())
       .leftJoin(ins, "_createdBy").on(b.createdBy, equalTo(ins.id))
        .applyWhere(qb.prepare().whereDsl().toWhereApplier());

    SortSpecification[] s = qb.toSortSpecifications(sortModel);   // 화이트리스트 검증
    return (s.length > 0 ? where.orderBy(s) : where)
        .build().render(RenderingStrategies.MYBATIS3);
}

audit 조인 제어

등록자·수정자 조인은 Audit 객체로 제어합니다.

원하는 것넘기는 값
등록자 + 수정자 (기본)getDatabaseSupport().getAudit()
등록자만audit.toBuilder().updateJoinKey(null).build()
조인 없음null
다른 테이블/프리픽스audit.toBuilder().insertColumnPrefix("reg_")...build()

실행 헬퍼 정리

메서드용도
InPagination.withRequestDsl(job, fetcher)요청 기반 페이징 — 가장 많이 씁니다
InPagination.withRequestDsl(job, fetcher, counter)카운트 로직을 직접 지정 (조인 필터 등 특수 케이스)
InPagination.pageDsl(job, fetcher, ...)수동 페이징 (요청과 무관하게 페이지 지정)
InPagination.pageCurrentDsl(job, fetcher)현재 컨텍스트로 LIMIT 만 적용 (엑셀 반복조회용)

카운트는 어떻게 세나

기본 동작은 바깥 ORDER BY 를 제거하고 count 서브쿼리로 감싸는 것입니다. 목록 SQL 을 그대로 재사용하므로 조건이 어긋날 일이 없습니다.

조인 때문에 서브쿼리 카운트가 부정확한 경우에만 3-arg 버전으로 counter 를 직접 넘깁니다.

대량 조회 — 엑셀 다운로드

수만 건을 한 번에 메모리에 올리면 안 됩니다. DbIterator페이지 단위 반복 조회합니다.

java
public SXSSFWorkbook excelBoard(NvQuery q) throws Exception {
    ExcelBuilder builder = ExcelBuilder.of()
        .withSheet("게시판")
        .addColumn("name", "이름")
        .addColumn("createdUser.loginId", "등록자")
        .addColumn("createdDt", "등록일시");

    DbIterator.workInPagination(1_000, () ->
        InPagination.pageCurrentDsl(
            (p) -> repository.listBoardProvider(q, null),   // null = 기본 정렬(결정적)
            repository::_boardDtoMapper))
        .forEach(builder::addRows);

    return builder.build();
}

반복 조회는 정렬이 결정적이어야 합니다

sortModelnull 을 넘겨 provider 의 기본 정렬(PK 기준)을 쓰는 이유입니다. 요청 정렬을 그대로 쓰면 페이지 경계에서 행이 중복·누락될 수 있습니다.

목록과 엑셀이 같은 provider·같은 매핑을 공유하므로, 목록에 보이는 것과 엑셀 내용이 어긋나지 않습니다.

설계 원칙 — 누가 무엇을 소유하나

이 구조를 이해하는 가장 빠른 방법입니다.

관심사소유자이유
SELECT 작성 (조인·프로젝션)Repository providerSQL 은 데이터 계층의 일
서버 강제조건 (불변식)Repository private 헬퍼서비스가 우회할 수 없어야 함
기본 정렬 (폴백)Repository provider모듈마다 다르고, 호출부가 반복할 일이 아님
어떤 조건·정렬·페이지를 쓸지Service업무 판단
페이징·카운트·행번호플랫폼 (InPagination)모든 목록이 똑같이 필요

서비스가 SQL 을 조립하면 안 됩니다

java
// ❌ 서비스에서 술어를 조립 — repository 의 일입니다
QueryBuilder.of(q, schema, dsl -> dsl.and(col, isEqualTo(v)), t);

// ✅ 값을 골라 넘기는 것은 서비스의 본래 일
repository.listBoardProvider(q, Pagination.sortModel.get());

// ✅ 컬럼을 "가리키는 것" 은 정당 — 오히려 권장 (컴파일 안전)
new SortModel().setSortBy(b.desc(b.createdDt))

2단계 조회(자식 매칭 후 IN 필터)에서도 서비스는 조립된 쿼리가 아니라 값을 넘깁니다.

java
// ✅ 1단계 결과를 값으로 전달, IN 조립은 provider 가
Collection<String> codes = pageContentRepository.listCodesMatching(q);
repository.listPageProvider(q, codes, sortModel);

문자열 SQL 조립은 금지입니다

${whereClause} / ${sortBy} 같은 문자열 치환은 프로젝트 전체에서 금지이며, 테스트(DollarTokenRatchetTest)가 mapper XML 에 이런 토큰이 들어오는 것을 막습니다.

동적 조건은 provider 의 applyWhere 로, 정렬은 SortSpecification[] 으로 작성하세요.

자주 겪는 문제

목록은 나오는데 전체 건수가 0 입니다

_usePaging=true 가 빠졌거나, Resource 에서 PaginationHeaderUtil.generatePaginationHttpHeaders() 를 응답에 붙이지 않았을 수 있습니다. 브라우저 Network 탭에서 x-nv-total-count 헤더를 확인하세요.

페이지를 넘기면 같은 행이 또 나옵니다

ORDER BY 가 유일하지 않아서입니다. 정렬 컬럼에 중복 값이 있으면 DB 가 페이지마다 다른 순서를 줄 수 있습니다. PK 를 타이브레이커로 추가하세요.

정렬이 요청대로 안 됩니다

프론트가 보낸 sortBy 의 컬럼명이 Dsl 프로퍼티와 다르면 조용히 드롭되고 폴백 정렬이 적용됩니다. 의도된 동작(화이트리스트)이므로 예외가 나지 않습니다. Dsl 의 프로퍼티명과 대조해 보세요.

행 번호(positionIdx)가 전부 0 입니다

응답 DTO 가 Countable 을 구현했는지 확인하세요. 구현하지 않으면 채워지지 않습니다.

관련 문서