TIL - 20260818

juni·2026년 8월 18일

TIL

목록 보기
433/468

0818 데이터베이스 실무 심화 (6/N): Pagination, Sorting, 관리자 목록 조회 최적화


✅ 1. 관리자 목록 조회가 중요한 이유

  • 관리자 화면에서 가장 자주 쓰는 기능은 대부분 목록 조회입니다.
  • 상담 목록, 주문 목록, 상품 목록, 알림톡 발송 이력, 엑셀 ExportJob 목록, 관리자 작업 로그는 모두 “검색/필터/정렬/페이지네이션”이 핵심입니다.
  • 목록 조회가 느리면 운영자가 매일 불편을 느끼고, 데이터가 많아질수록 장애처럼 체감될 수 있습니다.
관리자 목록 화면
  ↓
검색
  ↓
필터
  ↓
정렬
  ↓
페이지네이션
  ↓
상세/상태 변경

➕ 1-1. 목록 조회가 느려지는 이유

데이터가 많아짐
검색 조건이 복잡함
인덱스가 없음
불필요한 relation을 많이 include함
count 쿼리가 무거움
offset이 너무 커짐
정렬 기준이 비효율적임
LIKE 검색이 많음
  • 처음에는 데이터가 적어서 문제를 못 느낍니다.
  • 하지만 상담/주문 데이터가 쌓이면 같은 코드도 갑자기 느려질 수 있습니다.

✅ 2. Pagination이란 무엇인가?

  • Pagination은 목록 데이터를 한 번에 모두 가져오지 않고 페이지 단위로 나누어 가져오는 방식입니다.
  • 예를 들어 상담 데이터가 10만 건이어도 한 화면에서는 20건, 50건, 100건만 보여주면 됩니다.
전체 상담 100,000건
  ↓
1페이지 50건
  ↓
필요한 페이지의 데이터만 조회

➕ 2-1. Pagination이 필요한 이유

브라우저 렌더링 부담 감소
API 응답 크기 감소
DB 조회 부담 감소
관리자 화면 사용성 개선
엑셀 다운로드와 일반 목록 조회 분리

➕ 2-2. 페이지 크기 기준

고객 화면:
10~20개

관리자 목록:
20~100개

엑셀 다운로드:
페이지네이션이 아니라 별도 ExportJob
  • 관리자 목록에서 한 번에 1,000건씩 보여주는 것은 보통 좋지 않습니다.
  • 대량 데이터가 필요하면 목록 조회가 아니라 ExportJob으로 분리하는 것이 좋습니다.

✅ 3. Offset Pagination

  • 가장 흔한 방식은 page와 limit를 받아 skip과 take로 조회하는 방식입니다.
  • Prisma에서는 skip, take를 사용합니다.

➕ 3-1. 기본 개념

page = 1, limit = 20
skip = 0

page = 2, limit = 20
skip = 20

page = 3, limit = 20
skip = 40

➕ 3-2. Prisma 예시

const page = 1;
const limit = 20;
const skip = (page - 1) * limit;

const consults = await prisma.consult.findMany({
  skip,
  take: limit,
  orderBy: {
    createdAt: 'desc',
  },
});

➕ 3-3. 장점

구현이 쉬움
페이지 번호 UI 만들기 쉬움
전체 개수와 함께 쓰기 좋음
관리자 화면에 익숙함

➕ 3-4. 단점

뒤 페이지로 갈수록 느려질 수 있음
데이터가 추가/삭제되면 페이지 결과가 밀릴 수 있음
대량 데이터에서 skip 비용 증가
  • 관리자 화면 초반에는 offset pagination으로 충분한 경우가 많습니다.
  • 다만 데이터가 많아지고 깊은 페이지 조회가 많아지면 cursor pagination을 고려해야 합니다.

✅ 4. Cursor Pagination

  • Cursor Pagination은 마지막으로 본 데이터의 기준값을 다음 조회에 넘기는 방식입니다.
  • 무한 스크롤이나 최신순 목록에 적합합니다.
첫 요청:
최신 20건 조회

다음 요청:
마지막 row의 id 또는 createdAt을 cursor로 전달

다음 20건 조회

➕ 4-1. Prisma 예시

const consults = await prisma.consult.findMany({
  take: 20,
  cursor: cursorId
    ? {
        id: cursorId,
      }
    : undefined,
  skip: cursorId ? 1 : 0,
  orderBy: {
    id: 'desc',
  },
});

➕ 4-2. 장점

대량 데이터에서 성능이 안정적
뒤 페이지로 가도 offset보다 효율적
실시간으로 데이터가 추가되어도 비교적 안정적

➕ 4-3. 단점

페이지 번호 UI 구현이 어려움
특정 페이지로 바로 이동하기 어려움
정렬 조건이 복잡하면 설계가 까다로움

➕ 4-4. 실무 기준

관리자 번호 페이지:
offset pagination

무한 스크롤:
cursor pagination

대량 로그:
cursor pagination

상담 목록 초기:
offset으로 시작 가능

데이터가 매우 많아짐:
cursor 전환 검토
  • 관리자 상담 목록처럼 “1페이지, 2페이지, 3페이지”가 필요한 화면은 offset이 편합니다.
  • 로그나 활동 내역처럼 계속 아래로 내려보는 화면은 cursor가 더 적합합니다.

✅ 5. Sorting이란 무엇인가?

  • Sorting은 데이터를 어떤 기준으로 정렬할지 정하는 것입니다.
  • 관리자 목록에서는 최신순, 오래된순, 상태 변경순, 이름순, 상품명순, 신청일순 등을 사용할 수 있습니다.
정렬 기준:
createdAt desc
status asc
updatedAt desc
productName asc

➕ 5-1. 기본 정렬 기준

상담 목록:
createdAt desc

주문 목록:
createdAt desc 또는 updatedAt desc

상태 변경 이력:
createdAt desc

관리자 로그:
createdAt desc

상품 목록:
displayOrder asc, createdAt desc

➕ 5-2. 정렬 기준이 중요한 이유

운영자가 최근 데이터를 먼저 봐야 함
상태별 처리 우선순위를 정할 수 있음
동일 조건에서 페이지 결과가 흔들리지 않아야 함
인덱스와 연결되어 성능에 영향
  • 정렬 기준이 불명확하면 페이지를 넘길 때 데이터가 중복되거나 빠져 보일 수 있습니다.
  • 특히 createdAt만으로 정렬하면 같은 시간 데이터가 많을 때 순서가 불안정할 수 있습니다.

✅ 6. 안정적인 정렬 기준

  • 페이지네이션에서는 정렬이 안정적이어야 합니다.
  • 같은 값이 여러 개 있을 수 있는 컬럼만으로 정렬하면 결과 순서가 흔들릴 수 있습니다.

➕ 6-1. 불안정한 정렬

orderBy: {
  createdAt: 'desc',
}

문제:

createdAt이 같은 row가 여러 개 있을 수 있음
페이지 이동 시 순서가 흔들릴 수 있음
일부 데이터가 중복되거나 빠져 보일 수 있음

➕ 6-2. 안정적인 정렬

orderBy: [
  {
    createdAt: 'desc',
  },
  {
    id: 'desc',
  },
]

➕ 6-3. 기준

createdAt 정렬에는 id를 보조 정렬로 추가
updatedAt 정렬에도 id를 보조 정렬로 추가
동일 값이 가능한 컬럼은 단독 정렬 지양
정렬 기준과 인덱스 순서를 함께 고려
  • 실무에서는 createdAt desc, id desc 조합을 많이 씁니다.
  • 정렬이 안정적이어야 페이지네이션도 안정적입니다.

✅ 7. 검색 조건 설계

  • 관리자 목록은 단순 조회보다 검색 조건이 중요합니다.
  • 전화번호, 고객명, 상품명, 상태, 통신사, 유입경로, 날짜 범위 등을 조합해서 조회할 수 있어야 합니다.

➕ 7-1. 상담 목록 검색 조건 후보

keyword
phone
customerName
status
carrier
productId
source
utmCampaign
dateFrom
dateTo
assignedAdminId

➕ 7-2. 주문 목록 검색 조건 후보

orderNo
customerName
phone
status
carrier
productName
openedAt
createdAt
adminId

➕ 7-3. 검색 조건 기준

운영자가 실제로 찾는 기준인가?
DB index를 만들 수 있는 조건인가?
부분 검색이 필요한가?
정확히 일치해야 하는가?
여러 조건을 조합할 수 있는가?
  • 검색 조건을 많이 제공하는 것만이 좋은 것은 아닙니다.
  • 운영자가 실제로 쓰는 조건을 중심으로 구성해야 합니다.

✅ 8. Prisma where 조건 구성

  • Prisma에서는 동적으로 where 객체를 만들어 검색 조건을 구성할 수 있습니다.
const where: Prisma.ConsultWhereInput = {
  ...(status && {
    status,
  }),
  ...(productId && {
    productId,
  }),
  ...(source && {
    source,
  }),
};

➕ 8-1. 날짜 범위 조건

const where: Prisma.ConsultWhereInput = {
  createdAt: {
    gte: dateFrom,
    lte: dateTo,
  },
};

➕ 8-2. keyword 검색

const where: Prisma.ConsultWhereInput = {
  OR: [
    {
      customerName: {
        contains: keyword,
        mode: 'insensitive',
      },
    },
    {
      phone: {
        contains: keyword,
      },
    },
  ],
};

➕ 8-3. 주의

contains 검색은 인덱스를 잘 못 탈 수 있음
전화번호는 정규화된 값으로 검색 고려
대소문자 검색은 DB collation/인덱스 고려
OR 조건이 많으면 쿼리가 무거워질 수 있음
  • contains는 편하지만 데이터가 많아지면 느려질 수 있습니다.
  • 자주 쓰는 검색 조건은 정확 검색 또는 별도 검색용 컬럼을 고려해야 합니다.

✅ 9. 전화번호 검색 최적화

  • 상담/주문 관리자 화면에서는 전화번호 검색이 매우 자주 쓰입니다.
  • 사용자는 01012345678, 010-1234-5678, 1234 등 다양한 방식으로 검색할 수 있습니다.

➕ 9-1. 전화번호 저장 기준

입력값:
010-1234-5678

정규화 저장:
01012345678

표시:
010-1234-5678 또는 010****5678

➕ 9-2. 검색용 컬럼

phone:
원본 또는 암호화/마스킹 기준

phoneNormalized:
01012345678

phoneLast4:
5678

➕ 9-3. Prisma 검색 예시

const normalizedPhone = keyword.replace(/\D/g, '');

const where: Prisma.ConsultWhereInput = {
  OR: [
    {
      phoneNormalized: {
        contains: normalizedPhone,
      },
    },
    {
      phoneLast4: normalizedPhone.length === 4 ? normalizedPhone : undefined,
    },
  ],
};

➕ 9-4. 주의

전화번호는 개인정보
로그에 원본 출력 금지
검색 권한 제한 필요
암호화하면 contains 검색 어려움
마스킹과 검색 요구를 함께 고려
  • 전화번호 검색은 운영 편의와 개인정보 보호가 충돌할 수 있습니다.
  • 최소한 로그에는 전화번호 원본이 남지 않게 해야 합니다.

✅ 10. 날짜 범위 검색

  • 관리자 목록에서 날짜 범위 검색은 거의 필수입니다.
  • 오늘 신청, 이번 주 신청, 특정 캠페인 기간 신청 등을 확인해야 하기 때문입니다.

➕ 10-1. 날짜 범위 기준

createdAt:
접수일 기준

updatedAt:
최근 수정일 기준

openedAt:
개통일 기준

canceledAt:
취소일 기준

➕ 10-2. 날짜 검색 예시

const where: Prisma.ConsultWhereInput = {
  createdAt: {
    gte: startOfDay,
    lt: nextDayStart,
  },
};

➕ 10-3. lte보다 lt를 선호하는 이유

2026-08-18 00:00:00 이상
2026-08-19 00:00:00 미만

장점:
하루 전체 범위 표현 명확
밀리초/마이크로초 끝값 문제 감소

➕ 10-4. 주의

DB 저장 timezone 기준
관리자 화면 표시 timezone
검색 시작/종료 시간 변환
한국 시간 기준 일자 검색
  • DB에는 UTC로 저장하고, 화면에는 한국 시간으로 표시하는 구조가 많습니다.
  • 날짜 검색에서는 “사용자가 선택한 날짜”를 어떤 timezone 기준으로 변환할지 명확해야 합니다.

✅ 11. Index란 무엇인가?

  • Index는 DB가 데이터를 빠르게 찾기 위해 사용하는 구조입니다.
  • 책의 목차나 색인처럼, 모든 row를 처음부터 끝까지 훑지 않게 도와줍니다.
인덱스 없음:
전체 테이블 스캔

인덱스 있음:
조건에 맞는 위치로 빠르게 접근

➕ 11-1. 관리자 목록 인덱스 후보

consults.createdAt
consults.status
consults.status + createdAt
consults.phoneNormalized
consults.productId
consults.source
consults.assignedAdminId
orders.status + createdAt
audit_logs.actorId + createdAt

➕ 11-2. Prisma 인덱스 예시

model Consult {
  id              Int           @id @default(autoincrement())
  customerName    String
  phoneNormalized String
  status          ConsultStatus
  productId       Int?
  source          String?
  createdAt       DateTime      @default(now())
  updatedAt       DateTime      @updatedAt

  @@index([createdAt])
  @@index([status, createdAt])
  @@index([phoneNormalized])
  @@index([productId, createdAt])
  @@index([source, createdAt])
}

➕ 11-3. 주의

인덱스는 많다고 좋은 게 아님
쓰기/수정 성능 비용이 생김
저장공간이 증가함
실제 조회 패턴 기준으로 추가해야 함
  • 인덱스는 화면 요구사항을 보고 설계해야 합니다.
  • “검색 조건으로 자주 쓰이고 데이터가 많은 컬럼”부터 검토하는 것이 좋습니다.

✅ 12. 복합 인덱스

  • 복합 인덱스는 여러 컬럼을 묶은 인덱스입니다.
  • 관리자 목록에서는 상태 + 날짜, 상품 + 날짜, 관리자 + 날짜 조합이 자주 쓰입니다.

➕ 12-1. 예시

@@index([status, createdAt])
@@index([productId, createdAt])
@@index([assignedAdminId, createdAt])

➕ 12-2. 사용 예시

const consults = await prisma.consult.findMany({
  where: {
    status: 'NEW',
    createdAt: {
      gte: startDate,
      lt: endDate,
    },
  },
  orderBy: {
    createdAt: 'desc',
  },
});

➕ 12-3. 컬럼 순서가 중요한 이유

(status, createdAt) 인덱스:
status로 먼저 좁히고 createdAt 정렬/범위 조회

(createdAt, status) 인덱스:
날짜로 먼저 좁히고 status 조건 적용

쿼리 패턴에 따라 효율이 달라짐
  • 복합 인덱스는 컬럼 순서가 중요합니다.
  • 자주 쓰는 where 조건과 orderBy 기준을 보고 결정해야 합니다.

✅ 13. Count 쿼리 문제

  • 관리자 페이지에서 전체 개수를 보여주려면 count 쿼리가 필요합니다.
  • 하지만 조건이 복잡하고 데이터가 많으면 count도 느려질 수 있습니다.

➕ 13-1. 일반적인 목록 응답

{
  "items": [],
  "page": 1,
  "limit": 20,
  "total": 1352,
  "totalPages": 68
}

➕ 13-2. Prisma count 예시

const [items, total] = await prisma.$transaction([
  prisma.consult.findMany({
    where,
    skip,
    take: limit,
    orderBy,
  }),
  prisma.consult.count({
    where,
  }),
]);

➕ 13-3. 주의

count도 where 조건 영향을 받음
검색 조건이 복잡하면 count가 느려질 수 있음
대량 데이터에서는 totalPages 계산이 부담
실시간 정확한 total이 꼭 필요한지 검토
  • 관리자 화면에서는 전체 개수가 유용합니다.
  • 하지만 아주 큰 로그 테이블에서는 정확한 count보다 “다음 페이지 있음” 정도로 줄이는 것도 방법입니다.

✅ 14. 목록 조회 응답 구조

  • 프론트와 백엔드가 목록 응답 구조를 일관되게 맞추면 관리가 쉬워집니다.
export type PaginatedResponse<T> = {
  items: T[];
  page: number;
  limit: number;
  total: number;
  totalPages: number;
};

➕ 14-1. 백엔드 응답 예시

{
  "items": [
    {
      "id": 1,
      "customerName": "김고객",
      "phoneMasked": "010****5678",
      "status": "NEW",
      "productName": "Galaxy S 시리즈",
      "createdAt": "2026-08-18T01:05:00.000Z"
    }
  ],
  "page": 1,
  "limit": 20,
  "total": 124,
  "totalPages": 7
}

➕ 14-2. 기준

items:
현재 페이지 데이터

page:
현재 페이지 번호

limit:
페이지 크기

total:
검색 조건 기준 전체 개수

totalPages:
전체 페이지 수
  • 응답 구조가 통일되면 프론트 테이블 컴포넌트 재사용이 쉬워집니다.
  • 관리자 화면마다 응답 모양이 다르면 유지보수가 어려워집니다.

✅ 15. 목록 DTO 설계

  • 백엔드에서는 query parameter를 DTO로 받고 검증하는 것이 좋습니다.
  • page, limit, sort, filter 조건을 명확히 관리할 수 있습니다.

➕ 15-1. DTO 예시

export class ConsultListQueryDto {
  page?: number = 1;
  limit?: number = 20;
  keyword?: string;
  status?: ConsultStatus;
  productId?: number;
  source?: string;
  dateFrom?: string;
  dateTo?: string;
  sortBy?: 'createdAt' | 'updatedAt' | 'status';
  sortOrder?: 'asc' | 'desc';
}

➕ 15-2. 검증 기준

page는 1 이상
limit는 최대값 제한
sortBy는 허용된 컬럼만
sortOrder는 asc/desc만
status는 enum만
날짜 형식 검증
keyword 길이 제한

➕ 15-3. limit 제한

const limit = Math.min(query.limit ?? 20, 100);
  • 사용자가 limit=10000을 보내도 그대로 처리하면 안 됩니다.
  • 관리자 화면이라도 API에서 최대 limit를 제한해야 합니다.

✅ 16. SortBy 화이트리스트

  • 정렬 컬럼을 사용자가 직접 보내게 할 때는 반드시 허용 목록을 둬야 합니다.
  • 아무 컬럼이나 정렬하게 하면 성능과 보안 문제가 생길 수 있습니다.

➕ 16-1. 위험한 방식

const orderBy = {
  [query.sortBy]: query.sortOrder,
};

문제:

허용하지 않은 컬럼 정렬 가능
인덱스 없는 컬럼 정렬로 성능 저하
예상치 못한 쿼리 구조

➕ 16-2. 안전한 방식

const allowedSortFields = {
  createdAt: true,
  updatedAt: true,
  status: true,
} as const;

const sortBy = query.sortBy && allowedSortFields[query.sortBy]
  ? query.sortBy
  : 'createdAt';

const sortOrder = query.sortOrder === 'asc' ? 'asc' : 'desc';

const orderBy = [
  {
    [sortBy]: sortOrder,
  },
  {
    id: sortOrder,
  },
] as const;

➕ 16-3. 기준

정렬 가능한 컬럼만 허용
인덱스 여부 고려
기본 정렬 제공
보조 정렬 id 추가
  • 정렬은 사용자 편의 기능이지만 DB 성능에 직접 영향을 줍니다.
  • 허용된 정렬 기준만 제공하는 것이 안전합니다.

✅ 17. Relation include 최적화

  • Prisma에서 include를 많이 쓰면 편하지만, 불필요한 데이터를 많이 가져올 수 있습니다.
  • 관리자 목록에서는 상세 정보 전체보다 목록에 필요한 최소 필드만 가져오는 것이 좋습니다.

➕ 17-1. 과한 include 예시

const consults = await prisma.consult.findMany({
  include: {
    product: true,
    statusHistories: true,
    auditLogs: true,
    notificationLogs: true,
  },
});

문제:

응답 크기 증가
쿼리 복잡도 증가
프론트 렌더링 부담 증가
목록 화면에 필요 없는 데이터 포함

➕ 17-2. select로 최소 필드 조회

const consults = await prisma.consult.findMany({
  select: {
    id: true,
    customerName: true,
    phoneMasked: true,
    status: true,
    createdAt: true,
    product: {
      select: {
        id: true,
        modelName: true,
        carrier: true,
      },
    },
  },
});

➕ 17-3. 기준

목록:
최소 필드만

상세:
상세 API에서 필요한 relation 조회

이력:
상세 모달 또는 이력 탭에서 별도 조회

엑셀:
ExportJob에서 별도 조회
  • 목록 API와 상세 API를 분리하면 성능 관리가 쉬워집니다.
  • 목록 조회에서 모든 relation을 한 번에 가져오려는 습관은 피해야 합니다.

✅ 18. N+1 문제

  • N+1 문제는 목록 데이터를 가져온 뒤 각 row마다 추가 쿼리가 반복되는 문제입니다.
  • 예를 들어 상담 20건을 조회하고, 각 상담마다 상품 정보를 따로 조회하면 쿼리가 21번 발생합니다.
상담 목록 1번 조회
  ↓
상담 20건
  ↓
각 상담의 상품 조회 20번
  ↓
총 21번 쿼리

➕ 18-1. 나쁜 예시

const consults = await prisma.consult.findMany({ take: 20 });

const result = await Promise.all(
  consults.map(async (consult) => {
    const product = await prisma.product.findUnique({
      where: { id: consult.productId },
    });

    return {
      ...consult,
      product,
    };
  }),
);

➕ 18-2. 개선 예시

const consults = await prisma.consult.findMany({
  take: 20,
  include: {
    product: {
      select: {
        id: true,
        modelName: true,
        carrier: true,
      },
    },
  },
});

➕ 18-3. 기준

목록 row마다 반복 쿼리 금지
필요한 relation은 include/select로 한 번에 조회
복잡한 집계는 별도 쿼리나 materialized view 검토
  • N+1은 데이터가 적을 때는 잘 안 보입니다.
  • 데이터가 늘면 갑자기 목록 조회가 느려지는 대표 원인입니다.

✅ 19. 목록 API와 상세 API 분리

  • 관리자 화면에서는 목록에서 필요한 데이터와 상세 모달에서 필요한 데이터가 다릅니다.
  • 목록 API에 모든 상세 데이터를 담으면 매번 무거워집니다.

➕ 19-1. 권장 구조

GET /admin/consults
  → 목록용 최소 데이터

GET /admin/consults/:id
  → 상세 정보

GET /admin/consults/:id/histories
  → 상태 변경 이력

➕ 19-2. 목록 API 데이터

id
customerName 또는 maskedName
phoneMasked
status
productName
carrier
source
createdAt
assignedAdminName

➕ 19-3. 상세 API 데이터

상담 전체 정보
상담 메모
선택 상품 snapshot
유입 상세
상태 변경 이력
알림 발송 이력
관리자 처리 이력
  • 목록은 가볍게, 상세는 필요할 때만 무겁게 가져오는 것이 좋습니다.
  • 관리자 화면 체감 속도에 큰 차이를 만듭니다.

✅ 20. 필터 상태와 프론트 URL 동기화

  • 관리자 목록에서는 검색/필터/정렬/페이지 상태를 URL query로 관리하면 좋습니다.
  • 새로고침, 공유, 뒤로가기, 작업 복귀가 쉬워집니다.

➕ 20-1. 예시 URL

 /admin/consults?page=2&limit=50&status=NEW&source=naver&sortBy=createdAt&sortOrder=desc

➕ 20-2. 장점

새로고침해도 필터 유지
뒤로가기 동작 자연스러움
운영자가 특정 조건 URL 공유 가능
QA 재현 쉬움
상태 관리 단순화

➕ 20-3. 주의

개인정보 검색어가 URL에 남을 수 있음
너무 긴 query string 주의
기본값 처리 명확히
잘못된 query 값 검증
  • 전화번호 같은 개인정보를 URL에 그대로 남기는 것은 조심해야 합니다.
  • 내부 관리자 화면이라도 검색어 저장 범위를 생각해야 합니다.

✅ 21. TanStack Query와 목록 조회

  • 프론트에서 TanStack Query를 사용한다면 검색 조건을 queryKey에 정확히 포함해야 합니다.
  • 그래야 필터 변경 시 올바른 데이터가 캐싱되고 refetch됩니다.

➕ 21-1. queryKey 예시

const queryKey = [
  'admin',
  'consults',
  {
    page,
    limit,
    keyword,
    status,
    source,
    sortBy,
    sortOrder,
    dateFrom,
    dateTo,
  },
];

➕ 21-2. 주의

필터 조건 누락 시 캐시 꼬임
객체 key 순서와 안정성 고려
undefined/null 처리 기준 통일
페이지 변경과 필터 변경 구분
필터 변경 시 page를 1로 초기화

➕ 21-3. 상태 변경 후 invalidate

await updateConsultStatusMutation.mutateAsync(payload);

queryClient.invalidateQueries({
  queryKey: ['admin', 'consults'],
});
  • 상태 변경 후 목록 캐시를 갱신하지 않으면 화면에 이전 상태가 남아 보일 수 있습니다.
  • 현재 상세 query와 목록 query를 함께 갱신할지 기준을 정해야 합니다.

✅ 22. 상태 변경 후 목록 유지

  • 관리자가 목록에서 상담 상태를 바꾼 뒤, 현재 페이지와 필터가 유지되어야 합니다.
  • 상태 변경 후 무조건 1페이지로 튕기면 사용성이 나빠집니다.

➕ 22-1. 좋은 UX

상태 변경
  ↓
현재 필터/페이지 유지
  ↓
변경된 row 상태 갱신
  ↓
필요 시 현재 필터에서 사라짐

예시:

현재 필터:
status = NEW

상담 상태를 NEW → CALLED로 변경

결과:
해당 row가 NEW 목록에서 사라지는 것이 자연스러움

➕ 22-2. 주의

필터 조건에 따라 row가 사라질 수 있음
사용자에게 성공 toast 표시
페이지가 비면 이전 페이지로 이동 고려
목록 count 갱신 필요
  • 상태 변경 후 row가 사라지는 것은 버그가 아닐 수 있습니다.
  • 다만 운영자가 이해할 수 있게 성공 메시지와 UI 반응이 명확해야 합니다.

✅ 23. 엑셀 다운로드와 목록 조회 분리

  • 관리자 목록에서 “현재 검색 조건 전체를 엑셀로 다운로드”하는 기능은 자주 필요합니다.
  • 하지만 엑셀 다운로드를 일반 목록 API와 같이 처리하면 위험합니다.

➕ 23-1. 위험한 방식

GET /admin/consults?limit=100000
  ↓
백엔드에서 한 번에 전체 조회
  ↓
엑셀 생성
  ↓
응답 대기

문제:

API timeout
메모리 사용량 증가
DB 부하
관리자 화면 멈춤
다른 요청 영향

➕ 23-2. 권장 방식

관리자가 엑셀 다운로드 요청
  ↓
export_jobs 생성
  ↓
Worker가 검색 조건 기준으로 파일 생성
  ↓
S3 업로드
  ↓
다운로드 링크 제공

➕ 23-3. 기준

목록 API:
페이지 단위 조회

엑셀 Export:
별도 Job 처리

검색 조건:
ExportJob에 snapshot으로 저장

권한:
엑셀 다운로드 권한 별도 관리
  • 목록 조회와 엑셀 다운로드는 목적이 다릅니다.
  • 대량 데이터는 반드시 별도 작업으로 분리하는 것이 좋습니다.

✅ 24. 관리자 목록 성능 점검 순서

  • 목록이 느릴 때는 감으로 인덱스를 추가하지 말고 순서대로 확인해야 합니다.
1. 어떤 API가 느린지 확인
2. 요청 query parameter 확인
3. where/orderBy/include 확인
4. 실제 SQL 확인
5. EXPLAIN으로 실행 계획 확인
6. 인덱스 후보 검토
7. count 쿼리 분리/최적화 검토
8. 응답 필드 줄이기
9. 프론트 렌더링 비용 확인

➕ 24-1. 확인할 지표

API 응답 시간
DB query 시간
응답 payload 크기
items 개수
total count 시간
프론트 렌더링 시간
브라우저 메모리 사용

➕ 24-2. 자주 하는 실수

무조건 인덱스만 추가
include를 줄이지 않음
count 쿼리 비용 무시
limit 제한 없음
검색어 contains 남발
정렬 기준 불안정
프론트 테이블 렌더링 병목 무시
  • 목록 성능은 DB만의 문제가 아닙니다.
  • API 응답 크기와 프론트 렌더링도 같이 봐야 합니다.

✅ 25. 관리자 목록 API 설계 예시

➕ 25-1. 요청

GET /admin/consults?page=1&limit=20&status=NEW&source=naver&sortBy=createdAt&sortOrder=desc

➕ 25-2. Service 예시

async getConsultList(query: ConsultListQueryDto) {
  const page = Math.max(query.page ?? 1, 1);
  const limit = Math.min(query.limit ?? 20, 100);
  const skip = (page - 1) * limit;

  const where: Prisma.ConsultWhereInput = {
    ...(query.status && { status: query.status }),
    ...(query.source && { source: query.source }),
    ...(query.productId && { productId: query.productId }),
    ...(query.dateFrom || query.dateTo
      ? {
          createdAt: {
            ...(query.dateFrom && { gte: new Date(query.dateFrom) }),
            ...(query.dateTo && { lt: new Date(query.dateTo) }),
          },
        }
      : {}),
  };

  const allowedSortFields = ['createdAt', 'updatedAt', 'status'] as const;
  const sortBy = allowedSortFields.includes(query.sortBy as any)
    ? query.sortBy
    : 'createdAt';

  const sortOrder = query.sortOrder === 'asc' ? 'asc' : 'desc';

  const [items, total] = await this.prisma.$transaction([
    this.prisma.consult.findMany({
      where,
      skip,
      take: limit,
      orderBy: [
        { [sortBy]: sortOrder },
        { id: sortOrder },
      ],
      select: {
        id: true,
        customerName: true,
        phoneMasked: true,
        status: true,
        source: true,
        createdAt: true,
        product: {
          select: {
            id: true,
            modelName: true,
            carrier: true,
          },
        },
      },
    }),
    this.prisma.consult.count({ where }),
  ]);

  return {
    items,
    page,
    limit,
    total,
    totalPages: Math.ceil(total / limit),
  };
}

➕ 25-3. 개선 포인트

limit 최대값 제한
where 동적 구성
sortBy 화이트리스트
안정적 보조 정렬 id 추가
select로 필드 최소화
findMany와 count 조건 일치
  • 이 구조를 기본으로 두면 관리자 목록 API를 통일하기 좋습니다.
  • 실제 프로젝트에서는 날짜 변환, keyword 검색, 권한 조건을 추가하면 됩니다.

✅ 26. 실무 체크리스트

➕ 26-1. Pagination 체크리스트

  1. 목록 API에 page/limit가 있는가?
  2. limit 최대값을 제한하는가?
  3. 기본 page와 limit가 정해져 있는가?
  4. offset 방식이 현재 데이터 규모에 적합한가?
  5. 로그성 대량 데이터는 cursor 방식을 고려했는가?
  6. 전체 count가 꼭 필요한 화면인가?
  7. 페이지 변경 시 검색 조건이 유지되는가?
  8. 필터 변경 시 page를 1로 초기화하는가?

➕ 26-2. Sorting 체크리스트

  1. 기본 정렬 기준이 있는가?
  2. 정렬 가능한 컬럼이 제한되어 있는가?
  3. createdAt + id 같은 안정적 정렬을 사용하는가?
  4. 정렬 기준에 맞는 인덱스를 고려했는가?
  5. 인덱스 없는 컬럼 정렬을 허용하지 않는가?
  6. 같은 조건에서 페이지 결과가 흔들리지 않는가?
  7. 프론트 정렬과 백엔드 정렬 기준이 일치하는가?
  8. 상태별 우선순위 정렬이 필요한지 검토했는가?

➕ 26-3. 검색/필터 체크리스트

  1. 운영자가 실제로 쓰는 검색 조건인가?
  2. 전화번호는 정규화되어 저장되는가?
  3. 날짜 범위는 timezone 기준이 명확한가?
  4. 상태/통신사/유입경로 필터가 enum 또는 코드값으로 관리되는가?
  5. keyword 검색이 너무 무겁지 않은가?
  6. OR 조건이 과도하지 않은가?
  7. 개인정보 검색어가 로그/URL에 남지 않는가?
  8. 검색 조건과 ExportJob 조건이 일치하는가?

➕ 26-4. 성능 체크리스트

  1. 목록 API와 상세 API가 분리되어 있는가?
  2. 목록에서 불필요한 include를 하지 않는가?
  3. select로 필요한 필드만 가져오는가?
  4. N+1 쿼리가 발생하지 않는가?
  5. count 쿼리가 너무 느리지 않은가?
  6. 응답 payload가 과도하지 않은가?
  7. 프론트 테이블 렌더링이 느리지 않은가?
  8. EXPLAIN으로 느린 쿼리를 확인할 수 있는가?

✅ 27. AI에게 관리자 목록 조회 최적화를 물어볼 때 좋은 질문법

NestJS + Prisma + PostgreSQL 기반 관리자 상담 목록 조회를 최적화하고 싶어.

서비스 상황:
1. 상담 데이터가 계속 쌓이고 있음
2. 관리자는 상담 목록에서 검색/필터/정렬/페이지네이션을 사용함
3. 주요 검색 조건은 전화번호, 고객명, 상태, 상품, 유입 source, 신청일 범위임
4. 기본 정렬은 createdAt desc임
5. 상태 변경 후 목록 캐시를 갱신해야 함
6. 프론트는 React + TanStack Query를 사용함
7. 목록에서는 최소 데이터만 보여주고, 상세 모달에서 이력과 상세 정보를 조회하고 싶음
8. 엑셀 다운로드는 전체 조건 기준으로 별도 처리하고 싶음
9. 개인정보와 전화번호 검색도 고려해야 함

요청:
- offset pagination과 cursor pagination 중 어떤 방식이 적합한지
- 목록 API 응답 구조
- Prisma where/orderBy/select 예시
- sortBy 화이트리스트 설계
- 전화번호 검색 최적화
- 날짜 범위 timezone 처리 기준
- index 후보
- count 쿼리 주의점
- 목록 API와 상세 API 분리 기준
- TanStack Query queryKey 설계
- 상태 변경 후 invalidate 전략
- 엑셀 ExportJob 분리 방식
을 실무 기준으로 정리해줘.

➕ 27-1. AI 답변 검증 기준

무조건 cursor만 권하지 않는가?
관리자 페이지 번호 UI에는 offset이 현실적임을 설명하는가?
limit 최대값 제한을 언급하는가?
sortBy 화이트리스트를 제안하는가?
createdAt 단독 정렬보다 id 보조 정렬을 고려하는가?
목록과 상세 API 분리를 제안하는가?
불필요한 include와 N+1 문제를 지적하는가?
전화번호 개인정보와 검색 요구를 함께 고려하는가?
엑셀 다운로드를 일반 목록 API와 분리하는가?

📌 요약

  • 관리자 목록 조회는 상담/주문 운영의 핵심이며, 검색·필터·정렬·페이지네이션 설계가 잘못되면 데이터가 쌓일수록 체감 성능이 급격히 나빠집니다.
  • 일반적인 관리자 페이지 번호 UI에는 page + limit 기반 offset pagination이 구현하기 쉽고, 대량 로그나 무한 스크롤에는 cursor pagination이 더 적합합니다.
  • 페이지네이션에서는 createdAt desc, id desc처럼 안정적인 정렬 기준을 사용해야 페이지 이동 시 데이터가 중복되거나 빠지는 문제를 줄일 수 있습니다.
  • 검색 조건은 운영자가 실제로 쓰는 기준인 전화번호, 고객명, 상태, 상품, 유입 source, 날짜 범위 중심으로 설계해야 합니다.
  • 전화번호는 010-1234-5678 같은 입력값을 정규화해 phoneNormalized, phoneLast4 같은 검색용 컬럼을 고려할 수 있지만, 개인정보 보호와 로그 마스킹 기준도 함께 필요합니다.
  • 날짜 범위 검색은 gte 시작일, lt 다음날 시작 방식이 명확하며, DB 저장 timezone과 관리자 화면 표시 timezone을 반드시 구분해야 합니다.
  • 인덱스는 실제 조회 패턴 기준으로 추가해야 하며, 상담 목록에서는 status + createdAt, productId + createdAt, source + createdAt, phoneNormalized 등이 후보가 될 수 있습니다.
  • 목록 API는 최소 필드만 select하고, 상세 정보와 상태 이력은 별도 상세 API에서 조회하는 것이 성능과 유지보수에 좋습니다.
  • Prisma에서 relation을 과하게 include하거나 row마다 추가 조회를 반복하면 N+1 문제가 생길 수 있으므로 목록 조회는 필요한 relation만 선별해야 합니다.
  • TanStack Query를 사용할 때는 page, limit, keyword, status, source, sortBy, sortOrder 같은 조건을 queryKey에 포함하고, 상태 변경 후 관련 목록 query를 invalidate해야 합니다.
  • 엑셀 다운로드는 일반 목록 API에서 limit=100000처럼 처리하지 말고, 검색 조건을 snapshot으로 저장한 ExportJob/Worker 구조로 분리하는 것이 안전합니다.

0개의 댓글