본문 바로가기

고급 Hook 패턴

4. 고급 Hook 패턴

React Hooks를 활용한 고급 패턴들을 학습해보겠습니다. 이 패턴들을 통해 더 재사용 가능하고 유지보수하기 쉬운 코드를 작성할 수 있습니다.

커스텀 Hook 고급 패턴

1. useToggle Hook

import { useState, useCallback } from 'react';

function useToggle(initialValue = false) {
  const [value, setValue] = useState(initialValue);
  
  const toggle = useCallback(() => setValue(prev => !prev), []);
  const setTrue = useCallback(() => setValue(true), []);
  const setFalse = useCallback(() => setValue(false), []);
  
  return [value, { toggle, setTrue, setFalse, setValue }];
}

// 사용 예제
function ToggleExample() {
  const [isVisible, { toggle, setTrue, setFalse }] = useToggle(false);
  const [isEnabled, { toggle: toggleEnabled }] = useToggle(true);
  
  return (
    <div>
      <h2>Toggle Hook 예제</h2>
      
      <div>
        <button onClick={toggle}>
          {isVisible ? '숨기기' : '보이기'}
        </button>
        <button onClick={setTrue}>항상 보이기</button>
        <button onClick={setFalse}>항상 숨기기</button>
        
        {isVisible && <p>토글로 제어되는 내용입니다.</p>}
      </div>
      
      <div>
        <button onClick={toggleEnabled}>
          기능 {isEnabled ? '비활성화' : '활성화'}
        </button>
        <input 
          disabled={!isEnabled} 
          placeholder="기능 상태에 따라 활성화됩니다"
        />
      </div>
    </div>
  );
}

2. useLocalStorage Hook

import { useState, useEffect } from 'react';

function useLocalStorage(key, initialValue) {
  // localStorage에서 초기값 읽기
  const [storedValue, setStoredValue] = useState(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(`localStorage에서 ${key}를 읽는 중 오류 발생:`, error);
      return initialValue;
    }
  });
  
  // 값 설정 함수
  const setValue = (value) => {
    try {
      // 함수일 경우 현재 값을 인자로 전달
      const valueToStore = value instanceof Function ? value(storedValue) : value;
      setStoredValue(valueToStore);
      window.localStorage.setItem(key, JSON.stringify(valueToStore));
    } catch (error) {
      console.error(`localStorage에 ${key}를 저장하는 중 오류 발생:`, error);
    }
  };
  
  return [storedValue, setValue];
}

// 사용 예제
function LocalStorageExample() {
  const [name, setName] = useLocalStorage('userName', '');
  const [theme, setTheme] = useLocalStorage('theme', 'light');
  const [settings, setSettings] = useLocalStorage('userSettings', {
    notifications: true,
    language: 'ko'
  });
  
  return (
    <div>
      <h2>LocalStorage Hook 예제</h2>
      
      <div>
        <input
          value={name}
          onChange={(e) => setName(e.target.value)}
          placeholder="이름을 입력하세요"
        />
        {name && <p>안녕하세요, {name}님!</p>}
      </div>
      
      <div>
        <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
          테마 변경 (현재: {theme})
        </button>
      </div>
      
      <div>
        <label>
          <input
            type="checkbox"
            checked={settings.notifications}
            onChange={(e) => setSettings(prev => ({
              ...prev,
              notifications: e.target.checked
            }))}
          />
          알림 받기
        </label>
        
        <select
          value={settings.language}
          onChange={(e) => setSettings(prev => ({
            ...prev,
            language: e.target.value
          }))}
        >
          <option value="ko">한국어</option>
          <option value="en">English</option>
        </select>
      </div>
    </div>
  );
}

3. useFetch Hook

import { useState, useEffect } from 'react';

function useFetch(url, options = {}) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    const fetchData = async () => {
      try {
        setLoading(true);
        setError(null);
        
        // 실제 API 호출 대신 시뮬레이션
        await new Promise(resolve => setTimeout(resolve, 1000));
        
        // 가상의 데이터 생성
        const mockData = {
          users: [
            { id: 1, name: '김개발', email: 'kim@example.com' },
            { id: 2, name: '이디자인', email: 'lee@example.com' },
            { id: 3, name: '박기획', email: 'park@example.com' }
          ],
          posts: [
            { id: 1, title: 'React Hook 패턴', content: 'Hook 패턴에 대한 설명...' },
            { id: 2, title: '성능 최적화', content: '성능 최적화 방법...' }
          ]
        };
        
        // URL에 따라 다른 데이터 반환
        if (url.includes('users')) {
          setData(mockData.users);
        } else if (url.includes('posts')) {
          setData(mockData.posts);
        } else {
          setData({ message: 'Hello from API' });
        }
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    };
    
    fetchData();
  }, [url]);
  
  return { data, loading, error };
}

// 사용 예제
function FetchExample() {
  const [endpoint, setEndpoint] = useState('/api/users');
  const { data, loading, error } = useFetch(endpoint);
  
  return (
    <div>
      <h2>Fetch Hook 예제</h2>
      
      <div>
        <button onClick={() => setEndpoint('/api/users')}>
          사용자 목록
        </button>
        <button onClick={() => setEndpoint('/api/posts')}>
          게시글 목록
        </button>
        <button onClick={() => setEndpoint('/api/info')}>
          기본 정보
        </button>
      </div>
      
      {loading && <p>로딩 중...</p>}
      {error && <p>오류 발생: {error}</p>}
      {data && (
        <div>
          <h3>데이터:</h3>
          <pre>{JSON.stringify(data, null, 2)}</pre>
        </div>
      )}
    </div>
  );
}

컴파운드 컴포넌트 패턴

import { createContext, useContext, useState } from 'react';

// Modal Context 생성
const ModalContext = createContext();

function useModal() {
  const context = useContext(ModalContext);
  if (!context) {
    throw new Error('Modal 컴포넌트는 Modal.Provider 내부에서 사용해야 합니다');
  }
  return context;
}

// 메인 Modal 컴포넌트
function Modal({ children }) {
  const [isOpen, setIsOpen] = useState(false);
  
  const open = () => setIsOpen(true);
  const close = () => setIsOpen(false);
  
  return (
    <ModalContext.Provider value={{ isOpen, open, close }}>
      {children}
    </ModalContext.Provider>
  );
}

// Modal 하위 컴포넌트들
Modal.Trigger = function ModalTrigger({ children }) {
  const { open } = useModal();
  
  return (
    <button onClick={open}>
      {children}
    </button>
  );
};

Modal.Content = function ModalContent({ children }) {
  const { isOpen, close } = useModal();
  
  if (!isOpen) return null;
  
  return (
    <div>
      <div>
        {children}
      </div>
    </div>
  );
};

Modal.Header = function ModalHeader({ children }) {
  return (
    <div>
      <h3>{children}</h3>
    </div>
  );
};

Modal.Body = function ModalBody({ children }) {
  return <div>{children}</div>;
};

Modal.Footer = function ModalFooter({ children }) {
  return (
    <div>
      {children}
    </div>
  );
};

Modal.CloseButton = function ModalCloseButton({ children = '닫기' }) {
  const { close } = useModal();
  
  return <button onClick={close}>{children}</button>;
};

// 사용 예제
function CompoundComponentExample() {
  return (
    <div>
      <h2>컴파운드 컴포넌트 패턴</h2>
      
      <Modal>
        <Modal.Trigger>프로필 모달 열기</Modal.Trigger>
        <Modal.Content>
          <Modal.Header>사용자 프로필</Modal.Header>
          <Modal.Body>
            <p>이름: 김개발</p>
            <p>이메일: kim@example.com</p>
            <p>역할: React 개발자</p>
          </Modal.Body>
          <Modal.Footer>
            <Modal.CloseButton>확인</Modal.CloseButton>
          </Modal.Footer>
        </Modal.Content>
      </Modal>
      
      <Modal>
        <Modal.Trigger>설정 모달 열기</Modal.Trigger>
        <Modal.Content>
          <Modal.Header>애플리케이션 설정</Modal.Header>
          <Modal.Body>
            <label>
              <input type="checkbox" /> 알림 받기
            </label>
            <br />
            <label>
              테마: 
              <select>
                <option>라이트</option>
                <option>다크</option>
              </select>
            </label>
          </Modal.Body>
          <Modal.Footer>
            <button>저장</button>
            <Modal.CloseButton>취소</Modal.CloseButton>
          </Modal.Footer>
        </Modal.Content>
      </Modal>
    </div>
  );
}

Render Props 패턴을 Hook으로 변환

기존 Render Props vs Hook 패턴

import { useState, useEffect } from 'react';

// Hook 패턴으로 구현한 Mouse Tracker
function useMouse() {
  const [position, setPosition] = useState({ x: 0, y: 0 });
  
  useEffect(() => {
    const handleMouseMove = (e) => {
      setPosition({ x: e.clientX, y: e.clientY });
    };
    
    window.addEventListener('mousemove', handleMouseMove);
    
    return () => {
      window.removeEventListener('mousemove', handleMouseMove);
    };
  }, []);
  
  return position;
}

// 윈도우 크기 추적 Hook
function useWindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight
  });
  
  useEffect(() => {
    const handleResize = () => {
      setSize({
        width: window.innerWidth,
        height: window.innerHeight
      });
    };
    
    window.addEventListener('resize', handleResize);
    
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []);
  
  return size;
}

// 온라인 상태 추적 Hook
function useOnlineStatus() {
  const [isOnline, setIsOnline] = useState(navigator.onLine);
  
  useEffect(() => {
    const handleOnline = () => setIsOnline(true);
    const handleOffline = () => setIsOnline(false);
    
    window.addEventListener('online', handleOnline);
    window.addEventListener('offline', handleOffline);
    
    return () => {
      window.removeEventListener('online', handleOnline);
      window.removeEventListener('offline', handleOffline);
    };
  }, []);
  
  return isOnline;
}

// 사용 예제
function HookPatternExample() {
  const mousePosition = useMouse();
  const windowSize = useWindowSize();
  const isOnline = useOnlineStatus();
  
  return (
    <div>
      <h2>Hook 패턴 예제</h2>
      
      <div>
        <h3>마우스 위치</h3>
        <p>X: {mousePosition.x}, Y: {mousePosition.y}</p>
      </div>
      
      <div>
        <h3>윈도우 크기</h3>
        <p>너비: {windowSize.width}px, 높이: {windowSize.height}px</p>
      </div>
      
      <div>
        <h3>연결 상태</h3>
        <p>{isOnline ? '온라인' : '오프라인'}</p>
      </div>
    </div>
  );
}

고급 상태 관리 Hook

useReducer를 활용한 복잡한 상태 관리

import { useReducer, useContext, createContext } from 'react';

// 쇼핑카트 액션 타입
const CART_ACTIONS = {
  ADD_ITEM: 'ADD_ITEM',
  REMOVE_ITEM: 'REMOVE_ITEM',
  UPDATE_QUANTITY: 'UPDATE_QUANTITY',
  CLEAR_CART: 'CLEAR_CART',
  APPLY_COUPON: 'APPLY_COUPON'
};

// 쇼핑카트 reducer
function cartReducer(state, action) {
  switch (action.type) {
    case CART_ACTIONS.ADD_ITEM:
      const existingItem = state.items.find(item => item.id === action.payload.id);
      
      if (existingItem) {
        return {
          ...state,
          items: state.items.map(item =>
            item.id === action.payload.id
              ? { ...item, quantity: item.quantity + 1 }
              : item
          )
        };
      }
      
      return {
        ...state,
        items: [...state.items, { ...action.payload, quantity: 1 }]
      };
    
    case CART_ACTIONS.REMOVE_ITEM:
      return {
        ...state,
        items: state.items.filter(item => item.id !== action.payload)
      };
    
    case CART_ACTIONS.UPDATE_QUANTITY:
      return {
        ...state,
        items: state.items.map(item =>
          item.id === action.payload.id
            ? { ...item, quantity: action.payload.quantity }
            : item
        )
      };
    
    case CART_ACTIONS.CLEAR_CART:
      return {
        ...state,
        items: [],
        coupon: null
      };
    
    case CART_ACTIONS.APPLY_COUPON:
      return {
        ...state,
        coupon: action.payload
      };
    
    default:
      return state;
  }
}

// 쇼핑카트 Hook
function useShoppingCart() {
  const [state, dispatch] = useReducer(cartReducer, {
    items: [],
    coupon: null
  });
  
  const addItem = (item) => {
    dispatch({ type: CART_ACTIONS.ADD_ITEM, payload: item });
  };
  
  const removeItem = (id) => {
    dispatch({ type: CART_ACTIONS.REMOVE_ITEM, payload: id });
  };
  
  const updateQuantity = (id, quantity) => {
    if (quantity <= 0) {
      removeItem(id);
    } else {
      dispatch({ type: CART_ACTIONS.UPDATE_QUANTITY, payload: { id, quantity } });
    }
  };
  
  const clearCart = () => {
    dispatch({ type: CART_ACTIONS.CLEAR_CART });
  };
  
  const applyCoupon = (coupon) => {
    dispatch({ type: CART_ACTIONS.APPLY_COUPON, payload: coupon });
  };
  
  const getTotalPrice = () => {
    const subtotal = state.items.reduce((total, item) => 
      total + (item.price * item.quantity), 0
    );
    
    if (state.coupon) {
      return subtotal * (1 - state.coupon.discount / 100);
    }
    
    return subtotal;
  };
  
  const getTotalItems = () => {
    return state.items.reduce((total, item) => total + item.quantity, 0);
  };
  
  return {
    items: state.items,
    coupon: state.coupon,
    addItem,
    removeItem,
    updateQuantity,
    clearCart,
    applyCoupon,
    totalPrice: getTotalPrice(),
    totalItems: getTotalItems()
  };
}

// 사용 예제
function ShoppingCartExample() {
  const cart = useShoppingCart();
  
  const products = [
    { id: 1, name: '노트북', price: 1500000 },
    { id: 2, name: '마우스', price: 50000 },
    { id: 3, name: '키보드', price: 100000 }
  ];
  
  const coupons = [
    { id: 1, name: '10% 할인', discount: 10 },
    { id: 2, name: '20% 할인', discount: 20 }
  ];
  
  return (
    <div>
      <h2>고급 상태 관리 예제</h2>
      
      <div>
        <h3>상품 목록</h3>
        {products.map(product => (
          <div key={product.id}>
            <span>{product.name} - {product.price.toLocaleString()}원</span>
            <button 
              onClick={() => cart.addItem(product)}
            >
              장바구니에 추가
            </button>
          </div>
        ))}
      </div>
      
      <div>
        <h3>쿠폰</h3>
        {coupons.map(coupon => (
          <button
            key={coupon.id}
            onClick={() => cart.applyCoupon(coupon)}
          >
            {coupon.name}
          </button>
        ))}
      </div>
      
      <div>
        <h3>장바구니 ({cart.totalItems}개 아이템)</h3>
        {cart.items.length === 0 ? (
          <p>장바구니가 비어있습니다.</p>
        ) : (
          <>
            {cart.items.map(item => (
              <div key={item.id}>
                <span>
                  {item.name} - {item.price.toLocaleString()}원
                </span>
                <button onClick={() => cart.updateQuantity(item.id, item.quantity - 1)}>
                  -
                </button>
                <span>{item.quantity}</span>
                <button onClick={() => cart.updateQuantity(item.id, item.quantity + 1)}>
                  +
                </button>
                <button 
                  onClick={() => cart.removeItem(item.id)}
                    >
                  삭제
                </button>
              </div>
            ))}
            
            <div>
              {cart.coupon && (
                <p>적용된 쿠폰: {cart.coupon.name}</p>
              )}
              <p>
                총 금액: {Math.round(cart.totalPrice).toLocaleString()}원
              </p>
              <button onClick={cart.clearCart}>장바구니 비우기</button>
            </div>
          </>
        )}
      </div>
    </div>
  );
}

Hook 조합 패턴

여러 Hook을 조합한 복합 기능

import { useState, useEffect, useCallback } from 'react';

// 디바운스 Hook
function useDebounce(value, delay) {
  const [debouncedValue, setDebouncedValue] = useState(value);
  
  useEffect(() => {
    const handler = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);
    
    return () => {
      clearTimeout(handler);
    };
  }, [value, delay]);
  
  return debouncedValue;
}

// API 검색 Hook
function useSearch() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(false);
  
  const debouncedQuery = useDebounce(query, 300);
  
  useEffect(() => {
    if (!debouncedQuery) {
      setResults([]);
      return;
    }
    
    const searchUsers = async () => {
      setLoading(true);
      
      // 가상 검색 API 시뮬레이션
      await new Promise(resolve => setTimeout(resolve, 500));
      
      const mockUsers = [
        { id: 1, name: '김개발', email: 'kim@example.com' },
        { id: 2, name: '이디자인', email: 'lee@example.com' },
        { id: 3, name: '박기획', email: 'park@example.com' },
        { id: 4, name: '최마케팅', email: 'choi@example.com' },
        { id: 5, name: '정운영', email: 'jung@example.com' }
      ];
      
      const filtered = mockUsers.filter(user =>
        user.name.includes(debouncedQuery) || user.email.includes(debouncedQuery)
      );
      
      setResults(filtered);
      setLoading(false);
    };
    
    searchUsers();
  }, [debouncedQuery]);
  
  return {
    query,
    setQuery,
    results,
    loading
  };
}

// 검색 컴포넌트
function SearchExample() {
  const { query, setQuery, results, loading } = useSearch();
  const [selectedUser, setSelectedUser] = useState(null);
  
  return (
    <div>
      <h2>Hook 조합 패턴 - 실시간 검색</h2>
      
      <input
        type="text"
        value={query}
        onChange={(e) => setQuery(e.target.value)}
        placeholder="사용자 검색..."
      />
      
      {loading && <p>검색 중...</p>}
      
      {results.length > 0 && (
        <div>
          {results.map(user => (
            <div
              key={user.id}
              onClick={() => setSelectedUser(user)}
            >
              <div>{user.name}</div>
              <small>{user.email}</small>
            </div>
          ))}
        </div>
      )}
      
      {selectedUser && (
        <div>
          <h3>선택된 사용자</h3>
          <p>이름: {selectedUser.name}</p>
          <p>이메일: {selectedUser.email}</p>
          <button onClick={() => setSelectedUser(null)}>선택 해제</button>
        </div>
      )}
    </div>
  );
}

정리

고급 Hook 패턴의 핵심 개념을 학습했습니다:

  1. 커스텀 Hook 패턴: useToggle, useLocalStorage, useFetch 등의 재사용 가능한 로직
  2. 컴파운드 컴포넌트: Context와 Hook을 조합한 유연한 컴포넌트 구조
  3. Render Props를 Hook으로 변환: 더 간결하고 재사용 가능한 코드
  4. 고급 상태 관리: useReducer를 활용한 복잡한 상태 로직
  5. Hook 조합: 여러 Hook을 조합하여 복합적인 기능 구현
  6. 성능 최적화: useCallback, useMemo와 함께 사용한 최적화 기법
  7. 실전 활용: 실제 프로젝트에서 사용할 수 있는 패턴들

이러한 고급 Hook 패턴들은 React 애플리케이션의 코드 재사용성을 높이고, 유지보수성을 향상시키며, 더 선언적인 코드 작성을 가능하게 합니다. 복잡한 로직을 Hook으로 분리하여 컴포넌트를 단순하게 유지하는 것이 중요합니다.

다음 장에서는 React의 테스팅에 대해 알아보겠습니다.