TypeScript의 개념과 접목해 React Hook에 대해 정리해보겠습니다. 먼저, React Hook은 왜 생겨나게 된 것일까요?
결론부터 말씀드리면, 리액트 16.8부터는 기존 클래스 컴포넌트의 단점을 해결하기 위해 탄생했습니다. 기존 클래스 컴포넌트는 아래와 같은 단점을 가집니다.
1) 컴포넌트 간 상태 로직 재사용이 어렵다
2) 생명주기 메서드에 서로 관련없는 로직들이 얽혀 코드의 복잡성이 증가된다.
아래 예시 코드를 보며 클래스 컴포넌트의 단점을 좀 더 자세히 살펴보겠습니다.
// 기존 클래스 컴포넌트의 예시
class UserDashboard extends React.Component {
constructor(props) {
super(props);
this.state = {
userData: null,
notifications: [],
themeMode: 'light'
};
}
componentDidMount() { // 👈
// 사용자 데이터 로딩
this.fetchUserData();
// 알림 설정
this.setupNotifications();
// 테마 설정
this.setupTheme();
}
componentWillUnmount() { // 👈
// 여러 정리 작업들이 한 곳에 모여있음
this.clearNotifications();
this.removeThemeListener();
this.disconnectUserSession();
}
// 메서드들이 여러 곳에 흩어져 있음
fetchUserData() { ... }
setupNotifications() { ... }
setupTheme() { ... }
render() { ... }
}
기존의 클래스 컴포넌트에서는 componentDidMount, componentWillUnmount, componentDidUpdate와 같이 하나의 생명주기 함수에서만 상태 업데이트에 따른 로직을 실행시킬 수 있었습니다.
이러한 코드는 관련 없는 로직들이 한 생명주기 메서드에 섞여 있어야 했고, 로직 재사용이 어려우며, 코드가 길어지면 길어질 수록 유지보수가 어렵다는 단점이 있었습니다.
모든 상태를 하나의 함수 내에서 처리하다보니 관심사가 뒤섞이게 되어 상태에 따른 테스트나 잘못 발생한 사이드 이펙트의 디버깅이 어려워졌습니다. 이러한 점은 프로젝트의 규모가 커질수록 불편해졌습니다.
componentWillUnmount에선 componentDidMount에서 정의한 컴포넌트가 Dom에서 해제될 때 실행되어야 할 여러 사이 이펙트 함수를 호출하는데, 이는 여러 문제를 야기할 수 있습니다.
예를 들어, componentWillUnmount에서 실행되어야 할 코드가 하나 빠졌다면 componentDidMount와 비교해가며 어떤 함수가 빠졌는지 비교하면서 찾아야 하는 상황이 발생할 수 있습니다.
💡함수 컴포넌트에 리액트 훅을 적용한다면?
하지만 리액트 훅의 도입으로 함수 컴포넌트에서도 클래스 컴포넌트와 같이 컴포넌트 생명주기에 맞춰 로직을 실행할 수 있게 되었고, 코드를 재사용하거나 작은 단위로 분할해 테스트하는 것이 용이해졌습니다.
앞의 코드를 훅을 적용해 정리한 코드를 보며 좀 더 자세히 비교해볼게요.
function UserDashboard() {
// 각각의 관심사별로 상태와 효과를 분리
// 사용자 데이터 관련
const [userData, setUserData] = useState(null);
useEffect(() => {
const fetchData = async () => {
const data = await fetchUserData();
setUserData(data);
};
fetchData();
}, []);
// 알림 관련
const [notifications, setNotifications] = useState([]);
useEffect(() => {
const notification = setupNotifications();
return () => clearNotifications(notification);
}, []);
// 테마 관련
const [themeMode, setThemeMode] = useState('light');
useEffect(() => {
const theme = setupTheme();
return () => removeThemeListener(theme);
}, []);
return (...);
}
1️⃣ useState
컴포넌트 상태 관리를 위한 훅 입니다.
➖타입 정의
function useState<S>(
initialState: S | (() => S)
): [S, Dispatch<SetStateAction<S>>];
- S: 상태의 타입을 나타내는 제네릭
- initialState: 초기 상태값 또는 초기 상태를 반환하는 함수
- 반환값: [상태값, 상태 업데이트 함수]를 포함하는 튜플
- Dispatch<SetStateAction<S>>: 상태 업데이트 함수의 타입
➖useState에 TypeScript를 사용하면 좋은 이유
TypeScript를 useState를 적용하면 아래와 같이 필수 데이터의 누락을 방지해 실수를 방지하고, 잘못된 타입의 데이터가 입력되는 것을 컴파일 단계에서 방지함으로서 타입 안정성을 확보할 수 있습니다.
interface User {
name: string;
age: number;
email: string;
}
const [user, setUser] = useState<User>({
name: '',
age: 0,
email: ''
});
// ❌ 아래 코드는 컴파일 에러 발생
setUser({
name: 'John' // Property 'age' is missing
// Property 'email' is missing
});
2️⃣ useEffect
렌더링 이후 어떤 일을 수행해야하는 지 알려주기 위해 useEffect를 사용할 수 있습니다.
➖타입 정의
useEffect의 콜백함수에는 비동기 함수가 들어갈 수 없습니다. useEffect에서 비동기함수를 호출할 수 있다면 경쟁 상태*를 불러일으킬 수 있기 때문입니다.
경쟁상태(Race Condition)란?
멀티스레딩 환경에서 동시에 여러 프로세스 또는 스레드가 공유된 자원에 접근하려고 할 때 발생할 수 있는 문제로, 실행 순서나 타이밍 예측이 불가해져 프로그램 동작이 원치않는 방향으로 흘러갈 수 있습니다.
function useEffect(
effect: () => void | (() => void | undefined),
deps?: ReadonlyArray<any>
): void;
- effect: 실행할 효과 함수
- deps: 의존성 배열 (선택적)
- ReadonlyArray<any>: 읽기 전용 배열 타입
- 반환값: void (없음)
- 클린업 함수를 반환할 수 있음
➖useEffect 실행 조건
useEffect의 두번째 인자인 deps는 옵셔널하게 제공되고, deps 배열의 원소가 변경되면 실행한다는 식으로 사용합니다.
deps에 숫자, 문자열 같은 타입스크립트의 기본 자료형이 아닌 객체나 배열을 넣을 때는 주의해야하는데,useEffect는 deps가 변경되었는지를 얕은 비교로만 판단하므로, 실제 객체 값이 바뀌지 않았더라도 객체 참조값이 변경되면 콜백함수가 실행됩니다.
이를 위해선 실제 사용하는 값을 useEffect의 deps에 사용해야 합니다.
➖deps가 빈 배열일 땐 ...
useEffect는 Destructor를 반환하는데, 이것은 컴포넌트가 언마운트 될 때 실행되는 함수입니다.
하지만, deps가 빈 배열이라면 useEffect의 콜백함수는 컴포넌트가 처음 렌더링 될 때만 실행되며 Destructor (클린업 함수라고도 함)는 컴포넌트가 마운트 해제될 때 실행됩니다.
하지만, deps 배열이 존재한다면 배열의 값이 변경될 때마다 Destructor가 실행됩니다.
개념 정리 ( 얕은 비교, 클린업 함수)
얕은 비교 (shallow compare)
객체나 배열과 같은 복합 데이터 타입 값을 비교할 때, 내부의 각 요소나 속성을 재귀적으로 비교하지 않고, 해당 값들의 참조나 기본적인 타입 값만을 간단하게 비교하는 것
※ 이러한 특징은 useMemo나 useCallback과 같은 다른 훅에서도 동일하게 적용됨.
클린업 함수
컴포넌트 해제 전에 정리 작업을 수행하기 위한 함수 입니다.
➖사용 예시
function DataFetcher() {
const [data, setData] = useState<string[]>([]);
// 1. 기본적인 데이터 fetching
useEffect(() => {
const fetchData = async () => { // 별도의 async 함수 선언
try {
const response = await fetch('api/data');
const json = await response.json();
setData(json);
} catch (error) {
console.error('데이터 로딩 실패:', error);
}
};
fetchData(); // 함수 호출
return () => {// 👈 클린업 함수
// 정리 작업 수행
setData([]); // 데이터 초기화
};
}, []); // 👈 빈 deps 배열 = 마운트 시 1회만 실행
// 2. deps 배열 사용 시 주의사항
const [user, setUser] = useState({ id: 1, name: 'John' });
// 🔴 잘못된 사용 - 객체를 직접 deps에 넣음
useEffect(() => {
console.log(user);
}, [user]); // user 객체의 참조가 변경될 때마다 실행
// 🟢 올바른 사용 - 실제 사용하는 값만 deps에 넣음
useEffect(() => {
console.log(user.name);
}, [user.name]); // name이 변경될 때만 실행
return <div>{/* 렌더링 로직 */}</div>;
}
3️⃣ useLayoutEffect
useLayoutEffect의 타입 정의는 useEffect와 동일하고, 역할의 차이만 있습니다. 좀 더 정확히는 콜백 함수의 실행 시점이 다릅니다.
➖타입 정의
// useLayoutEffect 타입 정의
function useLayoutEffect(
effect: () => void | (() => void),
deps?: ReadonlyArray<any>
): void;
- useEffect와 동일한 타입 구조
- 실행 시점만 다름 (DOM 업데이트 직후, 브라우저 페인팅 전)
useEffect는 앞서 살펴본 componentDidUpdate와 같은 기존 생명주기 함수와는 다르게, 레이아웃 배치와 화면 렌더링이 모두 완료된 이후에 실행됩니다.
⭐ 꼭 알아두기 ! useEffect와 useLayoutEffect의 실행순서
1) React가 가상 DOM을 업데이트
2) DOM이 업데이트됨
3) useLayoutEffect 실행 (동기적)
4) 브라우저 화면 그리기
5) useEffect 실행 (비동기적)
➖사용 예시
function NameDisplay({ userId }: { userId: string }) {
const [name, setName] = useState('');
// ❌ useEffect 사용 시
// 처음에 빈 이름이 표시되었다가 데이터를 가져온 후 업데이트됨
useEffect(() => {
const fetchName = async () => {
const data = await fetchUserName(userId);
setName(data.name);
};
fetchName();
}, [userId]);
// ✅ useLayoutEffect 사용 시
// DOM에 반영되기 전에 데이터를 가져와서 빈 이름이 표시되는 것을 방지
useLayoutEffect(() => {
const fetchName = async () => {
const data = await fetchUserName(userId);
setName(data.name);
};
fetchName();
}, [userId]);
return (
<div className="name-container">
<h2>{name || 'Loading...'}</h2>
</div>
);
}
하지만, useLayoutEffect 를 사용하면 화면에 해당 컴포넌트가 그려지기 전에 콜백함수를 실행하므로 첫 번째 렌더링 때 빈 이름이 뜨는 경우를 방지할 수 있습니다.
4️⃣ useMemo와 useCallback
두 훅 모두 이전에 생성된 값 또는 함수를 기억하며, 동일한 값과 함수를 반복해서 생성하지 않도록 해주는 훅입니다.
어떤 값을 계산하는데 오래걸릴 때나 렌더링이 자주 발생하는 form에서 유용하게 사용할 수 있습니다.
➖타입 정의
Copyfunction useMemo<T>(
factory: () => T,
deps: DependencyList
): T;
- T: 메모이제이션할 값의 타입을 나타내는 제네릭
- factory: 계산할 값을 반환하는 함수
- DependencyList: 의존성 배열 (ReadonlyArray<any>의 별칭)
- 반환값: 메모이제이션된 값
function useCallback<T extends Function>(
callback: T,
deps: DependencyList
): T;
- T extends Function: 함수 타입을 나타내는 제네릭
- callback: 메모이제이션할 콜백 함수
- DependencyList: 의존성 배열
- 반환값: 메모이제이션된 콜백 함수
두 훅 모두 useEffect와 유사하게 deps 배열을 가지고 있으며, 이 의존성이 변경되면 값을 다시 계산합니다. 얕은 비교를 수행하기 때문에 deps 배열이 변경되지 않았는데도 다시 계산되지 않도록 주의해야 합니다.
반대로, 의존성 배열이 비어있다면 최초 마운트 시점에 동작합니다. 이렇게 되면, 최초 마운트 시점에 값과 함수가 생성되어 메모리에 저장되고, 이후 렌더링 부터는 동일한 값과 함수가 참조되고 언마운트 시점까지 이를 유지합니다.
모든 값과 함수를 useMemo와 useCallback을 사용해 과도하게 메모이제이션 하면, 이는 메모리 사용량 증가를 초래해 컴포넌트의 성능 향상을 저해할 수 있습니다. 아래 예시 코드를 통해 자세히 살펴 보겠습니다.
※ 메모이제이션 : 이전에 계산된 값을 저장함으로써 같은 입력에 대한 연산을 다시 수행하지 않도록 최적화 하는 기술
➖예시 코드
각 훅의 사용 예시와 잘못된, 올바른 사용 예시를 함께 살펴보겠습니다.
function ExpensiveCalculator({ numbers, onResult }) {
// 1. useMemo 사용 예시
const sum = useMemo(() => {
console.log("Calculating sum..."); // 디버깅용
return numbers.reduce((acc, num) => acc + num, 0);
}, [numbers]); // numbers가 변경될 때만 재계산
// 2. useCallback 사용 예시
const handleClick = useCallback(() => {
onResult(sum);
}, [sum, onResult]); // sum이나 onResult가 변경될 때만 함수 재생성
// 3. 잘못된 사용 예시
const badExample = useMemo(() => {
return "Hello"; // 단순 문자열은 메모이제이션이 불필요
}, []);
// 4. 올바른 사용 예시
const expensiveValue = useMemo(() => {
let result = 0;
for (let i = 0; i < 1000000; i++) {
result += complexCalculation(i);
}
return result;
}, []); // 복잡한 계산은 메모이제이션이 유용
return (
<div>
<h2>Sum: {sum}</h2>
<button onClick={handleClick}>Send Result</button>
</div>
);
}