Skip to content

Conversation

@wlrnjs
Copy link
Collaborator

@wlrnjs wlrnjs commented Aug 7, 2025

요구사항

기본

  • 중고마켓 페이지 주소는 “/items” 입니다.
  • 페이지 주소가 “/items” 일때 상단네비게이션바의 '중고마켓' 버튼의 색상은 “3692FF”입니다.
  • 상단 네비게이션 바는 이전 미션에서 구현한 랜딩 페이지와 동일한 스타일로 만들어 주세요.
  • 상품 데이터 정보는 https://panda-market-api.vercel.app/docs/#/ 에 명세된 GET 메소드 “/products” 를 사용해주세요.
  • '상품 등록하기' 버튼을 누르면 “/additem” 로 이동합니다. ( 빈 페이지 )
  • 전체 상품에서 드롭 다운으로 “최신 순” 또는 “좋아요 순”을 선택해서 정렬을 할 수 있습니다.

베스트 상품

  • Desktop : 4개 보이기
  • Tablet : 2개 보이기
  • Mobile : 1개 보이기
    전체 상품
  • Desktop : 12개 보이기
  • Tablet : 6개 보이기
  • Mobile : 4개 보이기

심화

  • 페이지 네이션 기능을 구현합니다.

스크린샷

pc
tablet
mobile

멘토에게

  • XSS 공격 방지를 위해 프론트와 백엔드 양쪽 모두 sanitize 처리를 해야한다고 알고 있습니다. 저는 dompurify 라이브러리를 사용했는데, 이 방식이 괜찮을지 궁금합니다. (사용한 파일: sanitize.js, ItemCard.jsx)

  • API 요청 시 isLoading, isError 상태를 관리해서 컴포넌트를 조건부로 렌더링하고 있습니다. 여기에 Empty 상태까지 추가하게 되면 코드가 꽤 복잡해질 것 같아서, 이런 경우 어떻게 하면 가독성 있게 관리할 수 있을지 고민입니다. (파일: BestProductSection.jsx)

  • 아이템 리스트에서 이미지 경로가 아예 없는 경우에는 기본 이미지(ProductNullImage)로 대체하고 있는데, 이미지 경로는 있지만 실제 이미지가 깨지는 경우엔 현재 alt 텍스트만 보여지고 있습니다. 이런 경우 fallback 이미지를 어떻게 처리하는 게 좋을까요? 지금은 src={clean(img) || "/ProductNullImage.png"} 형태로 사용하고 있습니다.

  • 반응형을 구현하기 위해 useDeviceType 훅을 만들어서 화면 크기에 따라 타입을 판별하고 있습니다. 그런데 console.log를 찍어보니 화면 크기가 변경될 때마다 계속 콘솔에 출력되는 문제가 있었고, 이를 해결하기 위해 실제 크기 변화가 감지될 때만 디바운싱과 클린업을 적용했습니다.
    다만 디바운싱을 적용하면서 반응 속도가 느려져 사용자 입장에서 즉각적인 피드백이 줄어드는 문제가 생겨, 오히려 UX가 나빠졌다는 생각이 들었습니다. 이런 경우엔 어떤 방식으로 개선하는 게 좋을지 궁금합니다.

  • 셀프 코드 리뷰를 통해 질문 이어가겠습니다.

@wlrnjs wlrnjs requested a review from kiJu2 August 7, 2025 03:17
@wlrnjs wlrnjs added the 매운맛🔥 뒤는 없습니다. 그냥 필터 없이 말해주세요. 책임은 제가 집니다. label Aug 7, 2025
@kiJu2
Copy link
Collaborator

kiJu2 commented Aug 8, 2025

스프리트 미션 하시느라 수고 많으셨어요.
지권님 학습에 도움 되실 수 있게 꼼꼼히 리뷰 하도록 해보겠습니다. 😊

@kiJu2
Copy link
Collaborator

kiJu2 commented Aug 8, 2025

XSS 공격 방지를 위해 프론트와 백엔드 양쪽 모두 sanitize 처리를 해야한다고 알고 있습니다. 저는 dompurify 라이브러리를 사용했는데, 이 방식이 괜찮을지 궁금합니다. (사용한 파일: sanitize.js, ItemCard.jsx)

보안까지 고려하시다니 훌륭합니다 👍👍👍👍👍
다만, 리액트는 기본적으로 XSS 공격을 방지하고 있어요.
만약 기본 방지 기능을 끄려면 다음과 같이 시도해볼 수 있습니다:

<p dangerouslySetInnerHTML={{ __html: item.description }} />

즉. 의도적으로 html 삽입을 허용하지 않는 이상 기본적으로 방지됩니다 😉

@kiJu2
Copy link
Collaborator

kiJu2 commented Aug 8, 2025

반응형을 구현하기 위해 useDeviceType 훅을 만들어서 화면 크기에 따라 타입을 판별하고 있습니다. 그런데 console.log를 찍어보니 화면 크기가 변경될 때마다 계속 콘솔에 출력되는 문제가 있었고, 이를 해결하기 위해 실제 크기 변화가 감지될 때만 디바운싱과 클린업을 적용했습니다.
다만 디바운싱을 적용하면서 반응 속도가 느려져 사용자 입장에서 즉각적인 피드백이 줄어드는 문제가 생겨, 오히려 UX가 나빠졌다는 생각이 들었습니다. 이런 경우엔 어떤 방식으로 개선하는 게 좋을지 궁금합니다.


성능이냐 vs 편의성이냐

지금 상황은 사실 저울과 같기에 또 다른 솔루션은 없어보입니다.

다만, useDeviceType에 따라 네트워크 통신이 이루어진다면 성능이 기하급수적으로 나빠질 수 있으므로 지권님께서 생각하신 디바운싱은 정말 좋은 방법이라 생각합니다.
만약, 디바운싱이 유저가 사이즈 하고 마지막에 이벤트가 발생하기에 구겨진 UI를 보게되는게 싫으시다면 개선책으로 쓰로틀링을 생각해볼 수도 있겠네요. 😊

쓰로틀링: 마지막 함수가 호출된 후 일정 시간이 지나기 전에 다시 호출되지 않도록 하는 것

디바운싱: 연이어 호출되는 함수들 중 마지막 함수(또는 제일 처음)만 호출하도록 하는 것

Comment on lines +24 to +58
const {
products: bestProducts,
isLoading: bestProductsLoading,
isError: bestProductsError,
} = useGetBestProductData({ pageSize: bestPageSize });

// 전체 상품 데이터
const {
products: allProducts,
isLoading: allProductsLoading,
isError: allProductsError,
} = useGetProductData({
page,
pageSize: allPageSize,
orderBy,
keyword: "",
});

return (
<>
<main className="products">
<BestProductSection
products={bestProducts}
isLoading={bestProductsLoading}
isError={bestProductsError}
setBestPageSize={setBestPageSize}
/>
<AllProductSection
products={allProducts}
onOrderByChange={handleOrderByChange}
isLoading={allProductsLoading}
isError={allProductsError}
setAllPageSize={setAllPageSize}
/>
</main>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

API 요청 시 isLoading, isError 상태를 관리해서 컴포넌트를 조건부로 렌더링하고 있습니다. 여기에 Empty 상태까지 추가하게 되면 코드가 꽤 복잡해질 것 같아서, 이런 경우 어떻게 하면 가독성 있게 관리할 수 있을지 고민입니다

크으 역시 지권님 👍👍 정말 좋은 고민입니다 !

개발자 사이에서 인기있는 도서 "클린 코드"의 저자 "로버트 마틴"도 말합니다.

"함수의 매개변수는 적을수록 좋다."

현재 지권님께서도 고민중인 사항이신 것 같아요.

지권님은 현재 Container / Presentational 패턴을 사용하고 계십니다.

Container/Presentational ?
React에서 관심사의 분리(SoC) 를 강제하는 방법은 Container/Presentational Pattern을 이용하는 방법이 있다. 이 를 통해 비즈니스 로직에서 뷰를 분리해낼 수 있다.

즉. 현재 Container(Items.jsx)에서 모든 데이터 통신을 받아 Presenter(BestProductSection)로 내려주고 있어요.

사실, Container / Presentational 는 오래된 패턴 중 하나예요. 리액트 훅이 나오면서 이러한 패턴은 많이 사용하지 않습니다.

그럼 어떻게할까?

props로 내리지 않고 훅을 사용해볼 수 있어요.
지권님은 현재 API 함수를 hook으로 잘 감싸서 상태를 관리하고 있어요. 정석적이고 훌륭한 패턴입니다.
이미 훅을 사용하고 계시기에 props로 전달하지 않고 각 컴포넌트에서 hook으로 호출하면 됩니다 !

이어서 작성해볼게요 !

Comment on lines +9 to +58
const Items = () => {
const [page, setPage] = useState("1");
const [allPageSize, setAllPageSize] = useState("10");
const [bestPageSize, setBestPageSize] = useState("4");
const [orderBy, setOrderBy] = useState("recent");

const handleOrderByChange = (koreanValue) => {
const orderByMap = {
최신순: "recent",
좋아요순: "favorite",
};
setOrderBy(orderByMap[koreanValue] || "recent");
};

// 베스트 상품 데이터
const {
products: bestProducts,
isLoading: bestProductsLoading,
isError: bestProductsError,
} = useGetBestProductData({ pageSize: bestPageSize });

// 전체 상품 데이터
const {
products: allProducts,
isLoading: allProductsLoading,
isError: allProductsError,
} = useGetProductData({
page,
pageSize: allPageSize,
orderBy,
keyword: "",
});

return (
<>
<main className="products">
<BestProductSection
products={bestProducts}
isLoading={bestProductsLoading}
isError={bestProductsError}
setBestPageSize={setBestPageSize}
/>
<AllProductSection
products={allProducts}
onOrderByChange={handleOrderByChange}
isLoading={allProductsLoading}
isError={allProductsError}
setAllPageSize={setAllPageSize}
/>
</main>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(이어서) 다음과 같이 개선해볼 수 있습니다 !

Suggested change
const Items = () => {
const [page, setPage] = useState("1");
const [allPageSize, setAllPageSize] = useState("10");
const [bestPageSize, setBestPageSize] = useState("4");
const [orderBy, setOrderBy] = useState("recent");
const handleOrderByChange = (koreanValue) => {
const orderByMap = {
최신순: "recent",
좋아요순: "favorite",
};
setOrderBy(orderByMap[koreanValue] || "recent");
};
// 베스트 상품 데이터
const {
products: bestProducts,
isLoading: bestProductsLoading,
isError: bestProductsError,
} = useGetBestProductData({ pageSize: bestPageSize });
// 전체 상품 데이터
const {
products: allProducts,
isLoading: allProductsLoading,
isError: allProductsError,
} = useGetProductData({
page,
pageSize: allPageSize,
orderBy,
keyword: "",
});
return (
<>
<main className="products">
<BestProductSection
products={bestProducts}
isLoading={bestProductsLoading}
isError={bestProductsError}
setBestPageSize={setBestPageSize}
/>
<AllProductSection
products={allProducts}
onOrderByChange={handleOrderByChange}
isLoading={allProductsLoading}
isError={allProductsError}
setAllPageSize={setAllPageSize}
/>
</main>
const Items = () => {
const [page, setPage] = useState("1");
// 해당 코드들은 컴포넌트 내부에 작성해도 될 것 같네요 !
return (
<>
<main className="products">
<BestProductSection
// Props가 아닌 컴포넌트 내부로 !
/>
<AllProductSection
// Props가 아닌 컴포넌트 내부로 !
/>
</main>

Comment on lines +8 to +45
const BestProductSection = ({
products,
isLoading,
isError,
setBestPageSize,
}) => {
const { isMobile, isTablet, isDesktop } = useDeviceType();

useEffect(() => {
if (isDesktop) setBestPageSize("4");
else if (isTablet) setBestPageSize("2");
else if (isMobile) setBestPageSize("1");
}, [isMobile, isTablet, isDesktop, setBestPageSize]);

return (
<section className="best-products">
<h1 className="best-products-title">베스트 상품</h1>
<article className="best-products-wrapper">
{/* isLoading, isError 상태 렌더링 */}
{isLoading && <IsLoading type="BEST" />}
{isError && (
<IsError message="베스트 상품을 불러오는데 실패했습니다." />
)}

{products?.list?.map((product) => (
<ItemCard
key={product.id}
img={product.images[0]}
type="BEST"
title={product.name}
price={product.price}
heartCount={product.favoriteCount}
/>
))}
</article>
</section>
);
};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(이어서) 해당 코드는 다음과 같이 작성될 수 있겠네요 😉

Suggested change
const BestProductSection = ({
products,
isLoading,
isError,
setBestPageSize,
}) => {
const { isMobile, isTablet, isDesktop } = useDeviceType();
useEffect(() => {
if (isDesktop) setBestPageSize("4");
else if (isTablet) setBestPageSize("2");
else if (isMobile) setBestPageSize("1");
}, [isMobile, isTablet, isDesktop, setBestPageSize]);
return (
<section className="best-products">
<h1 className="best-products-title">베스트 상품</h1>
<article className="best-products-wrapper">
{/* isLoading, isError 상태 렌더링 */}
{isLoading && <IsLoading type="BEST" />}
{isError && (
<IsError message="베스트 상품을 불러오는데 실패했습니다." />
)}
{products?.list?.map((product) => (
<ItemCard
key={product.id}
img={product.images[0]}
type="BEST"
title={product.name}
price={product.price}
heartCount={product.favoriteCount}
/>
))}
</article>
</section>
);
};
const BestProductSection = () => {
const [pageSize, setPageSize] = useState("4");
const { isMobile, isTablet, isDesktop } = useDeviceType();
useEffect(() => {
if (isDesktop) setPageSize("4");
else if (isTablet) setPageSize("2");
else if (isMobile) setPageSize("1");
}, [isMobile, isTablet, isDesktop]);
// 베스트 상품 데이터
const {
products,
isLoading,
isError,
} = useGetBestProductData({ pageSize });
return (
<section className="best-products">
<h1 className="best-products-title">베스트 상품</h1>
<article className="best-products-wrapper">
{/* isLoading, isError 상태 렌더링 */}
{isLoading && <IsLoading type="BEST" />}
{isError && (
<IsError message="베스트 상품을 불러오는데 실패했습니다." />
)}
{products?.list?.map((product) => (
<ItemCard
key={product.id}
img={product.images[0]}
type="BEST"
title={product.name}
price={product.price}
heartCount={product.favoriteCount}
/>
))}
</article>
</section>
);
};

Comment on lines +9 to +47
const AllProductSection = ({
products,
onOrderByChange,
isLoading,
isError,
setAllPageSize,
}) => {
const { isMobile, isTablet, isDesktop } = useDeviceType();

useEffect(() => {
if (isDesktop) setAllPageSize("10");
else if (isTablet) setAllPageSize("6");
else if (isMobile) setAllPageSize("4");
}, [isMobile, isTablet, isDesktop, setAllPageSize]);

return (
<section className="all-products">
{/* 제목, 옵션 */}
<OptionsBox onOrderByChange={onOrderByChange} isMobile={isMobile} />

{/* isLoading, isError 상태 렌더링 */}
{isLoading && <IsLoading type="ALL" />}
{isError && <IsError message="전체 상품을 불러오는데 실패했습니다." />}

<article className="all-products-wrapper">
{products?.list?.map((product) => (
<ItemCard
key={product.id}
img={product.images[0]}
type="ALL"
title={product.name}
price={product.price}
heartCount={product.favoriteCount}
/>
))}
</article>
</section>
);
};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(이어서) 해당 컴포넌트 또한 props 대신 다음과 같이 작성해볼 수 있습니다 😉

Suggested change
const AllProductSection = ({
products,
onOrderByChange,
isLoading,
isError,
setAllPageSize,
}) => {
const { isMobile, isTablet, isDesktop } = useDeviceType();
useEffect(() => {
if (isDesktop) setAllPageSize("10");
else if (isTablet) setAllPageSize("6");
else if (isMobile) setAllPageSize("4");
}, [isMobile, isTablet, isDesktop, setAllPageSize]);
return (
<section className="all-products">
{/* 제목, 옵션 */}
<OptionsBox onOrderByChange={onOrderByChange} isMobile={isMobile} />
{/* isLoading, isError 상태 렌더링 */}
{isLoading && <IsLoading type="ALL" />}
{isError && <IsError message="전체 상품을 불러오는데 실패했습니다." />}
<article className="all-products-wrapper">
{products?.list?.map((product) => (
<ItemCard
key={product.id}
img={product.images[0]}
type="ALL"
title={product.name}
price={product.price}
heartCount={product.favoriteCount}
/>
))}
</article>
</section>
);
};
const AllProductSection = () => {
const [pageSize, setPageSize] = useState("10");
const [orderBy, setOrderBy] = useState("recent");
const { isMobile, isTablet, isDesktop } = useDeviceType();
useEffect(() => {
if (isDesktop) setPageSize("10");
else if (isTablet) setPageSize("6");
else if (isMobile) setPageSize("4");
}, [isMobile, isTablet, isDesktop]);
// 전체 상품 데이터
const {
products,
isLoading,
isError,
} = useGetProductData({
page,
pageSize,
orderBy,
keyword: "",
});
const handleOrderByChange = (koreanValue) => {
const orderByMap = {
최신순: "recent",
좋아요순: "favorite",
};
setOrderBy(orderByMap[koreanValue] || "recent");
};
return (
<section className="all-products">
{/* 제목, 옵션 */}
<OptionsBox onOrderByChange={onOrderByChange} isMobile={isMobile} />
{/* isLoading, isError 상태 렌더링 */}
{isLoading && <IsLoading type="ALL" />}
{isError && <IsError message="전체 상품을 불러오는데 실패했습니다." />}
<article className="all-products-wrapper">
{products?.list?.map((product) => (
<ItemCard
key={product.id}
img={product.images[0]}
type="ALL"
title={product.name}
price={product.price}
heartCount={product.favoriteCount}
/>
))}
</article>
</section>
);
};

Comment on lines +18 to +24
<img
src={clean(img) || "/ProductNullImage.png"}
alt={clean(title) || "상품 이미지"}
className={
type === "BEST" ? "item-card-best-img" : "item-card-all-img"
}
/>
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fallback에 대한 질문

아이템 리스트에서 이미지 경로가 아예 없는 경우에는 기본 이미지(ProductNullImage)로 대체하고 있는데, 이미지 경로는 있지만 실제 이미지가 깨지는 경우엔 현재 alt 텍스트만 보여지고 있습니다. 이런 경우 fallback 이미지를 어떻게 처리하는 게 좋을까요? 지금은 src={clean(img) || "/ProductNullImage.png"} 형태로 사용하고 있습니다.

<img> 태그에는 onError 속성을 사용할 수 있어요. 😉
이미지를 불러오지 못했을 때 대체 이미지를 지정하는 가장 흔한 방식입니다.

Suggested change
<img
src={clean(img) || "/ProductNullImage.png"}
alt={clean(title) || "상품 이미지"}
className={
type === "BEST" ? "item-card-best-img" : "item-card-all-img"
}
/>
<img
src={clean(img)}
alt={clean(title) || "상품 이미지"}
className={type === "BEST" ? "item-card-best-img" : "item-card-all-img"}
onError={(e) => {
e.currentTarget.src = "/ProductNullImage.png";
}}
/>

Comment on lines +11 to +42
const useDeviceType = () => {
const [deviceType, setDeviceType] = useState(getDeviceType());
const deviceTypeRef = useRef(deviceType); // 이전 값을 저장해서 비교

useEffect(() => {
let resizeTimeout;

const handleResize = () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(() => {
const newType = getDeviceType();
if (newType !== deviceTypeRef.current) {
deviceTypeRef.current = newType;
setDeviceType(newType);
}
}, 150); // 디바운싱 150ms
};

window.addEventListener("resize", handleResize);
// 컴포넌트 언마운트 시 이벤트 리스너 제거
return () => {
clearTimeout(resizeTimeout);
window.removeEventListener("resize", handleResize);
};
}, []);

return {
isMobile: deviceType === "mobile",
isTablet: deviceType === "tablet",
isDesktop: deviceType === "desktop",
};
};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

굿굿 ! 훅을 활용한 방법 정말 좋네요 !

자주 사용되는 만큼 활용성이 좋을 것으로 예상되네요 👍 잘 분리하셨습니다 ! 👍

Comment on lines +15 to +31
const fetchProducts = async () => {
try {
const response = await fetch(`${import.meta.env.VITE_API_URL}/products?${params}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
const data = await response.json();
setProducts(data);
} catch (err) {
setIsError(err);
console.error("전체 상품 데이터 가져오기 실패:", err);
} finally {
setIsLoading(false);
}
};
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(제안/심화) 해당 fetch 함수를 따로 분리하시는 것도 좋은 방법이 될 수 있을 것 같아요.

레이어를 한 층 더 두는 것도 좋은 설계 방법입니다 !
이는 관심사 분리에도 용이할 수 있어요.

관심사 분리(separation of concerns, SoC)는 컴퓨터 프로그램을 구별된 부분으로 분리시키는 디자인 원칙으로, 각 부문은 개개의 관심사를 해결

관심사를 분리하게되면 다음과 같은 목표를 이룰 수 있어요:

  • 훅은 통신 요청에 대한 리액트의 '상태'를 다루는 데에 책임을 가지고,
  • fetch 함수는 "데이터를 가져오는 행위(API 통신)"에 대한 책임을 가지게 됩니다. 😉

즉. 다음과 같이 함수를 분리해볼 수 있을거예요:

// apis/product.js
export async function fetchProducts({ page = "1", pageSize = "10", orderBy = "latest", keyword = "" }) {
  const params = new URLSearchParams();
  params.append("page", page);
  params.append("pageSize", pageSize);
  params.append("orderBy", orderBy);
  if (keyword) params.append("keyword", keyword);

  const response = await fetch(`${import.meta.env.VITE_API_URL}/products?${params}`, {
    method: "GET",
    headers: {
      "Content-Type": "application/json",
    },
  });

  if (!response.ok) throw new Error("상품 데이터를 불러오지 못했습니다.");
  return await response.json();
}

해당 함수는 순수 함수이며 리액트와 관련이 없으니 .js파일로 작성해도 되겠네요 😉

@kiJu2
Copy link
Collaborator

kiJu2 commented Aug 8, 2025

수고하셨습니다 지권님 !
질문이 정말 상세하고 이해하기가 쉬웠어요 !
질문에 대한 답을 하다 보니 코드 리뷰가 다 될 정도였습니다 😂😂😂

이번 미션도 정말 수고 많으셨어요 지권님 🎉🎉

@kiJu2 kiJu2 merged commit 4f84098 into codeit-bootcamp-frontend:React-서지권 Aug 8, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

매운맛🔥 뒤는 없습니다. 그냥 필터 없이 말해주세요. 책임은 제가 집니다.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants