본문 바로가기

reducer·Context·데이터 요청

장바구니 reducer와 Context 및 데이터 요청 커스텀 훅으로 상태 계층을 구성합니다.

cartReducer.js (useReducer)

장바구니 상태를 관리하는 리듀서 함수를 정의합니다.

src/reducers/cartReducer.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가 항목을 제거합니다.

별도로 빈 배열에서 id 9를 수량 4로 추가하고 같은 id를 수량 2로 추가한 뒤, 수량을 3으로 변경해 보세요. 마지막 수량과 첫 입력 배열의 내용을 적습니다. 존재하지 않는 id를 REMOVE_ITEM으로 지우면 무엇이 달라지는지도 설명합니다.

더하기와 교체 대조

수량은 4→6→3입니다. 첫 입력 배열은 여전히 빈 배열이며, 각 호출은 다음 상태를 반환합니다. 없는 id의 삭제는 새 배열을 만들지만 남아 있는 항목의 내용은 같을 수 있습니다. 이 문제는 올바른 내부 payload를 가정합니다. ADD_ITEM의 수량·상품 모양 검증이나 RESTORE_CART의 항목 검증까지 구현되어 있다는 뜻은 아닙니다.

장바구니 reducer 액션별 다음 상태 — 상태 추적

실제 action 시퀀스 수량 4→6→3과 UPDATE 0.5 유지/0 제거, RESTORE 배열 참조/비배열 기존 참조가 달라진다. UI 정수 제한과 reducer 규칙을 분리한다.

장바구니 reducer는 액션과 입력 조건으로 다음 상태를 정합니다

CART REDUCER · ACTION CONTRACT

장바구니 reducer는 액션과 입력 조건으로 다음 상태를 정합니다

같은 상품 추가는 수량을 합치고, 수량 변경은 map 후 quantity > 0으로 걸러냅니다. UI의 정수 입력 제한과 reducer의 실제 수량 규칙을 구분합니다.

ADD와 REMOVE는 현재 배열에서 고른다

ADD와 REMOVE는 현재 배열에서 고른다ADD_ITEM과 REMOVE_ITEM은 서로 다른 action입니다. REMOVE가 ADD 다음에 필수로 실행되는 흐름이 아닙니다.일치없음선택현재 cartid가 유일한 입력ADD기존 idADD새 id수량합산항목추가REMOVE대상 id 제외새 cart 배열다른 항목 보존
ADD와 REMOVE는 현재 배열에서 고른다ADD_ITEM과 REMOVE_ITEM은 서로 다른 action입니다. REMOVE가 ADD 다음에 필수로 실행되는 흐름이 아닙니다.일치없음선택현재 cartid가 유일한입력ADD기존 idADD새 id수량합산항목추가REMOVE대상 id 제외새 cart 배열다른 항목 보존

ADD_ITEM과 REMOVE_ITEM은 서로 다른 action입니다. REMOVE가 ADD 다음에 필수로 실행되는 흐름이 아닙니다.

UPDATE와 RESTORE의 조건 경계

UPDATE와 RESTORE의 조건 경계UPDATE는 정수 여부를 검사하지 않습니다. RESTORE는 배열 내부 필드를 검증하지 않고, 알 수 없는 action은 오류를 던집니다.> 0≤ 0예아니오UPDATEmap으로 수량 교체> 0소수도유지≤ 0filter로제외RESTORE배열?배열payload그대로비배열현재참조 유지
UPDATE와 RESTORE의 조건 경계UPDATE는 정수 여부를 검사하지 않습니다. RESTORE는 배열 내부 필드를 검증하지 않고, 알 수 없는 action은 오류를 던집니다.> 0≤ 0예아니오UPDATEmap으로 수량교체> 0소수도유지≤ 0filter로제외RESTORE배열?배열payload그대로비배열현재참조유지

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가 이미 있음

해당 항목의 수량에 요청 수량을 더한 새 배열 이미 id가 유일한 입력에서는 중복 행을 만들지 않음
ADD_ITEM

같은 id가 없음

새 항목을 끝에 추가한 새 배열 payload의 초기 수량을 사용
REMOVE_ITEM

제거할 id를 받음

그 항목을 제외한 새 배열 다른 항목의 참조와 값은 보존
UPDATE_QUANTITY

새 수량이 0보다 큼

대상 항목의 수량만 바꾼 새 배열 양의 소수도 유지 · 직접 mutation 없음
UPDATE_QUANTITY

새 수량이 0 이하

map으로 수량 갱신 뒤 quantity > 0인 모든 항목만 유지 0 이하 수량 항목은 남지 않음
RESTORE_CART

Array.isArray()로 배열 여부를 확인함

배열 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과 restore0 요청: 항목 제거 / 비배열 restore: 기존 참조 유지0 이하를 제거하며 RESTORE 배열은 그대로, 비배열은 기존 state다내부 필드 검증과 모든 payload 정규화는 별도 보강이다

장바구니 reducer 액션별 다음 상태 — 상태 추적

실제 action 시퀀스 수량 4→6→3과 UPDATE 0.5 유지/0 제거, RESTORE 배열 참조/비배열 기존 참조가 달라진다. UI 정수 제한과 reducer 규칙을 분리한다.

장바구니 reducer는 액션과 입력 조건으로 다음 상태를 정합니다

CART REDUCER · ACTION CONTRACT

장바구니 reducer는 액션과 입력 조건으로 다음 상태를 정합니다

같은 상품 추가는 수량을 합치고, 수량 변경은 map 후 quantity > 0으로 걸러냅니다. UI의 정수 입력 제한과 reducer의 실제 수량 규칙을 구분합니다.

ADD와 REMOVE는 현재 배열에서 고른다

ADD와 REMOVE는 현재 배열에서 고른다ADD_ITEM과 REMOVE_ITEM은 서로 다른 action입니다. REMOVE가 ADD 다음에 필수로 실행되는 흐름이 아닙니다.일치없음선택현재 cartid가 유일한 입력ADD기존 idADD새 id수량합산항목추가REMOVE대상 id 제외새 cart 배열다른 항목 보존
ADD와 REMOVE는 현재 배열에서 고른다ADD_ITEM과 REMOVE_ITEM은 서로 다른 action입니다. REMOVE가 ADD 다음에 필수로 실행되는 흐름이 아닙니다.일치없음선택현재 cartid가 유일한입력ADD기존 idADD새 id수량합산항목추가REMOVE대상 id 제외새 cart 배열다른 항목 보존

ADD_ITEM과 REMOVE_ITEM은 서로 다른 action입니다. REMOVE가 ADD 다음에 필수로 실행되는 흐름이 아닙니다.

UPDATE와 RESTORE의 조건 경계

UPDATE와 RESTORE의 조건 경계UPDATE는 정수 여부를 검사하지 않습니다. RESTORE는 배열 내부 필드를 검증하지 않고, 알 수 없는 action은 오류를 던집니다.> 0≤ 0예아니오UPDATEmap으로 수량 교체> 0소수도유지≤ 0filter로제외RESTORE배열?배열payload그대로비배열현재참조 유지
UPDATE와 RESTORE의 조건 경계UPDATE는 정수 여부를 검사하지 않습니다. RESTORE는 배열 내부 필드를 검증하지 않고, 알 수 없는 action은 오류를 던집니다.> 0≤ 0예아니오UPDATEmap으로 수량교체> 0소수도유지≤ 0filter로제외RESTORE배열?배열payload그대로비배열현재참조유지

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가 이미 있음

해당 항목의 수량에 요청 수량을 더한 새 배열 이미 id가 유일한 입력에서는 중복 행을 만들지 않음
ADD_ITEM

같은 id가 없음

새 항목을 끝에 추가한 새 배열 payload의 초기 수량을 사용
REMOVE_ITEM

제거할 id를 받음

그 항목을 제외한 새 배열 다른 항목의 참조와 값은 보존
UPDATE_QUANTITY

새 수량이 0보다 큼

대상 항목의 수량만 바꾼 새 배열 양의 소수도 유지 · 직접 mutation 없음
UPDATE_QUANTITY

새 수량이 0 이하

map으로 수량 갱신 뒤 quantity > 0인 모든 항목만 유지 0 이하 수량 항목은 남지 않음
RESTORE_CART

Array.isArray()로 배열 여부를 확인함

배열 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과 restore0 요청: 항목 제거 / 비배열 restore: 기존 참조 유지0 이하를 제거하며 RESTORE 배열은 그대로, 비배열은 기존 state다내부 필드 검증과 모든 payload 정규화는 별도 보강이다

CartContext.js (useContext)

장바구니 상태와 디스패치 함수를 전역적으로 제공하는 Context를 생성합니다.

src/contexts/CartContext.js
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를 나누는 방식을 고려합니다.

CartProvider의 복원·전이·공유·저장 경계 — 상태 추적

storageReady=false 동안 초기 빈 목록 쓰기를 막고 복원 뒤 저장이 허용된다. localStorage/현재 state/ready를 추적해 복원 자료 소실을 이해할 가치가 있다.

storageReady가 초기 빈 배열의 덮어쓰기를 막습니다

CART PROVIDER · RESTORE BEFORE WRITE

storageReady가 초기 빈 배열의 덮어쓰기를 막습니다

mount Effect가 저장 배열을 RESTORE_CART로 복원합니다. 복원 완료 전 저장을 건너뛰고, 이후의 cartState는 Context로 공유하며 Effect에서 저장합니다.

CartProvider의 상태 흐름과 브라우저 저장소 경계Provider 바깥의 localStorage 값을 복원 Effect가 읽고 RESTORE_CART 액션을 useReducer에 보낸다. useReducer의 cartState와 안정된 dispatch는 Context를 통해 ProductItem와 CartSummary에 전달된다. 소비자의 액션은 dispatch를 거쳐 reducer로 돌아오고, storageReady가 된 복원 완료 상태부터 저장 Effect가 cartState를 localStorage에 기록한다. CartProviderlocalStorage브라우저 저장소복원 Effect배열 확인RESTORE_CARTcartReducer다음 상태 계산저장 Effect복원 완료 뒤 쓰기Context valuecartState · dispatchProductItem같은 값 구독CartSummary같은 값 구독사용자 액션dispatch로 전달
CartProvider의 상태 흐름과 브라우저 저장소 경계Provider 바깥의 localStorage 값을 복원 Effect가 읽고 RESTORE_CART 액션을 useReducer에 보낸다. useReducer의 cartState와 안정된 dispatch는 Context를 통해 ProductList와 CartSummary에 전달된다. 소비자의 액션은 dispatch를 거쳐 reducer로 돌아오고, storageReady가 된 복원 완료 상태부터 저장 Effect가 cartState를 localStorage에 기록한다. ProviderlocalStorage브라우저 저장소복원 Effect배열 확인RESTORE_CARTcartReducer다음 상태 계산저장 Effect복원 완료 뒤 쓰기Context valuecartState · dispatchProductItem같은 값 구독CartSummary같은 값 구독사용자 액션dispatch로 전달

초기 저장 차단 · 본문의 두 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에 기록한다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
초기 빈 statestate: [] / 쓰기: 차단cartState는 빈 배열이고 storageReady는 false다기존 저장값을 초기의 빈 값으로 덮지 않는다
복원state: 복원 배열 / ready: trueEffect가 저장 배열을 읽어 RESTORE하고 ready를 연다복원 완료와 저장 허용의 조건을 연결한다
후속 저장state: 현재 배열 / storage: 현재 JSON현재 cartState의 변경을 localStorage에 쓴다Context는 전달 통로이며 localStorage는 origin별 브라우저 자료다

CartProvider의 복원·전이·공유·저장 경계 — 상태 추적

storageReady=false 동안 초기 빈 목록 쓰기를 막고 복원 뒤 저장이 허용된다. localStorage/현재 state/ready를 추적해 복원 자료 소실을 이해할 가치가 있다.

storageReady가 초기 빈 배열의 덮어쓰기를 막습니다

CART PROVIDER · RESTORE BEFORE WRITE

storageReady가 초기 빈 배열의 덮어쓰기를 막습니다

mount Effect가 저장 배열을 RESTORE_CART로 복원합니다. 복원 완료 전 저장을 건너뛰고, 이후의 cartState는 Context로 공유하며 Effect에서 저장합니다.

CartProvider의 상태 흐름과 브라우저 저장소 경계Provider 바깥의 localStorage 값을 복원 Effect가 읽고 RESTORE_CART 액션을 useReducer에 보낸다. useReducer의 cartState와 안정된 dispatch는 Context를 통해 ProductItem와 CartSummary에 전달된다. 소비자의 액션은 dispatch를 거쳐 reducer로 돌아오고, storageReady가 된 복원 완료 상태부터 저장 Effect가 cartState를 localStorage에 기록한다. CartProviderlocalStorage브라우저 저장소복원 Effect배열 확인RESTORE_CARTcartReducer다음 상태 계산저장 Effect복원 완료 뒤 쓰기Context valuecartState · dispatchProductItem같은 값 구독CartSummary같은 값 구독사용자 액션dispatch로 전달
CartProvider의 상태 흐름과 브라우저 저장소 경계Provider 바깥의 localStorage 값을 복원 Effect가 읽고 RESTORE_CART 액션을 useReducer에 보낸다. useReducer의 cartState와 안정된 dispatch는 Context를 통해 ProductList와 CartSummary에 전달된다. 소비자의 액션은 dispatch를 거쳐 reducer로 돌아오고, storageReady가 된 복원 완료 상태부터 저장 Effect가 cartState를 localStorage에 기록한다. ProviderlocalStorage브라우저 저장소복원 Effect배열 확인RESTORE_CARTcartReducer다음 상태 계산저장 Effect복원 완료 뒤 쓰기Context valuecartState · dispatchProductItem같은 값 구독CartSummary같은 값 구독사용자 액션dispatch로 전달

초기 저장 차단 · 본문의 두 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에 기록한다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
초기 빈 statestate: [] / 쓰기: 차단cartState는 빈 배열이고 storageReady는 false다기존 저장값을 초기의 빈 값으로 덮지 않는다
복원state: 복원 배열 / ready: trueEffect가 저장 배열을 읽어 RESTORE하고 ready를 연다복원 완료와 저장 허용의 조건을 연결한다
후속 저장state: 현재 배열 / storage: 현재 JSON현재 cartState의 변경을 localStorage에 쓴다Context는 전달 통로이며 localStorage는 origin별 브라우저 자료다

useFetch.js (Custom Hook)

API 호출 로직을 재사용 가능한 커스텀 훅으로 만듭니다.

src/hooks/useFetch.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 경로로 들어가며, cleanup이 만든 AbortError는 사용자에게 보여 줄 오류에서 제외합니다. AbortController는 필요 없어진 네트워크 작업을 중단하고, ignore 플래그는 이전 Effect의 catch나 finally가 새 요청의 상태를 덮는 일을 막습니다.

이 훅은 Effect의 생명주기를 익히는 작은 예제입니다. 실제 제품에서는 캐시, 중복 요청 제거, 서버 데이터 로딩이 필요할 수 있으므로 사용 중인 프레임워크의 데이터 로더나 검증된 클라이언트 캐시를 우선 검토합니다.

이전 요청의 늦은 결과를 구별하기

URL A의 Effect가 요청한 뒤 URL이 B로 바뀌어 A의 cleanup과 B의 setup이 끝났다고 가정합니다. B의 정상 JSON 결과가 먼저 도착하고, 이후 A의 오래된 작업이 실패하여 catch와 finally로 들어갑니다. 각 Effect가 가진 ignore는 서로 다른 지역 변수입니다. B의 data·error·loading이 어떤 값을 유지하는지 먼저 예측합니다.

요청별 guard의 역할

B는 자기 결과를 data에 넣고 error는 null, loading은 false로 끝납니다. A는 cleanup에서 자기 ignore가 true가 되었으므로 catch에서 오류를 쓰지 않고 finally에서 loading도 바꾸지 않습니다. A가 늦게 성공해도 data를 쓰지 않습니다. 요청을 중단하는 abort와 늦은 상태 반영을 막는 guard는 서로 다른 역할입니다. 중단은 이미 서버에서 수행한 업무를 되돌리는 기능이 아닙니다.

useFetch Effect의 요청·정리·결과 반영 상태 — 상태 추적

A cleanup ignore=true/abort 뒤 B만 data/error/loading을 쓴다. 늦은 A 결과와 finally가 현재 UI를 덮지 않는 실제 guard 상태를 추적할 가치가 크다.

늦게 끝난 이전 요청은 현재 URL의 state를 쓰지 않습니다

USEFETCH · REQUEST LIFETIME

늦게 끝난 이전 요청은 현재 URL의 state를 쓰지 않습니다

URL마다 Effect의 ignore와 AbortController를 만듭니다. cleanup은 ignore=true와 abort()를 실행하고, 현재 Effect만 정상 데이터·오류·loading을 반영합니다.

useFetch Effect의 경쟁 조건을 막는 상태 흐름URL이 바뀌면 Effect가 data, error, loading을 준비하고 signal을 연결해 fetch한 뒤 response.ok를 검사하고 JSON을 파싱한다. cleanup은 ignore를 true로 바꾸고 요청을 abort한다. 요청 결과가 마련된 뒤 ignore가 false인 현재 Effect만 정상 데이터를 저장하거나 AbortError가 아닌 HTTP, 네트워크, JSON 파싱 실패를 error로 저장하고 loading을 false로 바꾼다. 아니오예DATA실패Effect setupdata · error 초기화요청하기fetch · JSON 읽기현재 작업?ignore=false정상 결과?DATA / 실패성공 반영data · loading=false실패 반영AbortError 제외cleanupURL 변경 · unmountignore=true · abort늦은 결과 무시state 변경 안 함
useFetch Effect의 경쟁 조건을 막는 상태 흐름URL이 바뀌면 Effect가 data, error, loading을 준비하고 signal을 연결해 fetch한 뒤 response.ok를 검사하고 JSON을 파싱한다. cleanup은 ignore를 true로 바꾸고 요청을 abort한다. 요청 결과가 마련된 뒤 ignore가 false인 현재 Effect만 정상 데이터를 저장하거나 AbortError가 아닌 HTTP, 네트워크, JSON 파싱 실패를 error로 저장하고 loading을 false로 바꾼다. 아니오예DATA실패Effect setupdata · error 초기화요청하기fetch · JSON 읽기현재 작업?ignore=false정상 결과?DATA / 실패성공 반영data · loading=false실패 반영AbortError 제외cleanupURL 변경 · unmountignore=true · abort늦은 결과 무시state 변경 안 함

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=false

fetch는 response.ok를 검사한 뒤 JSON을 파싱합니다. 현재 Effect의 AbortError는 error로 쓰지 않고, cleanup된 이전 Effect는 finally에서도 loading을 바꾸지 않습니다.

점선은 URL 변경·언마운트에 따른 정리 경로이고, 실선의 결과 분기와 별개입니다. 이전 Effect의 결과는 현재 화면 상태를 쓰지 않습니다.

ProductList는 loading이면 spinner를 보여줍니다. 응답이 배열이면 그 상품 목록을 쓰고, 실패·비배열이면 mock 상품과 안내를 씁니다. 정상 빈 배열은 mock으로 바꾸지 않습니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
원도표의 전체 관계·조건·설명
구분내용
원도표 노드·간선·영역 라벨아니요
원도표 설명ProductList는 loading이면 spinner를 보여 줍니다. 응답이 배열이면 그 상품 목록을 쓰고, 실패·비배열이면 mock 상품과 안내를 씁니다. 정상 빈 배열은 mock으로 바꾸지 않습니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
요청 A setupcurrent: A / loading: trueA가 ignore=false와 controller로 실행된다Effect별로 쓰기 권한을 캡처한다
URL B 교체A: 권한 종료 / B: 현재 요청A cleanup은 ignore=true와 abort, B setup은 새 상태를 준비한다abort와 늦은 반영의 차단은 서로 다른 장치다
B와 늦은 AB: 결과+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 상태를 추적할 가치가 크다.

늦게 끝난 이전 요청은 현재 URL의 state를 쓰지 않습니다

USEFETCH · REQUEST LIFETIME

늦게 끝난 이전 요청은 현재 URL의 state를 쓰지 않습니다

URL마다 Effect의 ignore와 AbortController를 만듭니다. cleanup은 ignore=true와 abort()를 실행하고, 현재 Effect만 정상 데이터·오류·loading을 반영합니다.

useFetch Effect의 경쟁 조건을 막는 상태 흐름URL이 바뀌면 Effect가 data, error, loading을 준비하고 signal을 연결해 fetch한 뒤 response.ok를 검사하고 JSON을 파싱한다. cleanup은 ignore를 true로 바꾸고 요청을 abort한다. 요청 결과가 마련된 뒤 ignore가 false인 현재 Effect만 정상 데이터를 저장하거나 AbortError가 아닌 HTTP, 네트워크, JSON 파싱 실패를 error로 저장하고 loading을 false로 바꾼다. 아니오예DATA실패Effect setupdata · error 초기화요청하기fetch · JSON 읽기현재 작업?ignore=false정상 결과?DATA / 실패성공 반영data · loading=false실패 반영AbortError 제외cleanupURL 변경 · unmountignore=true · abort늦은 결과 무시state 변경 안 함
useFetch Effect의 경쟁 조건을 막는 상태 흐름URL이 바뀌면 Effect가 data, error, loading을 준비하고 signal을 연결해 fetch한 뒤 response.ok를 검사하고 JSON을 파싱한다. cleanup은 ignore를 true로 바꾸고 요청을 abort한다. 요청 결과가 마련된 뒤 ignore가 false인 현재 Effect만 정상 데이터를 저장하거나 AbortError가 아닌 HTTP, 네트워크, JSON 파싱 실패를 error로 저장하고 loading을 false로 바꾼다. 아니오예DATA실패Effect setupdata · error 초기화요청하기fetch · JSON 읽기현재 작업?ignore=false정상 결과?DATA / 실패성공 반영data · loading=false실패 반영AbortError 제외cleanupURL 변경 · unmountignore=true · abort늦은 결과 무시state 변경 안 함

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=false

fetch는 response.ok를 검사한 뒤 JSON을 파싱합니다. 현재 Effect의 AbortError는 error로 쓰지 않고, cleanup된 이전 Effect는 finally에서도 loading을 바꾸지 않습니다.

점선은 URL 변경·언마운트에 따른 정리 경로이고, 실선의 결과 분기와 별개입니다. 이전 Effect의 결과는 현재 화면 상태를 쓰지 않습니다.

ProductList는 loading이면 spinner를 보여줍니다. 응답이 배열이면 그 상품 목록을 쓰고, 실패·비배열이면 mock 상품과 안내를 씁니다. 정상 빈 배열은 mock으로 바꾸지 않습니다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.

관계도의 보충 설명

원도표의 전체 관계·조건·설명
구분내용
원도표 노드·간선·영역 라벨아니요
원도표 설명ProductList는 loading이면 spinner를 보여 줍니다. 응답이 배열이면 그 상품 목록을 쓰고, 실패·비배열이면 mock 상품과 안내를 씁니다. 정상 빈 배열은 mock으로 바꾸지 않습니다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
요청 A setupcurrent: A / loading: trueA가 ignore=false와 controller로 실행된다Effect별로 쓰기 권한을 캡처한다
URL B 교체A: 권한 종료 / B: 현재 요청A cleanup은 ignore=true와 abort, B setup은 새 상태를 준비한다abort와 늦은 반영의 차단은 서로 다른 장치다
B와 늦은 AB: 결과+loading false / A: 현재 UI 쓰기 없음B만 data·error·loading을 쓰고 A의 catch와 finally도 무시한다전송 중단이 이미 서버에서 수행한 업무를 되돌리지는 않는다