⚛️ 18. React 동시성(Concurrent) 매운맛
📋 개요
React 18에 도입된 동시성 엔진의 진짜 의도를 파헤치고, useTransition과 useDeferredValue를 통해 메인 스레드 렌더링 렉을 양보(Yield)하는 현업의 필수 기법을 전수합니다.
🎯 이 섹션을 읽고 나면:
- 키보드에 한 글자를 칠 때마다 화면 전체가 뚝뚝 끊기는 '블로킹(Blocking) 렌더링' 지옥의 진실을 마주한다.
useTransition을 활용하여 무거운 렌더링을 백그라운드로 미루고 유저의 키보드 입력을 최우선 권력으로 모시는 법을 체득한다.- Debounce/Throttle 외부 훅을 버리고, 진정한 선언적 리액트 타이밍 훅인
useDeferredValue의 동작 원리를 이해할 수 있다.
📋 목차
📌 이 문서를 읽기 전에
⏱️ 예상 읽기 시간: 10분 / 핵심 파트: 6분
🗺️ 이 문서의 배경 세계관: '영수네 커뮤니티'
- 영수(PM):"영호 님, 유저 검색 페이지에서 검색창에 '김철수'라고 3글자를 치는데, 한 글자 칠 때마다 하단에 있는 1만 개 데이터짜리 테이블이 렌더링되느라 키보드가 안 먹혀요!"
- 영철(신입):"아... 그건
setTimeout으로 Debounce 500ms 묶어놔서 유저가 타자 다 칠 때까지 무시하도록 꼼수를 부려야 합니다만... UX가 좀 답답해지긴 하죠." - 영호(리드): "영철 님, 우리는 React 18 시대를 살고 있습니다. 이제 앱 렌더링을 '시작하면 못 멈추는' 단거리 달리기 구조가 아닙니다. 렌더링 도중 멈추고 딴짓(키보드 입력)을 먼저 처리하는 동시성(Concurrent) 엔진으로 진입할 때입니다."
🤔 왜 알아야 하는가: 단거리 달리기와 심장마비
React 17 이하 버전에선, 렌더링은 동기식(Synchronous) 이었습니다. 한 번 렌더링이 시작되면, 브라우저는 그 렌더링 연산이 끝날 때까지 심장이 멎은 듯 모든 사용자 입력을 철저히 틀어막습니다(메인 스레드 블로킹).
// ❌ 렉 걸리는 검색창 (React 17 이하의 죽음의 렌더링)
function UserSearch() {
const [keyword, setKeyword] = useState('');
// 🚨 한 글자 칠 때마다 만 개의 무거운 리스트가 리렌더링 (블로킹!)
return (
<div>
<input
value={keyword}
onChange={(e) => setKeyword(e.target.value)} // 유저 입력 1순위 위협
/>
<MegaHeavyUserList listFilter={keyword} /> {/* 500ms 걸리는 렌더링 */}
</div>
);
}이 상황의 문제점:
- 사용자가 "안녕" 두 글자를 빠르게 쳐도, 'ㅇ'을 그리고 밑의 표를 0.5초 동안 그리느라 'ㅏ' 입력을 받아줄 브라우저의 체력이 없습니다. 키보드가 씹힙니다!
- 과거엔 이걸 억지로 피하려고 Lodash의
_.debounce를 써서 타자를 멈추고 0.5초 뒤에 표를 그리게 했지만, 반사속도가 이질적입니다.
🏗️ 비유로 먼저 이해하기
🧒 5살에게 설명한다면?
- **과거의 화가 (동기식 렌더링):**왕(유저)이 초상화를 부탁함. 화가가 붓을 쥐고 그리기 시작하면 문을 아예 잠가버림. 왕이 "야! 물 좀 가져와!"라고 두들겨도 그림을 10시간 다 그릴 때까지 무시함.
- 리액트 18의 붓놀림 (동시성 - Concurrent): 화가(React 18) 는 슥삭슥삭 그림을 그리다가, "왕(유저) 이 문을 두드려 키보드 한 글자를 치면 즉시 붓을 허공에 멈춥니다(Yield)."
기민하게 뛰어나가 왕의 요구사항(키보드 타이핑 화면 표시) 부터 0.1초 만에 깔끔하게 처리하고, 다시 캔버스로 돌아와서 아까 멈췄던 그림(무거운 1만 개 표 필터링) 을 틈틈이 이어서 그립니다!
🧩 극의 체득: 동시성 API 발동하기
이제 영철이의 렉망진창 코드를 **React 18의 useTransition**을 써서 구원해 봅시다.
✅ 방법 1: useTransition (너는 나중에 그려져라!)
핵심 문법: **긴급한 업데이트(키보드 화면 표시)**와 **안 긴급한 업데이트(아래 표 필터링)**를 분리하여 생명을 부여합니다.
import { useState, useTransition } from 'react';
function SmoothSearch() {
const [keyword, setKeyword] = useState(''); // 긴급한 UI (인풋 창 텍스트)
const [deferredKeyword, setDeferredKeyword] = useState(''); // 안 긴급 (필터링)
// 🎯 동시성의 심장! (isPending은 이면 렌더링 중인지 여부)
const [isPending, startTransition] = useTransition();
const handleChange = (e) => {
// 1️⃣ [극강의 긴급] - 키보드 타이핑 화면에는 0.001초만에 즉시 그려져라!
setKeyword(e.target.value);
// 2️⃣ [우아한 양보] - 아래 표 필터링용 데이터 렌더링은 여유로울 때 뒤에서 해라!
startTransition(() => {
setDeferredKeyword(e.target.value);
});
};
return (
<div>
<input value={keyword} onChange={handleChange} />
{/* 백그라운드에서 뒤늦게 렌더링 되는 걸 isPending으로 흐리게 처리! */}
<div style={{ opacity: isPending ? 0.5 : 1 }}>
<MegaHeavyUserList listFilter={deferredKeyword} />
</div>
</div>
);
}이 코드가 발동되는 직관적 프로세스:
- "안녕"이라고 미친 속도로 타자를 쳐도,
keyword는 실시간 메인 스레드 1순위 권력을 가지므로 화면 창에 렉 없이 그대로 타이핑됩니다. - 한편
startTransition안에 갇힌 하단 1만 줄 표 그리기 명령은 렌더링 짬짬이 멈췄다 재개하며('Time Slicing' 기법), 키보드 입력을 절대 씹지 않고 가장 최신 텍스트 필터 상태(최종 글자 '녕')로 자연스럽게 귀결됩니다.
✅ 방법 2: useDeferredValue (입력 창 권한이 나에게 없을 때)
때로는 남이 만든 라이브러리 프롭스로 저 무거운 keyword 글자를 내려받아 올 때가 있습니다. 이 땐 startTransition으로 감쌀 타이밍조차 놓쳤죠? 이때 쓰는 마법의 도구입니다.
import { useDeferredValue } from 'react';
// 외부에서 텍스트(text)를 속수무책으로 내려받은 무거운 컴포넌트
function ListBox({ text }) {
// 🎯 "React야, 이 글자는 이면 세계에서 천천히 계산(Deffer)해서 반영해라!"
const deferredText = useDeferredValue(text);
return (
<>
<p>실제 유저 타이핑 (즉시 렌더): {text}</p>
{/* ⚠️ 오직 deferred된 지연 텍스트에만 무거운 표를 연결한다! */}
<MegaHeavyUserList listFilter={deferredText} />
</>
);
}방금 debounce 라이브러리를 완전히 쓰레기통에 처박았습니다. 타임아웃(200ms)처럼 인위적인 고정 시간을 기다리는 게 아닙니다. 유저의 기기 성능(CPU)이 좋으면 0.01초 만에 업데이트되고, 똥컴이면 0.5초 뒤에 뜨게 알아서 **자연 방임형 최적화(Native Schedulling)**를 해 준 것입니다.
🏁 이번에 배운 내용 총정리
| 관점 | 과거의 Debounce (setTimeout) | 현재의 Concurrent (useTransition) |
|---|---|---|
| 업데이트 타이밍 | 정해진 강제 고정시간 (예: 500ms) 무조건 대기 | 내 컴퓨터 상태와 유저 입력 횟수를 보아가며 지능적 양보(Yield) |
| 멈춤 기능 (Interruptible) | 못 멈춤. 한 번 시작한 렌더링은 심장마비 유발 | 렌더링 도중 붓을 내던지고 긴급한 유저 입력부터 먼저 그려줄 수 있음 (Time Slicing) |
| 목표 철학 | "일단 서버/연산 터지는 것만 막자" | "사용자 클릭/키보드 감각이 끊기는 것만큼은 무조건 막자!" |
🐣 영철이의 퇴근 일기
React 18이 나왔을 때 동시성(Concurrent) 어쩌구 하는 글을 보고 그냥 '빨라졌네' 하고 넘겼던 내 과거를 반성한다. 이게 뒷배경에 멈췄다 재개하는(Yield & Time Slicing) 엄청난 스케줄링 마법이 깔려있을 줄이야. 화가가 붓을 내려놓고 왕의 부탁을 0.01초 만에 응대하는 비유를 듣자마자 소름이 돋았다.
💡 "단거리 달리기처럼 렌더링 내내 컴퓨터를 먹통으로 만들던 렉망진창 시대는 끝.
useTransition으로 긴급한 UI와 안 긴급한 렌더링을 우아하게 분리(Yield)하자!"
useDeferredValue 덕분에 애먼 setTimeout과 디바운스는 이제 우리 프로젝트에서 볼 일 없을 거다. 사용자 키보드 입력이 씹히는 속 터지는 UX를 드디어 박살 낼 수 있다니, 당장 내일 가서 영숙 님께 자랑해야지. 오늘 배운 강렬한 깨달음 잊지 않게 꿀잠 자고 내일 최고의 퍼포먼스를 뽐내봐야겠다.
📝 마무리 퀴즈
Q1. 당신이 만드는 쇼핑몰 검색창에 사용자가 아이폰으로 무거운 렌더링을 유발하는 단어를 타이핑했습니다. 이때 startTransition 내부에 묶어둔 검색 결과 리스트 상태 업데이트 때문에 발생하는 현상에 대한 올바른 설명은 무엇입니까?
- A) React는 검색 결과 리스트 상태 업데이트를 즉시 실행하며, 대신 사용자의 키보드 입력 처리를 뒤로 미루어 화면이 일시정지(프리징)된다.
- B)
startTransition내부의 강제적 setTimeout 로직 때문에 모든 렌더링이 무조건 1초(고정 시간) 뒤에야 서버로 요청을 날리게 변환된다. - C) React는 일단 가장 긴급한 입력 필드의 글자 렌더링부터 처리하고, 이후 남은 브라우저 자투리 시간을 활용하여 백그라운드 캔버스에서 무거운 검색 결과 리스트 작업을 그리기 시작한다(낮은 우선순위 부여). 중간에 또 다른 키보드 입력이 들어오면 기존 백그라운드 렌더링은 기꺼이 폐기/중단된다.
✅ 정답: C
💡 상세 해설: 동시성 렌더링(Concurrent)의 백미입니다. C가 말하듯, 우선순위가 낮춰진 업데이트 작업(startTransition)은 렌더링 도중 언제든 더 급한 작업(onChange)에게 멱살을 잡혀 중단될 수 있는 유연성을 거머쥐었습니다.
Q2. 구시대의 useDebounce(value, 300) 커스텀 훅과, 최신 리액트의 useDeferredValue(value) 훅의 가장 근본적인 철학적/작동 방식의 차이를 단지 한 줄의 주관식으로 비유/요약해 보세요.
✅ 정답 및 주관식 해설:
"디바운스는 컴이 좋든 나쁘든 무식하게 초를 재서 무조건 300ms 뒤에 벽돌처럼 그림을 그리기 시작하는 '인위적인 지연(고정 지연)'이지만, useDeferredValue는 유저가 자판을 안 치고 CPU가 한가하다는 판단이 들면 0.001초 만에 최강급 컴퓨터 속도로 화면을 뽑아내는 유기적이고 똑똑한 '지능형 양보 패턴(유동 지연)'이다."