이번 주간은 지난 상품 목록 리팩토링과 장바구니 1단계 반영 및 2단계 기능 구현 시작, 각종 스터디와 일정 등...
맞물린 스케쥴이 너무 많아서 회고는 간단하게 작성해보려 한다. ㅠㅠ
1단계 PR 링크: https://github.com/woowacourse/react-shopping-cart/pull/344
원본 상태 : cartListData와 SelectionMap
cartListData는 서버에서 받아온 전체 장바구니 데이터를 의미하므로, 변경되지 않는 원본 상태로 관리했다.
selectionMap은 각 장바구니 아이템의 체크 여부를 관리하는 상태인데 이 상태는 cartListData에 의존적이긴 하지만, 아래와 같은 이유로 파생 상태가 아닌 독립적인 원본 상태로 관리했다.
cartListData가 갱신되더라도 리렌더링 전의 체크 상태를 유지하기 위해 기존에 존재하던 id의 선택 여부(boolean)는 유지되어야 한다.setState에 의해 직접 관리할 수 있어야 한다고 생각하였다.파생 상태 : 주문 금액, 배송비, 총 결제 금액, 전체 선택 여부(isSelectAll) 등
이 값들은 cartListData와 selectionMap만으로 계산 가능한 값들이므로, 별도로 상태로 저장하지 않고 파생 상태로 관리하였다.
useOrderListContext() 작성
cartListData와 selectionMap 이 페이지에 있는 모든 컴포넌트에서 사용하고 있다.
또, cartListData와 selectionMap은 장바구니 페이지뿐만 아니라 주문 확인 페이지 등 라우터 간에도 공유되어야 하는 상태다.
따라서 Context API를 사용해 cartListData와 selectionMap을 전역 상태로 사용할 수 있게 하는 useOrderListContext를 구현하였다.
selectionMap 상태 구조
우리는 Record<id, boolean> 형태의 selectionMap 구조를 선택하였다.
이 방식은 각 항목의 선택 여부를 개별적으로 명시할 수 있어 체크/해제 상태를 더 명확하게 추적할 수 있고,
배열을 사용해 포함 여부를 일일이 탐색하는 것보다 성능 면에서도 효율적이라고 판단했다.
또한 이후 상태를 부분적으로 병합하면서 유지해야 하는 상황에서 boolean 맵 구조가 더 적합하다고 생각했다.
예를 들어, 서버로부터 cartListData가 새롭게 갱신되었을 때, 기존에 존재하던 항목의 체크 상태는 유지하고,
새로 추가된 항목에 대해서만 기본값(true)으로 초기화해야한다.
이러한 요구를 반영하기 위해 useEffect 내부에서 함수형 업데이트 방식을 사용하였다.
export const useOrderListContext = () => {
const { selectionMap, setSelectionMap } =
useContext(OrderListContext);
if (!selectionMap) {
throw new Error(
"useOrderListContext must be used within an OrderListProvider"
);
}
useEffect(() => {
if (!cartListData) return;
setSelectionMap((prev) => {
const nextMap: Record<string, boolean> = {};
for (const cart of cartListData) {
nextMap[cart.id] = prev[cart.id] ?? true;
}
return nextMap;
});
}, [cartListData, setSelectionMap]);
return {
selectionMap,
setSelectionMap,
};
};
기존의 selectionMap을 기준으로 새로운 cartListData를 순회하면서, 기존 상태가 존재하는 항목은 그대로 유지하고,
존재하지 않는 항목에 대해서만 true를 설정하도록 하는 코드이다.
이렇게하면 useEffect는 렌더링 이후 실행을 보장하므로 초기 데이터 undefined 문제도 피할 수 있으면서,
컴포넌트가 리렌더링되어도 이전 선택값을 유지할 수 있다.
이를 통해 사용자의 선택 상태를 안정적으로 보존하면서도, 불필요한 리렌더링을 최소화할 수 있었다.
| 변하는 UI 요소 | 이 UI가 언제 변하나요? | UI 반영을 위해서 어떤 데이터가 필요한가요? (ex. 장바구니 상품 목록의 개수, 배송비) |
|---|---|---|
| 장바구니 리스트 | 페이지를 처음 열었을 때 | 장바구니 상품 목록(get) |
| 장바구니 상품 개수 | 장바구니에 아이템을 담았을 때 장바구니의 아이템을 삭제할 때 수량이 1개일 때 수량 감소 버튼을 누를 때 | 장바구니 상품 목록(get, post, delete) |
| 현재 장바구니 상품 개수 안내 문구 | 장바구니 상품 개수가 변경될 때 | ‘장바구니 상품 목록’의 파생 상태(length) |
| 장바구니 체크 리스트 | 최초 렌더링 시 전체 선택을 눌렀을 때, 개별 체크를 선택/해제할 때 | 장바구니 목록(상태는 [{cartItem, isChecked}]) (품절 상태의 아이템은 disabled) |
| 주문 금액 | 장바구니 상품의 개수가 변할 때 장바구니 상품의 수량이 변할 때 | 장바구니 목록(cartitem.quantity, cartitem.product.price 데이터) |
| 배송비 | 주문 금액이 100,000원 미만/이상일 때 | 주문 금액 |
| 총 결제 금액 | 주문 금액이 변경될 때 배송비가 변경될 때 | 주문 금액 + 배송비 |
| 주문 확인 버튼 | 장바구니 체크 리스트가 1개 이상일 때 장바구니 체크 리스트가 0개 일 때 | 장바구니 체크 리스트 데이터 |
각 UI가 언제 변하고 어떤 데이터가 필요하며 각 데이터는 상태/파생 상태/의존 상태 중 어떻게 관리되어야할지 생각해보는 활동을 하였다.

setState() 자체는 코드 실행 줄에 push 되고 pop 되지만,
setState() 의 내부 콜백은 리액트 스케쥴링상 이벤트 루프의 다음 사이클(혹은 React의 내부 큐)에 예약한다.
이러한 관점에서 setState() 는 비동기 함수다. 라고 말할 수 있다.
하지만 이는 리액트의 스케쥴링상 비동기적으로 작동하는 것이기 때문에, 자바스크립트에서 비동기를 처리하는 방식인 Promise 와는 다르다.
동기적으로 실행되는 다른 코드들이 모두 끝난 후, 리액트가 예약해둔 state 변경과 리렌더링이 실행되는 것이고,
이 과정은 자바스크립트의 마이크로태스크/매크로태스크 큐와는 별개로, 리액트가 자체적으로 관리하는 업데이트 큐에서 처리된다.
개발자 도구 탭에서 Profiler 탭에서 컴포넌트의 렌더 시간과 순서들을 확인할 수 있다.
debugger를 통해 내 프로그램의 실제 흐름을 정확히 파악하는 것도 좋은 것 같다.
| JS | 실행 컨텍스트, 클로저, call stack, heap |
|---|---|
| 브라우저 | 브라우저 렌더링, 이벤트 루프 |
| React | 렌더링(render/commit), 불변성 |
이번 주차는 페이먼츠 모듈 미션 2단계를 리뷰했다.
리뷰어의 타입스크립트 심화 피드백을 보고 타입스크립트를 공부해볼 수 있었다.
리뷰 과정에서 가장 흥미로웠던 부분은 Object.entries의 타입 추론 한계와, 이 피드백을 받고 개선하기 위해 크루가 만든 objectEntries 유틸 함수였다.
타입스크립트에서 Object.entries(obj)는 항상 [string, any][] 타입을 반환한다.
이 때문에 아래와 같은 코드에서 타입 안정성이 떨어진다.
const obj = { a: 1, b: 2 };
Object.entries(obj).forEach(([key, value]) => {
// key의 타입이 string이므로 obj[key]에 타입 에러가 발생할 수 있다.
});
이런 현상은 타입스크립트가 구조적 타이핑(structural typing)이라는 특징을 갖고 있기 때문이다.
즉, 타입스크립트는 객체 타입이 "열려" 있다고 가정한다.
런타임에는 타입에 명시되지 않은 속성(잉여 속성)이 추가될 수 있기 때문에,
Object.keys나 Object.entries는 항상 string 기반의 넓은 타입으로 반환한다.
예를 들어,
interface AB { a: string; b: string; }
const obj: AB = { a: 'x', b: 'y', c: 'z' }; // c는 타입에는 없지만, 런타임에는 존재 가능
만약 타입스크립트가 Object.keys(obj)의 반환 타입을 'a' | 'b'로 좁혀버리면,
런타임에 존재하는 'c'를 놓치게 되고, 타입과 실제 값이 불일치하는 문제가 생길 수 있다.
하지만, "이 객체는 닫힌 구조(타입에 정의된 키만 존재)"임을 개발자가 확신할 수 있는 경우도 있다.
예를 들어, 카드 번호 입력 필드처럼 구조가 초기화 함수에서 100% 결정되고,
이후에 동적으로 키가 추가/삭제되지 않는 경우다.
이런 상황에서는 타입을 좁혀서 더 안전하고 명확한 코드를 작성할 수 있다.
실제로, 크루는 제네릭을 활용해 객체의 키와 값 타입을 정확하게 추론하는 유틸 함수를 만들어 적용했다.
이 유틸 함수는 객체의 타입 정보를 최대한 보존하면서,
Object.entries와 유사하게 동작하도록 설계된 것이 인상적이었다.
이 함수는 제네릭 T를 받아,
keyof T (즉, 타입에 정의된 키만)T[keyof T] (타입에 정의된 값들의 유니언)으로 반환 타입을 좁혀주는 함수였다.
타입스크립트는 런타임 안전성을 최우선으로 한다.
자바스크립트 객체는 언제든 동적으로 속성이 추가/삭제될 수 있기 때문에,
기본적으로는 넓은 타입(string)을 반환해 잠재적 런타임 오류를 방지한다.
개발자가 "이 객체는 닫힌 구조임을 보증할 수 있다"고 판단하는 경우에만,
이런 유틸 함수를 사용해 타입을 좁히는 것이 올바른 설계다.
타입스크립트의 타입 시스템은 쉽지 않지만,
이런 심화 내용을 이해하고 직접 적용해보는 경험이 정말 큰 도움이 되는 것 같다.
다음 미션에서는 더 깊이 있게 활용해보고 싶다!