React Hook 완벽 마스터 가이드: 실전에서 제대로 쓰는 법 🚀

React Hook은 함수형 컴포넌트에서 상태와 생명주기를 다룰 수 있게 해주는 강력한 도구입니다. 이 글에서는 단순한 사용법을 넘어, 실제 프로덕션에서 안전하고 효율적으로 React Hook을 활용하는 방법을 TypeScript 기준으로 상세히 다룹니다.

📌 React Hook의 두 가지 철칙

Hook을 사용할 때 반드시 지켜야 할 두 가지 규칙이 있습니다:

  1. 최상위(Top Level)에서만 호출: 조건문, 반복문, 중첩 함수 안에서 호출 금지
  2. React 함수 내에서만 호출: React 컴포넌트나 커스텀 Hook에서만 사용
// ❌ 잘못된 예시
function Component({ isAdmin }: Props) {
  if (isAdmin) {
    const [adminData, setAdminData] = useState(null); // 조건문 안에서 호출
  }
  
  for (let i = 0; i < items.length; i++) {
    const [item, setItem] = useState(items[i]); // 반복문 안에서 호출
  }
}

// ✅ 올바른 예시
function Component({ isAdmin }: Props) {
  const [adminData, setAdminData] = useState(null);
  const [items, setItems] = useState<Item[]>([]);
  
  // 조건부 로직은 Hook 호출 이후에
  useEffect(() => {
    if (isAdmin) {
      fetchAdminData().then(setAdminData);
    }
  }, [isAdmin]);
}

🎯 핵심 React Hook 완벽 가이드

1. useState – 파생 상태는 계산으로

// ❌ 안티패턴: 파생 가능한 값을 상태로 저장
function BadExample({ items }: { items: Item[] }) {
  const [filteredItems, setFilteredItems] = useState<Item[]>([]);
  const [filter, setFilter] = useState('');
  
  // filter가 바뀔 때마다 동기화 필요 - 버그 발생 가능
  useEffect(() => {
    setFilteredItems(items.filter(item => item.name.includes(filter)));
  }, [items, filter]);
}

// ✅ 좋은 패턴: 파생 값은 계산
function GoodExample({ items }: { items: Item[] }) {
  const [filter, setFilter] = useState('');
  
  // 렌더링마다 계산되지만 성능 문제시에만 useMemo 고려
  const filteredItems = items.filter(item => 
    item.name.toLowerCase().includes(filter.toLowerCase())
  );
}

// 💡 함수형 업데이트: 이전 값 기반 갱신
const [count, setCount] = useState(0);
const increment = () => setCount(prev => prev + 1); // Race condition 방지

2. useEffect를 안전하게 쓰는 7가지 패턴

패턴 1: 의존성 배열 정직하게 작성

// ESLint의 exhaustive-deps 규칙을 반드시 켜두세요
useEffect(() => {
  // userId와 options를 사용하므로 의존성에 포함
  fetchUserData(userId, options);
}, [userId, options]); // 실제 사용한 모든 값 포함

패턴 2: 비동기 작업 취소 (AbortController)

function SearchResults({ query }: { query: string }) {
  const [results, setResults] = useState<Result[]>([]);
  const [error, setError] = useState<Error | null>(null);
  
  useEffect(() => {
    const controller = new AbortController();
    
    const search = async () => {
      try {
        setError(null);
        const response = await fetch(
          `/api/search?q=${encodeURIComponent(query)}`,
          { signal: controller.signal }
        );
        
        if (!response.ok) throw new Error('Search failed');
        
        const data = await response.json();
        setResults(data);
      } catch (err) {
        // AbortError는 무시
        if ((err as Error).name !== 'AbortError') {
          setError(err as Error);
        }
      }
    };
    
    if (query) search();
    
    // 클린업: 이전 요청 취소
    return () => controller.abort();
  }, [query]);
  
  return (
    <>
      {error && <div>Error: {error.message}</div>}
      {results.map(r => <SearchResult key={r.id} {...r} />)}
    </>
  );
}

패턴 3: 이벤트 리스너 클린업

function useWindowSize() {
  const [size, setSize] = useState({ width: 0, height: 0 });
  
  useEffect(() => {
    const handleResize = () => {
      setSize({ width: window.innerWidth, height: window.innerHeight });
    };
    
    // 초기값 설정
    handleResize();
    
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []); // 마운트 시 한 번만
  
  return size;
}

패턴 4: Stale Closure 방지

function Timer() {
  const [count, setCount] = useState(0);
  const savedCallback = useRef<() => void>();
  
  // 최신 callback 저장
  useEffect(() => {
    savedCallback.current = () => {
      setCount(count + 1); // count는 항상 최신값
    };
  });
  
  useEffect(() => {
    const id = setInterval(() => {
      savedCallback.current?.(); // 최신 callback 호출
    }, 1000);
    
    return () => clearInterval(id);
  }, []); // 의존성 없음
}

패턴 5: 조건부 상태 업데이트로 무한 루프 방지

useEffect(() => {
  const newValue = computeExpensiveValue(props);
  
  // 값이 실제로 변경될 때만 업데이트
  setDerivedState(prev => {
    if (prev === newValue) return prev; // 동일하면 업데이트 안 함
    return newValue;
  });
}, [props]);

3. 성능 최적화: useMemo/useCallback 언제 써야 하나?

` 써야 하는 경우 ✅

// 1. 비용이 큰 계산
const expensiveResult = useMemo(() => {
  return heavyComputation(largeDataSet);
}, [largeDataSet]);

// 2. 참조 동일성이 중요한 경우 (React.memo 자식 컴포넌트)
const ChildMemo = React.memo(({ onClick }: { onClick: () => void }) => {
  console.log('Child rendered');
  return <button onClick={onClick}>Click</button>;
});

function Parent() {
  // useCallback 없으면 매번 새 함수 → 자식 리렌더
  const handleClick = useCallback(() => {
    console.log('Clicked');
  }, []); // 의존성 없으면 한 번만 생성
  
  return <ChildMemo onClick={handleClick} />;
}

// 3. 무거운 객체/배열을 props로 전달
const chartData = useMemo(() => 
  processDataForChart(rawData), 
  [rawData]
);

` 안 써도 되는 경우 ❌

// 가벼운 계산은 메모화 오버헤드가 더 클 수 있음
const isEven = useMemo(() => number % 2 === 0, [number]); // ❌ 과도함

// 자식이 memo를 사용하지 않으면 의미 없음
function RegularChild({ onClick }: { onClick: () => void }) {
  return <button onClick={onClick}>Click</button>;
}

function Parent() {
  // RegularChild는 어차피 리렌더되므로 useCallback 불필요
  const handleClick = () => console.log('Clicked'); // ✅ 단순하게
}

4. useReducer – 복잡한 상태 관리

type State = {
  loading: boolean;
  error: Error | null;
  data: User | null;
};

type Action =
  | { type: 'FETCH_START' }
  | { type: 'FETCH_SUCCESS'; payload: User }
  | { type: 'FETCH_ERROR'; payload: Error }
  | { type: 'RESET' };

function userReducer(state: State, action: Action): State {
  switch (action.type) {
    case 'FETCH_START':
      return { ...state, loading: true, error: null };
    case 'FETCH_SUCCESS':
      return { loading: false, error: null, data: action.payload };
    case 'FETCH_ERROR':
      return { ...state, loading: false, error: action.payload };
    case 'RESET':
      return { loading: false, error: null, data: null };
    default:
      return state;
  }
}

function UserProfile({ userId }: { userId: string }) {
  const [state, dispatch] = useReducer(userReducer, {
    loading: false,
    error: null,
    data: null,
  });
  
  useEffect(() => {
    dispatch({ type: 'FETCH_START' });
    
    fetchUser(userId)
      .then(user => dispatch({ type: 'FETCH_SUCCESS', payload: user }))
      .catch(error => dispatch({ type: 'FETCH_ERROR', payload: error }));
  }, [userId]);
  
  if (state.loading) return <Spinner />;
  if (state.error) return <Error error={state.error} />;
  if (!state.data) return null;
  
  return <UserCard user={state.data} />;
}

5. Context + Hook으로 전역 상태 관리

// auth-context.tsx
type User = { id: string; name: string; role: 'admin' | 'user' };
type AuthState = {
  user: User | null;
  loading: boolean;
  login: (credentials: Credentials) => Promise<void>;
  logout: () => void;
};

const AuthContext = createContext<AuthState | null>(null);

export const AuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  
  // 초기 인증 상태 복원
  useEffect(() => {
    const token = localStorage.getItem('token');
    if (token) {
      validateToken(token)
        .then(setUser)
        .finally(() => setLoading(false));
    } else {
      setLoading(false);
    }
  }, []);
  
  const login = useCallback(async (credentials: Credentials) => {
    const { user, token } = await authenticate(credentials);
    localStorage.setItem('token', token);
    setUser(user);
  }, []);
  
  const logout = useCallback(() => {
    localStorage.removeItem('token');
    setUser(null);
  }, []);
  
  // value 객체 메모이제이션 - 리렌더 최소화
  const value = useMemo(
    () => ({ user, loading, login, logout }),
    [user, loading, login, logout]
  );
  
  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
};

// 타입 안전한 커스텀 Hook
export const useAuth = () => {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider');
  }
  return context;
};

🔧 실전 커스텀 Hook 패턴

1. useDebounce – 입력 지연 처리

export function useDebounce<T>(value: T, delay = 500): T {
  const [debouncedValue, setDebouncedValue] = useState(value);
  
  useEffect(() => {
    const timer = setTimeout(() => setDebouncedValue(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);
  
  return debouncedValue;
}

// 사용 예시
function SearchInput() {
  const [input, setInput] = useState('');
  const debouncedQuery = useDebounce(input, 300);
  
  const { data, loading } = useQuery({
    queryKey: ['search', debouncedQuery],
    queryFn: () => searchAPI(debouncedQuery),
    enabled: debouncedQuery.length > 2,
  });
}

2. useLocalStorage – 영속성 있는 상태

export function useLocalStorage<T>(
  key: string,
  initialValue: T
): [T, (value: T | ((prev: T) => T)) => void] {
  // 초기값 lazy initialization
  const [storedValue, setStoredValue] = useState<T>(() => {
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(`Error reading localStorage key "${key}":`, error);
      return initialValue;
    }
  });
  
  // 값 설정 함수
  const setValue = useCallback(
    (value: T | ((prev: T) => T)) => {
      try {
        setStoredValue(prevValue => {
          const nextValue = value instanceof Function ? value(prevValue) : value;
          window.localStorage.setItem(key, JSON.stringify(nextValue));
          return nextValue;
        });
      } catch (error) {
        console.error(`Error setting localStorage key "${key}":`, error);
      }
    },
    [key]
  );
  
  return [storedValue, setValue];
}

3. useIntersectionObserver – 무한 스크롤

interface UseIntersectionObserverProps {
  threshold?: number;
  root?: Element | null;
  rootMargin?: string;
  enabled?: boolean;
}

export function useIntersectionObserver(
  ref: RefObject<Element>,
  options: UseIntersectionObserverProps = {}
): boolean {
  const { threshold = 0, root = null, rootMargin = '0px', enabled = true } = options;
  const [isIntersecting, setIsIntersecting] = useState(false);
  
  useEffect(() => {
    if (!enabled || !ref.current) return;
    
    const observer = new IntersectionObserver(
      ([entry]) => setIsIntersecting(entry.isIntersecting),
      { threshold, root, rootMargin }
    );
    
    observer.observe(ref.current);
    
    return () => observer.disconnect();
  }, [ref, threshold, root, rootMargin, enabled]);
  
  return isIntersecting;
}

// 사용 예시: 무한 스크롤
function InfiniteList() {
  const loadMoreRef = useRef<HTMLDivElement>(null);
  const isLoadMoreVisible = useIntersectionObserver(loadMoreRef);
  
  useEffect(() => {
    if (isLoadMoreVisible) {
      loadNextPage();
    }
  }, [isLoadMoreVisible]);
  
  return (
    <>
      {items.map(item => <Item key={item.id} {...item} />)}
      <div ref={loadMoreRef}>Loading more...</div>
    </>
  );
}

4. useSyncExternalStore – 외부 스토어 구독

// 뷰포트 사이즈 구독 예시
function subscribe(callback: () => void) {
  window.addEventListener('resize', callback);
  return () => window.removeEventListener('resize', callback);
}

function getSnapshot() {
  return {
    width: window.innerWidth,
    height: window.innerHeight,
  };
}

function getServerSnapshot() {
  return { width: 0, height: 0 }; // SSR용
}

export function useViewport() {
  return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
}

🚨 자주 발생하는 버그와 해결책

1. 조건부 Hook 호출

// ❌ 버그: 조건에 따라 Hook 호출 순서가 달라짐
function BadComponent({ isSpecial }: { isSpecial: boolean }) {
  if (isSpecial) {
    const [special, setSpecial] = useState(true); // 💥 에러!
  }
}

// ✅ 해결: Hook을 항상 호출하고 조건부 로직은 내부에서
function GoodComponent({ isSpecial }: { isSpecial: boolean }) {
  const [special, setSpecial] = useState(false);
  
  useEffect(() => {
    if (isSpecial) {
      setSpecial(true);
    }
  }, [isSpecial]);
}

2. 의존성 누락으로 인한 Stale Closure

// ❌ 버그: count가 항상 0으로 고정
function BadCounter() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const id = setInterval(() => {
      console.log(count); // 항상 0 출력
    }, 1000);
    return () => clearInterval(id);
  }, []); // count 누락!
}

// ✅ 해결: 함수형 업데이트 또는 ref 사용
function GoodCounter() {
  const [count, setCount] = useState(0);
  
  useEffect(() => {
    const id = setInterval(() => {
      setCount(prev => {
        console.log(prev + 1);
        return prev + 1;
      });
    }, 1000);
    return () => clearInterval(id);
  }, []); // 의존성 없어도 OK
}

3. Context Value 재생성으로 인한 리렌더 폭발

// ❌ 버그: value 객체가 매번 새로 생성
function BadProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState(null);
  
  return (
    <UserContext.Provider value={{ user, setUser }}> {/* 매번 새 객체 */}
      {children}
    </UserContext.Provider>
  );
}

// ✅ 해결: useMemo로 메모이제이션
function GoodProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState(null);
  
  const value = useMemo(
    () => ({ user, setUser }),
    [user] // user가 변경될 때만 새 객체
  );
  
  return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}

📊 React Hook 선택 가이드

react hook 선택 가이드

🎯 요약

  1. React Hook은 규칙을 지켜야 합니다 – 최상위에서만, React 함수 내에서만
  2. 의존성 배열은 정직하게 – ESLint를 믿고 모든 의존성 포함
  3. 파생 상태는 계산으로 – 동기화 지옥을 피하세요
  4. 메모이제이션은 측정 후 – 무작정 쓰면 오히려 느려집니다
  5. Context value는 메모 필수 – 리렌더 폭발 방지
  6. 커스텀 Hook으로 로직 재사용 – 명확한 인터페이스로 설계
  7. 클린업을 잊지 마세요 – 메모리 누수는 실서비스의 적

🎯 마무리

React Hook은 함수형 프로그래밍의 장점을 React에 도입하여 더 깨끗하고 재사용 가능한 코드를 작성할 수 있게 해줍니다. 처음에는 클래스 컴포넌트에서 Hook으로의 전환이 어려울 수 있지만, 한 번 익숙해지면 훨씬 더 직관적이고 강력한 도구임을 깨닫게 될 것입니다.

React Hook을 마스터하는 가장 좋은 방법은 직접 사용해보는 것입니다. 작은 프로젝트부터 시작해서 점진적으로 복잡한 애플리케이션에 적용해보세요. Custom Hook을 작성하여 팀 내에서 공유하고, 커뮤니티의 다양한 React Hook 라이브러리들도 살펴보시기 바랍니다.

📚 추가 학습 자료

React 공식 문서 – HookuseHooks.com – Custom Hook 예시 모음

React Hook Form – Form 관리 라이브러리

SWR / React Query – 데이터 페칭 Hook 라이브러리

[2023] React Router: 라우팅 설정하기

React Router

React는 단일 페이지 애플리케이션 (SPA)를 구축하는데 매우 유용한 라이브러리입니다.

하지만, SPA는 여러 페이지가 있는 것처럼 보이는 사용자 경험을 제공하도록 설계되었기 때문에, 페이지간의 이동이 필요할 때 라우팅이 중요하게 작용합니다.

React에서 가장 널리 사용되는 라우팅 라이브러리는 react router입니다.

이 라이브러리를 사용하면 사용자의 현재 위치에 따라 다른 컴포넌트를 렌더링하거나 특정 URL에서 특정 컴포넌트를 렌더링하는 등의 기능을 구현할 수 있습니다.

이번 포스트에서는 react-router를 사용해 React 앱에 라우팅을 설정하는 방법에 대해 알아보겠습니다.

1. React Router 설치

먼저, 프로젝트에 react-router-dom 패키지를 설치해야 합니다.

이 패키지는 웹 어플리케이션을 위한 react-router의 버전입니다. npm을 사용하면 다음과 같이 설치할 수 있습니다.

npm install react-router-dom

2. React-router-dom 구성 요소 불러오기

라이브러리를 설치한 후에는, react-router-dom에서 필요한 구성 요소를 가져와야 합니다. 이를 위해 index.js 또는 app.js 파일에서 다음 코드를 작성해야 합니다.

import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';

3. Router 설정

BrowserRouter는 HTML5의 history API를 사용하여 UI를 URL과 동기화하는 라우터입니다. React 컴포넌트를 BrowserRouter 컴포넌트로 감싸면 라우팅이 활성화됩니다.

<Router>
  <App />
</Router>

4. 경로 정의

Switch 컴포넌트 내에서 Route 컴포넌트를 사용하여 경로를 정의합니다.

여기서 Switch 컴포넌트는 여러 Route 중 하나를 렌더링하는 역할을 합니다.

URL과 일치하는 첫 번째 Route 또는 Redirect 자식을 렌더링합니다. 이는 여러 경로가 일치하는 경우 첫 번째 경로만 렌더링하도록 보장합니다.

<Switch>
  <Route exact path="/" component={Home} />
  <Route path="/about" component={About} />
  <Route path="/contact" component={Contact} />
  <Route component={NotFound} />
</Switch>

5. 컴포넌트 생성

각 경로에 대해 표시할 컴포넌트를 정의해야 합니다. Home.js, About.js, Contact.js, NotFound.js 등의 컴포넌트를 생성하면 됩니다.

추가적인 개념: 파라미터와 쿼리

react router에서는 URL의 일부를 변수로 사용할 수 있습니다. 이를 통해 동적 라우팅을 구현할 수 있습니다. 예를 들어, /users/:id 경로를 정의하면, /users/1, /users/2 등 다양한 URL에서 동일한 컴포넌트를 렌더링하고, :id 부분은 파라미터로서 접근할 수 있습니다.

URL에 정보를 담는 또 다른 방법은 쿼리 파라미터를 사용하는 것입니다. 쿼리는 ? 다음에 나오며, &로 여러 개의 쿼리를 연결할 수 있습니다. 예를 들어, /search?query=react와 같이 사용됩니다.

결론

react-router는 React 웹 애플리케이션에서 라우팅을 관리하기 위한 라이브러리입니다.

이를 사용하면 사용자의 현재 위치에 따라 다른 컴포넌트를 렌더링하고, 특정 URL에서 특정 컴포넌트를 렌더링하는 등의 작업을 할 수 있습니다.

본 포스트에서는 react-router의 기본적인 사용법을 소개하고, 설치부터 라우팅 설정까지의 과정을 설명했습니다. 이를 통해 React에서의 라우팅 설정에 대한 이해를 돕고자 했습니다. 이상입니다.

[react router 추가 설명 문서]

[wooyung’s IT 블로그]