앱 Backend API를 만들 때 목록 조회는 정말 숨 쉬듯 만드는 기능입니다. 공지사항, 게시글, 배너, 관광지 목록 등 화면에 뿌려주는 데이터의 90%는 목록이니까요.
처음에는 그냥 page, keyword 정도만 대충 받아서 넘겨도 잘 돌아가는 것처럼 보입니다. 하지만 서비스가 조금만 커지면 정렬 기준이 추가되고, 카테고리나 지역 필터가 붙고, 관리자 상태값 처리까지 뒤엉키면서 API마다 파라미터 이름과 응답 구조가 제각각이 되기 일쑤입니다.
저의 경우에도 혼자서 서버와 앱을 같이 만지다 보니, 초기에 기준을 안 잡아두면 나중에 프론트엔드 코드 짤 때 공통 처리가 안 돼서 고생하곤 했습니다. 자꾸 까먹고 놓치는 부분이 많아서 실무에 바로 적용할 수 있도록 기준을 정리해 둡니다.
1. 내가 겪었던 문제들 (목록 API 기준이 없을 때)
프로젝트를 이래저래 운영하다 보면 목록 API에서 꼭 아래와 같은 문제가 터집니다.
- 일관성 없는 파라미터: 어떤 API는 page를 쓰고, 어떤 건 currentPage, 또 어떤 건 pageNo로 제각각입니다.
- 서버 부하: limit 제한을 안 걸어두니 클라이언트나 크롤러가 한 번에 수천 건의 데이터를 요청해서 DB가 뻗으려고 합니다.
- 보안 취약점 (SQL Injection): 클라이언트가 보낸 sort 값(예: id,desc)을 DB 쿼리에 그대로 꽂아 쓰다가 내부 컬럼명이 노출되거나 SQL 인젝션 위험이 생깁니다.
- 내부 데이터 유출: 관리자 CMS에서 쓰던 검색 조건을 앱 API에 그대로 재사용했다가, 미공개 상태나 관리자 메모 같은 내부 필드가 앱 화면 DTO에 섞여 나갑니다.
- 다음 페이지 유무 불명확: 앱에서 무한 스크롤을 구현해야 하는데 다음 페이지가 있는지 알 수 없어 불필요한 API 호출을 한 번 더 보냅니다.
2. 관리자 목록과 앱 API 목록은 완전히 분리하기
가장 먼저 해야 할 일은 운영자용 CMS 쿼리와 사용자 앱용 API를 분리하는 것입니다. 개발 편하자고 같이 쓰면 백퍼센트 내부 데이터가 샙니다.
| 구분 | 관리자 CMS 목록 | 앱 API 목록 |
| 목적 | 데이터 관리, 검수, 수정 | 공개 데이터 조회 및 빠른 렌더링 |
| 검색 범위 | 내부 상태, 관리자 메모, 삭제 데이터 포함 | 공개 필드 및 노출 가능한 데이터 중심 |
| 필터 조건 | 검수 상태, 미승인, 삭제 여부, 작성자 ID | 카테고리, 지역, 활성화된 태그 |
| 인증 | 관리자 세션 / 토큰 | 공개 API 또는 일반 회원 인증 |
| 응답 구조 | 테이블 표시용 (모든 필드 오픈 가능) | 앱 화면 맞춤형 DTO (필요한 필드만 전제) |
내부 쿼리 빌더 빌드 로직 일부는 재사용할 수 있어도, 최종적으로 클라이언트에 나가는 공개 조건과 응답 모델(DTO)은 무조건 따로 잡는 편이 안전합니다.
3. Pagination 기본값과 상한선(Limit Cap) 걸기
클라이언트에서 보내는 page와 limit은 절대로 그대로 믿으면 안 됩니다. 음수가 들어오거나 터무니없이 큰 값이 들어올 때를 대비해 방어 코드를 짜야 합니다.
아래는 PHP(Laravel 스타일)로 작성한 요청 파라미터 정제 예시입니다.
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
{
// 음수나 0이 들어오면 무조건 1페이지로 고정
$page = max(1, (int) ($input['page'] ?? 1));
// 최소 1건, 최대 50건으로 제한 (서버 부하 방지 기본값은 20)
$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);
}
}
여기서 핵심은 min(50, ...) 처럼 최대 요청 제한(Limit Cap)을 두는 것입니다. 클라이언트 개발자가 실수로 limit=10000을 보내더라도 서버가 알아서 커트해줘야 안전합니다.
4. 정렬(Sort)과 필터(Filter)는 Allowlist로 통제하기
클라이언트가 sort=published_at 처럼 실제 DB 컬럼명을 던지게 하면 DB 구조가 외부에 그대로 노출됩니다. 정렬 키는 무조건 내부에서 매핑하는 Allowlist 방식을 써야 합니다.
정렬 매핑 (SortResolver)
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
{
// 허용되지 않은 키가 들어오면 무조건 기본값('latest')으로 처리
return self::SORTS[$sort] ?? self::SORTS['latest'];
}
}
외부 API 스펙에는 latest, popular 같은 의미 기반의 키만 노출하고, 실제 DB 컬럼명 매핑은 서버 안에서만 처리합니다. 나중에 DB 컬럼명이 바뀌어도 API 계약을 깨지 않고 서버 코드만 고치면 되니 유지보수할 때도 훨씬 편합니다.
필터 검증 (CategoryFilter)
final class CategoryFilter {
private const ALLOWED = ['notice', 'event', 'guide'];
public static function normalize(?string $category): ?string
{
if (!$category) {
return null;
}
// 허용된 카테고리가 아니면 null 처리하거나 400 에러 반환
return in_array($category, self::ALLOWED, true) ? $category : null;
}
}
이상한 필터 값이 들어왔을 때 400 Bad Request를 뱉을지, 아니면 그냥 무시하고 기본 목록을 보여줄지는 기획 요건에 맞추면 됩니다. 보통 일반 검색 화면에서는 잘못된 필터는 무시하고 기본값을 보여주는 게 UX상 매끄럽고, 엄격한 데이터 조회가 필요하다면 에러를 내는 게 좋습니다.
5. 앱 전용 노출 조건 분리하기 (Query Class 활용)
is_published = true, deleted_at IS NULL 같은 노출 조건들을 컨트롤러마다 매번 작성하다 보면, 개발자 휴먼 에러로 특정 API에서 삭제된 글이 노출되는 대형 사고가 칩니다. 조건을 한곳에서 관리하는 쿼리 클래스나 스코프를 활용하는 것이 좋습니다.
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);
}
}
이렇게 비즈니스 로직(공개 조건)을 명확하게 한군데 모아두면, 나중에 "예약 게시물 노출 기능 추가해주세요" 같은 요구사항이 와도 이 클래스 한 곳만 고치면 끝나서 든든합니다.
6. 무한 스크롤을 위한 hasNext 처리 (Limit + 1 전략)
목록을 가져올 때 매번 전체 카운트(SELECT COUNT(*))를 치는 경우가 많습니다. 하지만 데이터가 수만, 수십만 건을 넘어가면 이 count 쿼리 때문에 성능이 뚝 떨어집니다.
전체 개수나 총 페이지 수가 굳이 필요 없는 무한 스크롤 UI라면 Limit + 1 방식을 쓰는 게 성능상 훨씬 이득입니다. 20개를 보여줘야 한다면 DB에서 21개를 조회해 보는 방식입니다.
$offset = ($params->page - 1) * $params->limit;
// limit보다 1개 더 많이 조회해본다
$rows = $query
->offset($offset)
->limit($params->limit + 1)
->get();
// 요청한 limit보다 데이터가 더 많으면 다음 페이지가 있는 것
$hasNext = $rows->count() > $params->limit;
// 실제 사용자에게 줄 데이터는 요청한 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,
],
]);
7. 검색어(Keyword) 처리 시 주의할 점
검색어 파라미터를 받아서 바로 LIKE 조건에 때려 박으면 DB 인덱스를 못 타서 전체 테이블 스캔(Full Table Scan)이 일어날 수 있습니다. 최소한 아래 기준은 잡고 가야 합니다.
- 공백 제거: 앞뒤 공백은 trim()으로 날려버리기.
- 빈 문자열 처리: ?keyword= 처럼 공백만 넘어오면 null로 취급해서 검색 조건에서 제외하기.
- 길이 제한: 너무 긴 검색어(예: 100자 이상)가 들어오면 서버 단에서 자르거나 요청 거부하기.
- 필드 제한: 앱 API에서는 관리자 메모나 내부 시스템 코드는 검색 대상에서 철저히 제외하고, title이나 content 같은 공개 필드로만 제한하기.
데이터가 진짜 많아지는 시점에는 어차피 인덱스 튜닝이나 OpenSearch 같은 검색 엔진을 따로 붙여야 하지만, 그 전단계인 기본 API 설계 단계에서도 이 정도 방어 코드는 필수입니다.
8. 최종 응답 구조(Contract) 통일하기
API마다 응답 구조가 다르면 앱(프론트엔드) 공통 pagination 컴포넌트를 만들 수가 없습니다. 어떤 건 items, 어떤 건 list, 어떤 건 rows로 쓰지 말고 하나로 통일합시다.
{
"data": [
{
"id": 1,
"title": "서버 점검 안내",
"summary": "안정적인 서비스를 위한 정기 점검 안내입니다.",
"publishedAt": "2026-06-11T23:00:00+09:00"
}
],
"meta": {
"page": 1,
"limit": 20,
"hasNext": false
}
}
실제 목록 데이터는 data 배열에 담고, 페이징 세부 정보는 meta 객체로 밀어 넣는 구조를 추천합니다. 이렇게 짜놓으면 프론트엔드 단에서도 데이터 파싱하는 공통 모듈 만들기가 아주 편해집니다.
9. 구현 후 셀프 테스트 체크리스트
코드를 다 짰다면 아래 케이스들을 하나씩 던져보면서 의도한 대로 방어가 되는지 확인해 봅니다. 보통 테스트 코드(Feature Test)로 짜두면 배포할 때마다 안심할 수 있습니다.
- [ ] page=0 이나 page=-1 을 보냈을 때 에러 없이 1페이지로 잘 잡히는가?
- [ ] limit=5000 을 보냈을 때 서버 상한선(예: 50)으로 제한되어 잘라 나오는가?
- [ ] sort=이상한값 을 던졌을 때 에러 안 나고 기본값(latest)으로 정렬되는가?
- [ ] category=all 이나 없는 값을 넣었을 때 의도한 필터 처리가 먹히는가?
- [ ] 비공개 상태, 삭제된 데이터, 미래 게시 예정 데이터가 목록에 절대 섞여 나오지 않는가?
- [ ] 데이터가 딱 limit 개수만큼 있을 때와 그보다 많을 때 hasNext 값이 정확하게 떨어지는가?
- [ ] 응답 DTO에 DB 컬럼명이나 내부 시스템 필드가 새어 나가지 않는가?
마치며
목록 조회 API는 백엔드 개발할 때 워낙 자주 만들다 보니 대충 손 가공대로 만들기 쉬운 영역입니다. 하지만 서비스 전체에서 가장 많이 호출되는 묵직한 녀석이기도 하죠.
초반에 귀찮더라도 QueryParam 객체나 Allowlist 구조를 명확히 잡아두면, 나중에 서비스가 커지고 필터가 수십 개로 늘어나도 흔들리지 않는 튼튼한 백엔드를 유지할 수 있습니다. 나중에 다른 프로젝트 시작할 때 템플릿처럼 긁어 쓰려고 정리해 둡니다. 끝.
'개발 노트 > Backend,CMS,API' 카테고리의 다른 글
| 모바일 앱 API 오류 응답 형식을 하나로 고정해야 하는 이유와 설계 방법 (Laravel 예시) (0) | 2026.05.21 |
|---|---|
| [Next.js 전환] 기존 Backend/CMS를 Next.js로 바꿀 때 앱 API 경계 유지하는 방법 (0) | 2026.05.21 |
| [Backend] API 캐시(Cache)와 레이트 리밋(Rate Limit) 적용할 때 놓치는 실무 운영 기준 정리 (0) | 2026.05.21 |
| [API 개발] DB Model을 그대로 반환하면 안 되는 이유 (DTO와 Serializer로 응답 계약 고정하기) (0) | 2026.05.20 |
| [백엔드 설계] 관리자 CMS와 모바일 앱 API 코드를 처음부터 분리해야 하는 이유 (0) | 2026.05.20 |