이번에 작성해볼 주제는 공통의 저주
입니다.
저도 프론트엔드 개발 초반에 빠졌던 이슈인데요.
처음 개발을 시작하면 dry원칙(don't repeat yourself)
이라는것을 많이 들으면서 개발을 진행할 것 입니다. 물론 이러한 원칙을 듣지않았더라도 반복되는 것은 공통
으로 분리해야된다. 이정도는 많이 알게 되죠.
이러한 공통을 지키겠다는 생각에 잡혀있을때 발생하는 문제(저주)
에 대해서 알아봅시다.
회원가입 | 로그인 |
---|---|
간단하게 이렇게 로그인, 회원가입 페이지를 기준으로 한번 보겠습니다.
로그인에 존재하는 아이디, 비밀번호의 input과 회원가입에 존재하는 input들의 ui가 매우 유사한것을 볼 수 있습니다.
그리고 이것을 보는 우리는 생각하죠. '공통 input을 이용하여 로그인, 회원가입 처리를 모두 해야겠다.'
현시점에서는 나쁘지 않은 시도입니다. 크게 저 input에 기능이 없다면 큰 문제가 없기때문이죠.
// 공통 input
type InputProps = InputHTMLAttributes<HTMLInputElement>;
export const Input = ({ type, ...props }: InputProps) => {
const [input, setInput] = useState('');
const handleChangeInput = (e: React.ChangeEvent<HTMLInputElement>) => {
setInput(e.target.value);
};
return <input {...props} type={type} onChange={handleChangeInput} value={input} />;
};
일단 간단하게 이렇게 input만 존재하는 공통이 있다고 가정해봅시다. (스타일도 적용된 상태라고 가정하겠습니다.)
여기서 기획에서 회원가입할때만 validation을 달아주고 싶다고 합니다.
음 그러면 공통에서 inputType
을 받아 회원가입인 경우만을 걸러낸다음, valid 한 값인지도 props로 받아서 처리하면 되겠네요.
type InputProps = {
inputType: 'signup' | 'signin';
isValid?: boolean;
} & InputHTMLAttributes<HTMLInputElement>;
export const Input = ({ type, inputType, isValid, ...props }: InputProps) => {
const [input, setInput] = useState('');
const handleChangeInput = (e: React.ChangeEvent<HTMLInputElement>) => {
setInput(e.target.value);
};
return (
<>
<input {...props} type={type} onChange={handleChangeInput} value={input} />
{inputType === 'signup' && isValid === false ? <div>유효하지 않습니다.</div> : null}
</>
);
};
props에 2개의 값을 추가하고, 회원가입이고 isValid가 false일때만 저러한 메세지를 밑에 보여주기로 하죠.
만약 저 똑같은 인풋을 이용하여 이러한 포인트를 다루는 인풋을 구현한다고 가정해보겠습니다.(ui가 달라보일 수 있지만 실제로는 ui가 같다는 가정입니다.)
type InputProps = {
inputType: 'signup' | 'signin' | 'point';
isValid?: boolean;
currentHasPoint: number;
} & InputHTMLAttributes<HTMLInputElement>;
export const Input = ({ type, inputType, isValid, ...props }: InputProps) => {
const [input, setInput] = useState('');
const handleChangeInput = (e: React.ChangeEvent<HTMLInputElement>) => {
if(inputType === 'point') {
const validPointInput = Number(e.target.value.replace(/[^0-9]+/g, ''));
if (validPointInput > currentHasPoint) {
alert(`이용가능한 포인트는 ${currentHasPoint}입니다.`);
setInput(currentHasPoint);
return;
}
setInput(validPointInput);
return;
}
setInput(e.target.value);
};
const inputFormat = inputType === 'point' ? `${input.toLocaleString()}P` : input;
return (
<>
<input {...props} type={type} onChange={handleChangeInput} value={inputFormat} />
{inputType === 'signup' && isValid === false ? <div>유효하지 않습니다.</div> : null}
</>
);
};
포인트를 다루는 인풋이기에 현재 보유한 포인트까지만 입력할 수 있는 예외를 추가해야합니다. 그러니까 inputType에 point
를 추가한뒤에 onChange의 이벤트핸들러에 pointform일때의 예외를 추가해봅시다.
또, 포인트는 100,000P
이런형식으로 보여야하니 input의 format도 포인트일때는 다르게 변경해봅시다.
공통의 Input
태그를 이용해서 로그인
, 회원가입
, 포인트
폼을 이용을 했습니다. ui가 동일하니 공통으로 잘 처리가 된듯하죠.
하지만 뭔가 이상하지 않나요? 공통이긴하지만 공통이 너무나 많은 기능을 하고있죠. 그덕분에 불필요한 props, 그리고 많은 분기처리가 있는 모습을 볼 수 있습니다.
만약 여기서 로그인
의 기능이 추가된다면 어떨까요? 여기에 또 로그인에 대한 props, 분기처리가 또 들어가게 되겠죠?
현재는 3개의 폼들로 예시를 들었지만 여기서 더 늘어난다면 훨씬 더 많은 분기처리를 하게 되죠.
자 이제는 기능에 대한 추가가 아니라 ui변경 요청이 들어왔습니다. 포인트폼의 ui가 완전히 바꾼다고 가정해봅시다. 그렇다면 이 공통 Input
으로 포인트 폼을 유지시킬 수 있을까요?
ui가 달라졌기에 또 다른 input을 만들어야겠죠? 하지만 이미 포인트는 Input
이라는 공통 태그에 강하게 묶여있기때문에 이 기능만 빼내기가 쉽지가 않아졌습니다.
이 때문에 Input
태그에 기능적 사이드 이펙트가 발생할수도 있습니다.
그래서 오늘 얘기하고싶은 주제 공통의 저주
입니다.
흔히 우리는 button
, input
이러한 것을 공통으로 많이 만들고 합니다.
그리고 개발을 한지 얼마안된상태로 '아 저런것들을 공통으로 빼서 관리하는구나' 라는 지식정도만을 알고 개발이 들어가게 되면 공통의 저주
에 빠지게 되는것이죠.
input
을 공통으로 만드는게 좋다고하여 비슷한 ui들을 공통으로 맞추기 위해서 억지로 공통input
태그에 props가 덕지덕지 붙게되고, 각 기능에 대한 분기처리가 넣어지는 아주 거대한 input
창고가 완성되는것이죠.
위의 예시는 비교적 눈치채지 쉬운 저주
이지만 실제로는 눈치채지 못한채 저렇게 강하게 묶여있는 컴포넌트들이 꽤 있을 수 있습니다.
그래서 이 저주를 해결 하는 방법은 어떤것이 있을까요?
간단합니다. 여러분이 ui
를 기준으로 공통으로 묶으려는 것을 도메인
으로 바라보시면 됩니다.
로그인
input과 포인트
input 2개의 도메인(관심사)은 다릅니다. 그렇기에 현재로써는 동일한 ui로 유지될 수 있지만 추후에 둘중에 하나에 기능이 추가될 수도 있고, 하나의 ui가 변할수도있습니다.
그렇기 때문에 이런경우에는 LoginInput
, PointInput
과 같은 식으로 동일한 ui를 가지지만 다른 컴포넌트로 분리를 해줘야합니다.
흔히 똑같은 코드를 다른 컴포넌트에 적용을하면 반복을 하지마라
라는 철학으로 불편하게 느껴지기 때문에 저주
에 걸리는것입니다.
LoginInput
, PointInput
이렇게 2개로 쪼개시면 당연히 클린코드라는것을 아시는 여러분들은 불편하게 느껴지실겁니다. 똑같은 코드가 다른 컴포넌트에 있으니까요 👻
물론 이것을 해결하는 방법이 존재합니다. 바로 기능이 존재하지 않고 ui만 존재하는 컴포넌트를 만드는것입니다.
그런후에 custom hook을 통해서 사용하는 도메인 측에서 로직을 주입해주는것이죠.
Input
// 스타일이 적용되있다고 가정하겠습니다.
interface InputProps<T> extends InputHTMLAttributes<HTMLInputElement> {
input: T
}
export const Input = <T,>({ type, input ...props }: InputProps<T>) => {
return <input {...props} type={type} onChange={handleChangeInput} value={input} />;
};
LoginInput
export const useLoginInput = () => {
const [input, setInput] = useState('');
const isValid = input.length > 8;
const handleChangeInput = (e: React.ChangeEvent<HTMLInputElement>) => {
setInput(e.target.value);
};
return {input, isValid, handleChangeInput};
}
export const LoginInput = () => {
const {input, isValid, handleChangeInput} = useLoginInput();
return (
<>
<Input input={input} onChange={handleChangeInput} type="text"/>
{!isValid ? <div>유효하지 않습니다.<div> : null}
</>
)
}
PointInput
export const usePointInput = () => {
const [input, setInput] = useState('');
const handleChangeInput = (e: React.ChangeEvent<HTMLInputElement>) => {
const validPointInput = Number(e.target.value.replace(/[^0-9]+/g, ''));
if (validPointInput > currentHasPoint) {
alert(`이용가능한 포인트는 ${currentHasPoint}입니다.`);
setInput(currentHasPoint);
return;
}
setInput(validPointInput);
return;
};
const inputFormat = `${input.toLocaleString()}P`;
return {inputFormat, handleChangeInput};
}
export const PointInput = () => {
const {inputFormat, handleChangeInput} = usePointInput();
return (
<>
<Input input={inputFormat} onChange={handleChangeInput} type="number" />
</>
)
}
이러한 관심사의 분리에 익숙하신 분들은 저주
에 잘 걸리지 않지만 익숙하지 않은분들은 자주 걸리는 저주
라고 생각됩니다.
ui를 보고 공통으로 분리하지말고 도메인(관심사)를 보고 분리하자 이정도만 생각하신뒤에 개발을 하시면 잘 걸리시지 않을겁니다. 🐳 🐳
평소 궁금해했던 부분인데 글로 작성해주셔서 감사합니다.
Headless Component + custom hook으로 분리하는 방법은 스스로 생각하시고,깨우치고,적용하신 부분인가요? 아니면 서칭 후 문서를 참고하셔서 Headless Component + custom hook 라는 키워드를 얻으신건가요 ?! 생각의 흐름이 궁금해서 여쭤봅니다!
ui를 보고 공통으로 분리하지말고 도메인(관심사)를 보고 분리하자!
좋은 말씀 감사합니다~ 너무 도움되는 글이었습니다. :D
이글을 리액트 배우기 전에 봤더라면 ,,,