From 93b292d0ecffb54b4d226763979d5a3ae78b0701 Mon Sep 17 00:00:00 2001 From: Nam Yooseong Date: Sat, 20 Jun 2026 18:53:07 +0900 Subject: [PATCH 1/6] =?UTF-8?q?docs:=20=EB=AF=B8=EC=85=98=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=EC=82=AC=ED=95=AD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../2.1-ContextAPI/READEME.md | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 02-state-management-tools/2.1-ContextAPI/READEME.md diff --git a/02-state-management-tools/2.1-ContextAPI/READEME.md b/02-state-management-tools/2.1-ContextAPI/READEME.md new file mode 100644 index 0000000..bd02ad7 --- /dev/null +++ b/02-state-management-tools/2.1-ContextAPI/READEME.md @@ -0,0 +1,54 @@ +# 02-1. 전역상태관리 - Context API + +## 🎯 요구사항 +- Context API를 사용해서 애플리케이션 내의 **props drilling** 문제를 해결하세요. + - props로 **똑같은** 데이터 혹은 함수를 전달하지 않도록 해야합니다. + - props를 쓴다면 그 이유를 PR에 적어주세요. +- PR에 Context API를 **왜** 사용하는지, 기존의 코드구조와 어떤 **trade-off**가 있는지 적어주세요. +- Context API와 데이터를 사용하는 Component 사이의 **관계를 도식화**하고 이미지를 PR에 첨부해주세요. + - 실제 코드와 상관없이 일반적인 관계를 나타내야합니다. + - 도식화 방식은 자유롭게 하셔도 좋습니다. + - (추천) **Figma** + +### 😗구현 예시 +- 컴포넌트의 이름이나 구조는 마음대로 변경해도 좋습니다. +- 아래의 코드는 Context를 설정하는 예시입니다. + +```javascript +import { createContext, useState } from "react"; + +// Context 생성 +const UserContext = createContext({ + user: { name: "", email: "" }, + setUser: () => {} +}); + +// Provider 컴포넌트 +export const UserProvider = ({ children }) => { + const [user, setUser] = useState({ name: "John Doe", email: "johndoe@example.com" }); + + return ( + + {children} + + ); +}; + +export default UserContext; + +``` + +## ✅ 키워드 +- props drilling +- 전역상태관리 + - Context + - Provider + - Consumer +- Hook : useContext + +## 🧙‍♀️ 진행 가이드 +- 진행시간 : 2시간 내에 완료하는 것을 목표로 합니다. + +## 🔗 참고 문서 +- [Context API 공식문서](https://ko.legacy.reactjs.org/docs/context.html) +- [리액트를 다루는 기술(저:김민준(velopert))](https://thebook.io/080203/0501/) \ No newline at end of file From 17049541514bde832ef40c73f3e9420ea0bb6d54 Mon Sep 17 00:00:00 2001 From: Nam Yooseong Date: Fri, 26 Jun 2026 11:19:25 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20RestaurantsContext=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EB=B0=8F=20Provider=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/context/RestaurantsContext.jsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/context/RestaurantsContext.jsx diff --git a/src/context/RestaurantsContext.jsx b/src/context/RestaurantsContext.jsx new file mode 100644 index 0000000..73f1d72 --- /dev/null +++ b/src/context/RestaurantsContext.jsx @@ -0,0 +1,15 @@ +import { createContext } from "react"; +import { useRestaurants } from "../hooks/useRestaurants.js"; + +export const RestaurantsContext = createContext(null); + +export function RestaurantsProvider({ children }) { + const { restaurants, addRestaurant, isLoading, error } = useRestaurants(); + return ( + + {children} + + ); +} From 3160fa2a93269c89d558de2f390202d8274ad6c6 Mon Sep 17 00:00:00 2001 From: Nam Yooseong Date: Fri, 26 Jun 2026 11:19:34 +0900 Subject: [PATCH 3/6] =?UTF-8?q?refactor:=20Context=20API=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=EC=9C=BC=EB=A1=9C=20props=20drilling=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.jsx | 28 +++---------- src/components/AddRestaurantModal.jsx | 16 ++++++-- src/components/RestaurantList.jsx | 57 +++++++++++++++++---------- 3 files changed, 53 insertions(+), 48 deletions(-) diff --git a/src/App.jsx b/src/App.jsx index de2481e..85e4ae6 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -3,21 +3,17 @@ import Header from "./components/Header.jsx"; import CategoryFilter from "./components/CategoryFilter.jsx"; import RestaurantList from "./components/RestaurantList.jsx"; import { useState } from "react"; -import { filterRestaurants } from "./utils/filterRestaurants.js"; import RestaurantDetailModal from "./components/RestaurantDetailModal.jsx"; import AddRestaurantModal from "./components/AddRestaurantModal.jsx"; -import { useRestaurants } from "./hooks/useRestaurants.js"; import { ALL_CATEGORY } from "./constants/categories.js"; +import { RestaurantsProvider } from "./context/RestaurantsContext.jsx"; function App() { const [selectedCategory, setSelectedCategory] = useState(ALL_CATEGORY); const [clickedRestaurant, setClickedRestaurant] = useState(null); const [isAddRestaurantModalOpen, setIsAddRestaurantModalOpen] = useState(false); - const { restaurants, addRestaurant, isLoading, error } = useRestaurants(); - const isRestaurantDetailModalOpen = !!clickedRestaurant; - const filteredRestaurants = filterRestaurants(restaurants, selectedCategory); function handleCategoryChange(e) { setSelectedCategory(e.target.value); @@ -39,27 +35,16 @@ function App() { setIsAddRestaurantModalOpen(false); } - async function handleRestaurantSubmit(restaurant) { - try { - await addRestaurant(restaurant); - setIsAddRestaurantModalOpen(false); - } catch { - alert("음식점 추가에 실패했습니다. 다시 시도해주세요."); - } - } - return ( - <> +
- {isLoading &&

불러오는 중...

} - {error &&

{error}

}
@@ -71,13 +56,10 @@ function App() { /> )} {isAddRestaurantModalOpen && ( - + )} - + ); } diff --git a/src/components/AddRestaurantModal.jsx b/src/components/AddRestaurantModal.jsx index 5a07adc..c93ab9c 100644 --- a/src/components/AddRestaurantModal.jsx +++ b/src/components/AddRestaurantModal.jsx @@ -1,7 +1,8 @@ -import { useState } from "react"; +import { useContext, useState } from "react"; import Modal from "./Modal.jsx"; import { CATEGORIES } from "../constants/categories.js"; import styled from "styled-components"; +import { RestaurantsContext } from "../context/RestaurantsContext.jsx"; const FormItem = styled.div` display: flex; @@ -77,14 +78,21 @@ const Button = styled.button` color: var(--grey-100); `; -export default function AddRestaurantModal({ onSubmit, onClose }) { +export default function AddRestaurantModal({ onClose }) { const [category, setCategory] = useState(""); const [name, setName] = useState(""); const [description, setDescription] = useState(""); + const { addRestaurant } = useContext(RestaurantsContext); - function handleFormSubmit(e) { + async function handleFormSubmit(e) { e.preventDefault(); - onSubmit({ category, name, description }); + + try { + await addRestaurant({ category, name, description }); + onClose(); + } catch { + alert("음식점 추가에 실패했습니다. 다시 시도해주세요."); + } } return ( diff --git a/src/components/RestaurantList.jsx b/src/components/RestaurantList.jsx index 26d0588..7d44803 100644 --- a/src/components/RestaurantList.jsx +++ b/src/components/RestaurantList.jsx @@ -1,5 +1,8 @@ +import { useContext } from "react"; import { CATEGORY_IMAGES } from "../constants/categoryImages.js"; import styled from "styled-components"; +import { RestaurantsContext } from "../context/RestaurantsContext.jsx"; +import { filterRestaurants } from "../utils/filterRestaurants.js"; const List = styled.ul` padding: 0 16px; @@ -64,27 +67,39 @@ const RestaurantDescription = styled.p` font-weight: 400; `; -export default function RestaurantList({ restaurants, onRestaurantClick }) { +export default function RestaurantList({ + selectedCategory, + onRestaurantClick, +}) { + const { restaurants, isLoading, error } = useContext(RestaurantsContext); + const filteredRestaurants = filterRestaurants(restaurants, selectedCategory); + return ( - - {restaurants.map((restaurant) => { - return ( - - - - ); - })} - + <> + {isLoading &&

불러오는 중...

} + {error &&

{error}

} + + {filteredRestaurants.map((restaurant) => { + return ( + + + + ); + })} + + ); } From de7a94dce402c1b1b91d00f977d6fa3090891d7f Mon Sep 17 00:00:00 2001 From: Nam Yooseong Date: Fri, 26 Jun 2026 11:21:32 +0900 Subject: [PATCH 4/6] =?UTF-8?q?refactor:=20styled-components=20=EC=84=A0?= =?UTF-8?q?=EC=96=B8=EC=9D=84=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20?= =?UTF-8?q?=ED=95=98=EB=8B=A8=EC=9C=BC=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/AddRestaurantModal.jsx | 142 +++++++++++------------ src/components/CategoryFilter.jsx | 34 +++--- src/components/Header.jsx | 32 ++--- src/components/Modal.jsx | 40 +++---- src/components/RestaurantDetailModal.jsx | 26 ++--- src/components/RestaurantList.jsx | 74 ++++++------ 6 files changed, 174 insertions(+), 174 deletions(-) diff --git a/src/components/AddRestaurantModal.jsx b/src/components/AddRestaurantModal.jsx index c93ab9c..394cec7 100644 --- a/src/components/AddRestaurantModal.jsx +++ b/src/components/AddRestaurantModal.jsx @@ -4,6 +4,77 @@ import { CATEGORIES } from "../constants/categories.js"; import styled from "styled-components"; import { RestaurantsContext } from "../context/RestaurantsContext.jsx"; +export default function AddRestaurantModal({ onClose }) { + const [category, setCategory] = useState(""); + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const { addRestaurant } = useContext(RestaurantsContext); + + async function handleFormSubmit(e) { + e.preventDefault(); + + try { + await addRestaurant({ category, name, description }); + onClose(); + } catch { + alert("음식점 추가에 실패했습니다. 다시 시도해주세요."); + } + } + + return ( + +
+ + + setCategory(e.target.value)} + required + > + + {CATEGORIES.map((value) => ( + + ))} + + + + + + setName(e.target.value)} + required + /> + + + + + setDescription(e.target.value)} + > + 메뉴 등 추가 정보를 입력해 주세요. + + + + + +
+
+ ); +} + const FormItem = styled.div` display: flex; flex-direction: column; @@ -77,74 +148,3 @@ const Button = styled.button` background: var(--primary-color); color: var(--grey-100); `; - -export default function AddRestaurantModal({ onClose }) { - const [category, setCategory] = useState(""); - const [name, setName] = useState(""); - const [description, setDescription] = useState(""); - const { addRestaurant } = useContext(RestaurantsContext); - - async function handleFormSubmit(e) { - e.preventDefault(); - - try { - await addRestaurant({ category, name, description }); - onClose(); - } catch { - alert("음식점 추가에 실패했습니다. 다시 시도해주세요."); - } - } - - return ( - -
- - - setCategory(e.target.value)} - required - > - - {CATEGORIES.map((value) => ( - - ))} - - - - - - setName(e.target.value)} - required - /> - - - - - setDescription(e.target.value)} - > - 메뉴 등 추가 정보를 입력해 주세요. - - - - - -
-
- ); -} diff --git a/src/components/CategoryFilter.jsx b/src/components/CategoryFilter.jsx index 0fc45d3..dc6fad3 100644 --- a/src/components/CategoryFilter.jsx +++ b/src/components/CategoryFilter.jsx @@ -1,23 +1,6 @@ import { ALL_CATEGORY, CATEGORIES } from "../constants/categories.js"; import styled from "styled-components"; -const CategoryFilterSection = styled.section` - display: flex; - justify-content: space-between; - padding: 0 16px; - margin-top: 24px; -`; - -const CategorySelect = styled.select` - height: 44px; - min-width: 125px; - border: 1px solid var(--grey-200); - border-radius: 8px; - background: transparent; - font-size: 16px; - padding: 8px; -`; - export default function CategoryFilter({ category, onCategoryChange }) { return ( @@ -38,3 +21,20 @@ export default function CategoryFilter({ category, onCategoryChange }) { ); } + +const CategoryFilterSection = styled.section` + display: flex; + justify-content: space-between; + padding: 0 16px; + margin-top: 24px; +`; + +const CategorySelect = styled.select` + height: 44px; + min-width: 125px; + border: 1px solid var(--grey-200); + border-radius: 8px; + background: transparent; + font-size: 16px; + padding: 8px; +`; diff --git a/src/components/Header.jsx b/src/components/Header.jsx index 81000dc..bc596b1 100644 --- a/src/components/Header.jsx +++ b/src/components/Header.jsx @@ -1,6 +1,22 @@ import styled from "styled-components"; import addButton from "../assets/add-button.png"; +export default function Header({ onAddButtonClick }) { + return ( + + 점심 뭐 먹지 + + + + + + ); +} + const Gnb = styled.header` display: flex; justify-content: space-between; @@ -32,19 +48,3 @@ const GnbButton = styled.button` object-fit: contain; } `; - -export default function Header({ onAddButtonClick }) { - return ( - - 점심 뭐 먹지 - - - - - - ); -} diff --git a/src/components/Modal.jsx b/src/components/Modal.jsx index a54aa22..ee92118 100644 --- a/src/components/Modal.jsx +++ b/src/components/Modal.jsx @@ -1,6 +1,26 @@ import { useEffect } from "react"; import styled from "styled-components"; +export default function Modal({ children, title, onClose }) { + useEffect(() => { + function handleKeyDown(e) { + if (e.key === "Escape") onClose(); + } + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [onClose]); + + return ( + <> + + + {title} + {children} + + + ); +} + const ModalBackdrop = styled.div` position: fixed; top: 0; @@ -25,23 +45,3 @@ const ModalTitle = styled.h2` line-height: 24px; font-weight: 600; `; - -export default function Modal({ children, title, onClose }) { - useEffect(() => { - function handleKeyDown(e) { - if (e.key === "Escape") onClose(); - } - document.addEventListener("keydown", handleKeyDown); - return () => document.removeEventListener("keydown", handleKeyDown); - }, [onClose]); - - return ( - <> - - - {title} - {children} - - - ); -} diff --git a/src/components/RestaurantDetailModal.jsx b/src/components/RestaurantDetailModal.jsx index fd82ba9..bb88d0e 100644 --- a/src/components/RestaurantDetailModal.jsx +++ b/src/components/RestaurantDetailModal.jsx @@ -1,6 +1,19 @@ import styled from "styled-components"; import Modal from "./Modal.jsx"; +export default function RestaurantDetailModal({ restaurant, onClose }) { + return ( + + + {restaurant.description} + + + + + + ); +} + const ButtonContainer = styled.div` display: flex; `; @@ -28,16 +41,3 @@ const Description = styled.p` line-height: 24px; font-weight: 400; `; - -export default function RestaurantDetailModal({ restaurant, onClose }) { - return ( - - - {restaurant.description} - - - - - - ); -} diff --git a/src/components/RestaurantList.jsx b/src/components/RestaurantList.jsx index 7d44803..71b8587 100644 --- a/src/components/RestaurantList.jsx +++ b/src/components/RestaurantList.jsx @@ -4,6 +4,43 @@ import styled from "styled-components"; import { RestaurantsContext } from "../context/RestaurantsContext.jsx"; import { filterRestaurants } from "../utils/filterRestaurants.js"; +export default function RestaurantList({ + selectedCategory, + onRestaurantClick, +}) { + const { restaurants, isLoading, error } = useContext(RestaurantsContext); + const filteredRestaurants = filterRestaurants(restaurants, selectedCategory); + + return ( + <> + {isLoading &&

불러오는 중...

} + {error &&

{error}

} + + {filteredRestaurants.map((restaurant) => { + return ( + + + + ); + })} + + + ); +} + const List = styled.ul` padding: 0 16px; margin: 16px 0; @@ -66,40 +103,3 @@ const RestaurantDescription = styled.p` line-height: 24px; font-weight: 400; `; - -export default function RestaurantList({ - selectedCategory, - onRestaurantClick, -}) { - const { restaurants, isLoading, error } = useContext(RestaurantsContext); - const filteredRestaurants = filterRestaurants(restaurants, selectedCategory); - - return ( - <> - {isLoading &&

불러오는 중...

} - {error &&

{error}

} - - {filteredRestaurants.map((restaurant) => { - return ( - - - - ); - })} - - - ); -} From ad07c4342bcad0c18132c8f54ab3e20fcd5306ac Mon Sep 17 00:00:00 2001 From: Nam Yooseong Date: Fri, 26 Jun 2026 11:40:02 +0900 Subject: [PATCH 5/6] =?UTF-8?q?docs:=20=EB=A6=AC=EB=93=9C=EB=AF=B8=20?= =?UTF-8?q?=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 261 ++++++++++++++++++++++-------------------------------- 1 file changed, 106 insertions(+), 155 deletions(-) diff --git a/README.md b/README.md index 357e2a0..20db4bc 100644 --- a/README.md +++ b/README.md @@ -1,218 +1,169 @@ -# styled-components를 적용해서 리팩토링하기 +# Context API를 사용해서 전역상태관리하기 ## 🎯 개인 목표 및 목표 달성을 위한 행동 가이드 이번 미션을 통해 다음과 같은 학습 경험들을 쌓는 것을 목표로 한다. -1. CSS Modules 방식과 CSS-in-JS 방식의 차이를 직접 마이그레이션하며 체감한다. -2. styled-components의 기본 사용법(기본 스타일링, 중첩 선택자, 컴포넌트 확장)을 익힌다. -3. 기존 코드의 구조를 유지하면서 스타일링 방식만 교체하는 리팩토링 경험을 쌓는다. +1. `createContext`, `Provider`, `useContext` 세 가지 개념의 역할을 명확히 이해하고 직접 구현한다. +2. props drilling이 발생하는 지점을 코드에서 직접 파악하고 Context로 해결하는 경험을 쌓는다. +3. UI 상태와 데이터 상태를 구분해서 Context 적용 범위를 스스로 판단하는 능력을 기른다. --- ## 📝 기능 구현 목록 -- [x] 모든 `.module.css` 파일을 제거하고 styled-components로 전환 -- [x] `Header`, `CategoryFilter`, `Modal`, `RestaurantList`, `RestaurantDetailModal`, `AddRestaurantModal` 전 컴포넌트에 styled-components 적용 -- [x] `App.css`는 전역 리셋 및 CSS 변수 정의 용도로만 유지 -- [x] CSS 변수(`var(--primary-color)` 등)를 styled-components 내부에서 활용 -- [x] `styled(Component)` 확장 패턴을 활용해 필수 입력 항목(`RequiredFormItem`) 스타일 분리 +- [x] `RestaurantsContext` 생성 및 `RestaurantsProvider` 구현 +- [x] `useRestaurants` 훅의 데이터(`restaurants`, `addRestaurant`, `isLoading`, `error`)를 Context로 관리 +- [x] `RestaurantList`에서 `useContext`로 `restaurants`, `isLoading`, `error` 직접 구독 +- [x] `AddRestaurantModal`에서 `useContext`로 `addRestaurant` 직접 호출 (`onSubmit` prop 제거) +- [x] `App.jsx`에서 음식점 관련 state/handler 제거, UI 상태(`selectedCategory`, `clickedRestaurant`, `isAddRestaurantModalOpen`)만 유지 --- ## 📚 학습 내용 -### styled-components란? +### Context API란? -CSS-in-JS 라이브러리로, JavaScript 파일 안에서 템플릿 리터럴 문법으로 CSS를 작성하고 이를 React 컴포넌트에 직접 연결하는 방식이다. +컴포넌트 트리 전체에 데이터를 전달할 수 있는 React 내장 기능이다. props를 통해 중간 컴포넌트를 거치지 않고, 필요한 컴포넌트가 데이터를 직접 꺼내 쓸 수 있게 한다. -```js -const Button = styled.button` - background-color: var(--primary-color); - color: white; - border-radius: 8px; - padding: 10px 20px; -`; -``` +### createContext / Provider / useContext -### CSS Modules vs styled-components trade-off +세 가지 API가 각각 다른 역할을 담당한다. -| | CSS Modules | styled-components | +| 개념 | 역할 | 설명 | |---|---|---| -| 스타일 위치 | 별도 `.module.css` 파일 | 컴포넌트 파일 내부 | -| 스코프 | 클래스명 해시로 자동 격리 | 컴포넌트 단위로 격리 | -| 동적 스타일링 | className 조건부 변경 필요 | props로 직접 처리 가능 | -| 가독성 | HTML 구조와 스타일 파일 분리 | 한 파일에서 구조+스타일 파악 가능 | -| 번들 크기 | 별도 런타임 없음 | styled-components 런타임 포함 | - -### 자식 선택자 중첩 - -styled-components는 Sass처럼 중첩 선택자를 지원한다. +| `createContext` | Context 객체 생성 | 데이터를 담을 컨테이너를 정의한다. 초기값을 인자로 받으며, Provider 없이 `useContext`를 호출했을 때 이 초기값이 반환된다. | +| `Provider` | Context 값 공급 | `value` prop으로 전달한 데이터를 하위 컴포넌트 트리 전체에 주입한다. `value`가 변경되면 해당 Context를 구독 중인 모든 컴포넌트가 리렌더링된다. | +| `useContext` | Context 값 구독 | 가장 가까운 상위 Provider의 `value`를 반환한다. Provider가 없으면 `createContext`의 초기값을 반환한다. | ```js -const Category = styled.div` - background: var(--lighten-color); - - img { - width: 36px; - height: 36px; - } -`; +// 1. createContext — 공간 생성 +export const RestaurantsContext = createContext(null); + +// 2. Provider — 데이터를 채워서 하위 컴포넌트에 공급 +export function RestaurantsProvider({ children }) { + const { restaurants, addRestaurant, isLoading, error } = useRestaurants(); + return ( + + {children} + + ); +} + +// 3. useContext — 필요한 컴포넌트에서 직접 꺼냄 +const { restaurants } = useContext(RestaurantsContext); ``` -### 컴포넌트 확장 (`styled(Component)`) +### React 18 vs React 19 Provider 문법 차이 -기존 styled-component를 상속해서 스타일을 추가할 수 있다. +React 19부터는 Context 객체 자체를 Provider로 사용할 수 있다. 공식문서가 React 19 기준으로 업데이트되었으므로 버전 확인이 필요하다. -```js -const FormItem = styled.div`...`; +```jsx +// React 18 — .Provider 필요 + -const RequiredFormItem = styled(FormItem)` - label::after { - content: "*"; - color: var(--primary-color); - } -`; +// React 19 — Context 자체를 Provider로 사용 가능 + ``` --- ## 🤔 고민했던 문제와 해결 과정에서 배운 점 -### 필수/선택 폼 항목의 `*` 표시 처리 +### 무엇을 Context에 넣을 것인가 -원본 CSS Modules에서는 `.formItem--required` 클래스를 조건부로 붙이는 방식으로 필수 항목에만 `*`를 표시했다. +Context에 모든 state를 넣는 게 아니라, **데이터 도메인**과 **UI 상태**를 구분하는 것이 핵심이다. -styled-components로 전환할 때 `label::after { content: "*" }`를 `FormItem`에 바로 넣으면 모든 항목에 `*`가 붙는 문제가 생긴다. +| 구분 | 상태 | 이유 | +|---|---|---| +| Context (데이터 도메인) | `restaurants`, `addRestaurant`, `isLoading`, `error` | 여러 컴포넌트에서 공유되는 서버 데이터 | +| props / 로컬 state (UI 상태) | `selectedCategory`, `clickedRestaurant`, `isAddRestaurantModalOpen` | 특정 화면의 인터랙션 상태로, App이 관리하는 게 자연스러움 | -`styled(FormItem)`으로 확장한 `RequiredFormItem`을 별도로 만들어서 필수 항목(카테고리, 이름)에만 적용하는 방식으로 해결했다. +판단 기준은 "그 데이터가 App 고유의 책임인가?"다. `addRestaurant`는 음식점 데이터 도메인의 책임이므로 Context가 적합하고, `selectedCategory`는 화면 UI의 책임이므로 로컬 state가 적합하다. -### 자식 선택자 중첩 — 언제 쓰고 언제 피할까 +### Provider 위치 결정 -styled-components는 Sass처럼 중첩 선택자를 지원하지만, "항상 피해야 한다"기보다는 **상황에 따라 다르다**가 정확하다. +처음에는 `RestaurantsProvider`를 App의 return 안에 배치했다. 이 경우 App 자신이 `useContext`로 Context 데이터를 꺼낼 수 없다는 문제가 생긴다. `useContext`는 자신보다 **상위에 있는** Provider를 찾기 때문이다. -**중첩이 괜찮은 경우** — 부모와 항상 함께 쓰이는 단순 HTML 요소일 때는 별도 컴포넌트로 분리하는 게 오히려 과도한 추상화다. +해결책은 두 가지였다. -```js -// img는 Category 없이 독립적으로 쓰일 일이 없으므로 중첩이 합리적 -const Category = styled.div` - background: var(--lighten-color); +1. Provider를 `main.jsx`로 올려서 App도 Context에 접근 가능하게 만들기 +2. App이 Context를 쓸 필요가 없도록 구조를 바꾸기 - img { - width: 36px; - height: 36px; - } -`; -``` +`AddRestaurantModal`이 `addRestaurant`를 Context에서 직접 꺼내도록 하면 App은 `addRestaurant`를 전혀 알 필요가 없어진다. `isLoading`, `error`도 `RestaurantList`가 직접 보여주면 된다. 결과적으로 App이 Context를 쓰지 않아도 되는 구조가 만들어졌고, Provider 위치 문제도 자연스럽게 해결됐다. -`&:hover`, `&:focus` 같은 가상 선택자도 중첩이 자연스럽고 관용적인 방식이다. +### filteredRestaurants를 어디서 처리할 것인가 -**중첩을 피하는 게 나은 경우** — 자식 요소가 의미 있는 스타일을 가지거나, 독립적으로 재사용될 가능성이 있을 때다. +기존에는 `App`이 `filteredRestaurants`를 계산해서 `RestaurantList`에 내려줬다. Context 도입 후 `restaurants`는 Context에서 오고, `selectedCategory`는 UI 상태로 props로 전달하게 됐다. -```js -// h3/p는 각자 의미 있는 스타일을 가지므로 분리 -const RestaurantName = styled.h3` - font-size: 18px; - font-weight: 600; -`; - -const RestaurantDescription = styled.p` - padding-top: 8px; - -webkit-line-clamp: 2; -`; +``` +// 변경 후 +RestaurantList에서 Context로 restaurants를 꺼내고, +props로 받은 selectedCategory로 필터링을 직접 처리 ``` -**판단 기준 요약** - -| | 중첩 OK | 분리 권장 | -|---|---|---| -| 스타일 복잡도 | 단순 (크기, 색상 1-2개) | 복잡한 스타일 블록 | -| 재사용 가능성 | 부모 없이 쓰일 일 없음 | 다른 곳에서도 쓰일 수 있음 | -| 조건부 스타일 | 없음 | props로 분기 필요 | -| 의미 전달 | 이름 필요 없음 | 이름이 코드 이해에 도움됨 | +`selectedCategory`를 props로 유지한 이유: UI 상태이므로 Context보다 props가 더 자연스럽다. 미션 요구사항에도 "props를 쓴다면 그 이유를 PR에 적어주세요"라고 명시되어 있으므로, 이 판단 근거를 기록한다. --- ## 🛠 리팩토링 -### 컴포넌트 파일 구조 단순화 - -기존에는 컴포넌트마다 폴더를 만들어 `ComponentName/ComponentName.jsx` + `ComponentName.module.css` 구조였다. styled-components 전환 후 CSS 파일이 사라지면서 폴더 없이 `ComponentName.jsx` 단일 파일로 정리했다. - -``` -before: -components/ - Header/ - Header.jsx - Header.module.css - -after: -components/ - Header.jsx -``` - -### 자식 태그 선택자 → 별도 styled 컴포넌트로 분리 +### App.jsx — 음식점 데이터 책임 제거 -의미 있는 콘텐츠 요소에 자식 태그 선택자를 사용하던 방식을 각각 별도의 styled 컴포넌트로 분리했다. +Context 도입 전 App은 `useRestaurants`를 직접 호출하고, 모든 handler를 가지고 있었다. 리팩토링 후 App은 UI 상태만 관리한다. ```js -// before — 자식 태그 선택자 사용 -const Info = styled.div` - h3 { font-size: 18px; font-weight: 600; } - p { padding-top: 8px; } -`; - -// after — 각각 명시적인 컴포넌트로 분리 -const Info = styled.div`...`; -const RestaurantName = styled.h3`font-size: 18px; font-weight: 600;`; -const RestaurantDescription = styled.p`padding-top: 8px;`; +// before — App이 데이터와 UI 상태를 모두 관리 +const { restaurants, addRestaurant, isLoading, error } = useRestaurants(); +const filteredRestaurants = filterRestaurants(restaurants, selectedCategory); +async function handleRestaurantSubmit(restaurant) { ... } + +// after — UI 상태만 남음 +const [selectedCategory, setSelectedCategory] = useState(ALL_CATEGORY); +const [clickedRestaurant, setClickedRestaurant] = useState(null); +const [isAddRestaurantModalOpen, setIsAddRestaurantModalOpen] = useState(false); ``` -적용 파일: `CategoryFilter`, `RestaurantList`, `AddRestaurantModal` - -### RestaurantList 시맨틱 구조 개선 - -`styled.section`이 일반 `
    `을 감싸는 이중 구조를 `styled.ul`로 단일화했다. +### RestaurantList — Context 구독 및 필터링 내부화 -```jsx -// before — section > ul > li 이중 구조 -const List = styled.section`...`; -return ( - -
      - {/* styled.li */} -
    -
    -); - -// after — ul > li 단일 구조 -const List = styled.ul`...`; -return ( - - {/* styled.li */} - -); +```js +// before +export default function RestaurantList({ restaurants, onRestaurantClick }) { ... } + +// after +export default function RestaurantList({ selectedCategory, onRestaurantClick }) { + const { restaurants, isLoading, error } = useContext(RestaurantsContext); + const filteredRestaurants = filterRestaurants(restaurants, selectedCategory); + ... +} ``` -### CSS 변수 누락 색상 추가 및 하드코딩 제거 +### AddRestaurantModal — onSubmit prop 제거 -코드에 하드코딩되어 있던 색상값을 CSS 변수로 정의하고 교체했다. - -```css -/* App.css에 추가 */ ---grey-50: #fcfcfd; /* 헤더 타이틀 텍스트 색상 */ ---grey-150: #e9eaed; /* 목록 구분선 색상 */ +```js +// before — App으로부터 onSubmit을 받아서 호출 +export default function AddRestaurantModal({ onSubmit, onClose }) { + function handleFormSubmit(e) { + e.preventDefault(); + onSubmit({ category, name, description }); + } +} + +// after — Context에서 addRestaurant를 직접 꺼내서 호출 +export default function AddRestaurantModal({ onClose }) { + const { addRestaurant } = useContext(RestaurantsContext); + async function handleFormSubmit(e) { + e.preventDefault(); + try { + await addRestaurant({ category, name, description }); + onClose(); + } catch { + alert("음식점 추가에 실패했습니다. 다시 시도해주세요."); + } + } +} ``` -| 파일 | before | after | -|---|---|---| -| `Header.jsx` | `color: #fcfcfd` | `color: var(--grey-50)` | -| `CategoryFilter.jsx` | `border: 1px solid #d0d5dd` | `border: 1px solid var(--grey-200)` | -| `RestaurantList.jsx` | `border-bottom: 1px solid #e9eaed` | `border-bottom: 1px solid var(--grey-150)` | - -이 작업의 배경 개념은 **Design Token(디자인 토큰)** 이다. 색상, 폰트 크기, 간격 같은 시각적 결정에 이름을 붙인 값으로 추상화한 것으로, `App.css`의 `:root` 블록이 바로 그 역할을 한다. +### styled-components 선언 위치 — 컴포넌트 하단으로 이동 -- **단일 진실 공급원** — 브랜드 색상이 바뀌면 변수 한 줄만 수정하면 전체에 반영된다. -- **색상 분열 방지** — 변수 체계 없이 하드코딩하면 개발자마다 "비슷한 회색"을 다르게 써서 미묘하게 다른 색상이 혼재하게 된다. 이번에 발견한 `#fcfcfd`, `#e9eaed`가 그 사례이다. -- **디자인-개발 소통** — 디자이너가 "grey-200 사용"이라고 전달하면 개발자는 `var(--grey-200)`을 그대로 쓴다. 값이 아닌 이름으로 소통하므로 오역이 줄어든다. -- **의미 전달** — `#ec4a0a`는 어떤 색인지 알 수 없지만 `--primary-color`는 의도가 명확하다. +파일을 열었을 때 가장 먼저 보고 싶은 건 컴포넌트 로직이지, 스타일 세부사항이 아니다. styled-components 선언이 상단에 있으면 컴포넌트 함수가 한참 아래로 밀려 가독성이 떨어진다. 모든 컴포넌트 파일에서 styled-components 선언을 컴포넌트 함수 아래로 이동했다. From 70dd947b96f36a989c8c1538754dc83b01e1dd43 Mon Sep 17 00:00:00 2001 From: Nam Yooseong Date: Sat, 27 Jun 2026 18:15:46 +0900 Subject: [PATCH 6/6] =?UTF-8?q?refactor:=20useRestaurantsContext=20?= =?UTF-8?q?=EC=BB=A4=EC=8A=A4=ED=85=80=20=ED=9B=85=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20Provider=20=EB=B0=A9=EC=96=B4=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/AddRestaurantModal.jsx | 6 +++--- src/components/RestaurantList.jsx | 5 ++--- src/context/useRestaurantsContext.js | 9 +++++++++ 3 files changed, 14 insertions(+), 6 deletions(-) create mode 100644 src/context/useRestaurantsContext.js diff --git a/src/components/AddRestaurantModal.jsx b/src/components/AddRestaurantModal.jsx index 394cec7..d1639dc 100644 --- a/src/components/AddRestaurantModal.jsx +++ b/src/components/AddRestaurantModal.jsx @@ -1,14 +1,14 @@ -import { useContext, useState } from "react"; +import { useState } from "react"; import Modal from "./Modal.jsx"; import { CATEGORIES } from "../constants/categories.js"; import styled from "styled-components"; -import { RestaurantsContext } from "../context/RestaurantsContext.jsx"; +import { useRestaurantsContext } from "../context/useRestaurantsContext.js"; export default function AddRestaurantModal({ onClose }) { const [category, setCategory] = useState(""); const [name, setName] = useState(""); const [description, setDescription] = useState(""); - const { addRestaurant } = useContext(RestaurantsContext); + const { addRestaurant } = useRestaurantsContext(); async function handleFormSubmit(e) { e.preventDefault(); diff --git a/src/components/RestaurantList.jsx b/src/components/RestaurantList.jsx index 71b8587..21a0e93 100644 --- a/src/components/RestaurantList.jsx +++ b/src/components/RestaurantList.jsx @@ -1,14 +1,13 @@ -import { useContext } from "react"; import { CATEGORY_IMAGES } from "../constants/categoryImages.js"; import styled from "styled-components"; -import { RestaurantsContext } from "../context/RestaurantsContext.jsx"; +import { useRestaurantsContext } from "../context/useRestaurantsContext.js"; import { filterRestaurants } from "../utils/filterRestaurants.js"; export default function RestaurantList({ selectedCategory, onRestaurantClick, }) { - const { restaurants, isLoading, error } = useContext(RestaurantsContext); + const { restaurants, isLoading, error } = useRestaurantsContext(); const filteredRestaurants = filterRestaurants(restaurants, selectedCategory); return ( diff --git a/src/context/useRestaurantsContext.js b/src/context/useRestaurantsContext.js new file mode 100644 index 0000000..7de8263 --- /dev/null +++ b/src/context/useRestaurantsContext.js @@ -0,0 +1,9 @@ +import { useContext } from "react"; +import { RestaurantsContext } from "./RestaurantsContext"; + +export function useRestaurantsContext() { + const context = useContext(RestaurantsContext); + if (context === null) + throw new Error("RestaurantsProvider 내부에서만 사용할 수 있습니다."); + return context; +}