
🚨 이것이 정답이 아닌 주니어 개발자의 의견임을 미리 알려드립니다..
자 이제 보험도 들었으니, 오늘은 React로 모델을 설계하는 내용에 대해 포스팅을 해보려고 합니다. 아직 3년차 주니어인 만큼 정답이라 생각하지 않지만, 이런 방식도 있구나 정도로만 이해해주시면 좋을 것 같습니다.
모델 설계를 왜 해야하지?
우리는 모델 설계를 왜 해야할까요? 강력하고 유지 관리 가능한 웹 애플리케이션을 구축하는 데 많은 도움이 되기 때문입니다. 모델 설계의 장점은 다음과 같습니다.
- 상태 관리: 잘 구조화된 모델은 애플리케이션 상태를 예측 및 관리 가능하게 만들어 앱의 안정성을 향상시킵니다.
- 재사용성: 모델 내에 기능을 캡슐화하면 DRY(Don't Repeat Yourself) 원칙을 준수하여 애플리케이션 전반에 걸쳐 코드 재사용성이 향상됩니다.
- 테스트: UI 구성요소에서 비즈니스 로직을 분리하면 테스트 및 디버깅이 단순화되어 독립적이고 효과적인 단위 테스트가 가능해집니다.
- 백엔드 통합: 명확한 모델 설계는 프런트엔드를 백엔드 프로세스에 맞춰 조정하여 보다 원활한 데이터 상호 작용 및 통합을 촉진합니다.
- 확장성: 좋은 모델 디자인은 기능과 사용자 기반의 손쉬운 확장을 지원하므로 주요 리팩터링 없이 애플리케이션이 성장할 수 있습니다.
- 협업: 일관된 모델 설계는 명확한 패턴과 관행을 설정하고 새로운 개발자의 학습 곡선을 줄여 팀 협업을 향상시킵니다.
- 고급 기능: 최적화된 성능과 최소한의 리렌더링을 위해 컨텍스트 및 Hook과 같은 React의 고급 기능을 활용합니다.
위에 내용을 보면 React에서의 모델 설계는 정말 많은 장점이 있다는 것을 알 수 있습니다. 저희는 오늘 여기서
재사용성
, 테스트
, 확장성
에 초점을 맞추어서 포스팅을 진행하려고 합니다.모델을 설계해보자
예제 환경 : 커머스 예제 모델 : 상품, 장바구니
모델을 설계하기에 앞서, 예제의 환경을 커머스로 해두었습니다. (커머스 만큼 가장 직관적인 모델 예제를 찾지 못했습니다..)
우리는 상품이라는 하나의 모델을 가지고, 장바구니에 담는 예제 코드를 작성할 것입니다. 여기서, 장바구니가 할 수 있는 행위를 우선 정의하고자 합니다.
장바구니의 역할
- 상품 추가
- 상품 제거
- 장바구니 초기화
- 장바구니 총 수량
- 장바구니 총 가격
이 이외에도 추가할 수 있는 장바구니의 역할이 있을 수 있지만, 이번 예제에서는 이정도만 적용해보겠습니다.
상품 모델의 경우 별다른 기능이 없으므로 간단하게 하나만 추가해보려고 합니다.
상품의 역할
- number 타입의 가격을 ₩ 원 형태로 변경
역할에 따른 테스트를 작성해보자!
상품 테스트 코드
import Product from '../Product'; import { mockProduct } from '../../server/mock'; describe('Product Model Test', () => { test('가격 변환 테스트', () => { const product = new Product(mockProduct[0]); expect(product.getPriceToKrFormat()).toBe('₩75,000'); }); });
number타입의 75000 을 ‘₩75,000’ 으로 변경해주는 것을 확인하는 간단한 테스트 코드를 작성하였습니다.
장바구니 테스트 코드
import Cart from '../Cart'; import { mockProduct } from '../../server/mock'; import Product from '../Product'; import { Coupon } from '../../service/api/couponApiService'; describe('Cart Model Test', () => { const cart: Cart = Cart.getInstance(); const product1 = new Product(mockProduct[0]); const product2 = new Product(mockProduct[1]); const product3 = new Product(mockProduct[3]); beforeEach(() => { cart.clear(); }); test('상품 추가', () => { cart.addProduct(product1); cart.addProduct(product2); expect(cart.listProducts().length).toBe(2); }); test('같은 상품 추가', () => { cart.addProduct(product1); cart.addProduct(product1); expect(cart.listProducts().length).toBe(1); expect(cart.totalQuantity()).toBe(2); }); test('상품 제거', () => { cart.addProduct(product1); cart.addProduct(product2); cart.removeProduct(product1.item_no); expect(cart.listProducts().length).toBe(1); expect(cart.totalQuantity()).toBe(1); cart.removeProduct(product1.item_no); expect(cart.listProducts().length).toBe(1); }); test('원하는 상품이 존재하는지', () => { cart.addProduct(product2); // product2만 추가합니다. expect(cart.isIncluded(product1.item_no)).toBe(false); expect(cart.isIncluded(product2.item_no)).toBe(true); }); });
간단한 테스트 코드 위주로 작성했습니다. 상품을 추가, 제거 정도만 파악하는 테스트 코드로 되어있습니다.
저는 왜 비즈니스 로직 구현 전에 테스트를 먼저 작성 했을까요?
테스트만 보면 정말 간단하게 구현이 될 것이라 생각합니다. 하지만, 막상 코드를 작성하다 보면 놓치는 부분이 있을 수 있습니다. 예를 들어보겠습니다.
const product1 = { item_no: 1, item_name: '상품', price: 1000, detail_image_url: '' }
위와 같은 상품이 있다고 가정해봅시다. product1 이라는 상품을 저희는 장바구니에 추가했습니다. 로직을 어떻게 구현했는지에 대해 다르긴 하겠지만, 무작정 “추가” 라는 워딩에 맞춰 장바구니에 넣었다고 가정한다면, 그 이후 product1이란 상품을 또 장바구니에 추가할때 item_no 검증없이 추가하는 로직으로 잘못 구현할 수도 있을 수 있습니다. 매우 당연하고, 쉽게 생각할 수 있지만, 실제로 코딩을 하다보면 놓칠 사이드 이펙트가 있기 때문에 비즈니스 로직의 경우 테스트를 먼저 작성하는 것을 좋아합니다.
위와 같은 이유로 저는 테스트 코드를 먼저 작성하는 편입니다. 이제 테스트를 작성했으니 본격적인 모델 구현을 진행해보도록 하겠습니다.
모델 구현!
Product.ts (상품 모델)
import { priceToKrFormat } from '../utils/priceFormat'; export interface ProductType { item_no: number; item_name: string; detail_image_url: string; price: number; score: number; availableCoupon?: boolean; } export default class Product { item_no: number; item_name: string; detail_image_url: string; price: number; score: number; availableCoupon?: boolean; constructor(props: ProductType) { const { item_no, item_name, detail_image_url, score, price, availableCoupon = true, } = props; this.item_no = item_no; this.item_name = item_name; this.detail_image_url = detail_image_url; this.price = price; this.score = score; this.availableCoupon = availableCoupon; } getPriceToKrFormat(price?: number) { return priceToKrFormat(price || this.price); } }
상품은 테스트 코드에 나와있듯이 number 를 ₩원 형태의 string 으로 변환하는 것만 하는 형태로 매우 간단하게 구현이 가능합니다.
Cart.ts (장바구니 모델)
import Product from './Product'; import { Coupon } from '../service/api/couponApiService'; export interface CartProduct { product: Product; quantity: number; } export default class Cart { private static instance: Cart; private products: Map<number, CartProduct>; private constructor() { this.products = new Map(); } public static getInstance(): Cart { if (!Cart.instance) { Cart.instance = new Cart(); } return Cart.instance; } totalQuantity() { return Array.from(this.products.values()).reduce( (acc, cur) => acc + cur.quantity, 0, ); } clear() { this.products = new Map(); } addProduct(product: Product): void { if (!this.products.has(product.item_no) && this.products.size === 3) return; if (this.products.has(product.item_no)) { const existingProduct = this.products.get(product.item_no); if (existingProduct) { existingProduct.quantity += 1; this.products.set(product.item_no, existingProduct); } } else { this.products.set(product.item_no, { product, quantity: 1 }); } } removeProduct(productId: number): void { if (this.products.has(productId)) { const existingProduct = this.products.get(productId); if (existingProduct && existingProduct.quantity > 1) { existingProduct.quantity -= 1; this.products.set(productId, existingProduct); } else { this.products.delete(productId); } } } listProducts(): CartProduct[] { return Array.from(this.products.values()).map( (product) => ({ ...product, product: product.product, }), ); } isIncluded(item_no: number) { return this.products.has(item_no); } getSummary(productIds: number[], coupon: Coupon | null): number { const products = productIds.map((id) => this.products.get(id)); if (products.some((product) => !product)) return 0; let [availableProduct, price] = products.reduce( (acc, cur) => { const { product, quantity } = cur as CartProduct; if (product.availableCoupon) { acc[0] += product.price * quantity; return acc; } acc[1] += product.price * quantity; return acc; }, [0, 0], ); if (coupon?.type === 'rate') { availableProduct = availableProduct - availableProduct / coupon.discountRate; } else if (coupon?.type === 'amount' && !!availableProduct) { availableProduct = availableProduct - coupon.discountAmount; } return Math.floor(availableProduct) + price; } }
이렇게 간단하게 구현이 가능합니다.
addProduct
(상품 추가), removeProduct
(상품 제거) 를 주축으로 로직 구현이 되었습니다. 당연히 테스트도 통과하는 것을 볼 수 있구요.그럼 우린 작성한 이 비즈니스 로직을 실제 view에서 어떻게 사용할 수 있을까요? 여기서 우리는 이 로직을 어떻게 react에서 사용이 가능하게 할 것인지를 고민해야 합니다.
cart class를 이용해서 우리는
listProduct
를 불러올 것입니다. 그때 할 수 있는 수행은 addProduct
, removeProduct
가 될 수 있죠. 하지만 우리가 addProduct
, removeProduct
를 한다고 UI가 리랜더링을 수행할까요? 정답은 “그렇지 않다.” 입니다. 그렇기때문에 우리는 addProduct
, removeProduct
를 수행할때 상태가 변경되었다는 것을 알려야합니다. 이번 프로젝트에서는 useSyncExternalStore라는 react 기본 훅을 사용할 예정입니다.useSyncExternalStore로 외부 cart 구독하기
const snapshot = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?)
useSyncExternalStore
는 기본적으로 이렇게 사용할 수 있습니다. snapshot
을 찍고, subscribe
(구독)하는게 변경되었을때 새로운 snapshot
을 줌과 동시에 리랜더링을 수행하게 도와줄 것입니다.그렇기 위해선 cart에서
listProduct
가 변경되는 시점(addProduct
, removeProduct
)때 새로 update 되었다는 것을 알려주어야 합니다. 따라서, 우리는 위에서 만든 cart모델에 subscribe
하는 로직을 추가할 것입니다.export default class Cart { private static instance: Cart; private products: Map<number, CartProduct>; private constructor() { this.products = new Map(); } // 이전 로직 addProduct(product: Product): void { if (!this.products.has(product.item_no) && this.products.size === 3) return; if (this.products.has(product.item_no)) { const existingProduct = this.products.get(product.item_no); if (existingProduct) { existingProduct.quantity += 1; this.products.set(product.item_no, existingProduct); } } else { this.products.set(product.item_no, { product, quantity: 1 }); } this.notifyUpdate(); } removeProduct(productId: number): void { if (this.products.has(productId)) { const existingProduct = this.products.get(productId); if (existingProduct && existingProduct.quantity > 1) { existingProduct.quantity -= 1; this.products.set(productId, existingProduct); } else { this.products.delete(productId); } } this.notifyUpdate(); } listProducts(): CartProduct[] { return Array.from(this.products.values()).map( (product) => ({ ...product, product: product.product, }), ); } private listeners: Function[] = []; subscribe(listener: Function) { this.listeners.push(listener); } unsubscribe(listener: Function) { this.listeners = this.listeners.filter((l) => l !== listener); } private notifyUpdate() { this.listeners.forEach((listener) => listener()); } }
notifyUpdate()
라는 내부 메소드를 이용해서 update되었다는 것을 알려주고, 우리는 이걸 토대로 cartStore를 구현해보겠습니다.import Cart from '../models/Cart'; import { useSyncExternalStore } from 'react'; import { Coupon } from '../service/api/couponApiService'; const cart = Cart.getInstance(); export const useCartStore = () => { const getSnapshot = () => cart.listProducts(); const subscribe = (callback: () => void) => { cart.subscribe(callback); return () => cart.unsubscribe(callback); }; const products = useSyncExternalStore(subscribe, getSnapshot); return { products, addProduct: cart.addProduct.bind(cart), removeProduct: cart.removeProduct.bind(cart) }; };
cart의 변경된
listProducts
를 받고 addProduct
, removeProduct
를 같이 넘겨주게 됩니다. 이렇게 되면 우리는 수행한 add, remove액션때 화면을 리랜더 시켜줄 수 있습니다.근데 여기서 문제가 있습니다.
listProducts(): CartProduct[] { return Array.from(this.products.values()).map( (product) => ({ ...product, product: product.product, }), ); }
listProdut의 타입은 Array 입니다. 내부에서는 return을 통해 매번 products값을 순회해서 배열로 만들어주고 있습니다. 이 말은 뭐냐 매번 새로운 주소(Ref)의 Array 를 만든다. 는 뜻입니다.
number, string, boolean 의 경우 값을 저장하지만 Object의 경우 주소값을 저장합니다. 따라서 저희가 위에서 만든 useCartStore는 매번 새로운 Array를 받기 때문에 add, remove를 하지 않아도 리랜더링이 일어나게 됩니다. 이런경우 화면에서 터져버리게 됩니다.
이걸 어떻게 해소할 수 있을까요?
add와 remove를 하지 않았을땐, 이전값을 제공하고, 변경점을 체크했다면 그때 바뀐값을 return 해주는 로직을 추가해주면 됩니다.
listProducts(): CartProduct[] { const currentProducts = Array.from(this.products.values()).map( (product) => ({ ...product, product: product.product, }), ); if (!this.lastProductList || this.isProductsChanged(currentProducts)) { this.lastProductList = currentProducts; } return this.lastProductList; } private isProductsChanged(currentProducts: CartProduct[]): boolean { if ( !this.lastProductList || this.lastProductList.length !== currentProducts.length ) { return true; } return !this.lastProductList.every((product, index) => { return ( product.product.item_no === currentProducts[index].product.item_no && product.quantity === currentProducts[index].quantity ); }); }
이렇게 변경되었는지를 체크해서, 변경되었다면 변경된 값을, 변경되지 않았다면 이전의 값을 return 해주면 같은 주소(Ref)를 갖게 되니 무한 리랜더링을 막을 수 있을 것입니다.
이렇게 만들어진 Cart 모델의 비즈니스 로직은 아래와 같습니다.
import Product from './Product'; import { Coupon } from '../service/api/couponApiService'; export interface CartProduct { product: Product; quantity: number; } export default class Cart { private static instance: Cart; private products: Map<number, CartProduct>; private lastProductList: CartProduct[] | null = null; private constructor() { this.products = new Map(); } public static getInstance(): Cart { if (!Cart.instance) { Cart.instance = new Cart(); } return Cart.instance; } totalQuantity() { return Array.from(this.products.values()).reduce( (acc, cur) => acc + cur.quantity, 0, ); } clear() { this.products = new Map(); } addProduct(product: Product): void { if (!this.products.has(product.item_no) && this.products.size === 3) return; if (this.products.has(product.item_no)) { const existingProduct = this.products.get(product.item_no); if (existingProduct) { existingProduct.quantity += 1; this.products.set(product.item_no, existingProduct); } } else { this.products.set(product.item_no, { product, quantity: 1 }); } this.notifyUpdate(); } removeProduct(productId: number): void { if (this.products.has(productId)) { const existingProduct = this.products.get(productId); if (existingProduct && existingProduct.quantity > 1) { existingProduct.quantity -= 1; this.products.set(productId, existingProduct); } else { this.products.delete(productId); } } this.notifyUpdate(); } listProducts(): CartProduct[] { const currentProducts = Array.from(this.products.values()).map( (product) => ({ ...product, product: product.product, }), ); if (!this.lastProductList || this.isProductsChanged(currentProducts)) { this.lastProductList = currentProducts; } return this.lastProductList; } private isProductsChanged(currentProducts: CartProduct[]): boolean { if ( !this.lastProductList || this.lastProductList.length !== currentProducts.length ) { return true; } return !this.lastProductList.every((product, index) => { return ( product.product.item_no === currentProducts[index].product.item_no && product.quantity === currentProducts[index].quantity ); }); } private listeners: Function[] = []; subscribe(listener: Function) { this.listeners.push(listener); } unsubscribe(listener: Function) { this.listeners = this.listeners.filter((l) => l !== listener); } private notifyUpdate() { this.listeners.forEach((listener) => listener()); } isIncluded(item_no: number) { return this.products.has(item_no); } getSummary(productIds: number[], coupon: Coupon | null): number { const products = productIds.map((id) => this.products.get(id)); if (products.some((product) => !product)) return 0; let [availableProduct, price] = products.reduce( (acc, cur) => { const { product, quantity } = cur as CartProduct; if (product.availableCoupon) { acc[0] += product.price * quantity; return acc; } acc[1] += product.price * quantity; return acc; }, [0, 0], ); if (coupon?.type === 'rate') { availableProduct = availableProduct - availableProduct / coupon.discountRate; } else if (coupon?.type === 'amount' && !!availableProduct) { availableProduct = availableProduct - coupon.discountAmount; } return Math.floor(availableProduct) + price; } }
이렇게 만든 아이들을 useCartStore에서 필요한 값들만 따로 추출해서 return해준다면 불필요한 로직을 제거할 수 있습니다. 아래처럼 말이죠.
import Cart from '../models/Cart'; import { useSyncExternalStore } from 'react'; import { Coupon } from '../service/api/couponApiService'; const cart = Cart.getInstance(); export const useCartStore = () => { const getSnapshot = () => cart.listProducts(); const subscribe = (callback: () => void) => { cart.subscribe(callback); return () => cart.unsubscribe(callback); }; const products = useSyncExternalStore(subscribe, getSnapshot); return { products, addProduct: cart.addProduct.bind(cart), removeProduct: cart.removeProduct.bind(cart), isIncluded: (item_no: number) => cart.isIncluded(item_no), size: products.length, getSummary: (ids: number[], coupon: Coupon | null) => cart.getSummary(ids, coupon), }; };
products
, addProduct
, removeProduct
를 제외하고, 상품이 존재하는지의 여부인 isIncluded
, 총 길이 size
, 장바구니 요약인 getSummary
를 추가로 넣어줄 수 있는 것이지요. 간결해진 View 코드
그럼 우리가 위에서 만든 코드는 어떻게 사용할 수 있을까요?
// 상품 카드 컴포넌트 import styled from '@emotion/styled'; import CartBadge from '../cart/cartBadge'; import Product from '../../models/Product'; import { useCartStore } from '../../hooks/useCartStore'; const ProductWrap = styled.li` position: relative; display: list-item; box-sizing: border-box; padding: 0 20px 20px 0; @media (max-width: 1920px) { width: 25%; } @media (max-width: 1550px) { width: 33.33%; } @media (max-width: 1160px) { width: 50%; } `; const ProductImg = styled.img` width: 100%; aspect-ratio: 1 / 1; object-fit: cover; `; const CartIcon = styled.div` position: absolute; top: 0; left: 0; `; interface Props { product: Product; } const ProductCard = ({ product }: Props) => { const { isIncluded, addProduct, removeProduct } = useCartStore(); const isCart = isIncluded(product.item_no); const handleClick = () => { if (isCart) { removeProduct(product.item_no); } else { addProduct(product); } }; return ( <ProductWrap> <CartIcon> <CartBadge isCart={isCart} onClick={handleClick} /> </CartIcon> <ProductImg src={product.detail_image_url} alt={product.item_name} /> <div> <h3>{product.item_name}</h3> <p>{product.getPriceToKrFormat()}</p> </div> </ProductWrap> ); }; export default ProductCard;
위 코드에서 우리는
isIncluded
, addProduct
, removeProduct
만을 따로 받아 매우 간결한 코드로 뷰 로직을 구현하였습니다. 그럼 상품을 받는 곳에서는 어떻게 구현할까요?// 장바구니 리스트 import { useCartStore } from '../../hooks/useCartStore'; import styled from '@emotion/styled'; import CartProductCard from './CartProductCard'; const CartListWrap = styled.div` padding: 0 20px; `; const CartList = () => { const { products, size } = useCartStore(); return ( <CartListWrap> <h2>장바구니</h2> {size > 0 ? ( products.map((product) => ( <CartProductCard key={product.product.item_no} product={product} /> )) ) : ( <div>담은 상품이 없습니다.</div> )} </CartListWrap> ); }; export default CartList;
이렇게 products를 불러오면, 우리는 장바구니의 상품 정보를 간결하게 구현할 수 있습니다. 비즈니스 로직과 뷰 로직을 완벽(?) 하게 분리했다 생각합니다.
그래서 이점이 뭔데?
이제 위 예제를 통해 많은 이점을 확인할 수 있었습니다. 간단하게 정리를 하자면
- 비즈니스 로직 테스트만으로 로직 테스트를 끝낼 수 있다.
- 비즈니스와 뷰 로직의 구분이 명확해진다.
- 필요한 내용을 따로 구현하고, store를 통해 원하는 것만 사용하기 용의하다.
- 모델 코드 확인으로만 코드 리딩이 가능해진다.
의 이점을 얻을 수 있다고 생각합니다.
매번 유지보수하기 어렵다던가, 로직 테스트 코드를 작성하면서 변경되는 컴포넌트들을 모두 탐지해 수정해주는 번거로운 작업을 수행하고 계시다면 로직을 따로 분리해서 개발을 진행해보는 것은 어떨까요?
정답이라 생각하지 않고, 실무는 정해진 일정이 있다보니 지켜지지 못할때가 많습니다. 하지만, 꾸준히 하다 보면 언젠간 좀 더 빠르게 모델을 설계, 테스트 코드를 작성할 수 있으리라 생각하기 때문에 꾸준히 도전해보는 것은 의미가 있다고 생각합니다.
저부터 점진적 마이그레이션(?) 을 통해 최대한 명확하고 간결한 코드를 작성하기 위해 노력해야할 것입니다.. 프론트엔드 화이팅입니다.. :D