카테고리 없음

[항해99 Front-End 4기 5주차]

haries 2025. 1. 28. 10:26

디자인 패턴과 함수형 프로그래밍

[ 주제 ]

클린코드와 리팩토링 두 번째 발제는 디자인 패턴과 함수형 프로그래밍이었습니다.

[ 과제 회고 ] 

1. 기본 과제

장바구니(CartPage.tsx)와 관리자 페이지(AdminPage.tsx)로 되어있는 간단한 상품 구매 페이지에 대한 리팩토링을 진행하는 과제입니다. 이 두 파일에는 비즈니스로직과 UI로직이 함께 존재하는 코드입니다. App.tsx를 포함하는 이 코드는 src/origin 폴더 안에 있는데, 이를 custom hook과 util 함수로 분리하여 src/refactoring 폴더 안에 App.tsx까지 리팩토링하는 과제입니다. 

나의 방향

저는 src/origin에 있는 단순한 구조를 src/refactoring 구조에 components, constants, hooks, models로 구분하였습니다.

 

1. components

여기에는 UI 컴포넌트가 들어가 있으며 UI 컴포넌트에 직접적으로 필요한 비즈니스 로직이 함께 있습니다.

예를 들어 useAdminPage.ts에는 상품 가격 수정, 상품 이름 수정 같은 AdminPage에서 필요한 비즈니스 로직을 담았고, useCartPage.ts에서는 최대 할인율을 자동으로 계산해서 보여주는 함수 등을 담았습니다.

 

2. constants

상품 리스트와 쿠폰 리스트 같은 초기값에 대한 정보를 담았습니다. 원래는 백엔드에서 가져와야하지만 백엔드가 없기 때문에 이곳에 초기값을 만들어 놨습니다.

 

3. hooks

여기에는 기본적인 상품, 쿠폰, 장바구니를 처리할 수 있는 비즈니스 로직을 담았습니다. 원래는 components에 있는 useAdminPage.ts와 useCartPage.ts과 여기에 있는 hooks에 있는 함수의 차이를 두려고 했습니다. hook에는 기본적인 상품 추가, 상품 수정, 쿠폰 추가, 장바구니 제거 등 정말 기본적인 비즈니스 로직을 담는 hook으로 관리하고 components에 있는 use함수들은 여기에 있는 hook을 사용하여 UI환경에 맞는 좀 더 복잡한 비즈니스 로직을 만드려고 했습니다. 예를 들어 쿠폰을 추가의 역할만 하는 함수는 hook/useProduct.ts에 만들고, 실제 UI에서 쿠폰을 추가하는 함수에는 newCoupon이라는 변수를 초기화 하는 로직도 추가(side effect)로필요하기 때문에 다음과 같이 작성했습니다.

  // src/refactoring/hook/useCoupon.ts
export const useCoupons = (initialCoupons: Coupon[]) => {
  const [coupons, setCoupons] = useState<Coupon[]>(initialCoupons);
  // 쿠폰 추가 함수
  const addCoupon = (newCoupon) => {
    setCoupons(prev => {
      return [
        ...prev,
        newCoupon
      ]
    })
  }
  
  return {
    coupons,
    addCoupon
  };
};
  
// src/refactoring/AdminPage/useAdminPage.ts
// 부수 효과까지 담은 쿠폰 추가함수
const handleAddCoupon = () => {
	onCouponAdd(newCoupon);
    setNewCoupon({
      name: '',
      code: '',
      discountType: 'percentage',
      discountValue: 0
    });
};

4. utils

utils에는 입력 값을 받아 결과 값을 도출하는 순수 계산 함수(컴포넌트 함수)를 작성하고자 하였습니다. 예를 들어 

// 할인 없이 총액 계산
export const calculateItemTotal = (item: CartItem) => {
  return item.quantity * item.product.price * (1 - getMaxApplicableDiscount(item));
};

// 할인율 계산해주는 함수
export const getMaxApplicableDiscount = (item: CartItem) => {
  const qty = item.quantity;
  const discounts = item.product.discounts;
  let discountRate = 0;
  for (let i = 0; i < discounts.length; i++) {
    if (qty >= discounts[i].quantity) {
      discountRate = discounts[i].rate;
    } else {
      break;
    }
  }
  return discountRate;
};

처럼 장바구니에 담은 총액을 계산하는 함수나, 할인률을 계산해주는 함수를 작성하였습니다.

2. 심화 과제

심화과제에서는 추가적으로 3개의 순수함수와 3개의 hook함수를 만들고 직접 테스트 코드를 작성하는 과제였습니다.

[순수함수] 

- 할인 정보를 알려주는 util 함수

export const discountInfo = (discount : Discount) => {
  return `${discount.quantity}개 이상 구매 시 ${discount.rate * 100}% 할인`
}

 

- 상품 검색 시 debounce 활용

직접 아래와 같이 debounce 함수를 만들어서 상품을 검색할 때 일정 시간 동안 마지막으로 입력 값까지의 입력 값으로 검색하는 로직을 만었습니다.

// debounce 함수
export const debounceCallback = (callback, delay) => {
  const [timeoutId, setTimeoutId] = useState(null);
  
  
  const debouncedCallback = useCallback((...args) => {
    // 이전 타이머가 있다면 제거
    if (timeoutId) {
      clearTimeout(timeoutId);
    }
    
    // 새로운 타이머 설정
    const newTimeoutId = setTimeout(() => {
      callback(...args);
    }, delay);
    
    setTimeoutId(newTimeoutId);
  }, [callback, delay, timeoutId]);
  
  // 컴포넌트 언마운트 시 타이머 정리
  useEffect(() => {
    return () => {
      if (timeoutId) {
        clearTimeout(timeoutId);
      }
    };
  }, [timeoutId]);
  
  return debouncedCallback;
}

// 검색을 할 때 debounce 처리를 도와주는 함수
const searchDebounce = debounceCallback((value) => {
	setSearchQuery(value)
}, 300);

 

- 상품 생성 및 수정을 할 때 입력 값에 대한 validation 함수를 만들었습니다.


// 상품 등록 시 값의 타입과 범위를 지정한 validation 함수입니다.
export const validationProductData = (value:unknown, key: keyof Product) : ValidationResult => {
  switch (key) {
    case "id":
      if (typeof value !== "string") {
        return {isValue : false, error : "아이디는 문자열이어야 합니다."}
      }
      if (value.length === 0) {
        return {isValid : false, error: "아이디는 빈 값일 수 없습니다."};
      }
      return {isValid : true}
    
    case 'name':
      if (typeof value !== 'string') {
        return { isValid: false, error: '이름은 문자열이어야 합니다' };
      }
      if (value.length === 0) {
        return { isValid: false, error: '이름은 빈 값일 수 없습니다.' };
      }
      return { isValid: true };
      
 ......

 

[Hook]

- CartPage.tsx에서 사용되는 함수를 모아놓은 useCartPage.ts를 개발하였습니다.

- 담은 상품을 새로고침을 해도 기억할 수 있도록 sessionStorage를 활용한 코드입니다.

import {useEffect, useState} from "react";
import {CartItem, Product} from "../../types.ts";

// 장바구니에 담은 데이터를 sessionStorage에 담는 훅
// 원래 useCart.ts를 대체하려 했으나, 대체했더니 기본 과제를 통과하지 못하는 걸로 나와서(useSessionStorage를 고려하진 않고 짜여진 테스트 코드여서 그런 것 같습니다)
// 심화과제 테스트에서만 useSessionStorage으로 테스트하도록 작업하였습니다.
export const useSessionStorage = () => {
  // 장바구니에 있는 물건들
  const [cart, setCart] = useState<CartItem[]>(() => {
    try {
      const savedCart = sessionStorage.getItem("cart");
      return savedCart ? JSON.parse(savedCart) : [];
    } catch (error) {
      console.error("세션스토리지에서 데이터를 불러오지 못했습니다", error);
      return [];
    }
  });
  
  // 장바구니에 물건 추가
  
  const addToCart = (product: Product) => {
    const remainingStock = getRemainingStock(product);
    let data = [];
    setCart(prevCart => {
      const existingItem = prevCart.find(item => item.product.id === product.id);
      if (existingItem) {
        return prevCart.map(item =>
          item.product.id === product.id
            ? { ...item, quantity: Math.min(item.quantity + 1, product.stock) }
            : item
        );
      }
      data = [...prevCart, { product, quantity: 1 }];
      return data
    });
    sessionStorage.setItem("cart", JSON.stringify(data));
  };
  
  // 장바구니에 물건 제거
  const removeFromCart = (productId: string) => {
    let data = [];
    setCart(prevCart => {
      data = prevCart.filter(item => item.product.id !== productId)
      return data;
    });
    sessionStorage.setItem("cart", JSON.stringify(data));
  };
  
  // 남아있는 장바구니 물건 목록
  const getRemainingStock = (product: Product) => {
    const cartItem = cart.find((item : CartItem) => item.product.id === product.id);
    return product.stock - (cartItem?.quantity || 0);
  }
  
  // 장바구니에 담긴 물건 변화가 있으면 sessionStorage에 담습니다.
  useEffect(() => {
    try {
      sessionStorage.setItem("cart", JSON.stringify(cart));
    } catch (error) {
      console.error("Error saving cart to sessionStorage:", error);
    }
  }, [cart]);
  
  return {
    cart,
    setCart,
    addToCart,
    removeFromCart
  };
}

 

- 상품 검색을 처리할 수 있는 hook 함수

export const useSearchProducts = ({products} : useSearchProductsProps) => {
  // 타이핑한 검색 키워드
  const [searchQuery, setSearchQuery] = useState<string>('');
  
  // 타이핑한 키워드에 따라 나타난 검색 결과를 담고있는 변수
  const filteredProducts = useMemo(() => {
    const query = searchQuery.toLowerCase().trim();
    if (!query) return products;
    
    return products.filter(product => {
      const name = product.name.toLowerCase();
      const price = product.price.toString();
      
      // 상품명 또는 가격으로 검색
      return name.includes(query) || price.includes(query);
    });
  }, [searchQuery, products])
  
  // 검색을 할 때 debounce 처리를 도와주는 함수
  const searchDebounce = debounceCallback((value) => {
    setSearchQuery(value)
  }, 300);
  
  // 검색 타이핑을 할 때 onChange를 받을 함수
  const handleSearch = (event : ChangeEvent<HTMLInputElement>) : void => {
    const value = event.target.value;
    searchDebounce(value)
    
  }
  
  return {
    searchQuery,
    filteredProducts,
    handleSearch
  }
}

 

이렇게 총 6개의 순수함수와 hook을 만들고 테스트하는 과정을 진행하며 과제를 마무리하였습니다.

Best Practice 방향