해보면서 느낀 리액트 성능 최적화에 대한 고찰
이 글을 통해 전하고 싶은 메세지
- 리액트 개발자 도구를 잘 쓰는 게 굉장히 중요하다.
useCallback
,useMemo
,React.memo
를 사용하면 리액트 렌더링에 있어서 최적화를 할 수 있다.- 단, 이것들을 사용할 때에는 언제 사용해야 하는지에 대한 판단이 중요하다.
- 정말, 정말로 언제 사용해야 하는 지에 대한 판단이 굉장히 중요하다.
- 잘못 사용하면 오히려 성능을 저하시킬 수 있기 때문이다.
글을 쓰게된 계기
나는 좋은 개발자인가?
프론트앤드 개발자이면서 사용자를 고려하고 있는건가?
프론트앤드 개발로 전향한지 거진 1년이 되어간다. 부스트캠프 등을 거치면서 많이 배웠다고 생각했었다.
2024년 회고를 진행하면서 스스로를 돌아보니, '구현'에 급급했지, 그 이상의 것을 고려하고 있었다.
어느정도 구현을 할 수 있게 된 지금, 리액트의 원리 탐구를 넘어서 사용자에게 가치를 전달하는 관점에서 접근할 필요가 있 었다.
항해플러스 프론트앤드 4기
앞선 이유와 더불어서, 실무적으로 깊은 성장을 하기 위해 항해플러스를 신청했다.
항해플러스에서는 프론트엔드 개발자로서 성장하기 위한 다양한 과정을 제공하고 있다.
처음에는 리액트와 자바스크립트에 대해서 깊게 다루는데, 그 과정에서 리팩토링과 성능 최적화를 경험할 수 있는 상황을 만나게 되었다.
이번 기회에 제대로 한번 몸으로 느껴보면서 성능 최적화에 대한 말말말에 대해 나만의 말을 만들어보자는 생각에 글을 적게 되었다.
문제 상황
코드 상황 (350줄 가까이 되는 코드, 스크롤 많아짐 주의)
App.tsx
import React, { useState, createContext, useContext } from 'react';
import { generateItems, renderLog } from './utils';
// 타입 정의
interface Item {
id: number;
name: string;
category: string;
price: number;
}
interface User {
id: number;
name: string;
email: string;
}
interface Notification {
id: number;
message: string;
type: 'info' | 'success' | 'warning' | 'error';
}
// AppContext 타입 정의
interface AppContextType {
theme: string;
toggleTheme: () => void;
user: User | null;
login: (email: string, password: string) => void;
logout: () => void;
notifications: Notification[];
addNotification: (message: string, type: Notification['type']) => void;
removeNotification: (id: number) => void;
}
const AppContext = createContext<AppContextType | undefined>(undefined);
// 커스텀 훅: useAppContext
const useAppContext = () => {
const context = useContext(AppContext);
if (context === undefined) {
throw new Error('useAppContext must be used within an AppProvider');
}
return context;
};
// Header 컴포넌트
export const Header: React.FC = () => {
renderLog('Header rendered');
const { theme, toggleTheme, user, login, logout } = useAppContext();
const handleLogin = () => {
// 실제 애플리케이션에서는 사용자 입력을 받아야 합니다.
login('user@example.com', 'password');
};
return (
<header className="bg-gray-800 text-white p-4">
<div className="container mx-auto flex justify-between items-center">
<h1 className="text-2xl font-bold">샘플 애플리케이션</h1>
<div className="flex items-center">
<button
onClick={toggleTheme}
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded mr-2"
>
{theme === 'light' ? '다크 모드' : '라이트 모드'}
</button>
{user ? (
<div className="flex items-center">
<span className="mr-2">{user.name}님 환영합니다!</span>
<button
onClick={logout}
className="bg-red-500 hover:bg-red-700 text-white font-bold py-2 px-4 rounded"
>
로그아웃
</button>
</div>
) : (
<button
onClick={handleLogin}
className="bg-green-500 hover:bg-green-700 text-white font-bold py-2 px-4 rounded"
>
로그인
</button>
)}
</div>
</div>
</header>
);
};
// ItemList 컴포넌트
export const ItemList: React.FC<{
items: Item[];
onAddItemsClick: () => void;
}> = ({ items, onAddItemsClick }) => {
renderLog('ItemList rendered');
const [filter, setFilter] = useState('');
const { theme } = useAppContext();
const filteredItems = items.filter(
(item) =>
item.name.toLowerCase().includes(filter.toLowerCase()) ||
item.category.toLowerCase().includes(filter.toLowerCase())
);
const totalPrice = filteredItems.reduce((sum, item) => sum + item.price, 0);
const averagePrice = Math.round(totalPrice / filteredItems.length) || 0;
return (
<div className="mt-8">
<div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-bold">상품 목록</h2>
<div>
<button
type="button"
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded text-xs"
onClick={onAddItemsClick}
>
대량추가
</button>
</div>
</div>
<input
type="text"
placeholder="상품 검색..."
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="w-full p-2 mb-4 border border-gray-300 rounded text-black"
/>
<ul className="mb-4 mx-4 flex gap-3 text-sm justify-end">
<li>검색결과: {filteredItems.length.toLocaleString()}개</li>
<li>전체가격: {totalPrice.toLocaleString()}원</li>
<li>평균가격: {averagePrice.toLocaleString()}원</li>
</ul>
<ul className="space-y-2">
{filteredItems.map((item, index) => (
<li
key={index}
className={`p-2 rounded shadow ${
theme === 'light' ? 'bg-white text-black' : 'bg-gray-700 text-white'
}`}
>
{item.name} - {item.category} - {item.price.toLocaleString()}원
</li>
))}
</ul>
</div>
);
};
// ComplexForm 컴포넌트
export const ComplexForm: React.FC = () => {
renderLog('ComplexForm rendered');
const { addNotification } = useAppContext();
const [formData, setFormData] = useState({
name: '',
email: '',
age: 0,
preferences: [] as string[],
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
addNotification('폼이 성공적으로 제출되었습니다', 'success');
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData((prev) => ({
...prev,
[name]: name === 'age' ? parseInt(value) || 0 : value,
}));
};
const handlePreferenceChange = (preference: string) => {
setFormData((prev) => ({
...prev,
preferences: prev.preferences.includes(preference)
? prev.preferences.filter((p) => p !== preference)
: [...prev.preferences, preference],
}));
};
return (
<div className="mt-8">
<h2 className="text-2xl font-bold mb-4">복잡한 폼</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<input
type="text"
name="name"
value={formData.name}
onChange={handleInputChange}
placeholder="이름"
className="w-full p-2 border border-gray-300 rounded text-black"
/>
<input
type="email"
name="email"
value={formData.email}
onChange={handleInputChange}
placeholder="이메일"
className="w-full p-2 border border-gray-300 rounded text-black"
/>
<input
type="number"
name="age"
value={formData.age}
onChange={handleInputChange}
placeholder="나이"
className="w-full p-2 border border-gray-300 rounded text-black"
/>
<div className="space-x-4">
{['독서', '운동', '음악', '여행'].map((pref) => (
<label key={pref} className="inline-flex items-center">
<input
type="checkbox"
checked={formData.preferences.includes(pref)}
onChange={() => handlePreferenceChange(pref)}
className="form-checkbox h-5 w-5 text-blue-600"
/>
<span className="ml-2">{pref}</span>
</label>
))}
</div>
<button
type="submit"
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
>
제출
</button>
</form>
</div>
);
};
// NotificationSystem 컴포넌트
export const NotificationSystem: React.FC = () => {
renderLog('NotificationSystem rendered');
const { notifications, removeNotification } = useAppContext();
return (
<div className="fixed bottom-4 right-4 space-y-2">
{notifications.map((notification) => (
<div
key={notification.id}
className={`p-4 rounded shadow-lg ${
notification.type === 'success'
? 'bg-green-500'
: notification.type === 'error'
? 'bg-red-500'
: notification.type === 'warning'
? 'bg-yellow-500'
: 'bg-blue-500'
} text-white`}
>
{notification.message}
<button
onClick={() => removeNotification(notification.id)}
className="ml-4 text-white hover:text-gray-200"
>
닫기
</button>
</div>
))}
</div>
);
};
// 메인 App 컴포넌트
const App: React.FC = () => {
const [theme, setTheme] = useState('light');
const [items, setItems] = useState(generateItems(1000));
const [user, setUser] = useState<User | null>(null);
const [notifications, setNotifications] = useState<Notification[]>([]);
const toggleTheme = () => {
setTheme((prevTheme) => (prevTheme === 'light' ? 'dark' : 'light'));
};
const addItems = () => {
setItems((prevItems) => [...prevItems, ...generateItems(1000, prevItems.length)]);
};
const login = (email: string) => {
setUser({ id: 1, name: '홍길동', email });
addNotification('성공적으로 로그인되었습니다', 'success');
};
const logout = () => {
setUser(null);
addNotification('로그아웃되었습니다', 'info');
};
const addNotification = (message: string, type: Notification['type']) => {
const newNotification: Notification = {
id: Date.now(),
message,
type,
};
setNotifications((prev) => [...prev, newNotification]);
};
const removeNotification = (id: number) => {
setNotifications((prev) => prev.filter((notification) => notification.id !== id));
};
const contextValue: AppContextType = {
theme,
toggleTheme,
user,
login,
logout,
notifications,
addNotification,
removeNotification,
};
return (
<AppContext.Provider value={contextValue}>
<div
className={`min-h-screen ${theme === 'light' ? 'bg-gray-100' : 'bg-gray-900 text-white'}`}
>
<Header />
<div className="container mx-auto px-4 py-8">
<div className="flex flex-col md:flex-row">
<div className="w-full md:w-1/2 md:pr-4">
<ItemList items={items} onAddItemsClick={addItems} />
</div>
<div className="w-full md:w-1/2 md:pl-4">
<ComplexForm />
</div>
</div>
</div>
<NotificationSystem />
</div>
</AppContext.Provider>
);
};
export default App;

처음에 주어진 코드와 디렉토리 구조이다.
내가 처리한 로직에 대해서는 내가 항해플러스 과제를 수행한 깃허브에서 확인할 수 있다.
현재 코드 구조
현재 코드의 구조를 정리하면 다음과 같다.
- 전체 도식도
- 컴포넌트 구조
- 전역 상태
- 커스텀 훅
- AppContext
- theme
- toggleTheme
- user
- login
- logout
- notifications
- addNotification
- removeNotification
- useAppContext
이미 커스텀 훅 하나를 포함하고 있다. 그리고, 코드 내부에도 여러가지 훅 요소가 많이 존재하고 있다.