React
비유로 시작하기
레고 블록을 생각해보세요. 작은 블록(컴포넌트)들을 조립해서 더 큰 구조물(페이지)을 만듭니다. 각 블록은 자체적인 모양(UI)과 동작(로직)을 가집니다. 블록 내부의 상태(state)가 바뀌면 해당 블록만 다시 그려집니다. 부모 블록에서 자식 블록에게 색상을 전달할 수 있습니다(props).
React는 컴포넌트 기반의 UI 라이브러리입니다. Facebook(현 Meta)이 만들었으며, 선언형 프로그래밍으로 UI를 구성합니다.
컴포넌트 (Component)
UI를 독립적으로 재사용 가능한 조각으로 나눈 것입니다.
// 함수형 컴포넌트 (현재 표준)
function UserCard({ user, onFollow }) { // props를 통해 데이터 수신
return (
<div className="user-card">
<img src={user.avatar} alt={user.name} />
<h3>{user.name}</h3>
<p>{user.bio}</p>
<button onClick={() => onFollow(user.id)}>팔로우</button>
</div>
);
}
// 사용
function App() {
const handleFollow = (userId) => {
console.log(`${userId} 팔로우`);
};
return (
<div>
<UserCard
user=
onFollow={handleFollow}
/>
</div>
);
}
State와 Props
// Props: 부모 → 자식 데이터 전달 (읽기 전용)
// State: 컴포넌트 내부 상태 (변경 가능)
function Counter({ initialCount = 0, step = 1 }) { // props
const [count, setCount] = useState(initialCount); // state
const increment = () => setCount(prev => prev + step);
const decrement = () => setCount(prev => prev - step);
const reset = () => setCount(initialCount);
return (
<div>
<button onClick={decrement}>-</button>
<span>{count}</span>
<button onClick={increment}>+</button>
<button onClick={reset}>Reset</button>
</div>
);
}
State 업데이트 원칙:
// 나쁜 예: 직접 변경
state.count = 1; // React가 감지 못함
// 좋은 예: setter 함수 사용
setCount(1);
// 이전 값 기반 업데이트는 함수형 업데이트 사용
setCount(prev => prev + 1); // 안전
setCount(count + 1); // 클로저 함정 위험
useEffect
사이드 이펙트(API 호출, 구독, 타이머)를 처리합니다.
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
// cleanup function을 위한 flag
let cancelled = false;
const fetchUser = async () => {
setLoading(true);
setError(null);
try {
const data = await api.getUser(userId);
if (!cancelled) { // 컴포넌트 언마운트 후 setState 방지
setUser(data);
}
} catch (err) {
if (!cancelled) {
setError(err.message);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
fetchUser();
// cleanup: 컴포넌트 언마운트 또는 userId 변경 시 실행
return () => {
cancelled = true;
};
}, [userId]); // dependency array: userId가 바뀔 때만 재실행
if (loading) return <Spinner />;
if (error) return <ErrorMessage message={error} />;
return <div>{user?.name}</div>;
}
Dependency Array 규칙
useEffect(() => { ... }); // 매 렌더마다 실행 (거의 사용 X)
useEffect(() => { ... }, []); // 마운트 1번만 실행
useEffect(() => { ... }, [id]); // id 변경 시마다 실행
렌더링 최적화
React는 state/props가 변경되면 컴포넌트를 재렌더링합니다. 불필요한 재렌더링을 방지하는 방법:
React.memo
props가 변경되지 않으면 재렌더링을 건너뜁니다.
// props가 같으면 재렌더링 안 함
const UserCard = React.memo(function UserCard({ user, onFollow }) {
console.log('UserCard 렌더링');
return (
<div>
<h3>{user.name}</h3>
<button onClick={() => onFollow(user.id)}>팔로우</button>
</div>
);
});
// 주의: 부모가 렌더링되면 함수가 새로 생성됨 → onFollow가 매번 다른 참조
function Parent() {
// 이렇게 하면 UserCard는 항상 재렌더링됨 (onFollow가 매번 새 함수)
const handleFollow = (id) => api.follow(id);
return <UserCard user={user} onFollow={handleFollow} />;
}
useCallback
함수를 메모이제이션합니다.
function Parent() {
const [users, setUsers] = useState([]);
// dependency가 변경되지 않으면 같은 함수 참조 반환
const handleFollow = useCallback((userId) => {
api.follow(userId);
setUsers(prev => prev.map(u =>
u.id === userId ? { ...u, following: true } : u
));
}, []); // 빈 배열: 마운트 시 한 번만 생성
return (
<div>
{users.map(user => (
<UserCard key={user.id} user={user} onFollow={handleFollow} />
))}
</div>
);
}
useMemo
값을 메모이제이션합니다.
function ProductList({ products, searchQuery, category }) {
// searchQuery나 category가 변경될 때만 재계산
const filteredProducts = useMemo(() => {
console.log('필터링 실행');
return products
.filter(p => p.category === category)
.filter(p => p.name.includes(searchQuery))
.sort((a, b) => b.rating - a.rating);
}, [products, searchQuery, category]);
// 무거운 통계 계산
const stats = useMemo(() => ({
total: filteredProducts.length,
avgPrice: filteredProducts.reduce((sum, p) => sum + p.price, 0)
/ filteredProducts.length,
}), [filteredProducts]);
return (
<div>
<p>총 {stats.total}개, 평균 {stats.avgPrice.toFixed(0)}원</p>
{filteredProducts.map(p => <ProductCard key={p.id} product={p} />)}
</div>
);
}
Custom Hook
상태 로직을 재사용하는 패턴입니다.
// API 호출 훅
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
fetch(url)
.then(res => res.json())
.then(data => { if (!cancelled) setData(data); })
.catch(err => { if (!cancelled) setError(err); })
.finally(() => { if (!cancelled) setLoading(false); });
return () => { cancelled = true; };
}, [url]);
return { data, loading, error };
}
// 로컬 스토리지 훅
function useLocalStorage(key, initialValue) {
const [value, setValue] = useState(() => {
try {
return JSON.parse(localStorage.getItem(key)) ?? initialValue;
} catch {
return initialValue;
}
});
const setStoredValue = useCallback((newValue) => {
setValue(newValue);
localStorage.setItem(key, JSON.stringify(newValue));
}, [key]);
return [value, setStoredValue];
}
// 디바운스 훅
function useDebounce(value, delay = 300) {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debouncedValue;
}
// 사용 예
function SearchPage() {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 300); // 300ms 지연
const { data, loading } = useFetch(`/api/search?q=${debouncedQuery}`);
const [recentSearches, setRecentSearches] = useLocalStorage('searches', []);
return (
<div>
<input value={query} onChange={e => setQuery(e.target.value)} />
{loading ? <Spinner /> : <SearchResults results={data} />}
</div>
);
}
상태 관리
1. Context API (소규모)
const ThemeContext = createContext('light');
const UserContext = createContext(null);
function App() {
const [theme, setTheme] = useState('light');
const [user, setUser] = useState(null);
return (
<ThemeContext.Provider value=>
<UserContext.Provider value=>
<Router />
</UserContext.Provider>
</ThemeContext.Provider>
);
}
// 소비
function Header() {
const { theme, setTheme } = useContext(ThemeContext);
const { user } = useContext(UserContext);
return <header className={theme}>{user?.name}</header>;
}
2. Redux Toolkit (대규모)
// store/orderSlice.js
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
export const fetchOrders = createAsyncThunk('orders/fetch', async (userId) => {
const response = await api.getOrders(userId);
return response.data;
});
const orderSlice = createSlice({
name: 'orders',
initialState: { items: [], loading: false, error: null },
reducers: {
cancelOrder: (state, action) => {
const order = state.items.find(o => o.id === action.payload);
if (order) order.status = 'CANCELLED';
},
},
extraReducers: (builder) => {
builder
.addCase(fetchOrders.pending, (state) => { state.loading = true; })
.addCase(fetchOrders.fulfilled, (state, action) => {
state.loading = false;
state.items = action.payload;
})
.addCase(fetchOrders.rejected, (state, action) => {
state.loading = false;
state.error = action.error.message;
});
},
});
export const { cancelOrder } = orderSlice.actions;
export default orderSlice.reducer;
// 컴포넌트에서 사용
function OrderList() {
const dispatch = useDispatch();
const { items, loading } = useSelector(state => state.orders);
useEffect(() => {
dispatch(fetchOrders(userId));
}, [dispatch]);
return loading ? <Spinner /> : items.map(order => <OrderItem key={order.id} order={order} />);
}
3. Zustand (경량)
import { create } from 'zustand';
const useCartStore = create((set, get) => ({
items: [],
addItem: (product) => set(state => ({
items: [...state.items, { ...product, quantity: 1 }]
})),
removeItem: (productId) => set(state => ({
items: state.items.filter(item => item.id !== productId)
})),
totalAmount: () => get().items.reduce((sum, item) =>
sum + item.price * item.quantity, 0),
}));
// 컴포넌트에서 사용 (Provider 불필요)
function CartButton() {
const { items, addItem } = useCartStore();
return <button onClick={() => addItem(product)}>담기 ({items.length})</button>;
}
4. React Query (서버 상태 관리)
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
function ProductDetail({ productId }) {
const queryClient = useQueryClient();
// 서버 데이터 조회 (자동 캐싱, 재요청)
const { data: product, isLoading } = useQuery({
queryKey: ['product', productId],
queryFn: () => api.getProduct(productId),
staleTime: 5 * 60 * 1000, // 5분간 신선
});
// 서버 데이터 변경
const mutation = useMutation({
mutationFn: (updatedProduct) => api.updateProduct(productId, updatedProduct),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['product', productId] });
},
});
if (isLoading) return <Spinner />;
return <div>{product.name}</div>;
}
극한 시나리오
시나리오: 100개 컴포넌트 동시 업데이트
// 나쁜 예: 부모 state 변경 → 100개 자식 모두 재렌더링
function Parent() {
const [tick, setTick] = useState(0);
useEffect(() => {
const id = setInterval(() => setTick(t => t + 1), 1000);
return () => clearInterval(id);
}, []);
return (
<div>
{items.map(item => (
<ExpensiveChild key={item.id} item={item} tick={tick} />
))}
</div>
);
}
// 좋은 예: tick을 사용하는 컴포넌트만 분리
function TickDisplay() {
const [tick, setTick] = useState(0);
useEffect(() => {
const id = setInterval(() => setTick(t => t + 1), 1000);
return () => clearInterval(id);
}, []);
return <span>{tick}</span>;
}
function Parent() {
return (
<div>
<TickDisplay /> {/* tick만 재렌더링 */}
{items.map(item => (
<ExpensiveChild key={item.id} item={item} /> {/* 재렌더링 없음 */}
))}
</div>
);
}