I/O 때문에 UI가 멈춘다면
비동기 코드를 작성했는데도 UI가 멈추는 이유를 알아보고, 비동기 I/O로 해결해요
Table Of Contents
들어가기
이 글에서는 비동기 코드인데도 UI가 멈추는 이유와, 비동기 I/O·Event Loop로 해결하는 방법, I/O와 CPU 작업의 구분을 다뤄요.
Promise나 async/await로 비동기 코드를 깔끔하게 쓰는 방법은 Callback Hell 벗어나서 비동기 코드 깔끔하게 처리하기에서 다뤘어요.
그런데 코드를 비동기로 작성했더라도, 파일을 읽거나 API를 부를 때 Input/Output을 처리하는 방법에 따라 UI가 멈추는 경우가 있어요.
I/O(Input/Output)는 파일을 읽고 쓰거나, 네트워크로 데이터를 주고받는 작업이에요. 이런 작업을 동기적으로 처리하면 메인 스레드를 오래 점유해서, 사용자 입력을 받지 못하고 UI가 멈춘 것처럼 보여요. 반대로 비동기로 처리하면 I/O 대기 시간 동안에도 다른 작업을 진행할 수 있어요.
비동기 코드인데 UI가 멈추는 이유
Promise나 async/await로 I/O 작업을 처리하는 비동기 코드를 작성해도, 프로그램이 멈춘 것처럼 느껴질 수 있어요.
이런 경우에는 I/O 작업이 동기적으로 실행되고 있는지 살펴봐야 해요.
Promise나 async/await는 코드를 비동기적으로 작성할 수 있게 해 주는 문법이에요.
하지만 이 문법이 I/O 작업 자체를 비동기로 바꿔주지는 못해요.
비동기 코드 안에서 동기 I/O를 실행하면, 메인 스레드는 그 I/O가 끝날 때까지 멈춰 있어요.
예를 들어, async 함수 안에서 readFileSync 함수를 부르는 경우를 살펴볼게요.
readFileSync가 호출된 순간, 메인 스레드는 그 자리에서 파일 읽기가 끝날 때까지 기다려요.
디스크에서 데이터를 읽는 작업은 운영체제가 처리해요.
하지만 그 결과가 반환될 때까지 JavaScript 실행은 다음 줄로 넘어가지 않아요.
파일이 클수록 기다리는 시간이 길어지고, 그동안 메인 스레드는 사용자 입력이나 화면 그리기를 처리하지 못해요.
정리하면, 비동기 코드와 비동기 I/O는 서로 다른 개념이에요.
- 비동기 코드는 실행 흐름을 나누는 방식이고,
- 비동기 I/O는 I/O 작업을 메인 스레드 밖에서 처리하는 방식이에요.
동기 I/O가 문제인 이유
동기 I/O는 I/O가 끝날 때까지 메인 스레드 실행을 멈춰요.
웹 브라우저의 메인 스레드는 이런 작업들을 담당해요.
- JavaScript 실행
- 화면 그리기(레이아웃·페인트)
- 사용자 입력 처리(클릭, 스크롤)
브라우저는 이 모든 작업을 단 하나의 메인 스레드에서 처리하고, 메인 스레드는 한 번에 한 가지 작업만 실행할 수 있어요.
문제는 I/O 작업이 대기 시간이 매우 길다는 거예요. I/O 작업의 실제 CPU 연산 시간은 짧지만, 결과를 기다리는 시간이 수십~수백 밀리초(ms)로 길어요. 동기 I/O는 이 대기 시간 동안에도 메인 스레드를 점유한 채로 기다리기 때문에, 그동안 화면 그리기나 사용자 입력 처리 같은 다른 작업을 전혀 할 수 없어요.
Event Loop: 메인 스레드의 작업 스케줄러
브라우저가 다음에 무엇을 실행할지는 Event Loop의 규칙에 따라 결정돼요. Event Loop는 Call Stack과 여러 종류의 Queue를 계속 감시하면서, 어떤 작업을 언제 실행할지 정해요.
- Call Stack(호출 스택): 지금 실행 중인 함수가 쌓이는 곳이에요. 맨 위에 있는 함수가 현재 실행 중인 코드예요.
- Task Queues(태스크 큐들): Call Stack이 비었을 때 실행할 작업들이 대기하는 곳이에요. 종류에 따라서 처리하는 시점이 달라져요.
- (매크로)태스크 큐:
setTimeout,setInterval, 사용자 이벤트와 일부 I/O 콜백 작업들이 들어가요. - 마이크로태스크 큐: Promise의
.then,queueMicrotask처럼 현재 작업 직후에 반드시 처리되어야 하는 작업들이 들어가요.
- (매크로)태스크 큐:
Event Loop(이벤트 루프)는 Call Stack을 계속 감시하다가, 스택이 완전히 비면 Queue에서 작업을 꺼내 실행해요. 먼저 하나의 매크로태스크를 실행하고, 그 과정에서 생성된 모든 마이크로태스크를 전부 처리해요. 마이크로태스크 큐가 완전히 비고 나면, 브라우저는 필요한 화면을 그려요. 이 단계에서 브라우저가 레이아웃을 계산하고 페인트해요. 마이크로태스크가 남아 있으면 렌더링이나 매크로태스크는 실행되지 않아요.
readFileSync라는 동기 I/O 함수를 실행하는 순간 벌어지는 일을 살펴볼게요.
readFileSync함수가 Call Stack에 들어가요.- 대기 시간을 포함해서 파일 읽기가 완전히 끝날 때까지 이 함수는 Call Stack에 들어있어요.
- Call Stack이 비지 않았기 때문에, 이 동안 사용자의 입력 이벤트는 Task Queue에 쌓이지만, 실행되지 못해요.
- 마이크로태스크 처리나 렌더링도 일어나지 않아요.
- 동기 I/O가 끝나서 Call Stack이 비면, Event Loop가 대기중인 작업을 처리하기 시작해요.
// 동기 I/O: Call Stack을 점유해요 const data = readFileSync("large-file.txt"); // 파일을 다 읽을 때까지 다음 줄로 못 넘어가요 updateUI(data);
결과적으로 사용자는 I/O가 끝날 때까지 동안 웹과 상호작용하지 못하고, 브라우저가 완전히 멈췄다고 느끼게 돼요.
비동기 I/O로 해결하기
비동기 I/O는 I/O를 시작한 뒤 기다리지 않고 다음 코드로 넘어가고, 완료되면 콜백이나 Promise로 결과를 받는 방식이에요.
앞에서 본 Event Loop는 비동기 I/O에서도 똑같이 작동해요. 차이는 I/O 대기를 Call Stack 바깥으로 넘긴다는 거예요.
비동기 I/O 함수 readFile을 호출하면 이렇게 동작해요.
readFile호출이 Call Stack에 들어가요.- 브라우저가 파일 읽기를 시스템 I/O에 맡기고,
readFile은 즉시 Call Stack에서 빠져나와요. - Call Stack이 비기 때문에, Event Loop는 Task Queue에 쌓인 다른 작업(사용자 입력, 화면 그리기)을 실행할 수 있어요.
- 파일 읽기가 끝나면, 콜백이 Task Queue에 들어가요.
- Event Loop가 Call Stack이 빈 시점에 콜백을 꺼내 실행해요.
readFileSync는 파일을 다 읽을 때까지 Call Stack에 남아 있지만, readFile은 읽기 요청만 보내고 즉시 빠져나와요.
그래서 I/O 대기 중에도 Call Stack이 비어 있고, UI가 계속 반응할 수 있어요.
// 비동기 I/O: Call Stack을 즉시 비워요 readFile("large-file.txt", (error, data) => { // 파일 읽기가 끝나면, 이 콜백이 Task Queue를 거쳐 실행돼요 updateUI(data); }); // readFile은 즉시 반환되고, 이 아래 코드가 바로 실행돼요 handleUserInput();
I/O 작업과 CPU 작업은 달라요
비동기 I/O가 UI를 멈추지 않게 하는 이유는 대기 시간을 메인 스레드 밖으로 넘기기 때문이에요.
하지만 오래 걸리는 작업이 항상 대기 시간 때문인 건 아니에요. 작업은 무엇이 시간을 차지하느냐에 따라 두 종류로 나뉘어요.
- I/O 작업
- CPU를 이용한 작업보다는 대기 시간이 길어요.
- 예를 들어, 파일을 읽거나, API를 호출하거나, DB에 쿼리를 날리는 작업 등이 포함돼요.
async/await와 함께 사용하면 대기 시간동안 메인 스레드를 점유하지 않기 때문에 성능 문제를 덜 수 있어요.
- CPU 작업
- CPU를 이용한 계산이 오래 걸려요.
- 예를 들어, 큰 이미지를 처리하거나, 암호화하거나, 복잡한 수학 계산을 하는 작업이 포함돼요.
async/await와 사용해도 CPU 연산이 오래 실행되기 때문에 여전히 블로킹이 발생해요.- 계산 자체를 메인 스레드 밖으로 옮겨야 해요.
- 브라우저에서는 Web Worker가 계산을 메인 스레드 밖으로 옮겨줘요.
// ✅ Web Worker로 CPU 집약적 작업을 분리해요 // worker.js self.onmessage = function (e) { const result = heavyComputation(e.data); self.postMessage(result); }; // main.js const worker = new Worker("worker.js"); worker.postMessage(imageData); worker.onmessage = function (e) { updateUI(e.data); };
이렇게 하면 무거운 계산은 Worker 스레드에서 진행되고, 메인 스레드는 사용자 입력과 화면 그리기를 계속 처리할 수 있어요.
대기 시간이 문제라면 비동기 I/O, 무거운 계산이 문제라면 Web Worker를 사용하면 좋아요.
실전에서 비동기 I/O 적용하기
비동기 I/O를 써야 할 때
- 사용자 입력을 받는 환경일 때
- 웹 브라우저, 데스크톱 앱, 모바일 앱에서는 메인 스레드가 UI와 사용자 입력을 모두 담당하기 때문에 비동기가 필수예요.
- 대용량 파일을 처리할 때
- 수십 MB 이상의 파일은 비동기로 처리해야 UI가 멈추지 않아요.
- 여러 요청을 병렬 처리할 때
- 3개 이상의 독립적인 API 호출은 비동기로 동시에 보내면 전체 시간이 크게 단축돼요.
- 실시간 업데이트가 필요할 때
- 채팅, 알림, 협업 기능처럼 백그라운드에서 계속 데이터를 주고받아야 할 때
비동기 I/O를 사용하는 예시
- 업로드 진행률을 표시해요
대용량 파일 업로드 시 진행률을 보여주면 사용자의 불편함을 줄여줄 수 있어요. 비동기 I/O를 쓰면 업로드 중에도 메인 스레드가 비어 있어서, 진행 이벤트를 받아 UI를 갱신할 수 있어요.
데이터를 여러 청크로 나누고, 업로드에 성공한 청크 개수로 진행률을 표시하는 예시예요.
// 파일을 청크로 나눠서 업로드하면서 진행률을 추적해요 async function uploadWithProgress(file, onProgress) { const chunkSize = 1024 * 1024; // 1MB씩 업로드할게요 const totalChunks = Math.ceil(file.size / chunkSize); for (let chunkIndex = 0; chunkIndex < totalChunks; chunkIndex++) { const start = chunkIndex * chunkSize; const end = Math.min(start + chunkSize, file.size); const chunk = file.slice(start, end); // 각 청크를 서버에 업로드해요 const formData = new FormData(); formData.append("chunk", chunk); formData.append("chunkIndex", chunkIndex); formData.append("totalChunks", totalChunks); formData.append("filename", file.name); // ✅ 비동기 I/O // fetch()가 반환하는 Promise를 await로 기다려요 // 서버에 데이터를 전송하는 동안, 메인 스레드가 비어서 다른 작업을 처리할 수 있어요. const response = await fetch("/upload", { method: "POST", body: formData, }); if (!response.ok) { throw new Error("업로드 실패"); } // 진행률을 업데이트해요 const progress = ((chunkIndex + 1) / totalChunks) * 100; onProgress(progress); } } // 이렇게 사용해요 await uploadWithProgress(file, (progress) => { updateProgressBar(progress); });
- 대용량 파일을 스트림으로 처리해요
몇 GB나되는 큰 미디어 파일을 다룰 때, 파일 전체를 한 번에 메모리에 올리면 브라우저가 크래시될 수 있어요. 대신 스트림으로 읽으면 파일을 작은 조각(청크)으로 나눠서 순차적으로 처리할 수 있어요.
예를 들어 2GB 파일도 1MB씩 나눠서 읽으면, 한번에 메모리에 올라가는 건 1MB뿐이에요.
이렇게 하면 메모리를 효율적으로 사용하면서도, 청크가 도착할 때마다 진행률을 실시간으로 업데이트할 수 있어요.
아래는 ReadableStream으로 대용량 파일을 청크 단위로 처리하는 예시예요.
// readableStream으로 대용량 파일을 청크 단위로 처리해요 async function processLargeFile(fileUrl) { // ✅ 비동기 I/O // 네트워크를 통해 파일 다운로드를 요청해요 // 서버가 응답할 때까지 대기하는 동안, 메인 스레드는 다른 작업을 처리할 수 있어요 const response = await fetch(fileUrl); const reader = response.body.getReader(); const contentLength = +response.headers.get("Content-Length"); let receivedLength = 0; while (true) { // ✅ 비동기 I/O // 네트워크에서 다음 청크가 도착할 때까지 기다려요 // 기다리는 동안 메인 스레드는 화면 그리기, 클릭 이벤트 등을 처리해요 const { done, value } = await reader.read(); if (done) break; receivedLength += value.length; // 받은 청크를 처리해요 await processChunk(value); const progress = (receivedLength / contentLength) * 100; updateProgressBar(progress); // 메인 스레드를 잠깐 비워서 다른 작업(렌더링, 입력 처리)이 실행될 기회를 줘요 await new Promise((resolve) => setTimeout(resolve, 0)); } }
- AI/서버 처리 중 UI 반응성 유지하기
AI가 대량의 데이터를 분석하거나 서버가 무거운 작업을 처리할 때, 결과가 나올 때까지 시간이 걸릴 수 있어요. 만약 이 시간 동안 UI가 멈춰 있다면, 사용자는 아무것도 할 수 없어서 답답함을 느끼게 돼요.
폴링(Polling) 패턴을 사용하면 서버에 주기적으로 처리 중인지를 물어보면서도, 그 사이에 사용자가 계속 편집하거나 취소 버튼을 누를 수 있어요. 아래는 폴링으로 서버 처리 상태를 확인하는 예시예요.
// 폴링으로 서버 처리 상태를 확인해요 async function processWithServer(inputData) { // ✅ 비동기 I/O // 서버에 무거운 작업을 요청하고, 처리 ID를 받아와요 const processingId = await startProcessing(inputData); while (true) { // 서버에 과도한 요청을 보내지 않기 위해 1초씩 대기해요(폴링) await new Promise((resolve) => setTimeout(resolve, 1000)); // ✅ 비동기 I/O // 서버의 처리 상태를 확인해요 const status = await checkStatus(processingId); updateProgressBar(status.progress); if (status.completed) { return status.result; } // 사용자가 취소 버튼을 눌렀다면 if (userCancelled) { // ✅ 비동기 I/O // 서버에 작업 취소를 요청해요 await cancelProcessing(processingId); throw new Error("사용자가 취소했어요"); } } }
- 백그라운드 자동 저장
사용자가 내용을 수정할 때마다 즉시 서버에 저장하면, 네트워크 요청이 과도하게 발생해요. 예를 들어 1초에 10글자를 타이핑하면 10번의 저장 요청이 발생하고, 이는 서버 부하와 네트워크 비용을 증가시켜요.
디바운스(Debounce) 패턴을 사용하면 사용자가 타이핑을 멈춘 뒤 일정 시간이 지날 때 저장 요청을 보내요. 이렇게 하면 불필요한 요청을 줄이면서도, 사용자 입력 중에 UI는 계속 반응할 수 있어요. 아래는 디바운스된 자동 저장 예시예요.
// 디바운스된 자동 저장 let autoSaveTimeout = null; let isSaving = false; function scheduleAutoSave(projectData) { // 이전 타이머가 있다면 취소해요 if (autoSaveTimeout) { clearTimeout(autoSaveTimeout); } // 사용자의 마지막 입력으로부터 3초 후에 저장 요청을 보내요 autoSaveTimeout = setTimeout(async () => { if (isSaving) return; isSaving = true; try { // ✅ 비동기 I/O // 서버에 프로젝트 데이터를 저장해요 await saveProject(projectData); showAutoSaveIndicator("저장했어요"); } catch (error) { showAutoSaveIndicator("저장에 실패했어요"); } finally { isSaving = false; } }, 3000); } // 컴포넌트가 언마운트될 때 타이머를 정리해요 (메모리 누수 방지) function cleanup() { if (autoSaveTimeout) { clearTimeout(autoSaveTimeout); } }
마치며
비동기 I/O는 I/O 대기 시간을 메인 스레드 밖으로 넘겨서 UI가 멈추지 않게 해요. 하지만 비동기 I/O가 항상 정답은 아니에요.
빌드 스크립트처럼 사용자 입력이 없는 환경이나, 앱 시작 시 필수 설정 파일을 읽는 경우에는 동기 I/O가 오히려 단순한 코드를 만들 수도 있어요.
동기 I/O와 비동기 I/O를 고를 때는 아래를 고려하세요.
동기 I/O에서 고려할 점
- UI가 멈춰요
- 웹 브라우저나 데스크톱 앱에서 동기 I/O를 쓰면 메인 스레드가 멈춰서, 사용자는 클릭도 스크롤도 할 수 없어요.
- 브라우저가 크래시돼요
- 대용량 파일을 한 번에 메모리에 올리면 브라우저가 멈추거나 탭이 종료될 수 있어요.
- 병렬 처리를 할 수 없어요
- 여러 API 요청을 순차적으로 보내면, 전체 대기 시간이 각 요청 시간의 합만큼 길어져요.
비동기 I/O에서 고려할 점
- 경쟁 상태가 생겨요
- 연속해서 요청이 발생하는 경우, 응답이 순서대로 도착하지 않을 수 있어요.
- 예를 들어서 검색창에서 빠르게 타이핑할 때, 이전 검색 결과가 나중에 도착해서 최신 검색어와 맞지 않는 결과가 표시될 수 있어요. 디바운싱을 적용하거나
AbortController로 이전 요청을 취소하세요.
- 메모리 누수가 생겨요
- 비동기 작업이 완료되기 전에 컴포넌트가 언마운트되면, 콜백이 계속 메모리에 남아 있을 수 있어요.
cleanup함수에서 타이머와 요청을 정리하세요.
- 에러를 놓치게 돼요
- Promise가 reject되었는데
.catch()나try-catch로 처리하지 않으면, 에러가 조용히 사라져서 디버깅이 어려워져요.
- Promise가 reject되었는데