렌더 함수 내 컴포넌트 생성 안티패턴
렌더 함수 내부에서 컴포넌트를 정의할 때 발생하는 문제와 해결 방법
읽는 데 41분
- #react
- #antipattern
- #performance
- #component
이 문서의 목차
적용 환경: React 16.8+, TypeScript 4.0+
입력 필드에 타이핑하면 커서가 사라지는데, onChange 핸들러를 아무리 고쳐도 해결되지 않습니다. 부모 컴포넌트의 버튼을 누르면 자식 컴포넌트의 상태가 초기화되고, 빈 의존성 배열을 가진 useEffect가 왜인지 계속 실행됩니다.
이런 증상들은 코드에 문법적 오류가 없고 첫 렌더링에서는 정상 동작하기 때문에 원인을 찾기 어렵습니다. 문제는 상태 변경으로 리렌더링이 발생할 때 비로소 드러나며, 이 모든 증상의 공통 원인은 렌더 함수 내부에서 컴포넌트를 정의하는 것입니다.
React는 화면을 갱신할 때 이전 Virtual DOM과 새 Virtual DOM을 비교합니다. 이때 완벽한 비교 대신 두 가지 가정을 사용하는데, 타입이 다른 요소는 완전히 다른 트리를 만든다는 것과, key prop으로 어떤 요소가 동일한지 명시할 수 있다는 것입니다.
여기서 핵심은 "타입이 같다"는 판단 기준입니다. React는 컴포넌트의 함수 참조(===)가 동일한지 확인합니다. <ChildComponent />를 렌더링하면 React는 ChildComponent 함수의 참조를 기억해두었다가, 다음 렌더링에서 같은 참조인지 비교합니다. 일반적으로 컴포넌트 함수는 모듈이 로드될 때 한 번 정의되므로 참조가 변하지 않지만, 렌더 함수 내부에서 컴포넌트를 정의하면 상황이 달라집니다.
렌더 함수 내부에서 컴포넌트를 정의하면, 매 렌더링마다 JavaScript 엔진이 새로운 함수 객체를 생성합니다. 이 함수들은 코드가 동일하더라도 서로 다른 참조를 가지므로, React는 "이전 컴포넌트가 사라지고 새 컴포넌트가 나타났다"고 판단합니다.
문제가 되는 코드를 살펴보겠습니다.
function ParentComponent() {
const [count, setCount] = useState(0);
// ❌ 렌더 함수 내부에서 컴포넌트 정의
const ChildComponent = () => {
return <div>자식 컴포넌트</div>;
};
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
<ChildComponent />
</div>
);
}버튼을 클릭하면 ParentComponent가 리렌더되고, 그 과정에서 ChildComponent 함수가 새로 생성됩니다. React 입장에서는 이전 렌더링의 ChildComponent와 현재 렌더링의 ChildComponent가 서로 다른 참조를 가지므로, 기존 컴포넌트를 언마운트하고 새 컴포넌트를 마운트합니다.
이 현상은 단순한 "리렌더"가 아니라 "리마운트"입니다. 리렌더링은 기존 컴포넌트 인스턴스를 유지한 채로 Virtual DOM만 갱신합니다. 반면 리마운트는 기존 인스턴스를 완전히 파괴하고 처음부터 새로 만듭니다. 이 차이가 다양한 버그의 원인이 됩니다.
| 구분 | 리렌더링 (정상) | 리마운트 (문제) |
|---|---|---|
| 동작 | Virtual DOM 비교 후 변경 부분만 반영 | 기존 인스턴스 파괴 후 새로 생성 |
| 상태 | 유지됨 | 초기화됨 |
| ref | 유지됨 | 초기화됨 |
| 포커스 | 유지됨 | 손실됨 |
| useEffect | 의존성 변경 시에만 실행 | 클린업 후 재실행 |
함수 참조가 매번 바뀌면 어떤 일이 일어날까요? 겉보기에 서로 관련 없어 보이는 여러 버그가 모두 같은 원인에서 비롯됩니다.
폼을 구현할 때 입력 필드를 별도 컴포넌트로 분리하고 싶을 때가 있습니다. 부모 상태에 쉽게 접근하려고 렌더 함수 안에 정의하면, 타이핑 자체가 불가능해지는 상황이 발생합니다.
function SearchForm() {
const [query, setQuery] = useState('');
// ❌ 매 렌더링마다 새로 생성
const SearchInput = () => (
<input
placeholder="검색어 입력"
value={query}
onChange={e => setQuery(e.target.value)}
/>
);
return (
<form>
<SearchInput />
<button type="submit">검색</button>
</form>
);
}사용자가 한 글자를 입력하면 setQuery가 호출되어 SearchForm이 리렌더됩니다. 이때 SearchInput이 새로 생성되므로 기존 input DOM 노드가 제거되고 새 노드가 생성됩니다. 사용자 입장에서는 글자를 칠 때마다 커서가 사라지고, 다시 input을 클릭해야 합니다. 한 글자마다 이 과정을 반복해야 하니 정상적인 입력이 불가능합니다.
포커스 손실보다 더 혼란스러운 문제도 있습니다. 부모와 자식이 각각 상태를 가질 때, 부모의 상태 변경이 자식의 상태까지 초기화시킵니다.
function Parent() {
const [count, setCount] = useState(0);
// ❌ 매 렌더링마다 새로 생성
const Counter = () => {
const [localCount, setLocalCount] = useState(0);
return (
<button onClick={() => setLocalCount(c => c + 1)}>
로컬: {localCount}
</button>
);
};
return (
<div>
<button onClick={() => setCount(c => c + 1)}>부모: {count}</button>
<Counter />
</div>
);
}Counter 컴포넌트에서 "로컬" 버튼을 눌러 localCount를 증가시킨 후, "부모" 버튼을 누르면 localCount가 0으로 돌아갑니다. 사용자는 부모의 버튼을 눌렀을 뿐인데, 자식의 카운터가 0으로 돌아간 이유를 알 수 없습니다.
상태 문제를 넘어, API 호출에도 영향을 미칩니다. 빈 의존성 배열을 가진 useEffect가 부모가 리렌더될 때마다 실행됩니다. 개발자는 "마운트 시 한 번만 실행"을 의도했지만, 컴포넌트 자체가 매번 리마운트되기 때문입니다.
function Parent() {
const [data, setData] = useState(null);
// ❌ 매 렌더링마다 새로 생성
const DataFetcher = () => {
useEffect(() => {
console.log('API 호출 실행');
fetchData().then(setData);
}, []); // 빈 배열인데도 매번 실행됨!
return <div>로딩 중...</div>;
};
return <DataFetcher />;
}위 코드에서 DataFetcher는 Parent가 리렌더될 때마다 새로운 함수로 생성됩니다. React는 이전 DataFetcher를 언마운트하고 새 DataFetcher를 마운트하므로, 빈 의존성 배열을 가진 useEffect가 매번 실행됩니다. 의존성 배열 설정이 올바른데도 왜 계속 실행되는지 원인을 찾기 어렵습니다.
정보
이 외에도 애니메이션이 매번 처음부터 재생되거나, 차트가 깜빡이며 다시 로드되거나, 비디오 재생 위치가 초기화되는 등 다양한 문제가 발생합니다. 공통점은 모두 "컴포넌트가 유지되어야 하는 상황에서 파괴되고 다시 생성된다"는 것입니다.
지금까지 본 모든 문제의 해결책은 같습니다. 컴포넌트를 렌더 함수 밖에서 정의하면 함수 참조가 안정적으로 유지됩니다.
가장 일반적인 방법입니다. 파일 최상위에 두면 모듈 로드 시점에 한 번만 생성되어 참조가 변하지 않습니다.
// ✅ 파일 최상위에 정의
const SearchInput = ({ value, onChange }: SearchInputProps) => (
<input
placeholder="검색어 입력"
value={value}
onChange={e => onChange(e.target.value)}
/>
);
function SearchForm() {
const [query, setQuery] = useState('');
return (
<form>
<SearchInput value={query} onChange={setQuery} />
<button type="submit">검색</button>
</form>
);
}이전에 클로저로 접근하던 query와 setQuery는 이제 props로 전달합니다. 코드가 약간 길어지지만, 컴포넌트의 의존성이 명시적으로 드러나 오히려 이해하기 쉬워집니다.
컴포넌트가 복잡하거나 재사용이 필요하다면 별도 파일로 분리하는 것이 좋습니다.
interface SearchInputProps {
value: string;
onChange: (value: string) => void;
}
export const SearchInput = ({ value, onChange }: SearchInputProps) => (
<input
placeholder="검색어 입력"
value={value}
onChange={e => onChange(e.target.value)}
/>
);import { SearchInput } from './SearchInput';
export function SearchForm() {
const [query, setQuery] = useState('');
return (
<form>
<SearchInput value={query} onChange={setQuery} />
<button type="submit">검색</button>
</form>
);
}컴포넌트가 아닌 단순 JSX를 반환하는 함수는 렌더 내부에서 정의해도 괜찮습니다. 단, 이 함수 내에서는 훅을 사용할 수 없습니다.
function TodoList() {
const [items, setItems] = useState(['A', 'B', 'C']);
// ✅ 컴포넌트가 아닌 렌더 헬퍼 함수
const renderItem = (item: string, index: number) => (
<li key={item}>{index + 1}. {item}</li>
);
return <ul>{items.map(renderItem)}</ul>;
}renderItem은 React 컴포넌트가 아니라 JSX를 반환하는 일반 함수입니다. <RenderItem />처럼 컴포넌트로 호출하지 않고 renderItem()처럼 함수로 호출합니다. 이 패턴은 단순 반복 렌더링에 유용하지만, 상태나 생명주기가 필요하면 정식 컴포넌트로 분리해야 합니다.
컴포넌트를 외부로 옮기려고 하면 몇 가지 의문이 생깁니다. "부모 상태에 어떻게 접근하지?", "동적으로 컴포넌트를 바꿔야 하면?" 각 상황별 해결책을 살펴보겠습니다.
"컴포넌트 내부에 정의해야 부모 상태에 접근할 수 있지 않나요?"라고 생각할 수 있습니다. 하지만 Props로 명시적으로 전달하는 것이 올바른 방법입니다.
// ❌ 클로저로 접근
function Parent() {
const [count, setCount] = useState(0);
const Child = () => <div>Count: {count}</div>; // count에 직접 접근
return <Child />;
}
// ✅ Props로 전달
const Child = ({ count }: { count: number }) => <div>Count: {count}</div>;
function Parent() {
const [count, setCount] = useState(0);
return <Child count={count} />;
}Props 전달이 번거롭게 느껴질 수 있지만, 데이터 흐름이 명시적으로 드러나고 컴포넌트의 재사용성도 높아집니다.
조건에 따라 다른 컴포넌트를 렌더링해야 할 때, 내부에서 switch문으로 컴포넌트를 정의하는 실수를 하기 쉽습니다.
// ❌ 동적 컴포넌트를 내부에서 정의
function Form({ fieldType }: { fieldType: string }) {
const Field = () => {
switch (fieldType) {
case 'text': return <input type="text" />;
case 'number': return <input type="number" />;
default: return null;
}
};
return <Field />;
}
// ✅ 컴포넌트 맵 사용
const TextField = () => <input type="text" />;
const NumberField = () => <input type="number" />;
const fieldComponents: Record<string, React.ComponentType> = {
text: TextField,
number: NumberField,
};
function Form({ fieldType }: { fieldType: string }) {
const Field = fieldComponents[fieldType];
return Field ? <Field /> : null;
}컴포넌트 맵을 미리 정의해두면 동적 선택이 필요할 때도 안전하게 처리할 수 있습니다.
Higher-Order Component를 렌더 함수 내에서 호출하는 것도 같은 문제를 일으킵니다.
// ❌ HOC를 렌더 함수 내에서 호출
function Parent() {
const ThemedButton = withTheme(Button); // 매번 새 컴포넌트 생성
return <ThemedButton />;
}
// ✅ HOC는 컴포넌트 외부에서 호출
const ThemedButton = withTheme(Button);
function Parent() {
return <ThemedButton />;
}원인을 알았다면, 애초에 발생하지 않도록 막는 것이 최선입니다. ESLint 규칙으로 자동 감지하거나, DevTools로 리마운트 여부를 확인할 수 있습니다.
ESLint 규칙으로 사전 차단
가장 효과적인 예방법입니다.
eslint-plugin-react의no-unstable-nested-components규칙을 활성화하면 렌더 함수 내 컴포넌트 정의를 자동으로 감지합니다.javascript // .eslintrc.js module.exports = { rules: { 'react/no-unstable-nested-components': ['error', { allowAsProps: true }] } };React DevTools Profiler로 확인
이미 문제가 발생한 경우, React DevTools의 Profiler 탭에서 컴포넌트의 렌더링 유형을 확인합니다.
- 정상적인 리렌더: "update"로 표시
- 리마운트 발생: "mount"로 표시
부모가 리렌더될 때 자식이 "mount"로 표시되면 이 안티패턴을 의심해야 합니다.
useEffect 로그로 디버깅
증상이 의심되는 컴포넌트에 다음 코드를 추가하여 마운트/언마운트 주기를 확인합니다.
tsx useEffect(() => { console.log('mounted'); return () => console.log('cleanup'); }, []);부모 상태 변경 시 "cleanup" → "mounted"가 번갈아 출력되면 리마운트가 발생하고 있는 것입니다.
핵심 정리
렌더 함수 내부에서 컴포넌트를 정의하면, 매 렌더링마다 React가 이를 새로운 컴포넌트로 인식하여 기존 인스턴스를 파괴하고 새로 생성합니다. 컴포넌트는 항상 파일 최상위 레벨이나 별도 파일에 정의하고, 필요한 데이터는 Props로 전달합니다. ESLint 규칙을 활성화하여 실수를 사전에 방지하는 것도 중요합니다.
이 안티패턴이 까다로운 이유는 디버깅 방향을 잘못 잡기 쉽기 때문입니다. 상태 관리나 이벤트 핸들러를 의심하다 시간을 낭비하는 경우가 많습니다. 패턴을 알고 있으면 증상만 보고도 원인을 짐작할 수 있으므로, 팀원들과 이 지식을 공유하는 것이 좋습니다.
관련 문서
글쓴이 mirunamu00



