custom hook 패턴을 이용한 프로젝트 리팩토링 및 회고

Yejung·2022년 11월 12일
5

프로젝트를 시간이 좀 지난 뒤 리팩토링 하려고 하니
내가 작성한 코드임에도 처음에 잘 눈에 들어오지 않았다.
우리 프로젝트는 Redux로 상태를 관리하는 곳도 있고 그렇지도 않은 곳도 있어서
FLUX 패턴으로 상태가 관리가 되고 있는 곳도 있고... 아닌 곳도 있고... 😅

프로젝트 후 공부를 하면서 관심사의 분리를 통해 코드 가독성을 높일 수 있다는 것을
알게 되었고 우선 View와 비즈니스 로직을 분리해야겠다고 느꼈다.

어떠한 기준으로 View와 비즈니스 로직을 분리할까?

유용한 리액트 패턴 5가지
프론트엔드에서 비즈니스 로직과 뷰 로직 분리하기 (feat. MVI 아키텍쳐)
Next.js에서 Container-Presenter? 디자인패턴의 대한 고민
어떤 React 디자인 패턴이 적절한 것일까 ?

어떤 방법이 좋을지 위의 글들을 찾아가면서 읽었고, custom hook 패턴으로 작성한 프로젝트를 읽었을 때 마음에 들었던 경험이 있어서 이 패턴을 적용하자고 팀원에게 의견을 제시했다.
(https://github.com/jin0106/ContiNew/issues/13)

(결과적으로 잘 설득했다)

Github Issue에서도 이야기를 나누고 따로 음성으로도 이야기를 나눈 뒤
custom hook 패턴을 적용하면서 생성되는 hook과 공용 컴포넌트가 아닌 컴포넌트들을 위해 container라는 폴더를 만들어 관리하기로 했다.

custom hook 구조를 사용하면 확실히 파일 수가 늘어나고, 컴포넌트 재사용이 조금 더 어려워진다는 단점이 있긴 하다. 그렇지만 현재 프로젝트를 봤을 때 여러 컴포넌트를 추상화해 재사용하는 것보다 가독성을 높이는게 급해보였다.

그리고 custom hook에서 가져온 변수, 함수 등을 이용해서 View에서 렌더링 해주기 때문에 변수명과 함수명이 잘 작성된 파일을 보면 View만 봐도 이렇게~ 이렇게~ 되겠구나, 좀 더 관심있는 부분은 해당 custom hook에 들어가서 어떠한 역할을 하는지 자세히 볼 수 있어 좋았다.

단, 어디까지가 View이고 어디까지가 비즈니스 로직인지 구분해야했다.

프론트엔드 아키텍처: Business Logic의 분리

위 링크에서 View는

View는 우리가 전달하고자 하는 정보를 전달하고 필요하다면 사용자로부터 행동을 입력받고 상호작용 합니다. 친숙한 언어로 풀어쓰자면 사용자에게 HTML와 CSS를 활용해 페이지를 제작하고 거기에 이미지나 영상 등 리소스를 추가해 정보를 전달합니다. 또한 사용자가 웹 페이지에 특정 요소를 클릭하거나 페이지를 스크롤 하는 등의 행동을 할 때 필요하다면 관련 이벤트를 리스닝하고 있다가 적절한 처리를 통해 상호작용 합니다. (여기에선 HTML로 표현했지만 UI가 더 정확한 표현입니다. 하지만 직관적인 이해를 위해 HTML로 대체해서 표현했습니다.)

비즈니스 로직은

Domain Logic 혹은 Business Logic은 현실 세계의 비지니스 규칙을 프로그램으로 표현한 부분

이라고 적혀있다.

위 글의 예시를 통해 생각해보면

단순히 토글을 사용자가 열고 닫을 수 있게 만든 것은 View,
50% 할인 이벤트가 진행되는 동안 토글을 열고 닫을 수 있게 만든 것은 비즈니스 로직
(그럼 그냥 버튼을 누르면 페이지 이동하는 것은 View이고, 버튼을 눌렀을 때 post로 서버에 데이터를 전송하고 이동하는 것은 비즈니스 로직일까...?)

이렇게 생각했는데 우선 확신이 없는 상태에서 리팩토링 할 순 없으니
지금은 View에서 사용하는 상태, 함수 등은 모두 custom hook에 담고
View는 해당 데이터로 JSX를 return 하는데만 집중하기로 한다.

(비즈니스 로직과 뷰를 잘 구분하게 해주는 좋은 글, 영상이 있다면 알려주시면 감사하겠습니다😀)
(틀린 부분에 대한 지적도 언제나 감사히 받습니다)

리팩토링 결과

리팩토링 전

import React, { useEffect, useState } from "react";
import { useRouter } from "next/router";
import { useSelector } from "react-redux";
import { Step, Stepper } from "react-form-stepper";
import { useForm, FormProvider } from "react-hook-form";

import contractApi from "src/api/contract";

import ContractForm from "@container/contracts/components/ContractForm";

import { RootState } from "src/store";
import { ContractType } from "src/types/contractType";
import styled from "styled-components";

export const ContractContext = React.createContext<ContractType>({});

function Contract() {
	const router = useRouter();
	const methods = useForm();

	const buyerId = router.query.buyerId as string;
	const sellerId = router.query.sellerId as string;
	const articleId = Number(router.query.articleId);
	const value = { buyer: buyerId, seller: sellerId, house_id: articleId };

	const [contract, setContract] = useState<ContractType>({});
	const { current_level: step, role } = contract;
	const loginId = useSelector((state: RootState) => state.userInfo.login_id);

	useEffect(() => {
		getContractInfo();
	}, []);

	const getContractInfo = async () => {
		const res = await contractApi.getContract(value);
		if (res.status) {
			if (sellerId === loginId) setContract({ ...res.data, role: "seller" });
			else setContract({ ...res.data, role: "buyer" });
		}
	};

	const handleBreakContractButton = async () => {
		if (window.confirm("계약을 파기하시겠습니까?")) {
			await contractApi.breakContract(value);
			router.push("/");
		}
	};

	const handleNextStepClick = async (data: ContractType) => {
		const contractInfo = {
			...data,
			house_id: articleId,
			seller_login_id: sellerId,
			buyer_login_id: buyerId,
			next_level: true,
		};
		const res = await contractApi.createContract(contractInfo);
		if (res.status) {
			alert(`${step}단계 계약서 작성이 완료되었습니다.`);
			window.location.href = "/contract";
		}
	};

	const handleTempSaveClick = async (data: ContractType) => {
		const contractInfo = {
			...data,
			house_id: articleId,
			seller_login_id: sellerId,
			buyer_login_id: buyerId,
			next_level: false,
		};
		const res = await contractApi.createContract(contractInfo);
		if (res.status) {
			alert(`계약서를 임시저장 했습니다.`);
			window.location.href = "/contract";
		}
	};

	const showButtons = () => {
		if (
			(step === 1 && role === "seller") ||
			(step === 2 && role === "buyer") ||
			(step === 3 && role === "seller")
		) {
			return (
				<StyledDiv>
					<Button id="save" onClick={methods.handleSubmit(handleTempSaveClick)}>
						임시 저장
					</Button>
					<Button id="next" onClick={methods.handleSubmit(handleNextStepClick)} isColor={true}>
						다음 단계
					</Button>
				</StyledDiv>
			);
		}
	};

	return (
		<ContractContext.Provider value={contract}>
			<FormProvider {...methods}>
				{step === 4 ? (
					<ContractForm />
				) : (
					<>
						<Stepper activeStep={step && step - 1}>
							<Step label="계약 조건 작성" />
							<Step label="신규 임차인 정보 작성 및 서명" />
							<Step label="임차인 서명" />
						</Stepper>
						<ContractForm />
						<BreakButton onClick={handleBreakContractButton}>계약 파기</BreakButton>
						{showButtons()}
					</>
				)}
			</FormProvider>
		</ContractContext.Provider>
	);
}

export default Contract;

const StyledDiv = styled.div`
	display: flex;
	justify-content: center;
`;

interface ButtonProps {
	isColor?: boolean;
}

const Button = styled.button<ButtonProps>`
	border: ${(props) => (props.isColor ? "none" : `1px solid ${props.theme.borderColor}`)};
	width: 10rem;
	height: 3rem;
	border-radius: 0.4rem;
	background-color: ${(props) => (props.isColor ? props.theme.mainColor : "#fff")};
	color: ${(props) => (props.isColor ? "#fff" : "#000")};
	margin-right: 2rem;
	cursor: pointer;
	margin-bottom: 7rem;
`;

const BreakButton = styled.button`
	width: 10rem;
	height: 3rem;
	border-radius: 0.4rem;
	cursor: pointer;
	border: none;
	background-color: inherit;
	color: #e31941;
	display: block;
	margin: 2rem 0 2rem 83vw;
	font-size: 1.2rem;
`;

리팩토링 후

import React from "react";
import { Step, Stepper } from "react-form-stepper";
import { FormProvider } from "react-hook-form";

import ContractForm from "@container/contracts/components/ContractForm";
import { ContractType } from "src/types/contractType";

import styled from "styled-components";
import useContract from "@container/contracts/hooks/useContract";
export const ContractContext = React.createContext<ContractType>({});

function Contract() {
	const { contract, methods, step, handleClickBreakContract, showButtons } = useContract();

	return (
		<ContractContext.Provider value={contract}>
			<FormProvider {...methods}>
				{step === 4 ? (
					<ContractForm />
				) : (
					<>
						<Stepper activeStep={step && step - 1}>
							<Step label="계약 조건 작성" />
							<Step label="신규 임차인 정보 작성 및 서명" />
							<Step label="임차인 서명" />
						</Stepper>
						<ContractForm />
						<BreakButton onClick={handleClickBreakContract}>계약 파기</BreakButton>
						{showButtons()}
					</>
				)}
			</FormProvider>
		</ContractContext.Provider>
	);
}

export default Contract;

const BreakButton = styled.button`
	width: 10rem;
	height: 3rem;
	border-radius: 0.4rem;
	cursor: pointer;
	border: none;
	background-color: inherit;
	color: #e31941;
	display: block;
	margin: 2rem 0 2rem 83vw;
	font-size: 1.2rem;
`;

jsx를 return 하기 전 복잡한 로직을 가지고 있던 컴포넌트를 이러한 방식으로 모두 리팩토링 했다.

결론

내가 모르는 단점도 많겠지만 우선 파일 내부의 변수, 함수가 줄어들다보니
해당 파일을 봤을 때 집중력있게 읽힌다는 점이 만족스럽다.
가독성이 좋아져서 내부 리팩토링도 더 원활하게 진행할 수 있을 듯 하다.
추후 다른 불편함이 또 생긴다면 다른 방법들도 또 고려를 해봐야겠다.

profile
이것저것... 차곡차곡...

0개의 댓글