일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | ||||
4 | 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | 22 | 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 |
- useLayoutEffect
- 클래스
- 초기마운트
- msw
- 리액트 훅
- ErrorBoundary
- key
- Firebase
- 리액트
- Database
- lazy()
- 모던자바스크립트
- express
- react-hook-form
- docker
- useEffect
- 리액트훅
- CSR
- reactquery
- react-hook
- next-cookies
- react
- NextJs
- SSR
- Today
- Total
한우의 개발일기
useAsync 파해치기 본문
useAsync 훅 분석
import { useEffect, useRef, useState } from 'react'
export function useAsync<T>(
func: () => Promise<T> | undefined | null,
deps = [],
) {
const ref = useRef(func)
ref.current = func
const [value, setValue] = useState<T>()
useEffect(() => {
ref.current()?.then(setValue)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps)
return value
}
이 코드의 핵심 동작을 단계별로 보면
1. ref 객체 생성과 최신 함수 참조 유지
const ref = useRef(func)
ref.current = func
이 두 줄이 이 훅에서 가장 중요한 부분.
첫 번째 줄(
const ref = useRef(func)
)은 초기 렌더링 시mountRef()
함수를 통해{ current: func }
형태의 ref 객체를 생성합니다.두 번째 줄(
ref.current = func
)은 매 렌더링마다ref.current
를 최신func
함수로 업데이트합니다. 이것은 컴포넌트가 리렌더링될 때마다 실행됩니다.
2. useEffect에서의 ref 사용
useEffect(() => {
ref.current()?.then(setValue)
}, deps)
이 useEffect
는 deps
배열의 값이 변경될 때만 실행됩니다. 그러나 중요한 점은 ref.current
를 통해 항상 최신 함수를 참조한다는 것입니다.
왜 useRef를 사용했는가?
이 코드에서 useRef를 사용한 두 가지 주요 이유를 이해하기 위해 React의 내부 동작을 생각해봅시다
1. 최신 함수 참조 유지
먼저 일반적인 클로저 문제를 살펴보겠습니다. React에서 함수형 컴포넌트는 매 렌더링마다 새로 실행됩니다. 그리고 useEffect
의 콜백은 해당 렌더링 시점의 값들을 클로저로 캡처합니다.
// 클로저 문제가 있는 예시
useEffect(() => {
func()?.then(setValue) // 항상 첫 렌더링 시점의 func를 참조
}, [deps]) // func를 의존성 배열에 넣지 않음
이 경우, useEffect
내부의 func
는 항상 첫 렌더링 시점의 함수를 참조합니다. 만약 부모 컴포넌트가 이 함수를 새로 만들어 전달한다면, deps
가 변경되어 useEffect
가 재실행되더라도 여전히 오래된 함수를 사용하게 됩니다.
useRef를 사용하면 이 문제를 해결할 수 있습니다:
ref.current = func
는 매 렌더링마다 최신 함수로 업데이트됩니다.useEffect
내부에서는ref.current
를 통해 항상 최신 함수를 참조합니다.
2. 리렌더링 방지
만약 func
를 useEffect
의 의존성 배열에 넣는다면:
useEffect(() => {
func()?.then(setValue)
}, [func, ...otherDeps]) // func를 의존성 배열에 포함
이렇게 하면 func
가 변경될 때마다 useEffect
가 재실행됩니다. 문제는 React에서 컴포넌트가 리렌더링될 때마다 함수가 새로 생성된다는 것입니다. 이는 불필요한 useEffect
실행과 최악의 상황에서는 무한 루프를 초래할 수 있습니다.
useRef를 사용하면
ref.current
의 변경은 리렌더링을 트리거하지 않습니다(React의updateRef()
함수는 기존 ref 객체를 그대로 반환).- 함수가 새로 생성되더라도
ref.current
에 할당하는 것은 리렌더링을 유발하지 않으므로 안전합니다.
내부 동작 상세 설명
React의 내부 구현 관점에서 볼 때
- 최초 렌더링 시
mountRef(func)
가 호출되어{ current: func }
객체가 생성됩니다. - 이후 렌더링에서는
updateRef(func)
가 호출되지만, 반환값은 여전히 첫 렌더링에서 생성된 동일한 ref 객체입니다. ref.current = func
코드는 단순히 JavaScript 객체의 속성을 변경하는 것이며, React의 상태 시스템(useState, useReducer 등)을 거치지 않기 때문에 리렌더링을 유발하지 않습니다.useEffect
내부에서는ref.current
를 통해 항상 최신 함수에 접근할 수 있습니다.
정리: useRef 사용의 두 가지 이점
최신 함수 참조
ref.current
는 항상 최신 함수를 가리킵니다. 이는useEffect
의 의존성 배열에 함수를 포함시키지 않으면서도 항상 최신 함수를 사용할 수 있게 합니다.불필요한 리렌더링 방지
ref.current
의 변경은 리렌더링을 유발하지 않기 때문에, 함수가 매 렌더링마다 새로 생성되더라도 안전하게 최신 참조를 유지할 수 있습니다.
이러한 방식으로 useAsync
훅은 비동기 함수를 안전하게 실행하면서, 불필요한 리렌더링과 클로저 문제를 모두 피할 수 있는 훅이었습니다
다른 방식과 예시
이해를 돕기 위해 useRef
를 사용한 방식과 그렇지 않은 방식의 예시를 비교해 보겠습니다.
예시 : 데이터 가져오기
사용자 ID가 변경될 때마다 사용자 정보를 가져오는 컴포넌트를 만들어 보겠습니다.
예시 1: useRef를 사용하지 않은 방식
function UserProfile({ userId, onDataFetched }) {
const [user, setUser] = useState(null);
// 사용자 데이터를 가져오는 함수
const fetchUserData = async () => {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
setUser(data);
onDataFetched(data); // 부모 컴포넌트에 데이터가 로드됐음을 알림
};
useEffect(() => {
fetchUserData();
}, [userId]); // fetchUserData는 의존성 배열에 포함되지 않음
return <div>{user ? user.name : "로딩 중..."}</div>;
}
이 코드의 문제점
클로저 문제
useEffect
내부의fetchUserData
는 첫 번째 렌더링 시점의 함수를 참조합니다. 만약onDataFetched
콜백이 변경된다면,fetchUserData
함수는 여전히 예전onDataFetched
함수를 호출합니다.불완전한 의존성 배열 ESLint 규칙은
fetchUserData
를 의존성 배열에 포함하라고 경고할 것입니다.
예시 2: fetchUserData를 의존성 배열에 추가한 경우
function UserProfile({ userId, onDataFetched }) {
const [user, setUser] = useState(null);
// 사용자 데이터를 가져오는 함수
const fetchUserData = async () => {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
setUser(data);
onDataFetched(data);
};
useEffect(() => {
fetchUserData();
}, [userId, fetchUserData]); // fetchUserData를 의존성 배열에 추가
return <div>{user ? user.name : "로딩 중..."}</div>;
}
이 코드의 문제점
무한 루프
fetchUserData
는 매 렌더링마다 새로 생성됩니다. 의존성 배열에 이 함수를 포함시키면, 다음과 같은 순환이 발생합니다:- 컴포넌트 렌더링 → 새
fetchUserData
생성 →useEffect
실행 →setUser
호출 → 컴포넌트 리렌더링 → 새fetchUserData
생성 → ...
- 컴포넌트 렌더링 → 새
불필요한 API 호출 사용자 ID가 변경되지 않았음에도 불구하고 매 렌더링마다 API 호출이 발생합니다.
예시 3: useCallback으로 해결 시도
function UserProfile({ userId, onDataFetched }) {
const [user, setUser] = useState(null);
// useCallback으로 함수 메모이제이션
const fetchUserData = useCallback(async () => {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
setUser(data);
onDataFetched(data);
}, [userId, onDataFetched]);
useEffect(() => {
fetchUserData();
}, [fetchUserData]);
return <div>{user ? user.name : "로딩 중..."}</div>;
}
이 접근 방식의 문제점
의존성 전파
onDataFetched
가 변경될 때마다 새로운fetchUserData
가 생성되고, 이로 인해useEffect
가 다시 실행됩니다. 부모 컴포넌트가 리렌더링될 때마다 새로운onDataFetched
함수를 생성하면(인라인 함수 사용 시 흔한 패턴), 이 문제가 더 심각해집니다.복잡성 증가 간단한 작업을 위해
useCallback
을 추가로 사용해야 합니다.
예시 4: useRef를 사용한 해결책 (useAsync 훅 사용)
function useAsync(func, deps = []) {
const ref = useRef(func);
ref.current = func; // 최신 함수 참조 유지
const [state, setState] = useState();
useEffect(() => {
ref.current()?.then(setState);
}, deps);
return state;
}
function UserProfile({ userId, onDataFetched }) {
// useAsync 훅 사용
const user = useAsync(async () => {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
onDataFetched(data);
return data;
}, [userId]); // onDataFetched는 의존성 배열에 포함되지 않음
return <div>{user ? user.name : "로딩 중..."}</div>;
}
이 접근 방식의 장점
항상 최신 함수 사용
ref.current
는 항상 최신 함수를 참조하므로, 최신onDataFetched
콜백을 사용합니다.불필요한 API 호출 방지
userId
가 변경될 때만 API 호출이 발생합니다.onDataFetched
가 변경되더라도 추가 API 호출은 없습니다.의존성 배열 단순화
onDataFetched
를 의존성 배열에 포함시킬 필요가 없습니다.코드 가독성 향상 커스텀 훅으로 로직을 추상화하여 컴포넌트 코드가 더 깔끔해집니다.
실제 동작 비교
이제 실제로 어떻게 동작하는지 시간에 따른 시퀀스로 비교해 보겠습니다:
부모 컴포넌트가 리렌더링되어 새 onDataFetched 함수가 생성됨
useRef를 사용하지 않은 접근법 (예시 1)
- 부모 컴포넌트 리렌더링
- 새
onDataFetched
함수 생성 및 자식 컴포넌트로 전달 - 자식 컴포넌트 리렌더링
- 새
fetchUserData
함수 생성 useEffect
는 재실행되지 않음 (의존성 배열에userId
만 포함됨)- 데이터 로드 완료 후 이전 onDataFetched 함수 호출 (클로저 문제)
useRef를 사용한 접근법 (예시 4)
- 부모 컴포넌트 리렌더링
- 새
onDataFetched
함수 생성 및 자식 컴포넌트로 전달 - 자식 컴포넌트 리렌더링
- 새 비동기 함수 생성
ref.current
가 새 함수로 업데이트됨useEffect
는 재실행되지 않음 (의존성 배열에userId
만 포함됨)- 기존 비동기 작업이 완료되면
ref.current()
를 통해 최신 함수 호출
이 비교를 통해 useRef
를 사용한 접근법이 클로저 문제를 어떻게 해결고 불필요한 리렌더링과 API 호출을 방지하는지 알 수 있을겁니다.
정리
useRef
를 사용한 접근법은 다음과 같은 상황에서 유용할듯 합니다
- 비동기 작업에서 항상 최신 함수나 값을 참조해야 할 때
- 의존성 배열에 함수를 포함시키지 않으면서도 클로저 문제를 해결하고 싶을 때
- 불필요한 리렌더링이나 API 호출을 방지하고 싶을 때
'React' 카테고리의 다른 글
Errorboundary 내부동작 이해하기 (1) | 2025.03.23 |
---|---|
useEffect vs useLayoutEffect (0) | 2025.03.21 |
useRef() 파해치기 (0) | 2025.03.18 |
리액트의 초기 마운트 (0) | 2025.02.13 |
리액트의 내부개요 (0) | 2025.02.13 |