웹 애플리케이션에서 대용량 데이터를 처리해야 할 때는 클라이언트 사이드 필터링만으로는 한계가 있습니다. 데이터가 수만 개 또는 그 이상으로 증가하면 성능 이슈가 발생하고 사용자 경험이 저하될 수 있습니다. 이번 포스팅에서는 대용량 데이터셋을 효율적으로 처리하기 위한 다양한 전략과 기법을 살펴보겠습니다.
1. 서버 사이드 페이지네이션
가장 기본적인 접근법은 서버 사이드 페이지네이션입니다. 이 방식은 전체 데이터셋을 한 번에 로드하지 않고, 필요한 일부만 서버에서 가져옵니다.
구현 방법:
// 클라이언트 측 코드
async function loadPage(pageNumber, pageSize) {
const response = await fetch(`/api/data?page=${pageNumber}&size=${pageSize}`);
const data = await response.json();
renderTable(data.items);
updatePagination(data.totalPages, pageNumber);
}
// 페이지네이션 UI 업데이트
function updatePagination(totalPages, currentPage) {
const paginationElement = document.querySelector('.pagination');
paginationElement.innerHTML = '';
for (let i = 1; i <= totalPages; i++) {
const pageButton = document.createElement('button');
pageButton.textContent = i;
pageButton.classList.toggle('active', i === currentPage);
pageButton.addEventListener('click', () => loadPage(i, 20));
paginationElement.appendChild(pageButton);
}
}
// 초기 페이지 로드
loadPage(1, 20);
서버 측에서는 다음과 같이 처리합니다:
// Express.js 예시
app.get('/api/data', (req, res) => {
const page = parseInt(req.query.page) || 1;
const size = parseInt(req.query.size) || 20;
const skip = (page - 1) * size;
// 데이터베이스에서 일부 데이터만 가져오기
db.collection('items')
.find()
.skip(skip)
.limit(size)
.toArray((err, items) => {
if (err) {
return res.status(500).json({ error: err.message });
}
// 전체 아이템 개수 구하기
db.collection('items').countDocuments((err, total) => {
if (err) {
return res.status(500).json({ error: err.message });
}
const totalPages = Math.ceil(total / size);
res.json({
items,
totalItems: total,
totalPages,
currentPage: page
});
});
});
});
2. 무한 스크롤링
긴 목록을 위한 현대적인 UI 패턴으로, 사용자가 페이지 하단에 도달할 때마다 새로운 데이터를 로드하는 방식입니다.
구현 방법:
let page = 1;
const pageSize = 20;
let loading = false;
let allDataLoaded = false;
// 스크롤 이벤트 리스너
window.addEventListener('scroll', () => {
// 페이지 하단에 도달했는지 확인
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 200) {
if (!loading && !allDataLoaded) {
loadMoreData();
}
}
});
async function loadMoreData() {
loading = true;
showLoadingIndicator();
try {
const response = await fetch(`/api/data?page=${page}&size=${pageSize}`);
const data = await response.json();
if (data.items.length === 0) {
allDataLoaded = true;
hideLoadingIndicator();
return;
}
// 새 데이터를 기존 테이블에 추가
appendToTable(data.items);
page++;
} catch (error) {
console.error('데이터 로드 오류:', error);
} finally {
loading = false;
hideLoadingIndicator();
}
}
// 로딩 인디케이터 관리
function showLoadingIndicator() {
const indicator = document.querySelector('.loading');
indicator.style.display = 'block';
}
function hideLoadingIndicator() {
const indicator = document.querySelector('.loading');
indicator.style.display = 'none';
}
// 초기 데이터 로드
loadMoreData();
3. 가상 스크롤링(Virtual Scrolling)
가상 스크롤링은 실제로 화면에 표시되는 요소만 DOM에 렌더링하고, 나머지는 가상으로 처리하는 기법입니다. 수만 개의 행이 있더라도 실제로는 화면에 보이는 20-30개만 DOM에 존재하게 됩니다.
직접 구현 예시:
class VirtualScroller {
constructor(options) {
this.element = options.element;
this.height = options.height;
this.rowHeight = options.rowHeight;
this.totalRows = options.totalRows;
this.rowRenderer = options.rowRenderer;
this.buffer = options.buffer || 5;
this.viewportHeight = this.height;
this.totalHeight = this.rowHeight * this.totalRows;
this.visibleRowCount = Math.ceil(this.viewportHeight / this.rowHeight) + this.buffer * 2;
this.scrollTop = 0;
this.startIndex = 0;
this.setupElements();
this.bindEvents();
this.render();
}
setupElements() {
this.element.style.height = `${this.height}px`;
this.element.style.overflow = 'auto';
this.content = document.createElement('div');
this.content.style.position = 'relative';
this.content.style.height = `${this.totalHeight}px`;
this.element.appendChild(this.content);
this.items = [];
}
bindEvents() {
this.element.addEventListener('scroll', () => {
this.onScroll();
});
}
onScroll() {
const newScrollTop = this.element.scrollTop;
const delta = newScrollTop - this.scrollTop;
this.scrollTop = newScrollTop;
const newStartIndex = Math.floor(this.scrollTop / this.rowHeight) - this.buffer;
const startIndex = Math.max(0, newStartIndex);
if (startIndex !== this.startIndex) {
this.startIndex = startIndex;
this.render();
}
}
render() {
// 화면에 보이는 행의 범위 계산
const endIndex = Math.min(this.startIndex + this.visibleRowCount, this.totalRows);
// 기존 아이템 제거
this.items.forEach(item => this.content.removeChild(item.element));
this.items = [];
// 새 아이템 생성
for (let i = this.startIndex; i < endIndex; i++) {
const item = {
index: i,
element: this.rowRenderer(i)
};
item.element.style.position = 'absolute';
item.element.style.top = `${i * this.rowHeight}px`;
item.element.style.height = `${this.rowHeight}px`;
item.element.style.left = '0';
item.element.style.right = '0';
this.content.appendChild(item.element);
this.items.push(item);
}
}
}
// 사용 예시
const tableBody = document.querySelector('.table-container');
const totalRows = 100000; // 10만 행
const virtualScroller = new VirtualScroller({
element: tableBody,
height: 500,
rowHeight: 40,
totalRows: totalRows,
rowRenderer: (index) => {
// 행 데이터를 가져오는 로직 (캐시 또는 서버 요청)
const rowData = getRowData(index);
const rowElement = document.createElement('div');
rowElement.className = 'table-row';
rowElement.innerHTML = `
<div class="cell">${rowData.id}</div>
<div class="cell">${rowData.name}</div>
<div class="cell">${rowData.email}</div>
`;
return rowElement;
}
});
4. 데이터 청크 로딩 및 캐싱
대량의 데이터를 여러 청크로 나누어 점진적으로 로드하고 클라이언트에 캐싱하는 방법입니다.
구현 방법:
class DataChunkManager {
constructor(options) {
this.chunkSize = options.chunkSize || 100;
this.fetchCallback = options.fetchCallback;
this.cache = new Map();
this.totalItems = options.totalItems || 0;
}
async getItem(index) {
const chunkIndex = Math.floor(index / this.chunkSize);
// 캐시에 청크가 있는지 확인
if (!this.cache.has(chunkIndex)) {
await this.fetchChunk(chunkIndex);
}
const chunk = this.cache.get(chunkIndex);
const itemIndexInChunk = index % this.chunkSize;
return chunk[itemIndexInChunk];
}
async fetchChunk(chunkIndex) {
const start = chunkIndex * this.chunkSize;
const end = Math.min(start + this.chunkSize, this.totalItems);
// 데이터 가져오기
const items = await this.fetchCallback(start, end);
// 캐시에 저장
this.cache.set(chunkIndex, items);
// 캐시 크기 제한 (예: 최대 10개 청크만 유지)
if (this.cache.size > 10) {
// 가장 먼저 추가된 항목 제거 (LRU 방식)
const oldestKey = this.cache.keys().next().value;
this.cache.delete(oldestKey);
}
return items;
}
clearCache() {
this.cache.clear();
}
}
// 사용 예시
const dataManager = new DataChunkManager({
chunkSize: 100,
totalItems: 100000,
fetchCallback: async (start, end) => {
const response = await fetch(`/api/data?start=${start}&end=${end}`);
return response.json();
}
});
// 가상 스크롤러와 함께 사용
const getRowData = async (index) => {
return await dataManager.getItem(index);
};
5. WebWorker를 활용한 백그라운드 처리
무거운 데이터 처리 작업을 메인 스레드에서 분리하여 UI 응답성을 유지하는 방법입니다.
구현 방법:
// main.js
let worker;
function initWorker() {
worker = new Worker('data-worker.js');
worker.onmessage = function(e) {
const { type, data } = e.data;
switch (type) {
case 'FILTER_RESULTS':
updateTable(data.results);
break;
case 'SORT_RESULTS':
updateTable(data.results);
break;
case 'DATA_LOADED':
hideLoadingIndicator();
break;
case 'ERROR':
showError(data.message);
break;
}
};
}
function loadData() {
showLoadingIndicator();
worker.postMessage({
type: 'LOAD_DATA',
url: '/api/all-data'
});
}
function filterData(searchTerm) {
worker.postMessage({
type: 'FILTER_DATA',
searchTerm
});
}
function sortData(column, direction) {
worker.postMessage({
type: 'SORT_DATA',
column,
direction
});
}
// 페이지 로드 시 WebWorker 초기화
window.addEventListener('load', initWorker);
// data-worker.js
let allData = [];
self.addEventListener('message', function(e) {
const { type, ...params } = e.data;
switch (type) {
case 'LOAD_DATA':
loadData(params.url);
break;
case 'FILTER_DATA':
filterData(params.searchTerm);
break;
case 'SORT_DATA':
sortData(params.column, params.direction);
break;
}
});
async function loadData(url) {
try {
const response = await fetch(url);
allData = await response.json();
self.postMessage({
type: 'DATA_LOADED',
data: {
count: allData.length
}
});
} catch (error) {
self.postMessage({
type: 'ERROR',
data: {
message: error.message
}
});
}
}
function filterData(searchTerm) {
if (!searchTerm) {
self.postMessage({
type: 'FILTER_RESULTS',
data: {
results: allData.slice(0, 100) // 첫 100개만 반환
}
});
return;
}
const searchTermLower = searchTerm.toLowerCase();
// 백그라운드에서 필터링 수행
const results = allData.filter(item =>
item.name.toLowerCase().includes(searchTermLower) ||
item.description.toLowerCase().includes(searchTermLower)
);
self.postMessage({
type: 'FILTER_RESULTS',
data: {
results: results.slice(0, 100), // 첫 100개만 반환
totalCount: results.length
}
});
}
function sortData(column, direction) {
const sortedData = [...allData].sort((a, b) => {
if (direction === 'asc') {
return a[column] > b[column] ? 1 : -1;
} else {
return a[column] < b[column] ? 1 : -1;
}
});
self.postMessage({
type: 'SORT_RESULTS',
data: {
results: sortedData.slice(0, 100) // 첫 100개만 반환
}
});
}
6. 서버 사이드 필터링과 클라이언트 사이드 필터링의 하이브리드 접근법
대용량 데이터를 효율적으로 처리하기 위해 서버 사이드와 클라이언트 사이드 기법을 결합하는 방법입니다.
구현 방법:
let cachedData = [];
let lastServerQuery = '';
const CACHE_THRESHOLD = 1000; // 서버 필터링 결과가 1000개 이하일 때만 클라이언트 필터링 사용
async function handleSearch(searchTerm) {
// 정확한 필터링을 위한 서버 요청
if (searchTerm.length >= 3 || !lastServerQuery) {
showLoadingIndicator();
try {
const response = await fetch(`/api/search?q=${encodeURIComponent(searchTerm)}`);
const data = await response.json();
cachedData = data.items;
lastServerQuery = searchTerm;
renderTable(cachedData);
hideLoadingIndicator();
return;
} catch (error) {
console.error('서버 검색 오류:', error);
hideLoadingIndicator();
return;
}
}
// 서버에서 가져온 데이터가 너무 많으면 항상 서버 필터링 사용
if (cachedData.length > CACHE_THRESHOLD) {
// 서버 요청 (위와 동일)
return;
}
// 마지막 서버 쿼리 결과에서 클라이언트 필터링
const filteredData = cachedData.filter(item =>
item.name.toLowerCase().includes(searchTerm.toLowerCase())
);
renderTable(filteredData);
}
// 디바운스 적용
const debouncedSearch = debounce(handleSearch, 300);
// 검색 입력 이벤트 처리
document.querySelector('#search').addEventListener('input', (e) => {
debouncedSearch(e.target.value);
});
7. IndexedDB를 활용한 클라이언트 사이드 데이터 저장
대량의 데이터를 브라우저의 IndexedDB에 저장하여 오프라인 지원 및 빠른 접근을 가능하게 하는 방법입니다.
구현 방법:
class IndexedDBStore {
constructor(dbName, storeName) {
this.dbName = dbName;
this.storeName = storeName;
this.db = null;
}
async open() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, 1);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
this.db = request.result;
resolve();
};
request.onupgradeneeded = (e) => {
const db = e.target.result;
if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName, { keyPath: 'id' });
}
};
});
}
async storeData(items) {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(this.storeName, 'readwrite');
const store = transaction.objectStore(this.storeName);
transaction.onerror = () => reject(transaction.error);
transaction.oncomplete = () => resolve();
// 기존 데이터 모두 삭제
store.clear();
// 새 데이터 저장
items.forEach(item => {
store.add(item);
});
});
}
async getAllData() {
return new Promise((resolve, reject) => {
const transaction = this.db.transaction(this.storeName, 'readonly');
const store = transaction.objectStore(this.storeName);
const request = store.getAll();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
});
}
async findByField(fieldName, value) {
const allItems = await this.getAllData();
return allItems.filter(item => item[fieldName].includes(value));
}
}
// 사용 예시
async function initDataStore() {
const dataStore = new IndexedDBStore('AppDB', 'items');
await dataStore.open();
// 최초 데이터 로드 및 저장
try {
const response = await fetch('/api/all-data');
const data = await response.json();
await dataStore.storeData(data);
console.log('데이터가 IndexedDB에 저장됨');
} catch (error) {
console.error('초기 데이터 로드 실패:', error);
}
return dataStore;
}
// 검색 구현
async function handleLocalSearch(searchTerm, dataStore) {
if (!searchTerm) {
const allData = await dataStore.getAllData();
renderTable(allData.slice(0, 50)); // 처음 50개만 표시
return;
}
const results = await dataStore.findByField('name', searchTerm);
renderTable(results);
}
8. React 또는 Vue와 같은 프레임워크의 최적화 기능 활용
최신 JavaScript 프레임워크는 대용량 데이터 렌더링을 위한 다양한 최적화 도구를 제공합니다.
React 예시 (react-window 라이브러리 사용):
import React, { useState, useEffect } from 'react';
import { FixedSizeList as List } from 'react-window';
import InfiniteLoader from 'react-window-infinite-loader';
function VirtualizedTable({ totalCount }) {
const [items, setItems] = useState({});
const [loading, setLoading] = useState(false);
// 아이템 로드 함수
const loadMoreItems = async (startIndex, stopIndex) => {
setLoading(true);
try {
const response = await fetch(`/api/data?start=${startIndex}&end=${stopIndex}`);
const data = await response.json();
setItems(prevItems => ({
...prevItems,
...data.items.reduce((acc, item, index) => {
acc[startIndex + index] = item;
return acc;
}, {})
}));
} catch (error) {
console.error('데이터 로드 오류:', error);
} finally {
setLoading(false);
}
};
// 아이템이 로드되었는지 확인
const isItemLoaded = index => !!items[index];
// 행 렌더링 함수
const Row = ({ index, style }) => {
const item = items[index];
if (!item) {
return (
<div style={style} className="row loading">
로딩 중...
</div>
);
}
return (
<div style={style} className="row">
<div className="cell">{item.id}</div>
<div className="cell">{item.name}</div>
<div className="cell">{item.email}</div>
</div>
);
};
return (
<InfiniteLoader
isItemLoaded={isItemLoaded}
itemCount={totalCount}
loadMoreItems={loadMoreItems}
threshold={10}
>
{({ onItemsRendered, ref }) => (
<List
className="virtual-table"
height={500}
itemCount={totalCount}
itemSize={40}
onItemsRendered={onItemsRendered}
ref={ref}
width="100%"
>
{Row}
</List>
)}
</InfiniteLoader>
);
}
9. GraphQL을 활용한 데이터 요청 최적화
필요한 데이터만 정확히 요청하여 네트워크 부담을 줄이는 방법입니다.
구현 예시:
// Apollo Client 사용
import { ApolloClient, InMemoryCache, gql } from '@apollo/client';
const client = new ApolloClient({
uri: '/graphql',
cache: new InMemoryCache()
});
// 필요한 항목만 요청
const fetchItems = async (page, pageSize, filters = {}) => {
const { searchTerm, category } = filters;
try {
const { data } = await client.query({
query: gql`
query GetItems($page: Int!, $pageSize: Int!, $search: String, $category: String) {
items(page: $page, pageSize: $pageSize, search: $search, category: $category) {
results {
id
name
price
category
}
totalCount
pageInfo {
hasNextPage
totalPages
}
}
}
`,
variables: {
page,
pageSize,
search: searchTerm || undefined,
category: category || undefined
}
});
return data.items;
} catch (error) {
console.error('GraphQL 쿼리 오류:', error);
throw error;
}
};
10. 프로그레시브 데이터 로딩 (필수 데이터 먼저)
중요한 정보를 먼저 로드하고 추가 정보는 나중에 로드하는 전략입니다.
구현 예시:
// 기본 정보만 먼저 로드
async function loadTable() {
showLoadingIndicator();
try {
// 첫번째 요청 - 기본 정보만
const response = await fetch('/api/data/basic');
const basicData = await response.json();
// 기본 정보로 먼저 테이블 렌더링
renderTableWithBasicData(basicData);
hideLoadingIndicator();
// 사용자에게 테이블이 보이는 동안 상세 정보 로드
loadDetailData(basicData.map(item => item.id));
} catch (error) {
console.error('데이터 로드 오류:', error);
hideLoadingIndicator();
}
}
// 상세 정보를 백그라운드에서 로드
async function loadDetailData(ids) {
try {
// 상세 정보 요청
const response = await fetch('/api/data/details', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ ids })
});
const detailsData = await response.json();
// 상세 정보로 테이블 업데이트
updateTableWithDetails(detailsData);
} catch (error) {
console.error('상세 정보 로드 오류:', error);
}
}
결론
대용량 데이터를 다루는 웹 애플리케이션 개발에서는 단일 접근법으로 모든 문제를 해결하기 어렵습니다. 대신, 여러 기법을 조합하여 최적의 사용자 경험을 제공하는 것이 중요합니다. 데이터의 특성, 사용자의 요구사항, 기술 스택 등을 고려하여 적절한 전략을 선택하세요.
효과적인 대용량 데이터 처리 전략을 위한 핵심 포인트:
- 필요한 데이터만 로드: 한 번에 모든 데이터를 로드하지 말고 필요한 부분만 가져옵니다.
- 점진적인 데이터 로드: 페이지네이션, 무한 스크롤, 청크 로딩 등을 활용합니다.
- 메모리 관리: 캐시 크기를 제한하고 불필요한 데이터를 정리합니다.
- 렌더링 최적화: 가상 스크롤링을 활용하여 DOM 요소 수를 제한합니다.
- 병렬 처리: WebWorker를 활용하여 UI 응답성을 유지합니다.
- 하이브리드 접근법: 서버와 클라이언트의 장점을 조합합니다.
- 모던 프레임워크 활용: React, Vue 등의 최적화 기능을 활용합니다.
이러한 전략을 적절히 조합하면 대용량 데이터도 효율적이고 반응성 높은 웹 애플리케이션을 구현할 수 있습니다.