본문 바로가기
FE

좋은 코드란 무엇일까?(1) - 페이지네이션의 버튼들

by Jiyoon-park 2023. 4. 5.

"지윤님 여기에 처음으로 돌아가는 버튼을 추가해 주세요."

"지윤님 이거 동작이 이상해요!"
"지윤님 요 모달에-"

 

 

 코드는 끝이 없다. 시간이 흘러서 버그이든 기능의 추가든 수정이든 사소한 문구 변화든 어떠한 이유로든 나는 내가 짠 코드를 다시 마주하게 된다. 엉망으로 짠 코드에는 간단한 기능 하나 끼워넣기도 까다로워 부담스러워진다. 다시 들여다보는 시점이 왔을 때 부담스럽지 않으려면 어떻게 해야 할까? 당연한 말이지만 확장 및 수정이 용이할 코드를 짜야한다. 

 

 감사하게도 요 근래 좋은 코드리뷰를 많이 받으면서, 이렇게 하면 조금 더 확장 및 수정이 용이한 코드에 접근할 수 있겠구나! 깨달음을 얻었다. 간략한 코드 예시를 통해서 코드에서 예상할 수 있는 문제점은 무엇인지, 그리고 해당 코드를 좀 더 가독성을 높이고 확장이 용이한 코드로 개선해 볼 수 있는 방법은 무엇일지-`페이지네이션의 버튼들` 편을 공유해보고자 한다.

 

 


구현해야 할 것

4개의 페이지가 있다. 각 페이지마다 테이블이 그려져 있고, 테이블 아래에는 동일한 페이지네이션 버튼이 존재한다. 페이지네이션 버튼 UI는 [처음으로] [이전으로] [다음으로] 이렇게 세 개의 버튼이 한 줄로 배열되어 있는 버튼 뭉치이다.

 

 

처음 구현한 것

모든 페이지가 동일한 UI를 갖고 있다 보니 아예 하나의 뭉치로 만들어 재사용하면 좋겠다 싶어 [처음으로] [이전으로] [다음으로]세 개의 버튼을 가진 PaginationButtons component를 만들었다.

function PaginationButtons() {
  return (
    <flex>
      <button>처음으로</button>
      <button>이전으로</button>
      <button>다음으로</button>
    </flex>
  )
}

 

그리고 UI와 로직을 분리하기 위해, 각각의 버튼에는 각 버튼을 클릭하면 외부로 어떤 역할을 하는 버튼인지 actionType을 넘겨주는 기능을 달아주었다. 구현은 아래와 같다.

type PaginationActionType = 'start' | 'prev' | 'next';
interface Props {
  onChange: (type: PaginationActionType) => void;
}
​
function PaginationButtons() {
  return (
    <flex>
      <button onClick={() => {onChange('start')}}>처음으로</button>
      <button onClick={() => {onChange('prev')}}>이전으로</button>
      <button onClick={() => {onChange('next')}}>다음으로</button>
    </flex>
  )
}
​

 

PaginationButtons의 사용은 아래와 같은 구조이며, PaginationButtons에서 넘겨받은 actionType을 기준으로 nextPage를 getPageNumber으로 넘겨받아 router로 넘겨주었다.

function Pagination() {
  const router = useRouter();
  
  const handleChange = (type: PaginationActionType) => {
    const nextPage = getPageNumber({ currentPage: page, actionType: type })
    router.push({ query: { ...router.query, page: nextPage }});
  }
  
  return (
    <PaginationButtons onChange={handleChange}/>
  )
}
​
function getPageNumber({
  actionType,
  currentPage,
}: {
  actionType: PaginationActionType;
  currentPage: number;
}) {
  switch (actionType) {
    case 'start':
      return DEFAULT_PAGE;
    case 'next':
      return currentPage + 1;
    case 'prev':
      return currentPage - 1;
    default:
      throw new Error('invalid type');
  }
}

 

이렇게 만들어 놓은 코드들로 각 페이지에 페이지네이션을 붙이기 위해서는 아래 코드들을 복붙 하기만 하면 된다.

function Pagination() {
  const router = useRouter();
  
  const handleChange = (type: PaginationActionType) => {
    const nextPage = getPageNumber({ currentPage: page, actionType: type })
    router.push({ query: { ...router.query, page: nextPage }});
  }
  
  return (
    <PaginationButtons onChange={handleChange}/>
  )
}

어떤가? 나는 코드 리뷰를 받기 전까지, 재사용성이 좋은 + 그리고 각 모듈들마다 책임이 분명한 코드를 만들었다고 생각했다. 하지만 해당 코드에는 현재는 드러나진 않았지만, 추후 드러날 수 있는 문제점들이 있다.

 

코드에서 예상가능한 문제점

문제 1. 확장 및 변경에 용이하지 않다 - 버튼은 무조건 [처음으로] [이전으로] [다음으로]세 개를 세트로만 쓸 수 있다

만약 "[처음으로] 버튼을 A 페이지에선 빼주시고 B 페이지에서만 넣어주세요." 혹은 "C 페이지에서는 [이전으로]와 [다음으로] 버튼 사이에 [숫자] 버튼들을 넣어주세요." 이런 요청이 들어온다면..? 그렇다면 이 코드는 재사용할 수가 없다. 재사용하기 용이하게 만들었다고 생각했으나, 전혀 재사용에 용이한 코드가 아니게 된 것.

 

문제 2. Pagination 기능을 완성하기 위해서 함께 해야만 하는 모듈들이 너무 많다

PaginationButtons도 알아야 하고 PaginationActionsType도 알아야 하고 getPageNumber도 알아야 한다. 강결합된 모듈들이 너무 많다. 이럴 경우 동일하게 확장이 어렵고 유지보수가 어려워진다.

 

 

해결방법

문제 1. 확장 및 변경에 용이하지 않다 - 버튼들의 뭉치-PaginationButtons를 Button으로 조각내자!

 

확장성 및 유지보수를 수월하게 하기 위해서는 사용하는 곳에서 원하는 버튼을 선택하는 책임을 넘기야 한다. 하나의 Buttons 뭉치가 아니라 Button들의 합성으로 버튼들을 조각내주었다.

// as-is
function PaginationButtons() {
  return (
    <flex>
      <button onClick={() => {onChange('start')}}>처음으로</button>
      <button onClick={() => {onChange('prev')}}>이전으로</button>
      <button onClick={() => {onChange('next')}}>다음으로</button>
    </flex>
  )
}
​
// to-be
function Pagination(children: ReactNode) {
  return (
    <flex>
      {children}
    </flex>
  )
}
​
interface Props extends HTMLProps<HTMLButtonElement> {}
function Pagination.StartButton = function StartButton(buttonProps: Props) {
  return (<button {...buttonProps}>처음으로</button>)
}
​
function Pagination.PrevButton = function PrevButton(buttonProps: Props) {
  return (<button {...buttonProps}>이전으로</button>)
}
​
function Pagination.NextButton = function NextButton(buttonProps: Props) {
  return (<button {...buttonProps}>다음으로</button>)
}

이렇게 조각내놓으면, 이제 위에서 예시로 들었던 요청들을 손쉽게 해결할 수 있다.

 

  • "처음으로 버튼을 A 페이지에선 빼주시고 B 페이지에서만 넣어주세요."
  • "C 페이지에서는 이전으로와 다음으로 버튼 사이에 숫자 버튼을 넣어주세요."
// 요청1. `처음으로` 버튼을 A 페이지에선 빼주시고 B 페이지에서만 넣어주세요.
function PagintaionButtons_A () {
  return (
    <Pagination>
      <Pagination.PrevButton />
      <Pagination.NextButton />
    </Pagination>
  )
}
​
function PagintaionButtons_B () {
  return (
    <Pagination>
      <Pagination.StartButton />
      <Pagination.PrevButton />
      <Pagination.NextButton />
    </Pagination>
  )
}
​
// 요청2. C 페이지에서는 이전으로와 다음으로 버튼 사이에 숫자 버튼을 넣어주세요.
function PagintaionButtons_C () {
  return (
    <Pagination>
      <Pagination.StartButton />
      <Pagination.PrevButton />
      // ... 무언가 숫자 버튼들
      <Pagination.NextButton />
    </Pagination>
  )
}

 

문제 2. Pagination 기능을 완성하기 위해서 함께 해야만 하는 모듈들이 너무 많다 - 강결합된 모듈들을 제거하자

 

이전 코드를 보자면, PaginationButtons를 사용하기 위해서는 PaginationActionType을 알아야 한다. 그리고 pagination 기능은 PaginationActionType으로 PaginationButtons와 연결되어 있다. 뿐만 아니라, pagination 기능은 getPageNumber를 알아야만 사용할 수 있다. getPageNumber는 또 PaginationActionType를 알아야만 값을 반환할 수 있다.

 

 

강결합되어 있는 부분들을 정리해 보자면, 아래와 같다

  - PaginationButtons와 PaginationActionType의 강결합

  - Pagination의 handleChange 기능과 getPageNumber의 강결합

  - getPageNumber와 PaginationActionType의 강결합

 

이렇게 UI-유틸-액션이 아주 쫀쫀하게 결합되어 있으니 요 세 개는 꼭 붙어 다녀야만 하는 존재가 되어버렸다. 꼭 붙어 다녀야만 하는 데에도 불구하고, model, util 각각의 기능마다 모듈 파일도 따로 분리시켜 놓으니 응집도도 너무 낮아졌다.

 

 

getPageNumber이나 PaginationActionType가 정말 필요한가?

usePagination hook 내부 모아서 깔끔하게 처리해줘 버리자.

// as-is
function 컴포넌트 () {
  // ...
  const handleChange = (type: PaginationActionType) => {
  const nextPage = getPageNumber({ currentPage: page, actionType: type })
  router.push({ query: { ...router.query, page: nextPage }});
  // ...
}
​
// to-be
function usePaginationState() {
  const router = useRouter();
  const currentPage = router.query['page']
​
  const movePage = (page: number) =>
    router.push({
      query: { ...router.query, page },
    });
​
  return [
    currentPage,
    {
      moveStart: () => movePage(DEFAULT_PAGE),
      movePrev: () => movePage(currentPage - 1),
      moveNext: () => movePage(currentPage + 1),
    }
  ] as const;
}

 

hook 하나의 내부 안에 관련된 로직들을 모아, 모듈들의 의존성이 사라짐으로써 강결합 된 부분들도 풀렸다. UI와 로직도 의존성을 분리해 놓아, 원한다면 usePaginationState는 다른 UI와 사용될 수도 있고, Pagination Buttons UI들 또한 다른 함수, 다른 훅들과 사용될 수 있다.

// 사용
function Pagination() {
  const [page, { movePrev, moveNext }] = usePaginationState()
  
  return (
    <Pagination>
      <Pagination.PrevButton onClick={movePrev}/>
      <Pagination.NextButton onClikc={moveNext}/>
    </Pagination>
  )
}

 

정리

좋은 코드는 확장 및 수정이 용이한 코드이다.

- UI 재사용을 위해서는 component 합성으로 풀어보자

- 강결합된 모듈들이 발견될 경우, 이것은 정말 모듈화를 시키는 것이 맞는지 의심해 보자