
Intro
다음은 실무에서 볼 수 있는 흔한 사례입니다. 어떤 문제가 있다고 생각하시나요?
오늘은 이 코드 스닛핏의 문제를 정의하고 개선해보도록 하겠습니다.
function SearchResult({ keyword }: { keyword: string }) {
const [items, setItems] = useState()
const DEBOUNCE_DELAY = 300;
const REQUEST_OPTIONS = useMemo(() => ({
credentials: "include",
cache: "no-store",
}), []);
const processResponse = useCallback((response: SearchResponse) => {
return response.items
.filter((item) => item.isActive)
.map((item) => ({
id: item.id,
title: item.title.trim(),
}));
}, []);
const debouncedFetch = useMemo(() =>
debounce(async (query: string) => {
const res = await fetch(`/api/search?q=${query}`, REQUEST_OPTIONS);
const data = await res.json();
const processed = processResponse(data);
setItems(processed);
}, DEBOUNCE_DELAY),
[DEBOUNCE_DELAY, REQUEST_OPTIONS, processResponse]);
useEffect(() => {
debouncedFetch(keyword);
return () => {
debouncedFetch.cancel();
};
}, [keyword, debouncedFetch]);
}
언뜻 보기에 모든 의존성 값을 성실하게 추가했고 불필요한 실행을 막기 위해 메모이제이션한 것을 볼 수 있습니다. 꽤 괜찮아 보이네요!
하지만 위 코드에서 몇 가지 질문이 생깁니다.
DEBOUNCE_DELAY, REQUEST_OPTIONS, processResponse를 컴포넌트 내부에 선언할 필요가 있을까요?
이들은 컴포넌트의 props와 state에 따라 정의가 변해야 하는 값들인가요?
아니면 렌더링과 무관하게 항상 동일해야 하는 값들인가요?
이번 글에서는 React 공식 문서가 정의하는 "반응형 값"의 개념을 바탕으로, 이 상황에서 왜 메모이제이션이 근본적인 해결책이 아니며 어떻게 코드의 반응성을 명확히 표현할 수 있는지 살펴보겠습니다.
Reactive Value와 Non-Reactive Value의 혼재
위 코드의 본질적 문제를 이해하기 위해서는 리액트에서 말하는 반응형값(reactive values)에 대해 이해해야합니다.
React 공식 문서는 reactive value를 다음과 같이 정의합니다.
반응형 값은 props, state 그리고 컴포넌트 바디에 선언된 모든 변수와 함수입니다.
Reactive values include props, state, and all the variables
and functions declared directly inside your component body
(출처: https://react.dev/reference/react/useEffect)
컴포넌트 내부에 선언된 모든 값은 반응형으로 간주됩니다.
- Props: 부모로부터 전달되어 렌더링마다 달라질 수 있습니다
- State: 컴포넌트 내부에서 변경되면 리렌더링을 유발합니다
- 컴포넌트 본문의 변수와 함수: 매 렌더링마다 새로 생성됨으로 달라질 수 있음을 가정합니다.
반대로 Non-reactive values는 컴포넌트 외부에 선언된 값들로 React의 렌더링 데이터 흐름에 영향을 주지 않습니다.
반응하지 않는 값, 즉 리액트는 렌더링마다 변경되지 않는 값이라는 의미로 해석합니다.
이제 다시 처음 보았던 SearchResult 컴포넌트로 돌아가보겠습니다.
function SearchResult({ keyword }: { keyword: string }) {
const DEBOUNCE_DELAY = 300; // 🔴 반응형값인가요?
const REQUEST_OPTIONS = useMemo(...); // 🔴 반응형값인가요?
const processResponse = useCallback(...); // 🔴 반응형값인가요?
}
코드를 다시 보면 반응형으로 선언될 필요가 없는 코드가 반응형 로직으로 선언됐네요. 덕분에 의존성 배열이 불필요하게 복잡해졌습니다.
위 코드를 좀 풀어서 이야기하면 이렇게 이야기 할 수 있습니다.
이 값들은 렌더링할 때 마다 props와 state에 의해서 변할 수 있는 반응형 값이니까,
메모이제이션이나 effect에서 반드시 참조해야 해!

위 코드의 의존성 관계를 보면 위와 같이 표현할 수 있습니다.
useEffect는 keyword와 debouncedFetch를 의존하고 있네요.
debouncedFetch는 다시 DEBOUNCE_DELAY, REQUEST_OPTIONS, processResponse를 의존하고 있고요.
의존 관계를 평탄화해보면 useEffect는 DEBOUNCE_DELAY, REQUEST_OPTIONS, processResponse, keyword에 의존하고 있고 이 4가지 의존성중 한 가지만 변경되어도 effect함수가 실행됩니다.
만약 useEffect가 예상치 못한 상황에 실행되는 문제가 발견됐다면 어떻게 해야할까요? 모든 의존성을 각각 확인해봐야합니다.
반응형 값과 비반응형 값을 구분해야 하는 이유가 여기에 있습니다. 불필요한 의존 관계를 제거하고 진짜 반응형 값만 노출시켜야 복잡도가 낮아집니다.
컴포넌트 내부에 선언된 변수는 Reactive Valeus입니다
렌더시 변경될 수 있는 값은 반응형 값(Reactvie Valeus)이며 컴포넌트 내부에서 선언된 값은 반응형 값으로 취급됩니다.
React 공식 문서의 ChatRoom 예시를 통해 이 개념을 이해해보겠습니다.
Reactive Value인 경우
function ChatRoom({ roomId }: { roomId: string }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]); // 🟢roomId는 prop이므로 의존성 필요
}
roomId는 props입니다. 부모가 다른 값을 전달하면 effect가 재실행됩니다.
Non-Reactive Value인 경우
const serverUrl = 'https://localhost:1234';
const roomId = 'music';
function ChatRoom() {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => connection.disconnect();
}, []); // 🟢roomId가 컴포넌트 외부에 있으므로 의존성 불필요
}
공식 문서는 이렇게 설명합니다:
Now that roomId is not a reactive value (and can't change on a re-render),
it doesn't need to be a dependency.
같은 이름 roomId지만:
- 컴포넌트 내부 (props): reactive → 의존성 필요
- 컴포넌트 외부 (상수): non-reactive → 의존성 불필요
코드의 위치가 리액트가 해석하는 값의 의미를 결정합니다.
함수도 동일한 원칙이 적용됩니다
// 🔴 컴포넌트 내부: 매 렌더링마다 새 함수 생성
function SearchResult({ keyword }: { keyword: string }) {
const processResponse = useCallback((response: SearchResponse) => {
return response.items
.filter((item) => item.isActive)
.map((item) => ({ id: item.id, title: item.title.trim() }));
}, []);
useEffect(() => {
// ...
}, [keyword, processResponse]); // 🔴 processResponse도 의존성!
}
// 🟢 컴포넌트 외부: 동일한 함수 참조
const processResponse = (response: SearchResponse) => {
return response.items
.filter((item) => item.isActive)
.map((item) => ({ id: item.id, title: item.title.trim() }));
};
function SearchResult({ keyword }: { keyword: string }) {
useEffect(() => {
// ...
}, [keyword]); // 🟢 processResponse는 의존성 아님
}
함수가 props나 state를 사용하지 않는다면, 컴포넌트 외부에 선언하는 것이 해당 함수의 성격을 정확히 반영합니다.
Non-Reactive Value를 명시적으로 분리하기
이제 처음의 SearchResult 컴포넌트를 개선해봅시다.
Before: 반응형 값이 이 불명확함
function SearchResult({ keyword }: { keyword: string }) {
const [items, setItems] = useState([]);
const DEBOUNCE_DELAY = 300;
const REQUEST_OPTIONS = useMemo(() => ({
credentials: "include",
cache: "no-store",
}), []);
const processResponse = useCallback((response: SearchResponse) => {
return response.items
.filter((item) => item.isActive)
.map((item) => ({
id: item.id,
title: item.title.trim(),
}));
}, []);
const debouncedFetch = useMemo(() =>
debounce(async (query: string) => {
const res = await fetch(`/api/search?q=${query}`, REQUEST_OPTIONS);
const data = await res.json();
const processed = processResponse(data);
setItems(processed);
}, DEBOUNCE_DELAY),
[DEBOUNCE_DELAY, REQUEST_OPTIONS, processResponse]);
useEffect(() => {
debouncedFetch(keyword);
return () => {
debouncedFetch.cancel();
};
}, [keyword, debouncedFetch]);
}
이제 문제가 보이시나요?
리액트 내부에 선언된 값들은 state와 props에 따라 변화가 필요한 값들만 정의되어야 합니다.
그런데 state, props의 변화에 반응(reactive)할 필요가 없는 코드가 섞여있네요
After: 반응성이 명확한 코드
// 컴포넌트 외부: Non-reactive values
const DEBOUNCE_DELAY = 300;
const REQUEST_OPTIONS = {
credentials: "include",
cache: "no-store",
} as const;
const processResponse = (response: SearchResponse) => {
return response.items
.filter((item) => item.isActive)
.map((item) => ({
id: item.id,
title: item.title.trim(),
}));
};
type DebounceCallback = (variables:{id:string, title:string}) => void;
const debouncedFetch = debounce(async (query: string, callback: DebounceCallback) => {
const res = await fetch(`/api/search?q=${query}`, REQUEST_OPTIONS);
const data = await res.json();
const processed = processResponse(data);
callback(processed);
}, DEBOUNCE_DELAY);
// 🟢 Reactive values만 포함
function SearchResult({ keyword }: { keyword: string }) {
const [items, setItmes] = useState([]);
useEffect(() => {
debouncedFetch(keyword, (result) => setItmes(result));
}, [keyword]); // 🟢 의존성이 명확함
}
리액트 공식 문서에서는 effect 의존성 제거하기 에서 정적 객체와 함수를 컴포넌트 외부로 이동하라 라고 가이드하고 있습니다.
개선후 아래와 같은 개선점이 생겼습니다.
useMemo,useCallback이 사라졌습니다- 데이터 패칭의 의존 관계가 단순해졌습니다. 이제
keyword에만 반응한다는 것이 명확합니다 - 컴포넌트 내부에 state와 props에 따라 반응해야 하는 코드만 남았습니다.
컴포넌트 인라인 함수를 외부로 추출했을 때의 이점
많은 개발자가 함수를 컴포넌트 내부에 정의하고 useCallback으로 감싸는 패턴을 사용합니다.
하지만 함수가 props나 state를 직접 사용하지 않는다면, 외부로 추출하는 것이 여러 이점을 제공합니다.
불필요한 메모이제이션 제거
// 🔴 Before: useCallback 필요
function Component() {
const formatDate = useCallback((date: Date) => {
return date.toISOString().split('T')[0];
}, []);
useEffect(() => {
const formatted = formatDate(new Date());
}, [formatDate]); // 의존성 필요
}
// ====================================================
// 🟢 After: 메모이제이션 불필요
const formatDate = (date: Date) => {
return date.toISOString().split('T')[0];
};
function Component() {
useEffect(() => {
const formatted = formatDate(new Date());
}, []); // formatDate는 의존성 아님
}
useCallback을 사용하지 않아도 됩니다. 함수는 이미 컴포넌트 외부에 있으므로 항상 동일한 참조를 유지합니다.
의존성이 단순화해진다.
// Before: 복잡한 의존성
useEffect(() => {
const result = transform(validate(normalize(data)));
}, [data, transform, validate, normalize]);
// After: 명확한 의존성
useEffect(() => {
const result = transform(validate(normalize(data)));
}, [data]); // transform, validate, normalize는 외부에
실제로 변하는 값(data)만 의존성에 남습니다. 의존성이 단순해지면서 어떤 값에 의존하고 있는지 명확해졌습니다.
함수 재사용성 향상된다
// 외부에 선언된 함수는 다른 컴포넌트에서도 사용 가능
const calculateDiscount = (price: number, rate: number) => {
return price * (1 - rate);
};
function ProductCard({ price }: { price: number }) {
const discounted = calculateDiscount(price, 0.1);
}
function CartSummary({ items }: { items: Item[] }) {
const total = items.reduce(
(sum, item) => sum + calculateDiscount(item.price, item.discount),
0
);
}
동일한 로직을 여러 컴포넌트에서 사용할 때, 함수를 복사할 필요가 없습니다.
테스트가 용의하다
// 컴포넌트와 독립적으로 테스트 가능
describe('processResponse', () => {
it('filters inactive items', () => {
const response = {
items: [
{ id: 1, title: 'Active', isActive: true },
{ id: 2, title: 'Inactive', isActive: false },
]
};
const result = processResponse(response);
expect(result).toHaveLength(1);
});
});
함수를 export하여 독립적으로 테스트할 수 있습니다. 컴포넌트를 렌더링할 필요가 없습니다.
추상화를 통해 컴포넌트의 역할이 뚜렷해졌다
// Before: 함수가 컴포넌트 내부에 흩어져 있음
function DataTable({ data }: { data: Item[] }) {
const sortData = useCallback(..., []);
const filterData = useCallback(..., []);
const validateData = useCallback(..., []);
const formatData = useCallback(..., []);
// 실제 렌더링 로직
// ...100줄
}
// After: 함수는 외부, 컴포넌트는 렌더링에만 집중
const sortData = (data: Item[]) => ...;
const filterData = (data: Item[]) => ...;
const validateData = (data: Item[]) => ...;
const formatData = (data: Item[]) => ...;
function DataTable({ data }: { data: Item[] }) {
// 30줄의 명확한 렌더링 로직
}
컴포넌트의 핵심 로직이 즉시 보입니다.
Non-reactive values 추출하기
아래 같은 로직들을 외부 추출해서 의존 관계를 단순하게 만드시길 바랍니다.
1. 상수
// ✅ 외부로
const MAX_FILE_SIZE = 5 * 1024 * 1024;
const DEBOUNCE_DELAY = 300;
const API_TIMEOUT = 5000;
2. 설정 객체
// ✅ 외부로
const FETCH_OPTIONS = {
credentials: "include",
cache: "no-store",
} as const;
const CHART_CONFIG = {
responsive: true,
maintainAspectRatio: false,
};
3. 순수 함수 (props/state 미사용)
사용하는 부분이 있더라도 매개변수를 통해 받는 식으로 의존 관계를 역전시키면 순수함수로 표현 가능해집니다.
// ✅ 외부로
const normalizeText = (text: string) => text.trim().toLowerCase();
const formatCurrency = (amount: number) => `$${amount.toFixed(2)}`;
const calculateTotal = (items: Item[]) =>
items.reduce((sum, item) => sum + item.price, 0);
4. 검증 함수
// ✅ 외부로
const validateEmail = (email: string) => {
const regex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return regex.test(email);
};
const isValidFileType = (file: File) => {
const allowedTypes = ['image/jpeg', 'image/png'];
return allowedTypes.includes(file.type);
};
마무리
"리액트가 상태에 반응한다"라는 의미를 곱씹어 보며 컴포넌트의 반응 관계가 명시적으로 표현될 수 있도록 비 반응형 로직(Non-reactive values)의 격리하는 과정을 보여드렸습니다. 이 과정을 통해 컴포넌트의 의존 관계가 단순해지면서 effect의 실행을 추적하기 쉬워졌습니다. 저도 처음에는 커스텀 훅 내부에 많은 함수를 정의했습니다. 그 당시에는 co-location의 관점이었습니다. "음! 응집됐군!"이라고 했던 것 같습니다. 현재의 코드 스타일은 지금까지 언급드린 것처럼 반응 관계를 명시하기 위해 정적 요소를 분리하는 것에 집중하고 있습니다. 덕분에 호출 관계가 단순해지면서 리액트의 데이터 흐름이 명확해지는 효과를 얻을 수 있었습니다.
가장 어려운 부분은 시시각각 동적인 부분에서 원인을 추적하는 것입니다. 매개변수에 따라 순수하게 응답값을 생성하는 함수는 관리하기 용의합니다. 꼭 필요한 값만 reactive valeus로 선언하는 습관을 만드신다면 보다 즐거운 코딩이 되실 것 같습니다.
'react' 카테고리의 다른 글
| 리액트의 동시성에 따른 tearing이슈 (0) | 2026.01.12 |
|---|---|
| 컴포넌트에서 서버 데이터 가져오기 (0) | 2026.01.05 |
| 함수 컴포넌트 시대에 돌아보는 class 문법 (0) | 2025.12.24 |
| 함수 컴포넌트와 클로저 (0) | 2025.12.23 |
| React에서 상태를 초기화하는 기준 (0) | 2025.12.22 |