안녕하세요! 오늘은 React의 가장 혁신적인 기능 중 하나인 Concurrent Mode에 대해 깊이 있게 알아보려고 합니다. React 18과 함께 정식으로 도입된 이 기능은 React 애플리케이션의 성능과 사용자 경험을 획기적으로 개선할 수 있는 잠재력을 가지고 있습니다. 프론트엔드 개발자라면 반드시 이해하고 있어야 할 중요한 개념이죠.
Concurrent Mode는 React의 새로운 렌더링 패러다임으로, UI 렌더링을 중단 가능하고, 우선순위를 조정할 수 있으며, 비동기적으로 처리할 수 있게 해주는 기능입니다. 이름에서 알 수 있듯이 '동시성(Concurrency)'을 핵심으로 합니다.
기존의 React는 렌더링 과정이 동기적이고 블로킹 방식으로 진행되었습니다. 즉, 렌더링이 시작되면 완료될 때까지 중단 없이 진행되어야 했죠. 하지만 Concurrent Mode에서는 렌더링 작업을 여러 조각으로 나누고, 우선순위에 따라 처리하며, 필요에 따라 중단하고 재개할 수 있습니다.
// React 18에서는 createRoot API를 통해 Concurrent Mode 활성화
import { createRoot } from 'react-dom/client';
const container = document.getElementById('root');
const root = createRoot(container); // Concurrent Mode 활성화!
root.render(<App />);
React 18부터는 createRoot API를 사용하면 자동으로 Concurrent 렌더링 기능을 사용할 수 있게 되었습니다. 별도의 "Concurrent Mode"를 활성화할 필요가 없어졌죠. 대신 Concurrent 기능들을 선택적으로 사용할 수 있습니다.
Concurrent Mode가 해결하고자 하는 핵심 문제들을 살펴보겠습니다:
복잡한 UI 업데이트가 발생할 때, 기존 React에서는 브라우저의 메인 스레드를 장시간 점유하여 사용자 입력에 대한 응답이 지연될 수 있었습니다. Concurrent Mode는 렌더링 작업을 작은 단위로 나누어 중요한 사용자 입력이나 애니메이션에 우선순위를 줌으로써 UI의 반응성을 유지합니다.
데이터를 가져오는 동안 로딩 상태를 표시하고, 데이터가 준비되면 UI를 업데이트하는 과정은 복잡할 수 있습니다. Concurrent Mode의 Suspense와 같은 기능은 이러한 데이터 로딩 패턴을 선언적으로 처리할 수 있게 해줍니다.
사용자들은 다양한 네트워크 환경과 다양한 성능의 디바이스를 사용합니다. Concurrent Mode는 이러한 다양성에 더 잘 대응할 수 있는 도구를 제공합니다. 예를 들어, 느린 네트워크 환경에서도 UI가 반응성을 유지할 수 있도록 돕습니다.
Concurrent Mode를 이해하기 위한 핵심 개념들을 살펴보겠습니다:
시간 분할은 렌더링 작업을 작은 청크로 나누어 브라우저가 사용자 이벤트를 처리할 수 있는 틈을 만드는 기법입니다. 이를 통해 큰 렌더링 작업 중에도 UI가 반응성을 유지할 수 있습니다.
// 내부적으로 React는 requestIdleCallback과 유사한 개념을 사용합니다
// (실제로는 scheduler 패키지의 더 정교한 구현 사용)
function workLoop(deadline) {
while (nextUnitOfWork && deadline.timeRemaining() > 0) {
nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
}
if (nextUnitOfWork) {
// 아직 작업이 남았지만 시간이 부족하므로 다음 프레임으로 이어서 처리
requestIdleCallback(workLoop);
} else {
// 모든 작업이 완료됨
commitRoot();
}
}
모든 UI 업데이트가 동일하게 중요한 것은 아닙니다. Concurrent Mode는 다양한 우선순위 레벨을 통해 중요한 업데이트(사용자 입력 등)를 먼저 처리할 수 있게 해줍니다.
React는 내부적으로 다음과 같은 우선순위 레벨을 사용합니다:
// React의 우선순위 레인(Lane) 개념 (간소화된 버전)
export const SyncLane = /* */ 0b0000000000000000000000000000001;
export const InputContinuousLane = /* */ 0b0000000000000000000000000000100;
export const DefaultLane = /* */ 0b0000000000000000000000000010000;
export const TransitionLane = /* */ 0b0000000000000000000000100000000;
export const IdleLane = /* */ 0b0100000000000000000000000000000;
export const OffscreenLane = /* */ 0b1000000000000000000000000000000;
각 레인(Lane)은 다른 우선순위를 나타내며, 높은 우선순위의 작업이 들어오면 낮은 우선순위의 작업을 중단하고 처리할 수 있습니다.
Suspense는 컴포넌트가 렌더링을 '중단'하고 특정 조건(예: 데이터 로딩)이 충족될 때까지 '대기'할 수 있게 해주는 메커니즘입니다. 이를 통해 데이터 로딩과 같은 비동기 작업을 선언적으로 처리할 수 있습니다.
// Suspense를 사용한 데이터 로딩 처리 예시
function ProfilePage() {
return (
<Suspense fallback={<LoadingSpinner />}>
<ProfileDetails />
<Suspense fallback={<PostsGlimmer />}>
<Posts />
</Suspense>
</Suspense>
);
}
트랜지션은 UI 업데이트를 '긴급하지 않은' 것으로 표시하여, 더 중요한 업데이트가 방해받지 않도록 하는 개념입니다. 예를 들어, 검색 입력 필드에 타이핑하는 것은 즉각적으로 반영되어야 하지만, 검색 결과를 표시하는 것은 약간 지연되어도 괜찮은 경우가 많습니다.
// useTransition 훅 사용 예시
import { useState, useTransition } from 'react';
function SearchBar() {
const [query, setQuery] = useState('');
const [searchResults, setSearchResults] = useState([]);
const [isPending, startTransition] = useTransition();
function handleChange(e) {
const value = e.target.value;
// 즉시 입력값을 업데이트 (높은 우선순위)
setQuery(value);
// 검색 결과 업데이트를 트랜지션으로 표시 (낮은 우선순위)
startTransition(() => {
setSearchResults(searchFor(value));
});
}
return (
<>
<input value={query} onChange={handleChange} />
{isPending ? <Spinner /> : <SearchResults results={searchResults} />}
</>
);
}
useDeferredValue 훅은 값의 업데이트를 지연시켜 UI의 반응성을 유지할 수 있게 해줍니다. 이는 useTransition과 유사하지만, 트랜지션을 시작하는 다른 컴포넌트에 의존하지 않고도 사용할 수 있습니다.
// useDeferredValue 사용 예시
import { useState, useDeferredValue } from 'react';
function SearchResults({ query }) {
// 쿼리 값의 업데이트를 지연시킴
const deferredQuery = useDeferredValue(query);
const results = searchFor(deferredQuery);
return (
<ul>
{results.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
}
React 18에서는 기존의 "Concurrent Mode"라는 별도의 모드 개념이 사라지고, 대신 Concurrent 기능들을 필요에 따라 선택적으로 사용할 수 있는 방식으로 변경되었습니다. 이를 "Concurrent Features"라고 부릅니다.
자동 배치(Automatic Batching)
여러 상태 업데이트를 하나의 렌더링으로 묶어 처리하는 기능입니다. React 18 이전에는 이벤트 핸들러 내에서만 배치가 적용되었지만, 이제는 Promise, setTimeout 등 비동기 컨텍스트에서도 자동으로 배치가 적용됩니다.
// React 18 이전: 두 번 렌더링됨
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// 리렌더링
// 리렌더링
}, 1000);
// React 18: 한 번만 렌더링됨
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// 한 번의 리렌더링만 발생
}, 1000);
트랜지션 API
앞서 설명한 useTransition 훅과 startTransition 함수를 통해 UI 업데이트의 우선순위를 낮출 수 있습니다.
Suspense 개선
Suspense가 이제 서버 사이드 렌더링(SSR)을 지원하며, 데이터 페칭과의 통합이 개선되었습니다.
신규 훅들
useDeferredValue, useId, useSyncExternalStore, useInsertionEffect 등의 새로운 훅들이 추가되었습니다.
React 18에서는 ReactDOM.render 대신 ReactDOM.createRoot를 사용하여 애플리케이션을 마운트합니다:
// React 17 이전
ReactDOM.render(<App />, document.getElementById('root'));
// React 18
import { createRoot } from 'react-dom/client';
const root = createRoot(document.getElementById('root'));
root.render(<App />);
이 새로운 API를 사용하면 자동으로 Concurrent 렌더링이 활성화되며, 모든 Concurrent 기능을 사용할 수 있게 됩니다.
Concurrent 렌더링의 내부 작동 원리를 더 깊이 이해해봅시다:
React의 Concurrent 모드는 "파이버" 아키텍처 위에 구축되어 있습니다. 파이버는 React 컴포넌트 트리의 각 노드를 나타내는 자료 구조로, 렌더링 작업을 작은 단위로 나누고 중단/재개할 수 있게 해줍니다.
// 파이버 객체의 간소화된 구조
type Fiber = {
// 컴포넌트 정보
type: any,
key: null | string,
// 파이버 트리 구조
child: Fiber | null,
sibling: Fiber | null,
return: Fiber | null,
// 작업 상태
flags: Flags,
subtreeFlags: Flags,
// 우선순위 관련
lanes: Lanes,
childLanes: Lanes,
// 상태 및 업데이트
memoizedState: any,
updateQueue: UpdateQueue<any> | null,
// 기타 정보들...
};
React의 렌더링 프로세스는 크게 두 단계로 나뉩니다:
렌더링 단계(Render Phase): 컴포넌트를 호출하고 변경사항을 계산하는 단계입니다. Concurrent Mode에서는 이 단계가 중단 가능하며, 우선순위에 따라 처리됩니다.
커밋 단계(Commit Phase): 계산된 변경사항을 실제 DOM에 적용하는 단계입니다. 이 단계는 항상 동기적이고 중단할 수 없습니다.
Concurrent 모드에서 React는 렌더링 작업을 작은 단위로 나누어 처리합니다:
function workLoopConcurrent() {
// 작업 단위가 있고 작업 기한이 지나지 않은 경우 계속 작업
while (workInProgress !== null && !shouldYield()) {
performUnitOfWork(workInProgress);
}
}
shouldYield() 함수는 브라우저가 중요한 작업(사용자 입력 처리 등)을 수행해야 할 때 true를 반환하여 React가 작업을 일시 중단하게 합니다.
React는 "레인(Lane)" 모델을 사용하여 작업의 우선순위를 관리합니다. 각 업데이트는 특정 레인에 할당되며, 높은 우선순위의 레인이 낮은 우선순위의 레인보다 먼저 처리됩니다.
// 우선순위에 따라 작업 레인 선택
function requestUpdateLane(fiber: Fiber): Lane {
// 특정 모드나 컨텍스트에 따라 적절한 레인 반환
if (isTransition) {
return TransitionLane;
} else if (isInputEvent) {
return InputContinuousLane;
} else {
return DefaultLane;
}
}
React는 높은 우선순위 작업이 들어오면 진행 중인 낮은 우선순위 작업을 중단하고, 높은 우선순위 작업을 먼저 처리한 후 낮은 우선순위 작업을 재개합니다:
// 높은 우선순위 작업이 들어왔을 때의 처리 (개념적 코드)
function handleHighPriorityUpdate() {
// 현재 진행 중인 작업 중단
if (currentWorkInProgress) {
interruptCurrentRender();
}
// 높은 우선순위 작업 처리
performHighPriorityUpdate();
// 중단된 작업 재개
if (interruptedWork) {
resumeWorkLoop(interruptedWork);
}
}
React는 "current" 트리와 "workInProgress" 트리를 번갈아가며 사용하는 이중 버퍼링 기법을 사용합니다. "current" 트리는 현재 화면에 표시된 상태를 나타내고, "workInProgress" 트리는 다음에 커밋될 변경사항을 계산하는데 사용됩니다.
// 렌더링 완료 후 트리 전환 (개념적 코드)
function finishConcurrentRender() {
// 커밋 단계 수행
commitRoot(root, workInProgressRoot);
// 트리 전환
root.current = workInProgressRoot;
workInProgressRoot = null;
}
이제 Concurrent Mode의 주요 기능들을 실제로 어떻게 활용할 수 있는지 살펴보겠습니다:
useTransition은 UI 업데이트를 "긴급하지 않은" 것으로 표시하여, 사용자 경험을 개선하는 데 도움을 줍니다:
import { useState, useTransition } from 'react';
function TabContainer() {
const [selectedTab, setSelectedTab] = useState('home');
const [isPending, startTransition] = useTransition();
function selectTab(tab) {
// 탭 전환을 트랜지션으로 처리
startTransition(() => {
setSelectedTab(tab);
});
}
return (
<div>
<TabButton
isSelected={selectedTab === 'home'}
onClick={() => selectTab('home')}
>
Home
</TabButton>
<TabButton
isSelected={selectedTab === 'posts'}
onClick={() => selectTab('posts')}
>
Posts (수천 개의 포스트)
</TabButton>
{/* 로딩 인디케이터 표시 */}
{isPending && <Spinner />}
{/* 탭 내용 */}
<div>
{selectedTab === 'home' && <HomeTab />}
{selectedTab === 'posts' && <PostsTab />}
</div>
</div>
);
}
이 예제에서 PostsTab이 렌더링하는데 시간이 오래 걸리는 무거운 컴포넌트라고 가정해봅시다. useTransition을 사용하면:
PostsTab이 렌더링되는 동안에도 UI는 반응성을 유지합니다.isPending 상태를 통해 로딩 인디케이터를 표시할 수 있습니다.useDeferredValue는 값의 업데이트를 지연시켜 UI의 반응성을 유지하는 데 도움을 줍니다:
import { useState, useDeferredValue, useMemo } from 'react';
function SearchResults() {
const [query, setQuery] = useState('');
// 쿼리 값의 업데이트를 지연시킴
const deferredQuery = useDeferredValue(query);
// 메모이제이션된 결과 계산
const results = useMemo(() => {
// 무거운 계산 작업 (예: 수천 개의 항목 필터링)
return computeSearchResults(deferredQuery);
}, [deferredQuery]);
// 로딩 상태 표시
const isStale = query !== deferredQuery;
return (
<div>
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="검색어 입력..."
/>
<div style={{ opacity: isStale ? 0.5 : 1 }}>
{isStale && <Spinner />}
<ul>
{results.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
</div>
);
}
이 예제에서:
query 상태는 즉시 업데이트됩니다.deferredQuery는 낮은 우선순위로 업데이트되므로, 입력 필드는 항상 반응성을 유지합니다.Suspense와 함께 데이터 페칭을 처리하는 패턴입니다:
import { Suspense } from 'react';
import { fetchData } from './api';
// 데이터 리소스 생성
const resource = fetchData();
function ProfilePage() {
return (
<Suspense fallback={<LoadingSpinner />}>
<ProfileDetails />
<Suspense fallback={<PostsGlimmer />}>
<Posts />
</Suspense>
</Suspense>
);
}
function ProfileDetails() {
// 데이터를 읽을 때 준비되지 않았다면 Suspense 발동
const data = resource.user.read();
return <h1>{data.name}</h1>;
}
function Posts() {
// 포스트 데이터를 읽을 때 준비되지 않았다면 Suspense 발동
const posts = resource.posts.read();
return (
<ul>
{posts.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}
이 패턴을 사용하면:
React 팀이 개발 중인 Offscreen 컴포넌트는 화면 밖의 콘텐츠를 미리 렌더링하거나 캐싱하는 데 사용될 수 있습니다:
// 실험적 기능 - 아직 공식적으로 출시되지 않음
import { unstable_Offscreen as Offscreen } from 'react';
function TabSwitcher() {
const [tab, setTab] = useState('home');
return (
<div>
<TabButtons activeTab={tab} onChange={setTab} />
<div className="tab-content">
{/* 현재 선택된 탭은 visible로 렌더링 */}
<Offscreen mode={tab === 'home' ? 'visible' : 'hidden'}>
<HomeTab />
</Offscreen>
{/* 다른 탭들은 hidden으로 렌더링 (낮은 우선순위) */}
<Offscreen mode={tab === 'profile' ? 'visible' : 'hidden'}>
<ProfileTab />
</Offscreen>
</div>
</div>
);
}
이 패턴은 탭 전환을 더 부드럽게 만들고, 사용자가 탭을 전환할 때마다 전체 콘텐츠를 다시 렌더링할 필요 없이 미리 렌더링된 콘텐츠를 표시할 수 있게 해줍니다.
Concurrent Mode를 활용한 성능 최적화 전략을 살펴보겠습니다:
복잡한 애플리케이션에서는 UI 업데이트의 우선순위를 전략적으로 조정하는 것이 중요합니다:
function ComplexDashboard() {
const [filters, setFilters] = useState(initialFilters);
const [isPending, startTransition] = useTransition();
// 필터 변경 처리
function handleFilterChange(newFilters) {
// 필터 UI 즉시 업데이트 (높은 우선순위)
setFilters(newFilters);
// 데이터 다시 가져오기 및 무거운 계산은 트랜지션으로 처리 (낮은 우선순위)
startTransition(() => {
fetchFilteredData(newFilters);
});
}
return (
<div className="dashboard">
<FilterPanel
filters={filters}
onChange={handleFilterChange}
/>
{/* 트랜지션 진행 중임을 표시 */}
{isPending ? <LoadingIndicator /> : null}
<DashboardContent />
</div>
);
}
이런 접근 방식은 사용자가 필터를 변경할 때 UI가 즉시 반응하게 하면서도, 무거운 데이터 처리는 백그라운드에서 일어나게 합니다.
무거운 컴포넌트의 렌더링을 지연시켜 초기 로딩 속도를 개선할 수 있습니다:
function HomePage() {
const [showComments, setShowComments] = useState(false);
const [isPending, startTransition] = useTransition();
// 댓글 섹션 표시 토글
function handleShowComments() {
startTransition(() => {
setShowComments(true);
});
}
return (
<div>
<MainContent />
{!showComments ? (
<button onClick={handleShowComments}>
댓글 보기 ({commentCount})
</button>
) : isPending ? (
<CommentsPlaceholder />
) : (
<Comments />
)}
</div>
);
}
이 패턴은 페이지의 중요한 콘텐츠를 먼저 보여주고, 사용자 상호작용이 발생할 때만 무거운 컴포넌트를 렌더링합니다.
Suspense 경계를 적절히 중첩하여 점진적 로딩 경험을 만들 수 있습니다:
function ProductPage({ productId }) {
return (
<Suspense fallback={<PageSkeleton />}>
<MainProductInfo productId={productId} />
<Suspense fallback={<SpecsSkeleton />}>
<ProductSpecs productId={productId} />
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews productId={productId} />
</Suspense>
<Suspense fallback={<RelatedSkeleton />}>
<RelatedProducts productId={productId} />
</Suspense>
</Suspense>
);
}
이 구조는 전체 페이지가 로드될 때까지 기다리지 않고, 각 섹션이 데이터를 받는 대로 단계적으로 표시됩니다.
useDeferredValue를 사용하여 렌더링 작업을 둘 이상의 단계로 분할할 수 있습니다:
function SearchPage() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
// 즉시 반응해야 하는 간단한 UI 컴포넌트
const searchHeader = (
<SearchHeader
query={query}
onChange={setQuery}
/>
);
// 무거운 결과 목록 컴포넌트
const searchResults = useMemo(() => {
return <SearchResults query={deferredQuery} />;
}, [deferredQuery]);
// 검색 결과가 "stale" 상태임을 표시
const isStale = query !== deferredQuery;
return (
<div className="search-page">
{searchHeader}
<div style={{ opacity: isStale ? 0.8 : 1 }}>
{isStale && <div className="updating-indicator">업데이트 중...</div>}
{searchResults}
</div>
</div>
);
}
이 패턴은 검색 입력과 같은 핵심 상호작용 요소가 항상 반응적으로 유지되도록 하면서, 무거운 결과 렌더링은 별도의 단계로 진행됩니다.
Concurrent Mode를 활용한 몇 가지 실제 사례를 살펴보겠습니다:
검색 기능은 Concurrent Mode의 이점을 명확하게 보여주는 대표적인 사례입니다:
function SearchBox() {
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();
const [results, setResults] = useState([]);
function handleChange(e) {
const value = e.target.value;
// 입력 필드 즉시 업데이트
setQuery(value);
// 검색 로직은 트랜지션으로 처리
startTransition(() => {
if (value.trim() === '') {
setResults([]);
} else {
const searchResults = performSearch(value);
setResults(searchResults);
}
});
}
return (
<div>
<input
value={query}
onChange={handleChange}
placeholder="검색어 입력..."
/>
<div className="results-container">
{isPending && <LoadingIndicator />}
<ul className={isPending ? 'stale-results' : 'fresh-results'}>
{results.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
</div>
);
}
이 구현은 다음과 같은 이점을 제공합니다:
탭 컴포넌트는 사용자가 다른 콘텐츠 섹션 사이를 전환할 때 Concurrent Mode의 이점을 보여줍니다:
function TabbedInterface() {
const [activeTab, setActiveTab] = useState('summary');
const [isPending, startTransition] = useTransition();
function handleTabChange(tab) {
// 탭 전환을 트랜지션으로 처리
startTransition(() => {
setActiveTab(tab);
});
}
// 각 탭의 활성 상태 확인
const isSummaryActive = activeTab === 'summary';
const isDetailsActive = activeTab === 'details';
const isReviewsActive = activeTab === 'reviews';
return (
<div className="tabbed-interface">
<div className="tab-buttons">
<button
className={isSummaryActive ? 'active' : ''}
onClick={() => handleTabChange('summary')}
>
요약
</button>
<button
className={isDetailsActive ? 'active' : ''}
onClick={() => handleTabChange('details')}
>
상세 정보
</button>
<button
className={isReviewsActive ? 'active' : ''}
onClick={() => handleTabChange('reviews')}
>
리뷰
</button>
</div>
{/* 트랜지션 진행 중 표시 */}
{isPending && <TabTransitionIndicator />}
<div className="tab-content">
{isSummaryActive && <SummaryTab />}
{isDetailsActive && <DetailsTab />}
{isReviewsActive && <ReviewsTab />}
</div>
</div>
);
}
이 구현은 다음 이점을 제공합니다:
대량의 데이터를 처리하는 그리드 컴포넌트는 Concurrent Mode의 이점을 크게 볼 수 있습니다:
function DataGrid({ initialData }) {
const [data, setData] = useState(initialData);
const [filters, setFilters] = useState({});
const [sortBy, setSortBy] = useState(null);
const [isPending, startTransition] = useTransition();
// 필터 변경 처리
function handleFilterChange(newFilters) {
// 필터 UI 즉시 업데이트
setFilters(newFilters);
// 데이터 필터링은 트랜지션으로 처리
startTransition(() => {
const filteredData = applyFilters(initialData, newFilters);
const sortedData = sortBy
? applySorting(filteredData, sortBy)
: filteredData;
setData(sortedData);
});
}
// 정렬 변경 처리
function handleSortChange(column) {
// 정렬 UI 즉시 업데이트
const newSortBy = {
column,
direction: sortBy?.column === column && sortBy.direction === 'asc'
? 'desc'
: 'asc'
};
setSortBy(newSortBy);
// 데이터 정렬은 트랜지션으로 처리
startTransition(() => {
const sortedData = applySorting(data, newSortBy);
setData(sortedData);
});
}
return (
<div className="data-grid">
<FilterPanel
filters={filters}
onChange={handleFilterChange}
/>
<div className="grid-container">
<GridHeader
columns={columns}
sortBy={sortBy}
onSortChange={handleSortChange}
/>
{isPending ? (
<div className="grid-overlay">
<LoadingSpinner />
{/* 이전 데이터를 흐리게 표시 */}
<div className="stale-data">
<GridRows data={data} />
</div>
</div>
) : (
<GridRows data={data} />
)}
</div>
</div>
);
}
이 패턴은 다음과 같은 이점을 제공합니다:
Concurrent Mode를 활용할 때 주의해야 할 몇 가지 사항들이 있습니다:
Concurrent Mode에서는 컴포넌트가 여러 번 렌더링될 수 있으며, 일부 렌더링은 "commit"되지 않을 수 있습니다. 따라서 렌더링 중에 부수 효과가 발생하지 않도록 주의해야 합니다:
// ❌ 잘못된 예: 렌더링 중 부수 효과
function SearchResults({ query }) {
// 렌더링 중 로그 기록 - 중복 로그가 발생할 수 있음
console.log(`Rendering results for: ${query}`);
// 렌더링 중 API 호출 - 중복 요청이 발생할 수 있음
const results = fetchSearchResults(query);
return <ResultsList results={results} />;
}
// ✅ 올바른 예: useEffect를 통한 부수 효과 관리
function SearchResults({ query }) {
const [results, setResults] = useState([]);
useEffect(() => {
// 커밋된 렌더링 후에만 실행
console.log(`Searching for: ${query}`);
fetchSearchResults(query).then(setResults);
}, [query]);
return <ResultsList results={results} />;
}
Suspense와 에러 경계를 효과적으로 배치하는 것이 중요합니다:
function UserProfile({ userId }) {
return (
<ErrorBoundary
fallback={error => <ErrorDisplay error={error} />}
>
<Suspense fallback={<ProfileSkeleton />}>
<UserProfileContent userId={userId} />
</Suspense>
</ErrorBoundary>
);
}
항상 ErrorBoundary를 Suspense 외부에 배치하여 로딩 중에 발생하는 에러도 잡을 수 있게 해야 합니다.
트랜지션 내에서 여러 상태를 업데이트할 때 일관성에 주의해야 합니다:
// ❌ 잘못된 예: 일부는 트랜지션 내부, 일부는 외부
function FilterAndSort() {
const [filters, setFilters] = useState(initialFilters);
const [sortOrder, setSortOrder] = useState('asc');
const [isPending, startTransition] = useTransition();
function handleFilterChange(newFilters) {
// 즉시 업데이트
setFilters(newFilters);
// 트랜지션 내 업데이트 - 일관성 문제 발생 가능
startTransition(() => {
setSortOrder('desc');
});
}
// ...
}
// ✅ 올바른 예: 관련 상태는 함께 업데이트
function FilterAndSort() {
const [filters, setFilters] = useState(initialFilters);
const [sortOrder, setSortOrder] = useState('asc');
const [isPending, startTransition] = useTransition();
function handleFilterChange(newFilters) {
startTransition(() => {
// 관련 상태 함께 업데이트
setFilters(newFilters);
setSortOrder('desc');
});
}
// ...
}
Concurrent Mode에서도 불필요한 렌더링을 방지하기 위한 메모이제이션이 여전히 중요합니다:
function SearchResults({ query }) {
const deferredQuery = useDeferredValue(query);
// 메모이제이션을 통해 쿼리가 변경될 때만 다시 계산
const results = useMemo(() => {
return computeExpensiveResults(deferredQuery);
}, [deferredQuery]);
// 지연된 쿼리와 현재 쿼리가 다른지 확인
const isStale = query !== deferredQuery;
return (
<div style={{ opacity: isStale ? 0.8 : 1 }}>
<ResultsList results={results} />
</div>
);
}
Concurrent Mode 기능을 서버 사이드 렌더링(SSR)과 함께 사용할 때 특별한 고려가 필요합니다:
// 서버에서 렌더링된 콘텐츠를 클라이언트에서 하이드레이션
function App() {
return (
<Suspense fallback={<FullPageSpinner />}>
{/* 서버에서 렌더링된 콘텐츠 */}
<MainContent />
{/* 클라이언트에서만 렌더링되는 콘텐츠 */}
<Suspense fallback={<SidebarSkeleton />}>
<ClientOnlySidebar />
</Suspense>
</Suspense>
);
}
React 18은 <Suspense>를 활용한 Streaming SSR을 지원하여, 서버에서 HTML을 점진적으로 전송할 수 있게 해줍니다.
Concurrent Mode의 미래 발전 방향과 그 의미를 살펴보겠습니다:
React Server Components는 Concurrent Mode와 함께 사용될 때 강력한 성능 최적화를 제공할 것입니다:
// 서버 컴포넌트 (서버에서만 실행)
// server-components.js
export async function ServerComponent() {
// 데이터베이스에서 직접 데이터 가져오기
const data = await db.query('SELECT * FROM items');
return (
<div>
{data.map(item => (
<div key={item.id}>
{item.name}
{/* 클라이언트 컴포넌트 포함 */}
<ClientComponent initialData={item} />
</div>
))}
</div>
);
}
// 클라이언트 컴포넌트 (브라우저에서 실행)
// client-components.js
'use client';
export function ClientComponent({ initialData }) {
const [data, setData] = useState(initialData);
// ...
return <InteractiveUI data={data} onUpdate={setData} />;
}
이 조합은 서버에서 데이터 로딩 및 초기 렌더링을 처리하면서도, 클라이언트에서는 Concurrent Mode를 활용한 부드러운 상호작용을 제공합니다.
미래에는 React가 더 많은 자동 최적화 도구를 제공할 것입니다:
// 미래의 React에서 가능할 수 있는 자동 최적화 예시
function ProductList() {
const [products, setProducts] = useState([]);
const [filters, setFilters] = useState({});
// React가 자동으로 최적의 우선순위 결정
function handleFilterChange(newFilters) {
setFilters(newFilters);
fetchProducts(newFilters).then(setProducts);
}
return (
<>
<FilterPanel filters={filters} onChange={handleFilterChange} />
<ProductGrid products={products} />
</>
);
}
미래 버전의 React는 상태 업데이트와 데이터 페칭 패턴을 분석하여 자동으로 트랜지션을 적용할 수 있을 것입니다.
미래의 React는 Suspense를 데이터 페칭 라이브러리와 더 깊게 통합할 것입니다:
// 미래의 통합된 데이터 페칭 예시
function ProfilePage({ userId }) {
// 선언적 데이터 요청이 Suspense와 자동 통합
const user = useData(`/api/users/${userId}`);
const posts = useData(`/api/users/${userId}/posts`);
const recommendations = useData(`/api/recommendations?userId=${userId}`);
return (
<div>
<UserHeader user={user} />
<UserPosts posts={posts} />
<Recommendations items={recommendations} />
</div>
);
}
이 접근 방식은 데이터 페칭 로직을 더 선언적이고 간결하게 만들면서도 Concurrent Mode의 모든 이점을 자동으로 활용할 수 있게 해줍니다.
React의 Concurrent Mode는 단순한 기능 추가가 아니라, 웹 애플리케이션의 반응성과 사용자 경험을 근본적으로 개선할 수 있는 패러다임 전환입니다. 비동기적이고 우선순위 기반의 렌더링을 통해, 복잡한 UI 업데이트 중에도 애플리케이션의 반응성을 유지할 수 있게 해줍니다.
React 18부터는 이런 기능들이 기본적으로 활성화되어 있으므로, 이제 모든 React 개발자가 Concurrent Mode의 이점을 활용할 수 있게 되었습니다.
성공적인 React 애플리케이션 개발을 위해, 이러한 Concurrent 기능들을 적절히 활용하고 내부 동작 원리를 이해하는 것이 중요합니다. 이 지식은 특히 복잡한 UI를 다루거나 성능 최적화가 필요한 상황에서 큰 경쟁력이 될 것입니다.