| title | React |
|---|
이 가이드는 React를 사용한 프론트엔드 개발 시 참고해야 할 가이드 및 모범 사례와 주요 개념들을 다룹니다.
React 개발 시 우리 팀이 선호하는 방식과 규칙들입니다:
로컬 상태과 전역 상태, 서버 상태를 잘 구분하여 사용합니다. 그래서 전역 상태 라이브러리(Zustand)를 꼭 써야하는 환경에서만 사용합니다.
- 로컬 상태:
useState/useReducer사용 - 전역 상태: Zustand 사용
- 서버 상태: React Query 사용
- Context API: 테마/인증 등 정적인 값에만 사용
useEffect가 정말 필요한지 검토하기
// ❌ 잘못된 사용: 렌더링과 관련된 계산에 useEffect 사용
function ProductPrice({ price, discount }) {
const [finalPrice, setFinalPrice] = useState(0);
useEffect(() => {
setFinalPrice(price * (1 - discount));
}, [price, discount]);
return <div>{finalPrice}원</div>;
}
// ✅ 올바른 사용: 렌더링 중에 직접 계산
function ProductPrice({ price, discount }) {
const finalPrice = price * (1 - discount);
return <div>{finalPrice}원</div>;
}
// ✅ 올바른 useEffect 사용: 외부 시스템과의 동기화
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
return <div>채팅방 {roomId}</div>;
}// ❌ 잘못된 사례: useEffect로 데이터 변환
function ProductList({ products }) {
const [formattedProducts, setFormattedProducts] = useState([]);
useEffect(() => {
const formatted = products.map((product) => ({
...product,
price: `${product.price.toLocaleString()}원`,
isOnSale: product.discountRate > 0,
}));
setFormattedProducts(formatted);
}, [products]);
return (
<ul>
{formattedProducts.map((product) => (
<li key={product.id}>
{product.name} - {product.price}
</li>
))}
</ul>
);
}
// ✅ 올바른 사례: 렌더링 중에 직접 데이터 변환
function ProductList({ products }) {
const formattedProducts = products.map((product) => ({
...product,
price: `${product.price.toLocaleString()}원`,
isOnSale: product.discountRate > 0,
}));
return (
<ul>
{formattedProducts.map((product) => (
<li key={product.id}>
{product.name} - {product.price}
</li>
))}
</ul>
);
}// 잘못된 사례
useEffect(() => {
const computedValue = calculateSomething(props.data);
setState(computedValue);
}, [props.data]);
// 올바른 사례
const computedValue = useMemo(
() => calculateSomething(props.data),
[props.data]
);// 잘못된 사례
useEffect(() => {
fetchData();
setupEventListener();
}, []);
// 올바른 사례
useEffect(() => fetchData(), []);
useEffect(() => setupEventListener(), []);// ❌ 잘못된 사례: useEffect로 이벤트 처리
function SaveButton({ data }) {
useEffect(() => {
if (data.isDirty) {
saveData();
showToast("저장되었습니다");
}
}, [data.isDirty]);
return <button>저장</button>;
}
// ✅ 올바른 사례: 이벤트 핸들러에서 직접 처리
function SaveButton({ data }) {
const handleSave = async () => {
await saveData();
showToast("저장되었습니다");
};
return <button onClick={handleSave}>저장</button>;
}컴포넌트 구조는 성급하게 쪼개지 않습니다. 중복을 허용하더라도 읽기 쉬운 컴포넌트 작성을 우선합니다.
- 컴포넌트는 기본적으로 함수형 컴포넌트로 작성
- Props 타입은 항상 명시적으로 정의
- 컴포넌트 파일은 단일 책임 원칙 준수
- 스타일은 CSS-in-JS로 관리 (styled-components)
useMemo, useCallback, React.memo 등 메모아이제이션 최적화는 실제 성능 이슈가 발생한 경우에만 사용합니다.
다만, 성능에 영향을 줄 가능성이 매우 높은 곳에는 미리 적용할 수 있습니다.
- 무분별한 최적화는 지양 (premature optimization)
- React.memo는 실제 성능 이슈가 있을 때만 사용
- 큰 리스트는 가상화 (react-window/react-virtualized)
- 이미지는 lazy loading 적용
UI를 담당하는 컴포넌트와 비지니스를 담당하는 커스텀 훅, 유틸리티, 상수 등을 적절히 분리하여 사용합니다.
- 비즈니스 로직은 custom hooks로 분리
- 재사용 가능한 UI는 컴포넌트로 분리
- 유틸리티 함수는 별도 모듈로 관리
- 상수는 constants 파일로 분리
통합 테스트 위주로 테스트를 진행하며, E2E 테스트는 최소화합니다. 결국 단위 테스트가 가장 많고 그 다음 통합 테스트가 많습니다.
- 컴포넌트는 React Testing Library로 테스트
- 로직은 Vitest + RTL + MSW로 단위 테스트, 통합 테스트
- E2E 테스트는 Playwright 사용
- 스냅샷 테스트는 최소화
개발자 경험 향상과 레파지토리 관리 편의성을 위해 다음 도구를 사용합니다.
- ESLint + Prettier 사용
- Husky(pre-commit hook)로 커밋 전 린트/테스트 실행
- Chrome React DevTools 활용 권고
- React 개발 팁
- Provider 랩핑 헬을 피하세요. 대신 컴포지션을 사용하여 여러 Provider를 합치세요
- useEffect에서 ref 변경을 정확히 감지할 수 없습니다. 대신 ref callback 함수를 사용하세요.
- 대규모 리스트 렌더링을 virtualize하세요.
- prop drilling을 피하세요. 대신 Composition 컴포넌트를 활용하세요.
- Dynamic 컴포넌트 타입에 Any를 쓰지마세요. 대신 Generic Type을 쓰세요.
- Dynamic Prop 타입을 느슨하게 하지마세요. 대신 Union Type을 이용해 명시적으로 사용하세요.
- useState는 비동기라는 것을 기억하세요. 비동기 동작이기에 최신값이 아닐 수 있습니다. 상태를 직접 바라보고 최신값을 사용하세요.
- input 상태는 기본값을 넣어주어야 합니다. 타입에 맞는 기본값을 넣어주세요.
- useEffect 내에서 무한 루프를 주의하세요. useEffect를 사용하지 않고도 문제를 해결할수도 있습니다.
- 상태가 Object인 경우 새로운 값으로 migrate할 때 주의하세요. 반드시 immutable 한 값으로 처리하세요.(비구조화 등)
- 비즈니스 로직을 UI에서 분리하세요. 대신 custom hook과 util로 분리하세요.
- useState를 복잡한 상태 관리로 사용하는 것을 피하세요. 대신 useReducer를 사용하세요.
- 불필요한 useEffect 사용을 피하세요. 대신 그냥 최신 상태를 사용하세요.
- any를 사용하지 마세요. infer된 타입을 사용하세요.
- 리액트의 defaultProps를 사용하는 것을 피하세요. 대신 default params를 사용하세요.
- event 타입을 암묵적 any 타입으로 쓰는 것을 피하세요.
- 이벤트 핸들러를 useEffect 안에 넣는 것을 피하세요. 대신 이벤트 핸들러를 바로 사용하세요.
- 이른 최적화를 피하세요. React.memo는 최후 수단으로 사용하세요. composition, relocate state 등 다양한 방법을 우선 사용해보세요.
- 만약 useEffect를 통한 data fetch를 해야만 하는 경우 racing condition을 피하도록 abort controller API를 사용하세요.
- isLoading(boolean)을 별도 상태로 선언해서 사용하는 것을 피하세요. 대신 데이터를 직접 바라보고 source of truth관점에서 선언해서 사용하세요.
- 성급한 컴포넌트 쪼개기 방지
- useEffect가 필요하지 않은 경우
- 안티 패턴
useState대신 변수 사용 금지- 컴포넌트 외부에 CSS 선언
useCallback으로 함수 재생성 방지useCallback으로 의존성 변경 방지useCallback으로useEffect트리거 방지useEffect에 빈 의존성 배열 추가 (의존성 없을 때만)- 모든 의존성을
useEffect에 명시 - 외부 코드 초기화에
useEffect사용 금지 - 외부 함수를
useCallback으로 감싸지 않기 - 빈 의존성 배열과
useMemo사용 금지 - 컴포넌트 내부에 컴포넌트 선언 금지
- 조건문/return 이후 훅 사용 금지
- 자식 컴포넌트가 렌더링 여부 결정하도록 허용
- 복수
useState대신useReducer사용 - 초기 상태를 객체 대신 함수로 작성
- 리렌더링 방지를 위해
useRef사용
- React 19
여러 Provider를 중첩해서 사용하면 코드가 복잡해지고 가독성이 떨어집니다.
// ❌ Provider 중첩 지옥
function App() {
return (
<ThemeProvider>
<AuthProvider>
<UserProvider>
<SettingsProvider>
<NotificationProvider>
<Router>
<Layout>
<MainContent />
</Layout>
</Router>
</NotificationProvider>
</SettingsProvider>
</UserProvider>
</AuthProvider>
</ThemeProvider>
);
}대신 Provider를 하나의 컴포넌트로 합치거나, 컴포지션 패턴을 사용하세요:
const providers = [
[ThemeProvider, themeProps],
[AuthProvider, authProps],
[UserProvider, userProps],
[SettingsProvider, settingsProps],
[NotificationProvider, notificationProps],
];
// ✅ 컴포지션 패턴 사용
const AppProviders = ({ children }) => {
return providers.reduce(
(acc, [Provider, props]) => <Provider {...props}>{acc}</Provider>,
children
);
};
function App() {
return (
<AppProviders>
<Router>
<Layout>
<MainContent />
</Layout>
</Router>
</AppProviders>
);
}useEffect는 ref 객체의 .current 속성 변경을 감지하지 못합니다. 대신 ref callback을 사용하세요:
// ❌ useEffect로는 ref 변경을 감지할 수 없음
function Example() {
const elementRef = useRef<HTMLDivElement>(null);
useEffect(() => {
// elementRef.current가 변경되어도 이 효과는 실행되지 않음
console.log(elementRef.current);
}, [elementRef.current]); // 의존성 배열에 ref.current를 넣어도 동작하지 않음
return <div ref={elementRef}>내용</div>;
}
// ✅ ref callback 함수 사용
function Example() {
const [height, setHeight] = useState(0);
const measureRef = useCallback((element: HTMLDivElement | null) => {
if (element) {
// element가 DOM에 마운트되거나 업데이트될 때마다 실행됨
setHeight(element.getBoundingClientRect().height);
}
}, []);
return <div ref={measureRef}>내용</div>;
}대규모 리스트를 렌더링할 때는 성능 최적화를 위해 가상화(virtualization)를 사용하세요. 가상화는 현재 화면에 보이는 항목만 렌더링하고 나머지는 필요할 때 렌더링하는 기법입니다.
// ❌ 모든 항목을 한 번에 렌더링
function LargeList({ items }) {
return (
<div>
{items.map((item) => (
<div key={item.id}>{item.content}</div>
))}
</div>
);
}
// ✅ react-window를 사용한 가상화
import { FixedSizeList } from "react-window";
function LargeList({ items }) {
const Row = ({ index, style }) => (
<div style={style}>{items[index].content}</div>
);
return (
<FixedSizeList
height={400}
width={300}
itemCount={items.length}
itemSize={35}
>
{Row}
</FixedSizeList>
);
}가상화를 위해 다음과 같은 라이브러리를 사용할 수 있습니다:
- react-window: 경량화된 가상화 라이브러리
- react-virtualized: 더 많은 기능을 제공하는 가상화 라이브러리
- react-virtual: 최신 훅 기반 가상화 라이브러리
prop drilling은 여러 계층의 컴포넌트를 거쳐 props를 전달하는 것을 의미합니다. 이는 코드의 유지보수를 어렵게 만들 수 있습니다.
// ❌ Prop Drilling - props가 여러 계층을 거쳐 전달됨
function TodoList({ todos, onToggle }) {
return (
<div>
<TodoItems todos={todos} onToggle={onToggle} />
</div>
);
}
function TodoItems({ todos, onToggle }) {
return todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} onToggle={onToggle} />
));
}
function TodoItem({ todo, onToggle }) {
return (
<div>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
{todo.text}
</div>
);
}
// ✅ Composition 패턴 - 컴포넌트를 조합하여 사용
function TodoList({ todos }) {
const handleToggle = (id) => {
// 토글 로직
};
return (
<div>
{todos.map((todo) => (
<TodoItem key={todo.id}>
<Checkbox
checked={todo.completed}
onChange={() => handleToggle(todo.id)}
/>
<span>{todo.text}</span>
</TodoItem>
))}
</div>
);
}
const TodoItem = ({ children }) => <div>{children}</div>;
const Checkbox = ({ checked, onChange }) => (
<input type="checkbox" checked={checked} onChange={onChange} />
);// ❌ any 타입 사용
const DynamicComponent = ({ as: Component, ...props }: { as: any }) => {
return <Component {...props} />;
};
// ✅ Generic Type 사용
type DynamicComponentProps<T extends React.ElementType> = {
as: T;
children?: React.ReactNode;
} & React.ComponentPropsWithoutRef<T>;
const DynamicComponent = <T extends React.ElementType = 'div'>({
as: Component,
children,
...props
}: DynamicComponentProps<T>) => {
return <Component {...props}>{children}</Component>;
};
// 사용 예시
<DynamicComponent as="button" onClick={() => {}} type="submit">
클릭
</DynamicComponent>
<DynamicComponent as="a" href="https://example.com">
링크
</DynamicComponent>// ❌ 느슨한 타입 정의
type ButtonProps = {
variant: string; // 어떤 문자열이든 허용됨
size: string;
};
// ✅ Union Type으로 명시적 정의
type ButtonProps = {
variant: 'primary' | 'secondary' | 'danger';
size: 'small' | 'medium' | 'large';
};
const Button = ({ variant, size, children }: ButtonProps) => {
return (
<button className={`btn-${variant} btn-${size}`}>
{children}
</button>
);
};
// 사용 예시
<Button variant="primary" size="medium">클릭</Button> // ✅ 타입 체크 통과
<Button variant="invalid" size="wrong">에러</Button> // ❌ 타입 에러// ❌ 이전 상태값을 참조할 수 있음
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
const isTwo = isCountTwo(count);
};
return <button onClick={handleClick}>{count}</button>;
}
// ✅ 업데이트 함수를 사용하여 최신 상태 보장
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
};
const isTwo = isCountTwo(count);
return <button onClick={handleClick}>{count}</button>;
}// ❌ 기본값 없이 input 상태 사용
const [text, setText] = useState<string>();
const [count, setCount] = useState<number>();
const [checked, setChecked] = useState<boolean>();
// ✅ 타입에 맞는 기본값 설정
const [text, setText] = useState<string>("");
const [count, setCount] = useState<number>(0);
const [checked, setChecked] = useState<boolean>(false);
// 사용 예시
function Form() {
const [name, setName] = useState<string>(""); // 빈 문자열로 초기화
const [age, setAge] = useState<number>(0); // 숫자 0으로 초기화
const [isAgree, setIsAgree] = useState<boolean>(false); // false로 초기화
return (
<form>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<input
type="number"
value={age}
onChange={(e) => setAge(Number(e.target.value))}
/>
<input
type="checkbox"
checked={isAgree}
onChange={(e) => setIsAgree(e.target.checked)}
/>
</form>
);
}// ❌ 무한 루프 발생
function Counter() {
const [value, setValue] = useState(0);
const [count, setCount] = useState(0);
useEffect(() => {
setCount(value + 1); // 상태 업데이트가 다시 effect를 트리거
}, [count, value]); // count, value가 변경될 때마다 effect 실행
return (
<div>
<input
type="text"
value={value}
onClick={(e) => setValue(e.target.value)}
/>
Count is {count}
</div>
);
}
// ✅ 불필요한 useEffect 제거
function Counter() {
const [value, setValue] = useState(0);
const count = value + 1;
return (
<div>
<input
type="text"
value={value}
onClick={(e) => setValue(e.target.value)}
/>
Count is {count}
</div>
);
}// ❌ 직접 객체 수정 - mutable
function UserProfile() {
const [user, setUser] = useState({ name: "홍길동", age: 30 });
const updateAge = () => {
user.age += 1; // 직접 수정하면 React가 변경을 감지하지 못함
setUser(user);
};
return <button onClick={updateAge}>나이 증가</button>;
}
// ✅ 새로운 객체 생성 - immutable
function UserProfile() {
const [user, setUser] = useState({ name: "홍길동", age: 30 });
const updateAge = () => {
setUser({
...user, // 기존 속성 복사
age: user.age + 1, // 새로운 값으로 업데이트
});
};
return <button onClick={updateAge}>나이 증가</button>;
}// ❌ UI와 비즈니스 로직이 혼재된 컴포넌트
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
const fetchUser = async () => {
setLoading(true);
try {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
setUser(data);
} catch (err) {
setError(err);
} finally {
setLoading(false);
}
};
fetchUser();
}, [userId]);
if (loading) return <div>로딩 중...</div>;
if (error) return <div>에러 발생!</div>;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}
// ✅ 비즈니스 로직을 custom hook으로 분리
function useUser(userId) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
const fetchUser = async () => {
setLoading(true);
try {
const response = await fetch(`/api/users/${userId}`);
const data = await response.json();
setUser(data);
} catch (err) {
setError(err);
} finally {
setLoading(false);
}
};
fetchUser();
}, [userId]);
return { user, loading, error };
}
// ✅ UI 로직만 남은 깔끔한 컴포넌트
function UserProfile({ userId }) {
const { user, loading, error } = useUser(userId);
if (loading) return <div>로딩 중...</div>;
if (error) return <div>에러 발생!</div>;
return (
<div>
<h2>{user.name}</h2>
<p>{user.email}</p>
</div>
);
}// ❌ 여러 개의 연관된 상태를 useState로 관리
function Counter() {
const [count, setCount] = useState(0);
const [step, setStep] = useState(1);
const [max, setMax] = useState(10);
const increment = () => {
if (count + step <= max) {
setCount(count + step);
}
};
const updateStep = (newStep: number) => {
setStep(newStep);
if (count + newStep > max) {
setCount(max);
}
};
}
// ✅ useReducer로 연관된 상태를 하나로 관리
type State = {
count: number;
step: number;
max: number;
};
type Action = { type: "increment" } | { type: "setStep"; payload: number };
function reducer(state: State, action: Action): State {
switch (action.type) {
case "increment":
return {
...state,
count: Math.min(state.count + state.step, state.max),
};
case "setStep":
return {
...state,
step: action.payload,
count: Math.min(state.count, state.max),
};
default:
return state;
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, {
count: 0,
step: 1,
max: 10,
});
return (
<div>
<button onClick={() => dispatch({ type: "increment" })}>
증가 ({state.count})
</button>
<input
type="number"
value={state.step}
onChange={(e) =>
dispatch({
type: "setStep",
payload: Number(e.target.value),
})
}
/>
</div>
);
}// ❌ 불필요한 useEffect 사용
function Profile({ user }) {
const [fullName, setFullName] = useState("");
useEffect(() => {
setFullName(`${user.firstName} ${user.lastName}`);
}, [user.firstName, user.lastName]);
return <div>{fullName}</div>;
}
// ✅ 직접 계산하여 사용
function Profile({ user }) {
const fullName = `${user.firstName} ${user.lastName}`;
return <div>{fullName}</div>;
}// 기존 객체/값으로부터 타입 추론하기
const user = {
id: 1,
name: "홍길동",
age: 30,
email: "hong@example.com",
} as const;
// ❌ 함수 파라미터에 any 사용
function processUser(user: any) {
console.log(user.name);
}
// ✅ typeof로 추론된 타입 사용
function processUser(user: typeof user) {
console.log(user.name);
}2019년 이후 defaultProps는 deprecated 입니다.
// ❌ defaultProps 사용
function Button({ label, type }) {
return <button type={type}>{label}</button>;
}
Button.defaultProps = {
type: "button",
label: "버튼",
};
// ✅ default parameters 사용
function Button({ label = "버튼", type = "button" }) {
return <button type={type}>{label}</button>;
}
// TypeScript에서의 사용
type ButtonProps = {
label?: string;
type?: "button" | "submit" | "reset";
};
function Button({ label = "버튼", type = "button" }: ButtonProps) {
return <button type={type}>{label}</button>;
}// ❌ 암묵적 any 타입 사용
const handleChange = (e) => {
setValue(e.target.value);
};
// ✅ 명시적 타입 지정
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setValue(e.target.value);
};
// ✅ 자주 사용되는 이벤트 타입들
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
// 클릭 이벤트 처리
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
// 폼 제출 처리
};
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
// 키보드 이벤트 처리
};// ❌ useEffect 안에서 이벤트 핸들러 정의
function Form() {
useEffect(() => {
const handleSubmit = async () => {
await saveData();
showNotification("저장 완료");
};
}, []);
return <button onClick={handleSubmit}>저장</button>;
}
// ✅ 이벤트 핸들러를 직접 정의
function Form() {
const handleSubmit = async () => {
await saveData();
showNotification("저장 완료");
};
return <button onClick={handleSubmit}>저장</button>;
}// ❌ 성급한 React.memo 사용
const ExpensiveList = React.memo(({ items, onItemClick }) => {
return (
<div>
{items.map((item) => (
<div key={item.id} onClick={() => onItemClick(item.id)}>
{item.name}
</div>
))}
</div>
);
});
// ✅ 상태 위치 조정으로 최적화
function ExpensiveList() {
const [items, setItems] = useState([]);
const handleItemClick = (id) => {
// 로직 처리
};
return (
<div>
{items.map((item) => (
<ListItem key={item.id} item={item} onClick={handleItemClick} />
))}
</div>
);
}
// ✅ 컴포지션으로 분리
function ListItem({ item, onClick }) {
return <div onClick={() => onClick(item.id)}>{item.name}</div>;
}// ❌ Race condition 발생 가능
function UserProfile({ userId }) {
const [data, setData] = useState(null);
useEffect(() => {
fetchUser(userId).then((user) => {
setData(user); // 이전 요청의 응답이 나중에 도착할 수 있음
});
}, [userId]);
}
// ✅ AbortController로 이전 요청 취소
function UserProfile({ userId }) {
const [data, setData] = useState(null);
useEffect(() => {
const abortController = new AbortController();
async function fetchData() {
try {
const response = await fetch(`/api/users/${userId}`, {
signal: abortController.signal,
});
const user = await response.json();
setData(user);
} catch (error) {
if (error.name === "AbortError") {
// 요청이 취소됨 - 무시
return;
}
// 다른 에러 처리
}
}
fetchData();
return () => {
abortController.abort(); // cleanup 시 이전 요청 취소
};
}, [userId]);
if (!data) return <div>로딩 중...</div>;
return <div>{data.name}</div>;
}// ❌ 별도의 isLoading 상태 관리
function UserProfile() {
const [isLoading, setIsLoading] = useState(false);
const [user, setUser] = useState(null);
useEffect(() => {
setIsLoading(true);
fetchUser()
.then((data) => setUser(data))
.finally(() => setIsLoading(false));
}, []);
if (isLoading) return <div>로딩 중...</div>;
return <div>{user?.name}</div>;
}
// ✅ 데이터 상태로부터 로딩 상태 유추
function UserProfile() {
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
fetchUser().then((data) => setUser(data));
}, []);
if (!user) return <div>로딩 중...</div>;
return <div>{user.name}</div>;
}성급한 추상화보다 중복이 낫다
컴포넌트를 너무 일찍 쪼개는 것은 오히려 코드의 복잡성을 증가시킬 수 있습니다. 다음과 같은 경우에만 컴포넌트를 분리하는 것이 좋습니다:
재사용성이 명확할 때
- 동일한 UI와 로직이 여러 곳에서 반복적으로 사용되는 경우
- 다른 프로젝트나 페이지에서도 사용될 가능성이 높은 경우
복잡한 로직 분리가 필요할 때
- 특정 부분의 로직이 너무 복잡해져서 관리가 어렵워질 때
- 상태 관리나 부수 효과가 독립적으로 처리되어야 할 때
성능 최적화가 필요할 때
- 특정 부분만 자주 리렌더링이 발생하는 경우
- React.memo()를 사용한 최적화가 효과적일 때
단순한 마크업 분리
// ❌ 불필요한 분리
const Header = () => <h1>제목</h1>;
const Content = () => <p>내용</p>;
// ✅ 하나의 컴포넌트로 유지
const Article = () => (
<div>
<h1>제목</h1>
<p>내용</p>
</div>
);한 번만 사용되는 컴포넌트
// ❌ 불필요한 분리
const UserProfile = () => {
return <UserProfileDetails user={user} />;
};
// ✅ 재사용되지 않는다면 하나로 유지
const UserProfile = () => {
return (
<div>
<h2>{user.name}</h2>
<p>{user.bio}</p>
</div>
);
};과도한 추상화
// ❌ 과도한 추상화
const Button = ({ color, size, children, ...props }) => {
return (
<button className={`btn-${color} btn-${size}`} {...props}>
{children}
</button>
);
};
// ✅ 실제로 필요한 속성만 추상화
const Button = ({ children, ...props }) => {
return <button {...props}>{children}</button>;
};단순히 컴포넌트의 크기가 커졌다는 이유만으로 분리하는 것은 오히려 코드베이스의 복잡성을 증가시킬 수 있습니다. 다음과 같은 핵심 원칙들을 고려하여 컴포넌트 분리를 결정해야 합니다:
- 응집도: 분리된 컴포넌트는 명확한 단일 책임을 가져야 함
- 의존성: 부모-자식 간의 props 전달이 과도하게 복재해지지 않아야 함
- 네이밍: 컴포넌트의 역할을 명확하게 표현하는 이름을 사용
- 크기: 파일 크기나 로직의 복잡도가 관리하기 어려울 정도로 커졌을 때 분리 고려
useEffect는 React의 강력한 기능이지만, 때로는 더 간단한 대안이 있을 수 있습니다. 다음은 useEffect 사용을 재고해볼 수 있는 상황들입니다.
// ❌ 불필요한 useEffect 사용
const [fullName, setFullName] = useState("");
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
// ✅ 렌더링 중에 직접 계산
const fullName = `${firstName} ${lastName}`;// ❌ props로부터 state를 동기화하기 위해 useEffect 사용
useEffect(() => {
setSearchQuery(props.defaultQuery);
}, [props.defaultQuery]);
// ✅ 가능하면 props를 직접 사용
function SearchBar({ defaultQuery }) {
return <input defaultValue={defaultQuery} />;
}// ❌ 이벤트 핸들링에 useEffect 사용
useEffect(() => {
if (status === 'success') {
showNotification('데이터 저장 성공');
}
}, [status]);
// ✅ 이벤트가 발생하는 곳에서 직접 처리
function handleSubmit() {
await saveData();
showNotification('데이터 저장 성공');
}// ❌ 부모 컴포넌트 업데이트를 위해 useEffect 사용
useEffect(() => {
onUpdate(data);
}, [data]);
// ✅ 필요한 경우에만 직접 호출
function handleChange(newData) {
setData(newData);
onUpdate(newData);
}// ❌ 앱 초기화를 위해 useEffect 사용
function App() {
useEffect(() => {
loadDataFromLocalStorage();
}, []);
}
// ✅ 앱 시작 시점에 직접 초기화
if (typeof window !== "undefined") {
loadDataFromLocalStorage();
}다음과 같은 경우에만 useEffect를 사용하세요:
외부 시스템과의 동기화
- WebSocket 연결 관리
- 브라우저 API 구독
- 타사 라이브러리 통합
데이터 페칭
- 단, React Query나 SWR 같은 데이터 페칭 라이브러리 사용을 권장
DOM 직접 조작
- 포커스 관리
- 스크롤 위치 제어
- 측정이 필요한 경우
다음과 같은 경우에만 useEffect를 사용하세요:
외부 시스템과의 동기화
- WebSocket 연결 관리
- 브라우저 API 구독
- 타사 라이브러리 통합
데이터 페칭
- 단, React Query나 SWR 같은 데이터 페칭 라이브러리 사용을 권장
DOM 직접 조작
- 포커스 관리
- 스크롤 위치 제어
- 측정이 필요한 경우
React 안티패턴에 대한 내용과 모범 사례를 안내합니다.
useState대신 변수 사용 금지- 컴포넌트 외부에 CSS 선언
useCallback으로 함수 재생성 방지useCallback으로 의존성 변경 방지useCallback으로useEffect트리거 방지useEffect에 빈 의존성 배열 추가 (의존성 없을 때만)- 모든 의존성을
useEffect에 명시 - 외부 코드 초기화에
useEffect사용 금지 - 외부 함수를
useCallback으로 감싸지 않기 - 빈 의존성 배열과
useMemo사용 금지 - 컴포넌트 내부에 컴포넌트 선언 금지
- 조건문/return 이후 훅 사용 금지
- 자식 컴포넌트가 렌더링 여부 결정하도록 허용
- 복수
useState대신useReducer사용 - 초기 상태를 객체 대신 함수로 작성
- 리렌더링 방지를 위해
useRef사용
- 문제점: 일반 변수는 리렌더링 시 초기화되므로 상태 변경이 UI에 반영되지 않음.
- 해결책:
const [count, setCount] = useState(0); // ✅ 상태 보존
-
문제점: 인라인 스타일은 매 렌더링 시 객체 재생성 → 성능 저하.
-
해결책:
// styles.css (외부 파일) .button { color: red; } // Component.jsx import './styles.css'; // ✅
- 문제점: 매 렌더링 시 새 함수 생성 → 자식 컴포넌트 불필요한 리렌더링 유발.
- 해결책:
const handleClick = useCallback(() => { // 로직 }, [deps]); // ✅ 의존성 배열 필수!
- 문제점: 함수가 의존성 배열에 포함될 때, 재생성되지 않도록 방지하지 않으면 불필요한 리렌더링 발생.
- 해결책:
const fetchData = useCallback(async () => { const res = await api.get(`/data?page=${page}`); }, [page]); // ✅ page 변경 시에만 함수 재생성
- 문제점:
useEffect내부에서 사용되는 함수가 변경되면useEffect가 재실행됨. - 해결책:
useEffect(() => { fetchData(); }, [fetchData]); // ✅ useCallback으로 메모이제이션된 함수 사용
- 문제점: 의존성 누락 시 최신 값 참조 불가 → Stale Closure 발생.
- 해결책:
useEffect(() => { mount 시 1회 실행 // ✅ }, []); // 정말 의존성이 없을 때만 사용
- 문제점: 누락된 의존성으로 인해 예상치 못한 버그 발생.
- 해결책:
useEffect(() => { console.log(count); }, [count]); // ✅ ESLint 규칙 준수
- 문제점: 외부 라이브러리 초기화가 렌더링과 결합되면 예측 불가능한 동작 발생.
- 해결책:
// 이벤트 핸들러나 별도 모듈에서 초기화 const initChart = () => { /* ... */ };
- 문제점: 이미 메모이제이션된 함수를 중복 감싸면 성능 저하.
- 해결책:
// 부모에서 전달된 함수는 그대로 사용 <Child onClick={props.onClick} /> // ✅
- 문제점: 빈 배열 → 값이 절대 갱신되지 않아 Stale Data 발생.
- 해결책:
const total = useMemo(() => price * quantity, [price, quantity]); // ✅
-
문제점: 매 렌더링 시 새 컴포넌트 생성 → State/Effect 초기화 발생.
-
해결책:
// 외부에 선언 const Child = () => { /* ... */ }; const Parent = () => <Child />; // ✅
- 문제점: 조건부 렌더링 이후 훅을 호출하면 훅 호출 순서가 보장되지 않아 버그 발생.
- 해결책:
const Component = () => { if (condition) return null; useEffect(() => {}); // ❌ return 이후 훅 호출 금지 // ✅ 훅은 항상 최상위에서 호출 return <div />; };
- 문제점: 부모가 강제로 리렌더링 → 성능 낭비.
- 해결책:
const Child = React.memo(({ data }) => { /* ... */ }); // ✅ Props 변화 시만 리렌더링
- 문제점: 연관된 상태를 개별로 관리하면 코드 복잡성 증가.
- 해결책:
const [state, dispatch] = useReducer(reducer, initialState); // ✅ 복잡한 상태 통합
- 문제점: 객체 초기화는 매 렌더링 시 실행 → 불필요한 계산.
- 해결책:
const [data] = useState(() => heavyCalculation()); // ✅ 초기 1회만 실행
- 문제점:
useState는 값 변경 시 리렌더링을 유발. - 해결책:
const intervalId = useRef(null); // ✅ 리렌더링 없이 값 보존
React 19에서는 다음과 같은 주요 변경사항들이 있습니다:
- Actions: startTransition은 이제 비동기 함수를 수용할 수 있음. 이 함수들은 "Actions"라 불리며, 상태를 백그라운드에서 업데이트하고 UI를 한 번에 커밋함. Actions는 비동기 요청과 같은 부작용을 수행할 수 있으며, Transition이 완료되기 전에 작업을 기다림.
- useActionState: Transition 내에서 Actions를 정렬하고 상태에 접근할 수 있는 새로운 훅임. 초기 상태와 reducer를 받아들임.
- useOptimistic: Transition이 진행 중일 때 상태를 업데이트하는 새로운 훅임. Transition이 완료되면 상태가 새로운 값으로 업데이트됨.
- use: 렌더링 시 리소스를 읽을 수 있는 새로운 API임. Promise나 Context를 수용하며, Promise가 제공되면 값이 해결될 때까지 중단됨.
- ref를 prop으로 사용: forwardRef가 필요 없이 ref를 prop으로 사용할 수 있음.
- Suspense 형제 프리워밍: 컴포넌트가 중단되면 React는 가장 가까운 Suspense 경계의 대체를 즉시 커밋함.
- 새로운 JSX 변환 필수: 번들 크기를 개선하고 React를 가져오지 않고 JSX를 사용할 수 있도록 하는 새로운 JSX 변환이 필요함.
- 렌더링 오류가 다시 던져지지 않음: 오류가 Error Boundary에 의해 잡히지 않으면 window.reportError에 보고됨.
- propTypes 제거: propTypes 사용이 조용히 무시됨. TypeScript로 마이그레이션을 권장함.
- 함수의 defaultProps 제거: ES6 기본 매개변수를 대신 사용할 수 있음.
- 문자열 refs 제거: 문자열 refs 사용을 ref 콜백으로 마이그레이션해야 함.
<Context>를 제공자로 사용:<Context.Provider>대신<Context>를 제공자로 렌더링할 수 있음.- 커스텀 엘리먼트 지원: React 19는 모든 커스텀 엘리먼트 테스트를 통과함.
- StrictMode 변경 사항: useMemo와 useCallback은 첫 번째 렌더링의 메모이제이션 결과를 재사용함.
- react-dom/test-utils 제거: act를 react-dom/test-utils에서 react로 이동함. 다른 유틸리티는 제거됨.
- ReactDOM.render, ReactDOM.hydrate 제거: 동시 렌더링을 위한 ReactDOM.createRoot 및 ReactDOM.hydrateRoot로 대체됨.
- 하이드레이션 오류에 대한 차이점: 불일치가 발생할 경우, React 19는 불일치된 콘텐츠의 차이점을 포함한 단일 오류를 기록함.
<form>action prop: Form Actions는 자동으로 폼을 관리하고 useFormStatus와 통합함. 성공 시 폼을 자동으로 재설정함.- 문서 메타데이터 지원: 문서 메타데이터 태그를 컴포넌트에서 네이티브로 렌더링할 수 있음.
- 스타일시트 지원: Suspense 경계의 콘텐츠를 공개하기 전에 클라이언트의
<head>에 스타일시트를 삽입함. - 비동기 스크립트 지원: 컴포넌트 트리 어디에서나 비동기 스크립트를 렌더링할 수 있음.
- prerender 및 prerenderToNodeStream API 추가: 정적 사이트 생성을 위한 API로, Node.js 스트림 및 웹 스트림과 같은 스트리밍 환경에서 작동하도록 설계됨.
- RSC 기능: 지시문, 서버 컴포넌트, 서버 함수가 안정화됨. 서버 컴포넌트를 포함한 라이브러리는 이제 React 19를 피어 종속성으로 타겟팅할 수 있음.
- 암시적 반환 금지: refs는 이제 정리 함수만 수용함. 암시적 반환은 오류를 발생시킴.
- useRef의 초기 인수 필요: 초기 인수는 이제 필수임.
- 엄격한 ReactElement 타이핑: React 요소의 props는 이제 any 대신 unknown으로 기본 설정됨.
- element.ref 접근: element.props.ref를 선호하여 element.ref 접근을 폐기함.
- react-test-renderer: React 19에서는 react-test-renderer가 폐기 경고를 기록하며 웹 사용을 위해 동시 렌더링으로 전환됨.