React 18의 렌더링 방식은 무엇이 달라졌을까
React 18을 이야기할 때 가장 많이 나오는 키워드는 아마 Concurrent Rendering일 것입니다.
하지만 이 개념은 처음 접하면 꽤 애매하게 느껴집니다.
- 기존 React 렌더링과 무엇이 다른지
- concurrent가 병렬 렌더링을 뜻하는지
createRoot를 쓰면 자동으로 모든 게 달라지는지automatic batching,startTransition,Suspense는 서로 어떻게 연결되는지- 실무에서는 이걸 어떻게 이해해야 하는지
이 질문들이 정리되지 않으면 React 18의 렌더링 방식은 그냥 "성능이 좋아졌다" 정도로만 남기 쉽습니다.
하지만 React 18의 변화는 단순 성능 개선이라기보다, React가 렌더링 작업을 더 유연하게 다룰 수 있도록 내부 모델을 확장한 변화에 가깝습니다.
이번 글에서는 기존 React의 렌더링 방식과 비교하면서, React 18에서 무엇이 달라졌는지 기본기부터 정리해보겠습니다.
한눈에 보면
먼저 짧게 정리하면 이렇습니다.
- 기존 React 렌더링은 대체로 동기적이고, 한 번 시작한 렌더링 작업을 끝까지 진행하는 방식에 가까웠습니다.
- React 18은 concurrent rendering을 통해 렌더링 작업을 중단, 재개, 폐기할 수 있는 기반을 마련했습니다.
createRoot를 사용해야 React 18의 새로운 렌더링 기능을 제대로 사용할 수 있습니다.- automatic batching은 이벤트 핸들러 밖의 상태 업데이트도 더 넓게 묶어 렌더링 횟수를 줄여줍니다.
startTransition과useTransition은 긴급한 업데이트와 덜 긴급한 업데이트를 구분할 수 있게 해줍니다.Suspense는 React 18에서 서버 렌더링, 스트리밍, 선택적 hydration과 함께 더 중요해졌습니다.
즉, React 18의 렌더링 변화를 이해한다는 것은 단순히 새 API를 외우는 것이 아니라, React가 UI 업데이트의 우선순위와 작업 흐름을 어떻게 더 똑똑하게 다루게 되었는지 이해하는 것에 가깝습니다.
먼저 기존 React 렌더링을 다시 떠올려보자
React에서 렌더링은 보통 아래 흐름으로 이해할 수 있습니다.
- state나 props가 바뀝니다.
- React가 컴포넌트 함수를 다시 실행합니다.
- 새로운 React element 트리를 계산합니다.
- 이전 결과와 비교합니다.
- 실제 DOM에 필요한 변경만 반영합니다.
이 흐름 자체는 React 18에서도 크게 달라지지 않습니다.
즉, React 18이 되었다고 해서 "리렌더링의 정의"가 완전히 바뀐 것은 아닙니다. 컴포넌트 함수는 여전히 UI 결과를 계산하고, React는 여전히 이전 결과와 다음 결과를 비교해 실제 DOM 변경을 최소화하려고 합니다.
달라진 것은 이 렌더링 작업을 React가 어떻게 스케줄링하고 다룰 수 있는가입니다.
기존 렌더링 방식의 한계는 무엇이었을까?
기존 React 렌더링을 아주 단순하게 표현하면, 한 번 렌더링 작업이 시작되면 React는 그 작업을 끝까지 처리하는 쪽에 가까웠습니다.
예를 들어 사용자가 검색창에 글자를 입력할 때:
function SearchPage() {
const [keyword, setKeyword] = useState('');
const filteredItems = heavyFilter(items, keyword);
return (
<>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
<SearchResult items={filteredItems} />
</>
);
}여기서 입력값이 바뀔 때마다:
- input 값 업데이트
- 무거운 필터링 계산
- 결과 리스트 렌더링
이 함께 일어날 수 있습니다.
문제는 사용자의 입력은 즉시 반응해야 하는 긴급한 작업인데, 검색 결과 목록 계산은 조금 늦어도 되는 작업일 수 있다는 점입니다.
하지만 기존 방식에서는 이런 우선순위를 React에게 표현하기가 쉽지 않았습니다.
즉, 모든 업데이트가 비슷한 긴급도를 가진 것처럼 처리되면, 무거운 렌더링 작업 때문에 입력 반응성까지 같이 나빠질 수 있습니다.
React 18의 핵심 변화는 무엇일까?
React 18의 핵심 변화는 렌더링 작업을 더 유연하게 다룰 수 있게 된 것입니다.
React 18의 concurrent rendering 기반에서는 React가 렌더링 작업을:
- 잠시 중단하거나
- 나중에 다시 이어서 하거나
- 더 중요한 업데이트가 들어오면 우선 처리하거나
- 이미 계산 중이던 결과가 오래되면 버릴 수 있습니다
이런 방식으로 다룰 수 있습니다.
여기서 중요한 것은 concurrent rendering이 "무조건 동시에 여러 화면을 병렬로 렌더링한다"는 뜻은 아니라는 점입니다.
더 정확히는 렌더링 작업을 한 덩어리의 동기 작업으로만 보지 않고, 우선순위에 따라 조절 가능한 작업으로 다룰 수 있게 되었다는 의미에 가깝습니다.
Concurrent Rendering은 병렬 렌더링일까?
이 부분이 가장 많이 오해됩니다.
concurrent라는 단어 때문에 "React가 여러 컴포넌트를 여러 스레드에서 동시에 렌더링하는 건가?"라고 생각하기 쉽습니다.
하지만 브라우저의 일반적인 JavaScript 실행 모델을 생각하면, React 컴포넌트 렌더링은 여전히 메인 스레드에서 실행되는 경우가 많습니다.
React 18의 concurrent rendering은 주로:
- 작업을 쪼개고
- 우선순위를 판단하고
- 중간에 양보하고
- 더 중요한 작업을 먼저 처리할 수 있게 하는 모델
로 이해하는 편이 좋습니다.
즉, concurrent rendering은 병렬 처리라기보다 협력적 스케줄링에 가까운 렌더링 모델 변화입니다.
createRoot는 왜 중요할까?
React 18의 새로운 렌더링 기능을 제대로 사용하려면 기존 ReactDOM.render 대신 createRoot를 사용해야 합니다.
기존 방식은 대략 이렇습니다.
import ReactDOM from 'react-dom';
import App from './App';
ReactDOM.render(<App />, document.getElementById('root'));React 18에서는 아래처럼 사용합니다.
import { createRoot } from 'react-dom/client';
import App from './App';
const root = createRoot(document.getElementById('root')!);
root.render(<App />);createRoot는 React 18의 concurrent 기능을 사용할 수 있는 루트를 만듭니다.
즉, React 18을 설치했다고 해서 기존 앱이 자동으로 모든 새 렌더링 동작을 사용하는 것은 아닙니다. 새로운 root API를 통해 React 18의 렌더링 모델에 진입해야 합니다.
React 18에서 렌더링은 더 많이 일어날까, 적게 일어날까?
이 질문은 조금 조심해서 봐야 합니다.
React 18은 어떤 경우에는 렌더링을 더 효율적으로 묶어줄 수 있고, 어떤 경우에는 개발 모드에서 렌더링이 더 자주 보이는 것처럼 느껴질 수도 있습니다.
예를 들어:
- automatic batching으로 불필요한 렌더링 횟수가 줄어드는 경우가 있고
- Strict Mode 개발 환경에서는 의도적으로 일부 로직이 두 번 실행되어 문제를 더 빨리 드러내기도 하며
- concurrent rendering에서는 렌더링 작업이 시작되었다가 폐기될 수 있으므로 "렌더링이 곧 commit"이라는 감각이 더 위험해집니다
즉, React 18에서는 렌더링을 단순히 "몇 번 실행됐는가"로만 보기보다, 어떤 렌더링이 실제로 commit되었고 사용자가 보는 화면에 반영되었는가를 구분해서 봐야 합니다.
Render phase와 Commit phase를 더 명확히 구분해야 한다
React 렌더링을 이해할 때 render phase와 commit phase 구분은 원래도 중요했습니다. React 18에서는 이 구분이 더 중요해졌습니다.
Render phase
React가 다음 UI가 어떤 모습이어야 하는지 계산하는 단계입니다.
이 단계에서는:
- 컴포넌트 함수 실행
- JSX 결과 계산
- 이전 트리와 비교할 준비
가 일어납니다.
Commit phase
계산된 결과를 실제 DOM에 반영하는 단계입니다.
이 단계에서는:
- DOM 변경
- ref 반영
- layout effect 실행
등이 일어납니다.
React 18의 concurrent rendering에서는 render phase 작업이 중간에 멈추거나 버려질 수 있습니다. 하지만 commit phase는 사용자에게 보이는 실제 변경이므로 일관성이 중요합니다.
즉, React 18에서는 render는 시도될 수 있지만, commit은 최종 반영이다라는 감각이 더 중요해집니다.
왜 render phase는 순수해야 할까?
React는 원래도 컴포넌트 렌더링을 순수하게 유지하라고 말합니다.
즉, 컴포넌트 함수 안에서:
- 네트워크 요청을 직접 보내거나
- 외부 상태를 변경하거나
- DOM을 직접 조작하거나
- 로그 저장 같은 중요한 side effect를 수행하는 것
은 피하는 편이 좋습니다.
React 18에서는 이 원칙이 더 중요해집니다.
왜냐하면 concurrent rendering에서는 render phase가:
- 실행되었다가 commit되지 않을 수도 있고
- 중간에 폐기될 수도 있고
- 개발 모드에서 더 엄격하게 검증될 수도 있기 때문입니다
예를 들어 아래 코드는 위험합니다.
function ProductPage({ productId }: { productId: string }) {
analytics.track('view_product', { productId });
return <ProductDetail productId={productId} />;
}컴포넌트 함수가 실행될 때마다 분석 이벤트가 전송될 수 있고, 실제 화면에 commit되지 않은 렌더링에서도 side effect가 발생할 수 있습니다.
이런 코드는 보통 useEffect 안으로 옮기는 편이 맞습니다.
function ProductPage({ productId }: { productId: string }) {
useEffect(() => {
analytics.track('view_product', { productId });
}, [productId]);
return <ProductDetail productId={productId} />;
}즉, React 18의 렌더링 모델을 이해할수록 컴포넌트 함수는 UI 계산에 집중하고, side effect는 effect로 분리해야 한다는 원칙이 더 선명해집니다.
Automatic Batching은 무엇이 달라졌을까?
React는 여러 state 업데이트를 하나의 렌더링으로 묶어 처리할 수 있습니다. 이것을 batching이라고 합니다.
기존 React에서도 이벤트 핸들러 안에서는 batching이 일어났습니다.
function Counter() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
const handleClick = () => {
setCount((prev) => prev + 1);
setFlag((prev) => !prev);
};
return <button onClick={handleClick}>{count}</button>;
}이 경우 setCount와 setFlag는 보통 한 번의 렌더링으로 묶입니다.
하지만 기존에는 Promise, setTimeout, native event handler 같은 React 이벤트 바깥에서는 batching이 일관되게 적용되지 않는 경우가 있었습니다.
React 18에서는 createRoot를 사용하는 경우 자동 batching 범위가 더 넓어졌습니다.
setTimeout(() => {
setCount((prev) => prev + 1);
setFlag((prev) => !prev);
}, 1000);React 18에서는 이런 업데이트도 자동으로 묶일 수 있습니다.
즉, automatic batching은 여러 상태 변경을 더 넓은 범위에서 하나의 렌더링으로 묶어 불필요한 렌더링을 줄이는 변화입니다.
Automatic Batching이 모든 문제를 해결할까?
그렇지는 않습니다.
batching은 렌더링 횟수를 줄여줄 수 있지만, 한 번의 렌더링 안에서 수행되는 계산이 무거우면 여전히 느릴 수 있습니다.
예를 들어:
- 거대한 리스트 필터링
- 복잡한 차트 계산
- 무거운 자식 트리 렌더링
같은 문제는 batching만으로 해결되지 않습니다.
즉, automatic batching은 좋은 기본 최적화이지만, 렌더링 범위 설계, 메모이제이션, transition, virtualization 같은 전략을 대체하지는 않습니다.
Transition은 왜 등장했을까?
React 18에서 중요한 개념 중 하나가 transition입니다.
핵심은 업데이트를 두 종류로 나누는 것입니다.
- 긴급한 업데이트
- 덜 긴급한 업데이트
예를 들어 검색창 입력을 생각해보겠습니다.
- input에 글자가 보이는 것은 즉시 반영되어야 합니다
- 검색 결과 리스트는 조금 늦게 바뀌어도 괜찮을 수 있습니다
이때 startTransition을 사용할 수 있습니다.
import { startTransition, useState } from 'react';
function SearchPage() {
const [keyword, setKeyword] = useState('');
const [query, setQuery] = useState('');
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const nextValue = e.target.value;
setKeyword(nextValue);
startTransition(() => {
setQuery(nextValue);
});
};
return (
<>
<input value={keyword} onChange={handleChange} />
<SearchResult query={query} />
</>
);
}여기서:
keyword업데이트는 긴급한 업데이트query업데이트는 transition 업데이트
로 볼 수 있습니다.
즉, transition은 "이 업데이트는 사용자 입력 반응성보다 덜 급해도 된다"는 힌트를 React에게 주는 방식입니다.
useTransition은 무엇을 도와줄까?
useTransition은 transition 상태를 컴포넌트 안에서 다루기 쉽게 해줍니다.
import { useTransition, useState } from 'react';
function SearchPage() {
const [keyword, setKeyword] = useState('');
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const nextValue = e.target.value;
setKeyword(nextValue);
startTransition(() => {
setQuery(nextValue);
});
};
return (
<>
<input value={keyword} onChange={handleChange} />
{isPending && <p>검색 결과를 업데이트하는 중입니다...</p>}
<SearchResult query={query} />
</>
);
}isPending을 사용하면 덜 긴급한 업데이트가 진행 중임을 UI에 표현할 수 있습니다.
즉, useTransition은 단순 성능 API가 아니라, 사용자에게 더 자연스러운 응답성을 제공하기 위한 API로 이해하는 편이 좋습니다.
useDeferredValue는 언제 사용할까?
useDeferredValue도 React 18에서 자주 함께 언급됩니다.
startTransition이 상태 업데이트를 감싸는 방식이라면, useDeferredValue는 어떤 값을 늦게 따라오게 만드는 방식에 가깝습니다.
예를 들어:
function SearchPage() {
const [keyword, setKeyword] = useState('');
const deferredKeyword = useDeferredValue(keyword);
return (
<>
<input value={keyword} onChange={(e) => setKeyword(e.target.value)} />
<SearchResult query={deferredKeyword} />
</>
);
}여기서 input의 keyword는 즉시 바뀌지만, SearchResult에 전달되는 deferredKeyword는 조금 늦게 따라올 수 있습니다.
즉, useDeferredValue는 무거운 자식 컴포넌트가 긴급한 입력 업데이트를 방해하지 않도록 값을 지연시키는 도구로 볼 수 있습니다.
Suspense는 React 18에서 왜 더 중요해졌을까?
Suspense는 React 18 이전에도 존재했습니다. 하지만 React 18에서는 concurrent rendering, streaming server rendering, selective hydration과 연결되면서 더 중요한 위치를 갖게 되었습니다.
기본적으로 Suspense는 어떤 컴포넌트가 아직 준비되지 않았을 때 fallback UI를 보여줄 수 있게 합니다.
<Suspense fallback={<Loading />}>
<UserProfile />
</Suspense>React 18 관점에서 중요한 것은, Suspense가 단순 로딩 컴포넌트 문법이 아니라 준비되지 않은 UI를 기다리면서도 다른 UI를 먼저 보여줄 수 있는 렌더링 경계가 된다는 점입니다.
특히 서버 렌더링에서는:
- 준비된 부분부터 HTML을 스트리밍하고
- 늦게 준비되는 부분은 나중에 보내고
- 클라이언트는 필요한 부분부터 hydration할 수 있습니다
이런 방향과 연결됩니다.
즉, React 18에서 Suspense는 "로딩 UI"를 넘어서, 렌더링 작업을 나누고 사용자에게 더 빠르게 의미 있는 화면을 보여주기 위한 경계로 이해할 수 있습니다.
Selective Hydration은 무엇일까?
서버 렌더링을 사용하는 앱에서는 hydration이 중요합니다.
hydration은 서버에서 만들어진 HTML에 클라이언트 React가 이벤트와 상태를 연결하는 과정입니다.
기존에는 hydration이 큰 덩어리로 진행되는 느낌이 강했습니다. React 18에서는 Suspense 경계와 함께 중요한 부분을 더 우선적으로 hydration할 수 있는 기반이 강화되었습니다.
예를 들어 사용자가 어떤 버튼을 먼저 클릭하려고 하면, React는 그 상호작용과 관련된 영역을 우선적으로 hydration할 수 있습니다.
즉, selective hydration은 서버 렌더링된 화면 전체가 모두 준비될 때까지 기다리는 대신, 사용자 상호작용에 중요한 부분을 더 빨리 활성화하려는 방향입니다.
React 18에서 Strict Mode가 더 이상하게 느껴지는 이유
React 18 개발 모드에서 StrictMode를 켜면 effect가 두 번 실행되는 것처럼 보이는 경우가 있습니다.
이것은 버그라기보다, React가 컴포넌트가 안전하게 재마운트될 수 있는지 검증하기 위해 개발 환경에서 의도적으로 더 엄격하게 동작하는 것입니다.
예를 들어:
- effect cleanup이 제대로 작성되어 있는지
- 외부 구독을 중복으로 만들지 않는지
- 렌더링과 effect 사이드 이펙트가 섞여 있지 않은지
를 더 빨리 발견할 수 있게 합니다.
즉, React 18의 Strict Mode에서 "왜 두 번 실행되지?"라는 현상을 만나면, 단순히 불편한 동작으로만 보기보다 앞으로의 concurrent rendering 환경에서도 안전한 코드인지 검증하는 신호로 보는 편이 좋습니다.
기존 React와 React 18을 비교하면
간단히 비교해보면 이렇습니다.
| 관점 | 기존 React | React 18 |
|---|---|---|
| Root API | ReactDOM.render |
createRoot |
| 렌더링 모델 | 대체로 동기 렌더링 중심 | concurrent rendering 기반 |
| 작업 중단/재개 | 제한적 | 더 유연하게 가능 |
| batching | 주로 React 이벤트 안에서 | Promise, timeout 등 더 넓은 범위 |
| 우선순위 표현 | 제한적 | transition으로 표현 가능 |
| Suspense | 일부 use case 중심 | SSR, streaming, hydration과 더 깊게 연결 |
이 표에서 핵심은 React 18이 단순히 API 몇 개를 추가한 것이 아니라, UI 업데이트를 더 유연하게 스케줄링할 수 있는 기반을 마련했다는 점입니다.
실무에서는 어떻게 이해하면 좋을까?
React 18의 렌더링 방식을 실무적으로 이해할 때는 아래 감각이 중요합니다.
1. 렌더링은 곧바로 화면 반영이 아닐 수 있다
render phase와 commit phase를 구분해야 합니다.
특히 concurrent rendering에서는 계산이 시작되었다가 폐기될 수 있습니다.
2. 컴포넌트 함수는 더더욱 순수해야 한다
렌더링 중 side effect를 만들면 React 18의 렌더링 모델에서 문제가 더 잘 드러납니다.
네트워크 요청, 분석 이벤트, 외부 상태 변경은 effect나 이벤트 핸들러 쪽으로 분리해야 합니다.
3. 긴급한 업데이트와 덜 긴급한 업데이트를 구분해야 한다
입력 반응성은 긴급하고, 무거운 리스트 업데이트는 덜 긴급할 수 있습니다.
이 차이를 표현할 때 transition이 의미를 갖습니다.
4. batching을 믿되, 설계를 대체하지는 않는다
automatic batching은 렌더링 횟수를 줄여주지만, 무거운 계산 자체를 없애주지는 않습니다.
상태 위치, 컴포넌트 분리, 메모이제이션, virtualization 같은 설계는 여전히 중요합니다.
5. Suspense는 로딩 UI 문법 이상으로 봐야 한다
React 18 이후 Suspense는 렌더링 경계, 서버 렌더링, hydration 전략과 연결됩니다.
단순히 <Loading />을 보여주는 도구로만 보면 React 18의 방향을 놓치기 쉽습니다.
자주 하는 오해
React 18 렌더링을 공부할 때 자주 생기는 오해를 정리하면 아래와 같습니다.
1. Concurrent Rendering은 무조건 병렬 렌더링이다
아닙니다. 여러 스레드에서 동시에 렌더링한다는 뜻보다, 렌더링 작업을 우선순위에 따라 더 유연하게 다룰 수 있다는 뜻에 가깝습니다.
2. React 18을 설치하면 자동으로 모든 기능이 켜진다
아닙니다. 클라이언트 렌더링에서는 createRoot를 사용해야 새로운 root API 기반 동작을 사용할 수 있습니다.
3. startTransition을 쓰면 무조건 빨라진다
아닙니다. transition은 업데이트 우선순위를 낮추는 힌트이지, 무거운 계산 자체를 없애는 도구는 아닙니다.
4. Automatic batching 때문에 최적화를 신경 쓰지 않아도 된다
아닙니다. batching은 렌더링 횟수를 줄일 수 있지만, 렌더링 비용 자체가 큰 구조는 여전히 개선해야 합니다.
5. Strict Mode에서 두 번 실행되는 것은 React 버그다
아닙니다. 개발 모드에서 side effect와 cleanup 문제를 드러내기 위한 의도적인 동작입니다.
같이 보면 좋은 글
- React의 리렌더링은 왜 일어날까: 기본 동작 방식부터 다시 이해하기
- React의 리렌더링은 왜 일어나고 실무에서는 어떻게 줄일까
- 왜 React를 쓸까: 순수 JavaScript와 다른 선택지까지 포함해 다시 보는 trade-off
- React Query에서 Suspense, SSR, Hydration은 어떻게 다뤄야 할까
결론
React 18의 렌더링 변화는 단순히 "더 빨라졌다"로 이해하면 부족합니다. 더 본질적으로는 React가 렌더링 작업을 더 유연하게 스케줄링하고, 긴급한 작업과 덜 긴급한 작업을 구분할 수 있는 기반을 갖췄다는 변화입니다.
짧게 정리하면:
- 기존 React는 대체로 동기 렌더링 중심으로 이해하기 쉬웠고
- React 18은 concurrent rendering을 통해 작업 중단, 재개, 폐기가 가능한 기반을 만들었으며
- automatic batching, transition, Suspense는 이 렌더링 모델 위에서 사용자 경험을 더 부드럽게 만들기 위한 도구입니다
결국 React 18의 렌더링 방식을 제대로 이해한다는 것은 새 API 이름을 외우는 것이 아니라, React가 UI 업데이트의 우선순위와 실제 화면 반영을 어떻게 다루는지 이해하는 것에 더 가깝습니다.
