프로솔브 v1.0.9 회고록을 보면 기존에는 Presentation-Container 패턴을 이용해 비즈니스 로직을 부모 컴포넌트에서 정의하고, Container에서는 UI만을 담당하게끔 구현했습니다.
하지만 위의 방식이 부모 컴포넌트가 상태와 로직을 모두 가지고 있기 때문에 SRP를 위반하고 컴포넌트 규모가 커질수록 복잡해진다는 단점이 있다고 느꼈습니다.
그래서 저는 각각의 컴포넌트의 상태 로직을 Custom Hook에서 정의해 사용하게끔 리팩터링함로써 UI와 비즈니스 로직을 분리하였습니다.
profile 페이지를 예시로 들면서 설명하겠습니다.
// pages/newTab/profile/index.tsx
const ProfileTabLayout = () => {
const [isLoaded, setIsLoaded] = React.useState(true);
const [isLoggedIn, setIsLoggedIn] = React.useState(true);
const [allProblems, setAllSolvedProblems] = React.useState<SolvedProblemType>([]);
const [solvedProblems, setSolvedProblems] = React.useState<SolvedProblemType>([]);
const selectedNavOption = useRecoilValue(navOption);
React.useEffect(() => {
(async () => {
await setUserInfoStorage();
const userEmail = await getUserEmail();
if (userEmail === undefined) {
setIsLoggedIn(false);
return;
}
const allProblems = await getAllProblemsList();
setAllSolvedProblems(allProblems);
const solvedProblems = await getSolvedProblemList(userEmail, allProblems);
setSolvedProblems(solvedProblems);
setIsLoaded(false);
})();
}, []);
const problemCnt = getProblemsCnt({ allProblems, solvedProblems });
const solvedLevelCnt = getProblemsLevelList(solvedProblems);
const chartInfoList = getChartInfoList({ allProblems, solvedProblems });
const filteredSolvedProblems = getFilteredSolvedProblems(solvedProblems);
const partTitleList = getPartTitleListOfSolvedProblems(solvedProblems);
return (
<ProfileTab>
<ProfileTab.Header />
<ProfileTab.Nav />
<ProfileTab.Content isLoggedIn={isLoggedIn} isLoaded={isLoaded}>
{selectedNavOption === 'MAIN' && (
<Statistics
problemCnt={problemCnt}
solvedLevelCnt={solvedLevelCnt}
chartInfoList={chartInfoList}
/>
)}
{selectedNavOption === 'PROBLEM' && (
<Problems
allSolvedCnt={solvedProblems.length}
solvedProblems={filteredSolvedProblems}
partTitleList={partTitleList}
/>
)}
</ProfileTab.Content>
<ProfileTab.Footer />
</ProfileTab>
);
};
이전의 profileTab 페이지는 상태와 로직을 전부 부모 컴포넌트인 ProfileTabLayout에서 관리하였습니다.
상태로는 데이터 로딩 여부인 isLoaded와 로그인 여부인 isLoggedIn, 전체 문제 데이터인 allProblems, 사용자가 풀이한 문제 데이터인 solvedProblems가 있습니다.
그리고 React.useEffect를 사용해 HTTP Request를 하면서 이 상태들을 세팅해주고 있습니다.
그리고 받아온 데이터인 allProblems와 solvedProblems를 파싱해 하위 컴포넌트에 필요한 데이터를 얻어 하위 컴포넌트에 전달해주고 있습니다.
제가 생각하는 기존 코드의 문제점은 다음과 같습니다.
부모 컴포넌트에서 비즈니스 로직을 전부 정의하니 SRP에 위반됩니다.
useEffect에 정의된 상태 세팅 로직은 굳이 컴포넌트에서 알 필요가 없습니다. 하위 컴포넌트에 전달할 필요한 상태와 상태 변경 로직만 있으면 됩니다.
데이터 파싱을 부모 컴포넌트에서 하지 않고 각각의 하위 컴포넌트에서 수행하는 것이 오히려 깔끔합니다.
데이터 파싱 로직은 순수함수이기 때문에 하위 컴포넌트에서 수행해도 언제나 동일한 UI를 보여주기 때문입니다.
위에서 열거한 코드 문제점을 해결하게끔 리팩터링을 해봅시다.
// pages/newTab/profile/Problems.tsx
const ProfileTab = () => {
const { isLoggedIn, isLoaded, allProblems, solvedProblems } = useProblems();
const selectedNavOption = useRecoilValue(navOption);
return (
<ContainerStyle>
<Header />
<NavBar />
<Content isLoggedIn={isLoggedIn} isLoaded={isLoaded}>
{selectedNavOption === 'MAIN' && (
<Statistics allProblems={allProblems} solvedProblems={solvedProblems} />
)}
{selectedNavOption === 'PROBLEM' && <Problems solvedProblems={solvedProblems} />}
</Content>
<FooterStyle />
</ContainerStyle>
);
};
리팩터링한 profileTab에서는 useProblems 훅을 이용해 하위 컴포넌트에 필요한 상태를 얻게끔 수정하였습니다. (아래에서 자세히 설명하겠습니다)
그리고 데이터 파싱에 필요한 데이터(allProblems, solvedProblems)를 하위 컴포넌트에 전달하여, 각각의 컴포넌트에서 파싱하게끔 수정하였습니다.
export const useUserEmail = () => {
const [isLoggedIn, setIsLoggedIn] = React.useState(true);
const [userEmail, setUserEmail] = React.useState<string | undefined>('');
React.useEffect(() => {
setUserEmailCallback({ setIsLoggedIn, setUserEmail });
}, []);
return {
isLoggedIn,
userEmail,
};
};
interface UserEmailCallback {
setIsLoggedIn: React.Dispatch<React.SetStateAction<boolean>>;
setUserEmail: React.Dispatch<React.SetStateAction<string | undefined>>;
}
const setUserEmailCallback = async ({ setIsLoggedIn, setUserEmail }: UserEmailCallback) => {
await setUserInfoStorage();
const userEmail = await getUserEmail();
if (!userEmail) {
setIsLoggedIn(false);
return;
}
setUserEmail(userEmail);
};
로그인 여부 및 로그인한 유저 이메일 주소는 useUserEmail hook에서 관리합니다.
이 Hook은 useProblems hook에서 사용자의 문제 데이터를 받아오는데 이용할 것입니다.
export const useProblems = () => {
const { isLoggedIn, userEmail } = useUserEmail();
const { isLoaded, setIsLoaded } = useIsLoaded();
const [allProblems, setAllSolvedProblems] = React.useState<SolvedProblemType>([]);
const [solvedProblems, setSolvedProblems] = React.useState<SolvedProblemType>([]);
React.useEffect(() => {
(async () => {
if (!userEmail) {
return;
}
const allProblems = await getAllProblemsList();
setAllSolvedProblems(allProblems);
const solvedProblems = await getSolvedProblemList(userEmail, allProblems);
setSolvedProblems(solvedProblems);
setIsLoaded(true);
})();
}, [userEmail]);
return {
isLoggedIn,
isLoaded,
allProblems,
solvedProblems,
};
};
useProblems hook은 로그인 여부와 유저 이메일 상태를 관리하는 Hook인 useUserEmail과 데이터 로딩중 여부 상태를 관리하는 useIsLoaded를 이용해 allProblems와 solvedProblems를 가져옵니다. allProblems는 전체 문제 데이터, solvedProblems는 유저가 풀이한 문제 데이터입니다.
ProfileTab 컴포넌트는 useProblems()를 호출하여 isLoggedIn, isLoaded, allProblems, solvedProblems 상태를 가져와 UI를 구성하는데 사용하게 됩니다.
리팩터링을 거치면서 컴포넌트는 UI 및 관련 로직만을 담당하고, Hook은 비즈니스 로직을 담당하게 되면서 SRP 원칙을 지키게 되었습니다.
리팩터링한 페이지는 비즈니스 로직이 복잡하지 않았기에 어렵지는 않았으나, 향후 비즈니스 로직이 추가되어도 유연하게 대처할 수 있게 되었습니다.