2023년 2월

바닐라 JS로 페이지네이션

2023년 2월

페이지네이션

HTML, CSS, JavaScript만으로 간단한 페이지네이션을 구현한다.

CSS는 봐달라. 기능 구현에 집중하겠다.

페이지네이션을 하려면 무슨 기능들이 필요할까?

  • 테이블에 펼칠 데이터 fetch.
  • 2번 버튼 클릭 시:
    2번 버튼 색 변경. 1번 버튼 색 원래대로.
    2번 버튼에 적합한 테이블 데이터 호출.
  • 드롭다운 클릭 시:
    15를 클릭 시 데이터가 15개씩 보이도록 변경
    5를 클릭 시 데이터가 5개씩 보이도록 변경
    보던 index에 맞춰서 active index 변경

 이 페이지네이션은 프로그래머스에서 제시한 제한시간 4시간짜리 연습 문제다. 요구사항에 맞춰서 구현 중, 고민이 들었다.

 ‘이래도 되나?’

 물론 실전에선 시간 내 요구 사항 충족이 최우선이다. 그러나 그건 기출 문제일 뿐이고, 나는 공부가 필요했다.
 나는 바닐라 자바스크립트로 좋은 코드를 짤 수 있을까? 내가 아는 지식으로 좋은 페이지네이션을 구현할 수 있을까?

 코드를 다시 보니 지식을 어설프게 활용하는데다, 각 함수가 서로 얽혀서 함수 이름에 걸맞지 않는 사이드 이펙트가 여기저기 뒤섞여 있었다.
 화면만 보면 적당히 작동하지만, 코드가 쓸데없이 복잡했다. 계획과 정리가 부족한 채 서두르고 있었다.

 뒤죽박죽이다. 코드를 수정할 때마다 에러가 나기 일쑤였다. 설령 마지막에 잘 동작이 되더라도, 이렇게 하면 안 된단 생각이 들었다. 정리해서 다시 짜보기로 했다.

변경 후 구조

 handleClick 이벤트 등록은 변경될 일이 없는 부모 노드에 등록했다.

클릭 시 각 이벤트에 반응을 처리해야 한다.
  • case 1: 숫자 버튼도 아니고, 화살표 버튼도 아니라서 아무 반응도 해선 안 된다.
  • case 2: 숫자 버튼에 맞게 index를 변경한다.
  • case 3: 첫 버튼이 눌리면 index를 0으로, 마지막 버튼이 눌리면 마지막 index로 변경.

 case 1을 감수하면서 부모 노드에 이벤트를 등록한 이유는 그게 편했기 때문이다. 내부 숫자 버튼들은 per가 바뀔 때마다 생성해야 하고, 그 때마다 이벤트를 재등록하는 코드를 넣고 싶진 않았다.
 이와 같은 코드는 이벤트 버블링 덕분에 구현할 수 있다.

 실제 이벤트는 버튼에서 일어나지만 버튼 자체에는 아무런 기능이 없다. 하지만 이벤트 버블링을 통해 클릭 이벤트가 상위 노드로 올라가기 때문에 부모 노드가 이벤트 핸들링 함수를 실행한다.

(async () => {
  fetch("./src/data.json")
    .then((res) => res.json())
    .then((result) => {
      data = result;
      setIndex(0);
    });

  let data;
  let index = 0;
  let per = 5;
  const tbody = document.querySelector("tbody");
  const btnBox = document.querySelector(".pagination");
  const select = document.querySelector("#dropdown");

  const lastIndex = () => Math.ceil(data.length / per) - 1;

  //? index에 맞는 데이터, 데이터 길이 반환
  const getDataAndLength = () => {
    let next = (index + 1) * per;
    if (next > data.length) next = data.length;
    const newData = data.slice(index * per, next);
    return [newData, newData.length];
  };

  //? index 변경 => active, table 변경.
  const setIndex = (newIndex) => {
    index = newIndex;
    const [data, length] = getDataAndLength();
    resetTable(length);
    setActive(index);
    setTable(data);
  };

  //? per 비교 변경 => 버튼 컨테이너, 테이블 reset
  const setPer = (newPer) => {
    if (per === newPer) return;
    const newIndex = Math.ceil((per * index + 1) / newPer) - 1;
    per = newPer;
    resetButtons(lastIndex() + 1);
    setIndex(newIndex);
  };

  //? active 버튼 교체
  const setActive = (index) => {
    document.querySelector(".active")?.classList.remove("active");
    const target = document.querySelectorAll(".button")[index];
    target.classList.add("active");
  };

  //? 테이블 내용 주입, 변경.
  const setTable = (data) => {
    const trs = tbody.querySelectorAll("tr");
    for (let i = 0; i < trs.length; i++) {
      const tds = trs[i].querySelectorAll("td");
      const props = Object.values(data[i]);
      for (let j = 0; j < tds.length; j++) {
        tds[j].textContent = props[j];
      }
    }
  };

  //? 테이블 HTML 생성.
  const resetTable = (length) => {
    const tr = "<tr><td></td><td></td><td></td><td></td></tr>";
    const newHTML = tr.repeat(length);
    tbody.innerHTML = newHTML;
  };

  //? 버튼 컨테이너 HTML 생성.
  const resetButtons = (last) => {
    let buttons = "";

    for (let i = 1; i <= last; i++) {
      buttons += `<button class="button">${i}</button>`;
    }

    const newHTML = `<button class="start"><<</button>
  ${buttons}
  <button class="last">>></button>`;

    btnBox.innerHTML = newHTML;
  };

  const handleSelect = (event) => {
    const newPer = Number(event.target.value);
    setPer(newPer);
  };

  //? target이 버튼이면 setIndex
  const handleClick = (event) => {
    const target = event.target.classList.value;
    if (target === "pagination") return;

    switch (target) {
      case "start":
        setIndex(0);
        break;
      case "button":
        const targetIndex = Number(event.target.innerHTML) - 1;
        setIndex(targetIndex);
        break;
      case "last":
        setIndex(lastIndex());
        break;
      default:
        break;
    }
  };

  btnBox.addEventListener("click", handleClick);
  select.addEventListener("click", handleSelect);
})();
1차 코드

 그러나 setIndex, setPer에 여러 함수들을 붙여서 리렌더링하는 모습이 사이드 이펙트와 다름이 없다. 게다가 index, per를 여기저기서 너무 편하게 가져다 쓴다. 내 의도와는 거리가 있다.

(async () => {
  let data;

  fetch("./src/data.json")
    .then((res) => res.json())
    .then((result) => {
      data = result;
      render(data);
    });

  const { getIndex, setIndex, getPer, setPer, isStateUpdated } = (() => {
    let prevState = {
      index: undefined,
      per: undefined,
    };

    let currentState = {
      index: 0,
      per: 5,
    };

    const getIndex = () => currentState.index;
    const setIndex = (newIndex) => {
      setState(newIndex, currentState.per);
    };

    const getPer = () => currentState.per;
    const setPer = (newPer) => {
      setState(currentState.index, newPer);
    };

    const setState = (index, per) => {
      if (currentState.index === index && currentState.per === per) {
        return;
      }

      if (currentState.per !== per) {
        prevState = { ...currentState };
        currentState = { index: 0, per };
      } else {
        prevState = { ...currentState };
        currentState = { index, per };
      }
      render();
    };

    const isStateUpdated = () => {
      if (
        (prevState.index !== currentState.index) |
        (prevState.per !== currentState.per)
      ) {
        return true;
      } else {
        return false;
      }
    };

    return { getIndex, setIndex, getPer, setPer, isStateUpdated };
  })();

  const render = () => {
    if (isStateUpdated()) {
      const [index, per] = [getIndex(), getPer()];
      const [tableData, tableSize] = getTableDataAndSize(index, per, data);
      const buttonIndexes = Math.ceil(data.length / per);
      resetButtons(buttonIndexes);
      resetTable(tableSize);
      setTable(tableData);
      setActive(index);
    } else {
      return;
    }
  };

  //? index에 맞는 테이블 컨텐츠
  const getTableDataAndSize = (index, per, data) => {
    let next = (index + 1) * per;
    if (next > data.length) next = data.length;
    const newData = data.slice(index * per, next);
    return [newData, newData.length];
  };

  //? 버튼 active 업데이트
  const setActive = (index) => {
    document.querySelector(".active")?.classList.remove("active");
    const target = document.querySelectorAll(".button")[index];
    target.classList.add("active");
  };

  //? 테이블 업데이트
  const setTable = (tableData) => {
    const tbody = document.querySelector("tbody");
    const trs = tbody.querySelectorAll("tr");
    for (let i = 0; i < trs.length; i++) {
      const tds = trs[i].querySelectorAll("td");
      const props = Object.values(tableData[i]);
      for (let j = 0; j < tds.length; j++) {
        tds[j].textContent = props[j];
      }
    }
  };

  //? 테이블 HTML 생성.
  const resetTable = (tableSize) => {
    const tr = "<tr><td></td><td></td><td></td><td></td></tr>";
    const newHTML = tr.repeat(tableSize);
    document.querySelector("tbody").innerHTML = newHTML;
  };

  //? 버튼 컨테이너 HTML 생성.
  const resetButtons = (buttonIndexes) => {
    let buttons = "";

    for (let i = 1; i <= buttonIndexes; i++) {
      buttons += `<button class="button">${i}</button>`;
    }

    const newHTML = `<button class="start"><<</button>
  ${buttons}
  <button class="last">>></button>`;

    btnBox.innerHTML = newHTML;
  };

  const handleSelect = (event) => {
    const newPer = Number(event.target.value);
    setPer(newPer);
  };

  //? target이 버튼이면 setIndex
  const handleClick = (event) => {
    const target = event.target.classList.value;
    if (target === "pagination") return;

    switch (target) {
      case "start":
        setIndex(0);
        break;
      case "button":
        const targetIndex = Number(event.target.innerHTML) - 1;
        setIndex(targetIndex);
        break;
      case "last":
        const lastIndex = document.querySelectorAll("button").length - 3;
        setIndex(lastIndex);
        break;
      default:
        break;
    }
  };

  const btnBox = document.querySelector(".pagination");
  const select = document.querySelector("#dropdown");
  btnBox.addEventListener("click", handleClick);
  select.addEventListener("click", handleSelect);
})();
2차 코드

 코드 수는 늘어났지만 원하는 모습에 많이 가까워졌다. 각 함수들이 주석과 이름에 맞춰서 역할에 집중한다.
 이게 최고라는 생각은 안 한다. 하지만 내 수준에선 정성과 고민을 들였다.

  • index, per 는 각 함수들이 공용으로 사용. 그러나 함부로 수정 불가능.
  • 실시간 데이터 업데이트가 발생할 일은 없으므로 data는 처음에 한 번만 받는다.
  • 사이드 이펙트 지양, 의존성 주입, 함수 외부에 변수 선언 최소화
  • 함수는 제 할 일에 집중.
    (에러가 나더라도 해당 영역에서만 에러. 다른 곳까지 여파가 안 미침)
  • 최적화는 신경을 덜 씀.
    (섣부른 최적화는 독만 된다. 정상 작동과 괜찮은 가독성에 초점을 맞췄다.)

 작년에 페이지네이션을 구현할 땐 도저히 모르겠어서 남들이 구현한 페이지네이션 코드를 따라해야만 했다. 이번엔 검색 없이 순전히 내 지식과 생각으로만 구현했다.
 프로그래머스에선 리액트 비스무리하게 만드는 것을 유도하는 듯하다. index.js와 App.js, 그리고 각 컴포넌트 파일들을 통해 새로 렌더링하는 코드를 해설로 제공한다.

마무리

 배움은 의문에서 비롯한다. 특정 패러다임을 염두한 건 아니지만 "어떻게 해야 이 프로그램이 더 원활하게 작동하고, 쉽게 추가하고, 쉽게 고칠 수 있을까?"를 고민하고, 머릿속으로 그려보면서 구현도 구현이지만 설계가 중요하단 느낌을 많이 받았다. 패러다임, 디자인 패턴 등은 이러한 고민에서 시작했고, 지금도 꾸준히 태어나고, 연구된다.

 왜 설계를 알아야 하고 많이 고민해야 할까? 예전부터 중요하다는 이야기는 많이 들었지만 그걸 "제대로" 실감하진 못했다.
 이번 연습 문제는 매우 간단한 구현을 할 뿐인데 가장 많이 든 생각은 이랬다.

‘이거 정말, 설계가 필요한데.’

 단순히 구현만 해놓고 끝낼 것이면 모르겠으나 그 구현체를 계속 관리하고, 남에게 서비스하고, 규모를 넓힐 예정이 있다면 얘기는 달라진다.

 학원에서 팀 프로젝트를 할 때, 우리에게 주어진 시간은 적었고, 우리는 급한 나머지 기획과 설계를 얼렁뚱땅 얼버무린 채 코드 더미를 쏟아냈다. 진척은 느렸고 분위기는 혼란스러웠다.
 코치는 우리가 정말로 충분히 이야기를 나눴는지, 모두가 공감했는지, 밑준비를 했는지 돌아보게끔 조언을 줬다. 우리는 정신을 차리고 처음부터 다시 기획하고 준비를 해서 짧은 기간에 (나름대로는) 만족스러운 결과물을 만들어냈다.

 테스트도 그렇다. 테스트는 "내가 구현해둔 게 의도대로 작동하는지 재확인"하는 게 아니다. "무엇"을 할 때마다 테스트는 내 설계를 검증한다. 내 설계도를 돌아보고, 더 나은 설계를 고민하게 만든다. 그래서 테스트를 작성하며 프로그래밍하길 권장하는 것이고, 그래서 TDD가 주목받았다.

 프로그래밍은 설계가 매우 중요하다. 충분한 설계를 하고 쌓아 올리는 단계에서도 수시로 설계를 확인하며 상황에 맞는 판단을 내려야 한다. HTML, CSS, JS만으로 무언가를 만들 일이 얼마나 있겠냐마는, 이번 연습 문제를 통해서 여러 고민을 머릿속에 담아본다.