페이지네이션 컴포넌트, 모듈 경계부터 그리기

1. 들어가며

왜 페이지네이션을, 왜 순수 JS로

회사에서 웹 접근성 심사 통과가 팀 목표가 됐다. 리드로서 이걸 추진하려면 나부터 제대로 알아야 했는데, 접근성이라는 게 문서만 읽어서는 도무지 손에 잡히지 않았다. aria-current를 붙이라는 건 알겠는데, 왜 붙여야 하는지, 안 붙이면 정확히 무슨 일이 생기는지는 직접 부딪혀보기 전엔 알 수 없는 종류의 지식이었다.

그래서 컴포넌트를 하나 처음부터 끝까지 만들어보기로 했다. 조건은 하나 React 같은 프레임워크 없이, 순수 JavaScript로만.

프레임워크를 뺀 이유는 불편함을 즐기려는 게 아니다. React를 6년 쓰다 보면 어떤 일이 프레임워크의 몫이고 어떤 일이 원래 내 몫이었는지 경계가 흐려진다. 상태가 바뀌면 화면이 따라오는 것, 이벤트가 알아서 정리되는 것, 컴포넌트가 트리에 붙고 떨어지는 것. 이게 당연한 일이 아니라 누군가 해주고 있던 일이라는 걸, 프레임워크가 없어져야 비로소 체감할 수 있다.

대상은 페이지네이션으로 골랐다. 겉보기엔 버튼 몇 개짜리 만만한 UI인데, 만들어보면 알게 된다. 이 안에 상태 관리, 파생값 계산, 렌더링 전략, 모듈 설계, 접근성, 배포 방식까지 컴포넌트 설계의 거의 모든 질문이 압축돼 있다.


2. 페이지네이션이란 무엇인가

페이지네이션(pagination)은 많은 데이터를 한 번에 다 보여주는 대신 일정 개수씩 페이지로 잘라 보여주고, 페이지 사이를 이동할 수단을 제공하는 UI다. 게시판 하단에서 늘 보는 그것이다.

text
[«] [‹] [3] [4] [5] [6] [7] [8] [9] [10] [11] [12] [›] [»]
 │   │  └───────────── 숫자 버튼 (창) ─────────────┘  │   │
처음 이전                                          다음  마지막

구성 요소는 단순하다. 현재 위치를 보여주는 숫자 버튼들, 한 칸씩 이동하는 이전/다음 버튼, 끝으로 점프하는 처음/마지막 버튼.

그런데 이 단순한 UI를 “만든다”는 게 정확히 무엇을 만든다는 뜻일까. 처음엔 나도 페이지 번호를 계산해서 버튼을 그리는 일 정도로 생각했다. 실제로 뚜껑을 열어보면 질문이 네 겹으로 쌓여 있다.

먼저 창 계산의 문제다. 전체가 200페이지인데 버튼은 10개만 보여줄 수 있다면, 지금 화면에 어느 번호들이 있어야 하나? 현재 페이지가 바뀔 때마다 이 목록은 어떻게 움직여야 하나?

그다음은 상태 동기화다. “현재 7페이지”라는 사실과 화면(강조된 버튼, 보이는 번호 목록, 활성화된 네비게이션 버튼)이 언제나 일치해야 한다. 페이지가 바뀌는 경로는 하나가 아니다. 숫자 클릭, 이전, 다음, 처음, 마지막. 어느 경로로 바뀌든 화면 전체가 어긋남 없이 따라와야 한다.

셋째는 상태의 주인이다. “현재 7페이지”라는 상태를 컴포넌트가 가져야 하나, 컴포넌트를 쓰는 쪽이 가져야 하나? 페이지가 바뀌면 서버에 데이터를 요청하는 건 컴포넌트 바깥의 일인데, 그럼 상태도 바깥이 갖는 게 맞나?

마지막은 사용자의 다양성이다. 마우스로 클릭하는 사람만 쓰는 게 아니다. 키보드로 Tab과 Enter를 오가는 사람, 스크린리더로 화면을 듣는 사람에게도 같은 기능이 같은 품질로 전달돼야 한다.

이 글에서 앞의 세 질문을 설계로 풀고, 네 번째 질문이 그 설계에 어떻게 개입하는지를 중간중간 보게 될 것이다. 접근성은 설계가 끝난 뒤 얹는 토핑이 아니었다. 설계 한가운데서 결정을 뒤집는 변수였다.


3. 기존 라이브러리들은 어떻게 만들고 있나

내 답을 만들기 전에 확인하고 싶은 게 있었다. “페이지네이션 컴포넌트를 만든다”는 게 업계에서는 무엇을 의미하는가.

성격이 최대한 갈리도록 여덟 곳을 골라 공식 문서와 공개 타입 정의를 읽었다.

대상 성격
MUI 완제품 컴포넌트 라이브러리
Ant Design 완제품 컴포넌트 라이브러리
GitHub Primer 자사 디자인 시스템
Zag.js 헤드리스 (상태 로직만 제공)
Ark UI 헤드리스 (Zag 래퍼)
shadcn/ui 복사해서 쓰는 소스 제공형
USWDS 미국 정부 디자인 시스템
TanStack Table 테이블 상태 라이브러리

같은 이름, 다른 물건

여덟 개를 늘어놓고 가장 먼저 눈에 띈 건 기능 차이가 아니라 주는 물건 자체가 다르다는 점이었다. 전부 “페이지네이션”이라는 같은 이름을 달고 있는데, 무엇을 주고 무엇을 안 주는지가 스펙트럼처럼 갈린다.

위치 주는 것 안 주는 것 사례
완제품 로직 + 마크업 + 스타일 없음 MUI, Ant Design, Primer
헤드리스 상태 계산 로직 마크업, 스타일 Zag.js, Ark UI
소스 제공형 마크업 + 스타일 소스 창 계산 로직 shadcn/ui
마크업 규격 HTML/CSS 클래스 규격 JavaScript 전부 USWDS
상태 API 페이지 상태 전이 UI 전부, 페이지 목록 계산까지 TanStack Table

양 끝이 특히 인상적이었다.

USWDS에는 JavaScript가 한 줄도 없다. 미국 정부 디자인 시스템인데, 페이지네이션은 “이런 클래스명으로 이런 HTML을 쓰라”는 규격 문서와 CSS만 준다. 컴포넌트를 코드가 아니라 합의된 마크업 모양으로 배포하는 방식이 있다는 걸 여기서 처음 봤다.

TanStack Table은 페이지 번호 목록을 계산해주지 않는다. 상태 전이 API는 있는데, “지금 화면에 1 … 4 5 6 … 20 중 무엇을 보여줄지” 정하는 함수가 없다. 페이지네이션에서 계산이라 부를 만한 유일한 부분이 의도적으로 빠져 있다.

이 스펙트럼을 기억해두자. 상편 마지막에 내 컴포넌트가 이 스펙트럼 위 어디에 서야 하는지 고민하는 순간이 오고, 하편 전체가 그 이동의 기록이다.

갈리는 것보다 겹치는 것

세부를 들여다보면 오히려 공통점이 더 또렷했다.

창은 전부 ellipsis 방식이다. [1] [...] [4] [5] [6] [...] [20]처럼 양 끝을 고정하고 현재 페이지 주변만 보여준 뒤 나머지를 로 접는다. 여덟 곳 중 창 계산을 하는 모든 곳이 이 모양이었다.

접근성 마크업도 거의 통일돼 있다. <nav aria-label="...">으로 감싸고, <ul>/<li> 목록 구조를 쓰고, 현재 페이지에 aria-current="page"를 붙인다.

항목은 <button> 아니면 <a>로 갈린다. 링크를 쓰는 곳은 페이지 상태를 URL에 두는 모델이고, 버튼을 쓰는 곳은 콜백 모델이다. 요소 선택이 곧 상태 설계였다.

정리하면 페이지네이션은 성숙한 컴포넌트다. 마크업 관용구가 수렴해 있고 API 형태도 예측 가능하다.

그런데 “왜”가 없다

여기까지 보면 자연스러운 결론은 “그럼 MUI 쓰면 되겠네”다. 실무에서는 대체로 맞는 결론이다.

하지만 문서를 아무리 읽어도 정작 알고 싶은 건 나오지 않았다. 여덟 곳이 무엇을 골랐는지는 적혀 있는데, 왜 그걸 골랐는지는 어디에도 없다. 왜 다들 ellipsis인가. 왜 Zag는 마크업을 안 주나. 왜 Primer만 상태를 URL에 두나. 문서는 결론만 적는다.

그 이유들을 알기 위해 직접 구현해보기로 했다.


4. 요구사항 정리

과제로 삼은 요구사항은 이렇다.

항목 규칙
숫자 버튼 최대 10칸
창 이동 슬라이딩. 현재 페이지가 창 안의 정해진 위치를 유지하며, 창 전체가 한 칸씩 미끄러진다
경계 시작·끝 페이지 근처에서도 10칸을 유지한다 (창을 줄이지 않는다)
네비게이션 이전 / 다음 / « 처음 / » 마지막, 4개 모두
데이터가 0건일 때 ?
전체가 1페이지일 때 ?

이 요구사항, 업계 표준이 아니었다

앞 장에서 본 대로 여덟 개 라이브러리는 전부 ellipsis 방식이다. 그런데 이 요구사항은 슬라이딩이다.

text
ellipsis:  [1] [...] [6] [7] [8] [...] [20]     ← 양 끝이 항상 보인다
슬라이딩:  [3] [4] [5] [6] [7] [8] [9] [10] [11] [12]   ← 창이 통째로 미끄러진다

두 방식의 실질적 차이는 하나다. ellipsis는 1페이지와 마지막 페이지가 항상 화면에 있고, 슬라이딩은 없다. 7페이지에서 20페이지로 한 번에 가려면 ellipsis에서는 [20]을 누르면 되지만, 슬라이딩에서는 창을 계속 밀어야 한다.

그래서 슬라이딩에서는 « 처음 / » 마지막 버튼이 선택이 아니라 필수가 된다. 요구사항에 네비게이션 버튼이 4개 다 들어 있던 이유가 여기 있었다. 창 방식이 버튼 구성을 이미 결정하고 있었던 것이다. 요구사항 항목들은 겉보기엔 나열이었지만 실제로는 서로를 물고 있는 그물이었다.

적혀 있지 않은 요구사항

조사를 끝내고 요구사항 표를 다시 보니, 여기 적힌 것이 전부가 아니었다.

  • 상태는 누가 갖는가. 표 어디에도 없지만 반드시 정해야 한다.
  • 마크업과 스타일을 얼마나 고정하고 얼마나 소비자에게 넘기는가. 여덟 개 라이브러리를 여섯 위치로 가른 바로 그 축이다.
  • 물음표 두 칸. 0건일 때 [1]을 그릴 것인가? 1페이지뿐일 때 컴포넌트를 숨길 것인가? 라이브러리들은 각자 정해놨지만 근거는 적어두지 않았다.

과제가 준 것은 “무엇을 계산할 것인가”뿐이었고, 남의 것을 열어보고 나서야 “무엇을 계약할 것인가”라는 질문이 있다는 걸 알았다. 이제 이 둘을 함께 설계한다.


5. 설계

코드보다 구조를 먼저 그리다

코드를 열기 전에 결정해야 할 것들을 먼저 늘어놓았다. 창을 어떻게 정렬할지, 무엇을 상태로 기억할지, 어떻게 그릴지, 이벤트를 어떻게 받을지, 코드를 어디에 둘지, 상태의 주인은 누구인지. 이 장이 이 글의 중심이다.

미리 전체 그림을 보여주면 이렇다.

text
        ┌────────────────────── 컴포넌트 내부 ──────────────────────┐
        │                                                          │
사용자   │   renderer ──── intent ────▶ core ◀─── 입력 (totalItems…) │
클릭 ───▶│   (DOM 전담)                (상태 소유)                    │
        │       ▲                       │                          │
        │       │                       ▼                          │
        │       └──── viewModel ──── calculator                    │
        │             (화면 명세)      (순수 계산)                    │
        └──────────────────────────────────────────────────────────┘

상태는 core만 갖고, 계산은 calculator만 하고, DOM은 renderer만 만진다. 그리고 이 셋 사이를 오가는 데이터는 intent(의도)와 viewModel(화면 명세) 딱 두 종류다. 왜 이 모양이 됐는지를 지금부터 결정 순서대로 따라간다.

5.1 창 계산

수식 하나로 모든 경우를 덮는다

숫자 버튼은 최대 10칸이다. 전체가 20페이지고 지금 7페이지라면, 그 10칸에 어떤 숫자가 들어가야 하나.

먼저 현재 페이지를 창 안 몇 번째 칸에 둘지 정해야 한다. 10은 짝수라 정확한 중앙이 없다. 5번째에 두면 앞 4칸/뒤 5칸이고, 6번째면 앞 5칸/뒤 4칸이다. 5번째를 골랐다. 목록 UI에서 사용자의 이동은 ‘다음’ 방향이 압도적으로 잦으니, 남는 한 칸은 진행 방향의 미리보기에 주는 게 낫다고 판단했다.

일반화하면 이렇다.

javascript
const behind = Math.ceil(windowSize / 2) - 1;  // W=10 → 앞 4칸
const ahead  = Math.floor(windowSize / 2);     // W=10 → 뒤 5칸

창 크기가 홀수면 자연스럽게 대칭이 되는 규칙이다.

다음은 경계다. 1페이지에 있으면 “앞 4칸”이 존재하지 않는다. 선택지가 갈린다. 창을 줄여서 [1]~[6]만 그릴 것인가, [1]~[10]으로 유지할 것인가.

줄이지 않기로 했다. 줄이면 경계 근처에서 버튼 개수가 계속 변하고, 그때마다 레이아웃이 흔들린다. 유지하면 clamp 한 번으로 끝난다.

javascript
const behind = Math.ceil(windowSize / 2) - 1;   // 4
const start =
  totalPages <= windowSize
    ? 1
    : clamp(currentPage - behind, 1, totalPages - windowSize + 1);
const end = Math.min(start + windowSize - 1, totalPages);

이 수식 하나가 창에 대한 모든 경우를 처리한다. 1페이지든 마지막 페이지든 중간이든, 전체가 10페이지 이하든. 조건 분기가 단 하나(totalPages <= windowSize)뿐이다.

이 결정에는 당장 보이지 않는 이자가 하나 붙는다. 창 크기가 항상 min(totalPages, 10)으로 고정되면 숫자 버튼의 개수가 변하지 않는다. 이 “구조 불변” 전제가 뒤에 나올 렌더링 전략(5.3)의 바닥이 된다.

5.2 상태 설계

기억은 하나, 나머지는 전부 계산

화면에 그려야 할 것은 네 가지다. 보이는 페이지 목록, 현재 페이지 강조, 네비게이션 버튼 4개의 노출 여부, 전체 페이지 수.

처음 내가 적은 상태 후보 목록은 이랬다.

text
- 창의 시작 페이지 번호 (windowStart)
- 창의 마지막 페이지 번호 (windowEnd)
- 현재 페이지 번호 (currentPage)
- 전체 페이지 수 (totalPages)

그런데 하나씩 따져보니 넷 중 셋은 계산으로 나온다.

  • 창의 시작·끝 → 5.1의 수식이 currentPagetotalPages로부터 만든다
  • totalPages → 입력값인 totalItemspageSize로부터 나온다

그래서 상태는 currentPage 하나로 줄었다. totalItemspageSize는 바깥에서 받는 입력이고, 나머지는 전부 매 렌더마다 다시 계산하는 파생값이다.

text
[입력]  totalItems · pageSize · windowSize        [상태]  currentPage
              │                                      │
              └──────────────────┬───────────────────┘

                          매 렌더마다 계산

     totalPages · 창의 시작/끝 · pages[] · 네비 버튼 표시 여부

“매번 계산하면 느리지 않나”는 여기서 쟁점이 아니다. 산술 몇 줄이다. 진짜 이유는 다른 데 있다.

windowStart를 상태로 저장하는 순간, “currentPagewindowStart는 항상 5.1의 수식 관계를 유지해야 한다”는 불변식을 사람이 지켜야 한다. 페이지가 바뀌는 경로는 다섯 개다: 숫자 클릭, , , «, ». 다섯 군데 전부에서 windowStart를 함께 갱신해야 하고, 하나만 빠뜨리면 이렇게 된다.

»를 눌러 20페이지로 갔는데 windowStart 갱신을 깜빡했다. 창은 여전히 3~12이고, 강조돼야 할 20번 버튼은 화면에 없다.

이런 버그의 고약한 점은 코드 리뷰로 잡기 어렵다는 것이다. 다섯 경로 각각의 코드는 그 자체로 멀쩡해 보인다. 문제는 코드가 아니라 코드 사이에 걸쳐 있는 암묵적 규칙에 있다.

파생값으로 두면 이 불변식을 수식이 강제한다. windowStart는 저장된 적이 없으므로 애초에 어긋날 방법이 없다. 상태 하나를 줄인다는 건 결국 사람이 지켜야 할 규칙 하나를 지운다는 뜻이었다.

짚어두자면, 여기서 줄인 건 동기화 부담이지 계산 비용이나 렌더링 비용이 아니다. DOM을 얼마나 만질지는 다음 결정에서 따로 정한다. “파생값이니까 매번 전체를 다시 그린다”로 이어지면 그건 다른 축의 결정을 섞는 것이다.

5.3 렌더링 전략

결정타는 성능이 아니라 포커스였다

currentPage가 바뀌면 화면을 갱신해야 한다. 방법은 둘이다.

  1. 전체 재렌더: 컨테이너를 비우고 처음부터 다시 그린다. 코드가 단순하다.
  2. 변경분 갱신: 이미 있는 요소의 내용과 속성만 바꾼다.

처음 든 근거는 이랬다. 페이지가 바뀌어도 구조는 거의 안 변한다. 숫자 버튼은 텍스트만 바뀌고, 네비게이션 버튼은 보일지 말지만 정하면 된다. 5.1의 clamp 덕분에 버튼 개수까지 고정돼 있으니 “구조는 고정, 내용만 변화”가 성립한다. 그러니 변경분 갱신이 자연스럽다.

그런데 이건 “변경분 갱신을 해도 된다”는 근거이지 “해야 한다”는 근거가 아니다. 버튼 열네 개짜리 UI에서 전체 재렌더의 성능 비용은 사실상 0이다. 성능이 이유라면 단순한 쪽(전체 재렌더)을 고르는 게 맞다.

결정타는 다른 곳에서 나왔다.

전체 재렌더는 포커스를 얹은 요소를 파괴한다. 키보드 사용자가 버튼에 포커스를 두고 Enter를 눌렀다고 하자. 페이지가 넘어가고, 컨테이너가 비워지고, 새 버튼이 생긴다. 이전 버튼은 이미 DOM에서 사라졌고 포커스는 body로 떨어진다. 다음 페이지로 가려고 Enter를 한 번 더 누르려던 사용자는, 문서 맨 처음부터 Tab을 다시 눌러야 한다.

포커스를 저장했다가 복원하는 코드를 넣어 해결할 수는 있다. 하지만 그러면 전체 재렌더를 고를 유일한 이유였던 단순함이 사라진다. 복잡한 복원 로직을 얹은 전체 재렌더보다는, 애초에 요소를 부수지 않는 변경분 갱신이 낫다. 변경분 갱신으로 결정.

렌더링 전략은 계산도 접근성도 아닌 순수한 설계 질문처럼 보였는데, 결정타를 쥔 건 접근성이었다. 설계를 정하려는데 접근성이 이미 답을 갖고 있었던 셈이다.

5.4 갱신 경로는 하나

viewModel과 멱등 update

변경분 갱신에는 함정이 있다. 이벤트마다 필요한 부분을 손으로 패치하기 시작하면 이렇게 된다.

text
‹ 클릭 → 숫자들 갱신 + 강조 이동 + (1페이지 도달 시) 이전·처음 버튼 숨김
» 클릭 → 숫자들 갱신 + 강조 이동 + 다음·마지막 버튼 숨김 + 이전·처음 버튼 표시
숫자 클릭 → 강조 이동 + (창이 밀렸으면) 숫자들 갱신 + ...

이벤트 종류만큼 갱신 코드가 생기고, 각 갱신 코드는 “이 이벤트에서는 화면의 어느 부분이 바뀌더라”라는 사람의 기억에 의존한다. 5.2에서 상태 층의 갱신 누락을 없앴는데, 같은 버그가 DOM 층에서 그대로 부활하는 구조다. 이번엔 상태가 아니라 화면이 어긋난다.

그래서 갱신 경로를 하나로 만들었다.

javascript
renderer.update(getViewModel({ currentPage, totalItems, pageSize }));

update()는 viewModel 하나를 받아 화면 전체를 그 명세와 일치시킨다. 어떤 경로로 페이지가 바뀌었는지 모르고, 알 필요도 없다. viewModel은 이렇게 생겼다.

javascript
{
  totalPages: 20,
  pages: [3, 4, 5, 6, 7, 8, 9, 10, 11, 12],  // 창에 보일 번호들
  currentPage: 7,                              // 강조할 번호
  showPrev: true,   // ‹
  showNext: true,   // ›
  showFirst: true,  // «
  showLast: true,   // »
}

중요한 성질이 하나 있다. 같은 viewModel로 몇 번을 호출하든 결과가 같다(멱등). update가 받는 건 “무엇을 바꿔라”라는 명령이 아니라 “화면은 이 모습이어야 한다”는 선언이다. 이벤트 종류만큼 갱신 코드가 생기는 대신, 갱신 코드는 하나이고 입력만 달라진다.

철회한 설계

“리렌더링 방지 모듈”

여기서 하나 고백할 것이 있다. 처음 설계에는 모듈이 하나 더 있었다. “React 역할을 해주는, 불필요한 리렌더링을 막아주는 계층.” React에서 하던 대로 memo 같은 최적화 층을 두려고 했다.

그런데 이건 React의 렌더 모델을 바닐라에 잘못 이식한 것이었다.

React에서는 상태가 바뀌면 프레임워크가 컴포넌트 함수를 다시 실행한다. 내가 원하든 아니든 실행되니까, memouseMemo로 막을 대상이 존재한다. 바닐라에서는 렌더가 내가 update()를 호출할 때만 일어난다. 막을 리렌더가 애초에 없다.

필요한 건 같은 상태로 두 번 호출됐을 때 건너뛰는 것 정도인데, 그건 조건문 한 줄이다.

javascript
const key = `${currentPage}/${totalItems}/${pageSize}`;
if (key === lastRenderedKey) return;
lastRenderedKey = key;

모듈 하나가 조건문 한 줄로 줄었다. 프레임워크를 걷어내고 만들어보기로 한 이유가 정확히 이런 자리다. React를 쓰며 당연하게 여기던 구조 중 어떤 것이 문제의 성질에서 나온 것이고 어떤 것이 프레임워크의 사정에서 나온 것인지는, 프레임워크가 없을 때 비로소 갈린다.

5.5 이벤트 바인딩

버튼마다 붙일 것인가, 하나로 받을 것인가

이제 입력 쪽이다. 버튼이 14개다(숫자 10 + 네비 4). 클릭을 받는 방식은 둘이다.

  1. 개별 바인딩: 버튼을 만들 때마다 addEventListener를 붙인다.
  2. 이벤트 위임(delegation): 컨테이너(<nav>)에 리스너 하나만 두고, 이벤트 버블링을 이용해 실제 눌린 버튼을 알아낸다.

위임을 골랐다. 근거는 셋이다.

가장 먼저, 변경분 갱신과 궁합이 맞는다. 5.3에서 버튼 노드를 재사용하며 내용만 바꾸기로 했다. 개별 바인딩이면 “이 노드의 리스너가 여전히 올바른 페이지를 가리키는가”를 갱신 때마다 신경 써야 한다. 클로저로 페이지 번호를 캡처한 리스너는 노드가 재사용되는 순간 낡은 번호를 들고 있는 시한폭탄이 된다. 위임에서는 리스너가 번호를 기억하지 않는다. 눌리는 순간 DOM에서 읽는다.

다음으로, 생명주기 관리가 사라진다. 리스너는 컨테이너에 하나뿐이니, 정리(destroy)할 때도 하나만 떼면 된다. 버튼 단위의 부착·해제 추적이 통째로 없어진다.

마지막으로, 버튼은 선언만 하고 해석은 한 곳에서 한다. 버튼이 자기 의미를 데이터 속성으로 선언하면, 리스너는 그걸 읽어 의도(intent)로 변환한다.

javascript
// renderer 내부: 컨테이너에 단 하나의 리스너
nav.addEventListener('click', (e) => {
  const button = e.target.closest('button[data-page], button[data-action]');
  if (!button || button.hidden) return;
  const intent = button.dataset.page
    ? { type: 'goto', page: Number(button.dataset.page) }
    : { type: button.dataset.action };   // 'prev' | 'next' | 'first' | 'last'
  onIntent(intent);   // core에게 넘긴다
});

여기서 설계상 중요한 선택이 하나 숨어 있다. DOM 이벤트를 core에 그대로 넘기지 않고, 의도(intent)로 번역해서 넘긴다. core가 받는 것은 { type: 'goto', page: 5 }이지 MouseEvent가 아니다.

왜 이 번역 층을 두나. core가 MouseEvent를 받는 순간 core는 DOM을 아는 코드가 된다. 그러면 core를 테스트할 때 DOM 이벤트를 흉내 내야 하고, 나중에 클릭이 아닌 다른 입력 경로(키보드 단축키, 외부 API 호출, URL 변경)가 생기면 각각을 core가 따로 해석해야 한다. 의도로 번역해두면 어떤 입력이든 같은 어휘로 수렴한다. 클릭이든 단축키든 결국 { type: 'next' } 하나다.

의도의 어휘는 다섯 개로 닫혀 있다.

text
{ type: 'goto', page: n } | { type: 'prev' } | { type: 'next' } | { type: 'first' } | { type: 'last' }

마지막으로, 키보드 처리 비용이 얼마나 드는지 보자. 0이다. 항목을 진짜 <button type="button">으로 만들었기 때문에 Tab 이동, Enter·Space 활성화가 전부 브라우저 네이티브로 동작하고, 활성화는 click 이벤트로 합성돼 위 리스너로 들어온다. keydown 핸들러가 한 줄도 없는 이유다. <div tabindex="0">으로 만들었다면 저걸 전부 손으로 다시 구현해야 했다. 결국 요소를 올바르게 고르는 것 자체가 가장 싼 이벤트 설계였던 셈이다.

5.6 모듈 경계

세 조각, 두 채널, 다섯 규칙

여기까지의 결정을 코드 단위로 배치할 차례다. 결과는 파일 셋이다.

text
src/calculator.js   순수 계산: 상태도 DOM도 모른다
src/renderer.js     DOM 전담: 계산 규칙을 모른다
src/core.js         컨트롤러: 상태를 소유하고 둘을 연결한다

각 모듈이 무엇을 알고 무엇을 모르는지 표로 못 박아두자. 모듈 경계는 “무엇을 아는가”보다 “무엇을 모르는가”로 정의하는 편이 오래간다.

모듈 아는 것 모르는 것 책임
calculator 창 수식, 파생 규칙 상태 저장, DOM, 이벤트 입력 → viewModel 계산
renderer DOM 구조, 이벤트 위임 창 수식, 현재 상태, 왜 바뀌었는지 viewModel → 화면 반영, 클릭 → intent 번역
core 상태(currentPage), intent 해석 DOM 세부 구조, 계산 세부 상태 소유, 흐름 조율

의존은 한 방향뿐이다.

graph LR
  Core -->|"getViewModel() 호출"| Calculator
  Core -->|"update(viewModel)"| Renderer
  Renderer -.->|"onIntent(intent) 콜백"| Core

calculator와 renderer는 서로의 존재를 모른다. calculator는 순수 함수라 테스트에 브라우저가 필요 없고, renderer는 viewModel만 받으므로 창 수식이 통째로 바뀌어도 손댈 일이 없다.

통신 채널은 둘뿐이다

이 구조에서 모듈 경계를 넘는 데이터는 정확히 두 종류다.

채널 방향 실체 의미
intent renderer → core { type: 'goto', page: 5 } “사용자가 이걸 원한다” (요청)
viewModel core → renderer { pages, currentPage, showPrev, ... } “화면은 이 모습이어야 한다” (명세)

이 둘 외의 경로(renderer가 core의 상태를 직접 읽거나, core가 renderer의 DOM을 직접 만지는 경로)는 존재하지 않는다. 채널이 좁고 방향이 정해져 있으면 버그가 났을 때 용의자의 범위가 즉시 좁혀진다. 화면이 이상하면 viewModel을 찍어보면 된다. viewModel이 맞는데 화면이 틀리면 renderer 잘못이고, viewModel부터 틀렸으면 calculator나 core 잘못이다.

클릭 한 번의 전체 여정을 시퀀스로 그리면 이렇다.

sequenceDiagram
  participant U as 사용자
  participant R as renderer
  participant C as core
  participant Calc as calculator
  U->>R: click (data-page="8")
  R->>C: intent { type: 'goto', page: 8 }
  C->>C: currentPage = 8 (범위 검증 후)
  C->>Calc: getViewModel(8, ...)
  Calc-->>C: viewModel
  C->>R: update(viewModel)
  R-->>U: 화면 갱신 (변경분만, 포커스 유지)

다섯 규칙

지금까지의 결정을 규칙으로 명문화하면 다섯 줄이다. 이 다섯 줄이 이 컴포넌트 설계의 전부라고 해도 된다.

  1. 상태는 core에만 있다. currentPage 하나. renderer는 상태를 저장하지 않는다. DOM은 출력이지 저장소가 아니다.
  2. 파생값은 저장하지 않는다. 창의 위치, 버튼 표시 여부는 매번 계산한다. 동기화할 대상을 만들지 않는다.
  3. 화면 변경 경로는 update(viewModel) 하나다. 이벤트별 수동 패치는 없다. update는 멱등이다.
  4. 경계를 넘는 데이터는 intent와 viewModel 둘뿐이다. 그 외의 참조는 만들지 않는다.
  5. calculator는 순수 함수다. 같은 입력엔 같은 출력. 부수 효과 없음.

규칙의 가치는 지금이 아니라 나중에 드러난다. 예를 들어 “페이지당 개수를 바꾸는 셀렉트를 추가해달라”는 요구가 온다면, 규칙 4에 따라 intent 어휘에 { type: 'resize', pageSize }가 추가되고, 규칙 2에 따라 totalPages 파생이 자동으로 따라오고, 규칙 3에 따라 화면은 기존 update가 그대로 처리한다. 어느 모듈의 어느 자리가 바뀔지 코드를 열기 전에 답할 수 있다.

5.7 상태의 주인은 누구인가

controlled / uncontrolled

마지막으로, 3장에서 발견한 숨은 요구사항이 내 문제가 됐다. currentPage를 컴포넌트가 갖게 할 것인가, 컴포넌트를 쓰는 쪽이 갖게 할 것인가.

React의 value / defaultValue 쌍으로 익숙한 그 갈림길이다. 바깥이 상태를 쥐고 매번 주입하면 controlled, 컴포넌트가 스스로 쥐면 uncontrolled.

둘 다 지원하기로 했다. page를 주입하면 controlled, 생략하면 uncontrolled.

근거는 실제 사용 장면이다. 페이지가 바뀌면 소비자는 서버에 그 페이지의 데이터를 요청해야 한다. 즉 currentPage에 실질적으로 반응하는 주체는 데이터를 가져오는 바깥쪽이다. 그렇다고 uncontrolled를 없애면, 단독 데모처럼 상태의 소유자가 따로 없는 경우가 불편해진다.

다만 이 선택에는 청구서가 따라온다. 같은 콜백이 모드에 따라 다른 뜻을 갖게 된다.

모드 onPageChange의 의미
uncontrolled 페이지가 바뀌었다는 통지 (완료 보고)
controlled 페이지를 바꾸고 싶다는 통지 (의도 전달). 바깥이 새 page를 주입해야 화면이 바뀐다

같은 이름의 콜백이 두 가지 의미를 가지면 API 표면도, 테스트 매트릭스도, 문서 분량도 두 배가 된다. 이 비용을 알고도 “둘 다”로 갔다.

이 결정은 하편에서 다시 열린다. 컴포넌트를 “남에게 주는 물건”으로 만들면서, 위에 적은 근거가 사실은 controlled를 고를 근거가 못 된다는 걸 발견하게 된다. 어느 쪽으로 뒤집히는지는 하편에서.

5.8 경계 케이스

특례 코드가 없어야 규칙이 맞게 잡힌 것이다

요구사항 표의 물음표 두 칸이 남았다.

전체가 0건일 때. [1]을 그리는 선택지도 있다. 컴포넌트 자리가 유지되니 레이아웃이 안 흔들린다. 하지만 그건 존재하지 않는 페이지를 가리키는 버튼이다. 아무것도 그리지 않기로 했다. 탐색할 대상이 없는데 탐색 UI를 그리는 건 거짓 정보다. “결과가 없습니다”를 보여주는 건 목록 쪽의 책임이지 페이지네이션의 책임이 아니다.

전체가 1페이지일 때. [1] 하나만 그린다. 그런데 여기서 마음에 들었던 건 결론이 아니라 특례 코드가 필요 없었다는 사실이다. 네비게이션 버튼 4개는 이미 일반 규칙을 갖고 있다.

javascript
showPrev:  currentPage > 1,
showNext:  currentPage < totalPages,
showFirst: currentPage > 1,
showLast:  currentPage < totalPages,

1페이지뿐이면 이전도 다음도 없으니 넷 다 자동으로 숨는다. if (totalPages === 1) 같은 분기가 코드 어디에도 없다.

경계 케이스가 일반 규칙에 흡수되면, 보통 그 규칙이 맞게 잡혔다는 신호다. 반대로 경계마다 분기가 하나씩 붙기 시작하면 규칙이 잘못 잡혔다는 뜻으로 읽으면 된다.


6. 구현

팩토리 함수로 조립하기

설계가 끝났으니 조립은 빠르다. 배포 형태는 흔한 팩토리 함수 방식이다.

함수를 호출하면 컴포넌트 인스턴스를 만들어 돌려준다.

calculator: 5.1과 5.2가 그대로 코드가 된다.

javascript
// calculator.js: 순수 함수. DOM도 상태도 모른다.
export function getViewModel({ currentPage, totalItems, pageSize, windowSize = 10 }) {
  const totalPages = Math.ceil(totalItems / pageSize);
  if (totalPages <= 0) {
    return { totalPages: 0, pages: [], currentPage: 0,
             showPrev: false, showNext: false, showFirst: false, showLast: false };
  }
  const behind = Math.ceil(windowSize / 2) - 1;
  const start = totalPages <= windowSize
    ? 1
    : clamp(currentPage - behind, 1, totalPages - windowSize + 1);
  const end = Math.min(start + windowSize - 1, totalPages);
  const pages = [];
  for (let p = start; p <= end; p += 1) pages.push(p);
  return {
    totalPages,
    pages,
    currentPage,
    showPrev:  currentPage > 1,
    showNext:  currentPage < totalPages,
    showFirst: currentPage > 1,
    showLast:  currentPage < totalPages,
  };
}
const clamp = (v, min, max) => Math.min(Math.max(v, min), max);

renderer: 처음 한 번 구조를 만들고(mount), 이후엔 내용만 갱신한다(update). 5.3의 “구조 고정, 내용만 변화”와 5.5의 이벤트 위임이 여기 들어 있다.

javascript
// renderer.js DOM 전담. 계산 규칙을 모른다.
export function createRenderer(container, { windowSize, onIntent }) {
  const nav = document.createElement('nav');
  nav.setAttribute('aria-label', '페이지네이션');
  // 구조는 mount에서 한 번만 만든다. 버튼 개수는 이후 변하지 않음.
  const first = navButton('«', 'first', '첫 페이지');
  const prev  = navButton('‹', 'prev',  '이전 페이지');
  const pageButtons = Array.from({ length: windowSize }, () => {
    const b = document.createElement('button');
    b.type = 'button';
    return b;
  });
  const next  = navButton('›', 'next', '다음 페이지');
  const last  = navButton('»', 'last', '마지막 페이지');
  nav.append(first, prev, ...pageButtons, next, last);
  // 이벤트 위임
  nav.addEventListener('click', (e) => {
    const button = e.target.closest('button[data-page], button[data-action]');
    if (!button || button.hidden) return;
    onIntent(button.dataset.page
      ? { type: 'goto', page: Number(button.dataset.page) }
      : { type: button.dataset.action });
  });
  container.append(nav);
  return {
    // viewModel과 화면을 일치시킨다.
    update(vm) {
      first.hidden = !vm.showFirst;
      prev.hidden  = !vm.showPrev;
      next.hidden  = !vm.showNext;
      last.hidden  = !vm.showLast;
      pageButtons.forEach((b, i) => {
        const page = vm.pages[i];
        b.hidden = page === undefined;
        if (page === undefined) return;
        b.textContent = page;
        b.dataset.page = page;
        if (page === vm.currentPage) b.setAttribute('aria-current', 'page');
        else b.removeAttribute('aria-current');
      });
    },
    destroy() {
      nav.remove();
    },                 // 리스너도 노드와 함께 정리된다
  };
}
function navButton(symbol, action, label) {
  const b = document.createElement('button');
  b.type = 'button';
  b.textContent = symbol;
  b.dataset.action = action;
  b.setAttribute('aria-label', label);
  return b;
}

core: 상태를 소유하고 흐름을 조율한다. 5.6의 두 채널과 5.7의 두 모드가 여기 있다.

javascript
// core.js: 상태 소유자. intent를 해석하고 두 모듈을 연결한다.
import { getViewModel } from './calculator.js';
import { createRenderer } from './renderer.js';

export function createPagination({
  container, totalItems, pageSize, windowSize = 10,
  page,                       // 주입하면 controlled, 생략하면 uncontrolled
  onPageChange,
}) {
  const controlled = page !== undefined;
  let currentPage = page ?? 1;
  let lastRenderedKey = null;
  const renderer = createRenderer(container, { windowSize, onIntent: dispatch });
  function totalPages() {
    return Math.ceil(totalItems / pageSize);
  }
  // intent → 목표 페이지 번호
  function resolve(intent) {
    const map = {
      goto:  () => intent.page,
      prev:  () => currentPage - 1,
      next:  () => currentPage + 1,
      first: () => 1,
      last:  () => totalPages(),
    };
    const target = map[intent.type]?.();
    return target >= 1 && target <= totalPages() ? target : null;   // 범위 밖은 무시
  }
  function dispatch(intent) {
    const target = resolve(intent);
    if (target === null || target === currentPage) return;
    if (controlled) {
      onPageChange?.(target);   // "바꾸고 싶다"는 의도만 통지, 화면은 바깥이 결정
      return;
    }
    currentPage = target;
    onPageChange?.(target);     // "바뀌었다"는 완료 통지
    render();
  }
  function render() {
    const key = `${currentPage}/${totalItems}/${pageSize}`;
    if (key === lastRenderedKey) return;         // 5.4의 조건문 한 줄
    lastRenderedKey = key;
    renderer.update(getViewModel({ currentPage, totalItems, pageSize, windowSize }));
  }
  render();
  return {
    update(next) {              // controlled의 page 주입, 데이터 개수 변경이 이 문으로 들어온다
      if (next.page !== undefined)       currentPage = next.page;
      if (next.totalItems !== undefined) totalItems = next.totalItems;
      if (next.pageSize !== undefined)   pageSize = next.pageSize;
      render();
    },
    destroy: renderer.destroy,
  };
}

사용하는 쪽은 이렇다.

javascript
const pagination = createPagination({
  container: document.querySelector('#pagination'),
  totalItems: 195,
  pageSize: 10,
  onPageChange: (page) => fetchList(page),
});

동작한다. 창은 수식 하나로 밀리고, 상태는 변수 하나이고, 갱신 경로는 하나이고, 경계 케이스는 분기 없이 흡수된다. 키보드로도 문제없이 조작된다. 일단 혼자 가볍게 쓰기에는 완성이다.


7. 팩토리의 한계

여기에 질문을 하나만 얹어보자. 이걸 남이 쓰면 어떻게 되나. 같은 회사의 다른 팀, 혹은 배포된 걸 처음 보는 사람.

질문 하나에 문제가 넷 딸려 나왔다.

첫째, 배선이 전부 수동이다. 소비자는 컨테이너 요소를 준비하고, DOM이 준비된 시점을 골라 createPagination을 호출하고, 반환된 인스턴스를 어딘가에 보관해야 한다. 페이지네이션이 다섯 군데면 이 배선을 다섯 번 한다. 우리 세 모듈 안에서는 그렇게 아꼈던 “사람이 지켜야 할 규칙”(5.2)이, 컴포넌트 바깥에서 고스란히 소비자의 몫으로 쌓이고 있었다.

둘째, HTML에 선언할 수 없다. 서버가 목록을 HTML로 그려서 내려주는 페이지를 생각해보자. 목록은 처음부터 화면에 있는데, 페이지네이션만은 JS가 로드되고 팩토리가 호출될 때까지 존재하지 않는다. “이 자리에 페이지네이션이 있다”를 마크업에 적을 방법이 없다. 명령형 API의 태생적 한계다.

셋째, 마크업과 스타일이 강제된다. renderer가 그리는 구조가 곧 최종 마크업이다. 디자인이 다른 세 팀이 쓰려면? 클래스를 주입받는 옵션을 열고, 마크업 구조를 바꾸는 옵션을 열고… 옵션이 늘어날수록 API 표면이 부풀어 오른다. 3장에서 본 Ant Design의 30개짜리 props가 어떻게 만들어졌는지 짐작이 가기 시작했다. 결국 표현의 다양성을 옵션으로 다 감당하려 하면 밑 빠진 독에 물 붓기가 된다.

넷째, 생명주기 관리가 소비자 책임이다. SPA에서 화면을 떠날 때 destroy()를 호출하지 않으면 리스너와 노드가 남는다. 호출을 깜빡해도 아무 에러가 없다. 조용히 새기만 한다.

즉, 컴포넌트의 로직은 잘 설계됐지만, 컴포넌트를 전달하는 방식이 설계되지 않았다. 3장의 스펙트럼으로 말하면, 나는 지금 “완제품” 위치에 서 있으면서 완제품이 갖춰야 할 전달 장치(선언적 사용, 스타일 위임, 생명주기 자동화)는 없는 어정쩡한 상태다.

방향은 보였다. 로직은 내가 지키고, 표현은 소비자에게 넘긴다.

3장 스펙트럼의 헤드리스 쪽으로 옮겨가는 것이다. 그리고 선언적으로 쓸 수 있어야 하니, 함수 대신 태그로 전달한다.

html
<my-pagination current-page="3" total-items="195" page-size="10"></my-pagination>

그런데 이렇게 방향을 정하자마자 새로운 질문들이 줄지어 나타났다.

  • 브라우저는 처음 보는 <my-pagination> 태그를 만나면 무엇을 하는가? JS가 로드되기 전과 후에 이 요소는 각각 무엇인가?
  • React의 헤드리스는 훅이 데이터를 뱉으면 JSX가 그려준다. 바닐라에는 그 렌더 층이 없다. 그럼 DOM은 누가 그리는가?
  • HTML 속성에는 문자열밖에 못 담는다. onPageChange 콜백은 어디로 가야 하는가?
  • 조각으로 쪼개서 소비자가 배치하게 한다면, 조각들은 서로를 어떻게 찾고 어떻게 통신하는가? 5.6에서 만든 intent/viewModel 채널은 이 구조에서 살아남는가?
  • 서버가 이 태그를 미리 내려보내면(SSR), JS가 실행되기 전까지 사용자에게 보이는 것은 무엇인가?

이 질문들은 다음편에서 다룰 생각이다. 미리 말해두면, 답을 찾는 과정에서 커스텀 엘리먼트의 upgrade 메커니즘, Shadow DOM과 light DOM의 갈림길, ElementInternals라는 낯선 API, 그리고 SSR의 구멍을 차례로 만나게 된다. 5.7에서 “둘 다 지원한다”로 닫았던 controlled/uncontrolled 결정이 뒤집히는 것도 그 길 위에서다.


마치며

이 글의 결정들을 한 줄씩으로 요약하면 이렇다.

결정 선택 핵심 근거
창 정렬 clamp 수식 하나, 경계에서도 창 유지 분기 최소화 + 버튼 개수 고정이 뒤 결정의 기반
상태 currentPage 하나, 나머지 파생 동기화 규칙을 사람이 아니라 수식이 지키게
렌더링 변경분 갱신 성능이 아니라 포커스 보존
갱신 경로 update(viewModel) 단일 · 멱등 이벤트별 수동 패치의 갱신 누락 차단
이벤트 컨테이너 위임 + intent 번역 노드 재사용과의 궁합, 입력 경로의 수렴
모듈 calculator / renderer / core 채널 2개(intent·viewModel)로 경계를 좁힘
상태 소유 controlled·uncontrolled 겸용 실사용 장면. 단, 하편에서 재검토
경계 케이스 일반 규칙에 흡수 특례 분기가 없어야 규칙이 맞는 것

돌아보면 이 표의 어느 행도 독립적이지 않다. 창 수식이 버튼 개수를 고정했고, 그 고정이 변경분 갱신을 가능하게 했고, 변경분 갱신이 이벤트 위임을 불렀고, 위임이 intent 채널을 만들었다. 설계는 결정의 목록이 아니라 결정 사이의 의존 그래프다. 그리고 그 그래프에서 가장 뜻밖의 노드는 접근성이었다(렌더링 전략의 결정타가 포커스였다는 것).

다음 편에서는 이 잘 만든 팩토리를 스스로 부순다. 헤드리스로 다시 짓는 과정에서, 이번 편에 세운 규칙들이 어디까지 살아남고 어디서 다시 열리는지 지켜봐 주시면 좋겠다.