관리자 목록 화면
↓
검색
↓
필터
↓
정렬
↓
페이지네이션
↓
상세/상태 변경
데이터가 많아짐
검색 조건이 복잡함
인덱스가 없음
불필요한 relation을 많이 include함
count 쿼리가 무거움
offset이 너무 커짐
정렬 기준이 비효율적임
LIKE 검색이 많음
전체 상담 100,000건
↓
1페이지 50건
↓
필요한 페이지의 데이터만 조회
브라우저 렌더링 부담 감소
API 응답 크기 감소
DB 조회 부담 감소
관리자 화면 사용성 개선
엑셀 다운로드와 일반 목록 조회 분리
고객 화면:
10~20개
관리자 목록:
20~100개
엑셀 다운로드:
페이지네이션이 아니라 별도 ExportJob
page와 limit를 받아 skip과 take로 조회하는 방식입니다.skip, take를 사용합니다.page = 1, limit = 20
skip = 0
page = 2, limit = 20
skip = 20
page = 3, limit = 20
skip = 40
const page = 1;
const limit = 20;
const skip = (page - 1) * limit;
const consults = await prisma.consult.findMany({
skip,
take: limit,
orderBy: {
createdAt: 'desc',
},
});
구현이 쉬움
페이지 번호 UI 만들기 쉬움
전체 개수와 함께 쓰기 좋음
관리자 화면에 익숙함
뒤 페이지로 갈수록 느려질 수 있음
데이터가 추가/삭제되면 페이지 결과가 밀릴 수 있음
대량 데이터에서 skip 비용 증가
첫 요청:
최신 20건 조회
다음 요청:
마지막 row의 id 또는 createdAt을 cursor로 전달
다음 20건 조회
const consults = await prisma.consult.findMany({
take: 20,
cursor: cursorId
? {
id: cursorId,
}
: undefined,
skip: cursorId ? 1 : 0,
orderBy: {
id: 'desc',
},
});
대량 데이터에서 성능이 안정적
뒤 페이지로 가도 offset보다 효율적
실시간으로 데이터가 추가되어도 비교적 안정적
페이지 번호 UI 구현이 어려움
특정 페이지로 바로 이동하기 어려움
정렬 조건이 복잡하면 설계가 까다로움
관리자 번호 페이지:
offset pagination
무한 스크롤:
cursor pagination
대량 로그:
cursor pagination
상담 목록 초기:
offset으로 시작 가능
데이터가 매우 많아짐:
cursor 전환 검토
정렬 기준:
createdAt desc
status asc
updatedAt desc
productName asc
상담 목록:
createdAt desc
주문 목록:
createdAt desc 또는 updatedAt desc
상태 변경 이력:
createdAt desc
관리자 로그:
createdAt desc
상품 목록:
displayOrder asc, createdAt desc
운영자가 최근 데이터를 먼저 봐야 함
상태별 처리 우선순위를 정할 수 있음
동일 조건에서 페이지 결과가 흔들리지 않아야 함
인덱스와 연결되어 성능에 영향
createdAt만으로 정렬하면 같은 시간 데이터가 많을 때 순서가 불안정할 수 있습니다.orderBy: {
createdAt: 'desc',
}
문제:
createdAt이 같은 row가 여러 개 있을 수 있음
페이지 이동 시 순서가 흔들릴 수 있음
일부 데이터가 중복되거나 빠져 보일 수 있음
orderBy: [
{
createdAt: 'desc',
},
{
id: 'desc',
},
]
createdAt 정렬에는 id를 보조 정렬로 추가
updatedAt 정렬에도 id를 보조 정렬로 추가
동일 값이 가능한 컬럼은 단독 정렬 지양
정렬 기준과 인덱스 순서를 함께 고려
createdAt desc, id desc 조합을 많이 씁니다.keyword
phone
customerName
status
carrier
productId
source
utmCampaign
dateFrom
dateTo
assignedAdminId
orderNo
customerName
phone
status
carrier
productName
openedAt
createdAt
adminId
운영자가 실제로 찾는 기준인가?
DB index를 만들 수 있는 조건인가?
부분 검색이 필요한가?
정확히 일치해야 하는가?
여러 조건을 조합할 수 있는가?
where 객체를 만들어 검색 조건을 구성할 수 있습니다.const where: Prisma.ConsultWhereInput = {
...(status && {
status,
}),
...(productId && {
productId,
}),
...(source && {
source,
}),
};
const where: Prisma.ConsultWhereInput = {
createdAt: {
gte: dateFrom,
lte: dateTo,
},
};
const where: Prisma.ConsultWhereInput = {
OR: [
{
customerName: {
contains: keyword,
mode: 'insensitive',
},
},
{
phone: {
contains: keyword,
},
},
],
};
contains 검색은 인덱스를 잘 못 탈 수 있음
전화번호는 정규화된 값으로 검색 고려
대소문자 검색은 DB collation/인덱스 고려
OR 조건이 많으면 쿼리가 무거워질 수 있음
contains는 편하지만 데이터가 많아지면 느려질 수 있습니다.01012345678, 010-1234-5678, 1234 등 다양한 방식으로 검색할 수 있습니다.입력값:
010-1234-5678
정규화 저장:
01012345678
표시:
010-1234-5678 또는 010****5678
phone:
원본 또는 암호화/마스킹 기준
phoneNormalized:
01012345678
phoneLast4:
5678
const normalizedPhone = keyword.replace(/\D/g, '');
const where: Prisma.ConsultWhereInput = {
OR: [
{
phoneNormalized: {
contains: normalizedPhone,
},
},
{
phoneLast4: normalizedPhone.length === 4 ? normalizedPhone : undefined,
},
],
};
전화번호는 개인정보
로그에 원본 출력 금지
검색 권한 제한 필요
암호화하면 contains 검색 어려움
마스킹과 검색 요구를 함께 고려
createdAt:
접수일 기준
updatedAt:
최근 수정일 기준
openedAt:
개통일 기준
canceledAt:
취소일 기준
const where: Prisma.ConsultWhereInput = {
createdAt: {
gte: startOfDay,
lt: nextDayStart,
},
};
lte보다 lt를 선호하는 이유2026-08-18 00:00:00 이상
2026-08-19 00:00:00 미만
장점:
하루 전체 범위 표현 명확
밀리초/마이크로초 끝값 문제 감소
DB 저장 timezone 기준
관리자 화면 표시 timezone
검색 시작/종료 시간 변환
한국 시간 기준 일자 검색
인덱스 없음:
전체 테이블 스캔
인덱스 있음:
조건에 맞는 위치로 빠르게 접근
consults.createdAt
consults.status
consults.status + createdAt
consults.phoneNormalized
consults.productId
consults.source
consults.assignedAdminId
orders.status + createdAt
audit_logs.actorId + createdAt
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])
}
인덱스는 많다고 좋은 게 아님
쓰기/수정 성능 비용이 생김
저장공간이 증가함
실제 조회 패턴 기준으로 추가해야 함
@@index([status, createdAt])
@@index([productId, createdAt])
@@index([assignedAdminId, createdAt])
const consults = await prisma.consult.findMany({
where: {
status: 'NEW',
createdAt: {
gte: startDate,
lt: endDate,
},
},
orderBy: {
createdAt: 'desc',
},
});
(status, createdAt) 인덱스:
status로 먼저 좁히고 createdAt 정렬/범위 조회
(createdAt, status) 인덱스:
날짜로 먼저 좁히고 status 조건 적용
쿼리 패턴에 따라 효율이 달라짐
count 쿼리가 필요합니다.{
"items": [],
"page": 1,
"limit": 20,
"total": 1352,
"totalPages": 68
}
const [items, total] = await prisma.$transaction([
prisma.consult.findMany({
where,
skip,
take: limit,
orderBy,
}),
prisma.consult.count({
where,
}),
]);
count도 where 조건 영향을 받음
검색 조건이 복잡하면 count가 느려질 수 있음
대량 데이터에서는 totalPages 계산이 부담
실시간 정확한 total이 꼭 필요한지 검토
export type PaginatedResponse<T> = {
items: T[];
page: number;
limit: number;
total: number;
totalPages: number;
};
{
"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
}
items:
현재 페이지 데이터
page:
현재 페이지 번호
limit:
페이지 크기
total:
검색 조건 기준 전체 개수
totalPages:
전체 페이지 수
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';
}
page는 1 이상
limit는 최대값 제한
sortBy는 허용된 컬럼만
sortOrder는 asc/desc만
status는 enum만
날짜 형식 검증
keyword 길이 제한
const limit = Math.min(query.limit ?? 20, 100);
limit=10000을 보내도 그대로 처리하면 안 됩니다.const orderBy = {
[query.sortBy]: query.sortOrder,
};
문제:
허용하지 않은 컬럼 정렬 가능
인덱스 없는 컬럼 정렬로 성능 저하
예상치 못한 쿼리 구조
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;
정렬 가능한 컬럼만 허용
인덱스 여부 고려
기본 정렬 제공
보조 정렬 id 추가
include를 많이 쓰면 편하지만, 불필요한 데이터를 많이 가져올 수 있습니다.const consults = await prisma.consult.findMany({
include: {
product: true,
statusHistories: true,
auditLogs: true,
notificationLogs: true,
},
});
문제:
응답 크기 증가
쿼리 복잡도 증가
프론트 렌더링 부담 증가
목록 화면에 필요 없는 데이터 포함
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,
},
},
},
});
목록:
최소 필드만
상세:
상세 API에서 필요한 relation 조회
이력:
상세 모달 또는 이력 탭에서 별도 조회
엑셀:
ExportJob에서 별도 조회
상담 목록 1번 조회
↓
상담 20건
↓
각 상담의 상품 조회 20번
↓
총 21번 쿼리
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,
};
}),
);
const consults = await prisma.consult.findMany({
take: 20,
include: {
product: {
select: {
id: true,
modelName: true,
carrier: true,
},
},
},
});
목록 row마다 반복 쿼리 금지
필요한 relation은 include/select로 한 번에 조회
복잡한 집계는 별도 쿼리나 materialized view 검토
GET /admin/consults
→ 목록용 최소 데이터
GET /admin/consults/:id
→ 상세 정보
GET /admin/consults/:id/histories
→ 상태 변경 이력
id
customerName 또는 maskedName
phoneMasked
status
productName
carrier
source
createdAt
assignedAdminName
상담 전체 정보
상담 메모
선택 상품 snapshot
유입 상세
상태 변경 이력
알림 발송 이력
관리자 처리 이력
/admin/consults?page=2&limit=50&status=NEW&source=naver&sortBy=createdAt&sortOrder=desc
새로고침해도 필터 유지
뒤로가기 동작 자연스러움
운영자가 특정 조건 URL 공유 가능
QA 재현 쉬움
상태 관리 단순화
개인정보 검색어가 URL에 남을 수 있음
너무 긴 query string 주의
기본값 처리 명확히
잘못된 query 값 검증
const queryKey = [
'admin',
'consults',
{
page,
limit,
keyword,
status,
source,
sortBy,
sortOrder,
dateFrom,
dateTo,
},
];
필터 조건 누락 시 캐시 꼬임
객체 key 순서와 안정성 고려
undefined/null 처리 기준 통일
페이지 변경과 필터 변경 구분
필터 변경 시 page를 1로 초기화
await updateConsultStatusMutation.mutateAsync(payload);
queryClient.invalidateQueries({
queryKey: ['admin', 'consults'],
});
상태 변경
↓
현재 필터/페이지 유지
↓
변경된 row 상태 갱신
↓
필요 시 현재 필터에서 사라짐
예시:
현재 필터:
status = NEW
상담 상태를 NEW → CALLED로 변경
결과:
해당 row가 NEW 목록에서 사라지는 것이 자연스러움
필터 조건에 따라 row가 사라질 수 있음
사용자에게 성공 toast 표시
페이지가 비면 이전 페이지로 이동 고려
목록 count 갱신 필요
GET /admin/consults?limit=100000
↓
백엔드에서 한 번에 전체 조회
↓
엑셀 생성
↓
응답 대기
문제:
API timeout
메모리 사용량 증가
DB 부하
관리자 화면 멈춤
다른 요청 영향
관리자가 엑셀 다운로드 요청
↓
export_jobs 생성
↓
Worker가 검색 조건 기준으로 파일 생성
↓
S3 업로드
↓
다운로드 링크 제공
목록 API:
페이지 단위 조회
엑셀 Export:
별도 Job 처리
검색 조건:
ExportJob에 snapshot으로 저장
권한:
엑셀 다운로드 권한 별도 관리
1. 어떤 API가 느린지 확인
2. 요청 query parameter 확인
3. where/orderBy/include 확인
4. 실제 SQL 확인
5. EXPLAIN으로 실행 계획 확인
6. 인덱스 후보 검토
7. count 쿼리 분리/최적화 검토
8. 응답 필드 줄이기
9. 프론트 렌더링 비용 확인
API 응답 시간
DB query 시간
응답 payload 크기
items 개수
total count 시간
프론트 렌더링 시간
브라우저 메모리 사용
무조건 인덱스만 추가
include를 줄이지 않음
count 쿼리 비용 무시
limit 제한 없음
검색어 contains 남발
정렬 기준 불안정
프론트 테이블 렌더링 병목 무시
GET /admin/consults?page=1&limit=20&status=NEW&source=naver&sortBy=createdAt&sortOrder=desc
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),
};
}
limit 최대값 제한
where 동적 구성
sortBy 화이트리스트
안정적 보조 정렬 id 추가
select로 필드 최소화
findMany와 count 조건 일치
createdAt + id 같은 안정적 정렬을 사용하는가?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 분리 방식
을 실무 기준으로 정리해줘.
무조건 cursor만 권하지 않는가?
관리자 페이지 번호 UI에는 offset이 현실적임을 설명하는가?
limit 최대값 제한을 언급하는가?
sortBy 화이트리스트를 제안하는가?
createdAt 단독 정렬보다 id 보조 정렬을 고려하는가?
목록과 상세 API 분리를 제안하는가?
불필요한 include와 N+1 문제를 지적하는가?
전화번호 개인정보와 검색 요구를 함께 고려하는가?
엑셀 다운로드를 일반 목록 API와 분리하는가?
page + limit 기반 offset pagination이 구현하기 쉽고, 대량 로그나 무한 스크롤에는 cursor pagination이 더 적합합니다.createdAt desc, id desc처럼 안정적인 정렬 기준을 사용해야 페이지 이동 시 데이터가 중복되거나 빠지는 문제를 줄일 수 있습니다.010-1234-5678 같은 입력값을 정규화해 phoneNormalized, phoneLast4 같은 검색용 컬럼을 고려할 수 있지만, 개인정보 보호와 로그 마스킹 기준도 함께 필요합니다.gte 시작일, lt 다음날 시작 방식이 명확하며, DB 저장 timezone과 관리자 화면 표시 timezone을 반드시 구분해야 합니다.status + createdAt, productId + createdAt, source + createdAt, phoneNormalized 등이 후보가 될 수 있습니다.select하고, 상세 정보와 상태 이력은 별도 상세 API에서 조회하는 것이 성능과 유지보수에 좋습니다.include하거나 row마다 추가 조회를 반복하면 N+1 문제가 생길 수 있으므로 목록 조회는 필요한 relation만 선별해야 합니다.limit=100000처럼 처리하지 말고, 검색 조건을 snapshot으로 저장한 ExportJob/Worker 구조로 분리하는 것이 안전합니다.