시니어 개발자가 알아야 할 15가지 고급 React 기술
- -
React 애플리케이션이 점점 더 복잡해짐에 따라, 초기에 사용하던 패턴들이 점차 한계를 드러낼 수 있습니다. 예를 들어, 간단한 MVP는 잘 작동하지만 시간이 지나면서 성능 문제나 상태 관리의 복잡성이 커질 수 있습니다.
이러한 문제는 주니어 개발자가 시니어로 성장하면서 마주하는 자연스러운 과정입니다. 다행히도, React에서 복잡한 문제를 해결할 수 있는 고급 기술들이 존재합니다.
이번 글에서는 useCallback과 ref의 효과적인 활용법부터 Suspense를 활용한 데이터 패칭, 가상화, 에러 핸들링, 성능 최적화까지 총 15가지 기법을 소개합니다. 글을 다 읽고 나면 더 탄탄한 React 개발 역량을 가질 수 있을 것입니다.
1. useCallback을 사용하여 서비스 참조 유지하기
useCallback은 이벤트 핸들러에서 함수 참조를 유지하는 데 주로 사용됩니다. 이를 통해 불필요한 리렌더링을 방지할 수 있습니다. 고급 개발에서는 이 패턴을 확장하여 WebSocket이나 Worker와 같은 지속적인 리소스의 참조를 유지하는 데 활용할 수 있습니다.
예시 코드:
function createConnection() {
// 예를 들어, WebSocket 연결 설정
return { sendMessage: (message) => console.log('Message sent:', message) };
}
function useConnection() {
const connection = useRef(createConnection());
// sendMessage 함수가 매번 새로 만들어지지 않도록 안정화
const sendMessage = useCallback((message) => {
connection.current.sendMessage(message);
}, []);
return sendMessage;
}
function App() {
const send = useConnection();
return <button onClick={() => send('Hello World!')}>Send Message</button>;
}
2. 상태 대신 ref를 사용하여 간결한 코드 작성
모든 값을 useState로 관리할 필요는 없습니다. useRef를 사용하면 렌더링과 관계없이 내부적으로 값을 유지할 수 있습니다. 예를 들어, UI와 무관하게 카운터 값을 증가시키는 경우 useRef를 사용하는 것이 더 효율적입니다.
예시 코드:
function Counter() {
const count = useRef(0);
const increaseCount = () => {
count.current += 1;
console.log('Current count:', count.current);
};
return <button onClick={increaseCount}>Increment</button>;
}
3. Suspense와 글로벌 리소스 캐시를 활용한 데이터 패칭
데이터 패칭을 할 때, 일반적으로 useEffect와 로딩 상태를 수동으로 관리합니다. 하지만 Suspense를 사용하면 이러한 로직을 간소화할 수 있습니다. 데이터가 아직 로드되지 않았다면 해당 컴포넌트는 자동으로 "대기 상태"로 전환됩니다.
예시 코드:
function createDataResource(fetchData) {
let status = 'loading';
let result;
const promise = fetchData().then(
(data) => {
status = 'success';
result = data;
},
(error) => {
status = 'failure';
result = error;
}
);
return {
fetch() {
if (status === 'loading') throw promise;
if (status === 'failure') throw result;
return result;
},
};
}
const userResource = createDataResource(() =>
fetch('/api/user').then((response) => response.json())
);
function UserProfile() {
const user = userResource.fetch();
return <div>Welcome, {user.name}!</div>;
}
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<UserProfile />
</Suspense>
);
}
4. Suspense와 동적 import 및 미리 로딩
React.lazy()를 사용하여 컴포넌트를 지연 로딩할 수 있습니다. 이를 개선하여, 사용자가 컴포넌트를 필요로 할 때 빠르게 로딩되도록 미리 데이터를 가져오는 방법을 적용할 수 있습니다.
예시 코드:
const Chart = React.lazy(() => import('./Chart'));
function usePreloadChart() {
useEffect(() => {
import('./Chart'); // 미리 컴포넌트 로드
}, []);
}
function Dashboard() {
usePreloadChart();
return (
<Suspense fallback={<div>Loading chart...</div>}>
<Chart />
</Suspense>
);
}
5. 자동 재시도하는 오류 경계(Error Boundaries)
앱에서 네트워크 요청이나 지연 로딩된 컴포넌트가 실패할 수 있습니다. 기존의 오류 경계는 대체 UI를 표시하지만, 이를 개선하여 일정 시간 후 자동으로 재시도하도록 만들 수 있습니다. 이렇게 하면 일시적인 오류를 원활하게 복구할 수 있습니다.
예시 코드:
import { useState, useEffect } from 'react';
function useRetryErrorBoundary() {
const [hasError, setHasError] = useState(false);
const [retries, setRetries] = useState(0);
const resetError = () => {
setHasError(false);
setRetries((prevRetries) => prevRetries + 1);
};
useEffect(() => {
if (hasError) {
const retryTimeout = setTimeout(resetError, 3000);
return () => clearTimeout(retryTimeout);
}
}, [hasError]);
return { hasError, setHasError, retries };
}
function RetryErrorBoundary({ children }) {
const { hasError, setHasError, retries } = useRetryErrorBoundary();
const handleError = () => {
setHasError(true);
};
if (hasError) {
return <div>Retrying...</div>;
}
return children(retries, handleError);
}
function ProblematicComponent({ retries, onError }) {
useEffect(() => {
if (retries < 2) {
onError();
}
}, [retries, onError]);
return <div>Loaded after {retries} retries</div>;
}
// 사용법
function App() {
return (
<RetryErrorBoundary>
{(retries, onError) => <ProblematicComponent retries={retries} onError={onError} />}
</RetryErrorBoundary>
);
}
6. 동적 항목 높이를 가진 리스트 가상화
리스트 항목이 동적으로 높이를 가지는 경우, react-window와 함께 useRef를 사용하여 항목의 크기를 추적하고 가상화할 수 있습니다. 이렇게 하면 메모리 사용량을 줄이고 렌더링 성능을 개선할 수 있습니다.
예시 코드:
import { VariableSizeList as List } from 'react-window';
function useItemSize(items) {
const sizeMap = useRef({});
const setItemSize = (index) => (el) => {
if (el) {
const height = el.getBoundingClientRect().height;
sizeMap.current[index] = height;
}
};
const getSize = (index) => sizeMap.current[index] || 50;
return { setItemSize, getSize };
}
function VariableHeightList({ items }) {
const { setItemSize, getSize } = useItemSize(items);
return (
<List height={400} itemCount={items.length} itemSize={getSize} width={300}>
{({ index, style }) => (
<div style={style} ref={setItemSize(index)}>
{items[index]}
</div>
)}
</List>
);
}
7. 복잡한 UI 흐름을 위한 상태 머신(State Machine) 사용
UI 상태가 복잡해지면 조건문을 많이 사용하게 되는데, XState를 사용하면 상태와 전환을 명확히 정의할 수 있어 유지보수성이 높아집니다.
예시 코드:
import { useMachine } from '@xstate/react';
import { createMachine } from 'xstate';
const formMachine = createMachine({
initial: 'editing',
states: {
editing: { on: { SUBMIT: 'validating' } },
validating: {
invoke: {
src: 'validateForm',
onDone: 'success',
onError: 'failure',
},
},
success: {},
failure: {},
},
});
function Form() {
const [state, send] = useMachine(formMachine, {
services: { validateForm: async () => {/* 검증 로직 */} },
});
return (
<button onClick={() => send('SUBMIT')}>
{state.value === 'editing' ? 'Submit' : state.value}
</button>
);
}
8. useTransition과 작업 큐를 이용한 제어된 동시성
React 18에서 useTransition을 활용하면 특정 상태 업데이트를 "비긴급(non-urgent)"으로 처리할 수 있습니다. 이를 통해 대량의 데이터를 가져오거나 무거운 계산을 수행할 때 UI가 멈추지 않고 반응성을 유지할 수 있습니다.
예제 코드:
function ComplexUI() {
const [isPending, startTransition] = useTransition();
const [data, setData] = useState([]);
function loadMore() {
startTransition(() => {
setData((old) => [...old, ...generateMoreData()]);
});
}
return (
<>
<button onClick={loadMore}>더 보기</button>
{isPending && <span>데이터 로딩 중...</span>}
<List data={data} />
</>
);
}
9. useImperativeHandle을 사용한 제어 가능한 컴포넌트 API 만들기
부모 컴포넌트가 자식 컴포넌트를 직접 제어해야 할 때 useImperativeHandle을 사용하면 부모가 자식 컴포넌트의 특정 메서드만 호출할 수 있도록 깔끔한 API를 설계할 수 있습니다.
예제 코드:
const FancyInput = forwardRef((props, ref) => {
const inputRef = useRef();
useImperativeHandle(ref, () => ({
focus: () => inputRef.current.focus(),
getValue: () => inputRef.current.value,
}));
return <input ref={inputRef} {...props} />;
});
function Parent() {
const fancyRef = useRef();
return (
<>
<FancyInput ref={fancyRef} />
<button onClick={() => fancyRef.current.focus()}>입력 필드 포커스</button>
</>
);
}
10. 커스텀 훅을 활용한 점진적 하이드레이션 (Progressive Hydration)
SSR을 사용하면 빠르게 콘텐츠를 사용자에게 제공할 수 있지만, 큰 앱을 한 번에 하이드레이션하면 초기 상호작용이 느려질 수 있습니다. 페이지의 비핵심 부분을 지연시켜 하이드레이션하면 초기 로드를 더 빠르게 유지할 수 있습니다.
예제 코드:
function useProgressiveHydration(delay = 1000) {
const [hydrated, setHydrated] = useState(false);
useEffect(() => {
const t = setTimeout(() => setHydrated(true), delay);
return () => clearTimeout(t);
}, [delay]);
return hydrated;
}
function HeavyComponent() {
const hydrated = useProgressiveHydration();
return hydrated ? <ExpensiveTree /> : <Placeholder />;
}
11. 포털과 Suspense를 조합하여 무거운 모달 최적화
모달은 종종 크고 복잡하여, 처음부터 로딩하면 번들 크기가 커지고 성능이 저하될 수 있습니다. 이를 해결하기 위해 모달을 지연 로딩하고, 포털을 사용하여 메인 DOM 밖에서 렌더링하면 성능을 최적화할 수 있습니다.
예제 코드:
const HeavyModal = lazy(() => import('./HeavyModal'));
function PortalModal({ open }) {
return open
? createPortal(
<Suspense fallback={<div>Loading Modal...</div>}>
<HeavyModal />
</Suspense>,
document.body
)
: null;
}
12. 부드러운 레이아웃 측정을 위한 useLayoutEffect 활용
useEffect는 화면을 그린 후 실행되므로 대부분의 경우 충분하지만, 특정 요소의 위치나 크기를 즉시 조정해야 할 때는 useLayoutEffect를 사용해야 합니다. 이를 통해 화면 깜빡임 없이 매끄러운 UI 업데이트가 가능합니다.
예제 코드:
function Popover({ anchorRef }) {
const popoverRef = useRef();
useLayoutEffect(() => {
const anchorRect = anchorRef.current.getBoundingClientRect();
popoverRef.current.style.top = `${anchorRect.bottom}px`;
}, [anchorRef]);
return <div ref={popoverRef} className="popover">Content</div>;
}
13. React Profiler API를 활용한 동적 성능 최적화
React Profiler API를 사용하면 렌더링 시간을 측정하고 성능이 느린 컴포넌트를 감지할 수 있습니다. 이를 통해 메모이제이션 임계값을 조정하거나 업데이트를 지연 처리하는 최적화 전략을 적용할 수 있습니다.
예제 코드:
import { unstable_Profiler as Profiler } from 'react';
function DynamicTuner() {
const [threshold, setThreshold] = useState(10);
function onRender(id, phase, actualDuration) {
if (actualDuration > threshold) {
// 성능 데이터를 기반으로 동적 최적화 수행
setThreshold((t) => t + 5);
}
}
return (
<Profiler id="App" onRender={onRender}>
<MyAppComponents />
</Profiler>
);
}
14. Immer와 useReducer를 활용한 불변 상태 관리
useReducer를 사용하면 상태 업데이트를 예측 가능하게 만들 수 있습니다. Immer를 활용하면 불변성을 유지하면서 객체를 직접 수정하는 듯한 코드를 작성할 수 있습니다.
예제 코드:
import produce from 'immer';
function reducer(state, action) {
return produce(state, (draft) => {
if (action.type === 'ADD_ITEM') {
draft.items.push(action.payload);
}
});
}
function ComplexList() {
const [state, dispatch] = useReducer(reducer, { items: [] });
return (
<button onClick={() => dispatch({ type: 'ADD_ITEM', payload: 'X' })}>
Add Item
</button>
);
}
15. 윈도잉(Windowing)과 포털(Portals)을 활용한 대형 SVG/캔버스 요소의 효율적인 렌더링
대형 SVG나 캔버스를 다룰 때 렌더링 성능을 최적화하기 위해서는 필요한 부분만 렌더링하는 방식이 유효합니다. 리스트 가상화와 비슷한 개념으로, 윈도잉을 사용하여 필요한 부분만 렌더링하고 포털을 활용해 UI를 분리합니다.
예제 코드:
function Segment({ color, target }) {
return createPortal(
<svg width="200" height="100"><rect width="200" height="100" fill={color} /></svg>,
target
);
}
export default function App() {
const containerRef = useRef(null);
const [visible, setVisible] = useState([0, 1]);
useEffect(() => {
const onScroll = () => {
const start = Math.floor(containerRef.current.scrollTop / 100);
setVisible([start, start + 1]);
};
containerRef.current.addEventListener('scroll', onScroll);
return () => containerRef.current.removeEventListener('scroll', onScroll);
}, []);
return (
<div ref={containerRef} style={{ height: 200, overflowY: 'auto' }}>
{[...Array(10)].map((_, i) => <div key={i} id={'seg' + i} style={{ height: 100 }} />)}
{visible.map((i) => (
<Segment key={i} color={i % 2 ? 'blue' : 'green'} target={document.getElementById('seg' + i)} />
))}
</div>
);
}
마무리 생각
이러한 기술들은 모든 코드베이스에서 한 번에 사용할 필요는 없습니다. 다만, 애플리케이션이 점점 복잡해지고 기존의 단순한 패턴들이 한계를 보일 때 유용하게 활용할 수 있습니다. 필요할 때 적절한 방법을 선택하여 적용하면 성능과 유지보수성을 크게 향상시킬 수 있습니다.
소중한 공감 감사합니다
포스팅 주소를 복사했습니다
이 글이 도움이 되었다면 공감 부탁드립니다.