React 에서 비즈니스 로직 분리하기 (Custom Hooks Pattern)
관심사의 분리
여러분은 내일 A 과목을 시험 칩니다. 벼락치기의 민족답게 우리는 수업 중에 필기했던 내용들을 시험 전 날이 되어서야 한 번 살펴보려고 합니다. 그런데, 여러분의 노트에는 여태 들었던 모든 과목의 강의에 대한 내용이 필기되어 있습니다(꽤 성실하네요). 당장 내일 시험인데, 이렇게 갖가지 과목이 섞여있는 노트에서 A과목에 대한 내용만 뽑아 살펴보자니 눈앞이 깜깜합니다. 몇몇 내용은 A과목에 대한 내용인지 아니면 다른 과목의 설명인지 혼란스럽기까지 합니다.
"누가 저런 바보짓을 해?"라고 생각하실 수 있는데 실제로 개발을 할 때 종종 일어나는 일입니다. 하나의 메서드, 혹은 컴포넌트가 굉장히 많은 일을 담당하고 있을 때 우리는 위와 비슷한 상황을 경험합니다. 새로운 기능을 추가해야 하거나 오류를 수정해야 할 때 함수가 너무 크다면 어떤 부분을 수정해야 할지 파악하는데만 엄청난 시간과 노력이 들어갑니다. 그제야 개발자들은 관심사의 분리라는 키워드를 되뇌게 됩니다.
우리는 동일한 책임, 역할에 따라 함수를 분리함으로써 코드의 재사용성과 가독성, 유지보수성을 높일 수 있습니다. 많은 개발자들은 리액트에서 랜더링에 관여하는 요소(UI)와 비동기 처리, 상태 변화 등의 비즈니스 로직을 분리하고자 했고 Presentaional & Container 패턴이 나오게 되었습니다. 비즈니스 로직을 수행하는 Container로 감싸고 결과 데이터를 Property로 자식에게 내려주게 하는 디자인 패턴입니다. 하지만 함수 컴포넌트와 Hooks가 등장하면서 Presentaional & Container 패턴은 더이상 권장되지 않는 패턴이 되었습니다. (때문에 이 글에서는 Presentaional & Container 패턴에 대해서 자세하게 다루지 않습니다)
선언형 프로그래밍
리액트는 JSX를 통해 UI 마크업과 JavaScirpt를 함께 사용할 수 있게 함으로써 선언형 프로그래밍이 가능하도록 만들었습니다. 여기서 선언형 프로그래밍이란 함수가 어떤 방식으로 동작하는지보다 어떤 결과를 나타내는지에 중점을 둔 프로그래밍을 말합니다.
//.html
<button id='btn'>버튼</button>
//.js
const button = document.selectById('#btn');
button.addEventListner('onclick', () => { alert('버튼 클릭') });
명령형 프로그래밍 - #btn이라는 id를 가진 버튼을 찾아서 onclick 이벤트에 alert 하는 이벤트 핸들러를 바인딩하라.
//.jsx
<button id='btn' onClick={alertClick}>버튼<button/>
function alertClick() {
alert('버튼 클릭');
}
선언형 프로그래밍 - 해당 button에 click이벤트가 일어나면 alertClick 해라.
일반적으로 렌더링에 대한 정보를 나타내는 뷰 입장에서는 무엇을 보여주고, 어떤 행동을 할 지에 대한 내용만 드러나면 해당 컴포넌트가 어떤 뷰를 랜더링 하는지 더 명확하게 알 수 있습니다. 상태의 변화를 다루는 비즈니스 로직이 컴포넌트에 존재하면 오히려 혼동을 일으킬 수 있습니다.
Custom Hooks
Custom Hooks를 잘 활용한다면 우리는 선언형 프로그래밍에 한 발 더 가까워질 수 있게 됩니다. 뿐만 아니라 렌더링과 관련 없는 state들은 hooks안으로 숨겨서 Component에는 랜더링에 필요한 데이터들만 쉽게 확인할 수 있게 됩니다.
현재 시간을 나타내는 간단한 시계를 구현해보겠습니다. 시계는 1초마다 갱신됩니다.
Clock.jsx (수정 전)
import { useEffect, useState, VFC } from "react";
function getCurrentTime() {
return new Date().toString();
}
const ONE_SEC = 1000;
export const Clock = () => {
const [time, setTime] = useState(getCurrentTime());
useEffect(() => {
const timeout = setTimeout(() => {
setTime(getCurrentTime());
}, ONE_SEC);
return () => {
clearTimeout(timeout);
};
}, [time]);
return <h2>{time}</h2>;
};
간단한 함수지만 컴포넌트가 지저분하게 느껴집니다. Clock은 현재 시간을 나타내는 컴포넌트 일 뿐인데, setTime이라는 함수나 useEffect를 갖고 있을 필요가 있을까요? 시간을 업데이트하는 로직은 모두 Custom Hooks로 분리해 보겠습니다.
Clock.jsx (수정 후)
import { useEffect, useState, VFC } from "react";
import { useClockTime } from "./hooks";
export const Clock = () => {
const time = useClock();
return <h2>{time}</h2>;
};
hooks.js
const getCurrentTime = () => {
return new Date().toString();
}
const ONE_SEC = 1000;
export const useClockTime = () => {
const [time, setTime] = useState(getCurrentTime());
useEffect(() => {
const timeout = setTimeout(() => {
setTime(getCurrentTime());
}, ONE_SEC);
return () => {
clearTimeout(timeout);
};
}, [time]);
return time;
}
이제 Clock 컴포넌트는 현재 시간을 렌더링 하기만 하고, 시간이 1초마다 업데이트되는 로직은 커스텀 훅으로 빼내어 확실한 관심사 분리가 되었습니다. 현재 시간을 나타내는 컴포넌트가 Clock이라는 것은 훨씬 명확해졌고 알아보기 쉬워졌습니다.
그런데, 모든 로직을 하나의 hooks에서 처리하고, 렌더링 혹은 상태 업데이트에 필요한 값들만 받아 컴포넌트에서 사용하는 것이 좋을까요?
버튼을 클릭 후 3초 후, alert이 뜨는 타이머를 구현해보겠습니다.
import { useState, VFC } from "react";
const ONE_SEC = 1000;
export const Timer: VFC = () => {
const [isStart, setIsStart] = useState(false);
const startTimer = () => {
setIsStart(true);
setTimeout(() => {
alert("3초가 지났습니다.");
setIsStart(false);
}, 3 * ONE_SEC);
};
return (
<button disabled={isStart} onClick={startTimer}>
타이머 시작
</button>
);
};
여기서 여러분은 Custom Hooks를어떻게 만들어서 비즈니스 로직을 묶으실 건가요? 우선 모든 로직을 아래처럼 숨긴다고 가정해보겠습니다.
Timer.jsx (모든 로직 숨기기)
import { useEffect, useState, VFC } from "react";
import { useTimer } from "./hooks";
export const Timer = () => {
const {isStart, startTimer} = useTimer();
return <button disabled={isStart} onClick={startTimer}>타이머 시작</button>;
};
hooks.js
const ONE_SEC = 1000;
export const useTimer = () => {
const [isStart, setIsStart] = useState(false);
const startTimer = () => {
setIsStart(true);
setTimeout(() => {
alert("3초가 지났습니다.");
setIsStart(false);
}, 3 * ONE_SEC);
};
return {
isStart,
startTimer
}
}
Timer 컴포넌트 안에 모든 비즈니스 로직이 useTimer로 빠지면서 렌더링과 click 이벤트 핸들러만 받아오고있습니다. 위 코드에 대해 어떻게 생각하시나요? 컴포넌트의 코드는 훨씬 짧아졌지만 비즈니스 로직은 파악하기 훨씬 어려워지지 않았나요?
타이머에 몇 초가 설정 되어 있는지, 그리고 해당 N초가 지나면 어떤 동작이 일어나는 지 Timer 컴포넌트만 보아서는 도무지 알 수 없게 되었습니다. 협업하는 동료 뿐만 아니라 몇 달 뒤에 내가 봐도 해당 로직을 찾기위해 코드를 한참 파고 들어야 할 수도 있을 것 같습니다.
클린 코드는 짧은 코드가 아닌 읽기 좋은(찾고 싶은 로직을 빠르게 찾을 수 있는) 코드입니다.
가능한 모든 코드를 뭉쳐서 하나의 커스텀 훅으로 묶어 배치하는 것이 좋은 것이 아니라, 해당 컴포넌트가 어떤 데이터를 어떻게 렌더링 하고 있는지 파악을 할 수 있도록 작성하는것이 더 좋은 방법입니다. 어떻게 동작하는지 알 필요가 없는 부분만 커스텀 훅으로 숨겨두는 것입니다.
useTimer에서 타이머를 몇 초로 설정할 것인지, 그리고 n초가 지난 뒤 어떤 동작을 수행할 지는 파라미터로 받도록 변경해줍니다.
const ONE_SEC = 1000;
export const useTimer = (seconds: number, onTimeout: () => void) => {
const [isStart, setIsStart] = useState(false);
const startTimer = () => {
setIsStart(true);
setTimeout(() => {
onTimeout();
setIsStart(false);
}, seconds * ONE_SEC);
};
return {
isStart,
startTimer
}
}
이제 Timer를 보면, 몇 초의 타이머를 설정할 지, 그리고 해당 시간이 지나면 어떤 행동을 할 지 알 수 있습니다.
import { useEffect, useState, VFC } from "react";
import { useTimer } from "./hooks";
export const Timer = () => {
const {isStart, startTimer} = useTimer(3, alertMessage);
function alertMessage() {
alert("3초가 지났습니다.");
}
return <button disabled={isStart} onClick={startTimer}>타이머 시작</button>;
};
TIP) 마지막으로 파라미터를 객체로 받는다면 키를 통해 각 값이 어떤 역할을 하는지 조금더 명확하게 보여줄 수 있습니다.
import { useEffect, useState, VFC } from "react";
import { useTimer } from "./hooks";
export const Timer = () => {
const {isStart, startTimer} = useTimer({
seconds: 3,
onTimeEnd: alertMessage
});
function alertMessage() {
alert("3초가 지났습니다.");
}
return <button disabled={isStart} onClick={startTimer}>타이머 시작</button>;
};
const ONE_SEC = 1000;
export const useTimer = ({ seconds, onTimeout }: { seconds: number; onTimeout: () => void }) => {
const [isStart, setIsStart] = useState(false);
const startTimer = () => {
setIsStart(true);
setTimeout(() => {
onTimeout();
setIsStart(false);
}, seconds * ONE_SEC);
};
return {
isStart,
startTimer
}
}
위처럼 모든 로직을 숨기는 것이 아닌, 적절하게 뭉쳐서 숨길 코드를 잘 정하고 추상화를 시키는 것이 가장 중요합니다. 비즈니스 로직과 뷰를 분리하고, 선언형 프로그래밍을 하는 가장 큰 목적은 관심사 분리를 통해 컴포넌트가 어떤 행위를 하는지 빠르게 파악하고 수정해야할 부분을 빠르게 찾기 위함입니다. 짧은 코드가 아닌 이해하기 쉬운 코드를 짤 수 있도록 많은 연습이 필요할 것 같습니다.
https://yujonglee.com/socwithhooks.html
https://felixgerschau.com/react-hooks-separation-of-concerns/