[우아한테크코스] 상품 목록 미션 주간 회고 05/19~05/25

Nayoung·2025년 5월 26일
2
post-thumbnail

1. 상품 목록 미션

1단계 PR 링크: https://github.com/woowacourse/react-shopping-products/pull/109
2단계 PR 링크: https://github.com/woowacourse/react-shopping-products/pull/130

1.1. 1단계 리팩토링


에러 핸들링 - ErrorBoundary와 전역 에러 관리의 한계

이번 미션에서는 페어와 ErrorBoundary를 사용하지 않고 에러를 처리해보자는 목표로 시작했다. '리액트에서의 에러 처리는 무조건 ErrorBoundary 를 써야지!' 하는 것보단 왜 에러 바운더리를 많이 쓰는 건지 에러를 직접 핸들링해보고 불편함과 필요성을 몸소 느껴보기 위함이었다.

처음에는 ErrorProvider를 만들어 context로 에러 상태를 전역에서 관리해보았지만, 실제로는 렌더링 에러는 ErrorBoundary로 처리하는 것이 훨씬 선언적이고 관리가 쉽다는 것을 깨달았다.

리렌더링 문제와 여러 컴포넌트에서 던지는 에러를 하나의 Provider에서 관리하는 것에 불편함을 느꼈기 때문이다.

그렇게 에러바운더리를 사용하여 리팩토링을 진행하였는데, 이 과정에서

  • ErrorBoundary는 컴포넌트 렌더링 시 발생하는 에러만 잡을 수 있다는 점
  • try-catch에서 발생하는 실행 중 에러는 잡지 못한다는 점
  • 전역 에러 상태를 context로 관리하면, 에러 상태가 바뀔 때마다 하위 컴포넌트가 모두 리렌더링되는 비효율이 있다는 점

을 직접 경험할 수 있었다.

그래서 렌더링 에러는 ErrorBoundary로,
함수 실행 중 에러(try-catch)는 별도의 함수형 토스트(showToast)로 처리하는 이원화된 구조가 가장 현실적이라는 결론을 내렸다.

(참고로, showToast는 현재 여러 개의 토스트를 동시에 쌓아 띄우는 기능이 없어 개선이 필요하다고 느꼈다. 매우매우 간소화시켜 직접 DOM 조작을 하는 리액트스럽지 않은 단순함수로 구현하였기 때문에...)


Context API - Context 작성의 활용

그동안 Context API를 사용할 때는 Provider만 신경 썼는데,
이번 미션을 통해 Context 객체 자체의 설계와 관리로 고유한 훅을 만들 수 있음을 학습했다.

특히,

  • APIDataProvider로 Cart뿐 아니라 다양한 API 데이터를 키로 관리할 수 있게 리팩토링한 경험
  • Context의 value 구조와 확장성을 고민한 경험

이 가장 좋은 학습 경험이었던 것 같다.

시지프의 API Provider 구조를 참고하며,
Context를 어떻게 설계하면 확장성과 유지보수성이 좋아지는지 더 깊게 고민하였고,

이번 미션의 프로그래밍 요구사항이었던

  • 서버 API 통신 결과를 Single Source of Truth (SSOT) 원칙에 따라 관리할 수 있도록, 커스텀 훅을 직접 개발한다.
  • GET method 를 사용하는 모든 API 에 이 커스텀 훅을 적용한다.
    GET /cart-items , GET /products API 를 통일된 인터페이스로 data fetching 할 수 있어야 한다.
    ex) useData, useResource 등의 이름으로 선언할 수 있다.
  • 반환값에는 데이터, 로딩 여부, 에러 정보 등이 포함되어야 한다.
    Context API 를 활용한다. 단, API 마다 Provider 를 따로 만들지 않고, 하나의 Context 에서 관리할 수 있어야 한다.
  • Context API 사용으로 인한 렌더링 문제는 해결하지 않아도 된다. 문제점은 학습하여 인지하도록 한다.

를 만족시킬 수 있었다.
최종적으로 완성된 API Provider는 이러했다.

...
const APIContext = createContext<{
  state: APIStateMap;
  setState: React.Dispatch<React.SetStateAction<APIStateMap>>;
}>({
  state: {},
  setState: () => {},
});

export function APIDataProvider({ children }: PropsWithChildren) {
  const [state, setState] = useState<APIStateMap>({});

  return (
    <APIContext.Provider value={{ state, setState }}>
      {children}
    </APIContext.Provider>
  );
}
export function useAPIDataContext<T>({
  fetcher,
  name,
}: {
  fetcher: () => Promise<T>;
  name: string;
}) {
  const { state, setState } = useContext(APIContext);

  const request = useCallback(async () => {
    setState((prev) => ({
      ...prev,
      [name]: { data: null, loading: true, error: null },
    }));

    try {
      const result = await fetcher();
      setState((prev) => ({
        ...prev,
        [name]: { data: result, loading: false, error: null },
      }));
    } catch (e) {
      setState((prev) => ({
        ...prev,
        [name]: { data: null, loading: false, error: e },
      }));
      showToast('데이터 요청에 실패하였습니다.', 'error');
    }
  }, [name, setState]);

  useEffect(() => {
    if (!state[name]) request();
  }, [request, state, name]);

  const resource = state[name] || {
    data: null,
    loading: false,
    error: null,
  };

  return {
    data: resource.data as T | undefined,
    loading: resource.loading,
    error: resource.error,
    refetch: request,
  };
}

loading 상태와 error 상태도 반환해야한다는 요구 사항에 맞게 각 state에 error와 loading 값을 갖게되었다.

그리고 data의 name 을 키로 받아 해당하는 키에 data 객체를 저장하고 이를 Provider의 하나의 state에서 관리를 함으로써 여러 데이터를 하나의 Provider에서 각각 관리할 수 있도록 context를 추상화하였다.


비동기 데이터, Suspense, 그리고 wrapPromise

리액트에서의 비동기 데이터 처리를 useEffect에서 하는 이유에 대해 먼저 고민하고 학습해보면서 Promise 에 이미 status 상태가 있는데 굳이 또 상태를 만들어줘야하는가에 대한 관점, 이러한 관점에서 강점을 갖는 suspense, use() 에 대해 학습했다.

또, react 버전이 18이었기 때문에 use()가 없어서 대신 넣어줬던 헬퍼함수 wrapPromise 에 대해서도 다시 공부했다.

useEffect와 비동기 데이터
처음에는 "컴포넌트 함수에 async를 붙일 수 없으니 useEffect에서 비동기 요청을 처리한다" 정도로만 이해하고 있었다.

그런데 실제로 then으로 데이터를 받아 props로 넘기는 식으로 해보면,
Promise가 pending 상태로 한 번 넘겨지고, 값이 resolve된 후에는 setState로 갱신해야 한다는 점, 그렇게 setState가 반복되면 무한 루프에 빠질 수 있다는 것 등의 문제를 직접 겪으며, React 생명주기와 useEffect의 역할을 더 깊이 이해하게 되었다.

그리고 Promise 는 이미 pending, fullfilled, error 등의 status 를 반환하고있는데 굳이 다시한 번 error, loading 상태를 만들어주어야하는가에 대해 의구심이 들었고 그로인해 promise의 status 에 따라 fallback UI 를 보여주는 suspense라는 것이 나왔음을 알게되었다.

Suspense와 wrapPromise
Suspense를 이용하면 로딩 상태를 별도로 관리하지 않아도 선언적으로 비동기 UI 를 처리할 수 있다.

Suspense는 컴포넌트가 Promise를 throw하면 pending 상태를 감지해 fallback UI를 보여주면서 기다렸다가, Promise가 resolve되면 다시 컴포넌트를 렌더링한다.

이때 원래는 promise를 던져주는 역할이 use() 지만, use()는 리액트 19버전부터 나온 훅이기 때문에 use()를 대체할 수 있는 wrapPromise 헬퍼 함수를 훔쳐왔 작성해주었다.

wrapPromise는 Promise의 상태를 추적해서, pending이면 Promise를 throw error면 에러를 throw, success면 데이터를 반환해주는 read() 함수를 반환해준다.

export function wrapPromise<T>(promise: Promise<T>) {
  let status = 'pending';
  let response: T;
  const suspender = promise.then(
    (res) => {
      status = 'success';
      response = res;
    },
    (err) => {
      status = 'error';
      response = err;
    }
  );

  const read = () => {
    switch (status) {
      case 'pending':
        throw suspender;
      case 'error':
        throw response;
      default:
        return response;
    }
  };
  return { read };
}

suspender에 promise를 두고 만약 resolve 되지 않은 상태(then이 실행되기 전)에서 read함수를 실행하면 기본 status인 pending 케이스가 실행되어 suspender(pending 상태인 Promise)를 throw 해주는 것이다.

그리고 resolve 된 시점에 다시 read()가 호출되면 then()에서 status가 success나 error로 바뀐 후이므로 response(promise 가 resolve된 반환값)이 return 된다.


1.2. 2단계 기능 구현

신경 쓴 점

1. RTL 테스트 아이디 삭제 및 Role/aria-label 추가

1단계 피드백에서 리뷰어가 'testid 를 꼭 사용해야할까요?' 라는 질문을 남겨주신만큼 testid 없이 findByRole ...등을 이용해 테스트를 작성해보았다.

처음에는 "테스트를 위해 aria-label이나 role을 이렇게까지 붙여야 하나?" 싶었지만,
생각해보니 스크린 리더나 크롤링 로봇 입장에서도 내 코드가 읽기 어려웠겠다는 생각이 들었다.

aria-label 이나 role, 시맨틱 태그 같은 것들을 신경쓰지 않고 막 작성하는 버릇이 있었는데 덕분에 웹 표준과 접근성에 대해 다시 한 번 고민해볼 수 있었다.

다만, role과 aria-label을 남용하는 건 오히려 웹 표준에 어긋날 수 있다는 점도 알게 되어 시맨틱 태그로 표현할 수 없는 경우에만 보완적으로 사용하도록 신경 썼다.


2. 범용적인 APIDataProvider(Context) 구현

위에서 언급한 API Provider 코드 작성에도 신경썼다.
하지만 아직은 Cart만 이 컨텍스트를 사용하고 있다.

Product 데이터는 현재 구조상 ProductList에서만 알고 있는 게 더 맞다고 판단했기 때문이었다.


3. 빈 상태 UI 및 UX 개선

이미지가 없을 때, 장바구니가 비어 있을 때 등 다양한 빈 상태(empty state) UI를 추가했다. 상품명이 한 줄을 넘어갈 때는 ...으로 말줄임 처리하여 가독성과 UI/UX도 신경 썼다.


4. 범용성 있는 Counter 컴포넌트 구현

  • Counter에서 canBeZero 옵션을 활성화하면 수량이 1일 때도 - 버튼(휴지통 아이콘)이 활성화되어 클릭 시 해당 아이템이 삭제되도록 하였다.

5. Dropdown 키보드 접근성 및 Tab 인덱스

드롭다운 옵션을 키보드로도 선택할 수 있도록 개선했고,
autoFocus로 Tab 인덱스를 맞춰 사이트 전체를 키보드로도 이용할 수 있게 했습니다.


고민한 점

1. Context 사용 시 리렌더링 이슈

현재 API 구조상 Context로 데이터를 관리하다 보니 불필요한 리렌더링이 발생한다는 점이 고민되었다.

컴포넌트 전체를 React.memo로 감싸는 방법도 생각해봤지만, 메모이제이션 자체도 비용이라는 점이 떠올라 최적화 방향에 대해 고민이 많았습니다.

이런 문제는 TanStack Query(useQuery)와 같은 라이브러리를 사용하면
내부적으로 캐싱과 구독 범위 관리가 잘 되어 있어서 해소할 수 있다고 얼핏 들어보았지만...
(사용해보진 않았다!)

TanStack Query는 내부 구현이 복잡해 보여,
혹시 모든 컴포넌트를 메모이제이션하지 않고, 너무 복잡한 로직과 패턴을 도입하지 않으면서도 Context 기반 구조에서 리렌더링을 줄일 수 있는 다른 최적화 아이디어가 있을지 고민해보고 있다.


2. 스터디

2.1. three.js 스터디

일단 키보드로 이동하고 마우스 드래그로 주변을 둘러볼 수 있는 기능을 구현했다!
6월 초까지 짬짬이 기능을 구현해서 하나의 씬을 완성하기로 했는데,

나는 카멜 행성이를 찾아라 사이트를 만들고 싶다.

미션과 다른 스터디를 병행하며 짬내서 부담없이 만들어보는 토이 사이트인만큼 그냥 내가 좋아하는 오브젝트들을 띄워두고, 해당 물체에 가까이 가면 동물의 숲처럼 대화할 수 있는 사이트를 만들고 싶다.

말은 이렇게해도, 점점 미션 일정도 빡빡해지고 하고있는 스터디도 많아서 2주동안 해당 목표를 달성할 수 있을지 걱정이긴 하지만... 그래도 재밌으니까 ㅎㅎ

게더에서 함께 춤추는 three.js 크루들 ㅎㅎ


2.2. 유연성 강화 스터디

투두메이트를 시작했다!

일정 관리와 나의 막연한 불안감을 해소하기 위해 크루들과 함께하는 투두 메이트이다 ㅎㅎ
확실히 집중력 흐려질 때 쯤 크루들이 일과를 완료했다고 중간중간 알림이 뜨니까 다시 자극받고 집중하게되는 등, 벌써 효과를 느끼고 있다!👍

3. +) 메모

3.1. 학습 키워드


[x] contextAPI
[x] fetching hook
[x] suspense
[x] ErrorBoundary
[] useQuery
[] cache
[] memoization
[x] SSoT
[x] MSW
[] 커스텀 에러 객체
[] 비동기 (callback, Promise, async/await)


여기에 적은 키워드들은 내가 개념을 온전히 이해해서 타인에게 완벽히 설명할 수 있을 때 체크를 표시하도록 한다.

3.2. 남은 궁금증

궁금증 발단
then 은 Promise가 풀리기 전까지 await 처럼 기다려주는 게 아니라, Promise가 풀렸을 때 then 콜백함수를 실행하도록 하는 문법인데,

then 이 실행되기 전에 Promise를 할당한 변수에 접근하면 Promise 객체가 뜬다.
그리고 then 에서 풀린 데이터를 return 하면 해당 데이터가 Promise로 할당되어있던 변수에 할당된다.

const cartProductData = getCartData().then((cartData) => return cartData.product)
// cartProductData는 프로미스 객체이다가 then 이 실행되면 cartData의 product를 저장하게된다.

그럼 궁금해지는게, const 는 재할당을 금지하고 있다는 점이다.
Promise '객체'를 저장하고 있다가 새로운 데이터 배열을 새로 저장한다는 게 갑자기 어색하게 느껴진다.

탐구해보기

function getNumberAsync() {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve([1, 2, 3]);
    }, 2000);
  });
}

let insideValue;
const promiseNumber = getNumberAsync().then((res) => {
  console.log("[then 콜백 내부] res객체 :", res);
  insideValue = res[0];
  return res[0];
});

console.log("[즉시] promiseNumber:", promiseNumber);
console.log("[즉시] promiseNumber 의 type:", typeof promiseNumber);
setTimeout(() => {
  console.log("[3초 후] promiseNumber:", promiseNumber);
  console.log("[3초 후] insideValue", insideValue);
}, 3000);

async function testAwait() {
  const value = await getNumberAsync();
  console.log("[await] value:", value);
}
testAwait();

이게 어찌된 일인지 테스트 코드를 작성해보았다.
그리고 아래는 해당 코드를 실행시킨 후 결과이다.

[즉시] promiseNumber: Promise { <pending> }
[즉시] promiseNumber 의 type: object
[then 콜백 내부] res객체 : [ 1, 2, 3 ]
[await] value: [ 1, 2, 3 ]
[3초 후] promiseNumber: Promise { 1 }
[3초 후] insideValue 1

일단, 내가
그리고 then 에서 풀린 데이터를 return 하면 해당 데이터가 Promise로 할당되어있던 변수에 할당된다. 라고 말했던 것 자체가 틀린 말이었다.

promise 를 반환하는 함수를 await 없이 실행하면 바로 Promise 객체가 할당된다.
그리고 then 으로 resolve된 Promise 를 받아 통째로 반환해도 기존에 할당됐던 Promise 객체의 [[PromiseResult]] 필드에 할당이 되는 것이지, 객체는 여전히 Promise이다.

즉, resolve된 값을 직접 접근할 수 있는 것은 then 내부 뿐이다.
그래서 then에서 resolve 된 데이터를 밖으로 꺼내주고싶다면, Promise가 할당되지 않은 변수에 할당해주어야한다.

그렇지만 await은 Promise가 풀리기 전까지 반환을 하지 않고 기다려서인지 Promise 객체가 할당되지 않고 resolve 된 데이터가 바로 할당된 모습을 볼 수 있다.

이어지는 궁금증

그럼 Promise 객체의 [[PromiseResult]]에 then 과 같은 메서드 없이 접근할 수 있는 방법은 없는 걸까?

async await 과 같은 기다리는 제너레이터 함수는 어떻게 구현하고 작동하는 걸까?

profile
프론트엔드 개발자로 성장하고 싶은 그래픽 디자이너입니다!

1개의 댓글

comment-user-thumbnail
2025년 5월 26일

우와 나영님이 만드신 토스트 보고싶네요~😬

답글 달기