[기술회고] 1천만 건 주문 이력 조회 최적화

Seong Hyeon Kim·2026년 6월 10일

트러블슈팅

목록 보기
8/8

[기술회고] 1천만 건 주문 이력 조회 최적화 (익명화 필드명 버전)

실제 업무 기반 회고이며, 서비스/테이블/필드명은 식별 방지를 위해 익명화했습니다.


0) 익명화 용어(문서 내 공통)

  • tenantOrgId: 로그인 사용자가 접근 가능한 조직(테넌트) 식별값
  • buyerOrgId: 주문의 구매 주체(고객사/구매사) 조직 식별값
  • catalogItemId: 주문에 연결된 상품 식별값
  • fulfillSiteId: 주문 처리/출고 거점 식별값
  • buyerLocationId: 구매 주체의 지점/위치 식별값
  • removedAt: 소프트 삭제 시각(없으면 활성 데이터)
  • eventYear/eventMonth/eventDay: 이력 이벤트 시점
  • search-order-records: 주문 이력 검색 API(익명화)
  • orderRecords: 주문 이력 컬렉션(익명화)
  • catalogItems: 상품 마스터 컬렉션(익명화)
  • organizations: 조직 마스터 컬렉션(익명화)
  • locations: 지점/거점 마스터 컬렉션(익명화)

1) 문제 상황: 페이지네이션 “20 / 10,000,000”의 원인

관리자 화면은 페이지당 20건을 보여줍니다.
겉으로는 20 / 10,000,000 구조라서 간단해 보이지만, 실제 서버 동작은 그렇지 않았습니다.

핵심 문제는:

  • 화면은 20건만 필요하지만
  • 백엔드는 매번 큰 범위를 재집계/재조합하며
  • 필터 옵션까지 넓은 범위에서 다시 계산하고 있었다는 점입니다.

즉, 느린 이유는 “표시 건수”가 아니라 “처리 건수”였습니다.


2) 기존 조회 흐름 (개선 전)

2-1. 실제 사용

기존 경로를 단순화하면 아래와 비슷했습니다.

  1. tenantOrgId 기준으로 접근 가능한 상품 후보(catalogItems)를 넓게 확보
  2. 확보한 catalogItemId 목록으로 orderRecords를 다시 필터링
  3. 그 결과를 다시 organizations/locations와 조합해 화면 데이터 및 필터 옵션 생성
  4. 이 과정에서 exact total 계산(countDocuments)도 자주 수행

즉,
상품 후보를 크게 만든 뒤 -> 이력에서 다시 거르고 -> 조직/지점 정보를 붙이는
다단계 구조였습니다.

2-2. 도서관 비유

  • 먼저 “읽을 가능성이 있는 책”을 큰 카트로 왕창 모으고
  • 그다음 대출원장에서 다시 골라내고
  • 마지막에 출판사/지점 카드까지 붙여서 정리하는 방식

3) 왜 느렸는가

3-1. 실제 사용

  • 페이지 20건만 필요해도 exact total 계산을 자주 수행
  • orderRecords에서 큰 group/lookup 집계 반복
  • 필터 옵션을 전체 목록 + cnt 중심으로 계산
  • catalogItems 선로딩(preload) 범위가 넓음

3-2. 도서관 비유

  • 20권 추천만 하면 되는데 매번 전체 장서 수 재조사
  • 장르 통계 재생성
  • 실제 안 볼 책까지 미리 카트에 담는 구조

4) 개선 단계

4-1. 1단계: 전체 count 강제 완화 (hasMore 중심)

실제 사용

  • 기본 경로를 includeExactTotal=false로 두고
  • limit + 1 조회로 hasMore만 판단
  • exact total은 필요한 경우에만 별도 경로

도서관 비유

  • “총 몇 권인지”를 매번 세지 않고
  • “다음 책장에 더 있는지”부터 확인

효과

  • 전수 count 비용 감소
  • CPU/IO 부담 완화

4-2. 2단계: preload 제거, 지연 로딩 적용

실제 사용

  • 먼저 orderRecords에서 페이지 20건 확정
  • 해당 20건의 catalogItemId만 모아서 catalogItems 조회
  • 불필요한 선로딩 제거

도서관 비유

  • 20권만 보여주면 되는데 수천 권을 미리 카트에 담지 않고
  • 지금 보여줄 20권 카드만 꺼내는 방식

효과

  • 메모리 피크 감소
  • 조회량 감소

4-3. 3단계: 인덱스 축 정렬

실제 사용

조회 패턴에 맞춰 인덱스 축 정리:

  • catalogItemId + removedAt + eventYear/eventMonth/eventDay
  • fulfillSiteId + removedAt + eventYear/eventMonth/eventDay
  • buyerLocationId + removedAt + eventYear/eventMonth

도서관 비유

  • 예전: 동선이 겹치는 오래된 서고지도 여러 장을 동시에 사용
  • 현재 : 자주 찾는 서고 동선 기준으로 위치번호 체계를 재정비

효과

  • 스캔 범위 축소
  • 조회 패턴과 인덱스 정렬

4-4. 4단계: 초기 무필터 fast-path

실제 사용

초기 무필터에서는 대형 이력 집계를 바로 타지 않고:

  • 지점 필터: locations 직접
  • 조직 필터: organizations 직접
  • 구매조직 필터: catalogItems.buyerOrgId 그룹 + organizations 조합

도서관 비유

  • 대출원장 전체를 뒤지지 않고
  • 출판사/지점 목록표에서 먼저 후보를 빠르게 보여줌

효과

  • 첫 진입 체감속도 개선

4-5. 5단계: 캐스케이드 정상 동작 복구(디버깅)

실제 사용

성능 개선 중 일부 분기에서 “항상 전체 옵션”이 노출되는 문제가 있었고,

  • 선택값이 들어오면 scopedBaseFilter 경로로 강제 전환
  • 실제 매칭되는 조직/지점/조건만 노출되도록 수정

도서관 비유

  • “서울 지점”을 선택했는데 전국 지점이 보이던 상태를
  • 서울 조건에 맞는 서가만 보이도록 복구

효과

  • 캐스케이드 정합성 회복
  • 기능 회귀 없이 성능 개선 유지

5) 그래서 “기존 경로”가 “어떻게 바뀌었나”

5-1. 기존

catalogItems 대량 후보 확보
-> orderRecords 재필터링
-> organizations/locations 조합
-> count/필터 집계 반복

5-2. 변경

조건 기반으로 스코프 먼저 축소
-> orderRecords 페이지 결과 20건 먼저 확정
-> 필요한 catalogItems/organizations/locations만 후로딩
-> 기본은 hasMore 중심 응답

핵심 차이:
“큰 후보를 만들고 줄이는 방식” -> “처음부터 좁은 범위로 시작하는 방식”


6) “거의 전체 선택인데도 왜 빠를 수 있나?”

6-1. 실제 사용

예: buyerOrgIds를 많이 선택한 상황에서도

  1. buyerOrgIds -> buyerLocationIds로 먼저 압축
  2. tenantOrgId, fulfillSiteId, removedAt 조건으로 범위 제한
  3. count 강제 없이 hasMore 중심
  4. 20건 확정 후 상세 후로딩

6-2. 도서관 비유

  • 출판사를 많이 선택해도 도서관 전체를 뛰는 게 아니라
  • 관련 서가번호 목록을 먼저 만들고 그 서가만 열람

7) 인덱스는 왜 빠르고, 왜 한계도 있는가

실제 동작

  • 인덱스 축과 조건이 맞으면 전체 스캔 없이 필요한 구간을 빠르게 탐색
  • 하지만 큰 $group/$lookup이나 광범위 count를 동시에 강제하면 비용이 커질 수 있음

도서관 비유

  • 인덱스는 "서고 B-12, 책장 4, 칸 7" 같은 위치번호 체계
  • 위치번호 덕분에 빠른 이동이 가능
  • 단, 매 요청마다 "도서관 전체 장서 재집계 + 장르 통계 전수 갱신"을 하면 느릴 수밖에 없음

8) 최종 결론

8-1. 실제 사용

성능 개선의 본질은 “DB를 더 세게 쓰는 것”이 아니라:

  • 전체를 덜 읽게 만들고
  • 필요한 시점에 필요한 데이터만 읽도록
    조회 흐름을 재설계한 것

8-2. 도서관 비유

  • 도서관 전체를 매번 재정리하던 운영에서
  • 필요한 책장만 정확히 찾아 읽는 운영으로 바꾼 것

9) 재사용 체크리스트

  • 기본 응답이 hasMore 중심인가?
  • preload를 페이지 확정 이후로 미뤘는가?
  • 초기 무필터 fast-path가 있는가?
  • 선택 후 캐스케이드 정합성을 유지하는가?
  • 인덱스 축이 실제 조회 패턴과 일치하는가?
  • 단계별 성능 로그(구간 ms)를 남기고 있는가?
profile
삽질도 100번 하면 요령이 생긴다. 부족한 건 경험으로 채우는 개발자

0개의 댓글