들어가며
최근 간단하게 대용량 데이터를 가공하여 대시보드를 구현해볼 좋은 기회가 있었다. 데이터를 받아오고 처리하는 과정에서 사용성을 해치지 않는 것이 주 목적이었다. 이를 위해 웹워커나, Transferable Object, 인덱싱을 활용하는 등 다양한 개념을 적용해보았다. 이전에는 해보지 않았던 작업이라 그런지 새로운 개념을 배우고 이를 직접 적용하며 어떻게 하면 효율적으로 데이터를 연산해서 보여줄지를 고민하며 정말 즐겁게 작업했다. 이번 글에서는 이 과정을 통해 배운 것들, 시도해본 것들을 기록해보려 한다.
주어진 조건
- API의 응답 크기 약 10MB(API를 수정할 수는 없음)
- 두 API(제품과 매출 데이터)의 응답 데이터를 제품 id를 기준으로 joining 필요
- 데이터를 가공하여 다양한 형태의 그래프 및 표의 형식으로 UI를 구현하고, 그 안에서 검색, 필터링하여 인사이트를 얻을 수 있도록 구성
챌린지1: 대용량 데이터를 효율적으로 처리하기
1) 웹워커의 도입과 그 이유
“대용량 데이터를 처리하면서도 사용성을 해치지 않는 것”이라는 목표를 고려했을 때 가장 먼저 떠올린 해결책은 웹워커(Web Worker) 활용이었다. 웹워커는 간단히 말해 브라우저 안에서 돌아가는 작은 ‘보조 작업자 스레드’다. 보통 자바스크립트 코드는 메인 스레드 위에서 실행되는데, 여기서는 렌더링/이벤트 처리/로직 실행이 모두 싱글 스레드 위에서 처리되기 때문에 연산이 길어지면 UI 블로킹이 발생한다. 웹워커는 이와 분리된 별도의 스레드를 제공해서, 무거운 연산을 맡겨도 메인 스레드는 계속 부드럽게 동작할 수 있게 해준다. 다만 웹워커에서는 DOM에는 직접 접근할 수 없고, 메인 스레드와는 postMessage
로만 데이터를 주고받는다는 것에 주의하여 활용해야한다.
웹워커의 이러한 이점을 활용하여 데이터 조인, 집계 같은 CPU 집약적 연산을 워커에게 위임하면 메인 스레드는 UI 렌더링과 사용자 이벤트 처리에 집중할 수 있을테니, 사용자가 대시보드를 탐색할 때 발생하는 끊김을 줄이고 더 부드러운 경험을 제공할 수 있어 꽤 좋은 솔루션이 될 수 있겠다고 생각했다. (하지만 결론적으로 이 방식은 그다지 유의미한 솔루션이 되진 않았는데 이유는 후술하겠다.)
2) 웹워커에게 어디까지 책임을 위임할 것인가?
웹워커를 도입하면서 가장 고민했던 부분 중 하나는, 워커에게 어디까지 책임을 맡길 것인가였다. 처음에는 데이터를 가져오는 fetch 단계부터 워커가 담당하면 메인 스레드의 부담을 줄일 수 있지 않을까 생각했다. 하지만 곧 이 방식은 여러 단점이 있다는 걸 알게 되었다. 필자가 프로젝트 전반에서 데이터 요청을 관리하기 위해 사용한 도구는 TanStack Query였는데, 만약 워커 안에서 fetch를 해버리면 캐싱, 요청 중복 방지, 상태 관리 같은 TanStack Query의 장점을 온전히 활용할 수 없게 된다. 또한 코드의 일관성을 생각했을 때도, 모든 API 호출 방식을 메인 스레드에서 통일해 두는 편이 유지보수와 가독성 측면에서 더 낫다고 판단했다. 이러한 판단 하에 fetching은 메인 스레드의 책임으로 남기기로 했다.
다만 응답을 워커로 전달하는 과정에서는 추가적인 고민이 필요했다. API 응답은 수 MB 단위의 대용량 데이터였고, 이를 한 번에(batch) 전달하면 메모리 사용량이 순간적으로 치솟을 수 있었다. 이 문제를 피하기 위해 선택한 방법이 스트리밍 전송이었다. 응답이 도착하는 대로 chunk 단위로 잘라 워커로 전달하면, 메모리를 한 번에 크게 점유하지 않아도 되고 워커는 도착하는 즉시 데이터를 가공하기 시작할 수 있다.
3) 웹워커 사용의 trade off
하지만 웹워커를 도입한다고 해서 모든 문제가 자동으로 해결되는 것은 아니었다. 워커가 별도의 스레드에서 연산을 수행하더라도, 결국은 메인 스레드에서 fetch해온 대용량 데이터를 워커로 넘겨야 하기 때문이다. 이 과정에서 데이터가 어떻게 전송되느냐가 중요한데, 단순히 postMessage
로 객체를 넘기면 브라우저는 데이터를 복사해서 전달한다. 작은 데이터라면 큰 문제가 되지 않지만, 수 MB 단위의 응답에서는 이야기가 달라진다.
예를 들어 API에서 받아온 응답이 10MB라면, 이를 워커로 보낼 때 메모리에 원본과 복사본이 동시에 존재하게 된다. 그 결과 순간적으로 20MB 이상의 메모리를 점유하게 되고, 복사 과정에서 CPU 부하와 전송 지연까지 발생한다. 데이터 크기가 더 커질수록 이런 오버헤드는 기하급수적으로 늘어난다. 결국 “UI 블로킹을 막겠다”는 목적으로 도입한 웹워커가, 오히려 성능 병목을 다른 지점에서 만들어낼 수 있다는 것이다.
이 문제를 완화하기 위해 활용한 것이 Transferable Object였다. postMessage
두 번째 인자로 transferable을 지정하면, 데이터를 복사하지 않고 소유권 자체를 워커로 이전할 수 있다. 이렇게 하면 메모리 사용량이 불필요하게 증가하지 않고, 데이터 전송 속도도 훨씬 빨라진다. 다만 이 경우 메인 스레드 측에서는 해당 데이터를 더 이상 사용할 수 없게 되므로, 언제 어떤 시점에 소유권을 넘길지를 신중하게 결정해야 했다.
worker.postMessage({ type: "stream-chunk", chunk: value }, [value.buffer]);
필자의 경우에는 메인스레드에서 fetch해온 데이터를 워커로 넘긴 뒤, 다시 메인스레드에서 활용할 필요가 없었다. 따라서 고민할 여지 없이 transferable을 적용할 수 있었고, 이를 통해 전송 효율을 개선할 수 있었다.
4) 하지만 정말 웹워커를 사용해야할까?
이번 프로젝트에서 가장 중요한 과제는 대용량 데이터를 연산하고 이를 최적화하는 일이었다. 그래서 처음에는 “웹워커가 반드시 필요하지 않을까?”라는 생각으로 접근했지만, 결과적으로는 꼭 그렇지만은 않았다. 웹워커를 도입한 가장 큰 이유는 UI 블로킹을 방지하기 위해서였는데, 실제로는 웹워커를 사용하든 하지 않든 데이터 연산이 모두 끝나야 유의미한 UI를 렌더링할 수 있었기 때문이다.
물론 장기적인 관점에서는 이야기가 달라진다. 애플리케이션 규모가 더 커지고, 데이터 연산 외에도 다른 UI/UX 요소들(추가 API를 통한 보조 정보 렌더링이나 복잡한 상호작용 처리 등)이 동시에 수행되는 상황이라면 웹워커의 효과는 분명히 커질 것이다. 그러나 이번 프로젝트처럼 화면의 99%가 주어진 API로 가져오는 데이터 연산에만 집중된 구조에서는, 웹워커 도입이 유효한 전략이라고 보기는 어려웠다. 실제로 LightHouse로 성능을 측정해보았을 때도, 웹워커를 거쳐 데이터를 처리하는 방식과 워커 없이 처리하는 방식의 결과는 거의 차이가 없었다.
챌린지2: 대용량 데이터를 효율적으로 조회하기
1) 인덱싱의 도입과 그 이유
조인된 데이터를 단순히 배열로만 관리하면, 특정 제품이나 특정 날짜에 해당하는 매출을 찾으려면 매번 Array.prototype.filter
나 find
같은 메서드로 전체 배열을 처음부터 끝까지 돌면서 조건을 검사해야 한다. 데이터가 적을 때는 큰 문제가 되지 않지만, 수천~수만 건의 데이터에서는 매 조회마다 O(n) 연산이 반복되므로 체감 속도가 급격히 떨어질 수 있다.
그래서 선택한 방법이 인덱싱이다. 인덱싱은 검색하기 좋은 보조 자료구조를 미리 만들어 두는 것인데, 예를 들어, 배열을 그대로 두는 대신 Map
이나 객체를 만들어 key를 제품 id, 날짜, 장르 같은 값으로 삼고, value에는 해당 데이터 목록을 저장해 둔다. 이렇게 해두면 조회할 때는 단순히 map.get(id)
한 번으로 원하는 데이터를 바로 찾을 수 있다. JS에서 배열 순회가 아닌 해시맵 조회를 활용하는 셈이다.
// 예시: 제품별 인덱스
const productIndex = new Map<number, JoinedRow[]>();
for (const row of joinedRows) {
if (!productIndex.has(row.productId)) {
productIndex.set(row.productId, []);
}
productIndex.get(row.productId)!.push(row);
}
// 조회할 때
const rowsForId = productIndex.get(42); // O(1)에 가까운 조회
응답 데이터를 받아온 직후, 제품별/출시일별/제품 타입별로 각각 인덱스를 만들어 두었다. 이렇게 하면 데이터 테이블에는 제품별 데이터가 바로 매핑되고, 스택형 막대 차트에는 출시일자별 집계 데이터가, 파이 차트에는 제품 타입별 데이터가 곧바로 연결될 수 있었다. 결국 한 번의 인덱싱 작업을 통해, 서로 다른 형태의 시각화와 조회 기능을 빠르게 지원할 수 있는 기반이 마련된 셈이다.
2) 인덱싱의 명과 암
인덱싱을 도입하면서 가장 크게 체감한 장점은 조회 속도였다. 특정 제품의 매출을 확인하고 싶을 때는 단순히 productIndex.get(id)
한 줄로 거의 O(1)에 가까운 속도로 데이터를 가져올 수 있었고, 특정 제품의 타입나 출시일자에 대한 데이터도 productTypeIndex.get(type)
이나 dateIndex.get(dateKey)
로 즉시 접근이 가능했다. 그 결과 차트나 테이블처럼 반복해서 동일한 조건으로 필터링하는 경우, 매번 배열 전체를 순회할 필요가 없어졌다. 또한 인덱스를 여러 기준으로 만들어 두니, “특정 날짜 → 특정 타입” 같은 2차 필터링도 빠르게 조합할 수 있었다. 결국 인덱싱은 데이터 테이블, 스택형 막대 차트, 파이 차트 등 서로 다른 뷰를 지탱하는 중요한 기반이 되었다.
물론 트레이드오프도 있었다. 가장 눈에 띄는 것은 메모리 사용량 증가였다. 원본 데이터 외에 별도의 인덱스 구조를 만들어 보관하다 보니, 데이터가 커질수록 메모리 소비도 늘어날 수밖에 없다. 또한 데이터가 변경되었을 때 인덱스를 함께 갱신해줘야 하는 동기화 비용도 추가된다.
결국 인덱싱은 성능을 얻는 대신 메모리와 연산비용을 감안하는 선택이었다. 이번 프로젝트에서는 실시간으로 데이터가 갱신되는 구조가 아니었고, 반복 조회 성능을 최적화 시키는 것이 최우선이었기 때문에 감수할 만한 비용이었지만, 모든 상황에서 항상 옳은 해법은 아니라는 점도 함께 배울 수 있었다.
결론
이번 프로젝트를 통해 깨달은 점은, 대용량 데이터를 다룰 때 단일한 정답은 없다는 것이었다. 웹워커, transferable object, 스트리밍 전송, 인덱싱 등 다양한 기술을 도입하면서 각각의 장점과 한계를 체감할 수 있었다. 어떤 방법은 즉각적인 성능 향상을 가져왔고, 어떤 방법은 기대와 달리 별다른 차이를 만들지 못하기도 했다. 중요한 건 기술 자체가 아니라, 어떤 맥락에서 어떤 문제를 해결하려고 쓰느냐였다.
예를 들어, 웹워커는 CPU 연산을 메인 스레드에서 분리해 UI 블로킹을 막는 데 분명 도움이 되지만, 화면 대부분이 데이터 연산으로만 이루어진 현재 구조에서는 LCP 개선에는 큰 영향을 주지 못했다. 반면에 인덱싱은 메모리를 추가로 소모하는 대가를 치르더라도 반복 조회 성능을 극적으로 개선해주었고, 스트리밍 전송은 메모리 피크를 줄이는 데 효과적이었다. 이렇게 각 접근법이 가진 trade-off를 직접 부딪히며 경험한 것이 이번 프로젝트의 가장 큰 수확이었다.
무엇보다 이번 과정을 통해 배운 건, 성능 최적화에서 중요한 것은 “지금 내 상황에서 진짜 필요한가?”를 먼저 묻는 태도라는 점이다. 기술은 그 자체로 만능 해답이 아니라, 문제의 성격과 맥락에 따라 적절히 선택되어야 한다. 앞으로 더 복잡하고 다양한 기능을 가진 애플리케이션을 개발하게 된다면, 이번에 시도해본 전략들을 떠올릴 것 같다. 이번 경험은 단순히 성능 실험을 넘어, 기술적 선택을 바라보는 시야를 넓혀주는 소중하고, 그리고 무엇보다도 개발의 즐거움을 다시 느낄 수 있었던 경험이었다.