[TIL]20250423

김민석·2025년 4월 23일
post-thumbnail

오늘목표

  • 6시30분 기상 후 운동(O)
  • 수업 전 약간의 공부(O)
  • 수업 내용 모르는 것 정리(O)
  • 저녁에 개인공부(O)

공부내용

Header 기능 요약

  const [isOn, setIsOn] = useState(false)
  const location = useLocation()

  const addClassOn = () => {
    setIsOn(!isOn)
  }

  useEffect(() => {
    setIsOn(false)
  }, [location.pathname])

  const handleResize = throttle(() => {
    if (window.innerWidth > 1100) {
      setIsOn(false)
    }
  }, 1000)

  useEffect(() => {
    window.addEventListener('resize', handleResize)
    return () => {
      window.removeEventListener('resize', handleResize)
    }
  }, [handleResize])
  • 버튼 클릭 시 토글:
    addClassOn 함수는 버튼 클릭 시 isOn 상태를 토글하여 네비게이션 바를 열고 닫습니다.

  • 페이지 이동 시 닫기:
    useEffect를 통해 location.pathname이 변경되면(즉, 페이지가 이동되면) isOn을 false로 설정하여 네비게이션 바를 자동으로 닫습니다.

  • 화면 크기 조절 시 닫기 (1100px 초과):
    브라우저의 너비가 1100px보다 클 경우 isOn을 false로 설정하여 네비게이션 바를 닫습니다. 이 동작은 resize 이벤트에 등록되어 있고 throttle로 1초에 한 번만 실행되도록 제한되어 있습니다. 이는 이벤트가 과도하게 호출되는 것을 방지하기 위함입니다.

const CustomNavLink = ({ to, label }) => (
  <NavLink className={({ isActive }) => (isActive ? `${css.active}` : '')} to={to}>
    {label}
  </NavLink>
)
const CustomIconLink = ({ to, icon }) => (
  <NavLink className={({ isActive }) => (isActive ? `${css.active}` : '')} to={to}>
    <i className={`bi ${icon}`}></i>
  </NavLink>
)

NavLink는 현재 페이지의 경로와 일치할 경우 isActive가 true가 되며, 이를 이용해 해당 링크에 active 클래스를 조건부로 적용하고 있습니다. 이렇게 조건부 스타일링을 통해 현재 페이지에 해당하는 링크에만 스타일을 줄 수 있습니다.

왜 따로 컴포넌트 만들었는가?

NavLink를 사용할 때마다 동일한 로직이 반복되기 때문에 중복되는 코드를 줄이고 재사용성을 높이기 위해 CustomNavLink와 CustomIconLink라는 별도의 컴포넌트를 생성한 것입니다.

Header 레이아웃

 <header className={css.hd}>
      <div className={css.con}>
        <h1 className={css.logo}>
          <Link to={'/'}>
            <Logo />
          </Link>
        </h1>
        <div className={isOn ? `${css.gnb} ${css.on}` : css.gnb}>
          <nav>
            <CustomNavLink to={'/shop'} label={'shop'} />
            <CustomNavLink to={'/about'} label={'about'} />
            <CustomNavLink to={'/blog'} label={'blog'} />
          </nav>
          <div className={css.icon}>
            <CustomIconLink to={'/search'} icon={'bi-search'} />
            <CustomIconLink to={'/mypage'} icon={'bi-person-circle'} />
            <CustomIconLink to={'/cart'} icon={'bi-basket'} />
          </div>
        </div>
        <i className={`${css.ham} bi bi-list`} title="전체메뉴 보기버튼" onClick={addClassOn}></i>
      </div>
    </header>
  • 헤더 고정
    hd 요소는 position: fixed를 사용하여 화면 상단에 항상 고정되도록 설정했습니다.

  • 콘텐츠 영역 반응형 설정
    .con 클래스는 최대 너비를 1200px로 제한하고, width: calc(100% - 4rem)을 사용하여 좌우 여백을 고려한 반응형 레이아웃을 구성했습니다. 이를 통해 화면 크기에 따라 유연하게 변하면서도 정렬된 레이아웃을 유지합니다.

.gnb nav a {
  font-size: var(--fs20);
  position: relative;
}

.gnb nav a::before {
  content: '';
  position: absolute;
  bottom: 0;
  left: 50%;
  transform: translateX(-50%);
  width: 0%;
  height: 2px;
  background-color: var(--dark-colors-accent-dark);
  transition: 0.3s;
}
.gnb nav a.active::before,
.gnb nav a:hover::before {
  width: 100%;
}
  • 네비게이션 링크 스타일링 (UX 향상)
    .gnb nav a에는 position: relative가 적용되어, 가상요소(::before)가 해당 요소를 기준으로 위치를 잡을 수 있도록 했습니다.
    이 가상요소는 처음에 너비가 0%로 설정되어 있다가, 사용자가 링크에 마우스를 올리거나 현재 페이지와 일치해 active 클래스가 적용되면 부드러운 애니메이션과 함께 선이 100%로 확장되어 시각적 피드백을 제공합니다.

  • 중앙 정렬을 위한 left + transform 사용
    left: 50%와 transform: translateX(-50%)을 함께 사용하여 가상요소가 링크의 중앙에 정확히 위치하도록 했습니다.

반응형일때 mediaquery 사용

@media (max-width: 1100px) {
  .gnb {
    display: flex;
    flex-direction: column;
    justify-content: center;
    position: fixed;
    width: 70%;
    height: 100%;
    top: 0;
    right: -100%;
    background-color: white;
    transition: 0.3s;
  }
  .gnb.on {
    right: 0;
  }
  .gnb nav {
    flex-direction: column;
    text-align: center;
    border: 1px solid lightgray;
    width: 70%;
  }
  h1 {
    position: relative;
    z-index: 10;
  }
  .ham {
    display: block;
    cursor: pointer;
    position: relative;
    z-index: 10;
  }
}
  • .gnb (네비게이션 메뉴)
    display: flex; flex-direction: column; justify-content: center;로 세로 정렬, 중앙 정렬.
    position: fixed;로 화면에 고정, right: -100%으로 화면 밖으로 숨김.
    .on 클래스가 붙으면 right: 0으로 변경되어 메뉴가 transition: 0.3s를 사용해 부드러운 애니메이션 슬라이드로 등장하게끔 하였음.

  • .gnb nav
    내부 네비게이션 링크들도 column 방향으로 정렬.
    중앙 정렬 후 너비는 70%로 설정하여 적절한 영역 확보.

  • .ham (햄버거 버튼)
    max-width: 1100px 이하일 때만 표시됨 (display: block).
    클릭 시 메뉴(.gnb)에 .on 클래스가 토글되는 버튼이다.
    z-index: 10을 줘서 햄버거 버튼이 메뉴 위에 항상 보이도록 하였음.

router.jsx

const MainPage = lazy(() => import('./pages/MainPage'))

// 더 나은 로딩 컴포넌트 정의

const router = createBrowserRouter([
  {
    path: '/',
    element: <Default />,
    errorElement: <NotFound />,
    children: [
      { path: '', element: <MainPage /> },
      { path: '/shop', element: <ShopPage />, loader: shopPageLoader },
      { path: '/about', element: <AboutPage /> },
      { path: '/blog', element: <BlogPage /> },
      { path: '/cart', element: <CartPage />, loader: cartPageLoader },
      {
        path: '/detail/:productId',
        element: <DetailPage />,
        loader: detailPageLoader,
      },
    ],
  },
])
export default router
  • lazy(): React의 코드 스플리팅 기능으로, 해당 페이지에 접근할 때 필요한 시점에 로딩됩니다.
    초기 로딩 시 불필요한 컴포넌트까지 가져오지 않아 성능 향상과 메모리 절약 효과를 가집니다.

  • element: 공통 레이아웃을 설정함. 모든 페이지는 이 컴포넌트를 기반으로 렌더링

  • errorElement: 라우팅 에러(잘못된 경로 등) 발생 시 보여줄 페이지

  • :productId는 URL 파라미터로 동적으로 페이지 렌더링 가능

  • loader 페이지 진입 전 데이터를 미리 불러와 페이지에서 바로 사용할 수 있도록 함

Detail 페이지

loader

export const detailPageLoader = async info => {
  try {
    console.log('loader : ', info)
    // 상품 id에 해당하는 정보
    const product = await getProductById(info.params.productId)

    if (!product) {
      throw new Response('[ERROR] 상품 존재하지 않음 ', {
        status: 404,
      })
    }

    const relatiedProducts = await getProductByCategory(product.category, 10)
    if (!relatiedProducts) {
      throw new Response('[ERROR] 상품 카테고리 에러 ', {
        status: 404,
      })
    }
    const filteredRelatedProducts = relatiedProducts.filter(p => p.id !== product.id)

    return { product, filteredRelatedProducts }
  } catch (error) {
    console.log('[ERROR] 상품 데이터 가져오는 중 오류')
    throw new Response('[ERROR] 상품 데이터 가져오는 중 오류', {
      status: error.status || 500,
    })
  }
}

이 함수는 상품 상세 페이지에 진입할 때 데이터를 미리 불러오는 역할을 합니다.
React Router에서 해당 경로(/detail/:productId)에 접근하면,
자동으로 info 객체를 전달해주고 이 안에 있는 params.productId를 활용합니다.

  • URL에서 상품 ID 추출
    React Router는 loader 함수에 info라는 객체를 전달하는데, 여기서 context,params,request가 있는데 params에서 productId를 꺼낼수 있고 ID를 사용해서 해당 상품의 정보를 Api에서 불러옴.

  • 상품 정보 불러오기
    추출한 ID로 상품 정보를 요청한 다음, 데이터가 존재하지 않으면 에러를 발생시킴.

  • 관련 상품 불러오기
    받아온 상품 정보를 이용하여 그 상품이 속한 카테고리를 이용하여 같은 카테고리의 다른 상품들을 불러옴.이때 limit를 두어 갯수에 제한을 두었음.

  • 현재 상품은 제외하기
    같은 카테고리 상품들 리스트에서 현재 상품과 같은 ID를 가진 항목을 filter함수를 이용하여 제거.

  • 데이터 반환
    이렇게 불러온 상품 정보와 관련 상품 리스트를 함께 반환.
    상세 페이지에서 바로 사용할 수 있어서 페이지가 빠르고 부드럽게 렌더링되기 때문에
    좋은 사용자 경험 제공

api

export const getProductById = async id => {
  try {
    const response = await axios.get(`/api/products/${id}`)
    return response.data
  } catch (error) {
    console.log('ProductById', error)
    throw error
  }
}

export const getProductByCategory = async (category, limit = 10) => {
  try {
    const response = await axios.get(`/api/products/`, {
      params: {
        category,
        _limit: limit,
      },
    })
    return response.data
  } catch (error) {
    console.log('ProductByCategory', error)
    throw error
  }
}

  • getProductById
    이 함수는 전달받은 상품 ID를 기반으로 해당하는 상품 정보를 가져오는 함수이다.
    특정 ID와 일치하는 상품 하나를 찾아서 반환함.

  • getProductByCategory
    이 함수는 상품 카테고리와 limit 를 인자로 받아,
    해당 카테고리에 속한 상품 리스트를 최대 지정된 개수만큼 불러온다.
    Axios 요청에서 params 옵션을 사용해서 쿼리 스트링을 생성함.

DetailPage 로직

 const { product, filteredRelatedProducts } = useLoaderData()
  const [isLoading, setIsLoading] = useState(false)
  const [isModalOpen, setIsModalOpen] = useState(false)
  const [count, setCount] = useState(1)
  useEffect(() => {
    setIsLoading(true)
    if (product && product.id) {
      const timer = setTimeout(() => {
        setIsLoading(false)
      }, 300)
      return () => clearTimeout(timer)
    }
  }, [product])
  if (isLoading) {
    return <div>Loading..</div>
  }
  const addToCart = () => {
    setIsModalOpen(true)
  }
  const increase = () => {
    setCount(prev => prev + 1)
  }

  const decrease = () => {
    setCount(prev => (prev > 1 ? prev - 1 : 1))
  }
  const closeModal = () => {
    setIsModalOpen(false)
  }
  • useLoaderData
    react-router에서 제공하는 훅으로 loader에서 반환된 데이터를 컴포넌트에서 받아올 수 있게 해준다.
    detailPageLoader에서 받아온 product와 filteredRelatedProducts를 사용.

  • 이벤트 함수들
    addToCart: 모달열기
    closeModal: 모달 닫기
    increase: 상품 수량 1 증가
    decrease: 상품 수량 1 감소 (단, 1 미만으로는 줄어들지 않게 제한)

  • setCount(prev => prev + 1) 을 사용하는 이유
    React의 비동기 상태 업데이트 특성과 불변성 유지 때문
    이전 상태(prev)를 기반으로 새로운 값을 계산하는 방식은
    여러 상태 업데이트가 동시에 일어나도 안정적으로 동작하게 도와줌. 특히 객체나 배열을 다룰 때는 불변성을 지키는 방식이 더욱 중요하다.

DetailTabInfo

import React, { useState } from 'react'
import styles from './DetailTabInfo.module.css'
const DetailTabInfo = () => {
  const [activeTab, setActiveTab] = useState(0)
  const tabTitle = ['Descript', 'Additional', 'Review']
  return (
    <>
      <div className={styles.tabBtn}>
        {tabTitle.map((title, index) => (
          <button
            className={`${activeTab === index && styles.active}`}
            onClick={() => setActiveTab(index)}
            key={index}
          >
            {title}
          </button>
        ))}
      </div>
      <div className={`${styles.tabContent} ${activeTab === 0 && styles.active}`}>
        <h3>제목</h3>
        <p>내용</p>
        <p>내용</p>
        <p>내용</p>
      </div>
      <div className={`${styles.tabContent} ${activeTab === 1 && styles.active}`}>
        <h3>제목2</h3>
        <p>내용</p>
        <p>내용</p>
        <p>내용</p>
      </div>
      <div className={`${styles.tabContent} ${activeTab === 2 && styles.active}`}>
        <h3>제목3</h3>
        <p>내용</p>
        <p>내용</p>
        <p>내용</p>
      </div>
    </>
  )
}

export default React.memo(DetailTabInfo)

상품 상세 페이지에서 탭(Tab) 형식으로 정보를 보여주는 UI 컴포넌트 탭 버튼을 클릭하면 각 탭에 맞는 내용을 보여줌
해당 탭이 활성화되어 있음을 시각적으로 표시해줌

  • 상태 관리
    activeTab 상태를 사용해 현재 활성화된 탭의 index를 저장
    탭 버튼 tabTitle 배열을 바탕으로 버튼을 동적으로 생성
    클릭 시 setActiveTab(index)로 해당 탭을 활성화
    현재 활성 탭이면 styles.active 클래스 추가
  • 탭 콘텐츠
    탭마다 하나의 div로 구성되어 있고 따로 컴포넌트를 만들어도 된다. activeTab과 index가 같을 때만 styles.active 클래스를 주어 해당 콘텐츠가 보이도록 CSS에서 제어

  • React.memo
    React.memo로 감싸 불필요한 리렌더링 방지

.tabContent {
  display: none;
  opacity: 0;
  transition: opacity 0.3s ease-in-out;
}
.tabContent.active {
  opacity: 1;
  display: block;
  animation: fadeIn 0.5s ease-in-out;
}

@keyframes fadeIn {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}
  • TabContent는 기본적으로 사용자에게 보이지 않도록 display: none과 opacity: 0으로 설정되어 있다. 이때 opacity 값이 변경될 경우 부드럽게 전환되도록 transition 속성을 사용하여 자연스러운 변화가 이루어지게 했다.

  • 사용자가 탭을 선택하면 해당 콘텐츠에 active 클래스가 추가되고, 이 클래스가 적용되면 display 속성은 block으로 바뀌어 화면에 표시되며 opacity는 1로 변경되어 콘텐츠가 보이게 된다.

  • animation 속성을 통해 fadeIn이라는 키프레임 애니메이션을 적용하여 콘텐츠가 점차 나타나는 부드러운 시각 효과를 추가했다. 이로써 탭 전환 시 자연스럽고 부드럽게 전환되도록 시각적 완성도를 높임!

import React, { useEffect, useState } from 'react'
import styles from './Modal.module.css'
import { formmatCurrency } from '@/utils/features'
import { useNavigate } from 'react-router-dom'
import { addCartData } from '@/api/cartApi'
const Modal = ({ product, count, onClose }) => {
  const [isActive, setIsActive] = useState(false)
  const navigate = useNavigate()
  // 마운트 직 후 active 클래스를 추가해야함 그래야 애니메이션 적용(?)
  useEffect(() => {
    const timer = setTimeout(() => {
      setIsActive(true)
      document.body.style.overflow = 'hidden'
    }, 1000)

    return () => {
      clearTimeout(timer)
      document.body.style.overflow = 'auto'
    }
  }, [])
  const handleClose = () => {
    setIsActive(false)
    setTimeout(onClose, 300)
  }
  const handleAddToCart = async () => {
    try {
      const cartItem = {
        id: product.id,
        title: product.title,
        img: product.img,
        price: product.price,
        category: product.category,
        discount: product.discount,
        count: count,
      }
      await addCartData(cartItem)
      handleClose()
      navigate('/cart')
    } catch (error) {
      console.log(error)
    }
  }
}

export default Modal
  • Props
    product, count, onClose를 props로 받는다.
    이 정보를 활용해 유저가 장바구니에 담기 전 상품 정보를 다시 한 번 확인을 도와줌.

  • 모달 열기 애니메이션 (isActive)
    useEffect를 사용해 컴포넌트가 마운트된 이후 setIsActive(true)를 1초 뒤에 실행한다.
    이렇게 딜레이를 둔 이유는 모달이 화면에 먼저 렌더링된 뒤에 active 클래스가 추가되어야 CSS 애니메이션이 적용되기 때문이다.
    동시에 document.body.style.overflow = 'hidden' 으로 설정하여 모달이 열릴 때 스크롤이 동작하지 않도록 처리함. 언마운트 시에는 overflow를 다시 auto로 바꿔줌.

  • 모달 닫기 함수 handleClose
    setIsActive(false)로 active 클래스를 제거해 모달을 사라지게 함.
    이때 바로 onClose()를 실행하면 애니메이션이 보이지 않으므로, 0.3초 딜레이를 주어 애니메이션이 끝난 뒤 모달이 완전히 제거되도록 함.

  • 장바구니에 담기 handleAddToCart
    product 정보와 count를 기반으로 cartItem 객체를 새로 생성하고
    addCartData(cartItem) API 요청을 보내 장바구니에 데이터 추가해줌
    요청이 성공하면 모달을 닫고 카트 페이지로 이동시킴.

.modal {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.141);
  z-index: 999999;
  display: flex;
  justify-content: center;
  align-items: center;
  backdrop-filter: blur(2px);
}

.container {
  background-color: white;
  transform: translateY(-200px);
  opacity: 0;
  transition:
    transform 0.5s,
    opacity 0.2s;
  padding: var(--fs33);
  border-radius: var(--fs16);
  width: 30%;
  box-shadow: 0 0 10px rgba(0, 0, 0, 0.508);
}

.modal.active .container {
  transform: translateY(0px);
  opacity: 1;
}

여기서 modal은 뒷 배경이다 뒷 배경은 position을 fixed로 하여 전체 화면을 채워주었고
backdrop-filter : blur()를 이용하며 blur처리하여 container(모달)에 집중 되도록 하였습니다.
모달의 첫 상태는 가운데 보다 조금 더 위로 위치하였고 active 클래스가 생긴다면 떨어지는 animation 효과를 주기위해 transition을 설정하였습니다.
active 클래스가 생성된다면 transform을 변경하였습니다.

addCart

export const addCartData = async cartItem => {
  try {
    const cart = await getCartData()
    const existingItem = cart.find(item => item.id === cartItem.id)
    if (existingItem) {
      const updateItem = {
        ...existingItem,
        count: existingItem.count + cartItem.count,
      }
      const response = await axios.put(`/api/cart/${existingItem.id}`, updateItem)
      return response.data
    } else {
      const response = await axios.post(`/api/cart/`, cartItem)
      return response.data
    }
  } catch (error) {
    console.log('getCartData', error)
    throw error
  }
}
  • getCartData() 호출
    전체 장바구니 데이터를 불러온다.

  • 같은 상품이 이미 있는지 확인
    find()를 사용해 cart 내에 cartItem.id와 같은 상품이 있는지 검사한다.

  • 상품이 이미 있다면 → count만 증가 불변성 지키기

    • 기존 existingItem의 count에 새로 담으려는 cartItem.count를 더한다.
    • 변경된 데이터를 PUT 요청으로 서버에 업데이트한다.
  • 상품이 없다면 → 새로 추가
    POST 요청으로 새로운 상품을 장바구니에 추가한다.

  • 에러 처리
    중간에 에러가 발생할 경우 로그를 출력하고 에러를 다시 throw 한다.

profile
나만의 기록장

0개의 댓글