bgyoonsContact

커머스 웹뷰 유저 화면 리팩토링 기록

문제해결 | | 17분 읽기

💡 커머스 웹뷰 서비스의 유저 화면을 마이그레이션하며 발견한 구조적 문제와 개선 과정을 정리했다.

들어가며

처음에는 화면을 새 API와 스타일로 옮기는 작업으로 생각했다. 하지만 컴포넌트를 열 때마다 데이터가 어디서 들어오는지부터 확인해야 했다.

이 컴포넌트가 받는 product는 어디서 오는 걸까?

부모 컴포넌트를 따라 올라가도 props를 넘기는 코드를 찾을 수 없었으며, 값은 들어오는데 전달하는 곳이 보이지 않았고, 타입은 모두 ?가 붙은 옵셔널이거나 any였다.

즉 마이그레이션을 하는 것보다 데이터의 출처를 추적하는 데 시간이 더 드는 상황이 반복됐다.

마이그레이션을 안정적으로 개발하게 가능하려면 이 구조부터 개선이 필요하다고 생각했다.

문제 도출 및 정의

마이그레이션하면서 "추적이 막히는 지점" 기록

별도의 코드 감사보다는 마이그레이션 작업 중 데이터 흐름을 파악하기 어려웠던 지점을 문서에 기록했다. 이유를 정리하니 몇 가지 패턴으로 나뉘었다.

패턴 막힌 이유
Spread Props ({...option}) 어떤 prop이 넘어가는지 호출부만 봐서는 알 수 없음
Container + React.cloneElement 부모가 자식에게 props를 주입하지만 호출부에는 드러나지 않음
서버 필드명과 다른 프론트 변수명 (productNo vs productId, Amt, Cnt, Nos) 응답 스키마와 바로 대응되지 않아 매번 이름을 대조해야 함
컴포넌트 안에서 직접 호출하는 useQuery/useInfiniteQuery 컴포넌트가 길어지고 UI와 데이터 로직이 섞임
훅 조건부 호출 경고 React Hook "useState" is called conditionally
로컬 스토리지 이벤트 기반 상태 동기화(ObserverStorage) 동기화가 필요한 이유와 이벤트를 발생시키는 곳을 파악하기 어려움

패턴마다 AS-IS / 개선 이유 / 주의사항 / 대상 목록 정리

문제와 수정 범위를 함께 확인할 수 있도록 항목마다 아래 형식으로 정리했다.

  • AS-IS 코드: 실제 문제가 되는 코드 조각
  • 개선 이유: 왜 고쳐야 하는지 (가독성, 추적성, 타입 추론, 버그 가능성 등)
  • 주의사항: 고칠 때 깨질 수 있는 것 (이벤트 버블링, 데이터 태깅 필드명 등)
  • 수정 대상 목록: 같은 패턴이 쓰인 컴포넌트 전체

이 목록을 기준으로 수정 순서와 범위를 정했다. Spread Props는 16개 컴포넌트에서, ProductCardContainer는 20개가 넘는 파일에서 사용하고 있었다.

예시 PR을 생성하여 파트에 공유

파트에서도 같은 방식으로 작업할 수 있도록 먼저 병합하지 않을 예시 PR을 만들었다. 패턴별 TO-BE 코드를 커밋 단위로 나눠 수정 방향을 공유했다.

  • Container → 커스텀 훅으로 분리한 예시
  • useInfiniteQuery를 커스텀 훅으로 분리한 예시
  • cloneElement를 render props로 바꾼 예시 (ModalSheetBottom)
  • 훅 호출 위치를 사용하는 컴포넌트 안으로 옮긴 예시

스크럼을 이용한 peer 리뷰를 통해 분석 내용을 보완했다.

Container의 <div {...props}>에 실제로 어떤 값이 전달되는지 함께 확인했고, 해당 훅을 자식 컴포넌트에서 직접 호출하는 방향으로 정리했다.

문제 파악

Container 패턴 사례

데이터 흐름을 파악할 때 가장 자주 막혔던 부분은 Container 패턴이었다.

<ProductCardContainer product={product} setQueryWishUpdate={setQueryWishUpdate}>
  <ProductCard product={product} />
</ProductCardContainer>
// ProductCardContainer.tsx
 
interface Props {
  product: Product;
  children: React.ReactNode;
  [p: string]: any; // 어떤 값이 들어오는지 알 수 없음
}
 
useEffect(() => {
  setProductState(product);
}, [product]);
 
return (
  <div {...props} onClick={handleClickContainer}>
    {React.cloneElement(rootChild, {
      ...rootChild?.props,
      product: productState
    })}
  </div>
);

Container 하나가 데이터 상태 관리, 장바구니 로직, 이벤트 태깅, 스토리지 이벤트 등록, 자식 props 주입을 모두 맡고 있었다.

재사용과 관심사 분리를 위한 구조였지만, 데이터 흐름과 타입을 파악하기 어려웠다.

  1. React.cloneElement는 React 공식 문서에서도 레거시로 분류된 API다.
  2. props가 어디서 주입되는지 호출부에 드러나지 않아 디버깅이 어렵다.
  3. 자식이 props를 나중에 주입받는 구조라 타입이 모두 옵셔널이고, 타입 추론이 어렵다.
  4. 클릭 핸들러가 래퍼 div에 걸려 있어서 자식의 클릭 이벤트와 버블링이 얽힌다.

이게 필요 없는 거 아닌가? → 왜 동작했지?

우선 주문 목록, 상세에서 사용하던 OrderSheetObserverContainer를 제거하기 전에 어떤 역할을 하는지 확인했다.

동작을 따라가 보니 다음 흐름으로 화면 간 상태를 맞추고 있었다.

  1. 주문 목록에서 카드를 클릭해 주문 상세로 들어갈 때, 로컬 스토리지 storage 이벤트 리스너를 등록한다.
  2. 주문 상세에서 주문 상태를 바꾸면(배송중 → 배송완료, 배송완료 → 구매확정) 로컬 스토리지 값을 바꾼다.
  3. 웹뷰 구조상 주문 상세 아래에 깔려 있는 주문 목록 페이지가 이벤트를 받아, 목록의 주문 상태를 상세와 맞춘다.

storage 이벤트는 값을 바꾼 문서가 아닌 다른 문서에서만 발생한다.

이 서비스는 앱 웹뷰에서 상세 화면이 목록 위에 새로 열리는 구조였고, 스토리지 이벤트로 두 화면의 상태를 동기화하고 있었다.

Container를 제거하더라도 스토리지 이벤트는 유지해야 했다.

이 과정에서 refetch 호출 위치가 의도와 다르다는 점도 확인했다.

상태가 바뀌는 쪽은 주문 상세이고, 갱신돼야 하는 쪽은 주문 목록인데, refetch가 주문 상세 쪽에 들어가 있었다.

목록이 정상적으로 갱신되는 것처럼 보였던 이유는 refetch가 아니었다.

Container의 setter가 로컬 스토리지 값을 받아 로컬 state를 갱신하면서 리렌더링이 일어나고 있었다.

서버 데이터를 다시 가져오는 대신 Container에 복사해 둔 state가 바뀌고 있었다.

개선 방법

✅ 개선 1. 주문 상태 Container 제거와 동기화 로직 재설계

Step 1: Container 제거, props 명시화

OrderSheetObserverContainer와 OrderObserverContainer를 삭제하고, cloneElement로 주입하던 orderSheet를 호출부에서 직접 전달하도록 바꿨다.

값이 전달되는 위치가 명확해져 옵셔널 타입도 필수로 바꿨다.

// AS-IS
<OrderSheetObserverContainer refetch={refetch} orderSheet={orderSheet}>
  <OrderSheetDetailCard openWeb={openWeb} />
</OrderSheetObserverContainer>
 
// TO-BE
<OrderSheetDetailCard openWeb={openWeb} orderSheet={orderSheet} />
interface Props {
  openWeb: ({ href }: { href: string }) => void;
  orderSheet: OrderDetailSheet; // 옵셔널 타입 제거
}

변경 후 이벤트 버블링 문제가 없는지도 확인했다.

Step 2: 전체 refetch 대신 캐시의 변경 부분만 수정

처음에는 이벤트를 받으면 목록을 refetch하도록 수정했다.

하지만 특정 주문 옵션의 상태 하나를 바꾸기 위해 무한 스크롤 목록 전체를 다시 받아올 필요는 없었다.

스토리지에 전달하는 데이터를 { orderOptionId, orderStatus }로 줄이고, React Query 캐시에서 해당 옵션만 갱신하도록 바꿨다.

// 스토리지에 싣는 값도 서버 필드명 그대로
observerOrder.set({ orderOptionId, orderStatus: 'BUY_CONFIRM' });

기존에 전달하던 orderOptionNo, orderStatusType, orderStatusTypeLabel, nextActions 등의 값도 서버 필드명 기준으로 정리했다.

Step 3: 리스너는 마운트 시 한 번만 등록

카드 클릭 시점에 리스너를 등록하면 클릭할 때마다 등록이 반복될 수 있었다.

useEffect에서 마운트 시 한 번 등록하고 언마운트 시 해제하도록 바꿨다.

Step 4: 커스텀 훅으로 추출

주문 목록과 주문 상세에는 스토리지 값을 파싱하고 검증한 뒤 setQueryData를 호출하는 로직이 중복돼 있었다.

이를 useOrderStatusSync 훅으로 분리하고, 각 화면에서는 어떤 쿼리를 어떻게 갱신할지만 전달하도록 했다.

const useOrderStatusSync = ({ queryKey, updater }) => {
  const queryClient = useQueryClient();
 
  useEffect(() => {
    observerOrder.onstorage((_, { newValue }) => {
      queryClient.setQueryData(queryKey, (prevData) =>
        updater(orderOptionId, orderStatus)(prevData)
      );
    });
 
    return () => observerOrder.removeHandler();
  }, []);
};
// 주문 목록 (무한 스크롤 캐시)
useOrderStatusSync({
  queryKey: ['get', '/users/orders'],
  updater: (orderOptionId, orderStatus) => ({
    // 업데이트 할 내용
  }),
});

결과

  • Container 2개(약 85줄) 삭제
  • cloneElement로 주입하던 props를 호출부에 명시
  • 옵셔널 타입을 필수로 바꿔 타입 추론 개선
  • 상태 변경 시 서버 재요청 없이 캐시에서 해당 옵션만 갱신
  • 동일 로직 중복 제거 (목록·상세가 같은 훅을 사용)

✅ 개선 2. useNextAction 리팩토링

useNextAction은 주문 상태별 버튼(취소, 배송조회, 구매확정, 재구매, 문의 등)의 액션을 만드는 훅이었다.

기존 구조에는 다음 문제가 있었다.

  1. 내부 분기가 많아 구조를 이해하기 어려움
  2. useNextAction이라는 이름만으로는 어떤 도메인의 액션인지 알기 어려움
  3. 부모(OrderSheetDetailCard)가 훅을 호출하고 결과 객체와 actionData(any)를 버튼에 내려 주는 구조 → 관심사 분리가 안 됨
// AS-IS: 부모가 액션을 계산해서 내려줌
const next = useNextAction();
const nextAction = next.actionByOption({ nextActions, productNo, ... });
 
<OrderNextActionButton
  statusType={statusType}
  nextAction={nextAction}
  actionData={{ accumulationAmount, trackingNumber, deliveryCompany }}
/>
 
// TO-BE: 버튼이 직접 훅을 호출, 부모는 데이터만 넘김
<OrderNextActionButton statusType={statusType} option={option} />
 
const OrderNextActionButton = ({ statusType, option }) => {
  const { actionByOption } = useOrderNextAction();
  const nextAction = actionByOption({ productId, orderOptionId, orderId, ... });
  ...
};

함께 정리한 것들

  • 이름을 useOrderNextAction으로 변경 (주문 도메인 훅임을 명시)
  • 직접 정의하던 nextAction 객체 타입과 any를 제거하고, OpenAPI 생성 타입과 StatusType을 사용
  • 서버 필드명에 맞춰 productNo → productId, PAY_* → PAYMENT_*로 통일
  • 누락돼 있던 주문 상태(CANCEL_REQUEST, RETURN_REQUEST 등) 추가

변수명을 서버 필드명에 맞추는 작업도 수정 중인 도메인부터 함께 적용했다.

✅ 개선 3. 찜하기 로직을 Container 밖으로 (팀 작업)

ProductCardContainer를 분석하면서 찜하기 캐시 갱신 훅(useSetQueryWishUpdate)을 변경 시 주의할 부분으로 기록했다.

상품 리스트마다 데이터 구조가 달라 다른 목록에 영향을 줄 가능성이 컸다.

이 부분은 예시 PR 리뷰에서 정한 방향에 따라 파트에서 작업을 이어 갔다.

  • useSetQueryWishUpdate 삭제
  • 찜 추가/삭제를 담당하는 useMutationWish 추가
  • 찜 버튼 컴포넌트가 직접 찜 상태를 처리하고, 부모는 찜 로직을 몰라도 되도록 변경
  • 약 40개 파일에서 mutate, setQueryWishUpdate prop 전달 제거

부모가 콜백을 전달하는 대신 필요한 컴포넌트에서 직접 훅을 호출하도록 바뀌었다.

Container 제거 시 영향이 클 것으로 봤던 찜하기 로직을 먼저 분리할 수 있었다.

🟡 그 밖에 진행 중인 것

  • 쿼리 로직 커스텀 훅 분리: 한꺼번에 분리하기보다 기능을 수정할 때마다 useWishListInfiniteQuery, useMaxDiscountCouponQuery, useRecommendedProductsAfterOrder처럼 쿼리를 훅으로 분리하고 있다. 이 방식을 팀 컨벤션으로 적용하고 있다.
  • 변수명 정리: 상품 옵션 선택 컴포넌트에서 fields / options / isAddOption / update / remove 같은 모호한 이름을 selectedOptionFields / optionCatalog / isFinalOptionDepth / updateOptions / resetOptions로 바꾸고 any 타입을 제거하는 등, 수정하는 영역부터 정리하고 있다.

남은 과제

  • cloneElement 제거
  • render props 또는 Composition + Context로 대체 검토
  • 훅 조건부 호출 - rules-of-hooks 린트 규칙을 켜서 위반 지점부터 찾을 예정
  • 장바구니 개수 스토리지 이벤트 - 필요성 재검토 필요

배운 점

  • 코드를 제거하기 전에 동작 원리를 확인해야 했다.

주문 상태 동기화는 refetch 방향이 반대였지만 Container 내부 state가 갱신돼 정상처럼 보였다. 실제 동작을 확인한 뒤 스토리지 이벤트는 유지하고 Container만 제거할 수 있었다.

  • props를 명시하면 데이터 흐름을 파악하기 쉬웠다.

spread, cloneElement, Container로 전달 과정을 감추면 호출부만으로 데이터 흐름을 알기 어려웠고, 타입도 옵셔널로 남았다. props를 직접 전달하고 필요한 컴포넌트에서 훅을 호출하니 코드를 읽고 수정하기 수월했다.

  • 대상 목록과 예시 PR을 바탕으로 조금씩 적용했다.

패턴별 수정 대상과 주의사항을 정리해 두니 기능 작업과 함께 해당 영역을 개선할 수 있었다. 예시 PR은 팀원들이 같은 방향으로 작업하는 기준이 됐다.

  • 변경 시 주의할 점도 함께 기록했다.

이벤트 버블링과 데이터 태깅에 쓰는 필드명처럼 수정 과정에서 문제가 생길 수 있는 항목을 미리 적어 두고 확인했다.