비유로 시작하기

레고 블록을 생각해보세요. 작은 블록(컴포넌트)들을 조립해서 더 큰 구조물(페이지)을 만듭니다. 각 블록은 자체적인 모양(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>
    );
}