페이지네이션, 가상화, 스마트 필터링, 쿼리 최적화를 이용해 10만 행 규모의 대시보드 목록을 빠르게 만드는 방법을 배우고 내부 도구의 반응성을 유지하세요.

목록 화면은 보통 한동안은 괜찮다가 어느 순간 느려집니다. 사용자는 작은 멈춤을 느끼기 시작하고, 스크롤이 끊기거나 업데이트 후 페이지가 잠깐 멈추고, 필터가 반응하는 데 몇 초가 걸리며, 거의 모든 클릭에 스피너가 뜨는 걸 보게 됩니다. 때로는 UI 스레드가 바빠서 브라우저 탭이 얼어붙은 것처럼 보이기도 합니다.
10만 행은 흔한 분기점입니다. 이 수치는 데이터베이스에는 여전히 정상적이지만 브라우저와 네트워크에서 작은 비효율이 눈에 띄게 드러나기 시작할 만큼 충분히 큽니다. 모든 것을 한꺼번에 보여주려 하면 단순한 화면이 무거운 파이프라인으로 변합니다.
목표는 모든 행을 렌더링하는 것이 아닙니다. 목표는 사용자가 필요한 것을 빠르게 찾도록 돕는 것입니다: 적절한 50개 행, 다음 페이지, 또는 필터로 좁힌 일부 결과.
작업을 네 부분으로 나누면 도움이 됩니다:
어떤 한 부분이라도 비용이 크면 전체 화면이 느리게 느껴집니다. 간단한 검색 상자가 10만 행을 정렬하는 요청을 트리거하고 수천 건의 레코드를 반환한 뒤 브라우저가 모두 렌더링하게 만들면 입력할 때 지연이 생깁니다.
내부 도구를 빠르게 만드는 팀(예: Koder.ai 같은 비브-코딩 플랫폼 사용)에서는 목록 화면이 종종 실제 데이터 증가가 "데모 데이터에서 작동"하는 것과 "매일 즉각적으로 느껴지는" 것의 차이를 드러내는 첫 장소가 됩니다.
최적화에 앞서 이 화면에서 "빠르다"가 무엇인지 정하세요. 많은 팀이 전체 로드(throughput)를 쫓지만, 사용자는 주로 낮은 지연(latency)을 필요로 합니다. 목록은 10만 행 전체를 전부 불러오지 않더라도 스크롤, 정렬, 필터에 빠르게 반응하면 즉각적으로 느껴질 수 있습니다.
실용적인 목표는 전체 로드 시간이 아니라 첫 행이 보이는 시간입니다. 사용자는 처음 20~50행이 빠르게 보이고 상호작용이 부드러우면 페이지를 신뢰합니다.
변경할 때마다 추적할 수 있는 소수의 수치를 고르세요:
COUNT(*)와 넓은 SELECT)이들은 흔한 증상과 일대일로 연결됩니다. 스크롤할 때 브라우저 CPU가 급증하면 프론트엔드가 행당 너무 많은 작업을 하고 있다는 신호입니다. 스피너가 오래 떠 있고 스크롤은 괜찮다면 보통 백엔드나 네트워크 문제입니다. 요청은 빠른데 페이지가 여전히 멈춘다면 거의 항상 렌더링이나 무거운 클라이언트 사이드 처리 때문입니다.
한 가지 간단한 실험을 해보세요: UI는 그대로 두고 백엔드가 같은 필터로 행을 임시로 20개만 반환하도록 제한합니다. 빨라지면 병목은 로드 크기나 쿼리 시간입니다. 여전히 느리면 렌더링, 포맷팅, 행당 컴포넌트를 살펴보세요.
예: 내부 Orders 화면에서 검색을 입력할 때 느리다면 API가 5,000행을 반환하고 브라우저가 매 키 입력마다 그걸 필터링하고 있을 수 있습니다. 또는 API가 인덱스 없는 필터에 대한 COUNT 쿼리로 2초 걸린다면 어떤 행도 바뀌기 전에 기다리게 됩니다. 다른 원인, 같은 사용자 불만입니다.
브라우저가 첫 병목인 경우가 많습니다. API가 빠르더라도 페이지가 너무 많은 내용을 그리려고 해서 느리게 느껴질 수 있습니다. 첫 번째 규칙은 간단합니다: 수천 개 행을 한꺼번에 DOM에 렌더하지 마세요.
완전한 가상화를 추가하기 전에도 각 행을 가볍게 유지하세요. 중첩 래퍼, 아이콘, 툴팁, 복잡한 조건 스타일이 많은 행은 스크롤과 업데이트 때마다 비용이 큽니다. 일반 텍스트, 작은 배지 몇 개, 그리고 한두 개의 인터랙티브 요소만 남기세요.
행 높이를 일정하게 유지하는 것은 생각보다 큰 도움이 됩니다. 모든 행이 같은 높이면 브라우저는 레이아웃을 예측하기 쉬워 스크롤이 부드러워집니다. 가변 높이 행(줄 바꿈 설명, 확장 가능한 메모, 큰 아바타)은 추가 측정과 리플로우를 유발합니다. 추가 정보가 필요하면 사이드 패널이나 단일 확장 영역을 고려하세요.
포맷팅도 조용한 비용입니다. 날짜, 통화, 무거운 문자열 작업이 여러 셀에서 반복되면 누적됩니다.
값이 보이지 않으면 아직 계산하지 마세요. 비용이 큰 포맷 결과는 캐시하고, 예를 들어 행이 보이게 되거나 사용자가 행을 열 때 계산하세요.
빠르게 적용해 눈에 띄는 성능 향상을 주는 조치:
예: 내부 Invoices 테이블이 통화와 날짜 12개 컬럼을 포맷하면 스크롤 시 끊깁니다. 인보이스별 포맷된 값을 캐시하고 오프스크린 행의 작업을 지연하면 백엔드 작업 전에도 즉각적으로 느껴질 수 있습니다.
가상화는 테이블이 실제로 볼 수 있는 행(위아래 소량의 버퍼 포함)만 그리는 것을 의미합니다. 스크롤할 때 동일한 DOM 요소를 재사용하며 내부 데이터만 교체합니다. 이렇게 하면 브라우저가 수만 개의 행 컴포넌트를 한꺼번에 페인트하려고 하지 않게 됩니다.
가상화는 긴 목록, 넓은 테이블, 또는 아바타·상태 칩·액션 메뉴·툴팁처럼 무거운 행에 적합합니다. 사용자가 많이 스크롤하며 부드럽고 연속적인 뷰를 기대할 때도 유용합니다.
가상화가 마법은 아닙니다. 다음 몇 가지가 종종 놀라움을 줍니다:
가장 단순한 접근법은 지루하지만 효과적입니다: 행 높이 고정, 예측 가능한 컬럼, 각 행에 너무 많은 인터랙티브 위젯을 두지 않기.
둘을 결합할 수 있습니다: 서버에서 가져오는 양을 제한하려면 페이지네이션(또는 커서 기반의 더 불러오기)을 사용하고, 가져온 슬라이스 내부에서는 가상화를 사용해 렌더링 비용을 낮춥니다.
실용적 패턴은 보통 100~500행의 일반 페이지 크기를 가져오고 그 안에서 가상화하며, 페이지 간 이동 제어를 명확히 제공하는 것입니다. 무한 스크롤을 쓴다면 "X 중 Y 로드됨" 같은 명시적 표시를 추가해 사용자가 아직 모든 것을 보고 있지 않다는 것을 알게 하세요.
데이터가 커질 때도 사용 가능한 목록 화면을 원하면 페이지네이션이 보통 가장 안전한 기본값입니다. 예측 가능하고 관리자 작업(검토, 수정, 승인)에 잘 맞으며 "필터와 함께 3페이지"처럼 내보내기할 때도 문제가 적습니다. 많은 팀이 더 화려한 스크롤을 시도한 뒤 다시 페이지네이션으로 돌아옵니다.
무한 스크롤은 캐주얼한 탐색에서는 좋지만 비용이 숨어 있습니다. 사용자는 위치 감각을 잃고, 뒤로 가기 버튼이 같은 위치로 돌아오지 않으며, 긴 세션에서 더 많은 행이 로드되면 메모리가 쌓입니다. 중간 대안은 페이지를 사용하지만 여전히 더 불러오기 버튼을 제공해 사용자가 방향을 잃지 않게 하는 것입니다.
오프셋 페이지네이션은 고전적인 page=10&size=50 방식입니다. 단순하지만 큰 테이블에서는 나중 페이지로 갈수록 느려질 수 있습니다. 데이터베이스가 많은 행을 건너뛰어야 하기 때문입니다. 또한 새로운 행이 들어오면 항목이 페이지 사이에서 밀리는 이상한 경험을 줄 수 있습니다.
키셋 페이지네이션(커서)은 "마지막으로 본 항목 다음의 50개"처럼 요청합니다(보통 id나 created_at 값 사용). 건너뛰기 비용이 적어 보통 성능이 빠르고 일정합니다.
실용 규칙:
사용자는 총계를 보는 것을 좋아하지만 일치하는 모든 행을 세는 전체 COUNT는 무거운 필터에서 비쌀 수 있습니다. 인기 필터에 대한 카운트를 캐시하거나, 페이지 로드 후 백그라운드에서 카운트를 업데이트하거나, 대략적인 수치(예: "10,000+")를 보여주는 옵션이 있습니다.
예: 내부 Orders 화면은 키셋 페이지네이션으로 결과를 즉시 보여주고 사용자가 필터를 멈춘 뒤 정확한 총계를 채워 넣을 수 있습니다.
Koder.ai로 이것을 빌드한다면 페이지네이션과 카운트 동작을 초기에 화면 스펙의 일부로 취급해 생성된 백엔드 쿼리와 UI 상태가 나중에 충돌하지 않게 하세요.
대부분 목록 화면은 너무 넓게 열려 있기 때문에 느려집니다: 모든 것을 불러온 다음 사용자가 좁히도록 합니다. 이 순서를 뒤집으세요. 유용한 소규모 결과를 반환하는 합리적 기본값으로 시작하세요(예: 최근 7일, 내 항목, 상태: Open). 전체 기간은 명시적 선택으로 두세요.
텍스트 검색도 흔한 덫입니다. 매 키 입력마다 쿼리를 실행하면 요청이 쌓이고 UI가 깜박입니다. 검색 입력을 디바운스해 사용자가 잠시 멈출 때만 쿼리하고, 새 쿼리가 시작되면 이전 요청을 취소하세요. 간단한 규칙: 사용자가 아직 타이핑 중이면 서버를 치지 마세요.
필터는 빠르게 느껴지려면 명확해야 합니다. 테이블 상단에 필터 칩을 보여 사용자가 어떤 필터가 활성화되어 있는지 한눈에 보고 한 번의 클릭으로 제거할 수 있게 하세요. 칩 레이블은 필드 이름 그대로가 아니라 사람 친화적으로 유지하세요(예: owner_id=42 대신 Owner: Sam). 누군가 "결과가 사라졌다"고 말하면 보통 보이지 않는 필터 때문입니다.
큰 목록을 복잡하게 만들지 않으면서 반응성을 유지하는 패턴:
저장된 뷰는 조용한 히어로입니다. 사용자가 매번 완벽한 일회성 필터 조합을 만들도록 가르치는 대신 실제 워크플로에 맞는 프리셋 몇 개를 제공하세요. 예: 운용팀은 오늘의 실패 결제와 고가치 고객 뷰를 원할 수 있습니다. 이런 뷰는 원클릭으로 이해하기 쉽고 백엔드에서도 빠르게 유지하기 쉽습니다.
채팅 기반 빌더(예: Koder.ai)로 내부 도구를 만든다면 필터를 보완 기능이 아니라 제품 흐름의 일부로 취급하세요. 가장 흔한 질문에서 시작해 기본 뷰와 저장된 뷰를 디자인하세요.
목록 화면은 상세 페이지와 같은 데이터를 거의 필요로 하지 않습니다. API가 모든 속성 정보를 반환하면 데이터베이스가 더 많은 일을 하고 브라우저가 사용하지도 않을 데이터를 받아 렌더링까지 하게 됩니다. 쿼리 셰이핑은 목록이 지금 필요한 것만 요청하는 습관입니다.
각 행을 렌더하는 데 필요한 컬럼만 반환하세요. 대부분 대시보드에서는 id, 몇 개의 레이블, 상태, 담당자, 타임스탬프만면 충분합니다. 큰 텍스트, JSON 블롭, 계산 필드는 사용자가 행을 열 때까지 기다리게 하세요.
첫 페인트에서 무거운 조인을 피하세요. 조인은 인덱스를 타고 작은 결과를 반환하면 괜찮지만, 여러 테이블을 조인하고 조인된 데이터로 정렬하거나 필터링하면 비용이 커집니다. 간단한 패턴은: 목록은 한 테이블에서 빠르게 가져오고, 관련 세부 정보는 온디맨드로(또는 보이는 행만 배치 로드) 불러오는 것입니다.
정렬 옵션을 제한하고 인덱스가 있는 컬럼으로 정렬하세요. "모든 것으로 정렬"은 유용해 보이지만 큰 데이터셋에서 느린 정렬을 강요할 수 있습니다. created_at, updated_at, status 같은 예측 가능한 선택을 선호하고 해당 컬럼에 인덱스가 있는지 확인하세요.
서버 측 집계에 주의하세요. 큰 필터셋에서의 COUNT(*), 넓은 컬럼에 대한 DISTINCT, 전체 페이지 수 계산은 응답 시간을 지배할 수 있습니다.
실용적 접근:
COUNT와 DISTINCT는 선택적으로 처리하고 캐시하거나 근사값 사용Koder.ai로 내부 도구를 만든다면 리스트 쿼리를 상세 쿼리와 분리해 계획 모드에서 경량 쿼리를 정의해 두세요. 그래야 데이터가 늘어날수록 UI가 빠르게 유지됩니다.
10만 행에서 빠르게 유지하려면 데이터베이스가 요청당 덜 일해야 합니다. 대부분 느린 목록은 "데이터가 너무 많다"가 아니라 잘못된 데이터 접근 패턴 때문입니다.
사용자가 실제로 하는 작업과 일치하는 인덱스부터 시작하세요. 목록이 주로 status로 필터링되고 created_at으로 정렬된다면 두 가지를 모두 지원하는 인덱스가 필요합니다. 그렇지 않으면 데이터베이스는 예상보다 훨씬 많은 행을 스캔한 뒤 정렬해야 해서 비용이 급증합니다.
보통 가장 큰 이득을 주는 수정:
tenant_id, status, created_at).OFFSET 페이지 대신 키셋(커서) 페이지네이션 선호. OFFSET은 건너뛰기 위해 많은 행을 걷게 만듭니다.간단한 예: 고객 이름, 상태, 금액, 날짜를 보여주는 Orders 테이블이라면 목록 뷰에서 모든 관련 테이블을 조인해 전체 주문 메모를 가져오지 마세요. 테이블에 사용되는 컬럼만 반환하고 나머지는 사용자가 주문을 클릭할 때 별도 요청으로 불러오세요.
Koder.ai 같은 플랫폼으로 빌드한다면 UI가 채팅에서 생성되더라도 이 마인드를 유지하세요. 생성된 API 엔드포인트가 커서 페이지네이션과 선택적 필드 지원을 하도록 하여 테이블이 커져도 데이터베이스 작업이 예측 가능하도록 하세요.
목록 페이지가 오늘 느리다면 모든 것을 처음부터 다시 쓰지 마세요. 정상적인 사용 흐름을 잠금하고 그 경로를 최적화하세요.
기본 뷰 정의. 기본 필터, 정렬 순서, 표시 컬럼을 선택하세요. 목록은 기본적으로 모든 것을 보여주려 할 때 느려집니다.
사용법에 맞는 페이징 방식 선택. 사용자가 대부분 처음 몇 페이지만 훑는다면 고전적 페이지네이션이 괜찮습니다. 사용자가 깊게 들어가거나(200페이지 이상) 얼마나 멀리 가든 성능을 안정적으로 유지해야 한다면 키셋 페이지네이션을 사용하세요(created_at + id 같은 안정적 정렬 기반).
테이블 본문에 가상화 추가. 백엔드가 빠르더라도 브라우저는 너무 많은 행을 렌더링하면 버티지 못합니다.
검색과 필터를 즉각적으로 느끼게 만들기. 입력을 디바운스해 매 키 입력마다 요청을 보내지 마세요. 필터 상태를 URL이나 단일 공유 상태 저장소에 보관해 새로고침, 뒤로 가기, 뷰 공유가 안정적으로 작동하게 하세요. 마지막 성공 결과를 캐시해 테이블이 텅 비었다가 깜박거리지 않게 하세요.
측정 후 쿼리와 인덱스 튜닝. 서버 시간, DB 시간, 페이로드 크기, 렌더 시간 등을 로깅한 뒤 쿼리를 정리하세요: 표시하는 컬럼만 선택하고, 필터를 빨리 적용하며, 기본 필터+정렬을 지원하는 인덱스를 추가하세요.
예: 10만 개 티켓이 있는 내부 지원 대시보드는 기본을 Open, 내 팀에 할당, 최신순 정렬, 6개 컬럼 표시로 정하세요. 티켓 id, subject, assignee, status, 타임스탬프만 가져오고 키셋 페이지네이션과 가상화를 적용하면 DB와 UI 모두 예측 가능해집니다.
Koder.ai로 내부 도구를 만든다면 이 계획은 반복-확인 워크플로에 잘 맞습니다: 뷰를 조정하고 스크롤과 검색을 테스트한 뒤 쿼리를 튜닝해 화면이 빠르게 유지되는지 확인하세요.
목록 화면을 망가뜨리는 가장 빠른 방법은 10만 행을 보통 페이지 데이터처럼 취급하는 것입니다. 대부분 느린 대시보드는 몇 가지 예측 가능한 함정이 있습니다.
가장 큰 문제 중 하나는 모든 것을 렌더하고 CSS로 숨기는 것입니다. 겉으로는 50개만 보이는 것처럼 보여도 브라우저는 10만 개의 DOM 노드를 생성하고 측정하며 스크롤 시 리페인트 비용을 지불합니다. 긴 목록이 필요하면 실제로 보이는 것만 렌더(가상화)하고 행 컴포넌트를 단순하게 유지하세요.
검색은 매 키 입력이 전체 테이블 스캔을 트리거할 때 조용히 성능을 망가뜨립니다. 필터가 인덱스로 지원되지 않거나 너무 많은 컬럼을 검색하거나 큰 텍스트 필드에 contains 쿼리를 무계획으로 실행하면 발생합니다. 좋은 규칙: 사용자가 먼저 손을 대는 필터는 UI 상 편의성뿐 아니라 DB에서도 저렴해야 합니다.
또 다른 흔한 문제는 목록에 요약만 필요할 때 전체 레코드를 가져오는 것입니다. 목록 행은 보통 5~12개 필드만 필요하지 전체 객체나 긴 설명, 관련 데이터를 다 필요로 하지 않습니다. 불필요한 데이터를 가져오면 DB 작업, 네트워크 시간, 프런트엔드 파싱 비용이 증가합니다.
내보내기와 총계는 메인 스레드에서 계산하거나 무거운 요청을 기다리면 UI를 멈추게 할 수 있습니다. UI를 인터랙티브하게 유지하세요: 내보내기는 백그라운드에서 시작하고 진행률을 표시하며, 모든 필터 변경마다 총계를 재계산하지 마세요.
마지막으로, 너무 많은 정렬 옵션은 역효과를 냅니다. 모든 컬럼으로 정렬을 허용하면 큰 결과셋을 메모리에서 정렬하거나 DB를 느린 실행 계획으로 몰아넣게 됩니다. 정렬은 소수의 인덱스가 있는 컬럼으로 제한하고 기본 정렬이 실제 인덱스와 일치하도록 하세요.
빠른 직관 점검:
목록 성능을 일회성 수정이 아니라 제품 기능으로 취급하세요. 실제 사람들이 실제 데이터로 스크롤하고 필터링하고 정렬할 때 빠르게 느껴져야 진짜로 빠른 목록입니다.
이 체크리스트로 올바른 문제를 고쳤는지 확인하세요:
간단한 현실 점검: 목록을 열고 10초간 스크롤한 뒤 흔한 필터(예: Status: Open)를 적용해 보세요. UI가 멈춘다면 문제는 보통 렌더링(너무 많은 DOM 행) 또는 업데이트마다 발생하는 무거운 클라이언트 측 변환(정렬, 그룹화, 포맷팅)입니다.
다음 단계(순서대로):
Koder.ai(koder.ai)로 이걸 빌드한다면 Planning Mode에서 시작하세요: 정확한 목록 컬럼, 필터 필드, API 응답 형태를 먼저 정의하세요. 그런 다음 스냅샷과 롤백을 사용해 실험이 화면을 느리게 하면 되돌릴 수 있게 반복하세요.
목표를 “모든 것을 로드”에서 “유용한 첫 행을 빠르게 보여주기”로 바꾸는 것부터 시작하세요. 전체 데이터셋을 한 번에 불러오지 않더라도 필터, 정렬, 스크롤 시 반응이 빠르면 페이지는 빠르게 느껴집니다.
로드나 필터 변경 후 첫 행이 보이는 시간, 필터/정렬이 업데이트되는 시간, 응답 페이로드 크기, 느린 DB 쿼리(특히 넓은 SELECT와 COUNT), 그리고 브라우저 메인스레드 스파이크를 측정하세요. 이 숫자들은 사용자가 느끼는 ‘지연감’과 직접 연결됩니다.
같은 필터와 정렬로 백엔드가 임시로 20행만 반환하도록 제한해 보세요. 빨라진다면 병목은 주로 쿼리 비용이나 페이로드 크기입니다. 여전히 느리면 렌더링, 포맷팅, 또는 행당 클라이언트 작업을 의심하세요.
한 번에 수천 개 행을 DOM에 렌더링하지 말고, 행 컴포넌트를 단순하게 유지하며 고정 행 높이를 사용하는 것이 가장 쉬운 개선입니다. 또한 화면에 보이지 않는 행의 무거운 포맷팅 작업은 지연시키고 캐시하세요.
가상화는 실제로 보이는 행(약간의 버퍼 포함)만 마운트하고 동일한 DOM 요소를 재사용합니다. 스크롤이 많거나 행이 무거울 때 유용하지만, 행 높이가 일정하지 않거나 레이아웃이 복잡하면 추가 조치가 필요합니다.
대부분의 관리자/내부 워크플로에는 페이지네이션이 안전한 기본값입니다. 무한 스크롤은 탐색이나 메모리 측면에서 문제를 만들 수 있습니다. 로드 더하기 버튼처럼 페이지 기반 접근을 유지하는 중간 대안도 고려하세요.
오프셋 페이지네이션은 구현이 쉽지만 깊은 페이지로 갈수록 느려질 수 있습니다(page=10&size=50 같은 방식). 키셋(커서) 페이지네이션은 보통 더 빠르고 일관된 성능을 유지하지만 정확한 페이지 점프에는 적합하지 않을 수 있습니다.
모든 키 입력마다 요청을 보내지 마세요. 입력을 디바운스하고, 새 요청이 시작되면 이전 요청을 취소하세요. 또한 기본 필터를 좁혀 첫 쿼리가 작은 결과를 반환하도록 하는 것이 중요합니다(예: 최근 7일, 내 항목).
목록이 실제로 렌더하는 필드만 반환하세요(보통 id, 레이블, 상태, 담당자, 타임스탬프 등 소수). 큰 텍스트, JSON 블롭과 같은 데이터는 상세 뷰에서 필요할 때 불러오세요.
기본 필터와 정렬이 실제 사용 방식과 일치하도록 하고, 그 패턴을 지원하는 인덱스를 추가하세요(예: 테넌트/필터 필드 + 정렬 열의 복합 인덱스). 전체 개수는 필수가 아니므로 캐시하거나 근사값을 보여주는 것도 고려하세요.