프로젝트를 시간이 좀 지난 뒤 리팩토링 하려고 하니
내가 작성한 코드임에도 처음에 잘 눈에 들어오지 않았다.
우리 프로젝트는 Redux로 상태를 관리하는 곳도 있고 그렇지도 않은 곳도 있어서
FLUX 패턴으로 상태가 관리가 되고 있는 곳도 있고... 아닌 곳도 있고... 😅
프로젝트 후 공부를 하면서 관심사의 분리를 통해 코드 가독성을 높일 수 있다는 것을
알게 되었고 우선 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 하기 전 복잡한 로직을 가지고 있던 컴포넌트를 이러한 방식으로 모두 리팩토링 했다.
내가 모르는 단점도 많겠지만 우선 파일 내부의 변수, 함수가 줄어들다보니
해당 파일을 봤을 때 집중력있게 읽힌다는 점이 만족스럽다.
가독성이 좋아져서 내부 리팩토링도 더 원활하게 진행할 수 있을 듯 하다.
추후 다른 불편함이 또 생긴다면 다른 방법들도 또 고려를 해봐야겠다.