1. Prop Drilling
1.1. 문제 정의
Prop Drilling은 복잡한 리액트 앱에서 상태(state)를 여러 컴포넌트가 공유해야 할 때, 상위 컴포넌트에서 관리하고 props를 통해 전달하는 과정에서 중간에 있는 많은 컴포넌트들이 단순히 데이터를 전달하는 역할만 하게 되는 문제를 말한다.
Prop Drilling의 문제
- 컴포넌트 재사용이 어려워짐
- 불필요한 상용구 코드(Boilerplate)가 증가함
- 코드 유지보수가 복잡해짐
- 컴포넌트 간 의존성이 높아짐
- 불필요한 리렌더링이 발생할 수 있음
1.2. 해결책 1: 컴포넌트 합성(Component Composition)
컴포넌트 합성은 중첩된 컴포넌트 구조에서 중간 컴포넌트들을 거치지 않고 직접 데이터나 함수를 전달할 수 있게 해주는 패턴이다.
주요 특징:
- children prop을 활용하여 컴포넌트 간 콘텐츠 전달
- 특정 prop을 위한 슬롯(slot) 패턴 활용 가능
- JSX 내에서 직접 컴포넌트 구성 가능
장점:
- 중간 컴포넌트를 통해 props를 전달할 필요가 없어짐
- 상위 컴포넌트에서 직접 하위 컴포넌트에 필요한 함수와 데이터 전달 가능
- 컴포넌트의 책임이 더 명확해짐 (구조와 내용의 분리)
- 컴포넌트 재사용성 증가
한계점:
- 컴포넌트 합성만으로는 모든 상황에서 프로퍼티 내리꽂기 문제를 해결하기 어려움
- 이 방법을 모든 컴포넌트에 적용하면 상위 컴포넌트(App 등)가 비대해질 수 있음
- 복잡한 상태 관리 요구사항에는 추가적인 해결책이 필요함
1.3. 해결책 2: Context API
Context API는 리액트에서 제공하는 기능으로, 컴포넌트 트리를 통해 데이터를 직접 전달하지 않고도 여러 컴포넌트 간에 데이터를 공유할 수 있게 해준다.
주요 특징:
- 전역 상태 관리를 위한 내장 도구
- 컴포넌트 트리 어느 위치에서든 데이터에 접근 가능
- Provider와 Consumer 패턴을 통한 데이터 공유
- useContext 훅을 통한 간편한 사용
장점:
- prop drilling 문제를 효과적으로 해결
- 컴포넌트 간 결합도 감소
- 코드 가독성 향상
- 불필요한 리렌더링 감소 가능
고려사항:
- Context는 빈번한 업데이트에는 최적화되어 있지 않을 수 있음
- 관련 있는 상태끼리 별도의 Context로 분리하면 불필요한 리렌더링을 줄일 수 있음
- 큰 규모의 앱에서는 Redux 같은 상태 관리 라이브러리가 더 적합할 수 있음
1.3.1. 기본 사용 방법
1) Context 객체 생성
// ThemeContext.js
import { createContext } from 'react';
// 기본값 설정 (선택사항)
const ThemeContext = createContext('light');
export default ThemeContext;
2) Provider를 통한 값 제공
// App.js
import { useState } from 'react';
import ThemeContext from './ThemeContext';
function App() {
const [theme, setTheme] = useState('light');
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<MainContent />
</ThemeContext.Provider>
);
}
3) Consumer를 통한 값 사용
// 자식 컴포넌트에서 useContext 사용
import { useContext } from 'react';
import ThemeContext from './ThemeContext';
function Button() {
const { theme, setTheme } = useContext(ThemeContext);
return (
<button
onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}
style={{ background: theme === 'light' ? '#fff' : '#333' }}
>
Toggle Theme
</button>
);
}
value 속성:
- Provider는 Context 값을 컴포넌트 트리 내에서 동적으로 변경할 수 있게 해준다.
- createContext의 기본값은 정적이므로, 동적인 값이나, 상태, 상태 변경 함수를 포함해야 할 때는 Provider의 value 속성을 사용해야 한다.
- Provider를 사용할 때 value 속성을 생략하면 Consumer는 항상 createContext의 기본값만 받게 되어 상태 관리가 불가능해진다.
1.3.3. useReducer
Context API를 사용하다보면 많은 상태를 한 컨텍스트에서 관리해야할 상황이 생긴다.
복잡한 상태 로직 관리가 필요할 때는 useState 대신 useReducer를 Context와 함께 사용하는 패턴이 효과적이다.
기본 구조:
const [state, dispatch] = useReducer(reducer, initialState, init);
state:
- 현재 상태값
- 리듀서가 관리하는 데이터를 담고 있음
- 컴포넌트에서 이 값을 읽어 UI를 렌더링
dispatch:
- 액션을 발생시키는 함수
- 액션 객체를 받아 리듀서 함수를 실행시킴
- 항상 안정적인 참조를 유지함 (리렌더링 시에도 변경되지 않음)
reducer:
- 이전 상태와 액션을 받아 새 상태를 반환하는 함수
- 형태: (state, action) => newState
- 순수 함수여야 함 (부수 효과 없이 같은 입력에 항상 같은 출력)
initialState:
- 상태의 초기값
- 첫 렌더링 시 사용되는 초기 상태
init:
- (선택사항) 초기 상태를 계산하는 함수
- initialState를 인자로 받아 실제 초기 상태를 계산
- 복잡한 초기화 로직이 있을 때 유용
ex)
// CounterContext.jsx
import { createContext, useReducer, useContext } from 'react';
import { counterReducer, initialState } from './counterReducer';
// Context 생성
const CounterContext = createContext(null);
// Provider 컴포넌트
function CounterProvider({ children }) {
const [state, dispatch] = useReducer(counterReducer, initialState);
return (
<CounterContext.Provider value={{ state, dispatch }}>
{children}
</CounterContext.Provider>
);
}
// 커스텀 훅으로 사용 단순화
function useCounter() {
const context = useContext(CounterContext);
if (context === undefined) {
throw new Error('useCounter must be used within a CounterProvider');
}
return context;
}
export { CounterProvider, useCounter };
// counterReducer.jsx
// 초기 상태
export const initialState = {
count: 0,
isLoading: false,
error: null
};
// 리듀서 함수
export function counterReducer(state, action) {
switch (action.type) {
case 'INCREMENT':
return { ...state, count: state.count + 1 };
case 'DECREMENT':
return { ...state, count: state.count - 1 };
case 'LOADING':
return { ...state, isLoading: true };
case 'SUCCESS':
return { ...state, isLoading: false, count: action.payload };
case 'ERROR':
return { ...state, isLoading: false, error: action.payload };
default:
throw new Error(`Unsupported action type: ${action.type}`);
}
}
// CounterDisplay.jsx
import React from 'react';
import { useCounter } from './counterContext';
function CounterDisplay() {
const { state, dispatch } = useCounter();
// 비동기 데이터 로딩 예시
const fetchData = async () => {
dispatch({ type: 'LOADING' });
try {
const data = await fetch('/api/counter');
const json = await data.json();
dispatch({ type: 'SUCCESS', payload: json.count });
} catch (error) {
dispatch({ type: 'ERROR', payload: error.message });
}
};
return (
<div>
<p>Count: {state.count}</p>
{state.isLoading && <p>Loading...</p>}
{state.error && <p>Error: {state.error}</p>}
<button onClick={() => dispatch({ type: 'INCREMENT' })}>+</button>
<button onClick={() => dispatch({ type: 'DECREMENT' })}>-</button>
<button onClick={fetchData}>Load Data</button>
</div>
);
}
export default CounterDisplay;
2. Side Effects와 useEffect
2.1. Side Effects란?
Side Effects는 컴포넌트의 주요 기능인 UI 렌더링(JSX 생성)과 직접 관련이 없는 추가 작업들을 의미한다.
브라우저 API 접근 및 조작, 데이터 저장 및 불러오기, 네트워크 요청 등의 작업이 Side Effects이다.
2.2. useEffect
useEffect 훅은 함수형 컴포넌트에서 Side Effects를 처리하기 위한 도구이다.
그러나 모든 Side Effects가 useEffect를 필요로 하는 것은 아니다.
2.3. useEffect가 필요한 상황
1) 비동기 작업이 포함된 Side Effects
- 결과를 기다려야 하는 작업(콜백 함수가 나중에 실행되는 경우)
- 예: 위치 정보 가져오기, 서버에서 데이터 fetch하기
ex) 위치 정보 가져오기
useEffect(() => {
// 비동기 작업: 브라우저 위치 API
navigator.geolocation.getCurrentPosition(
(position) => {
// 위치 정보를 받아온 후 실행되는 콜백
setUserLocation({
lat: position.coords.latitude,
lng: position.coords.longitude
});
},
(error) => {
console.error('위치를 가져오는데 실패했습니다:', error);
}
);
}, []); // 의존성 배열이 비어있으므로 컴포넌트 마운트 시 한 번만 실행
2) 컴포넌트 렌더링 후 자동으로 실행되어야 하는 코드
- 컴포넌트가 처음 마운트될 때 실행해야 하는 초기화 작업
- 특정 상태나 props가 변경될 때마다 실행되어야 하는 작업
- DOM 요소에 직접 접근
2.4. useEffect가 필요하지 않은 상황
1) 이벤트 핸들러 내부의 Side Effects
- 사용자 상호작용(클릭, 입력 등)에 대응하여 실행되는 코드
- 이벤트 핸들러는 특정 상호작용 시에만 실행되므로 무한 루프 걱정 없음
2) 동기적으로 즉시 완료되는 작업
- localStorage에서 데이터 저장/불러오기
- 동기적 계산
- 결과를 기다릴 필요가 없는 작업
3) 초기 상태 설정을 위한 데이터 로딩
- 컴포넌트 함수 바깥에서 처리 가능한 초기 데이터 로딩
- useState의 초기값으로 사용될 데이터
2.6. 의존성 배열
useEffect의 두 번째 인자로 전달되는 의존성 배열은 해당 effect가 언제 다시 실행되어야 하는지를 React에게 알려준다.
useEffect(() => {
// 실행할 부수 효과 코드
}, [dependency]); // 의존성 배열
- 빈 배열 []: 컴포넌트가 마운트될 때 한 번만 실행
- 의존성 포함 [dependency]: 의존성이 변경될 때마다 실행
- 의존성 생략: 모든 렌더링 후 실행 (무한 루프 가능성이 생김)
2.6.1. 함수를 의존성으로 사용할 때의 문제
컴포넌트 내에서 정의된 함수를 useEffect의 의존성으로 사용할 때 문제가 발생할 수 있다.
ex)
function MyComponent() {
const [count, setCount] = useState(0);
// 컴포넌트가 리렌더링될 때마다 새로운 함수 참조가 생성됨
const fetchData = () => {
console.log(`Fetching data with count: ${count}`);
// 데이터 패칭 로직...
};
useEffect(() => {
fetchData();
// 이 effect는 매 렌더링마다 실행됨! (fetchData가 매번 새로운 참조를 가지므로)
}, [fetchData]);
return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
}
문제점:
- 컴포넌트가 리렌더링될 때마다 새로운 함수 참조가 생성됨
- 의존성 배열에 포함된 함수의 참조가 변경되어 useEffect가 불필요하게 다시 실행됨
- 이로 인해 성능 문제나 무한 루프가 발생할 수 있음
2.6.2. useCallback을 사용한 최적화
이 문제를 해결하기 위해 React는 useCallback 훅을 제공한다.
useCallback은 의존성이 변경될 때만 새로운 함수 참조를 생성한다.
ex)
function MyComponent() {
const [count, setCount] = useState(0);
// useCallback을 사용하여 메모이제이션된 함수 생성
const fetchData = useCallback(() => {
console.log(`Fetching data with count: ${count}`);
// 데이터 패칭 로직...
}, [count]); // count가 변경될 때만 함수 재생성
useEffect(() => {
fetchData();
// count가 변경될 때만 실행됨
}, [fetchData]);
return <button onClick={() => setCount(count + 1)}>Count: {count}</button>;
}
장점:
- 의존성이 변경될 때만 함수가 새로 생성됨
- 불필요한 effect 재실행 방지
- 성능 최적화 및 예측 가능한 동작
2.6.3. useCallback 사용 시 고려사항
1) 실제로 필요한 경우에만 사용: 간단한 함수나 자주 변경되는 의존성을 가진 함수는 useCallback을 적용하는 비용이 더 클 수 있다.
2) 모든 의존성 포함: useCallback의 의존성 배열에도 함수 내에서 사용하는 모든 외부 값을 포함해야 한다.
3) 반환값 메모이제이션은 불가: useCallback은 함수 참조만 메모이제이션하며, 함수의 반환 값을 메모이제이션하지는 않는다 (반환값 메모이제이션은 useMemo 사용).
2.7. useEffect의 clean-up 함수
useEffect 내에서 타이머, 이벤트 리스너, 구독 등을 설정하면 컴포넌트가 언마운트되거나 effect가 다시 실행되기 전에 이러한 자원을 정리해야 한다.
이를 위해 useEffect는 정리(clean-up) 함수를 반환할 수 있다.
useEffect(() => {
// 설정 코드
return () => {
// 정리 코드
};
}, [dependencies]);
2.7.1. 정리 함수가 호출되는 시점
- 컴포넌트가 언마운트될 때
- 의존성이 변경되어 effect가 다시 실행되기 전에
- 즉, 이전 effect의 설정을 정리한 후 새 effect를 설정
2.7.2. 모달 컴포넌트에서 타이머 정리하기
모달 컴포넌트에서 setTimeout이나 setInterval을 사용할 때, 모달이 닫힌 후에도 타이머가 계속 실행되면 메모리 누수나 예상치 못한 동작이 발생할 수 있다.
function Modal({ onClose }) {
useEffect(() => {
// 5초 후에 자동으로 모달 닫기
const timer = setTimeout(() => {
onClose();
}, 5000);
// 정리 함수: 컴포넌트 언마운트 시 타이머 취소
return () => {
clearTimeout(timer);
};
}, [onClose]);
return <div className="modal">...</div>;
}
'학습 > React' 카테고리의 다른 글
use 훅 (0) | 2025.04.02 |
---|---|
useImperativeHandle (0) | 2025.03.23 |
useRef (0) | 2025.03.23 |
React 컴포넌트에서 변수와 함수의 스코프 차이 (0) | 2025.03.07 |
리액트 컴포넌트의 Props 전달 방식 - Forwarded Props 패턴 (Proxy Props) (0) | 2025.02.27 |