이벤트루프와 캐싱을 활용한 데이터 필터링&정렬 성능 최적화
프론트엔드 개발을 하다 보면 클라이언트에서 데이터를 필터링하고 정렬하는 작업을 수행해야 할 때가 많습니다. 특히 API 호출 후 데이터를 클라이언트에서 처리해야 하는 경우, 데이터 양이 많아질수록 필터링, 검색, 정렬 등의 작업이 점점 무거워집니다. 저희 서비스는 API로 데이터를 가져온 후 거의 모든 필터링, 검색, 정렬을 프론트에서 처리하고 있는데요. 물론 추후 개선될 예정이지만, 현재로서는 데이터 필터링 작업에서는 이 과정에서 몇가지 문제들이 나타났습니다. 데이터가 방대하다 보니 UI가 잠깐 멈추거나 부드럽지 못한 문제가 발생했죠. 이번 글에서는 이 문제를 해결했던 과정을 공유하려고 합니다.
문제1: 방대한 데이터 필터링 시 UI 멈춤 현상
캠프 데이터는 보통 몇백 개이지만, 많게는 수천 개 이상으로, 사용자가 필터를 적용하거나 검색어를 입력할 때마다 필터링 및 정렬 로직이 실행되어야 할 때가 있습니다. 데이터 양이 많아질수록 브라우저가 데이터를 처리하는 데 시간이 걸렸고, 그로 인해 UI가 잠깐이지만 멈추는 현상이 발생했습니다. 기존에는 데이터를 한꺼번에 처리하는 방식으로 작업을 수행했는데요, 아래는 문제가 있었던 기존 코드의 간단한 예시입니다.
기존 코드 예시: 이벤트 루프를 활용하지 않은 상태
const filteredCamps = getFilteredCamps(mergedCampList); // 필터링
const sortedCamps = getSortedCamps(filteredCamps, sortState); // 정렬
return sortedCamps; // 결과 반환
이 코드는 단순한 구조로 작성되어 있지만, 데이터 양이 많아질수록 필터링 및 정렬 작업이 메인 스레드를 차단(blocking)하게 됩니다. 결과적으로 렌더링이 중단되거나 UI가 느려지는 문제가 발생했죠.
이벤트 루프(Event Loop)란?
이 문제를 해결하기 위해 이벤트 루프를 활용했습니다. 이벤트 루프는 자바스크립트의 비동기 처리 메커니즘으로, 비동기 작업을 처리하여 메인 스레드가 차단되지 않도록 도와줍니다.
자바스크립트는 싱글 스레드 언어로, 한 번에 하나의 작업만 처리할 수 있습니다. 하지만 비동기 작업(예: API 호출, 타이머 등)을 이벤트 루프를 통해 백그라운드에서 처리하고, 완료된 작업을 다시 메인 스레드로 가져와 실행합니다. 이렇게 하면 오래 걸리는 작업이 메인 스레드를 차단하지 않으면서 동시에 다른 작업을 진행할 수 있게 됩니다.
이 개념을 데이터 처리에 적용하기 위해 데이터를 청크로 나누고, 각 청크를 비동기적으로 처리하도록 변경했습니다.
데이터를 청크로 나누는 방법
방대한 데이터를 한 번에 처리하는 대신, 데이터를 청크로 나누어 처리하면 이벤트 루프가 다른 작업을 처리할 여유를 가질 수 있습니다. 이를 통해 UI가 멈추는 현상을 방지할 수 있었죠. 아래는 데이터를 청크로 나누어 처리하는 코드 예제입니다.
청크 처리 코드
const chunkArray = (array: any[], chunkSize: number) => {
return Array.from({ length: Math.ceil(array.length / chunkSize) }, (_, i) =>
array.slice(i * chunkSize, (i + 1) * chunkSize)
);
};
// 사용 예시
const chunks = chunkArray(mergedCampList, 100);
for (const chunk of chunks) {
const filteredData = applyFilters(chunk); // 청크 단위로 필터링 처리
filteredCamps = [...filteredCamps, ...filteredData];
// 이벤트 루프에 다음 작업을 넘기기 위해 비동기 처리
await new Promise((resolve) => setTimeout(resolve, 0));
}
이 방식으로 데이터를 처리하면 각 청크 단위로 작업이 이루어지고, UI를 차단하지 않으면서도 방대한 데이터를 효율적으로 처리할 수 있습니다.
예를 들어, 1000개의 데이터를 한 번에 처리하려고 하면, 큰 상자를 한꺼번에 옮기려는 것과 비슷합니다. 이는 무겁고 시간이 오래 걸리죠. 하지만 100개씩 나누어 옮긴다면 작업은 더 부드럽고, 중간에 다른 일도 할 수 있습니다. 여기서 "100개씩 나누어 옮기는" 작업이 바로 청크 처리입니다.
문제2: 불필요한 필터링 로직 실행으로 인한 성능 저하
또 다른 문제는 필터링, 검색, 정렬 상태 중 하나만 변경되더라도 모든 로직이 다시 실행된다는 점이었습니다. 예를 들어, 검색 상태만 변경되었는데도 필터링과 정렬 로직까지 다시 실행되면서 불필요한 연산이 발생했죠. 데이터가 방대하다 보니 이러한 반복적인 연산이 성능 저하의 원인이 되었습니다.
이 문제를 해결하기 위해 캐싱을 도입했습니다. 각 상태와 관련된 중간 결과를 캐싱해, 상태가 변경되지 않았을 경우 해당 연산을 생략하도록 최적화했습니다. 이렇게 하면 변경된 부분만 재계산하고, 나머지는 이전에 계산된 결과를 재사용할 수 있습니다.
상태 비교 및 캐싱 구현
캐싱을 구현하기 위해 이전 상태와 현재 상태를 비교하는 로직을 추가했습니다. 상태 비교는 객체의 참조를 비교하여 상태가 변경되었는지 확인하는 방식으로 처리했습니다. 상태가 변경되었을 때만 해당 필터링 로직이 실행되도록 최적화한 코드입니다.
type Cache = {
prevSearchState?: SearchItemType | null;
prevSelectorState?: SelectorItemType[];
prevSortState?: SortedItemType;
filteredCampsCache?: CampListElement[];
};
const cache: Cache = {};
export const finalFilteredListAsync = async ({
searchState,
selectorState,
sortState,
campList,
}: FinalFilteredListProps): Promise<CampListElement[]> => {
// 상태 비교
const isSearchChanged = searchState !== cache.prevSearchState;
const isSelectorChanged = selectorState !== cache.prevSelectorState;
const isSortChanged = sortState !== cache.prevSortState;
// 상태 업데이트
cache.prevSearchState = searchState;
cache.prevSelectorState = selectorState;
cache.prevSortState = sortState;
let filteredCamps = cache.filteredCampsCache || campList;
// 검색 상태가 변경된 경우에만 검색 필터 실행
if (isSearchChanged) {
filteredCamps = applySearchFilter(filteredCamps, searchState);
}
// 셀렉터 상태가 변경된 경우에만 셀렉터 필터 실행
if (isSelectorChanged) {
filteredCamps = applySelectorFilter(filteredCamps, selectorState);
}
// 정렬 상태가 변경된 경우에만 정렬 실행
if (isSortChanged) {
filteredCamps = applySort(filteredCamps, sortState);
}
// 결과 캐싱
cache.filteredCampsCache = filteredCamps;
return filteredCamps;
};
- cache 객체: 이전 상태와 계산된 결과를 저장하는 역할을 합니다.
- isSearchChanged, isSelectorChanged, isSortChanged: 상태가 변경되었는지 확인하는 변수입니다. 객체의 참조를 비교하여 변경 여부를 판단합니다.
- 조건부 실행: 각 상태가 변경되었을 때만 관련 로직을 실행합니다.
- 결과 캐싱: 최종 계산된 결과를 캐시에 저장하여 다음에 재사용합니다.
만약 이전에 검색어가 "JavaScript"였고, 현재 검색어가 변경되지 않았다면 applySearchFilter 함수는 실행되지 않고, 기존의 필터링 결과를 그대로 사용합니다. 이렇게 하면 불필요한 연산을 줄일 수 있습니다.
이번 작업을 통해 프론트엔드에서 방대한 데이터를 처리할 때 성능 문제를 해결하기 위해 다양한 최적화 기법을 도입해야 한다는 점을 배웠습니다. 단순히 기능을 구현하는 데서 끝나는 것이 아니라, 성능과 사용자 경험까지 고려한 설계가 중요하다는 것을 다시 한번 느꼈습니다.