From 38218e9e9dc5001fae247422149b8637d1b9e799 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B1=84=EC=9D=80?= Date: Wed, 27 May 2026 21:23:48 +0900 Subject: [PATCH 1/4] =?UTF-8?q?keyword:=20chapter09=20=ED=82=A4=EC=9B=8C?= =?UTF-8?q?=EB=93=9C=20=EC=A0=9C=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- keyword/chapter09/keyword09.md | 1133 ++++++++++++++++++++++++++++++++ 1 file changed, 1133 insertions(+) create mode 100644 keyword/chapter09/keyword09.md diff --git a/keyword/chapter09/keyword09.md b/keyword/chapter09/keyword09.md new file mode 100644 index 00000000..86a4c94a --- /dev/null +++ b/keyword/chapter09/keyword09.md @@ -0,0 +1,1133 @@ +- **`Redux Toolkit`** 사용법을 공식문서를 보며 직접 정리해보기 🍠 + [Getting Started | Redux Toolkit](https://redux-toolkit.js.org/introduction/getting-started) + - Provider + + + #### **Provider 사용 이유** + + Redux store는 기본적으로 React와 연결되어 있지 않음 + + → 따라서 Provider로 감싸서 "모든 컴포넌트가 store 접근 가능" 하게 만듦! + + #### **Provider 사용 예시** + + ```tsx + import { Provider } from 'react-redux' + import { store } from './app/store' + + const root = ReactDOM.createRoot(document.getElementById('root')) + + root.render( + + + + ) + ``` + + #### **Provider 사용 시 주의사항** + + | 위치 | 앱 루트 최상단에 단 한 번만 배치 | + | --- | --- | + | context | 기본 ReactReduxContext 사용 (커스텀 가능) | + | children | 하위 모든 컴포넌트에서 useSelector/useDispatch 사용 가능 | + | SSR | Next.js에서는 요청별로 스토어 인스턴스를 새로 생성해야 함 | + + - configureStore + + + ```tsx + // 기존 Redux에서는: + createStore(rootReducer) + // 처럼 사용했지만, + + // RTK에서는: + configureStore() + // 를 사용합니다. + ``` + + - configureStore 특징 + + ### Step 1. reducer 자동 연결 + + ``` + reducer: { + counter:counterReducer + } + ``` + + 형태로 여러 reducer를 쉽게 합칠 수 있음 + + ### Step 2. Redux DevTools 자동 설정 + + 개발자 도구 자동 연결 + + ### Step 3. middleware 자동 설정 + + Thunk 같은 미들웨어 기본 포함 + + + #### **configureStore 사용 예시** + + ```tsx + import { configureStore } from '@reduxjs/toolkit' + import counterReducer from '../features/counter/counterSlice' + import authReducer from '../features/auth/authSlice' + + export const store = configureStore({ + reducer: { + counter: counterReducer, + auth: authReducer, + } + }) + + // TypeScript 타입 추출 + export type RootState = ReturnType + export type AppDispatch = typeof store.dispatch + ``` + + #### **configureStore 고급 옵션** + + ```tsx + configureStore({ + reducer: rootReducer, + + // 미들웨어 커스텀 (기본 미들웨어 유지 + 추가) + middleware: (getDefaultMiddleware) => + getDefaultMiddleware().concat(logger, analyticsMiddleware), + + // DevTools 옵션 (production에서는 비활성화 권장) + devTools: process.env.NODE_ENV !== 'production', + + // 초기 상태 (server-side 렌더링 등에 활용) + preloadedState: window.__PRELOADED_STATE__, + + // Enhancer 추가 + enhancers: (getDefaultEnhancers) => + getDefaultEnhancers().concat(monitorReducerEnhancer), + }) + ``` + + #### **기본 포함 미들웨어** + + | redux-thunk | 비동기 액션 함수(Thunk) 처리. createAsyncThunk의 동작 기반 | + | --- | --- | + | serializability | 직렬화 불가 값(Date, Promise 등)의 state/action 저장 경고 | + | immutability | 리듀서 외부에서 state 직접 변경 시 경고 (개발 환경) | + + > **주의:** middleware 배열을 직접 할당하면 기본 미들웨어가 제거됩니다. 반드시 `getDefaultMiddleware()`를 사용하세요. + > + + - createSlice + + + #### **createSlice 구조** + + ```tsx + createSlice({ + name, + initialState, + reducers + }) + ``` + + #### **createSlice 예시** + + ```tsx + import {createSlice }from'@reduxjs/toolkit'; + + constinitialState= { + value:0, + }; + + constcounterSlice=createSlice({ + name:'counter', + + initialState, + + reducers: { + increment: (state) => { + state.value+=1; + }, + + decrement: (state) => { + state.value-=1; + }, + + incrementByAmount: (state,action) => { + state.value+=action.payload; + }, + }, + }); + + exportconst { increment, decrement, incrementByAmount }= + counterSlice.actions; + + exportdefaultcounterSlice.reducer; + ``` + + - 주요 개념 + + Step 1. state: 현재 상태 값 + + ```tsx + state.value + ``` + + Step 2. action: 상태 변경 요청 객체 + + ```tsx + dispatch(increment()) + ``` + + Step 3. payload: 추가 데이터 전달 + + ```tsx + dispatch(incrementByAmount(5)) + // 여기서 5가 payload + ``` + + - Immer 기반 불변성 관리 + + + + ```tsx + // 그래서 아래 코드처럼: + state.value+=1; + // 직접 수정하는 것처럼 보여도, + // 실제로는 Immer가 불변성을 자동 처리합니다. + + // 기존 Redux에서는: + return { + ...state, + value:state.value+1 + } + // 처럼 복잡하게 작성해야 했습니다. + ``` + + + #### **createSlice 기본 사용법** + + ```tsx + import { createSlice, PayloadAction } from '@reduxjs/toolkit' + + interface CounterState { value: number; status: 'idle' | 'loading' } + + const counterSlice = createSlice({ + name: 'counter', // 액션 타입 prefix: 'counter/increment' + initialState: { value: 0, status: 'idle' } as CounterState, + + reducers: { + increment(state) { + state.value += 1 // Immer 덕분에 직접 변경 가능 + }, + decrement(state) { + state.value -= 1 + }, + incrementByAmount(state, action: PayloadAction) { + state.value += action.payload + }, + reset() { + return { value: 0, status: 'idle' } // 새 객체 반환도 가능 + } + } + }) + + // 액션 크리에이터 & 리듀서 내보내기 + export const { increment, decrement, incrementByAmount, reset } + = counterSlice.actions + export default counterSlice.reducer + ``` + + - 예시 + + **prepare callback — 액션 가공** + + ```tsx + createSlice({ + name: 'todos', + initialState: [], + reducers: { + addTodo: { + // reducer: 실제 상태 변경 + reducer(state, action) { + state.push(action.payload) + }, + // prepare: payload 가공 (uuid 등 주입) + prepare(text: string) { + return { payload: { id: nanoid(), text } } + } + } + } + }) + ``` + + **extraReducers — 외부 액션 처리** + + ```tsx + createSlice({ + name: 'counter', + initialState, + reducers: {}, + // 다른 슬라이스 / createAsyncThunk 액션 처리 + extraReducers: (builder) => { + builder + .addCase(fetchUser.pending, (state) => { + state.status = 'loading' + }) + .addCase(fetchUser.fulfilled, (state, action) => { + state.status = 'idle' + state.value = action.payload.count + }) + .addMatcher(isRejected, (state) => { + state.status = 'idle' + }) + } + }) + ``` + + - useSelector - 상태 읽기 + + + #### **useSelector의 사용 예시** + + ```tsx + import { useSelector } from 'react-redux'; + + const count = useSelector((state) => state.counter.value); + ``` + + #### **동작 원리** + + ```tsx + state.counter.value + // 의 값이 바뀌면 + // 해당 컴포넌트만 리렌더링됨 + ``` + + #### **useSelector 특징: selector 기반 구독** + + → 필요한 state만 구독 가능 + + ```tsx + // 즉: + state.counter.value + // 만 변경 감지 + + → 성능 최적화 가능 + ``` + + #### **useSelector의 기본 사용 방법** + + ```tsx + import { useSelector } from 'react-redux' + import type { RootState } from './app/store' + + function Counter() { + // selector가 반환한 값이 변경될 때만 리렌더링 + const count = useSelector( + (state: RootState) => state.counter.value + ) + return
{count}
+ } + ``` + + > **동작 방식:** 매 dispatch 후 selector를 재실행하고, 이전 값과 `===` 비교. 다르면 리렌더링 트리거. + > + + - useDispatch - 액션 발생 + + + #### **useDispatch 사용 예시** + + ```tsx + import { useDispatch } from 'react-redux'; + import { increment } from './counterSlice'; + + const dispatch = useDispatch(); + + + ``` + + #### **useDispatch 기본 사용 방법** + + ```tsx + import { useDispatch } from 'react-redux' + import { increment, incrementByAmount } + from './counterSlice' + + function CounterButtons() { + const dispatch = useDispatch() + + return ( + <> + + + + ) + } + ``` + + - 기타 **`Redux Toolkit`** 사용 방법을 상세하게 정리해 보세요 + - Redux 흐름 + ```tsx + 컴포넌트 + ↓ dispatch(action) + Reducer 실행 + ↓ + State 변경 + ↓ + useSelector 감지 + ↓ + 컴포넌트 리렌더링 + ``` + #### **전체 구조 요약** + Redux Toolkit의 핵심 패턴은 다음 순서로 구성됩니다. + 1. `createSlice`로 상태 단위(slice)를 정의하고 리듀서와 액션 크리에이터를 한 번에 만든다. + 2. `configureStore`로 slice reducer들을 합쳐 스토어를 만든다. + 3. `Provider`로 React 앱 최상단에 스토어를 주입한다. + 4. 컴포넌트에서 `useSelector`로 읽고, `useDispatch`로 액션을 발생시킨다. + - 전체 **`Redux Toolkit`** 예제 + - `store.ts` + + ```tsx + import { configureStore } from '@reduxjs/toolkit'; + import counterReducer from './counterSlice'; + + export const store = configureStore({ + reducer: { + counter: counterReducer, + }, + }); + ``` + + - `counterSlice.ts` + + ```tsx + import { createSlice } from '@reduxjs/toolkit'; + + const counterSlice = createSlice({ + name: 'counter', + + initialState: { + value: 0, + }, + + reducers: { + increment: (state) => { + state.value += 1; + }, + + decrement: (state) => { + state.value -= 1; + }, + }, + }); + + export const { increment, decrement } = counterSlice.actions; + + export default counterSlice.reducer; + ``` + + - `Counter.tsx` + + ```tsx + import { useSelector, useDispatch } from 'react-redux'; + import { increment, decrement } from './counterSlice'; + + const Counter = () => { + const count = useSelector((state) => state.counter.value); + + const dispatch = useDispatch(); + + return ( +
+

{count}

+ + + + +
+ ); + }; + + export default Counter; + ``` + + - 기타 **`Redux Toolkit`** 사용 방법 + - `createAsyncThunk` - 비동기 API 처리용 함수 + + + 정의 및 `extraReducers` 연결 + + + + ```tsx + import { createSlice, createAsyncThunk } from '@reduxjs/toolkit' + + // 1. Thunk 생성 — 'users/fetchById' 접두사 자동 부여 + export const fetchUserById = createAsyncThunk( + 'users/fetchById', + async (userId: string, thunkAPI) => { + const response = await fetch(`/api/users/${userId}`) + if (!response.ok) { + return thunkAPI.rejectWithValue('서버 오류') + } + return response.json() // fulfilled payload + } + ) + + // 2. slice의 extraReducers에서 처리 + const usersSlice = createSlice({ + name: 'users', + initialState: { data: null, status: 'idle', error: null }, + reducers: {}, + extraReducers: (builder) => { + builder + .addCase(fetchUserById.pending, (state) => { + state.status = 'loading' + }) + .addCase(fetchUserById.fulfilled, (state, action) => { + state.status = 'succeeded' + state.data = action.payload + }) + .addCase(fetchUserById.rejected, (state, action) => { + state.status = 'failed' + state.error = action.payload // rejectWithValue 값 + }) + } + }) + ``` + + #### **컴포넌트에서 dispatch** + + ```tsx + function UserProfile({ userId }: { userId: string }) { + const dispatch = useAppDispatch() + const { data, status } = useAppSelector(s => s.users) + + useEffect(() => { + dispatch(fetchUserById(userId)) + }, [userId]) + + if (status === 'loading') return + return
{data?.name}
+ } + ``` + + > **thunkAPI 활용:** `getState()`로 현재 상태 참조, `dispatch()`로 다른 액션 호출, `signal`로 요청 취소(AbortController) 가능합니다. + > + - `createEntityAdapter` - 정규화 데이터 관리 + + ```tsx + import { createEntityAdapter, createSlice } from '@reduxjs/toolkit'; + + // { ids: [], entities: {} } 구조로 자동 관리 + const todosAdapter = createEntityAdapter({ + sortComparer: (a, b) => a.title.localeCompare(b.title), // 정렬 기준 + }); + + const todosSlice = createSlice({ + name: 'todos', + initialState: todosAdapter.getInitialState({ loading: false }), + reducers: { + addTodo: todosAdapter.addOne, // 기본 CRUD 내장 + addTodos: todosAdapter.addMany, + updateTodo: todosAdapter.updateOne, + removeTodo: todosAdapter.removeOne, + upsertTodos: todosAdapter.upsertMany, // 없으면 추가, 있으면 업데이트 + }, + }); + + // 내장 셀렉터 + const { selectAll, selectById, selectIds } = todosAdapter.getSelectors( + (s: RootState) => s.todos, + ); + + const allTodos = useAppSelector(selectAll); + ``` + + - `middleware` - Redux 동작 중간에 실행되는 기능 + 예시 + - 로깅 + - API 처리 + - 에러 처리 + ```tsx + configureStore({ + reducer, + middleware: (getDefaultMiddleware) => getDefaultMiddleware(), + }); + ``` + - `listenerMiddleware` - 사이드 이펙트 처리 + + ```tsx + import { createListenerMiddleware, isAnyOf } from '@reduxjs/toolkit'; + + const listenerMiddleware = createListenerMiddleware(); + + // 특정 액션 발생 시 사이드 이펙트 실행 + listenerMiddleware.startListening({ + actionCreator: userLoggedIn, + effect: async (action, listenerAPI) => { + // analytics, localStorage 저장 등 + analytics.track('login', { userId: action.payload.id }); + }, + }); + + // 여러 액션 매칭 + listenerMiddleware.startListening({ + matcher: isAnyOf(increment, decrement), + effect: (action, { getState }) => { + localStorage.setItem('count', getState().counter.value); + }, + }); + + // configureStore에 등록 + configureStore({ + middleware: (getDefault) => + getDefault().prepend(listenerMiddleware.middleware), + }); + ``` + + > **`redux-saga` / `redux-observable` 대안:** 복잡한 비동기 흐름 없이 간단한 사이드 이펙트에 적합합니다. 취소(abortController) 및 fork도 지원합니다. + + - `RTK Query` - 데이터 페칭 & 캐싱 + + ```tsx + import { + createApi, + fetchBaseQuery, + } from '@reduxjs/toolkit/query/react'; + + // API 슬라이스 정의 + export const postsApi = createApi({ + reducerPath: 'postsApi', + baseQuery: fetchBaseQuery({ baseUrl: '/api' }), + tagTypes: ['Post'], + endpoints: (builder) => ({ + getPosts: builder.query({ + query: () => '/posts', + providesTags: ['Post'], + }), + addPost: builder.mutation>({ + query: (body) => ({ url: '/posts', method: 'POST', body }), + invalidatesTags: ['Post'], // 자동 캐시 무효화 + }), + }), + }); + + // 자동 생성 훅 사용 + export const { useGetPostsQuery, useAddPostMutation } = postsApi; + + // 컴포넌트에서 + const { data, isLoading, error } = useGetPostsQuery(); + ``` + + > **자동 처리 항목:** 로딩·에러 상태, 중복 요청 제거, 캐시 관리, 낙관적 업데이트, 폴링, refetch on focus/reconnect + + - `DevTools` - Redux 상태 흐름 추적 가능 + - action 기록 확인 + - state 변화 추적 + - 디버깅 편리 + → RTK에서는 자동 활성화됨 + + - **`Redux Toolkit`** 장점 + | 장점 | 설명 | + | ------------------ | ---------------- | + | 코드 감소 | boilerplate 감소 | + | 불변성 자동 처리 | Immer 내장 | + | 공식 권장 | Redux 팀 공식 | + | DevTools 자동 설정 | 디버깅 쉬움 | + | 비동기 처리 지원 | createAsyncThunk | + - **`Redux Toolkit`** 단점 + | 단점 | 설명 | + | ---------------------------- | ----------------------------------- | + | 초기 학습 난이도 | Redux 개념 이해 필요 | + | boilerplate 완전 제거는 아님 | slice/store 구조 필요 | + | 작은 프로젝트엔 과할 수 있음 | Context/Zustand가 더 간단할 수 있음 | + +- **Zustand**란 무엇인가요? 🍠 + + # **Zustand**란 무엇인가요? + + *** + + + + #### **특징:** + + - Provider 필요 없음 + - 보일러플레이트 거의 없음 + - Hook 기반 사용 + - selector 기반 구독 지원 + - 성능 최적화 쉬움 + +- 왜 **Zustand**를 사용할까요? 🍠 + + # 왜 Zustand를 사용할까요? + + *** + + #### 1. 코드가 매우 간단함 + + ```tsx + // Redux: + store + slice + provider + dispatch + reducer + // 필요 + + //Zustand: + store 하나 생성하면 끝! + ``` + + #### 2. Provider 필요 없음 + + ```tsx + // Redux는: + + // 필수 + + Zustand는 필요 없음 + ``` + + #### 3. selector 기반 최적화 - 필요한 state만 구독 가능 + + → 불필요한 리렌더 감소 + + #### 4. 비동기 처리 쉬움 - store 내부에서 async 함수 바로 사용 가능 + +- **Zustand** 기본 사용법 🍠 + + # **Zustand** 기본 사용법 + + *** + + ### 1) Store 만들기 + + ```tsx + import { create } from 'zustand'; + + interface CounterState { + count: number; + + increase: () => void; + + decrease: () => void; + } + + export const useCounterStore = create((set) => ({ + count: 0, + + increase: () => + set((state) => ({ + count: state.count + 1, + })), + + decrease: () => + set((state) => ({ + count: state.count - 1, + })), + })); + ``` + + ### 2) 컴포넌트에서 사용하기 + + ```tsx + const count = useCounterStore((state) => state.count); + + const increase = useCounterStore((state) => state.increase); + ``` + +- **Zustand**에서 중요한 개념 🍠 + + # **Zustand**에서 중요한 개념 + + *** + + ### 1) set 함수: 상태 변경 함수 + + ```tsx + set((state) => ({ + count: state.count + 1, + })); + ``` + + ### 2) get 함수: 현재 상태 조회 가능 + + ```tsx + create((set, get) => ({ + increase: () => { + const current = get().count; + + set({ + count: current + 1, + }); + }, + })); + ``` + + ### 3) 선택적 구독 (selector) + + ```tsx + const count = useStore((state) => state.count); + // 특정 값만 구독 가능 + -> 성능 최적화 + ``` + +- **Zustand** 객체 상태 관리 예시 🍠 + + # **Zustand** 객체 상태 관리 예시 + + *** + + ```tsx + import { create } from 'zustand'; + + interface UserState { + user: { + name: string; + age: number; + }; + + changeName: (name: string) => void; + } + + export const useUserStore = create((set) => ({ + user: { + name: '철수', + age: 20, + }, + + changeName: (name) => + set((state) => ({ + user: { + ...state.user, + name, + }, + })), + })); + ``` + +- **Zustand** 비동기 로직 예시 🍠 + + # **Zustand** 비동기 로직 예시 + + *** + + **Zustand**에서는 비동기 API 호출도 간단하게 store 안에서 사용할 수 있어요. + + ```tsx + import { create } from 'zustand'; + + interface UserState { + users: string[]; + + fetchUsers: () => Promise; + } + + export const useUserStore = create((set) => ({ + users: [], + + fetchUsers: async () => { + const response = await fetch('/users'); + + const data = await response.json(); + + set({ + users: data, + }); + }, + })); + ``` + +- **Zustand** + Persist 미들웨어 🍠 + + # **Zustand** + Persist 미들웨어 + + *** + + **Zustand**는 미들웨어를 활용해 로컬스토리지 등에 상태를 저장할 수 있어요. + + ```tsx + import { create } from 'zustand'; + import { persist } from 'zustand/middleware'; + + export const useStore = create( + persist( + (set) => ({ + count: 0, + + increase: () => + set((state) => ({ + count: state.count + 1, + })), + }), + + { + name: 'counter-storage', + }, + ), + ); + ``` + + #### **Persist란?** + + + +- **Zustand** + Immer 함께 쓰기 🍠 + + # **Zustand** + Immer 함께 쓰기 + + *** + + 불변성 관리를 쉽게 하고 싶다면 Immer 미들웨어도 사용 가능해요. + + ```tsx + import { create } from 'zustand'; + import { immer } from 'zustand/middleware/immer'; + + const useStore = create( + immer((set) => ({ + count: 0, + + increase: () => + set((state) => { + state.count += 1; + }), + })), + ); + ``` + +- **Zustand** vs Context API 🍠 + + # **Zustand** vs Context API + + *** + + | 비교 | Zustand | Context API | + | --------------- | -------------------- | ---------------- | + | 성능 | selector 기반 최적화 | 전체 value 구독 | + | 리렌더 | 필요한 컴포넌트만 | 전체 리렌더 가능 | + | Provider | 필요 없음 | 필요 | + | 코드량 | 적음 | 상대적으로 많음 | + | 대규모 상태관리 | 유리 | 불리 | + +- **`Context API`**의 **`value 전체 구독 메커니즘`**과 **`Zustand`**의 **`selector 기반 구독`**의 성능 차이를 설명해보세요. + - `Context API`의 `value 전체 구독 메커니즘` + Context API는 + ```tsx + + ``` + value 객체 전체를 구독합니다. + 즉, + ```tsx + consta = useContext(MyContext); + ``` + 를 사용하면, + 실제로는 `a`만 필요한 게 아니라 Provider의 value 전체 변경을 감지합니다. + - `Context API`의 `value 전체 구독 메커니즘` 의 문제점 + 예시 + ``` + value={{ user, theme }} + ``` + 일 때, + theme만 변경돼도 user 사용하는 컴포넌트까지 리렌더링될 수 있음 + → 성능 비효율 발생 + - **`Zustand`**의 **`selector 기반 구독`** + Zustand는 + ```tsx + constuser = useStore((state) => state.user); + ``` + 처럼 특정 state만 구독 가능 + 즉, + - user 변경 시 → user 사용하는 컴포넌트만 리렌더 + - theme 변경 시 → theme 사용하는 컴포넌트만 리렌더 + → 훨씬 효율적 +- **`Jotai`**의 **`atom`** 조합 방식이 파생 상태 관리에서 Zustand 대비 갖는 장점을 의존성 추적 관점에서 설명해보세요. + #### **`Jotai`의 `atom` 조합 방식의 장점** + + + #### atom 예시 + + ```tsx + constcountAtom=atom(0); + + constdoubleAtom=atom((get) =>get(countAtom)*2); + ``` + + #### **핵심 특징** + + + + #### **Zustand 대비 장점** + + - Zustand + - selector 직접 작성 필요 + - 파생 상태 수동 관리 많음 + - Jotai + - atom 간 의존성 자동 추적 + - 파생 상태 자동 재계산 + - memoization 자연스러움 + + 즉, + + > 복잡한 파생 상태 관리에서는 Jotai가 더 선언적이고 효율적일 수 있음 + > +- 서버 상태를 **`useEffect`**로 관리할 때 발생하는 캐싱/중복 요청/불일치 문제를 설명해보세요. + 예시 + ```tsx + useEffect(() => { + fetch('/users') + .then((res) => res.json()) + .then(setUsers); + }, []); + ``` + #### **문제 1. 캐싱 없음** + 페이지 이동 후 다시 돌아오면 매번 API 재요청 + → 비효율 + #### **문제 2. 중복 요청 발생** + 여러 컴포넌트에서 같은 API 호출 가능 + → 네트워크 낭비 + #### **문제 3. 상태 불일치** + A 컴포넌트의 users와 B 컴포넌트의 users가 각자 fetch하면 데이터 시점 달라질 수 있음 + → 서버 상태 동기화 문제 + #### **문제 4. loading/error 직접 관리 필요** + ``` + const [loading,setLoading]=useState(false); + const [error,setError]=useState(null); + ``` + 직접 전부 구현해야 함 + #### 그래서 + - TanStack Query + - SWR + 같은 서버 상태 관리 라이브러리가 등장함! + +#### React Query가 해결하는 것 + +| 문제 | 해결 | +| ----------------- | ------------- | +| 캐싱 | 자동 | +| 중복 요청 | deduplication | +| stale 관리 | 자동 | +| refetch | 자동 | +| loading/error | 기본 제공 | +| optimistic update | 지원 | + +#### 최종 정리 + +| 라이브러리 | 특징 | +| ------------- | ------------------------ | +| Redux Toolkit | 대규모/명확한 구조 | +| Zustand | 간단하고 빠름 | +| Jotai | atom 기반 파생 상태 강력 | +| Context API | 작은 규모 적합 | +| React Query | 서버 상태 관리 특화 | From b19d778dc0f4be9aaca7e62e607265884d0f70cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B1=84=EC=9D=80?= Date: Sat, 30 May 2026 23:25:25 +0900 Subject: [PATCH 2/4] =?UTF-8?q?practice:=20chapter09=20useReducer=20?= =?UTF-8?q?=EC=8B=A4=EC=8A=B5=20=EC=A0=9C=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- practice/chapter09/useReducer/src/App.css | 0 practice/chapter09/useReducer/src/App.tsx | 11 ++++++ practice/chapter09/useReducer/src/index.css | 0 practice/chapter09/useReducer/src/main.tsx | 10 +++++ .../useReducer/src/pages/useReducerPage.tsx | 38 +++++++++++++++++++ 5 files changed, 59 insertions(+) create mode 100644 practice/chapter09/useReducer/src/App.css create mode 100644 practice/chapter09/useReducer/src/App.tsx create mode 100644 practice/chapter09/useReducer/src/index.css create mode 100644 practice/chapter09/useReducer/src/main.tsx create mode 100644 practice/chapter09/useReducer/src/pages/useReducerPage.tsx diff --git a/practice/chapter09/useReducer/src/App.css b/practice/chapter09/useReducer/src/App.css new file mode 100644 index 00000000..e69de29b diff --git a/practice/chapter09/useReducer/src/App.tsx b/practice/chapter09/useReducer/src/App.tsx new file mode 100644 index 00000000..e20bb2a0 --- /dev/null +++ b/practice/chapter09/useReducer/src/App.tsx @@ -0,0 +1,11 @@ +import Counter from './pages/useReducerPage'; + +function App() { + return ( +
+ +
+ ); +} + +export default App; diff --git a/practice/chapter09/useReducer/src/index.css b/practice/chapter09/useReducer/src/index.css new file mode 100644 index 00000000..e69de29b diff --git a/practice/chapter09/useReducer/src/main.tsx b/practice/chapter09/useReducer/src/main.tsx new file mode 100644 index 00000000..bef5202a --- /dev/null +++ b/practice/chapter09/useReducer/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/practice/chapter09/useReducer/src/pages/useReducerPage.tsx b/practice/chapter09/useReducer/src/pages/useReducerPage.tsx new file mode 100644 index 00000000..41829ba3 --- /dev/null +++ b/practice/chapter09/useReducer/src/pages/useReducerPage.tsx @@ -0,0 +1,38 @@ +import { useReducer } from 'react'; + +type Action = + | { type: 'INCREMENT' } + | { type: 'DECREMENT' }; + +const reducer = (state: number, action: Action): number => { + switch (action.type) { + case 'INCREMENT': + return state + 1; + + case 'DECREMENT': + return state - 1; + + default: + return state; + } +}; + +const Counter = () => { + const [count, dispatch] = useReducer(reducer, 0); + + return ( +
+

{count}

+ + + + +
+ ); +}; + +export default Counter; \ No newline at end of file From 936de17985badbb9106d740a3e93c7c412a9db56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B1=84=EC=9D=80?= Date: Sun, 31 May 2026 00:16:42 +0900 Subject: [PATCH 3/4] =?UTF-8?q?mission:=20chapter09=20mission=5F1=20?= =?UTF-8?q?=EC=A0=9C=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mission/chapter09/mission_1/src/App.css | 0 mission/chapter09/mission_1/src/App.tsx | 24 ++++ .../src/components/CartContainer.tsx | 44 +++++++ .../mission_1/src/components/CartItem.tsx | 54 +++++++++ .../mission_1/src/components/Navbar.tsx | 21 ++++ .../mission_1/src/constants/cartItems.ts | 109 ++++++++++++++++++ mission/chapter09/mission_1/src/index.css | 1 + mission/chapter09/mission_1/src/main.tsx | 14 +++ .../mission_1/src/store/cartSlice.ts | 78 +++++++++++++ .../chapter09/mission_1/src/store/store.ts | 11 ++ 10 files changed, 356 insertions(+) create mode 100644 mission/chapter09/mission_1/src/App.css create mode 100644 mission/chapter09/mission_1/src/App.tsx create mode 100644 mission/chapter09/mission_1/src/components/CartContainer.tsx create mode 100644 mission/chapter09/mission_1/src/components/CartItem.tsx create mode 100644 mission/chapter09/mission_1/src/components/Navbar.tsx create mode 100644 mission/chapter09/mission_1/src/constants/cartItems.ts create mode 100644 mission/chapter09/mission_1/src/index.css create mode 100644 mission/chapter09/mission_1/src/main.tsx create mode 100644 mission/chapter09/mission_1/src/store/cartSlice.ts create mode 100644 mission/chapter09/mission_1/src/store/store.ts diff --git a/mission/chapter09/mission_1/src/App.css b/mission/chapter09/mission_1/src/App.css new file mode 100644 index 00000000..e69de29b diff --git a/mission/chapter09/mission_1/src/App.tsx b/mission/chapter09/mission_1/src/App.tsx new file mode 100644 index 00000000..373feb16 --- /dev/null +++ b/mission/chapter09/mission_1/src/App.tsx @@ -0,0 +1,24 @@ +import { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Navbar from './components/Navbar'; +import CartContainer from './components/CartContainer'; +import { calculateTotals } from './store/cartSlice'; +import type { RootState } from './store/store'; + +function App() { + const { cartItems } = useSelector((state: RootState) => state.cart); + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(calculateTotals()); + }, [cartItems, dispatch]); + + return ( + <> + + + + ); +} + +export default App; \ No newline at end of file diff --git a/mission/chapter09/mission_1/src/components/CartContainer.tsx b/mission/chapter09/mission_1/src/components/CartContainer.tsx new file mode 100644 index 00000000..2fb92350 --- /dev/null +++ b/mission/chapter09/mission_1/src/components/CartContainer.tsx @@ -0,0 +1,44 @@ +import { useDispatch, useSelector } from 'react-redux'; +import CartItem from './CartItem'; +import { clearCart } from '../store/cartSlice'; +import type { RootState } from '../store/store'; + +const CartContainer = () => { + const { cartItems, total } = useSelector((state: RootState) => state.cart); + const dispatch = useDispatch(); + + if (cartItems.length === 0) { + return ( +
+

장바구니가 비어 있습니다.

+

담긴 음반이 없습니다.

+
+ ); + } + + return ( +
+ {cartItems.map((item) => ( + + ))} + +
+
+ 총 금액 + ${total.toLocaleString()} +
+ +
+ +
+
+
+ ); +}; + +export default CartContainer; \ No newline at end of file diff --git a/mission/chapter09/mission_1/src/components/CartItem.tsx b/mission/chapter09/mission_1/src/components/CartItem.tsx new file mode 100644 index 00000000..e9a696c9 --- /dev/null +++ b/mission/chapter09/mission_1/src/components/CartItem.tsx @@ -0,0 +1,54 @@ +import { useDispatch } from 'react-redux'; +import { decrease, increase, removeItem } from '../store/cartSlice'; +import type { CartItemType } from '../constants/cartItems'; + +const CartItem = ({ id, title, singer, price, img, amount }: CartItemType) => { + const dispatch = useDispatch(); + + return ( +
+
+ {title} + +
+

{title}

+

{singer}

+

${price}

+ + +
+
+ +
+ + + + {amount} + + + +
+
+ ); +}; + +export default CartItem; \ No newline at end of file diff --git a/mission/chapter09/mission_1/src/components/Navbar.tsx b/mission/chapter09/mission_1/src/components/Navbar.tsx new file mode 100644 index 00000000..230ef633 --- /dev/null +++ b/mission/chapter09/mission_1/src/components/Navbar.tsx @@ -0,0 +1,21 @@ +import { useSelector } from 'react-redux'; +import type { RootState } from '../store/store'; + +const Navbar = () => { + const { amount } = useSelector((state: RootState) => state.cart); + + return ( + + ); +}; + +export default Navbar; diff --git a/mission/chapter09/mission_1/src/constants/cartItems.ts b/mission/chapter09/mission_1/src/constants/cartItems.ts new file mode 100644 index 00000000..8aa4f74f --- /dev/null +++ b/mission/chapter09/mission_1/src/constants/cartItems.ts @@ -0,0 +1,109 @@ +export type CartItemType = { + id: string; + title: string; + singer: string; + price: string; + img: string; + amount: number; +}; + +const cartItems = [ + { + id: 'recB6qcHPxb62YJ75', + title: 'Vancouver', + singer: 'BIG Naughty (서동현)', + price: '25000', + img: 'https://image.bugsm.co.kr/album/images/500/40752/4075248.jpg', + amount: 1, + }, + { + id: 'recdRxBsE14Rr2VuJ', + title: 'Empty Island', + singer: 'greenblue', + price: '18000', + img: 'https://f4.bcbits.com/img/a1472100223_10.jpg', + amount: 1, + }, + { + id: 'recwTo120XST3PIoW', + title: 'golden hour', + singer: 'JVKE', + price: '28000', + img: 'https://image.bugsm.co.kr/album/images/200/193874/19387484.jpg?version=20230503022513.0', + amount: 1, + }, + { + id: 'rec1JZlfCIBOPdcT2', + title: 'Home Sweet Home(From "어쩌면 우린 헤어졌는지 모른다")', + singer: 'Gogang (고갱)', + price: '20000', + img: 'https://is1-ssl.mzstatic.com/image/thumb/Music116/v4/8d/d7/0f/8dd70fba-0a8f-b7ce-a2d2-f0d32dad2837/8809912894132.jpg/1200x1200bf-60.jpg', + amount: 1, + }, + { + id: 'recwTo160XST3PIoW', + title: 'Lemon', + singer: 'Kenshi Yonezu(켄시 요네즈/米津 玄師)', + price: '30000', + img: 'https://image.bugsm.co.kr/album/images/200/7222/722272.jpg?version=20220514022202.0', + amount: 1, + }, + { + id: 'recaBo120XST3PIoW', + title: '돌멩이', + singer: 'MASYTA (마시따)', + price: '12000', + img: 'https://image.bugsm.co.kr/album/images/200/3271/327113.jpg?version=20230606014806.0', + amount: 1, + }, + { + id: 'recqBo123XST3PIoK', + title: 'L’Amour, Les Baguettes, Paris', + singer: '스텔라 장(Stella Jang)', + price: '32000', + img: 'https://image.bugsm.co.kr/album/images/200/40660/4066056.jpg?version=20211020003912.0', + amount: 1, + }, + { + id: 'recqBo133XST3PIoK', + title: 'NO PAIN', + singer: '실리카겔', + price: '22000', + img: 'https://image.bugsm.co.kr/album/images/200/40790/4079061.jpg?version=20220826063340.0', + amount: 1, + }, + { + id: 'recqBo145XST3PIoK', + title: '너에게 (feat. HYUN SEO)', + singer: 'Halsoon', + price: '20000', + img: 'https://image.bugsm.co.kr/album/images/200/204634/20463445.jpg?version=20230110013144.0', + amount: 1, + }, + { + id: 'recqBo129XST3PIoK', + title: '널 떠올리는 중이야(Think About You)', + singer: 'PATEKO (파테코) , Jayci yucca(제이씨 유카)', + price: '25000', + img: 'https://image.bugsm.co.kr/album/images/200/40581/4058181.jpg?version=20210726063528.0', + amount: 1, + }, + { + id: 'rdaqBo129XST3PIoK', + title: '끝나지 않은 얘기(feat. 다이나믹 듀오)', + singer: '릴러말즈 & TOIL', + price: '23000', + img: 'https://image.bugsm.co.kr/album/images/200/204692/20469237.jpg?version=20220827004220.0', + amount: 1, + }, + { + id: 'rdaqBo149XQT3PIoK', + title: '각자의 밤', + singer: '나상현씨 밴드', + price: '21000', + img: 'https://image.bugsm.co.kr/album/images/200/202235/20223594.jpg?version=20230904194021.0', + amount: 1, + }, +]; + +export default cartItems; diff --git a/mission/chapter09/mission_1/src/index.css b/mission/chapter09/mission_1/src/index.css new file mode 100644 index 00000000..d4b50785 --- /dev/null +++ b/mission/chapter09/mission_1/src/index.css @@ -0,0 +1 @@ +@import 'tailwindcss'; diff --git a/mission/chapter09/mission_1/src/main.tsx b/mission/chapter09/mission_1/src/main.tsx new file mode 100644 index 00000000..e4cedc93 --- /dev/null +++ b/mission/chapter09/mission_1/src/main.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { Provider } from 'react-redux'; +import App from './App'; +import { store } from './store/store'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + , +); diff --git a/mission/chapter09/mission_1/src/store/cartSlice.ts b/mission/chapter09/mission_1/src/store/cartSlice.ts new file mode 100644 index 00000000..7be6c803 --- /dev/null +++ b/mission/chapter09/mission_1/src/store/cartSlice.ts @@ -0,0 +1,78 @@ +import { createSlice } from '@reduxjs/toolkit'; +import cartItems from '../constants/cartItems'; +import type { CartItemType } from '../constants/cartItems'; + +type CartState = { + cartItems: CartItemType[]; + amount: number; + total: number; +}; + +const initialState: CartState = { + cartItems, + amount: 0, + total: 0, +}; + +const cartSlice = createSlice({ + name: 'cart', + initialState, + reducers: { + increase: (state, action) => { + const item = state.cartItems.find((item) => item.id === action.payload); + + if (item) { + item.amount += 1; + } + }, + + decrease: (state, action) => { + const item = state.cartItems.find((item) => item.id === action.payload); + + if (item) { + item.amount -= 1; + + if (item.amount < 1) { + state.cartItems = state.cartItems.filter( + (cartItem) => cartItem.id !== action.payload + ); + } + } + }, + + removeItem: (state, action) => { + state.cartItems = state.cartItems.filter( + (item) => item.id !== action.payload + ); + }, + + clearCart: (state) => { + state.cartItems = []; + state.amount = 0; + state.total = 0; + }, + + calculateTotals: (state) => { + let amount = 0; + let total = 0; + + state.cartItems.forEach((item) => { + amount += item.amount; + total += Number(item.price) * item.amount; + }); + + state.amount = amount; + state.total = total; + }, + }, +}); + +export const { + increase, + decrease, + removeItem, + clearCart, + calculateTotals, +} = cartSlice.actions; + +export default cartSlice.reducer; \ No newline at end of file diff --git a/mission/chapter09/mission_1/src/store/store.ts b/mission/chapter09/mission_1/src/store/store.ts new file mode 100644 index 00000000..ab4d8a60 --- /dev/null +++ b/mission/chapter09/mission_1/src/store/store.ts @@ -0,0 +1,11 @@ +import { configureStore } from '@reduxjs/toolkit'; +import cartReducer from './cartSlice'; + +export const store = configureStore({ + reducer: { + cart: cartReducer, + }, +}); + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; \ No newline at end of file From 6aeb096ba4e027ab0caa887cdb468f824d5bc2ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=B1=84=EC=9D=80?= Date: Sun, 31 May 2026 00:31:56 +0900 Subject: [PATCH 4/4] =?UTF-8?q?mission:=20chapter09=20mission=5F2=20?= =?UTF-8?q?=EC=A0=9C=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- mission/chapter09/mission_2/src/App.css | 0 mission/chapter09/mission_2/src/App.tsx | 24 ++++ .../src/components/CartContainer.tsx | 53 +++++++++ .../mission_2/src/components/CartItem.tsx | 54 +++++++++ .../mission_2/src/components/Modal.tsx | 38 ++++++ .../mission_2/src/components/Navbar.tsx | 21 ++++ .../mission_2/src/constants/cartItems.ts | 109 ++++++++++++++++++ .../mission_2/src/features/cart/cartSlice.ts | 75 ++++++++++++ .../src/features/modal/modalSlice.ts | 25 ++++ mission/chapter09/mission_2/src/index.css | 1 + mission/chapter09/mission_2/src/main.tsx | 14 +++ .../chapter09/mission_2/src/store/store.ts | 13 +++ 12 files changed, 427 insertions(+) create mode 100644 mission/chapter09/mission_2/src/App.css create mode 100644 mission/chapter09/mission_2/src/App.tsx create mode 100644 mission/chapter09/mission_2/src/components/CartContainer.tsx create mode 100644 mission/chapter09/mission_2/src/components/CartItem.tsx create mode 100644 mission/chapter09/mission_2/src/components/Modal.tsx create mode 100644 mission/chapter09/mission_2/src/components/Navbar.tsx create mode 100644 mission/chapter09/mission_2/src/constants/cartItems.ts create mode 100644 mission/chapter09/mission_2/src/features/cart/cartSlice.ts create mode 100644 mission/chapter09/mission_2/src/features/modal/modalSlice.ts create mode 100644 mission/chapter09/mission_2/src/index.css create mode 100644 mission/chapter09/mission_2/src/main.tsx create mode 100644 mission/chapter09/mission_2/src/store/store.ts diff --git a/mission/chapter09/mission_2/src/App.css b/mission/chapter09/mission_2/src/App.css new file mode 100644 index 00000000..e69de29b diff --git a/mission/chapter09/mission_2/src/App.tsx b/mission/chapter09/mission_2/src/App.tsx new file mode 100644 index 00000000..76fbf225 --- /dev/null +++ b/mission/chapter09/mission_2/src/App.tsx @@ -0,0 +1,24 @@ +import { useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Navbar from './components/Navbar'; +import CartContainer from './components/CartContainer'; +import { calculateTotals } from './features/cart/cartSlice'; +import type { RootState } from './store/store'; + +function App() { + const { cartItems } = useSelector((state: RootState) => state.cart); + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(calculateTotals()); + }, [cartItems, dispatch]); + + return ( + <> + + + + ); +} + +export default App; diff --git a/mission/chapter09/mission_2/src/components/CartContainer.tsx b/mission/chapter09/mission_2/src/components/CartContainer.tsx new file mode 100644 index 00000000..debbfb7b --- /dev/null +++ b/mission/chapter09/mission_2/src/components/CartContainer.tsx @@ -0,0 +1,53 @@ +import { useDispatch, useSelector } from 'react-redux'; +import CartItem from './CartItem'; +import Modal from './Modal'; +import { openModal } from '../features/modal/modalSlice'; +import type { RootState } from '../store/store'; + +const CartContainer = () => { + const { cartItems, total } = useSelector((state: RootState) => state.cart); + const { isOpen } = useSelector((state: RootState) => state.modal); + const dispatch = useDispatch(); + + if (cartItems.length === 0) { + return ( + <> + {isOpen && } + +
+

장바구니가 비어 있습니다.

+
+ + ); + } + + return ( + <> + {isOpen && } + +
+ {cartItems.map((item) => ( + + ))} + +
+
+ 총 금액 + ${total.toLocaleString()} +
+ +
+ +
+
+
+ + ); +}; + +export default CartContainer; diff --git a/mission/chapter09/mission_2/src/components/CartItem.tsx b/mission/chapter09/mission_2/src/components/CartItem.tsx new file mode 100644 index 00000000..9329360d --- /dev/null +++ b/mission/chapter09/mission_2/src/components/CartItem.tsx @@ -0,0 +1,54 @@ +import { useDispatch } from 'react-redux'; +import { decrease, increase, removeItem } from '../features/cart/cartSlice'; +import type { CartItemType } from '../constants/cartItems'; + +const CartItem = ({ id, title, singer, price, img, amount }: CartItemType) => { + const dispatch = useDispatch(); + + return ( +
+
+ {title} + +
+

{title}

+

{singer}

+

${price}

+ + +
+
+ +
+ + + + {amount} + + + +
+
+ ); +}; + +export default CartItem; diff --git a/mission/chapter09/mission_2/src/components/Modal.tsx b/mission/chapter09/mission_2/src/components/Modal.tsx new file mode 100644 index 00000000..fe1d775b --- /dev/null +++ b/mission/chapter09/mission_2/src/components/Modal.tsx @@ -0,0 +1,38 @@ +import { useDispatch } from 'react-redux'; +import { clearCart } from '../features/cart/cartSlice'; +import { closeModal } from '../features/modal/modalSlice'; + +const Modal = () => { + const dispatch = useDispatch(); + + const handleClearCart = () => { + dispatch(clearCart()); + dispatch(closeModal()); + }; + + return ( +
+
+

정말 삭제하시겠습니까?

+ +
+ + + +
+
+
+ ); +}; + +export default Modal; \ No newline at end of file diff --git a/mission/chapter09/mission_2/src/components/Navbar.tsx b/mission/chapter09/mission_2/src/components/Navbar.tsx new file mode 100644 index 00000000..230ef633 --- /dev/null +++ b/mission/chapter09/mission_2/src/components/Navbar.tsx @@ -0,0 +1,21 @@ +import { useSelector } from 'react-redux'; +import type { RootState } from '../store/store'; + +const Navbar = () => { + const { amount } = useSelector((state: RootState) => state.cart); + + return ( + + ); +}; + +export default Navbar; diff --git a/mission/chapter09/mission_2/src/constants/cartItems.ts b/mission/chapter09/mission_2/src/constants/cartItems.ts new file mode 100644 index 00000000..8aa4f74f --- /dev/null +++ b/mission/chapter09/mission_2/src/constants/cartItems.ts @@ -0,0 +1,109 @@ +export type CartItemType = { + id: string; + title: string; + singer: string; + price: string; + img: string; + amount: number; +}; + +const cartItems = [ + { + id: 'recB6qcHPxb62YJ75', + title: 'Vancouver', + singer: 'BIG Naughty (서동현)', + price: '25000', + img: 'https://image.bugsm.co.kr/album/images/500/40752/4075248.jpg', + amount: 1, + }, + { + id: 'recdRxBsE14Rr2VuJ', + title: 'Empty Island', + singer: 'greenblue', + price: '18000', + img: 'https://f4.bcbits.com/img/a1472100223_10.jpg', + amount: 1, + }, + { + id: 'recwTo120XST3PIoW', + title: 'golden hour', + singer: 'JVKE', + price: '28000', + img: 'https://image.bugsm.co.kr/album/images/200/193874/19387484.jpg?version=20230503022513.0', + amount: 1, + }, + { + id: 'rec1JZlfCIBOPdcT2', + title: 'Home Sweet Home(From "어쩌면 우린 헤어졌는지 모른다")', + singer: 'Gogang (고갱)', + price: '20000', + img: 'https://is1-ssl.mzstatic.com/image/thumb/Music116/v4/8d/d7/0f/8dd70fba-0a8f-b7ce-a2d2-f0d32dad2837/8809912894132.jpg/1200x1200bf-60.jpg', + amount: 1, + }, + { + id: 'recwTo160XST3PIoW', + title: 'Lemon', + singer: 'Kenshi Yonezu(켄시 요네즈/米津 玄師)', + price: '30000', + img: 'https://image.bugsm.co.kr/album/images/200/7222/722272.jpg?version=20220514022202.0', + amount: 1, + }, + { + id: 'recaBo120XST3PIoW', + title: '돌멩이', + singer: 'MASYTA (마시따)', + price: '12000', + img: 'https://image.bugsm.co.kr/album/images/200/3271/327113.jpg?version=20230606014806.0', + amount: 1, + }, + { + id: 'recqBo123XST3PIoK', + title: 'L’Amour, Les Baguettes, Paris', + singer: '스텔라 장(Stella Jang)', + price: '32000', + img: 'https://image.bugsm.co.kr/album/images/200/40660/4066056.jpg?version=20211020003912.0', + amount: 1, + }, + { + id: 'recqBo133XST3PIoK', + title: 'NO PAIN', + singer: '실리카겔', + price: '22000', + img: 'https://image.bugsm.co.kr/album/images/200/40790/4079061.jpg?version=20220826063340.0', + amount: 1, + }, + { + id: 'recqBo145XST3PIoK', + title: '너에게 (feat. HYUN SEO)', + singer: 'Halsoon', + price: '20000', + img: 'https://image.bugsm.co.kr/album/images/200/204634/20463445.jpg?version=20230110013144.0', + amount: 1, + }, + { + id: 'recqBo129XST3PIoK', + title: '널 떠올리는 중이야(Think About You)', + singer: 'PATEKO (파테코) , Jayci yucca(제이씨 유카)', + price: '25000', + img: 'https://image.bugsm.co.kr/album/images/200/40581/4058181.jpg?version=20210726063528.0', + amount: 1, + }, + { + id: 'rdaqBo129XST3PIoK', + title: '끝나지 않은 얘기(feat. 다이나믹 듀오)', + singer: '릴러말즈 & TOIL', + price: '23000', + img: 'https://image.bugsm.co.kr/album/images/200/204692/20469237.jpg?version=20220827004220.0', + amount: 1, + }, + { + id: 'rdaqBo149XQT3PIoK', + title: '각자의 밤', + singer: '나상현씨 밴드', + price: '21000', + img: 'https://image.bugsm.co.kr/album/images/200/202235/20223594.jpg?version=20230904194021.0', + amount: 1, + }, +]; + +export default cartItems; diff --git a/mission/chapter09/mission_2/src/features/cart/cartSlice.ts b/mission/chapter09/mission_2/src/features/cart/cartSlice.ts new file mode 100644 index 00000000..0c8747bb --- /dev/null +++ b/mission/chapter09/mission_2/src/features/cart/cartSlice.ts @@ -0,0 +1,75 @@ +import { createSlice } from '@reduxjs/toolkit'; +import cartItems from '../../constants/cartItems'; +import type { CartItemType } from '../../constants/cartItems'; + +type CartState = { + cartItems: CartItemType[]; + amount: number; + total: number; +}; + +const initialState: CartState = { + cartItems, + amount: 0, + total: 0, +}; + +const cartSlice = createSlice({ + name: 'cart', + initialState, + reducers: { + increase: (state, action) => { + const item = state.cartItems.find((item) => item.id === action.payload); + if (item) item.amount += 1; + }, + + decrease: (state, action) => { + const item = state.cartItems.find((item) => item.id === action.payload); + + if (item) { + item.amount -= 1; + + if (item.amount < 1) { + state.cartItems = state.cartItems.filter( + (cartItem) => cartItem.id !== action.payload + ); + } + } + }, + + removeItem: (state, action) => { + state.cartItems = state.cartItems.filter( + (item) => item.id !== action.payload + ); + }, + + clearCart: (state) => { + state.cartItems = []; + state.amount = 0; + state.total = 0; + }, + + calculateTotals: (state) => { + let amount = 0; + let total = 0; + + state.cartItems.forEach((item) => { + amount += item.amount; + total += Number(item.price) * item.amount; + }); + + state.amount = amount; + state.total = total; + }, + }, +}); + +export const { + increase, + decrease, + removeItem, + clearCart, + calculateTotals, +} = cartSlice.actions; + +export default cartSlice.reducer; \ No newline at end of file diff --git a/mission/chapter09/mission_2/src/features/modal/modalSlice.ts b/mission/chapter09/mission_2/src/features/modal/modalSlice.ts new file mode 100644 index 00000000..14dc61f8 --- /dev/null +++ b/mission/chapter09/mission_2/src/features/modal/modalSlice.ts @@ -0,0 +1,25 @@ +import { createSlice } from '@reduxjs/toolkit'; + +type ModalState = { + isOpen: boolean; +}; + +const initialState: ModalState = { + isOpen: false, +}; + +const modalSlice = createSlice({ + name: 'modal', + initialState, + reducers: { + openModal: (state) => { + state.isOpen = true; + }, + closeModal: (state) => { + state.isOpen = false; + }, + }, +}); + +export const { openModal, closeModal } = modalSlice.actions; +export default modalSlice.reducer; \ No newline at end of file diff --git a/mission/chapter09/mission_2/src/index.css b/mission/chapter09/mission_2/src/index.css new file mode 100644 index 00000000..d4b50785 --- /dev/null +++ b/mission/chapter09/mission_2/src/index.css @@ -0,0 +1 @@ +@import 'tailwindcss'; diff --git a/mission/chapter09/mission_2/src/main.tsx b/mission/chapter09/mission_2/src/main.tsx new file mode 100644 index 00000000..e4cedc93 --- /dev/null +++ b/mission/chapter09/mission_2/src/main.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import { Provider } from 'react-redux'; +import App from './App'; +import { store } from './store/store'; +import './index.css'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + , +); diff --git a/mission/chapter09/mission_2/src/store/store.ts b/mission/chapter09/mission_2/src/store/store.ts new file mode 100644 index 00000000..4e383e28 --- /dev/null +++ b/mission/chapter09/mission_2/src/store/store.ts @@ -0,0 +1,13 @@ +import { configureStore } from '@reduxjs/toolkit'; +import cartReducer from '../features/cart/cartSlice'; +import modalReducer from '../features/modal/modalSlice'; + +export const store = configureStore({ + reducer: { + cart: cartReducer, + modal: modalReducer, + }, +}); + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; \ No newline at end of file