📘 2025-06-27 TIL

📌 오늘 배운 핵심 요약

  • useParams로 URL 파라미터를 활용한 동적 라우팅 처리
  • useEffect로 비동기 API 요청 후 상태 관리
  • axios 기반 API 요청 구조 및 에러 핸들링
  • 도서 상세 페이지(BookDetail) 구성 및 렌더링
  • Zustand 전역 상태 저장소와 함께 로컬 상태(useState) 병행 사용

🧠 상세 학습 내용

📍 주제 1: 동적 라우팅 기반 도서 상세 페이지 구현

도서 목록에서 각 도서 클릭 시 /book/:bookId로 이동하며, 해당 ID를 기반으로 상세 정보를 조회하는 구조를 구현했다.

1. 라우트 설정 (App.tsx)

{
  path: 'book/:bookId',
  element: <BookDetail />,
}

2. URL 파라미터 추출: useParams()

const { bookId } = useParams();

3. 커스텀 훅 useBook으로 API 요청

export const useBook = (bookId: string | undefined) => {
  const [book, setBook] = useState<BookDetail | null>(null);

  useEffect(() => {
    if (!bookId) return;
    fetchBook(bookId).then(setBook);
  }, [bookId]);

  return { book };
};

4. API 요청 함수 정의 (fetchBook)

export const fetchBook = async (bookId: string) => {
  const response = await httpClient.get<BookDetail>(`/books/${bookId}`);
  return response.data;
};

5. 화면 렌더링 (BookDetail.tsx)

function BookDetail() {
  const { bookId } = useParams();
  const { book } = useBook(bookId);

  if (!book) return null;

  return (
    <section className={mainContainer}>
      <div className="flex justify-between items-center w-full">
        <Title size="lg" color="primary">{book.title}</Title>
      </div>
    </section>
  );
}

💡 보충 설명: if (!book) return null; = Early Return(얼리 리턴) 패턴

Early return은 조건이 맞지 않을 경우 최대한 빨리 함수 실행을 종료(return) 하여 불필요한 연산을 방지하는 패턴

function BookItem({ book }: { book?: Book }) {
  if (!book) return null; // 📌 book이 없으면 아무것도 렌더링하지 않고 종료

  return (
    <div>{book.title}</div>
  );
}

🧠 장점

  • 코드 깊이를 줄일 수 있어 가독성이 좋아짐
  • 예외 케이스를 먼저 처리하고, 정상 흐름은 아래에 배치함
  • 예상치 못한 오류를 미연에 방지함 (undefined 접근 방지)

📍 주제 2: 상태 관리 - useState + Zustand 병행 사용

이번 프로젝트에서는 로컬 상태 관리(useState)와 글로벌 상태 관리(Zustand)를 역할에 따라 분리해서 병행 사용했습니다. 이 조합은 UI 반응성과 전역 데이터 공유를 동시에 달성하는 데 큰 도움이 되었습니다.


✅ 왜 두 가지 상태 관리 방법을 함께 썼을까?

구분 역할 사용한 곳 이유
useState 로컬 UI 상태 관리 수량 조절(quantity) / 로딩 처리(loading) 즉각적인 UI 반응이 필요하고 전역 공유가 필요하지 않음
Zustand 전역 상태 저장 및 공유 장바구니(cart) 데이터 여러 페이지 간 공통된 상태 공유 필요 (ex. 장바구니 페이지, 헤더 아이콘 등)

✅ useState의 사용 예

const [quantity, setQuantity] = useState(1);
const [loading, setLoading] = useState(false);
  • quantity: 사용자가 장바구니에 담을 수량을 입력
  • loading: API 요청 중 버튼 비활성화를 위한 플래그

이들은 현재 페이지에서만 사용되는 상태이므로 전역으로 관리할 필요가 없었습니다. useState로 관리하면 컴포넌트가 독립적이고 예측 가능해집니다.


  Zustand 의 사용 예

const { addToCart, removeFromCart } = useCartStore();
  • 도서 정보를 cart라는 전역 배열에 추가/삭제
  • 헤더의 장바구니 아이콘이나 /cart 페이지에서 동일한 데이터를 사용함

Zustand는 Redux보다 설정이 훨씬 간단하고, React Context보다 가볍습니다. 복잡한 보일러플레이트 없이 상태를 공유할 수 있어, 중소규모 프로젝트에 특히 유리합니다.


✅ 병행 사용의 장점

  1. 단순한 로컬 상태는 useState로 간결하게 관리

    • 페이지 전환 시 초기화되어도 무방한 값은 useState로 관리
  2. 여러 컴포넌트에서 참조하는 전역 상태는 Zustand로 공유

    • 예: 장바구니 수량, 로그인 유저 정보 등
  3. 불필요한 리렌더링 최소화

    • useState로 세분화된 로컬 상태는 해당 컴포넌트만 리렌더링
    • Zustand는 shallow 비교로 selector 기반 최적화 가능

🔄 예시 흐름: 장바구니에 도서 추가

  1. 사용자가 + 버튼 클릭 → useState(quantity) 증가
  2. 장바구니 담기 버튼 클릭
    • 로컬 상태로 수량 보여주고 버튼 로딩
    • Zustand의 addToCart() 호출로 전역 상태 변경
    • API 요청 성공 시 toast 메시지
const handleAdd = async () => {
  addToCart(book, quantity); // Zustand
  setLoading(true);          // useState

  try {
    await addCart({ book_id: book.id, quantity }); // API
    toast.success('장바구니에 추가되었습니다!');
  } catch (err) {
    removeFromCart(book.id); // Zustand rollback
    toast.error('실패!');
  } finally {
    setLoading(false); // useState
  }
};

📄 실제 코드 예시: Zustand 장바구니 스토어 구현

import { create } from 'zustand';
import type { BookDetail } from '@/models/book.model';

interface CartItem {
  book: BookDetail;
  quantity: number;
}

interface CartState {
  items: CartItem[];
  addToCart: (book: BookDetail, quantity: number) => void;
  removeFromCart: (bookId: number) => void;
  clearCart: () => void;
}

export const useCartStore = create<CartState>(set => ({
  items: [],
  addToCart: (book, quantity) =>
    set(state => {
      const exists = state.items.find(item => item.book.id === book.id);
      if (exists) {
        return {
          items: state.items.map(item =>
            item.book.id === book.id
              ? { ...item, quantity: item.quantity + quantity }
              : item
          ),
        };
      } else {
        return {
          items: [...state.items, { book, quantity }],
        };
      }
    }),

  removeFromCart: bookId =>
    set(state => ({
      items: state.items.filter(item => item.book.id !== bookId),
    })),

  clearCart: () => set({ items: [] }),
}));

📌 이 상태 구조의 장점:

  • items 배열을 통해 도서와 수량을 객체 형태로 함께 저장
  • 같은 도서를 중복 추가 시 수량만 증가하도록 처리
  • removeFromCart, clearCart로 유연한 제어 가능

🧩 결론

  • 상태는 전역에서 공유할 필요가 있는가?, 페이지 전환 시 유지되어야 하는가?를 기준으로 나누어 관리하는 것이 가장 깔끔했습니다.
  • 이렇게 병행 사용하면 코드는 가볍고 예측 가능하며, 유지보수도 쉬워집니다.

AddCart


💭 회고

새롭게 알게 된 점

  • React에서 URL 파라미터를 통해 동적으로 페이지를 구성할 수 있다는 점이 인상 깊었다.
  • useEffect 안에서 비동기 요청을 처리하고, 상태를 분리해서 관리하는 패턴이 안정적이라는 것을 체감했다.
  • Zustand는 Redux보다 훨씬 간단한 API를 제공하면서도 전역 상태 관리에 유용했다.
  • useState와 Zustand를 함께 사용하면 서로의 단점을 보완하며 실무에서도 적용 가능한 구조가 만들어진다.

어렵게 느껴졌던 부분

  • 처음에는 상태를 어떤 기준으로 useState로 할지, Zustand로 할지 결정하는 것이 헷갈렸다.
  • 특히 장바구니 로컬 수량 상태와 전역 cart 상태를 동기화하는 흐름에서 혼란이 있었다.
  • 전역 상태 업데이트 후 API 요청이 실패했을 때 롤백 처리까지 고려하는 구조 설계가 어려웠다.

다음에 학습할 주제

  • 장바구니 목록
  • 주문서 작성
  • 주문 내역