토스처럼 모듈을 분리해보자

윤뿔소·2024년 10월 12일

현재 컴포넌트 분리 기준을 세우고 싶어 토스 유튜브를 찾아 관련 영상을 요약해 보았습니다. 엄청난 실력의 연사님들이 주신 발표 내용을 가공하고, 조합해 저만의 기준을 세우려고 합니다.

더욱 많은 유튜브가 있지만 시간 상 2개만 요약하겠습니다. 추후 영상을 더 추가할 예정입니다.


클린코드란 무엇인가?

실무에서 바로 쓰는 Frontend Clean Code / 진유림 님

원하는 로직을 간단하게, 빠르게 찾을 수 있는 코드

그래서 클린하게 코드를 짜려면 응집도, 단일책임, 추상화를 해주어야합니다.

응집도 : 같은 목적의 코드는 뭉쳐 두자

응집도는 소프트웨어 개념에서 나오는 것인데요. 하나의 모듈이 기능에 집중하기 위한 모든 정보와 역할을 갖고 있어야 한다는 의미입니다. 즉, 기능을 정의 후 관련 정보와 역할을 모두 뭉쳐두어야 합니다.

그렇다고 다 뭉쳐두고, 모든 함수나 컴포넌트를 뭉쳐서 따로 분리해야할까요? 아닙니다. 아래와 같이 적절히 분리해야합니다.

  • 뭉치면 쾌적 : 당장 몰라도 되는 '디테일', '정보', '세부 사항'
  • 뭉치면 답답 : 코드 파악에 필수적인 '핵심 정보', '로직', '액션'

이러한 기준을 잡고 응집도를 늘리면 됩니다. 그렇다면 어떻게 응집도를 올려야할까요?

확실하게 어떤 것을 넘겨주는지 보여주는 제목, 내용, 확인 버튼을 보여주는 것입니다. 다시 말해, 무엇을 보여주고, 넘겨주는지가 중요합니다. 리액트를 하다보면 어디서 많이 느껴봤을텐데 바로 선언적 프로그래밍으로 접근하는 것이 중요합니다.

'무엇'을 하는 모듈인지 빠르게 확인 가능하다는 것

이러한 원칙을 지키면 이제 어떤 것을 뭉치고, 어떤 것을 보여주는지 빠르게 보여줄 수 있는 클린 코드가 됩니다.

추가 : 명령형 프로그래밍은 나쁜 건가요?

나쁘지 않습니다. 명령형은 '어떻게'에 집중하면 됩니다. 예를 들어 React의 JSX 문법을 사용해서 HTML에서도 선언형 프로그래밍을 할 수 있다는 장점이 있지만, props으로 세부적으로 '어떻게' 넘겨야하는지 알 수 있게끔 하는 명령형 설계도 필요합니다.

  • 선언형 (JSX) : '무엇'을 보여줄지 집중

    <Button color="blue">클릭</Button>
  • 명령형 (props) : '어떻게' 동작할지 세부 제어

    const handleClick = () => {
      setIsLoading(true);
      fetchData()
        .then(response => {
          setData(response);
          setIsSuccess(true);
        })
        .catch(error => {
          setError(error.message);
          setIsSuccess(false);
        })
        .finally(() => {
          setIsLoading(false);
        });
    };
    <Button onClick={handleClick} disabled={isLoading}>
      클릭
    </Button>;

두 방식은 상호 보완적이며, 상황에 따라 적절히 사용하는 것이 중요합니다.
다시 말해, 쉽게 생각해서 뭘 보여주고, 뭘 안보여줄지 적절히 조합해 모듈을 짜야합니다. 의도를 담는 것이 중요하죠.

단일책임 : 하나의 모듈, 하나의 역할, 하나의 이름

하나의 모듈에는 하나의 책임만 져야하고, 그 책임을 잘 나타내는 이름을 작성해야함

코드를 읽을 때 그 역할이 제대로 담지 않은 모듈은 클린 코드에 부적합하다고 얘기할 수 있습니다.

async function handle질문제출() {
  const 약관동의 = await 약관동의_받아오기();
  if (!약관동의) {
    await 약관동의_팝업열기();
  }
  await 질문전송(questionValue);
  alert('질문이 등록되었어요.');
  const 연결전문가 = await 연결전문가_받아오기();
  if (연결전문가 !== null) {
    await 연결전문가_질문전송(questionValue);
    alert(`${연결전문가.name}에게 질문이 등록되었어요.`);
  }
}

어때요? 위 함수는 각각 순서대로 약관 체크 후 팝업, 질문 제출, 연결전문가 질문 제출을 하는 로직을 담고 있습니다. 그런데 핸들러 이름은 '질문 제출' 밖에 없네요?

다시 말해, 핸들러 함수의 이름에 맞지않는 역할들이 많아지다보니 정확히 이 함수가 어떤 역할을 하는지 모르는 경우가 발생합니다.
여기서 기능이 더 추가되면 읽기가 어려워지겠죠.

리팩토링 1 : 한 가지 일만 하는, 명확한 이름의 함수

함수는 하나의 뚜렷한 목적만을 가져야 합니다. 함수 이름은 그 목적을 명확히 드러내야 합니다.

async function handle연결전문가질문제출() {
 await 연결전문가_질문전송(questionValue);
 alert(`${연결전문가.name}에게 질문이 등록되었어요.`);
}

async function handle새전문가질문제출() {
 await 질문전송(questionValue);
 alert("질문이 등록되었어요.");
}

async function handle약관동의팝업() {
 const 약관동의 = await 약관동의_받아오기();
 if(!약관동의) {
   await 약관동의_팝업열기();
 }
}

각 함수는 단 하나의 책임만을 가지고 있으며, 그 이름으로 기능을 명확히 알 수 있습니다. 추후 유지/보수도 되게 좋겠네요.

리팩토링2 : 한 가지 일만 하는, 기능성 컴포넌트

button 클릭 이벤트에 API 콜과 Log를 찍는 함수가 2개 담겨져 있습니다.

이렇게 한다면 클릭 이벤트에 API 콜만 생각할 수 있겠네요. 가독성도 좋아지구요.

또, 자동으로 버튼 클릭 시 로그가 출력되겠죠. 로그 출력 역할의 성격을 생각했을 때, 재사용도 많을 테니 훨씬 유용하겠네요!

추상화 : 핵심 개념만 남겨두자

  1. 중요 개념만 남기고 추상화
  2. 추상화 레벨이 비슷한 코드로 만들자. 아니면 리팩토링 하자.

지속 가능한 컴포넌트 만들기

Effective Component 지속 가능한 성장과 컴포넌트 / 한재엽 님

우리는 컴포넌트를 봤을 때, 각각의 역할을 정의할 필요가 있습니다. 변경에 대응하기 위해서요.

그렇다면 컴포넌트는 크게 어떤 역할을 담당할까요? 내외부 데이터 관리, 정의된 데이터를 보여주는 UI 렌더링, 이 UI를 기반으로 사용자와의 상호작용으로 나뉩니다.

export function Button() {
  const [state, setState] = useState(false);

  return (
    <button
      onClick={() => {
        setState(x => !x);
      }}
    >
      버튼 {state ? 'on' : 'off'}
    </button>
  );
}
  1. 데이터 관리

    • const [state, setState] = useState(false);
    • 버튼의 상태를 나타내는 state와 이를 업데이트하는 setState 함수
  2. UI 렌더링

    • <button>버튼 {state ? 'on' : 'off'}</button>
    • 버튼 엘리먼트와 현재 상태에 따른 텍스트 표시
  3. 사용자 상호작용

    • onClick={() => { setState(x => !x); }}
    • 버튼 클릭 시 상태를 토글하는 이벤트 핸들러

이러한 부분들을 나눠서 생각하는 것이 키포인트입니다.

Headless UI 기반 추상화

이러한 달력을 그릴 때 일~월, 각 연도와 월별 날짜 등을 데이터로 정의할 듯 합니다. 이러한 부분을 추상화해 UI와 분리할 수 있습니다.

export default function Calendar() {
  // 데이터 추상화
  const { headers, body, view } = useCalendar()

  return (
    <Table>
      <Thead>
        <Tr>
          {headers.weekDays.map(({ key, value }) => (
            <Th key={key}>{format(value, 'E', { locale })}</Th>
          ))}
        </Tr>
      </Thead>
      <Tbody>
        {body.value.map(({ key, value: days }) => (
          <Tr key={key}>
            {days.map(({ key, value }) => (
              <Td key={key}>{getDate(value)}</Td>
            ))}
          </Tr>
        ))}
      </Tbody>
    </Table>
  )
}

이렇게 말이죠. 이러면 데이터 관리 로직과 UI가 분리됐습니다. 다른 UI에도 재사용도 가능하고, 각각 관심사를 분리해 유지/보수도 용이해졌군요.

이러한 패턴을 Headless 패턴이라고 합니다. 오로지 데이터 관리 로직, UI 퍼블리싱만 집중해서 볼 수 있습니다.

또한, 사용자와의 상호작용도 UI와 분리할 수 있습니다.

interface Props extends ComponentProps<typeof Button> {
  onLongPress?: (event: LongPressEvent) => void;
}

export function PressButton(props: Props) {
  const longPressProps = useLongPress();

  return <Button {...longPressProps} {...props} />;
}

function useLongPress() {
  return {
    onKeyDown: (e) => {
      // ...
    },
    onKeyUp: (e) => {
      // ...
    },
    onMouseDown: (e) => {
      // ...
    },
    onMouseUp: (e) => {
      // ...
    },
  };
}

이렇게 useLongPress를 써서 UI와 분리시켜줍니다. 데이터 추상화와 비슷한 모양이죠? 이렇게 해서 UI를 분리하고 상호작용 만을 정의할 수 있습니다.

이론 조합 : 지속 가능한 클린 컴포넌트 설계 원칙

  1. 응집도 높이기

    • 같은 목적의 코드는 함께 뭉치되, 핵심 정보와 세부 사항을 적절히 분리
    • '무엇'을 하는지 빠르게 파악할 수 있도록 선언적으로 설계
  2. 단일 책임 원칙 적용

    • 각 함수와 컴포넌트는 하나의 명확한 역할만 담당
    • 이름은 그 역할을 정확히 반영 필요
  3. 추상화 레벨 일치시키기

    • 중요한 개념만 남기고 세부 구현 추상화
    • 비슷한 추상화 레벨의 코드로 구성하여 일관성 유지
  4. Headless UI 패턴 활용

    • 컴포넌트를 데이터 관리, UI 렌더링, 사용자 상호작용으로 명확히 분리
      => 단일 책임 원칙에 의거
    • 필요 시 커스텀 훅을 활용해 로직을 UI에서 분리
  5. 재사용성과 유연성 확보

    • 분리된 로직은 다양한 UI 컴포넌트에서 재사용할 수 있도록 설계
    • Props를 통해 필요한 동작을 세부적으로 제어 가능케 해야함
  6. 의도를 명확히 표현

    • 컴포넌트와 함수의 이름, 구조를 통해 코드의 의도를 명확히 전달
    • 선언적 프로그래밍과 명령형 프로그래밍을 상황에 맞게 적절히 조합
profile
코뿔소처럼 저돌적으로

0개의 댓글