프론트엔드 개발을 하다 보면 대량의 데이터를 처리해야 하는 상황을 종종 만나는데요, 별 생각 없이 짠 반복문 하나가 애플리케이션 전체를 멈춰버릴 수 있습니다.
for (let i = 0; i < VERY_BIG_NUMBER; i++) {
// task 처리
}
이 코드가 5초 동안 실행된다고 해볼까요? 그 5초 동안 사용자는 버튼을 눌러도 반응이 없고, 스크롤도 안 되고, 화면이 그대로 얼어붙은 것처럼 보입니다. 왜 이런 일이 생기고, 어떻게 해결해야 할까요?
왜 멈추는 걸까

JavaScript는 싱글 스레드 언어입니다. 메인 스레드에서 긴 작업이 돌고 있으면, 그게 끝날 때까지 UI 렌더링이든 이벤트 처리든 다른 모든 게 줄을 서서 기다려야 해요.
브라우저는 초당 60프레임, 즉 16.67ms마다 한 프레임을 그리는 걸 목표로 하는데, 작업 하나가 이 시간을 넘기면 화면이 버벅이기 시작합니다. 5초짜리 작업이면 대략 300프레임을 통째로 날려버리는 셈이죠.
그럼 어떻게 해야 할까요 ? 핵심은 간단합니다. "한 번에 다 하지 말고, 나눠서 하자."
해결 방법
1. 작업을 청크로 나누기
가장 직관적인 방법은 큰 작업을 작은 덩어리로 쪼개는 겁니다.
async function processInChunks(totalItems, chunkSize = 1000) {
for (let i = 0; i < totalItems; i += chunkSize) {
const end = Math.min(i + chunkSize, totalItems);
for (let j = i; j < end; j++) {
// task 처리
}
// 메인 스레드에 숨 쉴 틈 주기
await new Promise(resolve => setTimeout(resolve, 0));
}
}
await processInChunks(1000000);
await new Promise(resolve => setTimeout(resolve, 0)); 이 로직을 중간에 왜 넣어준 걸까요 ?
자바스크립트는 싱글 스레드이기 때문에, for문이 도는 동안에는 함수 호출이 완전히 끝나기 전까지 멈추지 않고 실행됩니다. 100개짜리든 1000개짜리든 마찬가지예요. 함수가 동기 코드로만 되어 있으면 자바스크립트 엔진은 중간에 멈출 수 없습니다. 한 줄 한 줄 순서대로 끝까지 실행하는 게 동기 실행의 기본 동작이거든요.
그런데 await new Promise(resolve => setTimeout(resolve, 0))를 만나면 상황이 달라집니다. 이 코드는 단순히 "기다리는 코드"가 아니라, 자바스크립트 엔진에게 "여기서 콜스택을 비워줘"라고 명시적으로 요청하는 로직입니다. 콜스택이 비는 순간, 이벤트 루프가 다음에 무엇을 실행할지 고를 기회를 얻습니다. 이때 후보는 마이크로태스크 큐에 쌓인 것들, 화면을 다시 그려야 하는지 여부, 그리고 매크로태스크 큐(setTimeout 콜백 같은 것)입니다.
setTimeout(resolve, 0)은 "0ms 뒤에 바로 실행된다"는 뜻이 아니라, "매크로태스크 큐에 줄을 서고, 콜스택이 비고 마이크로태스크가 정리되고 필요하면 렌더링까지 끝난 뒤에야 차례가 와서 실행된다"는 뜻에 더 가깝습니다.
이 방법은 구현이 단순하고 기존 코드도 크게 안 바꿔도 됩니다. 진행률을 UI로 보여주기도 쉬워요. 다만 한 번씩 쉬어가기 때문에 전체 실행 시간이 늘어날 수 있고, 적당한 청크 크기를 직접 찾아야 한다는 단점이 있습니다.
직접 확인해보고 싶다면
다음 코드를 브라우저 콘솔에 실행해보면 체감할 수 있어요.
-
await 없는 버전
function withoutAwait() { for (let i = 0; i < 1000; i++) { let sum = 0; for (let j = 0; j < 5000000; j++) sum += j; // 무거운 연산 } console.log('끝'); } withoutAwait();첫 번째는 await 없이 for문만 1000번 도는 버전입니다. 실행 중에는 스크롤도, 클릭도 전혀 안 됩니다. for문이 몇 바퀴를 돌든 상관없어요.
-
await 있는 버전
async function withAwait() { for (let i = 0; i < 1000; i++) { let sum = 0; for (let j = 0; j < 5000000; j++) sum += j; await new Promise(resolve => setTimeout(resolve, 0)); } console.log('끝'); } withAwait();await를 넣은 버전은 실행 중에도 스크롤, 클릭이 됩니다. 매 바퀴마다 콜스택이 비워지기 때문이에요.
2. requestIdleCallback 활용하기
브라우저가 한가할 때만 작업을 처리하도록 맡기는 방법입니다.
requestIdleCallback이 하는 일
브라우저가 한가할 때 이 콜백 함수를 실행해달라고 예약을 거는 API입니다. requestIdleCallback(callback) 형태로, 콜백 함수를 인자로 넘겨야 하는데, 앞서 본 setTimeout이 "시간이 되면 실행해줘"였다면, 이건 시간과 상관없이 메인 스레드가 할 일이 없을 때 실행되는 거예요. 브라우저는 렌더링, 사용자 입력 처리 같은 우선순위 높은 작업을 다 처리하고 남는 시간이 있으면 그제서야 이 콜백을 실행합니다.
deadline이라는 매개변수
requestIdleCallback에 넘긴 콜백 함수는 항상 첫 번째 매개변수로 deadline 객체를 받게 되어 있어요. 이 객체는 "지금부터 얼마만큼의 여유 시간이 있는지" 알려주는 역할을 해요. deadline.timeRemaining()을 호출하면 남은 시간을 밀리초 단위로 반환합니다.
function processWithIdleCallback(items) {
let index = 0;
function processChunk(deadline) {
// 남은 시간이 0보다 크고, 아직 처리할 아이템이 남았다면 계속 수행
while (deadline.timeRemaining() > 0 && index < items.length) {
processItem(items[index]);
index++;
}
// 처리할 아이템이 남아있는 경우
if (index < items.length) {
requestIdleCallback(processChunk);
}
}
requestIdleCallback(processChunk);
}
while문이 도는 동안은 여유 시간이 있고 처리할 아이템이 남아있는 한 계속 processItem을 실행합니다. 여유 시간이 없다면(deadline.timeRemaining()이 0 이하가 되면) while문을 빠져나오고, 그 다음 if문에서 "아직 처리할 아이템이 남았는지" 확인합니다. 남았다면 requestIdleCallback(processChunk)를 다시 호출해서, "다음에 또 한가해지면 같은 함수를 또 불러줘"라고 재귀적으로 예약을 거는 거예요.
청크 나누기와 무엇이 다를까요?
앞서 살펴본 '청크 나누기' 방식이 "고정된 개수만큼 처리하고 무조건 쉰다"는 방식이라면, requestIdleCallback은 "브라우저가 허락한 시간 안에 할 수 있는 만큼만 처리하고, 시간이 다 되면 멈춘다"는 방식입니다. 브라우저의 현재 상태에 따라 한 번에 처리하는 아이템 개수가 매번 달라지기 때문에 훨씬 효율적이죠.
주의할 점
다만, 이 방식에는 주의할 점도 있습니다. 만약 브라우저가 매번 아주 짧은 유휴 시간(예: 1ms)만 제공한다면 어떻게 될까요? while문은 아이템을 한두 개만 처리하고 매번 종료될 것입니다. 이 경우 남은 아이템을 처리하기 위해 requestIdleCallback을 수천, 수만 번 재귀 호출하게 되어 처리 속도가 눈에 띄게 느려질 수 있습니다. 특히 모바일 기기의 배터리 절약 모드에서는 브라우저가 유휴 시간을 극히 짧게 잡기 때문에 이런 현상이 더 자주 발생합니다.
정리하자면, requestIdleCallback은 UI 반응성을 최우선으로 보장하면서 브라우저에게 스케줄링을 위임할 수 있는게 큰 장점입니다. 다만, 우선순위가 낮은 작업에만 적합하며, 작업 완료 시점을 정확히 예측하기 어렵다는 점을 고려해 사용해야 합니다.
3. Web Worker로 백그라운드 처리
CPU를 많이 쓰는 작업은 차라리 별도 스레드로 통째로 빼버리는 게 깔끔합니다.
지금까지 봤던 청크 나누기나 requestIdleCallback은 메인 스레드 하나를 잘게 나눠 쓰는 방식이었어요. Web Worker는 아예 다른 스레드를 하나 더 만드는 방식입니다. 메인 스레드는 UI를 담당하고, Worker 스레드는 무거운 연산을 담당해요. 둘이 동시에 돌기 때문에 Worker가 열심히 계산하는 동안에도 화면이 멈추지 않습니다.
postMessage로 데이터 주고받기
이때 데이터는 postMessage로 주고받습니다 메인 스레드와 Worker 스레드는 완전히 분리된 환경이라 변수를 직접 공유할 수 없어요. 데이터를 주고받으려면 postMessage로 메시지를 보내고, onmessage로 받는 구조를 씁니다.
// main.js
const worker = new Worker('worker.js');
worker.postMessage({ // Worker에게 데이터 전송
start: 0,
end: VERY_BIG_NUMBER,
data: myData,
});
worker.onmessage = (e) => { // Worker에서 결과 받기
console.log('작업 완료:', e.data);
};
worker.onerror = (error) => {
console.error('Worker 에러:', error);
};
// worker.js
self.onmessage = (e) => { // 메인 스레드에서 데이터 받기
const { start, end, data } = e.data;
const results = [];
for (let i = start; i < end; i++) {
results.push(heavyCalculation(data[i]));
}
self.postMessage(results); // 메인 스레드로 결과 전송
};
단점도 있어요
Worker는 메인 스레드와 완전히 분리된 환경에서 실행되기 때문에 document, window 같은 DOM에는 접근할 수 없습니다. 두 스레드가 동시에 같은 DOM을 건드리면 반영 순서를 보장할 수 없어서 아예 차단해둔 거예요. Worker는 순수한 연산만 담당하고, 결과를 메인 스레드에 돌려줘서 UI 반영은 메인 스레드가 직접 하는 구조입니다.
그리고 postMessage로 데이터를 넘길 때 내부적으로 직렬화 과정이 일어납니다. 데이터를 바이트 형태로 변환한 다음 상대 스레드에서 다시 원래 형태로 복원하는 작업인데, 이 변환이 메인 스레드에서 일어나기 때문에 대용량 데이터를 통째로 넘기면 그 순간 살짝 끊김이 생길 수 있어요. 이때는 Transferable Objects나 SharedArrayBuffer를 쓰면 복사 없이 데이터를 넘길 수 있어서 비용을 줄일 수 있습니다. 디버깅이 일반 코드보다 까다롭다는 것도 감안해야 해요.
4. Scheduler API
비교적 최근에 등장한 API로, scheduler.yield()를 쓰면 작업 중간에 브라우저에 제어권을 슬쩍 넘겨줄 수 있습니다.
async function processWithScheduler() {
for (let i = 0; i < VERY_BIG_NUMBER; i++) {
// task 처리
processItem(i);
// 1000개마다 브라우저에 제어권 반환
if (i % 1000 === 0) {
await scheduler.yield();
}
}
}
scheduler는 브라우저가 전역으로 제공하는 작업 스케줄링 API입니다. setTimeout이나 requestIdleCallback처럼 타이밍을 직접 제어하는 게 아니라, 브라우저 내부 스케줄러와 직접 대화하는 방식이에요.
그중 scheduler.yield()는 "지금 하던 작업을 잠깐 멈추고, 브라우저가 다른 급한 일을 처리할 기회를 줘"라는 뜻입니다. await와 함께 쓰면 그 줄에서 함수 실행이 일시 중단되면서 콜스택이 비워지고, 브라우저가 렌더링이나 이벤트 처리를 할 기회를 얻어요.
앞서 봤던 await new Promise(resolve => setTimeout(resolve, 0))와 하는 일이 비슷하게 느껴질 수 있는데, 차이가 있습니다. setTimeout은 매크로태스크 큐를 우회적으로 활용한 꼼수에 가까운 반면, scheduler.yield()는 브라우저 스케줄러와 직접 연결된 공식적인 방법이에요. 그리고 setTimeout은 양보하고 나서 다시 실행될 때 다른 매크로태스크들보다 우선순위가 밀릴 수 있는데, scheduler.yield()는 현재 작업의 우선순위를 그대로 유지한 채로 양보합니다.
코드도 제일 깔끔하고 브라우저가 알아서 최적화된 스케줄링을 해줍니다. 다만 비교적 최근에 표준화된 기능이라 사용 전에 브라우저 지원 범위를 한 번 확인하고 쓰는 걸 추천해요.
React를 쓰고 있다면
React 환경이라면 쓸 수 있는 옵션이 좀 더 있습니다.
React는 일반적으로 상태가 바뀌면 즉시 리렌더링을 시도하는데요, 어떤 업데이트는 "지금 당장 화면에 반영되어야 하는 것"이 있고, 어떤 업데이트는 "조금 늦어도 괜찮은 것"이 있어요. 이 둘을 같은 우선순위로 처리하면 무거운 렌더링 때문에 인풋이 버벅이는 문제가 생깁니다. React 18에서는 이 문제를 해결하기 위해 렌더링을 "긴급한 것"과 "나중에 해도 되는 것"으로 나눌 수 있게 됐어요. 긴급한 업데이트가 들어오면 진행 중이던 낮은 우선순위 렌더링을 중단하고 먼저 처리할 수 있습니다.
useDeferredValue
function SearchResults({ query }) {
const deferredQuery = useDeferredValue(query);
const results = useMemo(() => {
return hugeList.filter(item => item.includes(deferredQuery));
}, [deferredQuery]);
return <ResultList data={results} />;
}
useDeferredValue는 값에 적용하는 방식이에요. deferredQuery로 파생된 렌더링을 낮은 우선순위로 처리해줍니다. 사용자가 타이핑할 때마다 query는 즉시 바뀌지만, deferredQuery는 브라우저가 여유가 있을 때 뒤따라 바뀌는 식으로 동작해요.
한 가지 짚어두면, filter 연산 자체를 비동기로 바꿔주는 건 아닙니다. React가 그 결과로 만들어지는 렌더링을 낮은 우선순위로 처리해주는 것뿐이라, filter 함수 자체는 여전히 동기적으로 돌아가요. 그래도 사용자 입력 같은 긴급한 업데이트가 끼어들 틈을 만들어준다는 점에서 체감 반응성은 확실히 좋아집니다.
startTransition
function handleSearch(value) {
setInputValue(value); // 즉시 업데이트
startTransition(() => {
setSearchQuery(value); // 낮은 우선순위로 처리
});
}
startTransition은 상태 업데이트에 적용하는 방식이에요. 콜백 안에서 일어나는 setState를 낮은 우선순위로 처리해줍니다. setInputValue는 즉시 반영되어 타이핑이 버벅이지 않고, setSearchQuery는 브라우저가 여유가 있을 때 처리돼요.
즉, useDeferredValue가 외부에서 넘어오는 값을 다룰 때 적합하다면, startTransition은 컴포넌트 내부에서 직접 상태를 관리할 때 더 자연스럽습니다. 둘 다 결국 같은 문제를 해결하는 도구인데, 어디에 적용하느냐의 차이예요.
그래서 뭘 써야 하나
상황에 따라 최적의 방법이 다릅니다
| 상황 | 추천 방법 |
|---|---|
| 순수 계산 작업 (암호화, 이미지 처리 등) | Web Worker |
| UI 업데이트가 필요한 반복 작업 | 청크 + setTimeout |
| 검색, 필터링 등 사용자 입력 기반 | debounce + useDeferredValue |
| 우선순위가 낮은 백그라운드 작업 | requestIdleCallback |
실전 예시: 대량 데이터 테이블 렌더링
function DataTable({ data }) {
const [visibleData, setVisibleData] = useState([]);
useEffect(() => {
let cancelled = false;
async function loadData() {
const chunkSize = 100;
for (let i = 0; i < data.length; i += chunkSize) {
if (cancelled) break;
const chunk = data.slice(i, i + chunkSize);
setVisibleData(prev => [...prev, ...chunk]);
await new Promise(resolve => setTimeout(resolve, 0));
}
}
loadData();
return () => { cancelled = true; };
}, [data]);
return (
<table>
{visibleData.map(row => (
<TableRow key={row.id} data={row} />
))}
</table>
);
}
마치며
생각해보면 일이든 운동이든 큰 목표를 한 번에 해치우려고 하면 부담만 커지고 잘 안 되잖아요. 작게 쪼개서 하나씩 처리하다 보면 어느새 목표에 다가가 있는 것처럼 코드도 같은 것 같아요.
정리하다 보니 결국 "한 번에 다 하지 말고, 나눠서 하자." 가 핵심 메세지 같아요.
저도 싱글 스레드의 버벅임 때문에 Web Worker를 써본 적이 있는데 막상 붙여보면 데이터 주고받는 구조 짜는 게 은근 손이 많이 가서, 결국 청크 나누기로 바꾼 기억이 나네요. Scheduler API는 아직 안 써봤는데 다음 프로젝트에 한번 넣어볼 생각이에요.
