사용자 경험 개선
관리자 업무 효율 개선
운영 속도 향상
데이터 분석 기반 마련
예시:
관리자가 "아이폰 / 신청완료 / 최근 7일 / 네이버 유입" 조건으로 상담 신청 목록을 검색
↓
조건에 맞는 신청 데이터만 빠르게 조회
↓
상담, 엑셀 다운로드, 광고 성과 확인에 활용
| 구분 | 의미 | 예시 |
|---|---|---|
| 검색 | 사용자가 입력한 키워드로 찾기 | 이름, 전화번호, 상품명 검색 |
| 필터링 | 정해진 조건으로 걸러내기 | 상태, 카테고리, 날짜, 유입경로 |
| 정렬 | 결과 순서를 바꾸기 | 최신순, 가격순, 조회수순 |
| 페이지네이션 | 결과를 나눠서 가져오기 | 1페이지 20개 |
검색:
keyword=아이폰
필터:
status=PENDING
source=NAVER
startDate=2026-06-01
endDate=2026-06-24
정렬:
createdAt DESC
페이지네이션:
page=1
limit=20
고객 화면 검색 예시:
상품명 검색
카테고리 검색
요금제 검색
FAQ 검색
이벤트 검색
관리자 화면 검색 예시:
전화번호 검색
고객명 검색
상담 상태 필터
담당자 필터
유입경로 필터
날짜 범위 검색
중복 신청 검색
IP 기반 검색
GET /api/products?keyword=아이폰&category=phone&page=1&limit=20&sort=latest
GET /api/admin/consults?keyword=0101234&status=PENDING&source=NAVER&startDate=2026-06-01&endDate=2026-06-24&page=1&limit=50
| 파라미터 | 의미 |
|---|---|
keyword | 검색어 |
status | 상태 필터 |
category | 카테고리 필터 |
source | 유입경로 필터 |
startDate | 시작 날짜 |
endDate | 종료 날짜 |
page | 페이지 번호 |
limit | 한 페이지 개수 |
sort | 정렬 기준 |
order | ASC/DESC |
상담 신청 데이터 100,000건
↓
한 번에 전체 조회
↓
DB 부하 증가
응답 지연
브라우저 렌더링 느려짐
엑셀 기능과 충돌 가능
{
"items": [],
"page": 1,
"limit": 20,
"totalCount": 135,
"totalPages": 7
}
items에는 현재 페이지 데이터가 들어갑니다.totalCount는 전체 개수입니다.totalPages는 전체 페이지 수입니다.skip, take 또는 offset, limit을 사용합니다.async getConsults(page: number, limit: number) {
const skip = (page - 1) * limit;
const [items, totalCount] = await this.prisma.$transaction([
this.prisma.consult.findMany({
skip,
take: limit,
orderBy: {
createdAt: 'desc',
},
}),
this.prisma.consult.count(),
]);
return {
items,
page,
limit,
totalCount,
totalPages: Math.ceil(totalCount / limit),
};
}
page=1000, limit=50
↓
앞의 49,950개를 건너뛰고 조회
↓
데이터가 많을수록 부담 증가
GET /api/products?cursor=123&limit=20
cursor=123은 id 123 이후 데이터를 가져오라는 의미로 사용할 수 있습니다.async getProducts(cursor?: number, limit = 20) {
return this.prisma.product.findMany({
take: limit,
...(cursor && {
cursor: {
id: cursor,
},
skip: 1,
}),
orderBy: {
id: 'desc',
},
});
}
where에 추가하는 방식으로 구성합니다.async searchConsults(query: SearchConsultDto) {
const {
keyword,
status,
source,
startDate,
endDate,
page = 1,
limit = 50,
} = query;
const where: Prisma.ConsultWhereInput = {
...(status && {
status,
}),
...(source && {
source,
}),
...(startDate || endDate
? {
createdAt: {
...(startDate && { gte: new Date(startDate) }),
...(endDate && { lte: new Date(endDate) }),
},
}
: {}),
...(keyword && {
OR: [
{ name: { contains: keyword } },
{ phone: { contains: keyword } },
{ productName: { contains: keyword } },
],
}),
};
const skip = (page - 1) * limit;
const [items, totalCount] = await this.prisma.$transaction([
this.prisma.consult.findMany({
where,
skip,
take: limit,
orderBy: {
createdAt: 'desc',
},
}),
this.prisma.consult.count({
where,
}),
]);
return {
items,
totalCount,
page,
limit,
totalPages: Math.ceil(totalCount / limit),
};
}
where에 들어갑니다.findMany와 count에 같은 where를 사용해야 페이지 정보가 맞습니다.export class SearchConsultDto {
@IsOptional()
@IsString()
keyword?: string;
@IsOptional()
@IsEnum(ConsultStatus)
status?: ConsultStatus;
@IsOptional()
@IsString()
source?: string;
@IsOptional()
@IsDateString()
startDate?: string;
@IsOptional()
@IsDateString()
endDate?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit = 50;
}
나쁜 요청:
GET /api/admin/consults?limit=100000
문제:
DB 부하 증가
응답 지연
서버 메모리 사용 증가
브라우저 렌더링 부담
limit의 최대값을 제한해야 합니다.SELECT * FROM consults
WHERE phone = '01012345678';
consults 테이블 100,000건
↓
DB가 모든 row를 검사
↓
조건에 맞는 phone 찾기
CREATE INDEX idx_consults_phone ON consults(phone);
phone 인덱스 확인
↓
조건에 맞는 위치를 빠르게 찾음
↓
조회 속도 개선
| 컬럼 | 인덱스 검토 이유 |
|---|---|
phone | 전화번호 검색 |
status | 상태 필터 |
source | 유입경로 필터 |
createdAt | 날짜 검색, 최신순 정렬 |
productId | 상품별 신청 조회 |
managerId | 담당자별 조회 |
ipAddress | 중복/IP 분석 |
visitorId | 유입 추적 |
| 컬럼 | 인덱스 검토 이유 |
|---|---|
categoryId | 카테고리별 상품 목록 |
isVisible | 노출 상품 필터 |
createdAt | 최신순 정렬 |
sortOrder | 관리자 지정 정렬 |
slug | 상세 페이지 URL 조회 |
brand | 브랜드 필터 |
CREATE INDEX idx_consults_status_created_at
ON consults(status, createdAt);
WHERE status = 'PENDING'
ORDER BY createdAt DESC
model Consult {
id Int @id @default(autoincrement())
phone String
status String
source String?
createdAt DateTime @default(now())
@@index([status, createdAt])
@@index([source, createdAt])
}
@@index([status, createdAt])는 상태 필터 + 날짜 정렬에 도움을 줄 수 있습니다.좋지 않은 방식:
검색할 수도 있을 것 같은 모든 컬럼에 인덱스 생성
좋은 방식:
실제 자주 쓰는 쿼리 기준으로 필요한 인덱스만 생성
LIKE 또는 contains를 자주 사용합니다.SELECT * FROM products
WHERE name LIKE '아이폰%';
SELECT * FROM products
WHERE name LIKE '%아이폰%';
%가 붙으면 일반 B-Tree 인덱스를 제대로 활용하기 어렵습니다.contains로 시작해도 됩니다.contains보다 정규화된 값 + 인덱스가 더 좋습니다.초기:
DB contains 검색
데이터 증가:
DB Full Text Search 검토
검색 품질 중요:
OpenSearch / Elasticsearch 검토
ORDER BY createdAt DESC는 목록 API에서 매우 자주 사용됩니다.SELECT * FROM consults
WHERE status = 'PENDING'
ORDER BY createdAt DESC
LIMIT 50;
(status, createdAt) 복합 인덱스를 고려할 수 있습니다.createdAt만 인덱스를 걸었을 때보다 필터 조건과 함께 보는 것이 중요합니다.EXPLAIN, EXPLAIN ANALYZE로 쿼리 실행 계획을 볼 수 있습니다.EXPLAIN ANALYZE
SELECT * FROM consults
WHERE status = 'PENDING'
ORDER BY createdAt DESC
LIMIT 50;
Seq Scan:
테이블 전체를 읽는 방식
Index Scan:
인덱스를 사용하는 방식
Sort:
정렬 비용 발생
Seq Scan이 무조건 나쁜 것은 아닙니다.NestJS + Prisma + PostgreSQL로 관리자 상담 신청 목록 검색 API를 만들고 있어.
상황:
1. consults 테이블에는 상담 신청 데이터가 계속 쌓임
2. 예상 데이터는 1년 기준 10만 건 이상
3. 관리자는 전화번호, 이름, 상품명으로 검색함
4. 상태값 PENDING, DONE, CANCELLED로 필터함
5. 유입경로 NAVER, DANGGEUN, DIRECT로 필터함
6. 날짜 범위 검색을 자주 사용함
7. 기본 정렬은 createdAt DESC
8. page/limit 기반 페이지네이션을 사용함
9. 같은 검색 조건으로 엑셀 다운로드도 해야 함
요청:
- API Query Parameter 설계
- Prisma where 조건 구성
- 페이지네이션 응답 구조
- 필요한 인덱스 후보
- 복합 인덱스 설계 기준
- contains 검색의 한계
- 성능 확인 방법
을 설명해줘.
keyword, status, source, startDate, endDate, page, limit, sort 같은 Query Parameter를 명확하게 설계해야 합니다.LIKE '%검색어%' 또는 Prisma contains 검색은 데이터가 많아지면 느려질 수 있으므로 Full Text Search나 검색 엔진 도입을 검토할 수 있습니다.EXPLAIN ANALYZE로 실제 실행 계획과 속도를 확인해야 합니다.