시니어 FE 시리즈
시니어 프론트엔드 개발자가 되기위해 해보지 않았던 경험의 틈을 채우거나 기술적으로 딥다이브 해보고자 준비한 시리즈입니다.

동시성(Concurrency)은 단순히 async/await를 쓰는 수준을 넘어,
"사용자 경험을 해치지 않으면서 여러 작업을 어떻게 조율(Orchestration)하는가"에 대한 능력을 검증하는 아주 중요한 키워드입니다.
가장 대표적인 두 가지 구체적인 상황을 통해 심도 있게 파헤쳐 보겠습니다.
1. 사용자 입력과 무거운 렌더링의 충돌 (React 18 Transition)
[상황]
사용자가 검색창에 타이핑을 할 때마다, 하단에 수천 개의 데이터 리스트가 실시간으로 필터링되어 그려져야 합니다. 이때 리스트를 그리는 작업(무거운 작업) 때문에 사용자가 입력하는 글자가 버벅이며 늦게 나타나는 현상이 발생합니다.
[시니어의 해결책: 우선순위 큐(Priority Queue)]
과거에는 debounce나 throttle로 입력을 지연시켰지만, 이는 근본적인 해결책이 아닙니다. React 18의 Concurrent Mode는 작업을 '긴급(Urgent)'과 '전환(Transition)'으로 나눕니다.
- Urgent Update: 글자 입력 (사용자 반응성 직결)
- Transition Update: 리스트 필터링 (조금 늦어도 괜찮음)
[코드 예시]
const [isPending, startTransition] = useTransition();
const [filterTerm, setFilterTerm] = useState('');
const handleChange = (e) => {
// 1. 긴급 업데이트: 타이핑은 즉시 반영
setFilterTerm(e.target.value);
// 2. 비긴급 업데이트: 리스트 렌더링은 메인 스레드가 한가할 때 처리
startTransition(() => {
setFilteredList(bigData.filter(item => item.includes(e.target.value)));
});
};
return (
<>
<input type="text" onChange={handleChange} />
{isPending ? <Spinner /> : <List data={filteredList} />}
</>
);
💡 포인트: startTransition은 렌더링 도중 더 급한 작업(추가 타이핑)이 들어오면 진행 중인 렌더링을 중단(Interrupt)하고 새 작업을 먼저 처리합니다. 이것이 진정한 동시성의 핵심입니다.
2. Race Condition (경쟁 상태) 해결
[상황]
탭 메뉴(A, B, C)가 있습니다. 사용자가 'A'를 클릭하고 데이터가 오기 전에 빛의 속도로 'B'를 클릭했습니다. 그런데 네트워크 사정상 'A'의 응답이 'B'보다 늦게 도착했습니다. 결과적으로 화면은 'B' 탭인데 데이터는 'A' 것이 보여주는 데이터 불일치가 발생합니다.
[시니어의 해결책: 이전 요청 무효화(Cancellation)]
이것은 전형적인 비동기 동시성 문제입니다. 시니어는 이를 해결하기 위해 AbortController를 사용하거나, React Query의 자동 취소 기능을 활용합니다.
[코드 예시 (Native JS)]
let currentController = null;
async function fetchData(tabId) {
// 이전 요청이 있다면 취소
if (currentController) {
currentController.abort();
}
currentController = new AbortController();
try {
const response = await fetch(`/api/data/${tabId}`, {
signal: currentController.signal
});
const data = await response.json();
renderUI(data);
} catch (error) {
if (error.name === 'AbortError') {
console.log('이전 요청 취소됨');
}
}
}
3. 심화: 비동기 작업의 병렬성 제어 (Concurrency Control)
[상황]
사용자가 100개의 이미지를 업로드합니다. Promise.all로 한꺼번에 던지면 브라우저의 네트워크 커넥션 제한에 걸려 성능이 급격히 저하되거나 서버가 터질 수 있습니다.
[시니어의 해결책: 큐(Queue)를 이용한 동시 실행 수 제한]
한 번에 딱 5개씩만 업로드되도록 제어하는 큐 시스템을 설계해야 합니다. (라이브러리로는 p-limit 등이 있습니다.)
자바스크립트 연산 자체가 무거운 경우?
"그런데 useTransition은 렌더링 우선순위를 조절하는 것이지, 자바스크립트 연산 자체가 무거워서 메인 스레드를 점유하는 'Blocking' 현상 자체를 없애주는 건 아니지 않나요? 만약 filter 로직 자체가 2초가 걸린다면 useTransition을 써도 화면은 여전히 멈출 텐데, 이때는 어떻게 하시겠습니까?"
만약 연산 자체가 너무 무겁다면 Web Worker로 넘겨야 합니다.
[시니어 FE] Web Worker 에 대해 알아보자 (Comlink)
[시니어 FE] Web Worker 에 대해 알아보자 (Comlink)
시니어 FE 시리즈시니어 프론트엔드 개발자가 되기위해 해보지 않았던 경험의 틈을 채우거나 기술적으로 딥다이브 해보고자 준비한 시리즈입니다. 그 동안 Web Worker를 막연하게 알고만 있었지
11001.tistory.com
혹은 렌더링 단위를 쪼개는 Time Slicing 기법(예: 대량의 리스트를 requestIdleCallback으로 조금씩 나눠 그리기)도 있습니다.
Time Slicing
"브라우저의 메인 스레드를 독점하지 않고, 사용자에게 실시간 응답성을 제공하기 위해 작업을 잘게 쪼개는 기술"로 정의됩니다.
리액트의 Fiber 아키텍처가 내부적으로 하는 일이기도 하지만, 시니어라면 이를 브라우저 네이티브 API 수준에서 이해하고 직접 제어할 줄 알아야 합니다.
1. Time Slicing의 핵심 원리
브라우저는 보통 16.7ms(60fps)마다 한 프레임을 그립니다. 만약 자바스크립트 연산이 100ms 동안 메인 스레드를 점유하면, 그동안 브라우저는 화면을 그리지 못하고 사용자 입력도 받지 못하는 프리징(Freezing) 상태가 됩니다.
Time Slicing은 이 100ms짜리 작업을 5ms짜리 작업 20개로 쪼개서, 작업 사이사이에 브라우저가 쉬거나 다른 급한 일(사용자 클릭 등)을 처리할 틈을 주는 전략입니다.
2. requestIdleCallback을 이용한 구현
이 API는 브라우저가 모든 긴급한 작업(애니메이션, 입력 처리 등)을 마치고 한가할 때(Idle) 콜백을 실행하도록 예약합니다.
[시나리오: 10,000개의 리스트 아이템 렌더링]
한 번에 10,000개를 그리는 대신, 브라우저가 쉴 때마다 100개씩 끊어서 그리는 예시입니다.
const bigData = new Array(10000).fill(0).map((_, i) => `Item ${i}`);
let currentIndex = 0;
const CHUNK_SIZE = 100;
function renderChunk(deadline) {
// deadline.timeRemaining() > 0: 브라우저가 이번 프레임에서 쉴 수 있는 남은 시간(ms)
// 남은 시간이 있고, 아직 그릴 데이터가 있다면 계속 실행
while (deadline.timeRemaining() > 0 && currentIndex < bigData.length) {
const chunk = bigData.slice(currentIndex, currentIndex + CHUNK_SIZE);
// 실제 DOM 조작 또는 상태 업데이트 (현업에선 가상 DOM 업데이트)
renderToUI(chunk);
currentIndex += CHUNK_SIZE;
}
// 아직 남은 데이터가 있다면 다음 유휴 시간에 예약
if (currentIndex < bigData.length) {
requestIdleCallback(renderChunk);
}
}
// 최초 실행
requestIdleCallback(renderChunk);
3. 현대적인 대안: scheduler.postTask와 MessageChannel
requestIdleCallback은 실행 주기가 불안정할 수 있다는 단점이 있습니다. 그래서 더 정교한 제어가 필요할 때 시니어들은 다음 방식을 언급합니다.
- scheduler.postTask (Modern API): 크롬 등 최신 브라우저에서 지원하며, 작업에 우선순위(user-blocking, user-visible, background)를 부여하여 스케줄링할 수 있습니다.
- MessageChannel을 이용한 0ms 지연: 리액트의 Scheduler 패키지가 내부적으로 사용하는 방식입니다. setTimeout보다 빠르게 다음 태스크로 넘기면서도 메인 스레드에 제어권을 잠시 넘겨줍니다.
"Time Slicing이 왜 필요한가요?"
"결국 핵심은 메인 스레드 점유권의 양보(Yielding)입니다.
아무리 성능이 좋은 컴퓨터라도 자바스크립트가 루프를 돌며 스레드를 꽉 잡고 있으면 사용자는 '앱이 멈췄다'고 느낍니다.
무거운 리스트 렌더링이나 복잡한 데이터 가공 시, 작업을 청크(Chunk) 단위로 쪼개고 requestIdleCallback이나 MessageChannel을 활용해 브라우저가 프레임을 그릴 시간을 확보해 줍니다. 이렇게 하면 전체 작업 완료 시간은 조금 늘어날 수 있어도, 사용자가 느끼는 상호작용성(Interaction)은 비약적으로 향상됩니다."
🚀 추가 심화
"리액트 18의 useDeferredValue도 내부적으로는 이 Time Slicing 원리를 이용합니다. 만약 검색어 입력에 따라 리스트가 필터링되는 UI에서 debounce를 쓰는 것과 useDeferredValue를 쓰는 것의 결정적인 차이는 무엇일까요?"
- debounce는 설정한 시간(예: 300ms) 동안 무조건 기다려야 함.
- useDeferredValue는 브라우저가 한가해지는 즉시 실행됨 (고성능 기기에선 더 빠르게 반응).
debounce vs useDeferredValue
일단은 제 경험상 실무에서 useDeferredValue 는 거의 쓸 상황이 생기지 않고, debounce 를 훨씬 많이 쓰게됩니다.
왜냐면 '인터렉션'에 대한 지연이 중요하지, 사실 렌더링 '지연' 을 할 일이 그렇게 많지 않아요!
useDeferredValue 의 경우에는 렌더링을 '지연' 시키는 것이지 '인터렉션'에 대한 지연을 시키는게 아닙니다!
렌더링이 무거워서 지연해야하는 경우 메인 쓰레드가 여유로울때 렌더링한다는 개념이 useDeferredValue 입니다.
하지만 현실의 대부분은 인터렉션 -> 네트워크 요청 -> 렌더링 순서를 거쳐서 화면에 무언가를 보여주게 됩니다.
사용자가 단기간에 여러 번 인터렉션(연타, 입력) 했을때 '네트워크 요청' 이 여러번 들어가게 되면 렌더링을 미루는게 의미가 있나요?
네트워크 요청을 1번만 들어가게끔 지연하는게 훨낫죠.
1. "네트워크 요청이 포함된 경우" → Debounce가 압승
후보자님 말씀대로 인터렉션 -> 네트워크 -> 렌더링 흐름이라면, useDeferredValue는 네트워크 요청 횟수를 줄여주지 못합니다.
- 문제점: 입력을 10번 하면 네트워크 요청도 10번 발생합니다. 비록 리액트가 마지막 결과만 화면에 그리려고 노력하겠지만(Concurrent Rendering), 서버 부하와 불필요한 대역폭 낭비는 막을 수 없습니다.
- 해결책: 이때는 Debounce를 써서 네트워크 요청 자체를 1번으로 제한하는 것이 비용과 성능 측면에서 훨씬 유리합니다.
2. "순수 클라이언트 연산이 무거운 경우" → useDeferredValue가 유리
반면, 이미 모든 데이터를 브라우저가 가지고 있는 상태에서 사용자의 입력(Filter, Sort)에 따라 화면을 다시 그릴 때는 useDeferredValue가 빛을 발합니다.
- 상황: 10,000개의 주식 데이터를 이미 클라이언트가 들고 있고, 검색어에 따라 실시간 필터링을 할 때.
- 장점: Debounce는 300ms라는 인위적인 지연 시간이 생기지만, useDeferredValue는 사양이 좋은 컴퓨터에서는 지연 없이 즉시, 사양이 나쁜 컴퓨터에서는 버벅이지 않을 정도로만 끊어서 결과를 보여줍니다.
💡 정리: 시니어의 결정 트리 (Decision Tree)
| 구분 | Debounce / Throttle | useDeferredValue / Transition |
|---|---|---|
| 최적화 대상 | 이벤트 발생 횟수 및 외부 리소스(API) | 렌더링 우선순위 및 CPU 점유율 |
| 주요 타겟 | API 서버 부하, 과도한 함수 실행 방지 | 메인 스레드 블로킹(Jank) 방지, UX 응답성 |
| 핵심 이점 | 네트워크 비용 절감, 불필요한 작업 차단 | 기기 사양에 맞는 가변적 응답 속도 제공 |
| 결정적 단호함 | "네트워크 요청이 있다면 무조건 Debounce" | "로컬 데이터 가공/렌더링이 무겁다면 useDeferredValue" |
'프론트엔드 개발 > 시니어 시리즈' 카테고리의 다른 글
| [시니어 FE] CSRF GET 공격 시나리오와 방어법 (0) | 2026.03.07 |
|---|---|
| [시니어 FE] Web Worker 에 대해 알아보자 (Comlink) (0) | 2026.03.05 |