[front-end] 공통의 저주

Sming·2023년 11월 21일
124
post-thumbnail

이번에 작성해볼 주제는 공통의 저주입니다.

저도 프론트엔드 개발 초반에 빠졌던 이슈인데요.

처음 개발을 시작하면 dry원칙(don't repeat yourself) 이라는것을 많이 들으면서 개발을 진행할 것 입니다. 물론 이러한 원칙을 듣지않았더라도 반복되는 것은 공통으로 분리해야된다. 이정도는 많이 알게 되죠.

이러한 공통을 지키겠다는 생각에 잡혀있을때 발생하는 문제(저주)에 대해서 알아봅시다.

똑같은 ui의 input

회원가입로그인

간단하게 이렇게 로그인, 회원가입 페이지를 기준으로 한번 보겠습니다.

로그인에 존재하는 아이디, 비밀번호의 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만 존재하는 공통이 있다고 가정해봅시다. (스타일도 적용된 상태라고 가정하겠습니다.)

1️⃣ 첫번째 요청

여기서 기획에서 회원가입할때만 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일때만 저러한 메세지를 밑에 보여주기로 하죠.

2️⃣ 두번째 요청

만약 저 똑같은 인풋을 이용하여 이러한 포인트를 다루는 인풋을 구현한다고 가정해보겠습니다.(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개의 폼들로 예시를 들었지만 여기서 더 늘어난다면 훨씬 더 많은 분기처리를 하게 되죠.


3️⃣ ui변경 요청

자 이제는 기능에 대한 추가가 아니라 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를 보고 공통으로 분리하지말고 도메인(관심사)를 보고 분리하자 이정도만 생각하신뒤에 개발을 하시면 잘 걸리시지 않을겁니다. 🐳 🐳

profile
딩구르르

10개의 댓글

comment-user-thumbnail
2023년 11월 22일

이글을 리액트 배우기 전에 봤더라면 ,,,

1개의 답글
comment-user-thumbnail
2023년 11월 26일

평소 궁금해했던 부분인데 글로 작성해주셔서 감사합니다.

Headless Component + custom hook으로 분리하는 방법은 스스로 생각하시고,깨우치고,적용하신 부분인가요? 아니면 서칭 후 문서를 참고하셔서 Headless Component + custom hook 라는 키워드를 얻으신건가요 ?! 생각의 흐름이 궁금해서 여쭤봅니다!

ui를 보고 공통으로 분리하지말고 도메인(관심사)를 보고 분리하자!
좋은 말씀 감사합니다~ 너무 도움되는 글이었습니다. :D

답글 달기
comment-user-thumbnail
2023년 11월 26일

모발은 소중하니까...

답글 달기
comment-user-thumbnail
2023년 11월 26일

이제껏 코드 품질에 고민하던 내용에 해답을 얻고 갑니다
저는 공통의 저주에 걸려있었네요

답글 달기
comment-user-thumbnail
2023년 12월 1일

지식 +1

답글 달기
comment-user-thumbnail
2023년 12월 1일

한가지 궁금한 점은 보통 Headless Component 는 기능은 가지고 있되 UI를 가지고 있지 않은 컴포넌트를 뜻하지 않던가요? 🤔

1개의 답글
comment-user-thumbnail
2023년 12월 2일
답글 달기
comment-user-thumbnail
2023년 12월 4일

좋아좋아좋아

답글 달기