Checkbox 컴포넌트 개발기(feat. 3번의 리팩토링)

이은지·2022년 11월 26일
2
post-thumbnail

1️⃣ 처음

✨ 아이디어

  • 블랙박스 같은 컴포넌트
  • antd에서 제공하는 컴포넌트 처럼, 내부 작동 방식을 전혀 알지 못해도 사용할 수 있어야 한다.
  • 컴포넌트이므로 내부에 자신만의 상태를 가져야 한다. 외부에서 상태를 직접 수정하는 건 바람직하지 않다. 상태를 수정할 수 있는 API를 제공하는 방식이 바람직하다.

👩🏻‍💻 구현

commit 링크(전체 코드 확인하기)

  • 컴포넌트 내부에 checked state를 가진다.
  • onCheck , onUncheck 라는 API를 제공한다. 외부에서는 컴포넌트의 작동 방식에 대해 알 필요가 없다. 체크 시 원하는 동작, 체크 해제 시 원하는 동작을 전달하면 컴포넌트가 알아서 실행해준다.
  • checked state가 변경되면 checked 값에 따라 onCheck / onUncheck 중 하나를 호출한다.

당시에 아래와 같은 PR을 작성 했었다.

PR 링크

2️⃣ 첫번째 리팩토링

🧐 리뷰 받은 내용

코드리뷰를 통해 크게 두 가지의 피드백을 받았다.

  1. useDidUpdate hook의 로직을 onToggle 함수로 합칠 수 있을 것 같다.

  2. label 태그의 목적은 input에 대한 정보를 알려주는 것이다. 그러나 현재 label에는 input에 대한 정보가 없이 아이콘만 있다. label이 부적절하게 사용된 게 아닐까?

    웹 접근성을 고려하고자 하는 의도였다면 input, label 태그 대신 aria 속성을 활용해보는 것도 좋겠다.


사실 웹 접근성을 고려해서 input, label 태그를 사용한 건 아니었다. 유저에게 입력을 받는 거니까 input 태그를 사용하는 게 옳다고 생각했다. 시맨틱 태그와 헷갈렸다. <input></input> 은 시맨틱 태그가 아니다.

참고로 label의 존재 이유는 다음과 같다. 유저가 특정 입력 폼에 포커싱할 경우 스크린 리더(컴퓨터 화면의 텍스트를 음성으로 읽어주는 소프트웨어)가 해당 입력 폼에 대한 label의 내용을 읽어준다. 이를 통해 유저는 어떤 데이터를 입력해야 할 지 알 수 있다.

👩🏻‍💻 리팩토링 결과

commit 링크(전체 코드 확인하기)

  • 어차피 내부에 checked state가 있으므로 굳이 input 태그를 사용하지 않아도 되겠다고 판단했다. (e.target.checked 를 사용할 필요가 없음)
  • useDidUpdate hook의 로직을 onToggle 에 합쳤다. 그런데 합치는 과정에서 한 가지 고민이 있었다. 바로 useState가 비동기로 작동한다는 점이었다.
    const onToggle = (e: ChangeEvent<HTMLInputElement>) => {
        setChecked(e.target.checked);
        if (checked) {
          onCheck();
          return;
        }
        onUncheck();
    };
    이렇게 코드를 작성했을 때 내가 예상한 동작은 다음과 같다.
    1. setChecked 로 state가 업데이트된다.
    2. 업데이트 된 checked 값이 true 라면 onCheck 를, false라면 onUncheck 를 호출한다.
    그러나 실제로는 내 예상과 정반대로 동작했는데, 그 이유는 useState가 비동기로 동작하기 때문이었다. 리액트에서는 렌더링을 줄이고자 배치 기법을 사용한다. 즉 변경된 값들을 모아 한 번에 업데이트를 진행한다. 이때문에 useState는 비동기로 작동하는 것이다.(리액트의 batching에 대한 참고 링크)
    따라서 코드를 다음과 같이 작성했다.
    const onToggle = () => {
        setChecked((prev) => !prev);
        if (!checked && onCheck) { 
    		// setChecked에 의해 변경되기 전 상태가 false라면 onCheck를 호출한다.
    		// 물론 이 부분은 나중에 지적 받았다.
          onCheck();
          return;
        }
        if (onUncheck) {
          onUncheck();
        }
    };

3️⃣ 두번째 리팩토링

🧐 리뷰 받은 내용

리팩토링한 코드를 메인 브랜치에 머지 했는데, 다른 팀원분께서 위에서 고민한 부분에 대해 말씀을 주셨다. 비동기로 작동하는 useState의 값에 따라 함수 호출을 분기하는 건 적절하지 않다.

또 이렇게 컴포넌트 내부에 state가 있을 경우 onChange 로직이 컴포넌트 내부, 외부에 중복적으로 존재하게 된다는 단점을 말씀해주셨다.

👩🏻‍💻 리팩토링 결과

commit 링크(전체 코드 확인하기)

  • 다시 input, label을 사용하는 방식으로 변경했다. label이 input에 대한 정보를 포함하지 않고 있는 건 맞지만, label 내의 아이콘을 클릭하면 input이 클릭되어야 했기 때문에 이렇게 구현했다.
  • onToggle 함수를 외부에서 주입 받는다. input 태그에 change이벤트가 발생하면 onToggle 함수를 호출한다.
  • 컴포넌트 내부 state의 존재는 유지했다.

4️⃣ 세번째 리팩토링

내가 개발한 컴포넌트를 직접 사용하는 과정에서 문제점을 발견했다.

이렇게 생긴 컴포넌트를 개발해야 했는데, 저 전체 선택 버튼을 구현할 수 없었다.

해당 버튼은 두 가지 경우에 상태가 변경된다.

  1. 해당 버튼이 직접 클릭된 경우
  2. 하위 요소가 전체 선택된 경우/ 전체 선택되었다가 하나라도 체크 해제된 경우

첫번째 경우는 기존의 Checkbox 컴포넌트로 구현할 수 있었지만, 두번째는 불가능했다. 컴포넌트 외부의 상태에 내부 상태를 동기화시켜야 했기 때문이다. 컴포넌트에 발생하는 이벤트를 통해서만 상태가 변경되는 기존의 컴포넌트로는 구현할 수 없었다.

👩🏻‍💻 리팩토링 결과

commit 링크(전체 코드 확인하기)

  • 내부 state를 제거했다. props로 전달 받은 checked 에 의해 컴포넌트의 상태가 변경된다.

✅ 깨달은 점

장장 세 번의 리팩토링을 거쳤고.. 소중한 피드백도 받을 수 있었다 😭 리팩토링은 많이 할수록 좋다고 말씀해주셔서 마음이 따수워졌다

항상 나 혼자 쓸 용으로 컴포넌트를 만들다가, 다른 사람들도 사용할 수 있는 컴포넌트를 만들어야 한다고 하니 고민이 많았다. 그래서 antd, miu, 디프만의 다른 레퍼지토리 등 다양한 리소스를 참고하고, 내가 생각할 수 있는 모든 경우의 수를 고려하려고 했다. 그 노력들이 오히려 삽질로 이어졌다.

리소스를 참고하는 것도, 경우의 수를 고려하는 것도 좋지만 기능의 본질에 집중하는 게 중요하단 걸 느꼈다. 멋들어지게 짜려 하지 말고 심플하게 짜야 한다. 무작정 자료를 뒤지기 보다는 백지에서 혼자 고민하는 시간을 좀 더 가져야겠다. 그리고 고민할 땐 심플하게. 내가 할 수 있는 가장 간단한 방법으로.

필요성을 느끼면 그때 추가하기. 미리 조급해하며 이것저것 덕지덕지 덧붙이지 않기.


작은 컴포넌트 하나 개발하면서 참 알차게 배울 수 있었다. 꼼꼼히 리뷰해주신 팀원 분들한테 정말 정말 감사했다 🥺 컴포넌트의 정의를 이론으로만 아는 것과, 실제로 쓰이는 컴포넌트를 개발하는 건 완전히 달랐다.

또 이번에 이 컴포넌트를 개발하면서 처음으로 테스트 코드도 작성해보았다(!) 테스트 코드에 관한 건 기회가 된다면 별개의 글로 작성해 봐야겠다. 사실 테스트 코드가 필수는 아닌데 욕심이 나서 따라 작성해봤다. 해보길 잘했다 희희. 무튼 나의 첫 컴포넌트 개발기 끝~!

0개의 댓글