[react] 함수 컴포넌트 Hooks 기능
Hello. #{Somebody}
리액트 핵심, 훅(Hook)! 🚀
오늘은 리액트 함수 컴포넌트의 핵심이자, 개발을 훨씬 즐겁게 만들어주는 마법 같은 친구,
바로 훅(Hook)에 대해 알아보려 합니다. 😊
훅(Hook), 왜 갑자기 나타났을까요? 🤔
예전 리액트에서는 상태(state)를 관리하거나 생명주기(lifecycle) 기능을 사용하려면 꼭 클래스 컴포넌트를 사용해야 했습니다.
하지만 클래스 컴포넌트는 this 키워드의 혼란스러움, 코드 재사용의 어려움 등 몇 가지 불편한 점들이 있었죠.
이런 불편함을 해소하고, 더 간결하고 직관적인 방법으로 함수 컴포넌트에서도 리액트의 강력한 기능들을 사용할 수 있도록 등장한 것이 바로 훅(Hook)입니다.
훅 덕분에 우리는 함수 컴포넌트를 사용해서 상태 관리, 생명주기 관리 등을 할 수 있게 되었습니다.
상태 관리의 첫걸음, useState 🔢
useState는 컴포넌트가 기억해야 할 값, 즉 상태(state)를 함수 컴포넌트 안에서 사용할 수 있게 해줍니다.
useState(초기값)을 호출하면 [현재 상태값, 상태 업데이트 함수] 배열을 반환합니다.
상태 업데이트 함수가 호출되면 컴포넌트는 새로운 상태값으로 다시 렌더링됩니다.
import React, { useState } from 'react';
function BasicCounter() {
const [count, setCount] = useState(0); // 초기값 0
return (
<div>
<p>클릭 횟수: {count}</p>
{/* setCount를 호출하여 count 상태 업데이트 */}
<button onClick={() => setCount(count + 1)}>클릭하세요!</button>
</div>
);
}
export default BasicCounter;
가장 기본적이면서도 가장 많이 사용되는 훅
부수 효과 처리는 내게 맡겨! useEffect ✨
컴포넌트가 렌더링된 후에 처리해야 하는 작업들, 예를 들어 API 호출, 구독 설정, 직접적인 DOM 조작 등을 부수 효과(Side Effect)라고 합니다. useEffect는 이러한 부수 효과들을 함수 컴포넌트에서 다룰 수 있게 해줍니다.
useEffect(콜백함수, [의존성 배열]) 형태로 사용하며, 의존성 배열에 따라 콜백함수의 실행 시점이 결정됩니다.
import React, { useState, useEffect } from 'react';
function DataFetcher() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
console.log('데이터 가져오기 시작!');
// 예시: 실제로는 fetch나 axios 등을 사용합니다.
setTimeout(() => { // 비동기 작업 시뮬레이션
setData({ message: '짜잔! 데이터 도착!' });
setLoading(false);
console.log('데이터 가져오기 완료!');
}, 1500);
// 클린업(Clean-up) 함수: 컴포넌트가 사라지기 전이나, 다음 effect 실행 전에 호출됩니다.
return () => {
console.log('컴포넌트 언마운트 또는 업데이트 전 정리 작업');
// 예: 구독 해제, 타이머 제거 등
};
}, []); // 의존성 배열이 비어있으므로, 마운트 시 딱 한 번만 실행됩니다.
if (loading) {
return <p>데이터를 불러오는 중...</p>;
}
return <p>{data ? data.message : '데이터 없음'}</p>;
}
export default DataFetcher;
의존성 배열을 어떻게 관리하는지가 useEffect 활용의 핵심
"Prop Drilling" 이제 그만! useContext 🌐
깊게 중첩된 컴포넌트 구조에서 최상위 컴포넌트의 상태를 최하위 컴포넌트까지 전달해야 할 때,
중간의 모든 컴포넌트가 props를 계속해서 넘겨줘야 하는 번거로움을 "Prop Drilling"이라고 합니다.
useContext는 React.createContext로 생성된 Context 객체와 함께 사용되어, 이런 과정을 건너뛰고 필요한 컴포넌트에서 직접 전역적인 데이터에 접근할 수 있게 해줍니다. 마치 단체 메시지 방처럼, 필요한 사람에게 바로 정보를 전달하는 것과 같습니다.
import React, { useState, useContext, createContext } from 'react';
// 1. Context 생성
const UserContext = createContext(null);
function App() {
const [user, setUser] = useState({ name: '손님', isLoggedIn: false });
// 2. Provider로 감싸고 value 전달
return (
<UserContext.Provider value={{ user, setUser }}>
<Header />
<UserProfile />
{/* ... 다른 컴포넌트들 */}
</UserContext.Provider>
);
}
function Header() {
// 3. useContext로 Context 값 사용
const { user } = useContext(UserContext);
return <header>안녕하세요, {user.name}님!</header>;
}
function UserProfile() {
const { user, setUser } = useContext(UserContext);
const handleLogin = () => setUser({ name: '김리액트', isLoggedIn: true });
const handleLogout = () => setUser({ name: '손님', isLoggedIn: false });
return (
<div>
<p>로그인 상태: {user.isLoggedIn ? '로그인됨' : '로그아웃됨'}</p>
{user.isLoggedIn ? (
<button onClick={handleLogout}>로그아웃</button>
) : (
<button onClick={handleLogin}>로그인</button>
)}
</div>
);
}
export default App;
DOM 접근과 값 보존, useRef 📌
useRef는 두 가지 주요 사용 사례를 가집니다. 첫째, 특정 DOM 노드에 직접 접근해야 할 때 사용합니다(예: input 포커스). 둘째, 값이 변경되어도 컴포넌트를 리렌더링시키고 싶지 않은 '변수'(인스턴스 변수와 유사)를 관리할 때 사용합니다. useRef()는 .current 속성을 가진 객체를 반환하며, 이 속성을 통해 값이나 DOM 요소에 접근합니다.
import React, { useRef, useEffect, useState } from 'react';
function FocusInputAndTrackRenders() {
const inputRef = useRef(null);
const renderCountRef = useRef(0); // 리렌더링과 상관없이 렌더 횟수 추적
const [text, setText] = useState(''); // 입력값 추적 (리렌더링 유발)
useEffect(() => {
// 마운트 시 포커스
inputRef.current.focus();
}, []);
useEffect(() => {
// 매 렌더링 시 카운트 증가 (하지만 이 증가가 리렌더링을 유발하진 않음)
renderCountRef.current = renderCountRef.current + 1;
console.log(`컴포넌트 렌더링 횟수: ${renderCountRef.current}`);
}); // 의존성 배열 없으므로 매 렌더링 시 실행
return (
<div>
<input
ref={inputRef}
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="여기에 입력..."
/>
<p>입력된 텍스트: {text}</p>
<p>(이 컴포넌트는 {renderCountRef.current}번 렌더링되었습니다.)</p>
<button onClick={() => inputRef.current.focus()}>입력창 포커스</button>
</div>
);
}
export default FocusInputAndTrackRenders;
비싼 계산 결과는 저장해두자! useMemo 🧠
컴포넌트가 렌더링될 때마다 복잡하고 시간이 오래 걸리는 계산을 반복적으로 수행해야 한다면 성능에 좋지 않겠죠?
useMemo는 바로 이럴 때 사용하는 훅입니다. 계산 결과값을 메모이제이션(memoization, 기억)해두고, 의존성 배열의 값이 변경되었을 때만 다시 계산을 수행합니다. 결과적으로 불필요한 계산을 건너뛰어 성능을 최적화할 수 있습니다.
import React, { useState, useMemo } from 'react';
// 매우 복잡하고 오래 걸리는 계산 함수라고 가정
const calculateComplexValue = (num) => {
console.log('엄청 복잡한 계산 중...');
let result = 0;
for (let i = 0; i < num * 10000000; i++) {
result += Math.sqrt(i); // 의미 없는 복잡한 연산
}
return result;
};
function MemoExample() {
const [number, setNumber] = useState(1);
const [text, setText] = useState(''); // 이 상태가 변해도 위 계산은 다시 하지 않도록!
// useMemo 사용: number가 변경될 때만 calculateComplexValue 함수 실행
const complexValue = useMemo(() => {
return calculateComplexValue(number);
}, [number]); // number를 의존성 배열에 추가
return (
<div>
<input
type="number"
value={number}
onChange={(e) => setNumber(parseInt(e.target.value, 10))}
/>
<p>복잡한 계산 결과: {complexValue}</p>
<hr />
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="이 입력은 계산과 상관없어요"
/>
<p>입력된 텍스트: {text}</p>
</div>
);
}
export default MemoExample;
useMemo는 값을 기억하는 훅
함수도 기억할 수 있어! useCallback useCallback 🗣️
useCallback은 useMemo와 비슷하지만, 값이 아닌 함수 자체를 메모이제이션합니다.
이게 왜 필요할까요? 자바스크립트에서는 컴포넌트가 리렌더링될 때마다 내부에 선언된 함수들이 새로 생성됩니다.
만약 이 함수를 자식 컴포넌트의 prop으로 전달하고, 그 자식 컴포넌트가 React.memo 등으로 최적화되어 있다면, 부모가 리렌더링될 때마다 prop으로 받은 함수가 계속 새로운 참조값을 가지게 되어 불필요한 자식 컴포넌트 리렌더링을 유발할 수 있습니다.
useCallback은 의존성 배열의 값이 변경되지 않는 한, 동일한 함수 참조를 반환하여 이러한 문제를 방지합니다.
import React, { useState, useCallback } from 'react';
// React.memo로 감싸서 props가 변경되지 않으면 리렌더링되지 않도록 최적화
const ChildButton = React.memo(({ onClick, label }) => {
console.log(`${label} 버튼 렌더링됨!`);
return <button onClick={onClick}>{label}</button>;
});
function CallbackExample() {
const [count1, setCount1] = useState(0);
const [count2, setCount2] = useState(0);
// useCallback 사용: count1이 변경될 때만 새로운 함수 생성
const incrementCount1 = useCallback(() => {
setCount1(c => c + 1);
}, []); // count1에 의존하지 않으므로 빈 배열 가능 (함수형 업데이트 사용)
// 만약 count1을 직접 사용했다면 [count1] 필요
// useCallback 미사용: CallbackExample이 리렌더링될 때마다 항상 새로 생성됨
const incrementCount2 = () => {
setCount2(c => c + 1);
};
console.log('CallbackExample 부모 렌더링됨');
return (
<div>
<p>카운트 1: {count1}</p>
<ChildButton onClick={incrementCount1} label="카운트 1 증가 (useCallback)" />
<hr />
<p>카운트 2: {count2}</p>
<ChildButton onClick={incrementCount2} label="카운트 2 증가 (일반 함수)" />
</div>
);
}
// 개발자 도구 콘솔을 열고 각 버튼을 클릭해보세요.
// '카운트 2 증가' 버튼 관련 상태(count2)만 변경해도, '카운트 1 증가' 버튼은 리렌더링되지 않지만,
// '카운트 1 증가' 버튼 관련 상태(count1)만 변경해도, 일반 함수로 전달된 '카운트 2 증가' 버튼은 리렌더링됩니다.
// 이는 incrementCount2 함수가 매번 새로 생성되기 때문입니다.
export default CallbackExample;
useCallback(fn, deps)는 useMemo(() => fn, deps)와 동일하게 동작합니다.
즉, 함수를 위한 useMemo의 편의 문법이라고 생각할 수 있습니다.
복잡한 상태 로직은 useReducer에게! 🔄
useState만으로 관리하기에는 상태 로직이 너무 복잡해지거나, 여러 상태 값이 서로 연관되어 업데이트되어야 할 때 useReducer가 좋은 대안이 될 수 있습니다.
useReducer는 리덕스(Redux) 같은 상태 관리 라이브러리에서 영감을 받은 훅으로, 상태 업데이트 로직을 컴포넌트 외부의 리듀서(reducer) 함수로 분리하여 관리합니다. seReducer(reducer 함수, 초기 상태)를 호출하면 [현재 상태, dispatch 함수] 배열을 반환합니다. 상태를 업데이트하려면 dispatch 함수에 액션(action) 객체를 전달하고, 리듀서 함수는 현재 상태와 액션을 받아 새로운 상태를 반환하는 방식으로 동작합니다.
import React, { useReducer } from 'react';
// 1. 초기 상태 정의
const initialState = { count: 0 };
// 2. 리듀서 함수 정의: 상태와 액션을 받아 새로운 상태 반환
function reducer(state, action) {
switch (action.type) {
case 'INCREMENT':
return { count: state.count + 1 };
case 'DECREMENT':
return { count: state.count - 1 };
case 'RESET':
return { count: 0 };
case 'ADD_AMOUNT':
return { count: state.count + action.payload }; // 액션에 데이터를 담아 전달 가능
default:
throw new Error('알 수 없는 액션 타입입니다.');
}
}
function ReducerCounter() {
// 3. useReducer 사용: 리듀서와 초기 상태 전달
const [state, dispatch] = useReducer(reducer, initialState);
return (
<div>
<p>현재 카운트: {state.count}</p>
{/* 4. dispatch 함수로 액션 전달하여 상태 업데이트 요청 */}
<button onClick={() => dispatch({ type: 'INCREMENT' })}>증가</button>
<button onClick={() => dispatch({ type: 'DECREMENT' })}>감소</button>
<button onClick={() => dispatch({ type: 'RESET' })}>초기화</button>
<button onClick={() => dispatch({ type: 'ADD_AMOUNT', payload: 5 })}>+5</button>
</div>
);
}
export default ReducerCounter;
useReducer는 상태 업데이트 로직을 한 곳에 모아 관리하므로 코드의 가독성과 유지보수성이 향상되고,
복잡한 시나리오에서 상태 업데이트 과정을 예측하기 쉽게 만들어줍니다.
리액트의 주요 훅들을 다시 한번 표로 정리해 보았습니다.📋
useState | 함수 컴포넌트의 상태 관리 | 상태 변경 시 컴포넌트 리렌더링 유발 | [상태값, 상태설정함수] (배열) | 간단한 상태 관리, 독립적인 상태값 |
useEffect | 부수 효과(Side Effect) 처리 | 의존성 배열로 실행 시점 제어, 클린업 함수 제공 | undefined | API 호출, 구독, 타이머, DOM 조작 등 비동기/부수 작업 |
useContext | Prop Drilling 없이 전역적 데이터 공유 | Provider와 함께 사용, Context 값 구독 | Context의 value prop | 전역 상태(테마, 사용자 정보 등) 공유 |
useRef | DOM 요소 접근, 리렌더링 없는 값 저장 | .current 속성, 값 변경 시 리렌더링 없음 | { current: ... } (객체) | DOM 직접 제어, 렌더링과 무관한 값 저장 |
useMemo | 값(계산 결과) 메모이제이션 | 의존성 변경 시에만 값 다시 계산, 성능 최적화 | 메모이제이션된 값 | 복잡한 계산 결과 재사용, 불필요한 계산 방지 |
useCallback | 함수 메모이제이션 | 의존성 변경 시에만 함수 재생성, 자식 컴포넌트 최적화 | 메모이제이션된 함수 | 자식 컴포넌트에 콜백 전달 시 불필요한 리렌더링 방지 |
useReducer | 복잡한 상태 로직 관리 (useState 대안) | 상태 업데이트 로직 분리(reducer), 중앙 집중 관리 | [상태값, dispatch 함수] (배열) | 여러 상태가 연관된 복잡한 로직, 상태 관리 중앙화 |
훅들을 적재적소에 활용한다면, 여러분의 리액트 코드는 더욱 간결해지고, 성능은 향상되며, 유지보수는 용이해질 것입니다.
처음에는 어떤 훅을 언제 써야 할지 조금 헷갈릴 수도 있지만,
중요한 것은 '왜' 이 훅을 사용하는지 이해하고 문제 상황에 가장 적합한 훅을 선택하는 능력을 기르는 것입니다.
그럼 즐거운 개발 되시길 바랍니다!🚀😉
Bye. #{Somebody}