따꿍의 프로젝트
[Sprint 5] React 적용 (path alias 설정, layout 설정, element 사이사이에만 라인 설정하기, 반응형 디자인, useMediaQuery) 본문
[Sprint 5] React 적용 (path alias 설정, layout 설정, element 사이사이에만 라인 설정하기, 반응형 디자인, useMediaQuery)
공장 주인 따꿍 2026. 4. 24. 13:36스프린트 내용

https://github.com/quothraven1122/13-sprint-mission-fe/issues/32
feat: create market page · Issue #32 · quothraven1122/13-sprint-mission-fe
💬 기능 설명 Figma 디자인 중고마켓 페이지를 작성할 계획입니다. 🎬 작업 흐름 공통 컴포넌트 생성 버튼 생성 상품 컴포넌트 생성 검색창 생성 dropdown 생성 페이지 구성 생성 Header 작성하기 Body
github.com
작업 흐름
- vite로 프로젝트 생성하기 (React + JS Compiler)
- vite starter template 지우기
- layout 만들고 header와 footer만들기
MainLayout이라는걸 만들어서 Header / Page / Footer 구조의 레이아웃 생성하고 App.jsx에 넣음
중간의 Page는 children으로 받는 형식으로 작성
전체 layout의 min-height를 100vh, display는 flex로 지정하고 page를 flex:1로 정해서 무조건 footer이 밑에 오도록 작성
(밑의 "Footer이 무조건 밑에 있는 Layout 구조" 섹션 확인해보세요)
- useEffect에 fetch를 하는 패턴이 너무 길고 생각할게 많아서 그냥 axios를 다운받아서 사용했다
- Button, Item, ProductCardList, Input, Dropdown, Pagination 공통 컴포넌트 생성
상태관리는 무조건 부모인 MarketPage에서 관리한다는 생각으로 작성해야한다.⭐
그래서 state는 무조건 page에만 존재하게 하고, 컴포넌트는 UI만 보이게 한다.
props로는 필요한 상태, 데이터, 클래스네임, 주요 이벤트 헨들러 (onChange, onKeyDown) 만 전달하면 되는 듯 하다
| 전달할 Props | 예시 | 필요한 이유 |
| 데이터 | placeholder, title, column | 재사용성 (데이터 주어진거에 따라 조금씩 다른 컴포넌트 만들어짐) |
| 자식 | children | 재사용성 (자식 주어진거에 따라 조금씩 다른 컴포넌트 만들어짐) |
| 부모로부터 오는 state | value, data | state에 따른 UI 변경 |
| 클래스 | className | 커스텀 스타일링 가능하게 만들기 |
| 주요 이벤트 헨들러 | onChange, onKeyDown |
setState를 직접 주는 대신, 어떤 이벤트가 일어나면 이러한 행동을 했으면 좋겠다를 정의하는 여러 setState를 |
Button.jsx
데이터인 variant와 disabled, 자식인 children, 그리고 이벤트 헨들러인 onClick을 받는다.
export default function Button({
variant,
children,
disabled = false,
onClick,
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={`${styles.btn} ${styles[`btn-${variant}`]}`}
>
{children}
</button>
);
}
Item.jsx
보다시피 컴포넌트는 오로지 UI만 담당해야해서 data만 받는다.
export default function Item({ data }) {
return (
<div className={styles.container}>
<img
src={data.images[0] || defaultImg}
loading="lazy"
onError={(e) => {
e.target.src = defaultImg;
}}
className={styles.img}
/>
<div className={styles.content}>
<h2 className={styles.title}>{data.name}</h2>
<h3 className={styles.price}>{data.price.toLocaleString()}원</h3>
<div className={styles.meta}>
<img src={icHeartEmpty} />
{data.favoriteCount}
</div>
</div>
</div>
);
}
ProductCardList.jsx
Item 그룹의 타이틀과 아이템들을 디스플레이하는 컴포넌트다.
역시 계산하는 로직은 하나도 없고 오로지 데이터인 title, column과 state인 data, 그리고 자식인 children을 받는다.
export default function ProductCardList({ title, column = 5, data, children }) {
return (
<div>
<div className={styles.top}>
<h1 className={styles.title}>{title}</h1>
<div className={styles.toolbar}>{children}</div>
</div>
<div className={styles.list} style={{ "--column-count": column }}>
{data.map((d) => (
<Item data={d} key={d.id} />
))}
</div>
</div>
);
}
Input.jsx
역시 계산하는 로직은 넣으면 안된다.
setState를 바로 Input에 전달하는게 아니라,
input 입력 값이 바뀌면 해야하는 모든 전반적인 작업을 onChange라는 이벤트 헨들러 함수에 넣어서 전달한다.
데이터인 placeholder, state인 value, 이벤트 헨들러인 onChnage와 onKeyDown, 그리고 클래스인 className을 전달받는다.
<Input
placeholder="검색할 상품을 입력해주세요"
value={input}
onChange={(e) => {
setInput(e.target.value);
if (e.target.value.length === 0) {
setPage(1);
setKeyword(e.target.value);
}
}}
onKeyDown={(e) => {
if (e.code === "Enter") {
setPage(1);
setKeyword(input);
}
}}
className={styles.input}
/>;
export default function Input({
placeholder,
value,
onChange,
onKeyDown,
className,
}) {
return (
<div className={`${styles.container} ${className}`}>
<img src={icSearch} />
<input
placeholder={placeholder}
value={value}
onChange={onChange}
onKeyDown={onKeyDown}
/>
</div>
);
}
Dropdown.jsx
데이터, state, 그리고 이벤트 헨들러를 받는다.
export default function Dropdown({ menu, value, onChange }) {
const [open, toggle] = useDropdown();
return (
<div className={styles.container}>
<div className={styles.currentContainer} onClick={toggle}>
<div className={styles.current}>
<p>{value.name}</p>
<img src={icArrowDown} className={styles.icon} />
</div>
</div>
<div className={`${styles.dropdown} ${!open && styles.dropdownClose}`}>
{menu.map((m) => (
<div
key={m.id}
className={styles.menu}
onClick={(e) => {
onChange(m);
toggle();
}}
>
{m.name}
</div>
))}
</div>
</div>
);
}
Pagination.jsx
보면 계산하는 로직은 모두 utils에 따로 보관했다.
해당 컴포넌트가 받는 props는 단지 state인 currentPage와 totalCount, 그리고 이벤트 헨들러인 onChange이다.
아래 로직을 보면 버튼중에 currentPage state와 같은 값을 담고 있는 Button만 "circle-selected" 스타일을 가지게 된다.
import { getTotalPage, getRange } from "@/utils/pagination";
export default function Pagination({ currentPage, totalCount, onChange }) {
const totalPage = getTotalPage(totalCount);
const numbers = getRange(currentPage, totalPage);
return (
<div className={styles.pagination}>
<Button
variant="circle"
onClick={() => {
if (currentPage > 1) onChange((prev) => prev - 1);
}}
>
<div className={styles.btnTxt}>
<p>{"<"}</p>
</div>
</Button>
{numbers.map((n, i) => (
<Button
variant={n === currentPage ? "circle-selected" : "circle"}
onClick={() => {
onChange(n);
}}
key={i}
>
<div className={styles.btnTxt}>
<p>{n}</p>
</div>
</Button>
))}
<Button
variant="circle"
onClick={() => {
if (currentPage < totalPage) onChange((prev) => prev + 1);
}}
>
<div className={styles.btnTxt}>
<p>{">"}</p>
</div>
</Button>
</div>
);
}
- currentPage와 totalCount만으로도 현재 띄워저야하는 범위를 계산할수 있다.
- 한 페이지에 10개씩 띄우니 totalCount/10을하고 위로 올림을하면 있어야할 전체 페이지 계수 계산 가능하다
- Pagination 버튼을 한번에 5개씬 띄우긴 하지만 막빠지쪽으로 가면 5개 없을수도 있으니 마지막 버튼 값을 계산해줘야함
end와 totalPage중에 더 작은 것이 마지막 버튼 값이 된다.
- start값과 realEnd값을 바탕으로 띄워줘야하는 버튼들을 보여준다.
export const getTotalPage = (totalCount) => {
return Math.ceil(totalCount / 10);
};
export const getRange = (currentPage, totalPage) => {
const start = Math.floor((currentPage - 1) / 5) * 5 + 1;
const end = start + 5 - 1;
const realEnd = Math.min(end, totalPage);
return Array.from({ length: realEnd - start + 1 }, (_, i) => i + start);
};
발단
프로젝트 구조를 설정해나가면서 path alias를 세팅해주는게 좋겠다고 판단했다.
vite로 빌드한 프로젝트는 이걸 설정해준게 처음이라서 검색하면서 설정했다.
Path Alias 설정하기
vite.config.js에 alias를 설정해주면 된다.
import { defineConfig } from "vite";
import react, { reactCompilerPreset } from "@vitejs/plugin-react";
import babel from "@rolldown/plugin-babel";
import path from "path";
const __dirname = path.resolve();
export default defineConfig({
plugins: [react(), babel({ presets: [reactCompilerPreset()] })],
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
"@assets": path.resolve(__dirname, "src/assets"),
"@components": path.resolve(__dirname, "src/components"),
"@hooks": path.resolve(__dirname, "src/hooks"),
"@layouts": path.resolve(__dirname, "src/layouts"),
"@pages": path.resolve(__dirname, "src/pages"),
"@utils": path.resolve(__dirname, "src/utils"),
},
},
});
주요 포인트에 index.js를 세팅해준다.
export { default as Footer } from "./Footer/Footer";
export { default as Header } from "./Header/Header";
코드 구조
페이지에 여러 library랑 effect랑 state를 사용하다 보니까
코드가 어지러워 보였다.
그래서 코드 구조를 추천받아봤다.
hook -> state -> react query -> derived data (가공된 데이터) -> effect -> render
export default function MarketPage() {
/** 1️⃣ hooks */
const size = useResponsiveWidth();
/** 2️⃣ state */
const [token, setToken] = useState(null);
const [input, setInput] = useState("");
const [keyword, setKeyword] = useState("");
const [selected, setSelected] = useState(constant[0]);
const [page, setPage] = useState(1);
/** 3️⃣ server state (react-query) */
const { data: products = { list: [] } } = useQuery({
queryKey: ["products", token, page, selected.type, keyword],
queryFn: () => getProduct({ page, orderBy: selected.type, keyword }),
keepPreviousData: true,
});
const { data: bestRaw = [] } = useQuery({
queryKey: ["best", token],
queryFn: async () => {
const res = await getProduct({
page,
orderBy: "favorite",
});
return res?.list;
},
});
/** 4️⃣ derived data (가공된 데이터) */
const bestCountMap = {
mobile: 1,
tablet: 2,
desktop: 4,
};
const best = bestRaw.slice(0, bestCountMap[size]);
/** 5️⃣ effects */
useEffect(() => {
const signInPost = async () => {
const user = await signIn("example@email.com", "password");
localStorage.setItem("accessToken", user.accessToken);
setToken(user.accessToken);
};
signInPost();
}, []);
/** 6️⃣ render */
return (
<div className={styles.content}>
{/* UI */}
</div>
);
}
발단
Header / Main / Footer 구조를 자주 사용할 것 같아서
컴포넌트로 따로 정리해두는 것이 좋을 것 같았다.
그래서 layout 폴더를 만들고 안에 MainLayout.jsx를 생성했다.
Footer이 무조건 밑에 있는 Layout 구조


- layout을 min-height를 100vh로 해서
최소100vh, 아니면 더 커질 수 있도록 했다
그리고 display를 flex로 둬서 main이 flex:1해서 나머지 공간을 꽉 채울 수 있게했다.
import React from "react";
import { Header, Footer } from "@/components";
import styles from "./MainLayout.module.css";
export default function MainLayout({ children }) {
return (
<div className={styles.layout}>
<Header />
<main className={styles.main}>{children}</main>
<Footer />
</div>
);
}
.layout {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.main {
flex: 1;
}
발단
dropdown의 메뉴 사이사이에 줄 넣어주려고 하니까
어떻게 가장 깔끔하게 하는지 궁금했다.
반복되는 Element 사이사이에만 border 주기
.menu + .menu {
border-top: 1px solid var(--cool-gray-200);
}
+: Adjacent Sibling Selector
.menu 바로 뒤에 오는 .menu만 선택
아니면
.menu:not(:first-child) {
border-top: 1px solid var(--cool-gray-200);
}
발단
ProductCard가 Item 그룹의 타이틀과 아이템들을 디스플레이하는 컴포넌트인데
부모에서 주는 column props를 가지고 한줄에 아이템 몇개 들어가는지 조절하고 싶었다.
그러면 React에서 CSS로 값을 전달해야하는데 어떻게 하는지 궁금했다.
React에서 CSS 파일로 데이터 전달하기
css variable 작성하는 방법을 알것이다.
:root { --css-variablename: value}방식이다.
그걸 동일하게 JSX 파일에 하면 된다.
<div className={styles.list} style={{ "--column-count": column }}></div>
CSS파일에서 이 변수를 사용하면 된다.
.list {
display: grid;
grid-template-columns: repeat(var(--column-count), 1fr);
gap: 2.4rem;
}
이러면 grid안의 element들이 한줄에 모두 같은 width로 column변수값 개수로 존재하게 된다.
화면마다 column 개수 다르게 하기
이런식으로 분기마다 column 개수를 어떻게 할지에 대한 템플릿을 객체로 넘겨주면 되는것 같다.
//사용 방식
<ProductCardList
title="베스트 상품"
column={{
desktop: 4,
tablet: 2,
mobile: 1,
}}
data={best}
isPending={isBestPending}
/>;
//해결 방식
<div
className={styles.list}
style={{
"--col-desktop": column?.desktop,
"--col-tablet": column?.tablet,
"--col-mobile": column?.mobile,
}}
>
{data.map((d) =>
isPending ? <ItemSkeleton /> : <Item data={d} key={d.id} />,
)}
</div>;
React에서 넘겨받은 데이터는 css에서 아래의 방식으로 사용한다.
.list {
max-width: 100vw;
display: grid;
grid-template-columns: repeat(var(--col-desktop), 1fr);
gap: 2.4rem;
}
/* Tablet */
@media (max-width: 1280px) {
.list {
grid-template-columns: repeat(var(--col-tablet), 1fr);
}
}
반응형 디자인
피그마에서 디자인을 보고 있으면 의문점이 하나 생긴다
1. 화면 크기에 따라서 보여주는 데이터 개수가 달라진다.
2. 화면 크기에 따라서 툴탭에 요소들 순서가 달라진다 (파란 버튼이랑 input창이랑 순서가 달라짐)
실무에서는 이런거 어떻게 처리하는지 알아내기 위해 강사님께 조문을 구하러 갔다

1. 화면 크기에 따라 데이터 개수 바꾸기
이거는 비즈니스 로직이라서 Hook을 만들만 하다.
하지만 훅을 만들면 오로지 API에 요청하는 데이터 개수 바꾸는데에 사용해야하지 -> 오로지 데이터를 위해서만 사용
스타일 바꾸는데에 사용하면 절대 안된다. (코드 어지러워짐)
1) 첫번 조언: 이벤트리스너 - resize 이벤트로 커스텀 훅 만들기
import React, { useState, useEffect } from "react";
export default function useResponsiveWidth() {
const [width, setWidth] = useState(window.innerWidth);
useEffect(() => {
const handleResize = () => {
setWidth(window.innerWidth);
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
if (width > 1280) return "desktop";
if (width > 720) return "tablet";
else return "mobile";
}
나는 이거 잘 작동한다고 생각했는데, 강사님이 나중에 찾아와서 이 방법은 나중에 문제가 생긴다고 하셨다
resize 이벤트에 대해서 고민하고 실험해보라고 이런 대답을 줬는거라고 한다.
문제는 바로 1px씩 움직일때마다 이벤트가 발생한다는 것이다.
그래서 예전에는 debouncing 방식을 사용해서
"setTimeout과 clearTimeout을 사용해서 이벤트가 멈춘 뒤 N ms 후에 한번만 실행"을 했었다
useEffect(() => {
let timer;
const handleResize = () => {
clearTimeout(timer);
timer = setTimeout(() => {
setWidth(window.innerWidth);
}, 300);
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
✔ 장점
- 이벤트 폭주 방지
❌ 단점
- 딜레이 있음 (즉각 반응 아님)
- 여전히 resize 자체는 계속 발생 중
그래서 요새는 matchMedia방식을 사용한다고 한다.
이거는 "필셀 변화가 아니라 조건 변화"만 감지하는 것이라,
특정 css 조건을 만족할때만 이벤트가 발동한다.
window.matchMedia("(max-width: 768px)")
그걸 React스럽게 감싼것이 바로 useMediaQuery다.
내부적으로 matchMedia를 사용하고 있다.
그래서 나중에 말씀하시기로는 그냥 useMediaQuery 사용하는것도 좋은 방향이라고 한다.
import { useMediaQuery } from "react-responsive";
const isTablet = useMediaQuery({ maxWidth: 768 });
결국에는 resize로 만든 훅을 useMediaQuery를 사용하도록 바꿨다.
한곳에 breakpoint를 정의할 수 있으니 꽤나 편했다.
import { useMediaQuery } from "react-responsive";
export default function useResponsiveWidth() {
const isMobile = useMediaQuery({ maxWidth: 720 });
const isTablet = useMediaQuery({ minWidth: 721, maxWidth: 1280 });
const isDesktop = useMediaQuery({ minWidth: 1281 });
if (isMobile) return "mobile";
if (isTablet) return "tablet";
return "desktop";
}
2. 화면 크기에 따라 컴포넌트 순서 바꾸기
위의 훅을 사용했다.
const size = useResponsiveWidth();
{
!(size==="mobile") ? (
<>
<Input/>
<Dropdown/>
<Button>상품 등록하기</Button>
</>
) : (
<>
<Button>상품 등록하기</Button>
<Input/>
<Dropdown/>
</>
);
}
<공부하며 참고해야할 코드>
https://github.com/rjc1704/responsive-design-example.git
grid와 반응형 query방식 - advanced 브랜치 보기
화면 깜빡거림
pagination으로 새 이미지 데이터를 가져오거나 새로고침하면
자꾸만 화면이 깜빡깜빡 거렸다
1) skeleton 작성해보기
원래 Item과 동일하게 ItemSkeleton을 만들어봤다.
export default function ItemSkeleton() {
return (
<div className={styles.container}>
<Shimmer className={styles.img} />
<div className={styles.content}>
<div className={styles.title}></div>
<div className={styles.price}></div>
<div className={styles.meta}>
<img src={icHeartEmpty} />
<div className={styles.like}></div>
</div>
</div>
</div>
);
}
보면 안에 Shimmer이라는 컴포넌트가 있을텐데,
그건 안에 있는 children을 shimmer효과 있게 해주는 컴포넌트이다.
import styles from "./Shimmer.module.css";
export default function Shimmer({ children, className }) {
return <div className={`${styles.skeleton} ${className}`}>{children}</div>;
}
.skeleton {
background: linear-gradient(
90deg,
var(--secondary-200) 25%,
var(--secondary-100) 50%,
var(--secondary-200) 75%
);
background-size: 200% 100%;
animation: shimmer 1.4s infinite linear;
}
@keyframes shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
근데 문제가 일단 skeleton이 실제 item보다 더 커서 자꾸 layout이 왔다갔다 하기도 했고
나중에 강사님이 말씀하시기로는
tanstack query에 옵션을 잘 사용하면 skeleton 쓸일도 없다고 했다.
엥? tanstack query 사용했는데 무슨 옵션이 있었을까?
2) Tanstack Query의 placeholderData 옵션 사용하기
chatGPT로 조사했을때는 keepPreviousData로 나왔었는데
강사님께 여쭤보니까 최신 버젼에서는 placeholderData를 쓴다고 하셨다.
또한 useQuery에 isLoading을 썼다가 안되길래
또 chatGPT로 조사하니까 isFetching쓰라해서 썼는데 그것도 안되는 기분이었다.
또 강사님께 여쭤보니까 isPending이더라... (예전에는 isLoading)
진짜 chatGPT는 어떤 방향이 좋을지만 물어보고 사용법은 꾸역꾸역 어떻게든 공식문서를 읽어야겠다고 느꼈다.
const { data: products = { list: [] }, isPending: isProductsPending } =
useQuery({
queryKey: ["products", token, page, selected, keyword, size],
queryFn: () =>
getProduct({
page,
pageSize: size === "mobile" ? 4 : size === "tablet" ? 6 : 10,
orderBy: selected.type,
keyword,
}),
placeholderData: keepPreviousData,
});
'웹프로젝트 > 코드잇' 카테고리의 다른 글
| [스프린트] 사용한 기술 (0) | 2026.05.15 |
|---|---|
| MongoDB 연결 중 ECONNREFUSED 에러 (0) | 2026.04.30 |
| [Sprint 3] API 호출 공통 래퍼 (Axios의 interceptor / Fetch Wrapper) (0) | 2026.04.15 |
| [Sprint 3] npm script로 html 열기 (0) | 2026.04.15 |
| [코드리뷰] 불필요한 태그 정리 / 코드 분리 / 시멘틱 태그 / 반응형 구현 (0) | 2026.04.10 |
