reducer·Context·데이터 요청
장바구니 reducer와 Context 및 데이터 요청 커스텀 훅으로 상태 계층을 구성합니다.
앞 글 실습에서 장바구니 앱의 구조와 스타일을 준비했습니다. 이 글에서는 앱의 상태 계층을 이루는 세 파일, 곧 장바구니의 상태 전이를 계산하는 리듀서, 그 상태를 앱 전체에 나눠 주는 Context, 상품 목록을 불러오는 커스텀 훅을 차례로 만듭니다.
cartReducer.js (useReducer)
장바구니 상태를 관리하는 리듀서 함수를 정의합니다. 이 파일에는 JSX가 없으므로 .js로 저장합니다.
// 액션 타입 상수 정의
export const ADD_ITEM = 'ADD_ITEM';
export const REMOVE_ITEM = 'REMOVE_ITEM';
export const UPDATE_QUANTITY = 'UPDATE_QUANTITY';
export const RESTORE_CART = 'RESTORE_CART';
// 초기 장바구니 상태
export const initialCartState = [];
// 리듀서 함수
export const cartReducer = (state, action) => {
switch (action.type) {
case ADD_ITEM: {
const existingItem = state.find(item => item.id === action.payload.item.id);
if (existingItem) {
// 이미 있는 상품이면 수량만 증가
return state.map(item =>
item.id === action.payload.item.id
? { ...item, quantity: item.quantity + action.payload.quantity }
: item
);
} else {
// 새 상품이면 목록에 추가
return [
...state,
{ ...action.payload.item, quantity: action.payload.quantity }
];
}
}
case REMOVE_ITEM:
return state.filter(item => item.id !== action.payload.id);
case UPDATE_QUANTITY:
return state.map(item =>
item.id === action.payload.id
? { ...item, quantity: action.payload.quantity }
: item
).filter(item => item.quantity > 0); // 수량이 0이하가 되면 제거
case RESTORE_CART:
return Array.isArray(action.payload.items)
? action.payload.items
: state;
default:
throw new Error(`Unhandled action type: ${action.type}`);
}
};리듀서는 현재 상태와 액션만으로 다음 상태를 계산하는 순수 함수입니다. 같은 상품을 다시 넣을 때와 처음 넣을 때처럼 조건에 따라 달라지는 전이를 차례로 따라가 보면, 컴포넌트에서 빠뜨린 예외를 찾기 쉽습니다.
상품 한 종류의 수량을 따라가 봅니다. 처음 배열은 비어 있고, 올바른 상품 객체의 id가 7이라고 가정합니다. ADD_ITEM으로 수량 2를 넣으면 새 항목이 생기고, 같은 id를 수량 3으로 다시 넣으면 기존 수량에 더해져 5가 됩니다. UPDATE_QUANTITY로 1을 보내면 더하는 것이 아니라 수량을 1로 교체하며, 0을 보내면 마지막의 filter가 그 항목을 지웁니다.
장바구니 reducer 액션별 다음 상태 — 상태 추적
실제 action 시퀀스 수량 4→6→3과 UPDATE 0.5 유지/0 제거, RESTORE 배열 참조/비배열 기존 참조가 달라진다. UI 정수 제한과 reducer 규칙을 분리한다.
CART REDUCER · ACTION CONTRACT
장바구니 reducer는 액션과 입력 조건으로 다음 상태를 정합니다
같은 상품 추가는 수량을 합치고, 수량 변경은 map 후 quantity > 0으로 걸러냅니다. UI의 정수 입력 제한과 reducer의 실제 수량 규칙을 구분합니다.
ADD와 REMOVE는 현재 배열에서 고른다
ADD_ITEM과 REMOVE_ITEM은 서로 다른 action입니다. REMOVE가 ADD 다음에 필수로 실행되는 흐름이 아닙니다.
UPDATE와 RESTORE의 조건 경계
UPDATE는 정수 여부를 검사하지 않습니다. RESTORE는 배열 내부 필드를 검증하지 않고, 알 수 없는 action은 오류를 던집니다.
원래 설명과 상세 기준
UPDATE_QUANTITY · 코드가 허용하는 경계
기존: id=1, quantity=2
요청: id=1, quantity=0.5
결과: id=1, quantity=0.5 유지
요청: id=1, quantity=0 → 제거본문 reducer는 정수 여부를 검사하지 않습니다. 복원 배열의 내부 필드도 검증하지 않으며, 알 수 없는 action은 오류를 던집니다.
| 액션 · 조건 | 다음 상태 | 확인할 불변식 |
|---|---|---|
ADD_ITEM같은 |
해당 항목의 수량에 요청 수량을 더한 새 배열 | 이미 id가 유일한 입력에서는 중복 행을 만들지 않음 |
ADD_ITEM같은 |
새 항목을 끝에 추가한 새 배열 | payload의 초기 수량을 사용 |
REMOVE_ITEM제거할 |
그 항목을 제외한 새 배열 | 다른 항목의 참조와 값은 보존 |
UPDATE_QUANTITY새 수량이 0보다 큼 |
대상 항목의 수량만 바꾼 새 배열 | 양의 소수도 유지 · 직접 mutation 없음 |
UPDATE_QUANTITY새 수량이 0 이하 |
map으로 수량 갱신 뒤 quantity > 0인 모든 항목만 유지 | 0 이하 수량 항목은 남지 않음 |
RESTORE_CART
|
배열 payload 그대로 반환 · 아니면 현재 상태 | reducer 안에서는 저장소를 읽지 않음 |
ADD_ITEM · 두 분기
기존 id 수량에 요청 수량을 더한 새 배열입니다. id가 유일한 입력이면 중복 행을 만들지 않습니다.
새 id payload의 초기 수량을 가진 항목을 끝에 추가합니다.
REMOVE_ITEM
받은 id를 제외한 새 배열을 만듭니다. 나머지 항목의 참조와 값은 보존합니다.
UPDATE_QUANTITY
0보다 큼 대상 수량을 교체하며 양의 소수도 유지합니다. 직접 mutation은 없습니다.
0 이하 map으로 수량을 갱신한 뒤 quantity > 0인 모든 항목만 남깁니다.
RESTORE_CART
Array.isArray(payload)이면 그 배열을 그대로 반환하고, 아니면 현재 상태를 유지합니다. reducer는 저장소를 읽지 않습니다.
RESTORE_CART의 비배열 payload는 기존 참조를 그대로 반환합니다. 모든 action의 payload 검증·정규화는 별도 보강 과제입니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 표 행 | 액션 · 조건 | 다음 상태 | 확인할 불변식 |
| 원도표 설명 | 같은 id가 이미 있음 |
| 원도표 표 행 | ADD_ITEM · 같은 id가 이미 있음 | 해당 항목의 수량에 요청 수량을 더한 새 배열 | 이미 id가 유일한 입력에서는 중복 행을 만들지 않음 |
| 원도표 설명 | 같은 id가 없음 |
| 원도표 표 행 | ADD_ITEM · 같은 id가 없음 | 새 항목을 끝에 추가한 새 배열 | payload의 초기 수량을 사용 |
| 원도표 설명 | 제거할 id를 받음 |
| 원도표 표 행 | REMOVE_ITEM · 제거할 id를 받음 | 그 항목을 제외한 새 배열 | 다른 항목의 참조와 값은 보존 |
| 원도표 표 행 | UPDATE_QUANTITY · 새 수량이 0보다 큼 | 대상 항목의 수량만 바꾼 새 배열 | 양의 소수도 유지 · 직접 mutation 없음 |
| 원도표 표 행 | UPDATE_QUANTITY · 새 수량이 0 이하 | map으로 수량 갱신 뒤 quantity > 0인 모든 항목만 유지 | 0 이하 수량 항목은 남지 않음 |
| 원도표 설명 | Array.isArray()로 배열 여부를 확인함 |
| 원도표 표 행 | RESTORE_CART · Array.isArray()로 배열 여부를 확인함 | 배열 payload 그대로 반환 · 아니면 현재 상태 | reducer 안에서는 저장소를 읽지 않음 |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 동일 id 추가 | 수량: 4→6 / 배열: 새 배열 | 같은 상품을 추가해 수량 4에서 6으로 바뀐다 | 중복 행 대신 기존 항목의 수량을 합친다 |
| UPDATE 수량 | 본문 trace: 6→3 / 경계: 0.5도 유지 | 직접 reducer 요청 3 또는 0.5는 양수이므로 남는다 | UI 정수 제한이 reducer의 모든 입력을 검증하지 않는다 |
| 0과 restore | 0 요청: 항목 제거 / 비배열 restore: 기존 참조 유지 | 0 이하를 제거하며 RESTORE 배열은 그대로, 비배열은 기존 state다 | 내부 필드 검증과 모든 payload 정규화는 별도 보강이다 |
장바구니 reducer 액션별 다음 상태 — 상태 추적
실제 action 시퀀스 수량 4→6→3과 UPDATE 0.5 유지/0 제거, RESTORE 배열 참조/비배열 기존 참조가 달라진다. UI 정수 제한과 reducer 규칙을 분리한다.
CART REDUCER · ACTION CONTRACT
장바구니 reducer는 액션과 입력 조건으로 다음 상태를 정합니다
같은 상품 추가는 수량을 합치고, 수량 변경은 map 후 quantity > 0으로 걸러냅니다. UI의 정수 입력 제한과 reducer의 실제 수량 규칙을 구분합니다.
ADD와 REMOVE는 현재 배열에서 고른다
ADD_ITEM과 REMOVE_ITEM은 서로 다른 action입니다. REMOVE가 ADD 다음에 필수로 실행되는 흐름이 아닙니다.
UPDATE와 RESTORE의 조건 경계
UPDATE는 정수 여부를 검사하지 않습니다. RESTORE는 배열 내부 필드를 검증하지 않고, 알 수 없는 action은 오류를 던집니다.
원래 설명과 상세 기준
UPDATE_QUANTITY · 코드가 허용하는 경계
기존: id=1, quantity=2
요청: id=1, quantity=0.5
결과: id=1, quantity=0.5 유지
요청: id=1, quantity=0 → 제거본문 reducer는 정수 여부를 검사하지 않습니다. 복원 배열의 내부 필드도 검증하지 않으며, 알 수 없는 action은 오류를 던집니다.
| 액션 · 조건 | 다음 상태 | 확인할 불변식 |
|---|---|---|
ADD_ITEM같은 |
해당 항목의 수량에 요청 수량을 더한 새 배열 | 이미 id가 유일한 입력에서는 중복 행을 만들지 않음 |
ADD_ITEM같은 |
새 항목을 끝에 추가한 새 배열 | payload의 초기 수량을 사용 |
REMOVE_ITEM제거할 |
그 항목을 제외한 새 배열 | 다른 항목의 참조와 값은 보존 |
UPDATE_QUANTITY새 수량이 0보다 큼 |
대상 항목의 수량만 바꾼 새 배열 | 양의 소수도 유지 · 직접 mutation 없음 |
UPDATE_QUANTITY새 수량이 0 이하 |
map으로 수량 갱신 뒤 quantity > 0인 모든 항목만 유지 | 0 이하 수량 항목은 남지 않음 |
RESTORE_CART
|
배열 payload 그대로 반환 · 아니면 현재 상태 | reducer 안에서는 저장소를 읽지 않음 |
ADD_ITEM · 두 분기
기존 id 수량에 요청 수량을 더한 새 배열입니다. id가 유일한 입력이면 중복 행을 만들지 않습니다.
새 id payload의 초기 수량을 가진 항목을 끝에 추가합니다.
REMOVE_ITEM
받은 id를 제외한 새 배열을 만듭니다. 나머지 항목의 참조와 값은 보존합니다.
UPDATE_QUANTITY
0보다 큼 대상 수량을 교체하며 양의 소수도 유지합니다. 직접 mutation은 없습니다.
0 이하 map으로 수량을 갱신한 뒤 quantity > 0인 모든 항목만 남깁니다.
RESTORE_CART
Array.isArray(payload)이면 그 배열을 그대로 반환하고, 아니면 현재 상태를 유지합니다. reducer는 저장소를 읽지 않습니다.
RESTORE_CART의 비배열 payload는 기존 참조를 그대로 반환합니다. 모든 action의 payload 검증·정규화는 별도 보강 과제입니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 표 행 | 액션 · 조건 | 다음 상태 | 확인할 불변식 |
| 원도표 설명 | 같은 id가 이미 있음 |
| 원도표 표 행 | ADD_ITEM · 같은 id가 이미 있음 | 해당 항목의 수량에 요청 수량을 더한 새 배열 | 이미 id가 유일한 입력에서는 중복 행을 만들지 않음 |
| 원도표 설명 | 같은 id가 없음 |
| 원도표 표 행 | ADD_ITEM · 같은 id가 없음 | 새 항목을 끝에 추가한 새 배열 | payload의 초기 수량을 사용 |
| 원도표 설명 | 제거할 id를 받음 |
| 원도표 표 행 | REMOVE_ITEM · 제거할 id를 받음 | 그 항목을 제외한 새 배열 | 다른 항목의 참조와 값은 보존 |
| 원도표 표 행 | UPDATE_QUANTITY · 새 수량이 0보다 큼 | 대상 항목의 수량만 바꾼 새 배열 | 양의 소수도 유지 · 직접 mutation 없음 |
| 원도표 표 행 | UPDATE_QUANTITY · 새 수량이 0 이하 | map으로 수량 갱신 뒤 quantity > 0인 모든 항목만 유지 | 0 이하 수량 항목은 남지 않음 |
| 원도표 설명 | Array.isArray()로 배열 여부를 확인함 |
| 원도표 표 행 | RESTORE_CART · Array.isArray()로 배열 여부를 확인함 | 배열 payload 그대로 반환 · 아니면 현재 상태 | reducer 안에서는 저장소를 읽지 않음 |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 동일 id 추가 | 수량: 4→6 / 배열: 새 배열 | 같은 상품을 추가해 수량 4에서 6으로 바뀐다 | 중복 행 대신 기존 항목의 수량을 합친다 |
| UPDATE 수량 | 본문 trace: 6→3 / 경계: 0.5도 유지 | 직접 reducer 요청 3 또는 0.5는 양수이므로 남는다 | UI 정수 제한이 reducer의 모든 입력을 검증하지 않는다 |
| 0과 restore | 0 요청: 항목 제거 / 비배열 restore: 기존 참조 유지 | 0 이하를 제거하며 RESTORE 배열은 그대로, 비배열은 기존 state다 | 내부 필드 검증과 모든 payload 정규화는 별도 보강이다 |
위 그림은 장바구니 리듀서가 액션과 입력의 조건에 따라 다음 상태를 정하는 방식을 두 부분으로 보여 줍니다. 위쪽은 현재 장바구니 배열에서 고르는 두 액션입니다. ADD_ITEM은 id가 같은 항목이 있으면 수량을 합치고, 없으면 새 항목을 더하며, REMOVE_ITEM은 대상 id를 뺀 새 배열을 만들고 다른 항목은 그대로 둡니다. 그림의 설명처럼 두 액션은 서로 다른 액션이며, ADD_ITEM 다음에 REMOVE_ITEM이 꼭 실행되는 흐름이 아닙니다.
아래쪽은 UPDATE_QUANTITY와 RESTORE_CART의 조건을 보여 줍니다. UPDATE_QUANTITY는 map으로 수량을 교체한 뒤, 0보다 크면 남기고 0 이하이면 filter로 뺍니다. 이때 정수인지는 검사하지 않으므로 1.5 같은 소수도 0보다 크면 그대로 남습니다. 화면의 입력이 정수만 받도록 막는 것과 리듀서가 실제로 지키는 수량 규칙은 구별해야 합니다. RESTORE_CART는 넘겨받은 값이 배열이면 그대로 쓰고, 배열이 아니면 현재 상태의 참조를 유지하며, 배열 안의 필드가 올바른지는 검증하지 않습니다. 알 수 없는 액션은 오류를 던집니다.
CartContext.jsx (useContext)
장바구니 상태와 dispatch 함수를 앱 전체에 제공하는 Context를 만듭니다. 이 파일은 JSX를 쓰므로 .jsx로 저장합니다.
import React, { createContext, useEffect, useReducer, useState } from 'react';
import {
cartReducer,
initialCartState,
RESTORE_CART,
} from '../reducers/cartReducer';
// 1. Context 객체 생성
export const CartContext = createContext(null);
// 2. Provider 컴포넌트 생성
export const CartProvider = ({ children }) => {
const [cartState, dispatch] = useReducer(cartReducer, initialCartState);
const [storageReady, setStorageReady] = useState(false);
// 브라우저 저장소 복원은 외부 시스템 동기화이므로 Effect에서 수행
useEffect(() => {
try {
const savedCart = localStorage.getItem('shoppingCart');
const parsedCart = savedCart ? JSON.parse(savedCart) : initialCartState;
dispatch({
type: RESTORE_CART,
payload: { items: Array.isArray(parsedCart) ? parsedCart : initialCartState },
});
} catch (error) {
console.error('Failed to restore cart from localStorage', error);
} finally {
setStorageReady(true);
}
}, []);
// 복원이 끝날 때까지 쓰기를 건너뛰어 초기 빈 배열로 기존 값을 덮지 않음
useEffect(() => {
if (!storageReady) return;
try {
localStorage.setItem('shoppingCart', JSON.stringify(cartState));
} catch (error) {
console.error('Failed to persist cart to localStorage', error);
}
}, [cartState, storageReady]);
// Context Provider를 통해 상태와 dispatch 함수 제공
return (
<CartContext.Provider value={{ cartState, dispatch }}>
{children}
</CartContext.Provider>
);
};이 예제는 Vite로 만든 브라우저 앱을 전제로 합니다. localStorage는 현재 출처(origin)의 브라우저 저장소일 뿐, 서버에 있는 공유 데이터의 원본이 아닙니다. 서버 렌더링 환경으로 옮길 때도 저장소 접근은 클라이언트의 Effect 안에 두고, 서버와 클라이언트의 첫 화면이 달라지지 않도록 복원 상태를 따로 설계해야 합니다.
Context를 읽는 컴포넌트는 Provider의 value가 바뀌면 다시 렌더링됩니다. dispatch의 참조는 언제나 같지만, 이 예제의 인라인 value 객체는 CartProvider가 렌더링될 때마다 새로 만들어집니다. 실제로 측정해 보니 불필요한 소비자 렌더링이 문제가 된다면, 값을 메모이제이션하거나 상태를 읽는 Context와 액션을 보내는 Context를 바뀌는 빈도에 맞춰 나누는 방법을 고려합니다.
CartProvider의 복원·전이·공유·저장 경계 — 상태 추적
storageReady=false 동안 초기 빈 목록 쓰기를 막고 복원 뒤 저장이 허용된다. localStorage/현재 state/ready를 추적해 복원 자료 소실을 이해할 가치가 있다.
CART PROVIDER · RESTORE BEFORE WRITE
storageReady가 초기 빈 배열의 덮어쓰기를 막습니다
mount Effect가 저장 배열을 RESTORE_CART로 복원합니다. 복원 완료 전 저장을 건너뛰고, 이후의 cartState는 Context로 공유하며 Effect에서 저장합니다.
초기 저장 차단 · 본문의 두 Effect
첫 렌더: cartState=[], storageReady=false
저장 Effect: !storageReady → return
복원 Effect: 읽기/parse/RESTORE_CART
finally: setStorageReady(true)
다음 렌더부터: cartState 직렬화/저장읽기·파싱 실패도 finally에서 준비를 마칩니다. 쓰기 실패는 로그로 처리하며 복원은 배열 여부만 검사합니다. 항목 내부 필드·다른 탭의 동기화는 보장하지 않습니다.
localStorage는 세션 사이의 복원 매체입니다. reducer는 순수 계산을, Effect는 저장소 읽기·쓰기를 맡습니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 관계·조건 설명 | Provider 바깥의 localStorage 값을 복원 Effect가 읽고 RESTORE_CART 액션을 useReducer에 보낸다. useReducer의 cartState와 안정된 dispatch는 Context를 통해 ProductItem과 CartSummary에 전달된다. 소비자의 액션은 dispatch를 거쳐 reducer로 돌아오고, storageReady가 된 복원 완료 상태부터 저장 Effect가 cartState를 localStorage에 기록한다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 초기 빈 state | state: [] / 쓰기: 차단 | cartState는 빈 배열이고 storageReady는 false다 | 기존 저장값을 초기의 빈 값으로 덮지 않는다 |
| 복원 | state: 복원 배열 / ready: true | Effect가 저장 배열을 읽어 RESTORE하고 ready를 연다 | 복원 완료와 저장 허용의 조건을 연결한다 |
| 후속 저장 | state: 현재 배열 / storage: 현재 JSON | 현재 cartState의 변경을 localStorage에 쓴다 | Context는 전달 통로이며 localStorage는 origin별 브라우저 자료다 |
CartProvider의 복원·전이·공유·저장 경계 — 상태 추적
storageReady=false 동안 초기 빈 목록 쓰기를 막고 복원 뒤 저장이 허용된다. localStorage/현재 state/ready를 추적해 복원 자료 소실을 이해할 가치가 있다.
CART PROVIDER · RESTORE BEFORE WRITE
storageReady가 초기 빈 배열의 덮어쓰기를 막습니다
mount Effect가 저장 배열을 RESTORE_CART로 복원합니다. 복원 완료 전 저장을 건너뛰고, 이후의 cartState는 Context로 공유하며 Effect에서 저장합니다.
초기 저장 차단 · 본문의 두 Effect
첫 렌더: cartState=[], storageReady=false
저장 Effect: !storageReady → return
복원 Effect: 읽기/parse/RESTORE_CART
finally: setStorageReady(true)
다음 렌더부터: cartState 직렬화/저장읽기·파싱 실패도 finally에서 준비를 마칩니다. 쓰기 실패는 로그로 처리하며 복원은 배열 여부만 검사합니다. 항목 내부 필드·다른 탭의 동기화는 보장하지 않습니다.
localStorage는 세션 사이의 복원 매체입니다. reducer는 순수 계산을, Effect는 저장소 읽기·쓰기를 맡습니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 관계·조건 설명 | Provider 바깥의 localStorage 값을 복원 Effect가 읽고 RESTORE_CART 액션을 useReducer에 보낸다. useReducer의 cartState와 안정된 dispatch는 Context를 통해 ProductItem과 CartSummary에 전달된다. 소비자의 액션은 dispatch를 거쳐 reducer로 돌아오고, storageReady가 된 복원 완료 상태부터 저장 Effect가 cartState를 localStorage에 기록한다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 초기 빈 state | state: [] / 쓰기: 차단 | cartState는 빈 배열이고 storageReady는 false다 | 기존 저장값을 초기의 빈 값으로 덮지 않는다 |
| 복원 | state: 복원 배열 / ready: true | Effect가 저장 배열을 읽어 RESTORE하고 ready를 연다 | 복원 완료와 저장 허용의 조건을 연결한다 |
| 후속 저장 | state: 현재 배열 / storage: 현재 JSON | 현재 cartState의 변경을 localStorage에 쓴다 | Context는 전달 통로이며 localStorage는 origin별 브라우저 자료다 |
위 그림은 CartProvider 안에서 복원과 전이, 공유, 저장이 어떻게 이어지는지 보여 줍니다. Provider 바깥에 있는 localStorage의 값을 복원 Effect가 읽어 배열인지 확인하고, RESTORE_CART 액션으로 cartReducer에 보냅니다. 리듀서가 계산한 cartState와 참조가 바뀌지 않는 dispatch는 Context의 value로 ProductItem과 CartSummary 같은 소비자에게 전달되고, 소비자의 사용자 액션은 다시 dispatch를 거쳐 리듀서로 돌아옵니다. 그림의 두 판 가운데 하나는 소비자를 ProductList로 적었는데, 상품 목록 안의 ProductItem이 실제로 장바구니에 액션을 보내므로 같은 경로를 가리킵니다.
이 구조의 핵심은 storageReady가 초기의 빈 배열로 저장값을 덮어쓰지 않도록 막는다는 점입니다. 첫 렌더에서 cartState는 []이고 storageReady는 false이므로, 저장 Effect는 !storageReady를 보고 바로 돌아갑니다. 그동안 복원 Effect가 저장값을 읽고 파싱해 RESTORE_CART를 보내며, finally에서 setStorageReady(true)를 호출합니다. 그 뒤의 렌더부터 저장 Effect가 cartState를 로컬 스토리지에 기록합니다.
useFetch.js (Custom Hook)
API를 호출하는 로직을 재사용할 수 있는 커스텀 훅으로 만듭니다. 이 파일도 JSX가 없으므로 .js로 저장합니다.
import { useState, useEffect } from 'react';
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let ignore = false;
const abortController = new AbortController();
const fetchData = async () => {
setLoading(true);
setError(null);
setData(null);
try {
const response = await fetch(url, { signal: abortController.signal });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const json = await response.json();
if (!ignore) {
setData(json);
}
} catch (error) {
if (!ignore && error.name !== 'AbortError') {
setError(error);
}
} finally {
if (!ignore) {
setLoading(false);
}
}
};
fetchData();
// 이전 Effect는 네트워크 작업과 늦은 상태 반영을 모두 중단
return () => {
ignore = true;
abortController.abort();
};
}, [url]);
return { data, loading, error };
}
export default useFetch;fetch는 404나 500 응답만으로는 Promise를 거부(reject)하지 않으므로 response.ok를 직접 확인해야 합니다. response.json()의 파싱 실패도 같은 catch로 들어가며, 정리 함수가 일으킨 AbortError는 사용자에게 보여 줄 오류에서 뺍니다. AbortController는 필요 없어진 네트워크 작업을 중단하고, ignore 플래그는 이전 Effect의 catch나 finally가 새 요청의 상태를 덮어쓰지 못하게 막습니다.
이 훅은 Effect의 생명주기를 익히기 위한 작은 예제입니다. 실제 제품에서는 캐시, 중복 요청 제거, 서버 데이터 로딩 같은 기능이 필요할 수 있으므로, 쓰고 있는 프레임워크의 데이터 로더나 검증된 클라이언트 캐시 라이브러리를 먼저 검토합니다.
useFetch Effect의 요청·정리·결과 반영 상태 — 상태 추적
A cleanup ignore=true/abort 뒤 B만 data/error/loading을 쓴다. 늦은 A 결과와 finally가 현재 UI를 덮지 않는 실제 guard 상태를 추적할 가치가 크다.
USEFETCH · REQUEST LIFETIME
늦게 끝난 이전 요청은 현재 URL의 state를 쓰지 않습니다
URL마다 Effect의 ignore와 AbortController를 만듭니다. cleanup은 ignore=true와 abort()를 실행하고, 현재 Effect만 정상 데이터·오류·loading을 반영합니다.
URL A → URL B · 완료 순서가 뒤집혀도
A setup → 요청 A
URL B commit → A cleanup
A ignore=true / abort()
B setup → 요청 B
A의 늦은 catch/finally → state 쓰기 없음
B 결과 → data/error, loading=falsefetch는 response.ok를 검사한 뒤 JSON을 파싱합니다. 현재 Effect의 AbortError는 error로 쓰지 않고, cleanup된 이전 Effect는 finally에서도 loading을 바꾸지 않습니다.
점선은 URL 변경·언마운트에 따른 정리 경로이고, 실선의 결과 분기와 별개입니다. 이전 Effect의 결과는 현재 화면 상태를 쓰지 않습니다.
ProductList는 loading이면 spinner를 보여줍니다. 응답이 배열이면 그 상품 목록을 쓰고, 실패·비배열이면 mock 상품과 안내를 씁니다. 정상 빈 배열은 mock으로 바꾸지 않습니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 노드·간선·영역 라벨 | 아니요 |
| 원도표 설명 | ProductList는 loading이면 spinner를 보여 줍니다. 응답이 배열이면 그 상품 목록을 쓰고, 실패·비배열이면 mock 상품과 안내를 씁니다. 정상 빈 배열은 mock으로 바꾸지 않습니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 요청 A setup | current: A / loading: true | A가 ignore=false와 controller로 실행된다 | Effect별로 쓰기 권한을 캡처한다 |
| URL B 교체 | A: 권한 종료 / B: 현재 요청 | A cleanup은 ignore=true와 abort, B setup은 새 상태를 준비한다 | abort와 늦은 반영의 차단은 서로 다른 장치다 |
| B와 늦은 A | B: 결과+loading false / A: 현재 UI 쓰기 없음 | B만 data·error·loading을 쓰고 A의 catch와 finally도 무시한다 | 전송 중단이 이미 서버에서 수행한 업무를 되돌리지는 않는다 |
useFetch Effect의 요청·정리·결과 반영 상태 — 상태 추적
A cleanup ignore=true/abort 뒤 B만 data/error/loading을 쓴다. 늦은 A 결과와 finally가 현재 UI를 덮지 않는 실제 guard 상태를 추적할 가치가 크다.
USEFETCH · REQUEST LIFETIME
늦게 끝난 이전 요청은 현재 URL의 state를 쓰지 않습니다
URL마다 Effect의 ignore와 AbortController를 만듭니다. cleanup은 ignore=true와 abort()를 실행하고, 현재 Effect만 정상 데이터·오류·loading을 반영합니다.
URL A → URL B · 완료 순서가 뒤집혀도
A setup → 요청 A
URL B commit → A cleanup
A ignore=true / abort()
B setup → 요청 B
A의 늦은 catch/finally → state 쓰기 없음
B 결과 → data/error, loading=falsefetch는 response.ok를 검사한 뒤 JSON을 파싱합니다. 현재 Effect의 AbortError는 error로 쓰지 않고, cleanup된 이전 Effect는 finally에서도 loading을 바꾸지 않습니다.
점선은 URL 변경·언마운트에 따른 정리 경로이고, 실선의 결과 분기와 별개입니다. 이전 Effect의 결과는 현재 화면 상태를 쓰지 않습니다.
ProductList는 loading이면 spinner를 보여줍니다. 응답이 배열이면 그 상품 목록을 쓰고, 실패·비배열이면 mock 상품과 안내를 씁니다. 정상 빈 배열은 mock으로 바꾸지 않습니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 원도표 노드·간선·영역 라벨 | 아니요 |
| 원도표 설명 | ProductList는 loading이면 spinner를 보여 줍니다. 응답이 배열이면 그 상품 목록을 쓰고, 실패·비배열이면 mock 상품과 안내를 씁니다. 정상 빈 배열은 mock으로 바꾸지 않습니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| 요청 A setup | current: A / loading: true | A가 ignore=false와 controller로 실행된다 | Effect별로 쓰기 권한을 캡처한다 |
| URL B 교체 | A: 권한 종료 / B: 현재 요청 | A cleanup은 ignore=true와 abort, B setup은 새 상태를 준비한다 | abort와 늦은 반영의 차단은 서로 다른 장치다 |
| B와 늦은 A | B: 결과+loading false / A: 현재 UI 쓰기 없음 | B만 data·error·loading을 쓰고 A의 catch와 finally도 무시한다 | 전송 중단이 이미 서버에서 수행한 업무를 되돌리지는 않는다 |
위 그림은 useFetch의 Effect가 요청하고, 정리하고, 결과를 반영하는 흐름을 보여 줍니다. URL이 바뀌면 Effect의 setup이 data와 error를 초기화하고, signal을 연결해 fetch로 요청한 뒤 response.ok를 검사하고 JSON을 읽습니다. 결과가 준비되면 ignore가 false인 현재의 Effect만 정상 데이터를 저장하거나, AbortError가 아닌 HTTP 오류와 네트워크 오류, JSON 파싱 실패를 error에 저장하고 loading을 false로 바꿉니다.
URL이 바뀌거나 컴포넌트가 언마운트되면 정리 함수가 ignore = true로 바꾸고 abort()로 요청을 중단하므로, 늦게 끝난 이전 요청은 state를 바꾸지 않습니다. 그림 아래의 예처럼 URL A에서 B로 바뀌어 두 요청이 끝나는 순서가 뒤집혀도, 각 URL의 Effect가 자기만의 ignore와 AbortController를 가지므로 현재 URL의 state만 반영됩니다.
정리
cartReducer는 같은 상품의ADD_ITEM이면 수량을 더하고,UPDATE_QUANTITY는 수량을 교체하며 0 이하가 되면 항목을 지웁니다.- 리듀서는 정수인지, 복원한 배열 안의 필드가 올바른지는 검사하지 않으므로, 화면의 입력 제한과 리듀서의 규칙을 구별합니다.
CartProvider는 복원 Effect가 끝날 때까지storageReady로 저장을 막아, 초기의 빈 배열이 저장값을 덮어쓰지 않게 합니다.- Context의 인라인
value는 렌더마다 새 객체가 되므로, 측정해서 문제가 되면 메모이제이션하거나 Context를 나눕니다. useFetch는response.ok를 확인하고,AbortController로 이전 요청을 중단하며,ignore로 늦은 결과가 현재 state를 덮지 못하게 합니다.
연습 문제
1. 더하기와 교체
빈 배열에서 id 9인 상품을 수량 4로 추가하고, 같은 id를 수량 2로 다시 추가한 뒤, UPDATE_QUANTITY로 수량을 3으로 바꿉니다. 마지막 수량과 첫 호출에 넘긴 입력 배열의 내용은 무엇일까요? 없는 id를 REMOVE_ITEM으로 지우면 무엇이 달라질까요?
해설 보기
수량은 4에서 6, 그다음 3이 됩니다. 각 호출은 다음 상태를 새로 돌려줄 뿐이므로, 첫 호출에 넘긴 입력 배열은 여전히 빈 배열입니다.
없는 id를 지우면 filter가 새 배열을 만들지만, 남아 있는 항목의 내용은 이전과 같습니다. 이 문제는 올바른 페이로드를 가정하며, ADD_ITEM의 수량이나 상품 모양, RESTORE_CART의 항목을 검증하는 기능이 구현되어 있다는 뜻은 아닙니다.
2. 늦게 실패한 이전 요청
URL A의 Effect가 요청한 뒤 URL이 B로 바뀌어, A의 정리 함수와 B의 setup이 모두 실행되었습니다. B의 정상 JSON 결과가 먼저 도착하고, 그 뒤에 A의 오래된 작업이 실패해 catch와 finally로 들어갑니다. 두 Effect의 ignore는 서로 다른 지역 변수입니다. B의 data, error, loading은 어떤 값으로 남을까요?
해설 보기
B는 자기 결과를 data에 넣고, error는 null, loading은 false로 끝납니다. A는 정리 함수에서 자기 ignore가 true가 되었으므로 catch에서 오류를 쓰지 않고 finally에서도 loading을 바꾸지 않습니다. A가 늦게 성공했더라도 data를 쓰지 않습니다.
요청을 중단하는 abort()와 늦은 결과의 반영을 막는 ignore는 서로 다른 역할을 합니다. 또 중단은 서버에서 이미 처리한 작업을 되돌리는 기능이 아닙니다.
3. storageReady 검사를 빼면
저장 Effect에서 if (!storageReady) return; 줄을 지우면 첫 렌더 직후 저장 Effect는 로컬 스토리지에 무엇을 쓸까요? 개발 모드의 Strict Mode처럼 Effect가 한 번 더 실행되는 환경에서는 어떤 일이 생길 수 있을까요?
해설 보기
첫 렌더의 cartState는 초기값 []이므로, 저장 Effect가 저장되어 있던 장바구니를 "[]"로 덮어씁니다. 복원 Effect가 그보다 먼저 저장값을 읽어 두었다면 다음 렌더에서 복원된 장바구니가 다시 저장되지만, 그 사이에는 저장값이 비어 있습니다.
Strict Mode는 처음 마운트할 때 Effect의 setup을 한 번 더 실행하므로, 두 번째 복원 Effect가 이미 "[]"로 덮인 값을 읽어 RESTORE_CART로 빈 배열을 보낼 수 있습니다. 그러면 장바구니가 비워집니다. storageReady는 복원이 끝나기 전에는 쓰지 않게 해서 이런 덮어쓰기를 막습니다.
다음 글 장바구니 컴포넌트 통합에서는 상품과 장바구니의 컴포넌트를 만들어 이 상태 계층과 연결하고, 완성된 화면의 상태 변화를 확인합니다.