개발 노트/Backend,CMS,API

[Backend/API] 목록 조회 pagination, search, filter를 설계하는 기준

pposooj 2026. 5. 20. 15:02

Backend 앱 API에서 목록 조회는 거의 모든 서비스에 들어가는 기본 기능입니다. 공지 목록, 관광지 목록, 게시글 목록, 배너 목록처럼 앱 화면은 대부분 목록 데이터를 받아서 렌더링합니다. 그런데 pagination, search, filter, sort 기준을 처음부터 정하지 않으면 API마다 파라미터 이름과 응답 구조가 달라지기 쉽습니다.

처음에는 page, keyword 정도만 받아도 충분해 보입니다. 하지만 앱이 커지면 정렬 기준, 카테고리 필터, 지역 필터, 공개 상태, hasNext, limit 제한, 빈 결과 처리까지 함께 봐야 합니다.

특히 관리자 CMS 목록 조회와 앱 API 목록 조회는 목적이 다릅니다. 같은 query를 그대로 쓰면 내부 필드나 DB 컬럼 기준이 앱에 노출될 수 있습니다. 처음에는 재사용이 편해 보일 수 있지만, 실제로 정리해보면 앱 API 목록은 공개 조건과 응답 계약을 별도로 잡는 편이 더 안전한 것 같습니다.

이 글에서는 Backend 앱 API에서 목록 조회 pagination, search, filter를 설계하는 기준을 정리해보겠습니다. API 목록 설계를 검토할 때 놓치기 쉬운 기본값, 허용 범위, 공개 조건, 응답 구조를 중심으로 살펴보겠습니다.

이 글에서 다룰 문제

목록 조회 API 기준이 없으면 다음 문제가 생길 수 있습니다.

  • API마다 page, currentPage, pageNo처럼 파라미터 이름이 달라진다.
  • limit 제한이 없어 과도한 데이터 요청이 발생한다.
  • sort 값을 그대로 DB 컬럼명으로 받아 SQL injection 위험이 생긴다.
  • 관리자용 검색 조건이 앱 API에 그대로 노출된다.
  • 빈 검색어, 잘못된 filter 값, 음수 page 처리 기준이 없다.
  • 앱에서 다음 페이지 존재 여부를 알기 어려워 중복 호출이 생긴다.

목록 조회는 단순해 보이지만 앱 성능과 사용자 경험에 직접 연결됩니다. 그래서 기본값과 허용 범위를 명확히 정해두는 것이 좋습니다.

관리자 목록과 앱 API 목록은 다르다

관리자 CMS 목록은 운영자가 데이터를 찾고 수정하기 위한 화면입니다. 반면 앱 API 목록은 사용자에게 공개 가능한 데이터를 빠르고 안정적으로 제공하기 위한 인터페이스입니다.

구분 관리자 CMS 목록 앱 API 목록
목적 관리, 검수, 수정 공개 데이터 조회
검색 범위 내부 상태, 관리자 메모 포함 가능 공개 필드 중심
필터 검수 상태, 삭제 여부, 작성자 카테고리, 지역, 공개 태그
응답 테이블 표시용 앱 화면 DTO
인증 관리자 인증 공개 API 또는 앱 사용자 인증
노출 기준 내부 필드 포함 가능 공개 가능한 필드만 포함

이 차이를 무시하고 관리자 목록 query를 그대로 앱 API에서 재사용하면 내부 상태나 DB 컬럼 기준이 앱으로 새어 나갈 수 있습니다. query 일부는 재사용할 수 있어도, 공개 조건과 응답 모델은 분리하는 편이 안전합니다.

pagination 기본값 정하기

먼저 pagelimit의 기본값, 최소값, 최대값을 정해야 합니다. 아래는 요청 값을 정리하는 예시입니다.

final class ListQueryParams
{
    public function __construct(
        public readonly int $page,
        public readonly int $limit,
        public readonly ?string $keyword,
        public readonly ?string $category,
        public readonly string $sort,
    ) {
    }

    public static function fromRequest(array $input): self
    {
        $page = max(1, (int) ($input['page'] ?? 1));
        $limit = min(50, max(1, (int) ($input['limit'] ?? 20)));
        $keyword = trim((string) ($input['keyword'] ?? '')) ?: null;
        $category = trim((string) ($input['category'] ?? '')) ?: null;
        $sort = (string) ($input['sort'] ?? 'latest');

        return new self($page, $limit, $keyword, $category, $sort);
    }
}

여기서 중요한 기준은 limit 상한입니다. 앱에서 실수로 너무 큰 값을 요청해도 서버가 제한해야 합니다. 기본값은 앱 화면에서 무리 없이 쓸 수 있는 정도로 정하고, 최대값은 서버와 DB 부하를 고려해 제한하는 것이 좋습니다.

처음에는 클라이언트에서 알아서 적당한 값을 보낼 것이라고 생각하기 쉽습니다. 하지만 API는 항상 잘못된 요청이 들어올 수 있다는 기준으로 방어하는 편이 안전합니다.

sort allowlist 만들기

정렬 기준은 반드시 allowlist로 관리해야 합니다. 클라이언트에서 받은 sort 값을 DB 컬럼명으로 그대로 쓰면 위험합니다.

final class SortResolver
{
    private const SORTS = [
        'latest' => ['published_at', 'desc'],
        'oldest' => ['published_at', 'asc'],
        'popular' => ['view_count', 'desc'],
    ];

    public static function resolve(string $sort): array
    {
        return self::SORTS[$sort] ?? self::SORTS['latest'];
    }
}

API 파라미터는 latest, popular처럼 앱에 공개해도 되는 의미 기반 키를 쓰고, 실제 DB 컬럼명은 서버 내부에서만 매핑하는 편이 안전합니다. 이렇게 하면 DB 컬럼명이 앱 계약으로 굳어지는 문제도 줄일 수 있습니다.

filter allowlist 만들기

filter도 마찬가지로 허용 가능한 값만 처리해야 합니다. 예를 들어 카테고리 필터를 받을 때 실제 내부 코드나 관리자 상태값을 그대로 노출하지 않는 것이 좋습니다.

final class CategoryFilter
{
    private const ALLOWED = ['notice', 'event', 'guide'];

    public static function normalize(?string $category): ?string
    {
        if (!$category) {
            return null;
        }

        return in_array($category, self::ALLOWED, true) ? $category : null;
    }
}

잘못된 filter 값이 들어왔을 때 400 에러를 낼지, 무시하고 기본 목록을 보여줄지는 API 성격에 따라 정하면 됩니다. 검색 화면에서는 잘못된 filter를 무시하는 방식이 사용자 경험상 나을 수 있고, 엄격한 API 계약이 필요한 곳에서는 400 에러가 더 나을 수 있습니다.

앱 공개 조건을 분리하기

앱 API 목록은 공개 가능한 데이터만 조회해야 합니다. 공개 조건은 controller마다 반복하기보다 query class나 scope로 분리하는 편이 좋습니다.

final class NoticeListQuery
{
    public function forApp(ListQueryParams $params): Builder
    {
        [$sortColumn, $sortDirection] = SortResolver::resolve($params->sort);
        $category = CategoryFilter::normalize($params->category);

        return Notice::query()
            ->where('is_published', true)
            ->whereNull('deleted_at')
            ->where('published_at', '<=', now())
            ->when($params->keyword, function ($query, string $keyword) {
                $query->where('title', 'like', "%{$keyword}%");
            })
            ->when($category, function ($query, string $category) {
                $query->where('public_category', $category);
            })
            ->orderBy($sortColumn, $sortDirection);
    }
}

is_published, deleted_at, published_at 같은 공개 조건은 controller마다 반복하지 않는 편이 좋습니다. query class나 scope로 묶어두면 누락 가능성이 줄어듭니다.

처음에는 controller 안에서 바로 조건을 추가하는 방식이 간단해 보이지만 목록 API가 늘어나면 공개 조건이 조금씩 달라질 수 있습니다. 공개 데이터 기준은 한 곳에 모아두는 편이 유지보수에 더 좋은 것 같습니다.

hasNext 응답 구조 만들기

앱에서는 다음 페이지가 있는지 알아야 합니다. 전체 개수를 매번 내려줄 수도 있지만, 성능이 중요한 API라면 limit + 1 방식으로 hasNext만 계산할 수 있습니다.

$offset = ($params->page - 1) * $params->limit;
$rows = $query
    ->offset($offset)
    ->limit($params->limit + 1)
    ->get();

$hasNext = $rows->count() > $params->limit;
$items = $rows->take($params->limit)->values();

return response()->json([
    'data' => $items->map(fn ($notice) => NoticeSerializer::listItem($notice)),
    'meta' => [
        'page' => $params->page,
        'limit' => $params->limit,
        'hasNext' => $hasNext,
    ],
]);

전체 개수가 꼭 필요한 화면이 아니라면 hasNext만으로도 무한 스크롤을 구현할 수 있습니다. 다만 관리자 CMS에서는 전체 개수가 필요할 수 있으므로, 관리자 목록과 앱 목록 기준을 나눠야 합니다.

검색어 처리 기준

검색어는 길이와 공백 처리 기준을 정해야 합니다. 검색어를 그대로 like 조건에 넣을 때는 DB 성능도 함께 고려해야 합니다.

  • 앞뒤 공백을 제거한다.
  • 빈 문자열은 null로 처리한다.
  • 너무 긴 검색어는 제한한다.
  • 특수문자와 와일드카드 처리를 고려한다.
  • 검색 대상 필드는 공개 가능한 필드로 제한한다.
  • 관리자 메모나 내부 코드는 검색 대상에 포함하지 않는다.

데이터가 많아지면 인덱스, 전문 검색, 검색 엔진 도입 같은 별도 전략이 필요할 수 있습니다. 이 글에서는 기본 API 설계 기준에 집중해 정리했습니다.

응답 계약 예시

앱 API 목록 응답은 가능한 한 일관되게 유지하는 것이 좋습니다. API마다 items, list, rows처럼 이름이 달라지면 앱 공통 처리 코드가 복잡해집니다.

{
  "data": [
    {
      "id": 1,
      "title": "공지 제목",
      "summary": "공지 요약",
      "publishedAt": "2026-05-13T00:00:00+09:00"
    }
  ],
  "meta": {
    "page": 1,
    "limit": 20,
    "hasNext": false
  }
}

목록 API는 datameta 구조를 통일하는 편이 좋습니다. 이렇게 해두면 앱에서 목록 화면을 재사용하거나 공통 pagination 처리를 만들 때 훨씬 편합니다.

테스트 기준

목록 조회 API는 테스트할 항목이 많습니다. 특히 비공개 데이터가 목록에 포함되지 않는지 확인하는 테스트는 꼭 필요합니다. 앱 API에서는 보여도 되는 데이터만 내려준다는 기준이 가장 중요합니다.

  • page가 0 이하일 때 기본값 또는 오류 기준이 적용되는지 확인한다.
  • limit이 최대값을 넘으면 제한되는지 확인한다.
  • 허용되지 않은 sort 값이 기본 sort로 처리되는지 확인한다.
  • 허용되지 않은 filter 값이 의도한 기준으로 처리되는지 확인한다.
  • 비공개/삭제/미래 게시 데이터가 앱 API에 포함되지 않는지 확인한다.
  • hasNext가 정확히 계산되는지 확인한다.
  • 응답에 내부 필드와 DB 컬럼명이 노출되지 않는지 확인한다.

자주 하는 실수

1. limit 상한을 두지 않는 경우가 있습니다. 앱에서 실수로 큰 값을 요청하면 서버와 DB에 부담이 갈 수 있습니다.

2. sort 값을 DB 컬럼명으로 직접 받는 경우도 있습니다. DB 컬럼명을 그대로 API 파라미터로 받으면 보안과 유지보수 모두 위험해집니다.

3. 관리자 검색 조건을 앱 API에 그대로 쓰는 경우가 있습니다. 앱에서는 관리자 메모, 내부 상태, 삭제 여부 같은 기준이 필요하지 않습니다.

4. 전체 개수를 무조건 계산하는 경우도 있습니다. 데이터가 많아지면 count 쿼리가 부담이 될 수 있습니다. 앱 화면에 꼭 필요한지 먼저 확인하는 편이 좋습니다.

결론

Backend 앱 API에서 목록 조회는 단순한 데이터 반환 기능이 아니라 앱 화면의 안정성과 서버 부하를 함께 결정하는 핵심 기능입니다. pagination, search, filter, sort 기준을 정하지 않으면 API마다 응답이 달라지고, 내부 필드가 노출될 위험도 커집니다.

좋은 구조는 page/limit 기본값과 상한을 정하고, sort/filter를 allowlist로 처리하며, 앱 공개 조건과 DTO 응답을 분리하는 것입니다. 또한 hasNext 같은 meta 구조를 통일하면 앱에서 목록 화면을 더 쉽게 관리할 수 있습니다.

목록 API는 익숙해서 대충 만들기 쉬운 부분입니다. 하지만 가장 많이 호출되는 API일 가능성이 높기 때문에, 초기에 기준을 잡아두는 것이 운영 안정성에 큰 차이를 만드는 것 같습니다.

참고 자료

  • Laravel Query Builder, Pagination, API Resource 공식 문서
  • REST API pagination과 filtering 설계 관련 문서\
  • 프로젝트 내부 API 응답 계약, DTO/serializer, 목록 조회 테스트 문서
  • DB 인덱스와 검색 성능 관련 문서