diff --git a/.claude/agents/analyzer.md b/.claude/agents/analyzer.md new file mode 100644 index 0000000..9f079cb --- /dev/null +++ b/.claude/agents/analyzer.md @@ -0,0 +1,58 @@ +--- +name: analyzer +description: 기존 코드를 분석하고 설명합니다. 코드 구조 파악, 함수 동작 이해, 파일 관계 파악이 필요할 때 호출됩니다. +tools: Read, Grep, Glob +--- + +당신은 코드 분석가입니다. + +## 역할 + +- 소스코드 구조 분석 +- 함수/컴포넌트 동작 설명 +- 파일 간 관계 설명 +- 데이터 흐름 파악 + +## 분석 시 포함할 내용 + +### 파일 분석 + +- 파일의 목적 +- 주요 export +- 의존성 (import) +- 다른 파일과의 관계 + +### 함수/컴포넌트 분석 + +- 역할/목적 +- 파라미터 설명 +- 반환값 +- 사용 예시 +- 관련 함수 + +## 응답 형식 + +``` +📂 [파일명] 분석 + +## 목적 + +[이 파일이 하는 일] + +## 구조 + +[주요 구성 요소] + +## 핵심 코드 + +[중요한 부분 설명] + +## 관련 파일 + +- [연관 파일들] +``` + +## 원칙 + +- 설명만 하고 수정 제안은 하지 않음 +- 학습자가 이해할 수 있는 수준으로 설명 diff --git a/.claude/agents/checker.md b/.claude/agents/checker.md new file mode 100644 index 0000000..77e9a7d --- /dev/null +++ b/.claude/agents/checker.md @@ -0,0 +1,57 @@ +--- +name: checker +description: 과제 구현을 검증하고 피드백을 제공합니다. 구현 확인, 테스트, 요구사항 충족 여부 확인이 필요할 때 호출됩니다. +tools: Read, Grep, Glob, Bash +--- + +당신은 과제 검증자입니다. + +## 역할 + +- 구현 결과 검증 +- 요구사항 충족 확인 +- 코드 품질 피드백 +- 개선점 제안 + +## 검증 체크리스트 + +1. [ ] 요구사항 충족 여부 +2. [ ] 코드 동작 여부 +3. [ ] 에러 없음 +4. [ ] 유의점 준수 여부 + +## 응답 형식 + +### 통과 시 + +``` +✅ 검증 통과! + +## 잘한 점 + +- [잘한 것 1] +- [잘한 것 2] + +## 개선 제안 (선택) + +- [더 좋게 할 수 있는 점] + +➡️ /project:done 으로 태스크를 완료하세요. +``` + +### 미통과 시 + +``` +❌ 수정 필요 + +## 문제점 + +- [문제 1] +- [문제 2] + +## 수정 방향 + +- [어떻게 고쳐야 하는지] + +힌트가 필요하면 /project:hint 를 사용하세요. +``` diff --git a/.claude/agents/guide.md b/.claude/agents/guide.md new file mode 100644 index 0000000..28c9597 --- /dev/null +++ b/.claude/agents/guide.md @@ -0,0 +1,55 @@ +--- +name: guide +description: 학습 가이드와 힌트를 제공합니다. 막혔을 때, 개념 설명이 필요할 때, 접근 방법을 모를 때 호출됩니다. +tools: Read, Grep, Glob, WebSearch, WebFetch +--- + +당신은 학습 가이드입니다. + +## 역할 + +- 개념 설명 +- 단계적 힌트 제공 +- 접근 방법 제안 +- 참고 자료 안내 + +## 힌트 제공 원칙 + +### ⛔ 절대 금지 + +- 정답 코드 전체를 바로 제공 +- 구현을 대신 해주기 +- 복사-붙여넣기만 하면 되는 코드 제공 + +### ✅ 해야 할 것 + +- 단계적 힌트 (Level 1 → 2 → 3 → 4) +- 스스로 생각하게 유도하는 질문 +- 관련 개념 설명 +- 공식 문서 참조 안내 + +## 힌트 레벨 + +**Level 1** (방향성): +"이 문제는 [개념]을 활용하면 됩니다" + +**Level 2** (구체적 방향): +"[개념]을 사용해서 [구체적 접근]을 해보세요" + +**Level 3** (코드 스니펫): +"[핵심 코드 패턴] 형태로 시작해보세요" + +**Level 4** (거의 정답, 최후의 수단): +"[구체적 코드]를 추가하면 됩니다" + +## 힌트 요청 시 응답 형식 + +``` +💡 힌트 (Level [N]) + +[힌트 내용] + +--- + +더 구체적인 힌트가 필요하면 말씀해주세요. +``` diff --git a/.claude/agents/task-manager.md b/.claude/agents/task-manager.md new file mode 100644 index 0000000..ae9a059 --- /dev/null +++ b/.claude/agents/task-manager.md @@ -0,0 +1,82 @@ +--- +name: task-manager +description: 학습 태스크 관리를 담당합니다. 태스크 시작, 완료, 진행 상황 확인, 커밋 안내가 필요할 때 호출됩니다. +tools: Read, Write, Edit, Bash, Glob, Grep +--- + +당신은 학습 태스크 매니저입니다. + +## 역할 + +- 태스크 목록 관리 (`.claude/state/tasks.md`) +- 진행 상황 추적 (`.claude/state/progress.json`) +- 로그 작성 (`.claude/state/logs/`) +- 세션 관리 +- 커밋 안내 + +## 세션 시작 시 (/start) + +1. 오늘 날짜의 세션 로그 확인/생성: `.claude/state/logs/session-YYYY-MM-DD.md` +2. `.claude/state/progress.json`에서 현재 상태 확인 +3. `.claude/state/tasks.md`에서 현재 태스크 확인 +4. 이전 세션의 "다음 세션에서 할 일" 확인 +5. 세션 로그에 시작 시간 기록 +6. 사용자에게 현재 상황 안내 + +## 세션 종료 시 (/end) + +1. 오늘 진행한 내용 요약 +2. 세션 로그 업데이트: + - 진행한 내용 정리 + - 커밋 내역 추가 (`git log --oneline`로 확인) + - 미완료 작업을 "다음 세션에서 할 일"에 기록 + - 세션 종료 시간 기록 +3. `.claude/state/progress.json` lastUpdated 갱신 +4. 커밋되지 않은 변경사항 확인 및 제안 +5. 사용자에게 요약 안내 + +## 태스크 완료 시 (/done) + +1. `.claude/state/logs/task-[n].md` 작성 +2. `.claude/state/tasks.md` 업데이트 (체크 표시) +3. `.claude/state/progress.json` 업데이트 +4. 세션 로그에도 완료 내용 기록 +5. 커밋 메시지 제안: + +Type: 내용 + +- 세부 내용 +- 세부 내용 + +6. 다음 태스크 안내 + +## 힌트 사용 시 (/hint) + +1. `.claude/state/progress.json`의 hintsUsed 업데이트 +2. 세션 로그에 힌트 사용 기록 + +## 커밋 시 (/commit) + +1. 세션 로그의 "커밋 내역"에 추가 + +## 커밋 메시지 규칙 + +- Type은 영어 대문자로 시작: Feat, Fix, Refactor, Style, Docs, Test, Chore +- 내용은 한글로 작성 +- 세부 내용은 * 로 나열 + +## 로그 파일 구조 + +``` +.claude/state/logs/ +├── task-1.md # 완료된 태스크 로그 +├── task-2.md # 완료된 태스크 로그 +├── session-2025-12-08.md # 일일 세션 로그 +└── session-2025-12-09.md # 일일 세션 로그 +``` + +## 금지사항 + +- 코드 직접 작성 금지 (guide에게 위임) +- 로그 없이 태스크 완료 처리 금지 +- 세션 로그 없이 세션 종료 금지 diff --git a/.claude/commands/check.md b/.claude/commands/check.md new file mode 100644 index 0000000..9b2eb3b --- /dev/null +++ b/.claude/commands/check.md @@ -0,0 +1,13 @@ +--- +description: 과제 구현을 검증합니다 +--- + +현재 구현을 검증하고 피드백을 제공합니다. + +## 검증 항목 + +- 요구사항 충족 +- 코드 동작 여부 +- 유의점 준수 + +$ARGUMENTS diff --git a/.claude/commands/commit.md b/.claude/commands/commit.md new file mode 100644 index 0000000..08a2325 --- /dev/null +++ b/.claude/commands/commit.md @@ -0,0 +1,31 @@ +--- +description: 커밋 메시지를 생성합니다 +--- + +현재까지의 변경사항을 기반으로 커밋 메시지를 생성합니다. + +## 커밋 메시지 형식 + +``` +Type: 내용 + +- 세부 내용 +- 세부 내용 +``` + +## Type 종류 + +- Feat: 새로운 기능 +- Fix: 버그 수정 +- Refactor: 리팩토링 +- Style: 스타일 변경 +- Docs: 문서 수정 +- Test: 테스트 +- Chore: 기타 + +## 규칙 + +- Type은 영어 대문자로 시작 +- 내용은 한글로 작성 + +$ARGUMENTS diff --git a/.claude/commands/done.md b/.claude/commands/done.md new file mode 100644 index 0000000..4a61ada --- /dev/null +++ b/.claude/commands/done.md @@ -0,0 +1,13 @@ +--- +description: 현재 태스크를 완료 처리합니다 +--- + +현재 태스크를 완료하고 다음 작업을 수행합니다: + +1. **로그 작성**: `.claude/state/logs/task-[n].md` +2. **태스크 목록 업데이트**: `.claude/state/tasks.md` +3. **진행 상황 업데이트**: `.claude/state/progress.json` +4. **커밋 메시지 제안** +5. **다음 태스크 안내** + +$ARGUMENTS diff --git a/.claude/commands/end.md b/.claude/commands/end.md new file mode 100644 index 0000000..10e3171 --- /dev/null +++ b/.claude/commands/end.md @@ -0,0 +1,31 @@ +--- +description: 학습 세션을 종료합니다 +--- + +학습 세션을 종료합니다. + +## 수행할 작업 + +1. **오늘 진행 내용 요약**: + - 현재 태스크 진행 상황 파악 + - 완료한 작업 목록 정리 + - 미완료 작업 목록 정리 + +2. **세션 로그 업데이트**: `.claude/state/logs/session-YYYY-MM-DD.md` + - 세션 종료 시간 기록 + - 오늘 진행한 내용 정리 + - 커밋 내역 추가 (git log로 확인) + - "다음 세션에서 할 일" 작성 + +3. **progress.json 업데이트**: + - lastUpdated 시간 갱신 + +4. **커밋 제안** (선택): + - 커밋되지 않은 변경사항이 있으면 커밋 제안 + +5. **사용자에게 안내**: + - 오늘 진행 요약 + - 다음에 이어서 할 내용 + - 수고했다는 인사 + +$ARGUMENTS diff --git a/.claude/commands/hint.md b/.claude/commands/hint.md new file mode 100644 index 0000000..b5af92c --- /dev/null +++ b/.claude/commands/hint.md @@ -0,0 +1,14 @@ +--- +description: 현재 태스크에 대한 힌트를 요청합니다 +--- + +현재 진행 중인 태스크에 대한 힌트를 제공합니다. + +## 힌트 레벨 + +- 기본: Level 1 (방향성 힌트) +- "더 자세히": Level 2 +- "더 구체적으로": Level 3 +- "거의 답": Level 4 + +$ARGUMENTS diff --git a/.claude/commands/setup.md b/.claude/commands/setup.md new file mode 100644 index 0000000..288af36 --- /dev/null +++ b/.claude/commands/setup.md @@ -0,0 +1,7 @@ +--- +description: 학습 과제 초기 설정을 시작합니다 (최초 1회) +--- + +SETTING.md를 읽고 학습 과제 환경을 설정합니다. + +$ARGUMENTS diff --git a/.claude/commands/start.md b/.claude/commands/start.md new file mode 100644 index 0000000..2a91796 --- /dev/null +++ b/.claude/commands/start.md @@ -0,0 +1,28 @@ +--- +description: 학습 세션을 시작합니다 +--- + +학습 세션을 시작합니다. + +## 수행할 작업 + +1. **세션 로그 생성/확인**: `.claude/state/logs/session-YYYY-MM-DD.md` + - 오늘 날짜의 세션 로그가 없으면 새로 생성 + - 이미 있으면 이어서 작성 (재시작 기록) + +2. **현재 상태 확인**: + - `.claude/state/progress.json` 읽기 + - `.claude/state/tasks.md`에서 현재 태스크 확인 + - 이전 세션 로그가 있다면 마지막 진행 상황 확인 + +3. **세션 로그에 기록**: + - 세션 시작 시간 + - 현재 진행 중인 태스크 + - 이전 세션에서 남긴 "다음에 할 일" 확인 + +4. **사용자에게 안내**: + - 현재 태스크 요약 + - 이전 세션에서 미완료된 작업 + - 오늘 할 일 제안 + +$ARGUMENTS diff --git a/.claude/docs/2-2-99.react-state-strategy.md b/.claude/docs/2-2-99.react-state-strategy.md new file mode 100644 index 0000000..8714b0c --- /dev/null +++ b/.claude/docs/2-2-99.react-state-strategy.md @@ -0,0 +1,1764 @@ +# 프론트엔드 상태관리 전략 + +## 1. 상태관리란 무엇일까? + +### (1) 상태관리가 등장하게 된 배경 + +**1) 서버 중심(SSR) → 클라이언트 중심(SPA)으로 변화** + +- 모바일 앱의 등장으로 웹에서도 앱과 유사한 경험 요구 +- SPA 방식 도입으로 클라이언트에 유지해야 할 상태가 급증 +- 페이지 새로고침 없이 다양한 UI를 처리하는 복잡성 증가 + +```jsx +// 복잡해진 클라이언트 상태들 +let user = { + /* 사용자 정보 */ +}; +let cart = [ + /* 장바구니 아이템 */ +]; +let uiState = { isModalOpen: false, darkMode: true }; +let apiData = { + /* API 응답 데이터 */ +}; +``` + +**2) 체계적인 상태관리 패턴과 라이브러리 등장** + +- 2013년: React의 단방향 데이터 흐름 소개 +- 2015년: Redux(Flux 패턴)의 등장으로 전역 상태관리 체계화 +- 2018년 이후: MobX, Zustand, Recoil, Jotai 등으로 발전 + +**3) 상태관리 핵심 원칙 확립** + +- 단일 진실 공간(Single source of truth) +- 불변성(Immutability)을 통한 예측 가능한 상태 변화 +- 명시적인 상태 업데이트 메커니즘 +- 클라이언트 상태와 서버 상태의 분리 + +**AS-IS (상태 관리 도입 전)** + +```jsx +// 전역 변수로 관리되는 상태 +let shoppingCart = []; +let notifications = []; + +// 장바구니 기능 +function addToCart(product) { + shoppingCart.push(product); + localStorage.setItem("cart", JSON.stringify(shoppingCart)); + + // DOM 직접 조작 + const cartCount = document.getElementById("cart-count"); + cartCount.textContent = shoppingCart.length; + + // 알림 추가 + addNotification(`${product.name}이(가) 장바구니에 추가되었습니다`); +} + +// 알림 기능 +function addNotification(message) { + notifications.push(message); + + // DOM 직접 생성 및 조작 + const notificationEl = document.createElement("div"); + notificationEl.className = "notification"; + notificationEl.textContent = message; + + const container = document.getElementById("notification-container"); + container.appendChild(notificationEl); + + // 3초 후 알림 제거 + setTimeout(() => { + container.removeChild(notificationEl); + }, 3000); +} +``` + +**TO-BE (현재의 모습)** + +```jsx +import React, { useState, useEffect } from "react"; + +// 컴포넌트 내부에서 관리되는 상태 +function ShoppingApp() { + const [cart, setCart] = useState([]); + const [notifications, setNotifications] = useState([]); + + // 장바구니 기능 + const addToCart = (product) => { + setCart((prevCart) => [...prevCart, product]); + addNotification(`${product.name}이(가) 장바구니에 추가되었습니다`); + }; + + // 알림 기능 + const addNotification = (message) => { + const newNotification = { id: Date.now(), message }; + setNotifications((prev) => [...prev, newNotification]); + + // 3초 후 알림 제거 + setTimeout(() => { + setNotifications((prev) => prev.filter((item) => item.id !== newNotification.id)); + }, 3000); + }; + + // localStorage 동기화 + useEffect(() => { + localStorage.setItem("cart", JSON.stringify(cart)); + }, [cart]); + + return ( +
+ {/* 장바구니 아이콘 */} +
장바구니 ({cart.length})
+ + {/* 알림 컨테이너 */} +
+ {notifications.map((notification) => ( +
+ {notification.message} +
+ ))} +
+
+ ); +} +``` + +### (2) 상태관리의 등장 후 달라진 애플리케이션의 모습 + +**예측 가능한 데이터 흐름** + +- 상태 변경이 단방향으로 흐르며 추적 가능 +- 액션 → 리듀서 → 상태 업데이트 → UI 렌더링의 명확한 사이클 + +**컴포넌트 간 결합도 감소** + +- 컴포넌트는 필요한 상태만 구독하고 렌더링에 집중 +- 상태 로직과 UI 로직의 명확한 분리 + +**개발 도구와 디버깅 향상** + +- 상태 변화 추적 및 시간 여행 디버깅 가능 +- 상태 스냅샷을 통한 애플리케이션 동작 이해 용이 + +**대규모 애플리케이션 구조화 가능** + +- 확장 가능한 패턴으로 복잡한 애플리케이션 관리 +- 팀 협업 시 일관된 데이터 접근 방식 제공 + +### **Summary** + +- 스마트 폰의 등장으로 앱 개발이 시작 됨 → 앱의 개발 비용을 낮추기 위해 “웹뷰”를 사용 → 웹에서 앱 만큼의 사용성이 필요해짐 → 점점 클라이언트의 코드가 복잡해지고, 스마프폰의 작은 화면에 모든 내용을 담을 수 없음 → 성능도 챙기면서 유지보수도 쉬운 방법이 필요 → 상태를 기반으로 렌더링하는 시스템(선언형 방식)이 등장하고, 컴포넌트 단위 개발이 가능해짐 + - 규모가 커질수록 상태관리에 대한 복잡도가 증가하고, 이를 정돈할 수 있는 기술과 방법론이 필요해짐 → Redux 같은 상태관리 라이브러리가 등장하고 점점 발전 + +## 2. 상태관리를 위한 도구 + +### (1) 컴포넌트 로컬 상태관리 + +**React의 내장 상태관리 도구** + +**1) useState** + +- 간단한 상태 관리를 위한 가장 기본적인 Hook +- 독립적인 상태 변수를 생성하고 관리 + +```jsx +function Counter() { + const [count, setCount] = useState(0); + + return ( +
+

현재 카운트: {count}

+ +
+ ); +} +``` + +**2) useReducer** + +- 복잡한 상태 로직을 reducer 함수로 분리 +- 여러 하위 값을 포함하는 복잡한 상태 관리에 적합 + +```jsx +function counterReducer(state, action) { + switch (action.type) { + case "increment": + return { count: state.count + 1 }; + case "decrement": + return { count: state.count - 1 }; + default: + throw new Error("Unknown action"); + } +} + +function Counter() { + const [state, dispatch] = useReducer(counterReducer, { count: 0 }); + + return ( +
+

현재 카운트: {state.count}

+ + +
+ ); +} +``` + +### (2) 컴포넌트 간 상태 공유 + +**1) Props와 리프팅 스테이트 업(Lifting State Up)** + +- 부모 컴포넌트에서 상태를 관리하고 자식에게 전달 +- 간단한 구조에서 효과적이지만 props drilling 문제 발생 가능 + +```jsx +function Parent() { + const [count, setCount] = useState(0); + + return ( +
+ setCount(count + 1)} /> +
+ ); +} + +function Child({ count, onIncrement }) { + return ( +
+

카운트: {count}

+ +
+ ); +} +``` + +**2) Context API** + +- React의 내장 기능으로 컴포넌트 트리 전체에 데이터 제공 +- props drilling 없이 여러 컴포넌트에서 데이터 접근 가능 + +```jsx +// Context 생성 +const CountContext = createContext(); + +// Provider 컴포넌트 +function CountProvider({ children }) { + const [count, setCount] = useState(0); + + return {children}; +} + +// Consumer 컴포넌트 +function Counter() { + const { count, setCount } = useContext(CountContext); + + return ( +
+

카운트: {count}

+ +
+ ); +} + +// 사용 +function App() { + return ( + +
+

카운터 앱

+ + {/* 다른 컴포넌트들도 동일한 상태에 접근 가능 */} +
+
+ ); +} +``` + +### (3) 전역 상태관리 라이브러리 + +**1) Redux** + +- Flux 패턴 기반의 상태관리 라이브러리 +- 단일 스토어, 불변성, 순수 함수 리듀서가 핵심 원칙 + +```jsx +// 액션 타입 정의 +const INCREMENT = "INCREMENT"; + +// 액션 생성자 +const increment = () => ({ type: INCREMENT }); + +// 리듀서 +const counterReducer = (state = { count: 0 }, action) => { + switch (action.type) { + case INCREMENT: + return { ...state, count: state.count + 1 }; + default: + return state; + } +}; + +// 스토어 생성 +const store = createStore(counterReducer); + +// 리액트와 연결 +function Counter() { + const count = useSelector((state) => state.count); + const dispatch = useDispatch(); + + return ( +
+

카운트: {count}

+ +
+ ); +} +``` + +**2) Zustand** + +- 훅 기반의 간결한 상태관리 라이브러리 +- Redux보다 적은 보일러플레이트 코드 + +```jsx +import create from "zustand"; + +// 스토어 생성 +const useCountStore = create((set) => ({ + count: 0, + increment: () => set((state) => ({ count: state.count + 1 })), + decrement: () => set((state) => ({ count: state.count - 1 })), +})); + +// 컴포넌트에서 사용 +function Counter() { + const { count, increment, decrement } = useCountStore(); + + return ( +
+

카운트: {count}

+ + +
+ ); +} +``` + +**3) Jotai** + +- 원자(atom) 기반 상태관리 라이브러리 +- 작은 단위의 상태를 조합하여 사용 + +```jsx +import { atom, useAtom } from "jotai"; + +// 기본 atom 생성 +const countAtom = atom(0); + +// 파생 atom +const doubleCountAtom = atom((get) => get(countAtom) * 2); + +function Counter() { + const [count, setCount] = useAtom(countAtom); + const [doubleCount] = useAtom(doubleCountAtom); + + return ( +
+

카운트: {count}

+

2배 카운트: {doubleCount}

+ +
+ ); +} +``` + +**4) MobX** + +- 객체 지향적 접근 방식의 상태관리 라이브러리 +- 관찰 가능한(observable) 상태와 자동 반응(reaction)이 특징 + +```jsx +import { makeObservable, observable, action, computed } from "mobx"; +import { observer } from "mobx-react-lite"; + +// 상태 스토어 정의 +class CounterStore { + count = 0; + + constructor() { + makeObservable(this, { + count: observable, + increment: action, + decrement: action, + doubleCount: computed, + }); + } + + increment() { + this.count += 1; + } + + decrement() { + this.count -= 1; + } + + get doubleCount() { + return this.count * 2; + } +} + +// 스토어 인스턴스 생성 +const counterStore = new CounterStore(); + +// observer HOC로 컴포넌트 감싸기 +const Counter = observer(() => { + return ( +
+

카운트: {counterStore.count}

+

2배 카운트: {counterStore.doubleCount}

+ + +
+ ); +}); +``` + +- 불변성보다 가변성 채택(mutable state) +- 반응형 프로그래밍 패러다임 기반 +- 상태 변화를 자동으로 추적하고 UI 업데이트 +- Redux보다 적은 보일러플레이트 코드 +- 객체 지향 프로그래밍 스타일에 적합 + +### (4) 서버 상태 관리 도구 + +**1) TanStack Query** + +- 서버 데이터 가져오기, 캐싱, 동기화를 위한 라이브러리 +- 자동 리프레시, 데이터 무효화, 페이지네이션 지원 + +```jsx +import { useQuery, useMutation, queryClient } from "react-query"; + +// 데이터 조회 +function Products() { + const { data, isLoading, error } = useQuery("products", fetchProducts); + + if (isLoading) return
로딩 중...
; + if (error) return
에러: {error.message}
; + + return ( +
+ {data.map((product) => ( +
{product.name}
+ ))} +
+ ); +} + +// 데이터 변경 +function AddProduct() { + const mutation = useMutation(addProduct, { + onSuccess: () => { + queryClient.invalidateQueries("products"); + }, + }); + + return ( + + ); +} +``` + +**2) SWR (Stale-While-Revalidate)** + +- Vercel에서 개발한 데이터 가져오기 라이브러리 +- 캐시된 데이터를 먼저 보여주고 백그라운드에서 갱신 + +```jsx +import useSWR from "swr"; + +function Profile() { + const { data, error, isLoading } = useSWR("/api/user", fetcher); + + if (isLoading) return
로딩 중...
; + if (error) return
에러가 발생했습니다
; + + return
안녕하세요, {data.name}님!
; +} +``` + +이러한 상태관리 도구들은 각기 다른 사용 사례와 복잡성에 맞게 설계되었습니다. 어떤 도구를 선택할지는 애플리케이션의 규모, 팀의 선호도, 성능 요구사항 등에 따라 달라집니다. + +### Summary + +| **라이브러리** | **특징** | **장점** | **단점** | **성능 최적화 방법** | +| ------------------------ | ------------------------------- | ----------------------- | -------- | -------------------- | +| **Context API** | 컴포넌트 트리 전체 데이터 공유 | • props drilling 방지 | +| • React 내장 기능 | • 소비자 컴포넌트 전체 리렌더링 | +| • 깊은 중첩 시 성능 이슈 | • Context 분리 | +| • memo 활용 | +| **Redux** | 단일 스토어, Flux 패턴 | • 예측 가능한 상태 변화 | + +• 강력한 개발자 도구 +• 풍부한 미들웨어 | • 많은 보일러플레이트 +• 복잡한 설정 | • 선택적 구독 +• reselect +• immer | +| **Zustand** | 간결한 API, 훅 기반 | • 최소 보일러플레이트 +• 쉬운 통합 | • 제한된 개발자 도구 +• 대규모 앱에 제한적 | • 선택적 상태 구독 +• 상태 분리 | +| **Jotai** | 원자 기반, 상향식 접근 | • 세분화된 리렌더링 +• 조합 가능한 API | • 복잡한 상태 관계 관리 어려움 | • 최소한의 atom 구독 +• atom 분리 | +| **React Query** | 서버 데이터 특화 | • 자동 리프레시 +• 내장된 오류 처리 +• 페이지네이션 | • 클라이언트 상태에 오버헤드 | • 쿼리 키 설계 +• 캐시 무효화 정책 | + +## 3. 코드를 통해 살펴보기 + +### (1) useState: Modal로 입력폼을 관리하는 사례 + +```tsx +export const PostsManagerPage = () => { + const { onOpen } = useToggleState() + const postAddModalProps = useAddPostModal(); + + return ( + + + + 게시물 관리자 + + + + + + + + + + ) +} +``` + +```tsx +export const useAddPostModal = () => { + const { onClose } = useToggleState(); + const [formData, setFormData] = useState({ title: "", body: "", userId: 1 }); + + const addPostMutation = useMutation({ + ...postMutations.addMutation(), + onSuccess: () => { + setFormData({ title: "", body: "", userId: 1 }); + queryClient.invalidateQueries({ queryKey: ["posts"] }); + + handleClose(); + }, + onError: (error) => { + console.error("게시물 추가 오류:", error); + }, + }); + + const handleChange = ({ field, value }: { field: string; value: string | number }) => { + setFormData((prev) => ({ ...prev, [field]: value })); + }; + + const handleSubmit = () => { + addPostMutation.mutateAsync(formData); + }; + + const handleClose = () => { + setFormData({ title: "", body: "", userId: 1 }); + onClose("addPost"); + }; + + return { + formData, + onChange, + onSubmit, + pending: addPostMutation.isPending, + }; +}; +``` + +```tsx +interface PostAddModalProps { + formData: { + title: string; + body: string; + userId: number; + }; + onChange: (data: { field: string; value: string | number }) => void; + onSubmit: () => void; + pending: boolean; +} + +export const PostAddModal = ({ formData, onChange, onSubmit, pending }: PostAddModalProps) => { + const { isOpen, onClose } = useToggleState(); + return ( + onClose("addPost")} title='새 게시물 추가'> + onChange({ field, value })} + submitLabel={{ default: "게시물 추가", loading: "추가 중..." }} + onSubmit={onSubmit} + pending={pending} + /> + + ); +}; +``` + +```mermaid +sequenceDiagram + participant Hook as useAddPostModal + participant Page as PostsManagerPage + participant Modal as PostAddModal + participant Form as PostForm + + Note over Hook: 초기 상태 설정
(formData, mutation) + Hook-->>Page: formData, handlers 제공 + + Note over Page: 사용자가 '게시물 추가' 버튼 클릭 + Page->>Modal: formData, handlers 전달 + + Note over Modal: Modal 컴포넌트 렌더링 + Modal->>Form: formData, onChange, onSubmit 전달 + + Note over Form: 사용자가 폼 입력 + Form-->>Modal: onChange 이벤트 + Modal-->>Hook: handleChange 호출 + Hook-->>Hook: setFormData로 상태 업데이트 + + Note over Form: 사용자가 제출 + Form-->>Modal: onSubmit 이벤트 + Modal-->>Hook: handleSubmit 호출 + Hook-->>Hook: addPostMutation 실행 + + Note over Hook: 성공 시 + Hook-->>Hook: 1. formData 초기화 + Hook-->>Hook: 2. posts 쿼리 무효화 + Hook-->>Modal: 3. Modal 닫기 +``` + +1. useAddPostModal이 로컬 상태(useState)로 값이 관리되고 있습니다. +2. useAddPostModal에서 setState가 발생하면 PostsManagerPage이 렌더링 됩니다. +3. PostsManagerPage의 경우 여러 개의 Modal과 게시물 목록을 포함하여 수백 개의 컴포넌트를 렌더링하고 있습니다. +4. 결과적으로 PostManagerPage에서 렌더링이 발생 → 자식 컴포넌트에게 전파 되면서, 1회 입력당 60ms 이상의 렌더링 비용이 발생합니다. + +이를 개선하기 위해선 다음과 같은 과정이 필요합니다. + +1. PostForm 내부에 useState로 form의 상태를 정의하고, change 시점에 반영합니다. +2. PostForm에서 submit을 하는 경우 PostManagerPage에 알립니다. + +```tsx +// Page에서는 PostAddModal에 대한 관심사 제거 +export const PostsManagerPage = () => { + const { onOpen } = useToggleState() + + return ( + + + + 게시물 관리자 + + + + + + + + + + ) +} +``` + +```tsx +// AddPost에 대한 API 요청을 하나의 묶어서 관리 +export const useAddPost = () => { + const addPostMutation = useMutation({ + ...postMutations.addMutation(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["posts"] }); + }, + onError: (error) => { + console.error("게시물 추가 오류:", error); + }, + }); + + return { + submit: addPostMutation.mutateAsync, + pending: addPostMutation.isPending, + }; +}; + +// formData에 대한 관심사를 하나로 묶어서 관리 +export const useAddPostForm = () => { + const [formData, setFormData] = useState({ title: "", body: "", userId: 1 }); + + const changeFormData = ({ field, value }: { field: string; value: string | number }) => { + setFormData((prev) => ({ ...prev, [field]: value })); + }; + + return { + value: formData, + change: changeFormData, + }; +}; +``` + +```tsx +// PostAddModal이 자신의 상태를 스스로 책임지도록 함 +export const PostAddModal = () => { + const { isOpen, onClose } = useToggleState(); + + const formData = useAddPostForm(); + const addPost = useAddPost(); + const opened = isOpen("addPost"); + const close = () => onClose("addPost"); + + const handleSubmit = () => { + addPost.submit(formData.value); + close(); + }; + + return ( + + formData.change({ field, value })} + submitLabel={{ + default: "게시물 추가", + loading: "추가 중...", + }} + onSubmit={handleSubmit} + pending={addPost.pending} + /> + + ); +}; +``` + +AS-IS: input에 입력할 때 70ms 정도 시간 소요 + +TO-BE: input에 입력할 때 **3ms 정도의 시간** 소요 + +```mermaid +sequenceDiagram + participant Page as PostsManagerPage + participant Modal as PostAddModal + participant Form as PostForm + participant AddPost as useAddPost + participant AddForm as useAddPostForm + + Note over Modal: 초기화 + Modal->>AddForm: useAddPostForm() 호출 + AddForm-->>Modal: {value, change} 반환 + Modal->>AddPost: useAddPost() 호출 + AddPost-->>Modal: {submit, pending} 반환 + + Note over Page: 사용자가 '게시물 추가' 버튼 클릭 + Page->>Modal: isOpen("addPost") + + Note over Form: 사용자가 폼 입력 + Form->>Modal: onChange 호출 + Modal->>AddForm: change({field, value}) + AddForm->>AddForm: setFormData 실행 + + Note over Form: 사용자가 제출 버튼 클릭 + Form->>Modal: onSubmit 호출 + Modal->>AddPost: submit(formData) + AddPost->>AddPost: mutation.mutateAsync 실행 + + Note over AddPost: 성공 시 + AddPost->>AddPost: posts 쿼리 무효화 + Modal->>Modal: close() +``` + +1. PostAddModal 내부로 formData의 state 옮겼습니다. +2. PostAddModal 내부에서 상태 변화가 발생해도 PostsManagerPage까지 전파되지 않습니다. +3. PostAddModal의 관심사를 PostAddModal 스스로 관리하도록 만들었습니다. + +### (2) Context/Provider: with Drag&Drop 기반의 시간표 관리 프로그램 + +```jsx +function App() { + return ( + + + + + + + + ); +} +``` + +```jsx +export const useScheduleContext = () => { + const context = useContext(ScheduleContext); + if (context === undefined) { + throw new Error("useSchedule must be used within a ScheduleProvider"); + } + return context; +}; + +export const ScheduleProvider = ({ children }) => { + const [schedulesMap, setSchedulesMap] = useState({}); + const contextValue = { schedulesMap, setSchedulesMap }; + + return {children}; +}; +``` + +```jsx +export default function ScheduleDndProvider({ children }: PropsWithChildren) { + const { schedulesMap, setSchedulesMap } = useScheduleContext(); + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }) + ); + + const handleDragEnd = (event: any) => { + /* ...요약.... */ + }; + + return ( + + {children} + + ); +} +``` + +```jsx +const ScheduleTable = ({ tableId, schedules, onScheduleTimeClick, onDeleteButtonClick }: Props) => { + + ... + + const dndContext = useDndContext(); + const activeTableId = (() => { + const activeId = dndContext.active?.id; + return activeId ? String(activeId).split(":")[0] : null; + })(); + + return ( + + + + {schedules.map((schedule, index) => ( + + ))} + + ); +}; +``` + +```mermaid +sequenceDiagram + participant App + participant ScheduleProvider + participant ScheduleDndProvider + participant ScheduleTables + participant ScheduleTable + participant DraggableSchedule + + App->>ScheduleProvider: 1. Context 생성
(schedulesMap, setSchedulesMap) + ScheduleProvider->>ScheduleDndProvider: 2. DnD Context 초기화 + ScheduleDndProvider->>ScheduleTables: 3. DnD 기능 제공 + ScheduleTables->>ScheduleTable: 4. schedules 전달 + ScheduleTable->>DraggableSchedule: 5. schedule, id 전달 + + DraggableSchedule->>ScheduleDndProvider: 6. Drag 이벤트 발생 + ScheduleDndProvider->>ScheduleProvider: 7. handleDragEnd로 상태 업데이트 + ScheduleProvider-->>ScheduleTables: 8. 변경된 상태 전파 + ScheduleTables-->>ScheduleTable: 9. 변경된 schedules 전달 + ScheduleTable-->>DraggableSchedule: 10. 변경된 위치 적용 +``` + +1. 최상위 레이어에 Schedule 값을 관리하기 위한 Context를 정의했습니다. +2. ScheduleTable에 렌더링된 시간표 개체를 드래그할 수 있습니다. +3. ScheduleTable의 개체 하나를 드래그 하면 Drag/Drop 정보를 관리하는 Context에 접근하고, 개체를 움직시작하면 ScheduleContext와 DragContext에 각각 값이 업데이트 됩니다. +4. ScheduleTable 한 개당 최소 24개의 컴포넌트 (네모 상자 묶음)를 가지고 있고, 전부 Context를 가져다 사용하고 있습니다. +5. 드래그를 한 번 할 때 마다 모든 시간표 컴포넌트에 렌더링이 전파됩니다. +6. 1회 드래그를 할 때 마다 200ms 이상의 렌더링 비용이 소모되고, 보통 드래그 기반의 인터랙션은 수십 회의 렌더링을 필요로 합니다. +7. 결국 현재와 같은 시스텡믄 사용자가 어플리케이션을 사용하기 불가능한 수준의 상태가 됩니다. + +여기서 핵심은 Context의 관리방법입니다. Context의 값이 변경될 때, Context를 의존하는 모든 컴포넌트에 영향이 갑니다. 이를 최소화 하기 위해선 **Context를 작은 단위로 만들어서 관리**해야 합니다. + +먼저 drag가 다른 ScheduleTable로 전파되지 않도록 하는 방법입니다. + +```jsx +function App() { + return ( + + + + + + ); +} +``` + +```jsx +function ScheduleTables() { + const { schedulesMap, setSchedulesMap } = useScheduleContext(); + return ( + + {Object.entries(schedulesMap).map(([tableId, schedules], index) => ( + + + + + + + ))} + + ) +} +``` + +ScheduleDndProvider를 최상위 레이어에 씌워놓는게 아니라, 각각의 ScheduleTable에 씌워서 관리하도록 합니다. + +ScheduleTable에서 Drag/Drop을 하더라도 다른 스케쥴로 전파되지 않도록 만들 수 있습니다. + +```mermaid +sequenceDiagram + participant App + participant ScheduleProvider + participant ScheduleTables + participant ScheduleDndProvider + participant ScheduleTable + participant DraggableSchedule + participant DndContext + + App->>ScheduleProvider: 1. Context 생성 (schedulesMap) + ScheduleProvider-->>ScheduleTables: 2. Context 제공 + ScheduleTables->>+ScheduleDndProvider: 3. 각 테이블별 DnD 초기화 + ScheduleDndProvider->>DndContext: 4. DnD Context 생성 + ScheduleDndProvider-->>ScheduleTable: 5. DnD 기능 래핑 + ScheduleTable->>DraggableSchedule: 6. schedule, id 전달 + + Note over DraggableSchedule: 드래그 시작 + DraggableSchedule->>DndContext: 7. Drag 이벤트 발생 + DndContext->>ScheduleDndProvider: 8. handleDragEnd 호출 + ScheduleDndProvider->>ScheduleProvider: 9. setSchedulesMap으로 상태 갱신 + ScheduleProvider-->>ScheduleTables: 10. 갱신된 상태 전파 + ScheduleTables-->>ScheduleTable: 11. 갱신된 schedules 전달 +``` + +이를 통해 알 수 있는 부분은, 인터랙션이 빈번하게 발생하는 환경에서 컨텍스트를 전역상태로 만들어서 관리하는 경우 컨텍스트의 변화가 전역에 전파되고 불필요한 렌더링이 많이 발생한다는 것입니다. + +그래서 컨텍스트는 작은 단위로 만들어서 관리해야 하고, 꼭 필요한 곳에서만 사용하도록 만들어야 불필요한 렌더링을 예방할 수 있습니다. + +```jsx +function AppContextProvider({ children }) { + const [theme, setTheme] = useState("light"); + const [items, setItems] = useState(generateItems(1000)); + const [user, setUser] = useState(null); + const [notifications, setNotifications] = useState([]); + + 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, type) => { + const newNotification: Notification = { + id: Date.now(), + message, + type, + }; + setNotifications((prev) => [...prev, newNotification]); + }; + + const removeNotification = (id) => { + setNotifications((prev) => prev.filter((notification) => notification.id !== id)); + }; + + const contextValue = { + theme, + toggleTheme, + user, + login, + logout, + notifications, + addNotification, + removeNotification, + }; + + return {chidlren}; +} +``` + +- 테마, 사용자 데이터, 알림 등의 관심사가 뭉쳐져셔 관리되는 모습 +- 테마를 의존하는 컴포넌트 A가 있을 때, 사용자 데이터가 변경되어도 컴포넌트 A가 렌더링 됨 + +```jsx +function AppContextProvider({ children }) { + const [theme, setTheme] = useState("light"); + const [user, setUser] = useState(null); + const [notifications, setNotifications] = useState([]); + const [items, setItems] = useState(() => generateItems(1000)); + + const addItems = useCallback(() => { + setItems((prevItems) => [ + ...prevItems, + ...generateItems(1000, prevItems.length), + ]); + }, []); + + const toggleTheme = useCallback(() => { + setTheme((prevTheme) => (prevTheme === "light" ? "dark" : "light")); + }, []); + + const login = useCallback((email: string) => { + setUser({ id: 1, name: "홍길동", email }); + addNotification("성공적으로 로그인되었습니다", "success"); + }, []); + + const logout = useCallback(() => { + setUser(null); + addNotification("로그아웃되었습니다", "info"); + }, []); + + const addNotification = useCallback( + (message: string, type: Notification["type"]) => { + const newNotification: Notification = { + id: Date.now(), + message, + type, + }; + setNotifications((prev) => [...prev, newNotification]); + }, + [], + ); + + const removeNotification = useCallback((id: number) => { + setNotifications((prev) => + prev.filter((notification) => notification.id !== id), + ); + }, []); + + const themeContextValue = useMemo( + () => ({ theme, toggleTheme }), + [theme, toggleTheme], + ); + + const userContextValue = useMemo( + () => ({ user, login, logout }), + [user, login, logout], + ); + + const notificationContextValue = useMemo( + () => ({ + notifications, + addNotification, + removeNotification, + }), + [notifications, addNotification, removeNotification], + ); + + return ( + + + + {children} + + + + ) +} +``` + +- 관심사 단위로 컨텍스트를 분리하도록 하여 불필요한 렌더링을 방지하고, 코드에 대한 응집도를 높일 수 있습니다. + +```jsx +function AppContextProvider({ children }) { + const themeContextValue = useThemeUseCase(); + const userContextValue = useUserUseCase(); + const notificationContextValue = useNotificationUseCase(); + + return ( + + + + {children} + + + + ); +} + +const useUserContext = () => useContext(UserContext); +const useThemeContext = () => useContext(ThemeContext); +const useNotificationContext = () => useContext(NotificationContext); +``` + +- 분할된 컨텍스트 로직을 훅으로 묶어주는 모습 + +### (3) Zustand: TodoList를 통해 알아보기 + +이번에는 요구사항의 변화와 코드의 변화를 유심히 지켜봐주세요. + +**<할 일 관리 요구사항>** + +1. 사용자는 새로운 할 일을 추가할 수 있어야 함 +2. 사용자는 할 일을 완료 상태로 표시할 수 있어야 함 +3. 사용자는 할 일을 삭제할 수 있어야 함 +4. 사용자는 모든 할 일 목록을 볼 수 있어야 함 + +```tsx +export interface Todo { + id: string; + text: string; + completed: boolean; +} + +interface TodoState { + todos: Todo[]; + addTodo: (text: string) => void; + toggleTodo: (id: string) => void; + deleteTodo: (id: string) => void; +} + +export const useTodoStore = create()( + persist( + (set) => ({ + todos: [], + addTodo: (text) => + set((state) => ({ + todos: [...state.todos, { id: crypto.randomUUID(), text, completed: false }], + })), + toggleTodo: (id) => + set((state) => ({ + todos: state.todos.map((todo) => + todo.id === id ? { ...todo, completed: !todo.completed } : todo + ), + })), + deleteTodo: (id) => + set((state) => ({ + todos: state.todos.filter((todo) => todo.id !== id), + })), + }), + { + name: "todo-storage", + } + ) +); +``` + +TodoStore 내부에서 todos 값과 todos를 변경시키는 함수가 응집되어있습니다. + +```tsx +export function TodoForm() { + const [text, setText] = useState(""); + const addTodo = useTodoStore((state) => state.addTodo); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (text.trim()) { + addTodo(text); + setText(""); + } + }; + + return ( +
+ setText(e.target.value)} + placeholder='새로운 할 일을 입력하세요' + className='flex-1' + /> + +
+ ); +} +``` + +- TodoForm에서는 addTodo를 가져와서 사용합니다. + +```tsx +export function TodoList() { + const todos = useTodoStore((state) => state.todos); + + if (todos.length === 0) { + return ( +
+ 할 일이 없습니다. 새로운 할 일을 추가해보세요! +
+ ); + } + + return ( +
    + {todos.map((todo) => ( + + ))} +
+ ); +} + +function TodoItem({ todo }: { todo: Todo }) { + const toggleTodo = useTodoStore((state) => state.toggleTodo); + const deleteTodo = useTodoStore((state) => state.deleteTodo); + + return ( +
  • +
    + toggleTodo(todo.id)} + /> + +
    + +
  • + ); +} +``` + +- TodoList와 TodoItem에서는 todos를 가져와서 사용합니다. + +```mermaid +sequenceDiagram + participant TodoForm + participant TodoList + participant TodoItem + participant Store as Zustand Store + + Note over Store: 초기 상태 생성 (todos: []) + + TodoForm->>Store: useTodoStore(state => state.addTodo) + TodoList->>Store: useTodoStore(state => state.todos) + TodoItem->>Store: useTodoStore(state => state.toggleTodo)
    useTodoStore(state => state.deleteTodo) + + Note over TodoForm: 사용자가 할 일 입력 + TodoForm->>Store: addTodo(text) + Store-->>TodoList: 상태 변경 알림 + TodoList->>TodoList: 리렌더링 + + Note over TodoItem: 사용자가 할 일 체크 + TodoItem->>Store: toggleTodo(id) + Store-->>TodoItem: 상태 변경 알림 + TodoItem->>TodoItem: 리렌더링 + + Note over TodoItem: 사용자가 할 일 삭제 + TodoItem->>Store: deleteTodo(id) + Store-->>TodoList: 상태 변경 알림 + TodoList->>TodoList: 리렌더링 +``` + +현재는 큰 무리 없이 상태를 관리할 수 있어보입니다. 그런데 여기에 요구사항이 이렇게 추가되면 어떻게 될까요? + +**<추가된 요구사항>** + +1. 다른 사용자의 할 일 목록을 조회할 수 있습니다. +2. 할 일 목록이 한 화면에서 보여져야 합니다. + +간단하게 접근해보자면, Store에서 중첩된 배열로 TodoList를 관리하는 방법이 있습니다. + +```jsx +export interface TodoList { + id: string + userId: string + userName: string + todos: Todo[] +} + +interface TodoState { + todoLists: TodoList[] + addTodo: (listId: string, text: string) => void + toggleTodo: (listId: string, todoId: string) => void + deleteTodo: (listId: string, todoId: string) => void +} + +export const useTodoStore = create()( + (set) => ({ + todoLists: [ + { id: listId1, userId: "user1", userName: "유저 A", todos: [] }, + { id: listId2, userId: "user2", userName: "유저 B", todos: [] }, + { id: listId3, userId: "user3", userName: "유저 C", todos: [] }, + { id: listId4, userId: "user4", userName: "유저 D", todos: [] }, + { id: listId5, userId: "user5", userName: "유저 E", todos: [] }, + { id: listId6, userId: "user6", userName: "유저 F", todos: [] }, + ], + addTodo: (listId, text) => + set((state) => ({ + todoLists: state.todoLists.map((list) => + list.id === listId + ? { + ...list, + todos: [...list.todos, { id: crypto.randomUUID(), text, completed: false }], + } + : list, + ), + })), + toggleTodo: (listId, todoId) => + set((state) => ({ + todoLists: state.todoLists.map((list) => + list.id === listId + ? { + ...list, + todos: list.todos.map((todo) => + todo.id === todoId ? { ...todo, completed: !todo.completed } : todo, + ), + } + : list, + ), + })), + deleteTodo: (listId, todoId) => + set((state) => ({ + todoLists: state.todoLists.map((list) => + list.id === listId + ? { + ...list, + todos: list.todos.filter((todo) => todo.id !== todoId), + } + : list, + ), + })), + }), +) +``` + +```jsx +function App() { + const { todoLists } = useTodoStore(); + + return ( +
    +
    +

    + 다중 사용자 할 일 목록 +

    +
    +
    + {todoLists.map((todoList) => ( + + ))} +
    +
    +
    +
    + ); +} + +export function TodoList({ todoList }: TodoListProps) { + const { id, userName, todos } = todoList; + + return ( +
    +
    +

    {userName}의 할 일 목록

    + + {todos.length === 0 ? ( +
    + 할 일이 없습니다. 새로운 할 일을 추가해보세요! +
    + ) : ( +
      + {todos.map((todo) => ( + + ))} +
    + )} +
    +
    + ); +} +``` + +어느 정도 요구사항은 만족했습니다. 다만 상태의 덩어리가 너무 크고, 각각의 컴포넌트에 이게 계속 전파되는 모습입니다. + +이 문제를 해결하기 위해선 다음과 같은 접근이 필요합니다. + +1. 전역 상태를 **정규화(normalized)**된 상태로 관리하여 각 사용자의 할일 목록을 O(1) 의 비용으로 가져올 수 있도록 합니다. + + ```jsx + const createdDummyTodos = () => Array.from({ length: 5 }).map((_, index) => ({ + id: crypto.randomUUID(), + text: `할 일 ${index + 1}`, + completed: false + })) + + export const useTodoStore = create()( + (set, get) => ({ + todosUserMap: { + listId1: { id: "listId1", userId: "user1", userName: "유저 A", todos: [] }, + listId2: { id: "listId2", userId: "user2", userName: "유저 B", todos: createdDummyTodos() }, + listId3: { id: "listId3", userId: "user3", userName: "유저 C", todos: createdDummyTodos() }, + }, + getTodoIds: () => Object.keys(get().todosUserMap), + addTodo: (id, text) => ..., + toggleTodo: (id, todoId) => ..., + deleteTodo: (id, todoId) => ..., + }), + ) + ``` + +2. 정규화를 하면 crud에 대한 로직이 단순해집니다. + + ```jsx + export const useTodoStore = create()( + (set, get) => ({ + todosUserMap: {...}, + getTodoIds: () => Object.keys(get().todosUserMap), + addTodo: (id, text) => set(({ todosUserMap }) => ({ + todosUserMap: { + ...todosUserMap, + [id]: { + ...todosUserMap[id], + todos: todosUserMap[id].todos.concat([ + { + id: crypto.randomUUID(), + text, + completed: false, + }, + ]), + } + }, + })), + toggleTodo: (id, todoId) => ..., + deleteTodo: (id, todoId) => ..., + }), + ) + ``` + +3. 아예 순수 함수로 분리할 수 있습니다. + + ```jsx + const appendTodoItem = (itemMap, { id, text }) => ({ + ...itemMap, + [id]: { + ...itemMap[id], + todos: itemMap[id].todos.concat([ + { + id: crypto.randomUUID(), + text, + completed: false, + }, + ]), + } + }) + + export const useTodoStore = create()( + (set, get) => ({ + todosUserMap: {...}, + getTodoIds: () => Object.keys(get().todosUserMap), + addTodo: (id, text) => set(({ todosUserMap }) => ({ + todosUserMap: appendTodoItem(todosUserMap, { id, text }) + }), + toggleTodo: (id, todoId) => ..., + deleteTodo: (id, todoId) => ..., + }), + ) + ``` + + 순수함수로 분리하는 경우 손쉽게 테스트를 작성할 수 있습니다. + +Store를 사용하는 코드는 다음과 같이 달라집니다. + +```jsx +function App() { + const getTodoIds = useTodoStore((state) => state.getTodoIds); + const listIds = getTodoIds(); + + return ( +
    +
    +

    + 다중 사용자 할 일 목록 +

    +
    +
    + {listIds.map((listId) => ( + + ))} +
    +
    +
    +
    + ); +} +``` + +```jsx +function TodoList({ id }: TodoListProps) { + const { userName, todos } = useTodoStore((state) => state.todosUserMap[id]); + + return ( +
    +
    +

    {userName}의 할 일 목록

    + + {todos.length === 0 ? ( +
    + 할 일이 없습니다. 새로운 할 일을 추가해보세요! +
    + ) : ( +
      + {todos.map((todo) => ( + + ))} +
    + )} +
    +
    + ); +} +``` + +redux, jotai 등 전역상태 라이브러리는 selector를 토대로, 가져오는 값이 변경될 때에만 렌더링이 발생하도록 합니다. + +selector를 사용하지 않을 경우, 상태 변이가 더 예민하게 반응하고 렌더링이 쉽게 전파됩니다. + +```jsx +export function TodoList({ id }: TodoListProps) { + const { todosUserMap } = useTodoStore(); + const { userName, todos } = todosUserMap[id] + + return (...) +} +``` + +```jsx +export function TodoList({ id }: TodoListProps) { + const { userName, todos } = useTodoStore(state => state.todosUserMap[id]) + + return (...) +} +``` + +AS-IS: todosUserMap이 변화할 때 마다 렌더링이 발생 + +TO-BE: todosUserMap[id]가 변화할 때 렌더링이 발생 + +그리고 `useStore` `useStoreWithEqualityFn` 등을 사용하는 방법도 있습니다. + +```jsx +const getTodoIds = useTodoStore((state) => state.getTodoIds); +const listIds = getTodoIds(); +``` + +```jsx +const listIds = useStore(useTodoStore, (state) => Object.keys(state.todosUserMap)); +``` + +```jsx +const listIds = useStoreWithEqualityFn( + useTodoStore, + (state) => Object.keys(state.todosUserMap), + (a, b) => JSON.stringify(a) === JSON.stringify(b) +); +``` + +jotai, redux, zustand 를 같이 비교해보겠습니다. + +```jsx +import { configureStore, createSlice } from "@reduxjs/toolkit"; +import { useSelector } from "react-redux"; +import { createSelector } from "reselect"; + +// Redux 초기 상태 및 슬라이스 생성 (리듀서 없이) +const todosSlice = createSlice({ + name: "todos", + initialState: { + todosMap: todosUserMap, + }, + reducers: {}, +}); + +// 스토어 설정 +const store = configureStore({ + reducer: { + todos: todosSlice.reducer, + }, +}); + +// Redux 셀렉터 생성 +// 1. 기본 셀렉터 - 전체 데이터 맵 가져오기 +const selectTodosMap = (state) => state.todos.todosMap; + +// 2. 특정 사용자의 할 일 목록을 가져오는 셀렉터 +const selectUserTodoList = (state, userId) => { + const userListId = Object.values(state.todos.todosMap).find((list) => list.userId === userId)?.id; + return userListId ? state.todos.todosMap[userListId] : null; +}; + +// 3. 모든 사용자 이름 목록을 가져오는 셀렉터 +const selectUserNames = (state) => Object.values(state.todos.todosMap).map((list) => list.userName); + +// 4. 특정 사용자의 완료된 할 일 수를 가져오는 셀렉터 +const selectCompletedTodoCount = (state, userId) => { + const userListId = Object.values(state.todos.todosMap).find((list) => list.userId === userId)?.id; + + if (!userListId) return 0; + return state.todos.todosMap[userListId].todos.filter((todo) => todo.completed).length; +}; + +// 5. 메모이제이션된 셀렉터 (Reselect 사용) +const selectTotalTodoCount = createSelector([selectTodosMap], (todosMap) => + Object.values(todosMap).reduce((total, list) => total + list.todos.length, 0) +); + +// 6. 특정 리스트ID로 직접 데이터 가져오는 셀렉터 +const selectSpecificList = (state, listId) => state.todos.todosMap[listId]; + +// Redux 셀렉터 사용 예시 (컴포넌트 없이) +// useSelector 호출은 실제로는 컴포넌트 내부에서 이루어져야 하지만, 예시를 위해 표현 +const userId = "user2"; +const listId = listIds[1]; + +const todoMap = useSelector(selectTodosMap); +const userTodoList = useSelector((state) => selectUserTodoList(state, userId)); +const userNames = useSelector(selectUserNames); +const completedCount = useSelector((state) => selectCompletedTodoCount(state, userId)); +const totalTodos = useSelector(selectTotalTodoCount); +const specificList = useSelector((state) => selectSpecificList(state, listId)); +``` + +```jsx +import { atom, useAtomValue } from "jotai"; +import { selectAtom } from "jotai/utils"; + +// 기본 아톰 생성 +const todosMapAtom = atom(todosUserMap); + +// 파생 아톰 및 셀렉터 생성 +// 1. 특정 사용자 할 일 목록을 가져오는 아톰 +const userTodoListAtom = (userId) => + atom((get) => { + const todosMap = get(todosMapAtom); + const userListId = Object.values(todosMap).find((list) => list.userId === userId)?.id; + return userListId ? todosMap[userListId] : null; + }); + +// 2. 모든 사용자 이름 목록을 가져오는 아톰 +const userNamesAtom = atom((get) => Object.values(get(todosMapAtom)).map((list) => list.userName)); + +// 3. 특정 사용자의 완료된 할 일 수를 가져오는 아톰 +const completedTodoCountAtom = (userId) => + atom((get) => { + const todosMap = get(todosMapAtom); + const userListId = Object.values(todosMap).find((list) => list.userId === userId)?.id; + + if (!userListId) return 0; + return todosMap[userListId].todos.filter((todo) => todo.completed).length; + }); + +// 4. 모든 사용자의 할 일 총 개수를 가져오는 아톰 +const totalTodoCountAtom = atom((get) => + Object.values(get(todosMapAtom)).reduce((total, list) => total + list.todos.length, 0) +); + +// 5. 특정 리스트ID로 직접 데이터 가져오는 아톰 +const specificListAtom = (listId) => atom((get) => get(todosMapAtom)[listId]); + +// 6. selectAtom 유틸리티를 사용한 셀렉터 예시 +const completedTodosAtom = selectAtom(todosMapAtom, (todosMap) => { + const result = {}; + Object.entries(todosMap).forEach(([listId, list]) => { + result[listId] = list.todos.filter((todo) => todo.completed); + }); + return result; +}); + +// Jotai 셀렉터 사용 예시 (컴포넌트 없이) +// useAtomValue 호출은 실제로는 컴포넌트 내부에서 이루어져야 하지만, 예시를 위해 표현 +const userId = "user2"; +const listId = listIds[1]; + +const todosMap = useAtomValue(todosMapAtom); +const userTodoList = useAtomValue(userTodoListAtom(userId)); +const userNames = useAtomValue(userNamesAtom); +const completedCount = useAtomValue(completedTodoCountAtom(userId)); +const totalTodos = useAtomValue(totalTodoCountAtom); +const specificList = useAtomValue(specificListAtom(listId)); +const completedTodos = useAtomValue(completedTodosAtom); +``` + +### Summary + +**<추가로 고민해보면 좋은 지점>** + +- useReducer, zustand, redux 같은 것들을 사용하는 경우 react 렌더링 시스템에 대한 의존성을 낮출 수 있고, 이는 테스트를 효과적으로 관리할 수 있도록 해줍니다. +- 상태관리에서 발생하는 대부분의 문제는 “결합도”가 높고 “응집도”가 낮은 모습과 관련 있습니다. 상태의 응집도를 높이면 자연스럽게 렌더링도 응집도 있게 관리할 수 있게 됩니다. +- 응집도를 높이기 위해 custom hook을 적극적으로 사용하면 좋습니다. custom hook을 재활용을 목적으로 구성할 수도 있지만 관심사를 분리하고 응집도를 높이기 위해 custom hook을 활용할 수 있습니다. +- 상태관리의 코드를 변경하지 않고 렌더링을 최적화 하려면 useMemo, useCallback, memo 등을 적극적으로 사용해야 합니다. 하지만 이는 불필요한 비용을 소모하게 만들 수 있으므로 가능하다면 상태관리 시스템을 잘 구축하여 불필요한 메모이제이션을 남발하지 않도록 해야합니다. + +**** + +- 컨텍스트를 최적화 하려면 컨텍스트에서 관리하는 값을 작게 쪼개서 컨텍스트의 종류를 많이 만들어야 합니다. +- 컨텍스트를 참조하는 컴포넌트를 최소화 해야합니다. +- 상태관리 라이브러리는 정의는 큰 덩어리로 해도, 상태를 작은 단위로 가져와서 사용할 수 있는 장치를 제공합니다. +- react에서 제공하는 zustand, redux, jotai 등은 useSyncExternalStore와 useSyncExternalStoreWithSelector를 기반으로 만들어졌습니다. 이를 이용하면 직접 상태관리 라이브러리를 만들 수도 있습니다. +- context는 어플리케이션의 상태를 관리하기보단, 컴포넌트 그룹의 UI 상태를 관리하는 방식으로 쓰면 효과적입니다. (컴파운드 컴포넌트) diff --git a/.claude/docs/4-2-1.Front-end Performance Optimization Catalog.md b/.claude/docs/4-2-1.Front-end Performance Optimization Catalog.md new file mode 100644 index 0000000..261d8ac --- /dev/null +++ b/.claude/docs/4-2-1.Front-end Performance Optimization Catalog.md @@ -0,0 +1,114 @@ +## 참고자료 + +[프론트엔드에서 가장 중요한데 가장 안하는거](https://www.youtube.com/watch?v=_8deUuO0iJ8) + +[FE Guide](https://ui.toast.com/fe-guide/ko) + +## 1. 코드 레벨 최적화 + +### 1.1 JavaScript 최적화 + +- 코드 분할 및 레이지 로딩 +- 불필요한 렌더링 최소화 (React의 메모이제이션 등) +- 이벤트 델리게이션 +- 디바운싱과 쓰로틀링 +- 메모리 누수 방지 + +### 1.2 CSS 최적화 + +- 크리티컬 CSS 인라인화 +- CSS 애니메이션 최적화 (transform, opacity 사용) +- 불필요한 스타일 제거 + +### 1.3 HTML 최적화 + +- DOM 구조 최적화 +- 적절한 시맨틱 태그 사용 + +### 1.4 리소스 최적화 + +- 이미지, 폰트 등 미디어 자원 최적화 +- 스프라이트 이미지 사용 + +### 1.5 렌더링 최적화 + +- 가상 스크롤링 구현 +- 레이아웃 스래싱 방지 +- will-change 속성 사용 + +### 1.6 프레임워크 특화 최적화 + +- React, Vue, Angular 등 프레임워크별 최적화 기법 적용 + +## 2. 인프라 레벨 최적화 + +### 2.1 서버 최적화 + +- 서버 사이드 렌더링 (SSR) 구현 +- 정적 사이트 생성 (SSG) 활용 +- API 응답 최적화 + +### 2.2 네트워크 최적화 + +- CDN 활용 +- HTTP/2, HTTP/3 활용 +- 압축 (Gzip, Brotli 등) 적용 + +### 2.3 캐싱 전략 + +- 브라우저 캐시 설정 최적화 +- 서비스 워커 구현 +- 메모리 캐시, 디스크 캐시 활용 + +### 2.4 로드 밸런싱 + +- 트래픽 분산을 통한 응답 시간 개선 + +### 2.5 데이터베이스 최적화 + +- 쿼리 최적화 +- 인덱싱 전략 + +### 2.6 컨테이너화 및 마이크로서비스 + +- Docker를 이용한 컨테이너화 +- 마이크로서비스 아키텍처 도입 + +### 2.7 클라우드 서비스 활용 + +- Auto-scaling 구현 +- 서버리스 아키텍처 활용 + +## 3. 통합적 접근 + +### 3.1 성능 모니터링 및 분석 + +- 실시간 모니터링 도구 활용 +- 로그 분석 및 성능 메트릭 추적 + +### 3.2 보안 최적화 + +- HTTPS 적용 +- 컨텐츠 보안 정책 (CSP) 설정 + +### 3.3 프로그레시브 웹 앱 (PWA) + +- 서비스 워커를 통한 오프라인 기능 구현 +- 푸시 알림 활용 + +| 특성 | 코드 레벨 최적화 | 인프라 레벨 최적화 | +| ------------ | ------------------------------------- | ---------------------------------- | +| 주요 영역 | JavaScript, CSS, HTML, 리소스 관리 | 서버, 네트워크, 캐싱, 데이터베이스 | +| 구현 주체 | 프론트엔드 개발자 | 백엔드 개발자, DevOps 엔지니어 | +| 구현 난이도 | 중간 ~ 높음 | 높음 | +| 적용 범위 | 특정 애플리케이션 | 전체 시스템 또는 여러 애플리케이션 | +| 변경 주기 | 빈번함 | 상대적으로 덜 빈번함 | +| 즉시성 | 빠른 적용 및 결과 확인 가능 | 적용 및 결과 확인에 시간 소요 | +| 확장성 | 제한적 | 높음 | +| 비용 효율성 | 대체로 저비용 | 초기 투자 비용이 높을 수 있음 | +| 성능 영향 | 로컬 성능 향상에 직접적 | 전체 시스템 성능에 광범위한 영향 | +| 유지보수 | 지속적인 코드 관리 필요 | 설정 후 상대적으로 안정적 | +| 팀 협업 | 주로 프론트엔드 팀 내 | 여러 팀 간 협업 필요 | +| 도구 및 기술 | 번들러, 프레임워크, 최적화 라이브러리 | 클라우드 서비스, CDN, 캐싱 솔루션 | +| 주요 이점 | 세밀한 성능 튜닝 가능 | 대규모 성능 향상 및 안정성 개선 | +| 제한 사항 | 브라우저/디바이스 제약 | 인프라 제약, 비용 | diff --git a/.claude/docs/4-2-2.To measure performance.md b/.claude/docs/4-2-2.To measure performance.md new file mode 100644 index 0000000..7034c8b --- /dev/null +++ b/.claude/docs/4-2-2.To measure performance.md @@ -0,0 +1,198 @@ +# 참고자료 + +[Bora Lee on LinkedIn: [프론트엔드 성능 모니터링(측정편) - 1] 현대 경영학의 아버지 피터 드러커는 “측정할 수 없으면 관리할 수 없고, 관리할 수…](https://www.linkedin.com/posts/learner-bora_프론트엔드-성능-모니터링측정편-1-현대-경영학의-아버지-피터-activity-7226187837338009600-FtBz?utm_source=share&utm_medium=member_desktop) + +[Bora Lee on LinkedIn: [프론트엔드 성능 모니터링(측정편) - 2] 앞서 소개한 PageSpeed Insights의 경우 여러 가지 장점이 있으나, 실제…](https://www.linkedin.com/posts/learner-bora_프론트엔드-성능-모니터링측정편-2-앞서-소개한-pagespeed-activity-7226655481723858944-CCBz?utm_source=share&utm_medium=member_desktop) + +[Core Web Vitals - Chrome Web Store](https://chromewebstore.google.com/detail/core-web-vitals/adeniimnihmbpgpbljmnohjpoolmgabj) + +[웹 바이탈 확장 프로그램을 사용하여 코어 웹 바이탈 문제 디버그  |  Articles  |  web.dev](https://web.dev/articles/debug-cwvs-with-web-vitals-extension?hl=ko) + +[](https://developer.mozilla.org/en-US/docs/Web/API/Performance_API/Using_the_Performance_API) + +[Profiler – React](https://react.dev/reference/react/Profiler) + +[실적 패널: 웹사이트 실적 분석  |  Chrome DevTools  |  Chrome for Developers](https://developer.chrome.com/docs/devtools/performance/overview?hl=ko) + +[성능 최적화](https://ui.toast.com/fe-guide/ko_PERFORMANCE) + +[](https://www.webpagetest.org/) + +[커스텀 측정항목  |  Articles  |  web.dev](https://web.dev/articles/custom-metrics?hl=ko) + +# 1. 프론트엔드 성능의 종류와 측정 방법 + +[사용자 중심 성능 측정항목  |  Articles  |  web.dev](https://web.dev/articles/user-centric-performance-metrics?hl=ko) + +## (1) 측정할 수 있는 성능의 종류 + +### 1) 로딩 성능 + +- **First Contentful Paint (FCP):** 페이지의 첫 번째 콘텐츠(텍스트, 이미지 등)가 렌더링되는 시간을 측정합니다. 사용자가 페이지가 **로딩되었음을 인지할 수 있는 첫 시점**입니다. + [First Contentful Paint (FCP)  |  Articles  |  web.dev](https://web.dev/articles/fcp?hl=ko) +- **Largest Contentful Paint (LCP):** 가장 큰 콘텐츠가 렌더링되는 시간으로, 사용자가 페이지가 **거의 로드되었다고 느끼는 시점**을 반영합니다. + [Largest Contentful Paint (LCP)  |  Articles  |  web.dev](https://web.dev/articles/lcp?hl=ko) +- **Interaction to Next Paint(INP)**: INP는 사용자가 페이지와 함께 한 모든 **상호작용**의 지연 시간을 관찰하고 모든 상호작용 또는 거의 모든 상호작용이 아래에 있는 단일 값을 보고합니다. + [Interaction to Next Paint(다음 페인트와의 상호작용)(INP)  |  Articles  |  web.dev](https://web.dev/articles/inp?hl=ko) +- **Time to Interactive (TTI):** 페이지가 완전히 상호작용 가능해지는 시점까지의 시간입니다. 모든 주요 리소스가 로드되고 이벤트 핸들러가 설정된 시점입니다. + [상호작용 시작 시간 (TTI)  |  Articles  |  web.dev](https://web.dev/articles/tti?hl=ko) +- **Total Blocking Time (TBT):** 페이지 로딩 중 사용자가 인터랙션을 시도할 때 발생하는 지연 시간을 측정합니다. + [총 차단 시간 (TBT)  |  Articles  |  web.dev](https://web.dev/articles/tbt?hl=ko) +- **Speed Index:** 페이지의 시각적 콘텐츠가 얼마나 빨리 완전히 로드되는지를 측정하는 지표입니다. + [속도 색인  |  Lighthouse  |  Chrome for Developers](https://developer.chrome.com/docs/lighthouse/performance/speed-index?hl=ko) + +### 2) 렌더링 성능 + +- **Frame Rate (FPS):** 애니메이션이나 스크롤 시의 부드러움을 나타내며, 초당 프레임 수를 측정합니다. +- **Cumulative Layout Shift (CLS):** 페이지가 로드되는 동안 발생하는 예상치 못한 레이아웃 변경의 빈도를 측정합니다. 사용자가 예상치 못한 움직임을 경험하게 되는 경우가 줄어들도록 도와줍니다. + [Cumulative Layout Shift (CLS)  |  Articles  |  web.dev](https://web.dev/articles/cls?hl=ko) +- **Long Tasks:** 메인 스레드에서 실행되는 긴 작업(50ms 이상)으로 인해 페이지 응답성이 저하되는 시간을 측정합니다. + [장기 작업 최적화  |  Articles  |  web.dev](https://web.dev/articles/optimize-long-tasks?hl=ko) + +### 3) 자바스크립트 성능 + +- **Execution Time:** 자바스크립트 코드가 실행되는 시간을 측정하여 성능을 분석합니다. +- **Memory Usage:** 자바스크립트 코드가 사용하고 있는 메모리의 양을 측정합니다. 메모리 누수가 없는지, 효율적으로 메모리가 사용되고 있는지를 파악할 수 있습니다. + [할당 프로파일러 도구 사용 방법  |  Chrome DevTools  |  Chrome for Developers](https://developer.chrome.com/docs/devtools/memory-problems/allocation-profiler?hl=ko) + [measureUserAgentSpecificMemory()로 웹페이지의 총 메모리 사용량을 모니터링합니다.  |  Articles  |  web.dev](https://web.dev/articles/monitor-total-page-memory-usage?hl=ko) + [JavaScript의 메모리 관리 - JavaScript | MDN](https://developer.mozilla.org/ko/docs/Web/JavaScript/Memory_management) + [당신이 모르는 자바스크립트의 메모리 누수의 비밀](https://ui.toast.com/weekly-pick/ko_20210611) +- **Garbage Collection 빈도 및 지속 시간:** 가비지 컬렉터가 메모리를 정리하는 빈도와 그 과정에서의 시간 소모를 측정합니다. + [가비지 컬렉션](https://ko.javascript.info/garbage-collection) + +### 4) 네트워크 성능 + +- **Resource Size:** 페이지에 필요한 리소스(이미지, CSS, JS 파일 등)의 크기를 측정합니다. 파일 크기가 클수록 로드 시간이 증가할 수 있습니다. +- **Number of Requests:** 페이지 로딩 시 발생하는 네트워크 요청 수를 측정합니다. 요청 수가 많을수록 페이지 로드 속도가 느려질 수 있습니다. +- **Time to First Byte (TTFB):** 사용자가 요청을 보낸 후, 서버에서 첫 번째 바이트를 받기까지 걸리는 시간을 측정합니다. 서버 응답 시간을 개선하는 데 중요한 지표입니다. + +## (2) 성능을 측정하는 도구 + +### 1) 브라우저 내장 도구 + +- **Chrome DevTools** + - **Performance 탭:** 전체 페이지 성능을 분석할 수 있으며, 로딩 성능, 자바스크립트 실행 시간, 렌더링 성능 등을 상세히 살펴볼 수 있습니다. + [실적 패널: 웹사이트 실적 분석  |  Chrome DevTools  |  Chrome for Developers](https://developer.chrome.com/docs/devtools/performance/overview?hl=ko) + - **Network 탭:** 페이지의 모든 네트워크 요청을 분석하여 로딩 성능을 최적화할 수 있습니다. + [네트워크 패널: 네트워크 부하 및 리소스 분석  |  Chrome DevTools  |  Chrome for Developers](https://developer.chrome.com/docs/devtools/network/overview?hl=ko) + - **Memory 탭:** 메모리 사용량과 가비지 컬렉션을 분석하여 자바스크립트 성능을 최적화할 수 있습니다. + [메모리 패널 개요  |  Chrome DevTools  |  Chrome for Developers](https://developer.chrome.com/docs/devtools/memory?hl=ko) +- **Lighthouse:** 웹 페이지의 성능, 접근성, SEO 등을 종합적으로 평가하는 도구입니다. 자동화된 보고서를 통해 성능을 개선할 수 있는 방법을 제시합니다. + [개요  |  Lighthouse  |  Chrome for Developers](https://developer.chrome.com/docs/lighthouse/overview?hl=ko) + +### 2) 외부 성능 측정 도구 + +- **WebPageTest:** 웹 페이지의 성능을 다양한 환경에서 테스트할 수 있는 도구로, 상세한 성능 분석 결과를 제공합니다. + [](https://www.webpagetest.org/) + [웹페이지 성능/속도측정 - www.webpagetest.org](https://test-myid.tistory.com/4) +- **Google Search Console:** 사이트의 기본 성능 이외에 검색 성능등을 모니터링하고 최적화 하는데 사용하는 무료도구로, 웹사이트 소유자만 접근 가능합니다. 성능 이외에도 SEO 최적화를 위한 모니터링과 수단을 설명해줍니다. + [](https://search.google.com/search-console/welcome) +- **GTmetrix:** 페이지 로딩 속도와 관련된 다양한 지표를 제공하며, 개선 사항을 제안해 줍니다. + [GTmetrix | Website Performance Testing and Monitoring](https://gtmetrix.com/) + [내 웹사이트 로딩 속도 측정 체크 방법1 - gtmetrix.com - 사이트 최적화 하기](https://blog.naver.com/myappkorea/222226836260) +- **PageSpeed Insights:** Google에서 제공하는 도구로, 모바일과 데스크톱 환경에서의 페이지 성능을 평가합니다. + [PageSpeed Insights](https://pagespeed.web.dev/?hl=ko) + [맞춤형 풀스택 옵저버빌리티 및 보안 | Datadog](https://www.datadoghq.com/ko/dg/monitor/personalized-demo-request/?utm_source=google&utm_medium=paid-search&utm_campaign=dg-brand-apac-ko-brand&utm_keyword=datadog&utm_matchtype=p&igaag=158938765812&igaat=&igacm=20569180520&igacr=674510540631&igakw=datadog&igamt=p&igant=g&utm_campaignid=20569180520&utm_adgroupid=158938765812&gad_source=1) + [PageSpeed Insights 정보  |  Google for Developers](https://developers.google.com/speed/docs/insights/v5/about?hl=ko) + +### 3) 성능 모니터링 서비스 + +- **New Relic:** 애플리케이션 성능을 실시간으로 모니터링하고, 문제 발생 시 알림을 제공합니다. 서버 성능까지 포함하여 전체적인 성능 분석이 가능합니다. + [Monitor, Debug and Improve Your Entire Stack](https://newrelic.com/) + [여기어때 서비스 모니터링 : New Relic 이야기](https://techblog.gccompany.co.kr/여기어때-서비스-모니터링-new-relic-이야기-dad36583dec4) +- **Datadog:** 애플리케이션, 서버, 데이터베이스 등 다양한 환경의 성능을 모니터링할 수 있으며, 통합된 대시보드를 제공합니다. + [Datadog 소개](https://velog.io/@kameals/system-monitoring-datadog-intro) +- **Sentry:** 자바스크립트 오류 추적 및 성능 모니터링에 특화된 도구로, 버그와 성능 문제를 실시간으로 파악할 수 있습니다. + [Application Performance Monitoring & Error Tracking Software](https://sentry.io/welcome/) + [Sentry로 우아하게 프론트엔드 에러 추적하기 | 카카오페이 기술 블로그](https://tech.kakaopay.com/post/frontend-sentry-monitoring/) + [Sentry로 사내 에러 로그 수집 시스템 구축하기](https://engineering.linecorp.com/ko/blog/log-collection-system-sentry-on-premise) + [프론트엔드 에러 로그 시스템 Sentry 적용기](https://urbanbase.github.io/dev/2021/03/04/Sentry.html) +- Azure Application Insights: Microsoft Azure 클라우드 플랫폼의 일부로, 웹 애플리케이션의 성능과 사용량을 모니터링하고 분석하는 도구입니다. 상대적으로 간단한 설정으로 설치 가능하고, Azure 인프라를 사용하는 경우 데이터 적재 등의 연동에 유리합니다. + [Application Insights 개요 - Azure Monitor](https://learn.microsoft.com/ko-kr/azure/azure-monitor/app/app-insights-overview) + +## (3) 코드를 통해서 직접 성능을 측정해보기 + +### 1) Web API를 이용한 성능 측정 + +```jsx +// Performance API를 사용한 시간 측정 +const startTime = performance.now(); + +// 측정하고자 하는 작업 수행 +heavyComputation(); + +const endTime = performance.now(); +console.log(`작업 실행 시간: ${endTime - startTime} 밀리초`); + +// Navigation Timing API를 사용한 페이지 로드 시간 측정 +window.addEventListener("load", () => { + const navigationTiming = performance.getEntriesByType("navigation")[0]; + console.log( + `페이지 로드 시간: ${navigationTiming.loadEventEnd - navigationTiming.navigationStart} 밀리초` + ); +}); +``` + +```bash +# 결과물 예시 +작업 실행 시간: 543.2 밀리초 +페이지 로드 시간: 1245.7 밀리초 +``` + +### 2) Core Web Vitals 측정 + +```jsx +const observer = new PerformanceObserver((list) => { + for (const entry of list.getEntries()) { + if (entry.entryType === "largest-contentful-paint") { + console.log(`LCP: ${entry.startTime}`); + } + if (entry.entryType === "layout-shift") { + console.log(`CLS: ${entry.value}`); + } + if (entry.entryType === "first-input") { + console.log(`FID: ${entry.processingStart - entry.startTime}`); + } + } +}); + +observer.observe({ entryTypes: ["largest-contentful-paint", "layout-shift", "first-input"] }); +``` + +```bash +# 결과물 예시 +LCP: 2345.6 +CLS: 0.1 +FID: 95.3 +``` + +### 3) React 성능 프로파일링 + +```jsx +import React, { Profiler } from "react"; + +function onRenderCallback( + id, + phase, + actualDuration, + baseDuration, + startTime, + commitTime, + interactions +) { + console.log(`컴포넌트 ${id}의 렌더링 시간: ${actualDuration}`); +} + +function MyComponent() { + return ( + + {/* 컴포넌트 내용 */} + + ); +} +``` + +```bash +# 결과물 예시 +컴포넌트 MyComponent의 렌더링 시간: 78.5 +``` diff --git a/.claude/docs/4-2-3. Code Perspective Performance Optimization Case.md b/.claude/docs/4-2-3. Code Perspective Performance Optimization Case.md new file mode 100644 index 0000000..f364881 --- /dev/null +++ b/.claude/docs/4-2-3. Code Perspective Performance Optimization Case.md @@ -0,0 +1,1405 @@ +## 코드 분할 및 레이지 로딩 최적화 사례 + +### AS-IS + +대규모 싱글 페이지 애플리케이션(SPA)에서 모든 JavaScript 코드를 하나의 큰 번들로 제공하여 초기 로딩 시간이 매우 길었습니다. + +```jsx +// App.js +import React from "react"; +import { BrowserRouter as Router, Route, Switch } from "react-router-dom"; +import Home from "./components/Home"; +import About from "./components/About"; +import Contact from "./components/Contact"; +import LargeComponent1 from "./components/LargeComponent1"; +import LargeComponent2 from "./components/LargeComponent2"; + +function App() { + return ( + + + + + + + + + + ); +} + +export default App; +``` + +### TO-BE + +코드 분할과 레이지 로딩을 적용하여 필요한 코드만 로드하도록 변경했습니다. + +- Why: 초기 로딩 시간을 줄이고 사용자 경험을 개선하기 위해 +- How: + ```jsx + // App.js + import React, { Suspense, lazy } from "react"; + import { BrowserRouter as Router, Route, Switch } from "react-router-dom"; + + const Home = lazy(() => import("./components/Home")); + const About = lazy(() => import("./components/About")); + const Contact = lazy(() => import("./components/Contact")); + const LargeComponent1 = lazy(() => import("./components/LargeComponent1")); + const LargeComponent2 = lazy(() => import("./components/LargeComponent2")); + + function App() { + return ( + + Loading...}> + + + + + + + + + + ); + } + + export default App; + ``` + 1. React.lazy()와 Suspense를 사용하여 컴포넌트 레벨에서 코드 분할 구현 + 2. 웹팩의 dynamic import를 활용하여 라우트 기반 코드 분할 적용 + 3. 중요하지 않은 리소스는 Intersection Observer API를 사용하여 레이지 로딩 구현 +- Result: + 1. 초기 로딩 시간 감소 + 2. Time to Interactive(TTI) 개선 + 3. 전체 페이지 중량 감소 + +### 참고자료 + +- React 공식 문서: Code-Splitting (https://reactjs.org/docs/code-splitting.html) +- 웹팩 문서: Lazy Loading (https://webpack.js.org/guides/lazy-loading/) + +--- + +## 웹 폰트 FOUT/FOIT 최적화 사례 + +### AS-IS + +웹 폰트 로딩 중 FOUT(Flash of Unstyled Text) 또는 FOIT(Flash of Invisible Text) 현상으로 사용자 경험이 저하되었습니다. + +```jsx +/* styles.css */ +@font-face { + font-family: 'CustomFont'; + src: url('custom-font.woff2') format('woff2'); +} + +body { + font-family: 'CustomFont', sans-serif; +} +``` + +### TO-BE + +폰트 로딩 전략을 최적화하여 FOUT/FOIT 현상을 최소화하고 일관된 사용자 경험을 제공했습니다. + +- Why: 텍스트 깜빡임 현상을 줄이고 일관된 타이포그래피를 제공하기 위해 +- How: + ```css + /* styles.css */ + @font-face { + font-family: "CustomFont"; + src: url("custom-font.woff2") format("woff2"); + + /* + font-display: swap을 사용하여 폰트가 로드되는 동안 시스템 폰트를 표시하고, + FontFace API를 사용하여 폰트 로딩을 프로그래매틱하게 제어합니다. + 이를 통해 텍스트가 보이지 않는 현상을 방지하고 일관된 사용자 경험을 제공할 수 있습니다. + */ + font-display: swap; + } + + body { + font-family: "CustomFont", Arial, sans-serif; + } + ``` + ```jsx + // font-loader.js + if ("fonts" in document) { + const font = new FontFace("CustomFont", "url(custom-font.woff2)"); + font.load().then(() => { + document.fonts.add(font); + document.body.classList.add("fonts-loaded"); + }); + } + ``` + 1. font-display 속성 최적화 (swap, optional, fallback 등) + 2. FontFace API를 사용한 프로그래매틱 폰트 로딩 + 3. 로컬 폰트와 유사한 폴백 폰트 지정 + 4. FOFT(Flash of Faux Text) 기법 적용 +- Result: + 1. 사용자가 인지하는 폰트 변경 깜빡임 현상 감소 + 2. 초기 컨텐츠 가시성 향상 + 3. 일관된 타이포그래피로 인한 사용자 경험 개선 + +### 참고자료 + +- CSS-Tricks: FOUT, FOIT, FOFT (https://css-tricks.com/fout-foit-foft/) +- web.dev: Avoid invisible text during font loading (https://web.dev/avoid-invisible-text/) + +--- + +## 이벤트 위임(Event Delegation) 최적화 사례 + +### AS-IS + +많은 수의 요소에 개별적으로 이벤트 리스너를 추가하여 메모리 사용량이 높고 성능이 저하되었습니다. + +```html +
    + +
    +

    상품 1

    +

    가격: 10,000원

    + +
    +
    +

    상품 2

    +

    가격: 20,000원

    + +
    + +
    +``` + +```jsx +// 모든 "장바구니 담기" 버튼에 개별적으로 이벤트 리스너 추가 +const addToCartButtons = document.querySelectorAll(".add-to-cart"); + +addToCartButtons.forEach((button) => { + button.addEventListener("click", function () { + const productId = this.closest(".product-card").dataset.productId; + addToCart(productId); + }); +}); + +function addToCart(productId) { + console.log(`상품 ${productId}를 장바구니에 담았습니다.`); + // 실제 장바구니 추가 로직... +} +``` + +### TO-BE + +이벤트 위임 패턴을 적용하여 이벤트 핸들링을 최적화했습니다. + +- Why: 메모리 사용량을 줄이고 동적으로 추가되는 요소들의 이벤트 처리를 효율적으로 하기 위해 +- How: + ```jsx + // 상품 목록 컨테이너에 단일 이벤트 리스너 추가 + const productList = document.getElementById("product-list"); + + productList.addEventListener("click", function (event) { + if (event.target.classList.contains("add-to-cart")) { + const productId = event.target.closest(".product-card").dataset.productId; + addToCart(productId); + } + }); + + function addToCart(productId) { + console.log(`상품 ${productId}를 장바구니에 담았습니다.`); + // 실제 장바구니 추가 로직... + } + + // 동적으로 새 상품 추가 (예시) + function addNewProduct(productId, name, price) { + const newCard = document.createElement("div"); + newCard.className = "product-card"; + newCard.dataset.productId = productId; + newCard.innerHTML = ` +

    ${name}

    +

    가격: ${price}원

    + + `; + productList.appendChild(newCard); + } + + // 새 상품 추가 예시 + addNewProduct(101, "새 상품", 30000); + ``` + 1. 공통 부모 요소에 단일 이벤트 리스너 추가 + 2. event.target을 사용하여 실제 이벤트가 발생한 요소 식별 + 3. 이벤트 처리 로직에 조건문 추가하여 특정 요소만 처리 + 4. 필요한 경우 event.stopPropagation() 사용하여 이벤트 전파 제어 +- Result: + 1. 이벤트 리스너 수 감소 + 2. 메모리 사용량 절감 + 3. 동적으로 추가되는 요소들의 이벤트 처리 용이성 증가 + +### 참고자료 + +- MDN: Event delegation (https://developer.mozilla.org/en-US/docs/Learn/JavaScript/Building_blocks/Events#Event_delegation) +- [JavaScript.info](http://javascript.info/): Event delegation (https://javascript.info/event-delegation) +- 참고로 리액트는 이벤트를 다 이벤트 위임 방식으로 관리하고 있습니다. + [React에서는 이벤트를 어떻게 처리하고 있을까?](https://puki4416blog.netlify.app/how-to-react-event-handle/) + +--- + +## 이미지 최적화 사례 Case 1 + +### AS-IS + +대용량 이미지를 한 번에 로딩하여 초기 페이지 로드 시간이 길고 사용자 경험이 좋지 않았습니다. + +### TO-BE + +프로그레시브 이미지 로딩 기법을 적용하여 사용자 경험을 개선하고 로딩 성능을 최적화했습니다. + +- Why: 초기 페이지 로드 시간을 단축하고 사용자에게 시각적 피드백을 빠르게 제공하기 위해 +- How: + ```html + + + + + + 프로그레시브 이미지 로딩 + + + +
    + 샘플 이미지 +
    + + + + + ``` + 1. 저해상도 이미지를 먼저 로드하여 플레이스홀더로 사용 + 2. 고해상도 이미지를 비동기적으로 로드 + 3. CSS blur 효과를 사용하여 저해상도에서 고해상도로의 전환을 부드럽게 처리 + 4. Intersection Observer API를 사용하여 뷰포트에 진입한 이미지만 로드 +- Result: + 1. 초기 페이지 로드 시간 감소 + 2. 사용자의 인지된 로딩 속도 향상 + 3. 데이터 사용량 최적화로 모바일 사용자 경험 개선 + +### 참고자료 + +- web.dev: Lazy loading images (https://web.dev/lazy-loading-images/) +- CSS-Tricks: Progressive Image Loading (https://css-tricks.com/the-complete-guide-to-lazy-loading-images/) + +--- + +## 이미지 최적화 사례 Case 2 + +### AS-IS + +대용량 이미지 파일들을 최적화 없이 그대로 사용하여 페이지 로딩 속도가 느리고 모바일 사용자의 데이터 소비가 많았습니다. + +```jsx + + + + + + Image Gallery + + + +
    + Description 1 + Description 2 + Description 3 + +
    + + + + +``` + +### TO-BE + +다양한 이미지 최적화 기법을 적용하여 이미지 로딩 속도와 품질을 개선했습니다. + +- Why: 페이지 로딩 속도를 개선하고 모바일 사용자의 데이터 사용량을 줄이기 위해 +- How: + ```html + + + + + + Optimized Image Gallery + + + +
    + + + + Description 1 + + +
    + + + + + ``` + - 지연 로딩 (Lazy Loading): Intersection Observer API를 사용하여 뷰포트에 가까워질 때만 이미지를 로드 + - 반응형 이미지: CSS `background-image`를 사용하여 이미지 크기를 유연하게 조절 + - WebP 형식 지원: 브라우저가 지원하는 경우 WebP 형식의 이미지를 우선적으로 로드 + - 플레이스홀더 사용: 이미지가 로드되기 전에 SVG 플레이스홀더를 표시하여 레이아웃 시프트를 방지 + - 점진적 로딩: 저해상도 플레이스홀더에서 고해상도 이미지로 자연스럽게 전환 +- Result: + 1. 페이지 로드 시간 감소 + 2. 이미지 관련 데이터 전송량 감소 + 3. 모바일 사용자의 이탈률 감소 + +### 참고자료 + +- web.dev: Optimize your images (https://web.dev/fast/#optimize-your-images) +- MDN: Responsive images (https://developer.mozilla.org/en-US/docs/Learn/HTML/Multimedia_and_embedding/Responsive_images) + +--- + +## 로컬 스토리지를 활용한 상태 지속성 최적화 사례 + +### AS-IS + +페이지 새로고침 시 모든 상태가 초기화되어 사용자 경험이 단절되고 불필요한 API 호출이 발생했습니다. + +```jsx +let cart = []; + +function addToCart(productId) { + // API를 호출하여 상품 정보를 가져옴 + fetch(`/api/products/${productId}`) + .then((response) => response.json()) + .then((product) => { + cart.push(product); + updateCartUI(); + }); +} + +function removeFromCart(productId) { + cart = cart.filter((item) => item.id !== productId); + updateCartUI(); +} + +function updateCartUI() { + const cartElement = document.getElementById("cart"); + cartElement.innerHTML = cart + .map( + (item) => ` +
  • + ${item.name} - $${item.price} + +
  • + ` + ) + .join(""); +} + +// 페이지 로드 시 장바구니 UI 업데이트 +updateCartUI(); +``` + +### TO-BE + +로컬 스토리지를 활용하여 중요 상태를 유지하고 필요한 데이터만 서버에서 갱신하도록 최적화했습니다. + +- Why: 페이지 새로고침 시에도 일관된 사용자 경험을 제공하고 불필요한 네트워크 요청을 줄이기 위해 +- How: + ```jsx + let cart = []; + + function loadCart() { + const savedCart = localStorage.getItem("cart"); + if (savedCart) { + cart = JSON.parse(savedCart); + updateCartUI(); + } + } + + function saveCart() { + localStorage.setItem("cart", JSON.stringify(cart)); + } + + function addToCart(productId) { + // 먼저 로컬 스토리지를 확인 + const existingProduct = cart.find((item) => item.id === productId); + if (existingProduct) { + existingProduct.quantity += 1; + saveCart(); + updateCartUI(); + } else { + // API를 호출하여 새로운 상품 정보만 가져옴 + fetch(`/api/products/${productId}`) + .then((response) => response.json()) + .then((product) => { + cart.push({ ...product, quantity: 1 }); + saveCart(); + updateCartUI(); + }); + } + } + + function removeFromCart(productId) { + cart = cart.filter((item) => item.id !== productId); + saveCart(); + updateCartUI(); + } + + function updateCartUI() { + const cartElement = document.getElementById("cart"); + cartElement.innerHTML = cart + .map( + (item) => ` +
  • + ${item.name} - $${item.price} x ${item.quantity} + +
  • + ` + ) + .join(""); + } + + // 페이지 로드 시 로컬 스토리지에서 장바구니 정보 로드 + document.addEventListener("DOMContentLoaded", loadCart); + + // 주기적으로 카트 데이터 서버와 동기화 (선택적) + function syncCartWithServer() { + fetch("/api/sync-cart", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(cart), + }) + .then((response) => response.json()) + .then((updatedCart) => { + cart = updatedCart; + saveCart(); + updateCartUI(); + }); + } + + // 예: 5분마다 서버와 동기화 + setInterval(syncCartWithServer, 5 * 60 * 1000); + + /* + 주의사항: + - 민감한 정보는 로컬 스토리지에 저장하지 않도록 주의해야 합니다. + - 브라우저의 로컬 스토리지 용량 제한(보통 5-10MB)을 고려해야 합니다. + - 여러 기기 간 동기화가 필요한 경우, 추가적인 서버 동기화 로직이 필요할 수 있습니다. + */ + ``` + 1. 중요 상태 데이터를 로컬 스토리지에 저장 + 2. 페이지 로드 시 로컬 스토리지의 데이터를 먼저 확인하여 상태 초기화 + 3. 백그라운드에서 필요한 데이터만 서버와 동기화 + 4. 데이터 만료 시간 설정으로 오래된 데이터 자동 갱신 +- Result: + 1. 페이지 새로고침 후 초기 로딩 시간 단축 + 2. 불필요한 API 호출 감소 + 3. 오프라인 상태에서도 기본적인 기능 사용 가능 + +### 참고자료 + +- MDN: Web Storage API (https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API) +- web.dev: Storage for the web (https://web.dev/storage-for-the-web/) + +--- + +## CSS 애니메이션 성능 최적화 사례 + +### AS-IS + +JavaScript를 사용한 애니메이션으로 인해 메인 스레드에 부하가 걸리고 성능이 저하되었습니다. + +```html + + +
    + + +``` + +### TO-BE + +CSS 애니메이션을 활용하여 GPU 가속을 적용하고 성능을 최적화했습니다. + +- Why: 부드러운 애니메이션을 제공하고 CPU 사용량을 줄여 전반적인 성능을 개선하기 위해 +- How: + ```html + + +
    + + + ``` +- how + 1. GPU 가속: `transform` 속성을 사용하여 GPU 가속을 활용합니다. 이는 `left`와 `top`을 변경하는 것보다 훨씬 효율적입니다. + 2. 메인 스레드 부하 감소: 애니메이션이 CSS에 의해 처리되므로 JavaScript 실행으로 인한 메인 스레드 부하가 없습니다. + 3. 선언적 정의: 애니메이션의 시작과 끝 상태를 명확하게 정의하여 코드의 가독성이 향상됩니다. + 4. `will-change` 속성: 브라우저에게 변화할 속성을 미리 알려줌으로써 최적화를 유도합니다. +- 추가적인 최적화 팁: + 1. 복잡한 애니메이션의 경우, `opacity`와 `transform` 속성만을 애니메이션화하는 것이 가장 효율적입니다. + 2. 애니메이션이 필요 없는 요소에는 `pointer-events: none`을 적용하여 불필요한 이벤트 처리를 방지할 수 있습니다. + 3. 3D 변형을 사용하면(`transform: translate3d(0,0,0)`) 강제로 GPU 가속을 활성화할 수 있습니다. 하지만 남용은 피해야 합니다. + + + +### 참고자료 + +- MDN: CSS animations (https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_Animations/Using_CSS_animations) +- web.dev: Animations and performance (https://web.dev/animations-and-performance/) + +--- + +## 메모이제이션을 통한 불필요한 렌더링 최소화 사례 (React) + +### AS-IS + +대규모 React 애플리케이션에서 불필요한 리렌더링이 빈번하게 발생하여 성능 저하의 원인이 되었습니다. + +```jsx +// App.js +import React, { useState } from "react"; +import ProductList from "./ProductList"; +import Cart from "./Cart"; + +function App() { + const [products, setProducts] = useState([ + { id: 1, name: "상품 1", price: 10000 }, + { id: 2, name: "상품 2", price: 20000 }, + // ... 더 많은 상품들 + ]); + const [cart, setCart] = useState([]); + + const addToCart = (productId) => { + const product = products.find((p) => p.id === productId); + setCart([...cart, product]); + }; + + return ( +
    + + +
    + ); +} + +// ProductList.js +import React from "react"; +import ProductItem from "./ProductItem"; + +function ProductList({ products, addToCart }) { + return ( +
    + {products.map((product) => ( + + ))} +
    + ); +} + +// ProductItem.js +import React from "react"; + +function ProductItem({ product, addToCart }) { + console.log(`Rendering ProductItem: ${product.name}`); + return ( +
    +

    {product.name}

    +

    가격: {product.price}원

    + +
    + ); +} + +export default ProductItem; +``` + +- 장바구니에 상품을 추가할 때마다 모든 `ProductItem` 컴포넌트가 리렌더링됩니다. +- `addToCart` 함수가 매 렌더링마다 새로 생성되어 자식 컴포넌트의 불필요한 리렌더링을 유발합니다. + +### TO-BE + +React의 메모이제이션 기능을 활용하여 불필요한 리렌더링을 최소화했습니다. + +- Why: 애플리케이션의 반응 속도를 개선하고 CPU 사용량을 줄이기 위해 +- How: + ```jsx + // App.js + import React, { useState, useCallback } from "react"; + import ProductList from "./ProductList"; + import Cart from "./Cart"; + + function App() { + const [products, setProducts] = useState([ + { id: 1, name: "상품 1", price: 10000 }, + { id: 2, name: "상품 2", price: 20000 }, + // ... 더 많은 상품들 + ]); + const [cart, setCart] = useState([]); + + const addToCart = useCallback( + (productId) => { + setCart((prevCart) => { + const product = products.find((p) => p.id === productId); + return [...prevCart, product]; + }); + }, + [products] + ); + + return ( +
    + + +
    + ); + } + + // ProductList.js + import React, { useMemo } from "react"; + import ProductItem from "./ProductItem"; + + function ProductList({ products, addToCart }) { + const productItems = useMemo( + () => + products.map((product) => ( + + )), + [products, addToCart] + ); + + return
    {productItems}
    ; + } + + // ProductItem.js + import React, { memo } from "react"; + + const ProductItem = memo(({ product, addToCart }) => { + console.log(`Rendering ProductItem: ${product.name}`); + return ( +
    +

    {product.name}

    +

    가격: {product.price}원

    + +
    + ); + }); + + export default ProductItem; + ``` + 1. `useCallback`을 사용하여 `addToCart` 함수를 메모이제이션합니다. 이로 인해 불필요한 리렌더링이 줄어듭니다. + 2. `ProductList` 컴포넌트에서 `useMemo`를 사용하여 상품 목록이 변경될 때만 자식 컴포넌트를 다시 렌더링합니다. + 3. `ProductItem` 컴포넌트를 `React.memo`로 감싸 props가 변경될 때만 리렌더링되도록 합니다. +- Result: + 1. 컴포넌트 리렌더링 횟수 감소 + 2. CPU 사용량 감소 (대신 메모리 사용량 증가) + +### 참고자료 + +- React 공식 문서: Memoizing Components (https://reactjs.org/docs/react-api.html#reactmemo) +- React 공식 문서: Hooks API Reference (https://reactjs.org/docs/hooks-reference.html) + +--- + +## 상태 관리 최적화 사례 (React) + +### AS-IS + +대규모 React 애플리케이션에서 전역 상태 관리가 비효율적이어서 불필요한 리렌더링이 자주 발생했습니다. + +### TO-BE + +상태 관리 라이브러리와 최적화 기법을 적용하여 리렌더링을 최소화하고 애플리케이션 성능을 개선했습니다. + +- Why: 불필요한 리렌더링을 줄여 애플리케이션의 반응성을 높이고 성능을 개선하기 위해 +- How: + 1. Redux 대신 Recoil이나 Jotai 같은 경량 상태 관리 라이브러리 도입 + 2. 상태를 더 작은 단위로 분리하여 관리 (Atomic 패턴 적용) + 3. useSelector 훅 사용 시 메모이제이션 적용 + 4. 상태 변경 시 불변성을 지키며 업데이트하는 로직 구현 +- Result: + 1. 컴포넌트 리렌더링 횟수 50% 감소 + 2. 상태 업데이트 시 반응 속도 40% 개선 + 3. 메모리 사용량 20% 절감 + +### 참고자료 + +- Recoil 공식 문서: https://recoiljs.org/ +- Redux 공식 문서: https://redux.js.org/tutorials/essentials/part-6-performance-normalization + +--- + +## 인터섹션 옵저버를 활용한 스크롤 성능 최적화 사례 + +### AS-IS + +긴 스크롤 페이지에서 모든 요소를 한 번에 렌더링하여 초기 로딩이 느리고 스크롤 성능이 저하되었습니다. + +```jsx +import React from "react"; + +const LongScrollPage = () => { + const items = Array.from({ length: 1000 }, (_, index) => ({ + id: index, + title: `Item ${index}`, + image: `https://via.placeholder.com/150?text=Image${index}`, + })); + + return ( +
    + {items.map((item) => ( +
    +

    {item.title}

    + {item.title} +

    Some description for {item.title}

    +
    + ))} +
    + ); +}; + +export default LongScrollPage; +``` + +### TO-BE + +Intersection Observer API를 활용하여 뷰포트에 있는 요소만 렌더링하도록 최적화했습니다. + +- Why: 초기 렌더링 속도를 높이고 스크롤 시 부드러운 사용자 경험을 제공하기 위해 +- How: + ```jsx + import React, { useState, useEffect, useRef } from "react"; + + // 범용 LazyLoad 컴포넌트 + const LazyLoad = ({ children, placeholder }) => { + const [isVisible, setIsVisible] = useState(false); + const ref = useRef(null); + + useEffect(() => { + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setIsVisible(true); + observer.disconnect(); + } + }, + { rootMargin: "100px" } + ); + + if (ref.current) { + observer.observe(ref.current); + } + + return () => { + if (ref.current) { + observer.unobserve(ref.current); + } + }; + }, []); + + return
    {isVisible ? children : placeholder}
    ; + }; + + // 아이템 컴포넌트 + const Item = ({ title, image, description }) => ( +
    +

    {title}

    + {title} +

    {description}

    +
    + ); + + // 메인 컴포넌트 + const LongScrollPage = () => { + const items = Array.from({ length: 1000 }, (_, index) => ({ + id: index, + title: `Item ${index}`, + image: `https://via.placeholder.com/150?text=Image${index}`, + description: `Description for Item ${index}`, + })); + + return ( +
    + {items.map((item) => ( + + Loading... +
    + } + > + + + ))} + + ); + }; + + export default LongScrollPage; + ``` + 1. `IntersectionObserver`를 사용하여 각 아이템의 가시성을 추적합니다. + 2. 뷰포트 밖의 요소는 플레이스홀더로 대체됩니다. + 3. 요소가 뷰포트에 진입할 때 실제 콘텐츠로 교체됩니다. + 4. 이미지에 `loading="lazy"` 속성을 추가하여 브라우저의 네이티브 이미지 지연 로딩을 활용합니다. +- Result: + 1. 초기 렌더링 속도가 크게 향상됩니다. 처음에는 뷰포트 내의 아이템만 렌더링됩니다. + 2. 메모리 사용량이 줄어듭니다. 실제 콘텐츠는 필요할 때만 렌더링됩니다. + 3. 스크롤 성능이 개선됩니다. 렌더링되는 실제 DOM 요소의 수가 줄어듭니다. + 4. 사용자 경험이 향상됩니다. 스크롤이 부드러워지고, 콘텐츠가 자연스럽게 로드됩니다. +- 주의할 점: + - SEO를 고려해야 하는 경우, 서버 사이드 렌더링과 함께 사용하는 것이 좋습니다. + - 네트워크 요청이 많아질 수 있으므로, 적절한 캐싱 전략을 함께 사용하는 것이 좋습니다. + +### 참고자료 + +- MDN: Intersection Observer API (https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API) +- web.dev: Lazy loading images and video (https://web.dev/lazy-loading/) + +--- + +## 가상 스크롤링(Virtual Scrolling) 구현 사례 + +### AS-IS + +대량의 데이터를 리스트로 표시할 때 모든 항목을 한 번에 렌더링하여 초기 로딩 속도와 스크롤 성능이 매우 저하되었습니다. + +```jsx +import React from "react"; + +const LargeList = ({ items }) => { + return ( +
    + {items.map((item) => ( +
    + {item.name} +
    +

    {item.name}

    +

    {item.email}

    +
    +
    + ))} +
    + ); +}; + +const App = () => { + // 10,000개의 아이템을 가정 + const items = Array.from({ length: 10000 }, (_, index) => ({ + id: index, + name: `User ${index}`, + email: `user${index}@example.com`, + avatar: `https://via.placeholder.com/50?text=${index}`, + })); + + return ; +}; + +export default App; +``` + +### TO-BE + +가상 스크롤링 기법을 적용하여 화면에 보이는 항목만 렌더링하도록 최적화했습니다. + +- Why: 대량의 데이터를 효율적으로 표시하고 스크롤 성능을 개선하기 위해 +- How: + ```jsx + import React from "react"; + import { FixedSizeList as List } from "react-window"; + import AutoSizer from "react-virtualized-auto-sizer"; + + const Row = ({ index, style, data }) => { + const item = data[index]; + return ( +
    + {item.name} +
    +

    {item.name}

    +

    {item.email}

    +
    +
    + ); + }; + + const VirtualList = ({ items }) => { + return ( + + {({ height, width }) => ( + + {Row} + + )} + + ); + }; + + const App = () => { + // 10,000개의 아이템을 가정 + const items = Array.from({ length: 10000 }, (_, index) => ({ + id: index, + name: `User ${index}`, + email: `user${index}@example.com`, + avatar: `https://via.placeholder.com/50?text=${index}`, + })); + + return ( +
    + +
    + ); + }; + + export default App; + ``` + 1. `react-window` 라이브러리의 `FixedSizeList` 컴포넌트를 사용하여 가상 스크롤링을 구현합니다. + 2. `AutoSizer`를 사용하여 컨테이너의 크기에 따라 리스트 크기를 자동으로 조정합니다. + 3. `Row` 컴포넌트는 각 아이템을 렌더링하는 로직을 담당합니다. 이 컴포넌트는 필요할 때만 렌더링됩니다. + 4. `itemSize`를 80으로 설정하여 각 아이템의 높이를 지정합니다. 실제 사용 시에는 아이템의 실제 크기에 맞게 조정해야 합니다. + 5. 전체 리스트의 높이를 뷰포트 높이(`100vh`)로 설정하여 전체 화면에 맞추었습니다. + +result: + +- 메모리 사용량 감소: 화면에 보이는 항목만 렌더링하므로 메모리 사용량이 크게 줄어듭니다. +- 초기 렌더링 시간 단축: 전체 10,000개 항목이 아닌 화면에 보이는 항목만 렌더링하므로 초기 로딩 속도가 매우 빨라집니다. +- 스크롤 성능 향상: 스크롤 시 새로운 항목을 효율적으로 렌더링하여 부드러운 스크롤 경험을 제공합니다. + +### 참고자료 + +- react-window 문서: https://react-window.vercel.app/ +- web.dev: Virtualize large lists with react-window (https://web.dev/virtualize-long-lists-react-window/) + +--- + +## 프리페칭(Prefetching)을 통한 네비게이션 최적화 사례 + +### AS-IS + +사용자가 링크를 클릭할 때마다 새로운 페이지 리소스를 로드하여 페이지 전환이 느렸습니다. + +```jsx +// App.js +import React from "react"; +import { BrowserRouter as Router, Route, Link } from "react-router-dom"; +import Home from "./components/Home"; +import About from "./components/About"; +import Contact from "./components/Contact"; + +function App() { + return ( + +
    + + + + + +
    +
    + ); +} + +export default App; +``` + +### TO-BE + +프리페칭 기술을 도입하여 예상되는 사용자 행동에 따라 리소스를 미리 로드하도록 개선했습니다. + +- Why: 페이지 전환 속도를 개선하고 사용자 경험을 향상시키기 위해 +- How: + ```jsx + // App.js + import React, { useEffect } from "react"; + import { BrowserRouter as Router, Route, Link } from "react-router-dom"; + import { useInView } from "react-intersection-observer"; + + const Home = React.lazy(() => import("./components/Home")); + const About = React.lazy(() => import("./components/About")); + const Contact = React.lazy(() => import("./components/Contact")); + + function PrefetchLink({ to, children }) { + const [ref, inView] = useInView({ + triggerOnce: true, + rootMargin: "200px", + }); + + useEffect(() => { + if (inView) { + const componentMap = { + "/": () => import("./components/Home"), + "/about": () => import("./components/About"), + "/contact": () => import("./components/Contact"), + }; + + if (componentMap[to]) { + componentMap[to](); + } + } + }, [inView, to]); + + return ( + + {children} + + ); + } + + function App() { + useEffect(() => { + // About 페이지의 주요 리소스 프리페치 + const resources = ["/static/js/about.chunk.js", "/static/css/about.chunk.css"]; + + resources.forEach((resource) => { + const link = document.createElement("link"); + link.rel = "prefetch"; + link.href = resource; + document.head.appendChild(link); + }); + }, []); + + return ( + +
    + + + Loading...
    }> + + + + + +
    + ); + } + + export default App; + ``` + 1. 태그를 사용하여 중요 리소스 프리페치 + 2. Intersection Observer를 활용해 뷰포트에 들어온 링크의 리소스 프리페치 + 3. 사용자 행동 분석을 통해 자주 방문하는 페이지 예측 및 프리페치 + 4. Service Worker를 사용하여 백그라운드에서 리소스 프리페치 및 캐싱 + + [Service Worker를 사용하여 백그라운드에서 프리페치](https://www.notion.so/Service-Worker-2cd2dc3ef514813f9f64cdbaa60ff0b3?pvs=21) +- Result: + 1. 페이지 전환 시간 평균 감소 + 2. 사용자의 체감 로딩 속도 크게 개선 + 3. 바운스 레이트 감소 +- 주의할 점: + - 프리페치할 리소스의 정확한 경로를 알아야 합니다. 이는 보통 빌드 프로세스 후에 결정되므로, 동적으로 이 정보를 가져오는 방법을 고려해볼 수 있습니다. + - 너무 많은 리소스를 프리페치하면 초기 로딩 시 불필요한 네트워크 사용이 발생할 수 있으므로, 중요한 리소스만 선별적으로 프리페치해야 합니다. + +### 참고자료 + +- web.dev: Preload critical assets to improve loading speed (https://web.dev/preload-critical-assets/) +- Google Developers: Prefetching Resources to Improve Performance (https://developers.google.com/web/fundamentals/performance/resource-prioritization#prefetch) + +--- + +## 번들 크기 최적화 사례 + +### AS-IS + +대규모 SPA에서 단일 JavaScript 번들 파일의 크기가 매우 커서 초기 로딩 시간이 길었습니다. + +```jsx +// webpack.config.js +const path = require("path"); + +module.exports = { + entry: "./src/index.js", + output: { + filename: "bundle.js", + path: path.resolve(__dirname, "dist"), + }, +}; + +// src/index.js +import React from "react"; +import ReactDOM from "react-dom"; +import { BrowserRouter as Router, Route, Switch } from "react-router-dom"; +import moment from "moment"; +import lodash from "lodash"; +import Home from "./components/Home"; +import About from "./components/About"; +import Contact from "./components/Contact"; + +const App = () => ( + + + + + + + +); + +ReactDOM.render(, document.getElementById("root")); + +// 불필요하게 전체 라이브러리를 import +console.log(moment().format("MMMM Do YYYY, h:mm:ss a")); +console.log(lodash.chunk(["a", "b", "c", "d"], 2)); +``` + +### TO-BE + +번들 분석 및 최적화 기법을 적용하여 번들 크기를 줄이고 로딩 성능을 개선했습니다. + +- Why: 초기 페이지 로드 시간을 단축하고 사용자 경험을 개선하기 위해 +- How: + ```jsx + // webpack.config.js + const path = require("path"); + const BundleAnalyzerPlugin = require("webpack-bundle-analyzer").BundleAnalyzerPlugin; + const TerserPlugin = require("terser-webpack-plugin"); + + module.exports = { + entry: "./src/index.js", + output: { + filename: "[name].[contenthash].js", + path: path.resolve(__dirname, "dist"), + }, + optimization: { + minimizer: [new TerserPlugin()], + splitChunks: { + chunks: "all", + }, + }, + plugins: [new BundleAnalyzerPlugin()], + }; + + // src/index.js + import React, { lazy, Suspense } from "react"; + import ReactDOM from "react-dom"; + import { BrowserRouter as Router, Route, Switch } from "react-router-dom"; + import { format } from "date-fns"; + + const Home = lazy(() => import("./components/Home")); + const About = lazy(() => import("./components/About")); + const Contact = lazy(() => import("./components/Contact")); + + const App = () => ( + + Loading...}> + + + + + + + + ); + + ReactDOM.render(, document.getElementById("root")); + + // 필요한 기능만 import하여 사용 + console.log(format(new Date(), "MMMM do yyyy, h:mm:ss a")); + + // lodash 함수는 필요할 때 동적으로 import + if (needChunkFunction) { + import("lodash/chunk").then((module) => { + const chunk = module.default; + console.log(chunk(["a", "b", "c", "d"], 2)); + }); + } + ``` + 1. webpack-bundle-analyzer를 사용하여 번들 크기를 분석합니다. + 2. Tree shaking이 자동으로 적용되어 사용하지 않는 코드가 제거됩니다. + 3. React.lazy와 Suspense를 사용하여 코드 분할을 구현했습니다. + 4. moment.js 대신 더 가벼운 date-fns를 사용하고, 필요한 함수만 import 합니다. + 5. lodash는 필요한 함수만 동적으로 import 하여 사용합니다. + 6. TerserPlugin을 사용하여 코드 압축 및 최적화를 수행합니다. + 7. contenthash를 사용하여 장기 캐싱을 가능하게 합니다. +- Result: + 1. 메인 번들 크기 감소 + 2. 초기 로딩 시간 단축 + 3. Time to Interactive (TTI) 개선 + +### 참고자료 + +- webpack 공식 문서: Code Splitting (https://webpack.js.org/guides/code-splitting/) +- web.dev: Reduce JavaScript payloads with code splitting (https://web.dev/reduce-javascript-payloads-with-code-splitting/) + +--- diff --git a/.claude/docs/4-2-4. React profiling and basic optimization.md b/.claude/docs/4-2-4. React profiling and basic optimization.md new file mode 100644 index 0000000..616306a --- /dev/null +++ b/.claude/docs/4-2-4. React profiling and basic optimization.md @@ -0,0 +1,1047 @@ +## (2) 최적화 되지 않은 예제 코드 + +[front_3rd_chapter1-3/packages/example/src/common-sample/origin/App.tsx at main · hanghae-plus/front_3rd_chapter1-3](https://github.com/hanghae-plus/front_3rd_chapter1-3/blob/main/packages/example/src/common-sample/origin/App.tsx) + +```tsx +import { PropsWithChildren, useState } from "react"; + +type NewsCategory = "정치" | "경제" | "사회" | "문화"; + +interface NewsItem { + id: number; + title: string; + category: NewsCategory; + likes: number; + content: string; +} + +const NEWS_CATEGORIES = ["정치", "경제", "사회", "문화"] as const; + +const generateNewsData = (count: number): NewsItem[] => { + return Array.from({ length: count }, (_, index) => ({ + id: index + 1, + title: `뉴스 제목 ${index + 1}`, + category: [...NEWS_CATEGORIES].sort(() => Math.random() - 0.5)[0], + likes: Math.floor(Math.random() * 100), + content: `이것은 뉴스 ${index + 1}의 내용입니다. 실제 내용은 더 길 것입니다.`, + })); +}; + +const newsItems = generateNewsData(50); + +const Header = ({ totalLikes }: { totalLikes: number }) => ( +
    +

    뉴스 피드

    +

    총 좋아요 수: {totalLikes}

    +
    +); + +const SidebarItem = ({ + selected = false, + onClick, + children, +}: PropsWithChildren<{ + selected?: boolean; + onClick?: () => void; +}>) => { + const className = selected + ? "bg-blue-500 text-white hover:bg-blue-600" + : "bg-gray-200 text-gray-800 hover:bg-gray-300"; + + return ( + + ); +}; + +const Sidebar = ({ + items, + value, + change, +}: { + items: (NewsCategory | null)[]; + value: NewsCategory | null; + change: (category: NewsCategory | null) => void; +}) => ( + +); + +const NewsCard = ({ item, onLike }: { item: NewsItem; onLike: (id: number) => void }) => ( +
    +

    {item.title}

    +

    {item.content}

    +
    + {item.category} + +
    +
    +); + +const NewsFeed = ({ news, onLike }: { news: NewsItem[]; onLike: (id: number) => void }) => ( +
    + {news.map((item) => ( + + ))} +
    +); + +const App = () => { + const [news, setNews] = useState(newsItems); + const [category, setCategory] = useState(null); + + const filteredNews = category ? news.filter((item) => item.category === category) : news; + + const totalLikes = news.reduce((sum, item) => sum + item.likes, 0); + + const changeCategory = (newCategory: NewsCategory | null) => { + setCategory(newCategory); + }; + + const likeFeed = (id: number) => { + setNews(news.map((item) => (item.id === id ? { ...item, likes: item.likes + 1 } : item))); + }; + + return ( +
    +
    +
    + + +
    +
    + ); +}; + +export default App; +``` + +## (3) 성능 프로파일링 + +### 1) 렌더링이 되는 컴포넌트를 바로 확인하기 + +- 개발자도구 → Profiler → 톱니바퀴 아이콘 클릭 → General → Highlight updates when components render 체크 + +- 렌더링 되는 컴포넌트를 바로 확인 가능합니다. + +### 2) 성능 프로파일을 통해서 렌더링 비용 확인하기 + +- 개발자도구 → profiler → 왼쪽에 **파란색 동그라미** 클릭 → 어플리케이션 이용 → 왼쪽 **붉은색 동그라미** 클릭 +- 어떤 컴포넌트가 렌더링 되는지 렌더링 단계별로 확인 가능합니다. + +### 3) 컴포넌트 프로파일 + +- 개발자도구 → components + - 컴포넌트 트리 확인 가능 + - 컴포넌트에서 쓰이는 state 확인 가능 + - custom hook을 사용하면 해당 custom hook에 대한 다양한 정보 또한 확인 가능합니다. + - state, props 등 변경 가능 + + + +## (4) memo, useMemo, useCallback 으로 최적화하기 + +[front_3rd_chapter1-3/packages/example/src/common-sample/refactor/App.tsx at main · hanghae-plus/front_3rd_chapter1-3](https://github.com/hanghae-plus/front_3rd_chapter1-3/blob/main/packages/example/src/common-sample/refactor/App.tsx) + +```tsx +import { memo, PropsWithChildren, useCallback, useMemo, useState } from "react"; + +type NewsCategory = "정치" | "경제" | "사회" | "문화"; + +interface NewsItem { + id: number; + title: string; + category: NewsCategory; + likes: number; + content: string; +} + +const NEWS_CATEGORIES = ["정치", "경제", "사회", "문화"] as const; + +const generateNewsData = (count: number): NewsItem[] => { + return Array.from({ length: count }, (_, index) => ({ + id: index + 1, + title: `뉴스 제목 ${index + 1}`, + category: [...NEWS_CATEGORIES].sort(() => Math.random() - 0.5)[0], + likes: Math.floor(Math.random() * 100), + content: `이것은 뉴스 ${index + 1}의 내용입니다. 실제 내용은 더 길 것입니다.`, + })); +}; + +const newsItems = generateNewsData(50); + +const Header = memo(({ totalLikes }: { totalLikes: number }) => ( +
    +

    뉴스 피드

    +

    총 좋아요 수: {totalLikes}

    +
    +)); + +const SidebarItem = memo( + ({ + selected = false, + onClick, + children, + }: PropsWithChildren<{ + selected?: boolean; + onClick?: () => void; + }>) => { + const className = selected + ? "bg-blue-500 text-white hover:bg-blue-600" + : "bg-gray-200 text-gray-800 hover:bg-gray-300"; + + return ( + + ); + } +); + +const Sidebar = memo( + ({ + items, + value, + change, + }: { + items: (NewsCategory | null)[]; + value: NewsCategory | null; + change: (category: NewsCategory | null) => void; + }) => ( + + ) +); + +const NewsCard = memo(({ item, onLike }: { item: NewsItem; onLike: (id: number) => void }) => ( +
    +

    {item.title}

    +

    {item.content}

    +
    + {item.category} + +
    +
    +)); + +const NewsFeed = memo(({ news, onLike }: { news: NewsItem[]; onLike: (id: number) => void }) => ( +
    + {news.map((item) => ( + + ))} +
    +)); + +const App = () => { + const [news, setNews] = useState(newsItems); + const [category, setCategory] = useState(null); + + const filteredNews = useMemo( + () => (category ? news.filter((item) => item.category === category) : news), + [news, category] + ); + + const totalLikes = useMemo(() => news.reduce((sum, item) => sum + item.likes, 0), [news]); + + const changeCategory = useCallback((newCategory: NewsCategory | null) => { + setCategory(newCategory); + }, []); + + const likeFeed = useCallback((id: number) => { + setNews((prevNews) => + prevNews.map((item) => (item.id === id ? { ...item, likes: item.likes + 1 } : item)) + ); + }, []); + + const sidebarItems = useMemo(() => [null, ...NEWS_CATEGORIES], []); + + return ( +
    +
    +
    + + +
    +
    + ); +}; + +export default App; +``` + +[화면 기록 2024-10-01 오후 3.55.41.mov](https://prod-files-secure.s3.us-west-2.amazonaws.com/83c75a39-3aba-4ba4-a792-7aefe4b07895/3fe1afbf-83a6-4c36-ab13-04bd41bde640/%E1%84%92%E1%85%AA%E1%84%86%E1%85%A7%E1%86%AB_%E1%84%80%E1%85%B5%E1%84%85%E1%85%A9%E1%86%A8_2024-10-01_%E1%84%8B%E1%85%A9%E1%84%92%E1%85%AE_3.55.41.mov) + +![image.png](https://prod-files-secure.s3.us-west-2.amazonaws.com/83c75a39-3aba-4ba4-a792-7aefe4b07895/83bc66c4-c476-4aa6-b755-807e80709654/image.png) + +## (5) 결과 비교 + +### AS-IS + +![image.png](https://prod-files-secure.s3.us-west-2.amazonaws.com/83c75a39-3aba-4ba4-a792-7aefe4b07895/5685104b-f77a-42bb-a180-805fe551f83d/d150906e-68c4-483b-b4d9-c37d0316a270.png) + +- 총 렌더링 비용: 3.9ms + +### TO-BE + +![image.png](https://prod-files-secure.s3.us-west-2.amazonaws.com/83c75a39-3aba-4ba4-a792-7aefe4b07895/83bc66c4-c476-4aa6-b755-807e80709654/image.png) + +- 총 렌더링 비용: 0.8ms + +## (6) Context 사용 예제 + +### AS-IS + +[front_3rd_chapter1-3/packages/example/src/context-sample/origin/App.tsx at main · hanghae-plus/front_3rd_chapter1-3](https://github.com/hanghae-plus/front_3rd_chapter1-3/blob/main/packages/example/src/context-sample/origin/App.tsx) + +```tsx +import React, { createContext, useContext, useState, PropsWithChildren } from "react"; + +// 타입 정의 +type Theme = "light" | "dark"; +type NewsCategory = "정치" | "경제" | "사회" | "문화"; + +interface NewsItem { + id: number; + title: string; + category: NewsCategory; + content: string; + likes: number; +} + +interface User { + name: string; + email: string; +} + +interface Notification { + id: number; + message: string; +} + +// 모든 상태를 하나의 Context에 넣음 +interface AppState { + theme: Theme; + user: User | null; + news: NewsItem[]; + notifications: Notification[]; + category: NewsCategory | null; + toggleTheme: () => void; + login: (name: string, email: string) => void; + logout: () => void; + addNews: (news: Omit) => void; + likeNews: (id: number) => void; + setCategory: (category: NewsCategory | null) => void; + addNotification: (message: string) => void; + removeNotification: (id: number) => void; +} + +const AppContext = createContext(null); + +// Provider 컴포넌트 +const AppProvider: React.FC = ({ children }) => { + const [theme, setTheme] = useState("light"); + const [user, setUser] = useState(null); + const [news, setNews] = useState([]); + const [notifications, setNotifications] = useState([]); + const [category, setCategory] = useState(null); + + const toggleTheme = () => { + setTheme((prev) => (prev === "light" ? "dark" : "light")); + }; + + const login = (name: string, email: string) => { + setUser({ name, email }); + }; + + const logout = () => { + setUser(null); + }; + + const addNews = (newNews: Omit) => { + setNews((prev) => [...prev, { ...newNews, id: Date.now(), likes: 0 }]); + }; + + const likeNews = (id: number) => { + setNews((prev) => + prev.map((item) => (item.id === id ? { ...item, likes: item.likes + 1 } : item)) + ); + }; + + const addNotification = (message: string) => { + setNotifications((prev) => [...prev, { id: Date.now(), message }]); + }; + + const removeNotification = (id: number) => { + setNotifications((prev) => prev.filter((notif) => notif.id !== id)); + }; + + const value: AppState = { + theme, + user, + news, + notifications, + category, + toggleTheme, + login, + logout, + addNews, + likeNews, + setCategory, + addNotification, + removeNotification, + }; + + return {children}; +}; + +// 커스텀 훅 +const useApp = () => { + const context = useContext(AppContext); + if (!context) throw new Error("useApp must be used within an AppProvider"); + return context; +}; + +// 컴포넌트 +const Header = () => { + const { theme, toggleTheme, user, logout, notifications } = useApp(); + + return ( +
    +
    +

    뉴스 피드

    +
    + + {user && {user.name}님 환영합니다} + {user && ( + + )} +
    + 🔔 + {notifications.length > 0 && ( + + {notifications.length} + + )} +
    +
    +
    +
    + ); +}; + +const LoginForm = () => { + const { login } = useApp(); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + login(name, email); + }; + + return ( +
    + setName(e.target.value)} + placeholder='이름' + className='w-full p-2 border rounded' + /> + setEmail(e.target.value)} + placeholder='이메일' + className='w-full p-2 border rounded' + /> + +
    + ); +}; + +const NewsItem = ({ item }: { item: NewsItem }) => { + const { likeNews, theme } = useApp(); + + return ( +
    +

    {item.title}

    +

    {item.content}

    +
    + {item.category} + +
    +
    + ); +}; + +const NewsList = () => { + const { news, category } = useApp(); + + const filteredNews = category ? news.filter((item) => item.category === category) : news; + + return ( +
    + {filteredNews.map((item) => ( + + ))} +
    + ); +}; + +const CategoryFilter = () => { + const { setCategory } = useApp(); + const categories: (NewsCategory | "all")[] = ["all", "정치", "경제", "사회", "문화"]; + + return ( +
    + {categories.map((cat) => ( + + ))} +
    + ); +}; + +const AddNewsForm = () => { + const { addNews, addNotification } = useApp(); + const [title, setTitle] = useState(""); + const [content, setContent] = useState(""); + const [category, setCategory] = useState("정치"); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + addNews({ title, content, category }); + addNotification(`새 뉴스가 추가되었습니다: ${title}`); + setTitle(""); + setContent(""); + }; + + return ( +
    + setTitle(e.target.value)} + placeholder='제목' + className='w-full p-2 border rounded' + /> +