본문으로 건너뛰기

안동민 개발노트

본문 시작

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}`);
  }
};

리듀서는 현재 상태와 액션만으로 다음 상태를 계산하는 순수 함수입니다. 같은 상품을 추가할 때와 처음 추가할 때처럼 조건에 따라 달라지는 전이를 표로 추적하면, 컴포넌트에서 빠진 예외를 찾기 쉽습니다.

장바구니 reducer가 ADD_ITEM, REMOVE_ITEM, UPDATE_QUANTITY, RESTORE_CART 액션과 입력 조건에 따라 불변성을 지키며 다음 배열 상태를 계산하는 규칙 표
같은 액션도 현재 항목 존재 여부나 새 수량에 따라 다른 다음 상태를 만든다.
액션 조건 다음 상태 확인할 불변식
ADD_ITEM 같은 id가 이미 있음 해당 항목의 수량에 요청 수량을 더한 새 배열 동일 상품 행은 하나만 유지
ADD_ITEM 같은 id가 없음 새 항목을 끝에 추가한 새 배열 payload의 초기 수량을 사용
REMOVE_ITEM 제거할 id를 받음 그 항목을 제외한 새 배열 다른 항목의 참조와 값은 보존
UPDATE_QUANTITY 새 수량이 1 이상 대상 항목의 수량만 바꾼 새 배열 직접 mutation 없이 교체
UPDATE_QUANTITY 새 수량이 0 이하 수량 갱신 뒤 해당 항목을 제거한 배열 0 이하 수량 항목은 남지 않음
RESTORE_CART Array.isArray()로 배열 여부를 확인함 배열 payload 또는 확인 실패 시 현재 상태 reducer 안에서는 저장소를 읽지 않음
ADD_ITEM
기존 상품 수량만 더하고 행은 하나로 유지한다.
새 상품 payload 수량과 함께 배열 끝에 추가한다.
REMOVE_ITEM
받은 id를 제외한 새 배열을 반환한다.
UPDATE_QUANTITY
1 이상 대상 수량만 교체한다.
0 이하 갱신 뒤 항목을 제거한다.
RESTORE_CART
Array.isArray()가 참인 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를 나누는 방식을 고려합니다.

브라우저 localStorage를 Effect에서 복원한 뒤 RESTORE_CART 액션으로 reducer 상태를 만들고, CartProvider가 상태와 dispatch를 소비자에게 전달하며 복원 완료 상태부터 다시 저장하는 경계
CartProvider의 상태 흐름과 브라우저 저장소 경계 Provider 바깥의 localStorage 값을 복원 Effect가 읽고 RESTORE_CART 액션을 useReducer에 보낸다. useReducer의 cartState와 안정된 dispatch는 Context를 통해 ProductList와 CartSummary에 전달된다. 소비자의 액션은 dispatch를 거쳐 reducer로 돌아오고, storageReady가 된 복원 완료 상태부터 저장 Effect가 cartState를 localStorage에 기록한다. CART PROVIDER · 브라우저 실행 경계 GET RESTORE cartState 변경 SET state state + dispatch action localStorage origin별 문자열 저장소 복원 Effect 읽기 · parse · 배열 검증 useReducer(cartReducer) state + action → cartState 저장 Effect storageReady 뒤 직렬화 CartContext.Provider value = cartState + dispatch ProductList 상품 추가 action 요청 CartSummary 상태 읽기 · 수량 action dispatch(action)
  1. 브라우저 저장소 복원

    Effect가 localStorage 문자열을 읽고 배열인지 검증한다.

  2. reducer에 복원 요청

    RESTORE_CART 액션이 검증된 배열로 다음 상태를 계산한다.

  3. Context로 한 경계에서 공유

    Provider가 cartState와 안정된 dispatch를 소비자에게 전달한다.

  4. 소비자는 읽거나 action을 보냄

    화면은 배열을 직접 고치지 않고 reducer 규칙을 통과시킨다.

  5. 복원 완료 상태부터 저장

    storageReady 전 쓰기를 건너뛰어 초기 빈 배열 덮어쓰기를 막고, 복원 완료 렌더부터 현재 상태를 직렬화한다.

localStorage는 Provider의 상태 원본이 아니라 브라우저 세션 사이의 복원 매체다. reducer는 순수 계산을 맡고, 저장소 읽기·쓰기는 Effect가 맡는다.

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의 catchfinally가 새 요청의 상태를 덮는 일을 막습니다.

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

useFetch Effect가 URL마다 로딩 상태와 AbortController를 준비하고, cleanup에서 이전 Effect를 무효화·취소하며, 현재 Effect의 정상 응답만 data로, HTTP·네트워크·JSON 파싱 실패는 error로 반영하는 상태 흐름
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로 바꾼다. NO YES DATA FAILURE Effect setup ignore=false · loading=true 요청 처리 signal · response.ok 검사 JSON parse 또는 catch cleanup URL 변경 또는 unmount ignore=true · abort() 현재 Effect인가? ignore === false 반영할 결과 종류 data / failure 이전 결과 무시 catch/finally도 state 쓰기 없음 정상 결과 반영 data 설정 · loading=false 요청·파싱 실패 반영 AbortError 제외 · error 설정 loading=false
  1. URL별 Effect setup

    ignore=false로 시작하고 data와 error를 비운 뒤 loading을 연다.

  2. 취소 가능한 요청

    AbortController.signal을 fetch에 연결하고 response.ok 확인 뒤 JSON을 파싱한다.

  3. cleanup 분기

    URL 변경이나 unmount면 먼저 ignore=true로 만들고 abort()한다.

  4. 현재 Effect만 결과 반영

    ignore가 false일 때만 data나 HTTP·네트워크·JSON 파싱 error를 쓰고 loading을 닫는다.

  5. 이전 요청은 state를 쓰지 않음

    늦은 reject나 finally가 새 요청의 loading을 false로 덮지 못한다.

네트워크 취소는 불필요한 작업을 줄이고, 결과 무시 가드는 비동기 완료 순서가 바뀌어도 현재 URL의 화면 상태만 남긴다.