MVVM 패턴으로 비즈니스 로직 분리하기 (2)

Jessie·2024년 8월 15일

비즈니스 로직 분리하기

처음 MVVM을 접했을 때 가장 어려웠던 개념이 ViewModel이었기때문에 우선은 View와 Model을 먼저 분리해야겠다고 생각했다. View는 화면에 보여지는 TSX 부분이라고 생각하니 쉽게 감이 잡혔고, Model은 데이터를 중심으로 분리해나가기로 했다.

class ProductDetailModel implements ProductDetailAction {
    private readonly id: string
    private readonly title: string
    private readonly content: string
    private readonly price: number
    private readonly discount: number
    private readonly discountPrice: number
    private readonly registeredDate: string
    
    constructor(raw: ProductDataType) {
        this.id = raw.id
        this.title = raw.title
        this.content = raw.content
        this.price = raw.price
        this.discount = raw.discount
        this.discountPrice = raw.price - (raw.price * (raw.discount * 0.01))
        this.registeredDate = raw.registeredDate
    }
    
    getProductDetailData = () => ({
        id: this.id,
        title: this.title,
        content: this.content,
        price: this.price,
        discount: this.discount,
        discountPrice: this.discountPrice,
        this.registeredDate: this.registeredDate
    })

}

서버로부터 받아온 데이터는 ProductDetailModel이라는 클래스 안에 저장된다. 그리고 이 데이터를 기반으로 새로운 값을 만들어내거나 수정, 삭제 등 데이터를 가공하는 모든 로직이 Model 안에서 진행된다. 예시 코드에서는 서버에서 정가와 할인율을 받아와 할인가를 구하는 코드가 포함되었다.

상품등록일의 포맷을 변경하는 함수는 Model에서 실행할까 고민을 했는데, 포맷을 변경하는 것은 단순히 View를 위한 로직이라고 판단되어 포함하지 않았다. 만약에 등록일을 수정하는 기능이 생긴다고 하면 Date 객체의 문자열 포맷으로 Model에 저장되고 서버에 보내져야하지, '0000년 0월 0일'의 포맷으로 처리를 할 수 있는 것이 아니기 때문이다.

ViewModel 코드 작성하기

ViewModel의 코드는 View가 Model에 직접적으로 접근할 수 없게 하는 것에 목적을 두고 작성을 하기로 했다. 하지만 그 전에 가장 고민을 많이 했던 포인트가 두가지가 있었다.

  1. Api 코드는 어떻게 작성을 해야할까?
    Api는 데이터와 관련된 로직이니 Model에 포함해야 할까, 아니면 직접적으로 데이터를 가공하는 것은 아니니 ViewModel에 포함해야 할까? 나도 이러한 고민을 했고, 다른 사람들도 이 부분에 대한 의견이 나뉘어 있었다. 실제 코드 예시를 찾아보니 Kotlin이나 Swift에서는 api를 부르는 레이어(Repository, Service 등)가 따로 있고, 그 레이어를 ViewModel이 불러오는 형태가 많이 보였다. 몇몇 타입스크립트 코드 예시는 Model에서 불러오는 것도 있었지만, (적어도 내가 찾아봤을때는) ViewModel에서 불러오는 예시의 수보다 훨씬 적어서 더 많이 쓰이는 형태를 사용해보기로 했다. 따라서 api 요청을 하는 함수를 작성하고, 그 함수를 ViewModel에서 불러오는 방식을 적용했다.

  2. ViewModel은 class로 만들까, function component로 만들까?
    이 질문에 대한 답은 api 코드를 ViewModel이 부르게 되면서 결정하게 되었다. ViewModel 내부에서 api를 부르는 메서드는 비동기 함수로 작성이 되는데, 이 비동기 함수를 바로 View로 전달하게 되면 View마다 비동기 함수를 다루는 코드를 작성해야 했다. ViewModel 내부에서 useState를 사용하여 api가 돌려주는 값을 저장하여 View에서 바로 사용할 수 있게 하기 위해 function component로 작성하기로 했다.

Api 요청 함수

// 서버에 상품 데이터를 요청하는 api
async function getProductDetail(productId: string) {
    const getProductDetail = async () => {
        try {
            const productData = await Axios.get( ... )
            return new ProductDetailModel(productData.data)
        } catch(error) {
            console.error(error)
        }
    }
    
    return getProductDetail
}

// 장바구니에 상품을 담는 api 요청
async function postProductToCart(productId: string) {
    await Axios.post( ... )
}

서버에 상품 데이터를 요청하는 api는 서버로부터 답을 성공적으로 받아오게 되면 이 값을 Model에 넣어 리턴해준다.

장바구니에 상품을 담는 api 요청은 서버로 보내기만 하면 되기 때문에 그냥 POST 요청을 보내는 단순한 함수다. 만약에 장바구니의 내용을 처음 렌더링 될때만 서버에서 받아오고, 그 데이터를 계속 이용하고 싶다면 장바구니 Model을 생성하여 사용하면 된다. POST 요청이 성공했다면 그 정보를 클라이언트 단에서 장바구니 Model에 바로 업데이트 해주어 추가적인 get 요청 없이 사용하는 것이다. 새로고침을 하지 않는 한 서버에 GET 요청을 하지 않아도 된다는 장점이 있지만, 싱글톤 패턴 등을 사용하여 Model을 전역에서 사용할 수 있도록 만들어야하고, 데이터가 계속 캐싱되어있어야한다는 단점도 있어 상황마다 장단점을 잘 따져보고 적용해야 한다. 현재 프로젝트에 이 방식을 적용한 부분이 있기는 한데, 이것까지 적으면 글이 너무 복잡해질 것 같아 짧게 기록만 하고 넘어간다.

내가 작성한 ViewModel

function ProductDetailViewModel(productId: string): ProductDetailVM {
    const [productDetailModel, setProductDetailModel] = useState<ProductDetailModel>()
    const [productDetailData, setProductDetailData] = useState<{
        id: string
        title: string
        content: string
        price: number
        discount: number
        discountPrice: number
        registeredDate: string
    }>({        
        id: ''
        title: ''
        content: ''
        price: 0
        discount: 0
        discountPrice: 0
        registeredDate: ''
    })
    
    useEffect(() => {
        setProductDetailData(produceDetailModel.getProductDetailData())
    }, [productDetailModel])
    
    async function getProductDetailData() {
        try {
            const data = await getProductDetail(productId)
            setProductDetailModel(data)
            setProductDetailData(data.getProductDetailData())
        } catch(error) {
            console.error(error)
        }
    }
    
    async function addProductToCart() {
        try {
            await postProductToCart(productId)
        } catch(error) {
            console.error(error)
        }
    }

    return { productDetailData, getProductDetailData, addProductToCart } 
}

GET 요청을 보낸 후, 성공 시 돌아오는 Model을 받아 productDetailModel state에 저장한다. 이 Model 값을 가지고 있는 state는 이후 Model의 내용을 업데이트 할 때 바로 사용할 수 있다. State값에 저장이 되어있기 때문에 업데이트 이후 다른 처리를 해주지 않아도 바뀐 데이터가 바로 반영된다.

productDetailData state를 따로 만들어 둔 이유는 View가 Model의 메서드를 직접 부르게하고싶지 않아서이다. productDetailModel은 model을 가지고 있는 값이라 실제 데이터를 사용하려면 데이터를 돌려주는 메서드를 먼저 불러야하는데, 그렇게 되면 View가 Model에 직접 접근해야 하고, MVVM 규칙에 어긋나기 때문이다. 대신 이 값은 Model이 바뀌어도 바뀐 값이 반영이 되지 않기 때문에 useEffect를 사용해서 Model 값이 바뀌면 자동으로 업데이트되도록 해주었다.

분리 후의 View

function ProductDetailScene() {
    const { id } = useParams()
    const { productDetailData, getProductDetailData, addProductToCart } = ProductDetailViewModel(id)
    const { title, registeredDate, price, discount, discountPrice, content } = productDetailData
    
    useEffect(async () => {
        getProductDetailData(id)
    }, [])
    
    function formatDate(date: string) {
       // ... Date 객체의 날짜를 문자열로 표현한 포맷을 '0000년 00월 00일' 포맷으로 바꿔주는 함수
    }

	return (
		<ProductDetailContainer>
            <ProductDetailTitle>{title}</ProductDetailTitle>
            <ProductDetailDate>
                상품등록일: {FormatDate(registeredDate)}
            </ProductDetailDate>
			<ProductDetailPriceContainer>
                <ProductDetailOriginalPrice>상품 가격: {price}</ProductDetailOriginalPrice>
                <ProductDetailDiscount>할인: {discount}%</ProductDetailDiscount>
                <ProductDetailDiscountPrice>할인가: {discountPrice}</ProductDetailDiscountPrice>
            </ProductDetailPriceContainer>
            <ProductDetailContent>{content}</ProductDetailContent>
            <ProductDetailCartButton onClick={() => addProductToCart(productId)}> 
                /* 장바구니 버튼 컴포넌트 내용 */
            </ProductDetailCartButton>
		</ProductDetailContainer>
	)
}

export default ExperienceDetailScene

비즈니스 로직을 분리한 후의 View 코드이다. useEffect를 사용하여 처음 렌더링될때 상품 정보를 받아오는 ViewModel의 메서드를 한번 불러주고, ViewModel에 세팅된 정보를 사용하게 된다. 기존의 코드보다 길이도 많이 줄었고, 데이터는 단순히 불러다 쓰기만 하고 있다.


MVVM 패턴으로 비즈니스 로직을 분리하면서 나름대로 최선을 다했지만, 답을 내지 못한 부분들이 많다. 이것들에 대해서는 이어서 기록을 남겨보록 하겠다. >> 3편에서 계속..

profile
주니어 프론트엔드 개발자입니다 😎

0개의 댓글