이 글은 React를 사용하면서 아직 익숙하지 않지만
“언젠가는 반드시 필요해지는 패턴들”을 정리한 메모이다.
callback ref, lazy, portal, useLayoutEffect처럼
일상적인 CRUD 화면에서는 잘 드러나지 않지만
타이밍·성능·레이아웃 문제가 발생하는 순간
선택지를 넓혀주는 도구들을 중심으로 다룬다.
ref 속성에 useRef 대신 함수를 전달하는 패턴
DOM이 생성되는 시점에 즉시 실행가능 하다
// useRef
const inputRef = useRef(null);
// callback ref
const inputCallbackRef = (node) => {
if (node) {
node.focus(); // DOM 붙는 즉시 실행 보장
}
};
조건부 렌더링처럼 DOM이 동적으로 생기는 상황에서 정확한 타이밍 포착이 필요할 때
// useRef + useEffect
function Home() {
const inputRef = useRef(null);
useEffect(() => {
inputRef.current.focus(); // 타이밍 이슈 가능
}, []);
return <input ref={inputRef} />;
}
// callback ref
function Home() {
const inputCallbackRef = (node) => {
if (node) {
node.focus(); // DOM 붙는 즉시 실행 보장
}
};
return <input ref={inputCallbackRef} />;
}
두 방식 모두 JSX가 평가되는 시점에 요소를 DOM에 연결한다는 점은 같다.
하지만 focus 되는 타이밍이 다르다.
따라서 조건부 렌더링처럼 DOM이 동적으로 생기는 상황에서는 callback ref로 보다 정확하게 타이밍을 포착할 수 있다
1. JSX 평가
2. DOM 연결
3. paint
4. useEffect 실행 → focus()
1. JSX 평가
2. DOM 연결 → 즉시 callback 실행 → focus()
3. paint
- 번들링이 사용된 경우, 초기 화면에 필요하지 않은 JS 파일의 다운로드를 미루기 위한 코드 스플리팅 방식이다.
- 각 페이지에 필요한 단위로 JS 파일을 분리 후, 불필요한 JS의 다운로드는 뒤로 미룸으로 초기로딩 시간을 줄일 수 있다.
// 일반 import
import HeavyComponent from './HeavyComponent'; // 초기 번들에 포함
// lazy import
const HeavyComponent = lazy(() => import('./HeavyComponent')); // 별도 chunk로 분리
초기 화면에 보이지 않는 무거운 컴포넌트의 로딩을 지연시켜 초기 로딩 시간을 단축할 때
const HeavyComponent = lazy(() => import('./HeavyComponent'));
function Dashboard() {
const [showChart, setShowChart] = useState(false);
return (
<>
<Button onPress={() => setShowChart(true)} />
{showChart && ( // false면 로드 안 함
<Suspense fallback={<Loading />}>
<HeavyComponent />
</Suspense>
)}
</>
);
}
두 방식 모두 컴포넌트를 불러온다는 점은 같다.
하지만 다운로드 타이밍과 번들 구성이 다르다.
따라서 초기 화면에 불필요한 컴포넌트는 lazy로 로딩을 지연시켜 초기 번들 크기를 줄일 수 있다
1. 번들러가 빌드 시 모든 파일을 main.bundle.js로 합침
2. 앱 시작
3. main.bundle.js 다운로드 (HeavyChart 포함)
4. 앱 렌더링 시작
5. showChart === true 시 즉시 표시
1. 번들러가 빌드 시 HeavyChart를 별도 chunk로 분리
→ main.bundle.js, HeavyChart.chunk.js
2. 앱 시작
3. main.bundle.js 다운로드 (HeavyChart 제외)
4. 앱 렌더링 시작
5. showChart === true 시
→ HeavyChart.chunk.js 다운로드 시작
→ Suspense fallback 표시
→ 다운로드 완료 후 컴포넌트 표시
lazy는 "다운로드 타이밍"을 제어한다는 점에서 성능 최적화의 다른 측면을 다룬다고 볼 수 있다.
- useState의 초기값 계산을 컴포넌트 마운트 시 한 번만 실행하도록 지연시키는 최적화 기법
- 초기값 계산 비용이 클 때, 함수를 전달하여 불필요한 재계산을 방지할 수 있음
// 일반 초기화 - 매 렌더링마다 계산
const [state, setState] = useState(expensiveCalculation());
// 게으른 초기화 - 마운트 시 한 번만 계산
const [state, setState] = useState(() => expensiveCalculation());
useState의 초기값 계산 비용이 클 때, 불필요한 재계산을 방지하여 성능을 최적화할 때
// 일반 초기화 - 매 렌더링마다 localStorage 접근
function TodoList() {
const [todos, setTodos] = useState(
JSON.parse(localStorage.getItem('todos')) || []
); // 리렌더링마다 실행됨
return <div>{todos.length}개의 할일</div>;
}
// 게으른 초기화 - 마운트 시 한 번만 localStorage 접근
function TodoList() {
const [todos, setTodos] = useState(() => {
const saved = localStorage.getItem('todos');
return saved ? JSON.parse(saved) : [];
}); // 마운트 시 한 번만 실행
return <div>{todos.length}개의 할일</div>;
}
두 방식 모두 state를 초기화한다는 점은 같지만, 초기값 계산 실행 빈도가 다르다.
따라서 초기값 계산 비용이 큰 경우 게으른 초기화로 불필요한 계산을 방지할 수 있다
1. 컴포넌트 렌더링
2. expensiveCalculation() 실행 → 결과 계산
3. useState가 이미 초기화되어 있으면 결과 버림
4. 기존 state 값 사용
(부모가 리렌더링할 때마다 2-4 반복)
1. 컴포넌트 첫 렌더링 (마운트)
2. useState가 함수 실행 → expensiveCalculation() 실행
3. 결과를 state 초기값으로 설정
4. 이후 렌더링에서는 함수 실행 안 함
(부모가 리렌더링해도 계산 안 함)
컴포넌트를 부모 DOM 바깥에 렌더링하는 기능.
JSX는 부모 컴포넌트 안에 작성하지만, 실제 DOM은 지정한 다른 위치에 생성됨
모달, 다이얼로그: 부모의 overflow: hidden이나 z-index에 영향받지 않아야 할 때
툴팁, 드롭다운: 부모 영역 밖으로 튀어나와야 할 때
토스트 알림: 화면 최상단에 고정되어야 할 때
import { createPortal } from 'react-dom';
function Modal({ children, isOpen }) {
if (!isOpen) return null;
return createPortal(
<div className="modal">{children}</div>,
document.getElementById('modal-root')
);
}
function App() {
const [isOpen, setIsOpen] = useState(false);
return (
<div style={{ overflow: 'hidden' }}>
<button onClick={() => setIsOpen(true)}>모달 열기</button>
<Modal isOpen={isOpen}>
모달 내용 {/* overflow: hidden 영향 안 받음 */}
</Modal>
</div>
);
}
불필요한 리렌더링을 방지하기 위해 함수, 값, 컴포넌트의 참조를 유지하는 최적화 기법
useCallback(함수), useMemo(값), React.memo(컴포넌트) 세 가지를 함께 사용해야 효과가 있다
function Parent() {
const [count, setCount] = useState(0);
// useCallback: 함수 참조 유지
const handleClick = useCallback(() => {
console.log('clicked');
}, []);
// useMemo: 객체 참조 유지
const style = useMemo(() => ({
color: 'red'
}), []);
return (
<>
<button onClick={() => setCount(count + 1)}>+</button>
<Child onClick={handleClick} style={style} />
</>
);
}
// React.memo: props 변경 시에만 리렌더링
const Child = React.memo(({ onClick, style }) => {
console.log('Child 렌더링'); // count 변경해도 출력 안 됨
return <button onClick={onClick} style={style}>클릭</button>;
});
1. state 변경*
2. props 변경
3. 부모 컴포넌트가 렌더링될 때
4. forceUpdate() 호출할 때
5. useSyncExternalStore의 외부 객체 값이 변경될 때(React 18+)
paint와 useEffect 이전에 실행되는 Effect이다.
useLayoutEffect(() => {
const rect = ref.current.getBoundingClientRect();
setPosition({ x: rect.left, y: rect.top }); // 깜빡임 없음
}, []);
[실행 순서]
1. JSX 평가
2. DOM 연결
3. 🔵 useLayoutEffect 실행 (paint 전, 동기)
4. paint
5. 🟡 useEffect 실행 (paint 후, 비동기)
// useEffect: 깜빡임 발생 가능
function Tooltip() {
const [position, setPosition] = useState({ x: 0, y: 0 });
const ref = useRef(null);
useEffect(() => {
const rect = ref.current.getBoundingClientRect();
setPosition({ x: rect.left, y: rect.top - 30 });
}, []); // paint 후 실행 → 위치 변경이 보임
return <div ref={ref} style={{ left: position.x, top: position.y }}>툴팁</div>;
}
// useLayoutEffect: 깜빡임 없음
function Tooltip() {
const [position, setPosition] = useState({ x: 0, y: 0 });
const ref = useRef(null);
useLayoutEffect(() => {
const rect = ref.current.getBoundingClientRect();
setPosition({ x: rect.left, y: rect.top - 30 });
}, []); // paint 전 실행 → 위치 변경이 안 보임
return <div ref={ref} style={{ left: position.x, top: position.y }}>툴팁</div>;
}
두 방식 모두 부수 효과를 처리한다는 점은 같다.
하지만 실행 타이밍과 동기/비동기 여부가 다르다.
따라서 DOM 측정 후 즉시 반영이 필요한 경우 useLayoutEffect로 깜빡임을 방지할 수 있다
1. JSX 평가
2. DOM 연결
3. paint (초기 위치로 화면에 표시)
4. useEffect 실행 → DOM 측정 → setPosition
5. 리렌더링
6. paint (새 위치로 화면에 표시) → 깜빡임 발생
1. JSX 평가
2. DOM 연결
3. useLayoutEffect 실행 → DOM 측정 → setPosition (동기)
4. 리렌더링 (paint 전에 완료)
5. paint (새 위치로 화면에 표시) → 깜빡임 없음
useLayoutEffect는 동기적이라 무거운 로직 넣으면 렌더링 블로킹됨
대부분의 경우 useEffect로 충분
DOM 측정 → 즉시 반영이 필요할 때만 사용