페이지네이션 컴포넌트, 헤드리스로 다시 설계하기
전편 줄거리. 상태는 currentPage 하나로 줄이고 나머지는 전부 파생값으로 계산했다. 렌더링은 포커스 보존을 이유로 변경분 갱신을 골랐고, 갱신 경로는 멱등한 update(viewModel) 하나로 통일했다. 코드는 calculator(순수 계산) / renderer(DOM 전담) / core(상태 소유)로 나눴고, 모듈 경계를 넘는 데이터는 intent(사용자의 의도)와 viewModel(화면 명세) 둘뿐이라는 계약을 세웠다.
그리고 마지막에 팩토리 방식의 한계 넷을 만났다. 배선이 전부 수동이고, HTML에 선언할 수 없고, 마크업과 스타일이 강제되고, 생명주기 관리가 소비자 책임이다. 진단은 하나였다. 로직은 설계됐지만, 로직을 전달하는 방식은 설계되지 않았다.
이번 편은 그 전달 방식을 설계한다.
8. 헤드리스로 다시 설계하기
8.1 로직은 지키고, 표현은 넘긴다
상편 3장의 스펙트럼을 다시 꺼내자. 완제품(MUI) → 헤드리스(Zag) → 소스 제공형(shadcn) → 마크업 규격(USWDS). 팩토리 구현의 한계 중 셋째(마크업, 스타일 강제)를 풀려면 이 스펙트럼 위에서 자리를 옮겨야 한다.
같은 회사 안에서도 페이지네이션의 생김새는 화면마다 다르다. 목록 페이지와 검색 결과와 관리자 테이블이 같은 모양일 이유가 없다. 이 다양성을 완제품 위치에서 감당하려면 옵션이 늘어난다. 상편에서 본 Ant Design의 30개짜리 props가 그 끝이다. 표현의 다양성은 옵션이 아니라 위임으로 푸는 게 맞다.
그래서 헤드리스로 간다. 창 계산, 상태 관리, 상태와 화면의 동기화처럼 어떤 디자인에서든 똑같은 이 로직만 라이브러리가 지키고, 생김새는 소비자가 정한다.
8.2 그런데 바닐라의 헤드리스는 React의 헤드리스와 다르다
여기서 잠깐 멈춰야 한다. “헤드리스”라는 말을 React 생태계에서 배웠다면, 바닐라로 오는 순간 그 그림이 절반쯤 무너진다.
React의 헤드리스가 우아하게 성립하는 건 React가 렌더링을 대신 해주기 때문이다.
// React의 헤드리스: 훅이 데이터를 뱉으면, 그리는 건 React가 한다
const { pages, currentPage } = usePagination({ totalItems: 195 });
return pages.map((p) => <button key={p}>{p}</button>);훅은 배열만 돌려준다. 그 배열이 실제 DOM이 되는 과정(요소 생성, 갱신, 제거, 이벤트 정리)은 전부 React의 몫이다. 헤드리스 라이브러리는 “머리(로직)”만 만들면 되고, “몸(렌더링)”은 프레임워크라는 공용 인프라가 제공한다.
바닐라에는 그 공용 인프라가 없다. 로직만 뱉는 라이브러리를 만들면, “그래서 DOM은 누가 그리는가”가 미해결로 남는다. 소비자가 그리게 하면 상편에서 그렇게 공들여 만든 변경분 갱신·포커스 보존·멱등 렌더를 소비자가 전부 다시 구현해야 한다. 로직에서 가장 어려웠던 부분을 넘겨버리는 셈이다.
그래서 바닐라의 헤드리스는 선을 다시 그어야 한다.
로직 + 최소한의 마크업은 라이브러리가 갖고, 배치와 스타일은 소비자가 갖는다.
순수 헤드리스(로직만)와 완제품 사이의 중간 지점이다. 상편 스펙트럼에서 Zag(로직만)와 shadcn(마크업+스타일) 사이, Ark UI가 서 있던 자리이기도 하다.
8.3 전달 형태
함수가 아니라 태그
팩토리 한계의 첫째(수동 배선)와 둘째(선언 불가)와 넷째(생명주기)는 전달 형태의 문제였다. 이 셋을 한 번에 해결하는 플랫폼 기능이 이미 있다. 커스텀 엘리먼트(Custom Elements), 브라우저에 내 태그를 등록하는 표준 API다.
<my-pagination current-page="3" total-items="195" page-size="10"></my-pagination>함수 호출이 태그 선언으로 바뀌면 무엇이 좋아지는지는 다음 장에서 브라우저의 동작과 함께 자세히 본다. 여기서는 형태부터 마저 정하자.
8.4 조각으로 쪼갠다
compound 구조
배치를 소비자가 정하려면 컴포넌트가 통짜여서는 안 된다. 조각으로 쪼개서 소비자가 마크업으로 조립하게 한다. <Tabs> 안에 <Tab>을 놓듯이, compound component 패턴이다.
<my-pagination current-page="3" total-items="195" page-size="10">
<my-first></my-first>
<my-prev></my-prev>
<my-pages></my-pages>
<my-next></my-next>
<my-last></my-last>
</my-pagination>부모(호스트)가 상태를 갖고, 자식 조각들이 그 상태를 받아 각자 자기 부분을 그린다. 소비자는 조각의 순서를 바꾸고, 일부를 빼고, 사이에 자기 요소를 끼워 넣을 수 있다. 이전/다음만 쓰는 미니 버전, 숫자만 쓰는 버전이 마크업 편집만으로 만들어진다.
8.5 첫 충돌
compound와 슬라이딩 창
그런데 설계를 그리자마자 급소가 하나 나왔다.
compound가 우아하게 성립하는 건 자식이 고정이고, 소비자가 직접 쓰기 때문이다. 탭 세 개짜리 UI면 <Tab>을 세 개 쓴다. 탭이 네 개가 되는 건 소비자가 마크업을 고칠 때뿐이다.
그런데 이 컴포넌트의 핵심은 슬라이딩 창이고, 보이는 페이지 목록은 파생값이라 계속 바뀐다. 1~10이었다가 다음 순간 4~13이 된다. 소비자가 <my-page value="1">부터 열 개를 손으로 썼다고 하자. 창이 밀리면 그 열 개는 누가 만들고 없애나?
- 소비자가 다시 쓴다 → 창이 밀릴 때마다 소비자가 슬라이딩 계산을 해야 한다. 로직을 라이브러리가 갖는다는 헤드리스의 전제가 무너진다.
- 부모가 만들고 지운다 → 소비자가 마크업에 쓴 자식을 부모가 마음대로 지웠다 만든다. “소비자가 조립한다”는 compound의 전제가 무너진다.
정적 마크업과 동적 파생값은 같은 자리에 있을 수 없다. 이게 충돌의 본질이다. 대부분의 헤드리스 라이브러리가 다른 컴포넌트는 compound로 주면서 페이지네이션만은 usePagination() 훅으로 배열을 뱉는 이유가 바로 이것이었다. 페이지 목록이 파생값이라서다.
해소는 동적인 부분만 하나의 조각 안으로 접는 것이다. 개별 <my-page>를 노출하지 않고 <my-pages> 하나만 노출한다. 슬라이딩하는 버튼 집합은 <my-pages>의 내부 구현으로 숨고, 소비자는 한 줄만 쓴다.
이건 compound를 부분적으로 포기한 게 맞다. 하지만 경계선이 원칙과 정확히 일치한다.
정적인 것(조각의 종류·순서·배치)은 소비자의 마크업에, 동적인 것(파생되는 버튼들)은 라이브러리의 내부에.
조각의 목록이 확정됐다. 호스트 <my-pagination>, 그리고 part 다섯: <my-first> <my-prev> <my-pages> <my-next> <my-last>.
9. 브라우저는 이 태그를 어떻게 살려내는가
형태는 정해졌다. 이제 이 형태가 브라우저 위에서 실제로 어떻게 동작하는지를 알아야 한다. 여기가 팩토리와 커스텀 엘리먼트의 차이가 갈리는 지점이고, 하편 전체를 지탱하는 바닥이다.
9.1 미지의 태그를 만난 브라우저
HTML 파서가 <my-pagination>을 만나면 무슨 일이 일어날까. 에러? 무시?
둘 다 아니다. 브라우저는 일단 받아들인다. 이름에 하이픈(-)이 들어간 태그는 “유효한 커스텀 엘리먼트 이름”으로 취급되어, 아직 정의가 없어도 평범한 HTMLElement로 DOM 트리에 들어간다. 스펙 용어로 미정의(undefined) 상태다.
이 상태의 요소는 세 가지 성질을 갖는다.
- 자식 마크업은 그대로 렌더링된다.
<my-pagination>안에 쓴<my-prev>나 텍스트는 화면에 보인다. 태그 자체는 아무 동작도 스타일도 없는 투명한 상자일 뿐이다. - 기본 display는
inline이다. 정의가 로드되기 전까지는 스타일 면에서도<span>과 다를 게 없다. - 동작이 없다. 클릭해도, 속성을 바꿔도 아무 일도 일어나지 않는다.
9.2 define, 그리고 upgrade
소급해서 살아나는 요소
스크립트가 로드되어 이 한 줄이 실행되는 순간 상황이 바뀐다.
customElements.define('my-pagination', MyPagination);브라우저는 문서를 뒤져 이미 트리에 들어와 있던 <my-pagination>들을 전부 찾아내서, 소급해서 승격시킨다. 이 과정을 스펙은 upgrade라고 부른다. 죽어 있던 태그에 클래스가 입혀지면서 살아나는 순간이다.
전체 타임라인을 그리면 이렇다.
시간 ──────────────────────────────────────────────────────────▶
① HTML 파싱
<my-pagination current-page="3">이 '미정의 요소'로 트리에 배치된다.
자식 마크업은 이 시점부터 화면에 보인다. 동작은 없다.
② 스크립트 로드·실행
customElements.define('my-pagination', MyPagination)
③ upgrade (브라우저가 자동 수행)
문서에 이미 있던 <my-pagination>마다:
├─ constructor() 클래스의 인스턴스로 승격
├─ attributeChangedCallback(...) 이미 적혀 있던 감시 대상 속성이
│ 하나씩 콜백으로 전달된다
└─ connectedCallback() "문서에 연결되었다"는 신호.
여기서 렌더와 이벤트를 배선한다
④ 동작 시작
이후 속성이 바뀔 때마다 attributeChangedCallback이 다시 불린다sequenceDiagram
participant P as HTML 파서
participant D as DOM 트리
participant S as 스크립트
participant B as 브라우저 (커스텀 엘리먼트 레지스트리)
P->>D: <my-pagination>을 미정의 요소로 배치 (자식은 즉시 렌더)
S->>B: customElements.define('my-pagination', 클래스)
B->>D: 기존 요소들을 스캔 → upgrade
B->>D: constructor 실행 (인스턴스 승격)
B->>D: attributeChangedCallback (초기 속성 전달)
B->>D: connectedCallback (렌더·이벤트 배선)
Note over D: 이 시점부터 살아 있는 컴포넌트
팩토리와 비교하면 차이가 선명하다. 팩토리에서는 “요소를 찾고, 적절한 시점에 초기화 함수를 부르고, 인스턴스를 보관하는” 배선이 전부 소비자 코드였다. 커스텀 엘리먼트에서는 그 배선을 브라우저가 표준 동작으로 해준다. 태그가 문서에 있기만 하면, 코드가 언제 도착하든 알아서 살아난다. 요소가 트리에서 빠지면 disconnectedCallback이 불리니 생명주기 정리(팩토리 한계 넷째)도 플랫폼 몫이 된다.
그리고 이 “태그가 먼저, 코드가 나중이어도 된다”는 성질을 기억해두자. 12장 SSR의 복선이다.
9.3 정의 전의 시간
FOUC와 :defined
타임라인의 ①과 ③ 사이, 즉 태그는 보이는데 아직 살아나지 않은 구간이 존재한다. 이 구간에서 스타일이 어긋난 채 보이는 문제를 FOUC(Flash of Unstyled Content)라고 부른다.
CSS에는 이 구간을 겨냥하는 표준 선택자가 있다. :defined는 승격이 끝난 요소에만 매칭된다.
/* 정의되기 전에는 자리는 지키되 보이지 않게 */
my-pagination:not(:defined) {
visibility: hidden;
display: block;
min-height: 40px; /* 예약 높이. 레이아웃 밀림(CLS) 방지 */
}이 구간의 존재 자체가 커스텀 엘리먼트라는 방식의 트레이드오프 첫 번째다. 선언적 사용의 대가로, “선언은 됐지만 살아있지 않은 시간”이 생긴다.
9.4 문자열의 문
콜백이 들어올 수 없다
두 번째 트레이드오프는 더 구조적이다. HTML 속성에는 문자열밖에 담을 수 없다.
팩토리 시절의 인터페이스를 다시 보자.
createPagination({
totalItems: 195, // 숫자
onPageChange: (page) => fetchList(page), // 함수
});totalItems: 195는 total-items="195"로 옮기면 된다. 문자열이 됐으니 파싱과 검증이 필요해졌지만(“195”는 숫자로, “abc”는 거부로) 옮겨지긴 한다. 그런데 onPageChange는 옮길 방법이 없다. 함수를 문자열로 적을 수는 없다.
즉 “태그만 심으면 자동으로 동작한다”는 절반만 참이다. 자동인 것은 초기화와 렌더까지고, 페이지가 바뀌었을 때 데이터를 다시 불러오는 반응의 배선은 여전히 수동이다. 보여주기까지는 참, 반응까지는 거짓.
그래서 방향을 뒤집었다. 콜백을 주입받는(in) 대신, 이벤트를 내보낸다(out).
// 컴포넌트가 내보내는 쪽
this.dispatchEvent(new CustomEvent('page-navigate', {
detail: { page: 5 },
bubbles: true,
}));// 소비자가 받는 쪽
document.querySelector('my-pagination')
.addEventListener('page-navigate', (e) => fetchList(e.detail.page));낯선 발명이 아니다. <input>이 정확히 이 모양이다. 값은 속성(value)으로 들어오고, 변화는 이벤트(change)로 나간다. 네이티브 요소들이 수십 년 쓴 관습이라 소비자가 새로 배울 게 없다.
이벤트 이름을 page-change가 아니라 page-navigate로 지은 데는 이유가 있는데, 바로 다음 절의 결정과 얽혀 있다.
9.5 상태의 주인, 다시
상편의 결정이 뒤집히다
상편 5.7에서 controlled와 uncontrolled를 둘 다 지원하기로 했다. 그때 controlled의 근거는 이거였다.
“페이지가 바뀌면 서버에 데이터를 요청하는 건 바깥의 일이니까.”
이벤트 구조를 설계하다가 이 근거를 다시 보게 됐고, 구멍을 발견했다. uncontrolled + 이벤트 조합에서도 바깥은 데이터를 요청할 수 있다. 컴포넌트가 스스로 페이지를 바꾸고 “바뀌었다”고 알려주면, 바깥은 그때 가져오면 된다. 저 근거가 실제로 증명하는 건 “이벤트가 필요하다”이지 “바깥이 상태를 소유해야 한다”가 아니었다.
바깥이 관여한다는 것과 바깥이 소유한다는 것은 다른 명제다. 상편의 나는 이 둘을 뭉뚱그렸다.
그럼 controlled가 진짜로 이기는 장면은 어디인가. 바깥이 거부권을 가져야 할 때다.
목록을 불러오는 중이라 페이지 이동을 잠시 막고 싶다.
- uncontrolled: 사용자가 5를 누르면 컴포넌트가 먼저 5로 바뀐다. 바깥이 “지금은 안 돼” 하고 되돌리면 화면이 4 → 5 → 4로 깜빡인다.
- controlled: 사용자가 5를 누르면 “5로 가고 싶다”는 의도만 나간다. 바깥이 무시하면 화면은 애초에 움직이지 않는다.
컴포넌트가 상태를 쥐면 바깥의 거부는 언제나 사후 취소가 되고, 바깥이 쥐면 사전 승인이 된다. 이 차이는 논리 문제가 아니라 화면에 그대로 보이는 문제다.
그래서 커스텀 엘리먼트 버전은 controlled 전용으로 확정했다. current-page는 바깥이 쥔다. 컴포넌트는 의도를 이벤트로 알리고, 바깥이 current-page 속성을 갱신해야 화면이 바뀐다.
사용자 클릭 → [컴포넌트] page-navigate 이벤트 발행 (화면은 아직 그대로)
→ [소비자] 승인하면 current-page="5"로 갱신
→ [컴포넌트] attributeChangedCallback → 렌더이벤트 이름이 page-change가 아닌 이유가 여기 있다. change는 이미 일어난 일을 알리는 과거형인데, 이 이벤트는 상태를 바꾸지 않은 채 발행된다. 바뀌길 원한다는 뜻이니 navigate가 정직하다. 덤으로 상편 5.7의 청구서(같은 콜백이 모드마다 다른 의미를 갖는 문제)도 함께 사라졌다. 모드가 하나면 의미도 하나다.
부수적으로 재미있는 발견도 있었다. 상편 3장에서 여덟 라이브러리 중 GitHub Primer만 uncontrolled를 지원하지 않는 걸 봤다. 결론이 우리와 같다. 그런데 근거가 정반대다. Primer는 각 페이지가 <a href>라 클릭하면 브라우저가 이동하고, 페이지 상태는 URL이 갖는다. 컴포넌트 안에 기억할 상태가 애초에 없어서 uncontrolled라는 개념이 성립하지 않는 것이다. 우리는 상태가 존재하되 주인을 바깥으로 옮겼다. 비교표에서 같은 칸에 들어간다고 같은 설계가 아니다.
9.6 문자열 입구가 만든 새 계약
마지막 트레이드오프. 속성으로 받는다는 건 모든 입력이 문자열로 들어온다는 뜻이다.
static observedAttributes = ['current-page', 'total-items', 'page-size'];
attributeChangedCallback(name, oldValue, newValue) {
// newValue는 항상 문자열이다. "10"일 수도, "abc"일 수도, ""일 수도 있다.
}팩토리 시절 totalItems: 195는 숫자로 들어왔고 입력 검증은 “있으면 좋은 것”이었다. 커스텀 엘리먼트에서는 컴포넌트 입구에서의 검증이 선택이 아니라 강제다. Number() 변환, NaN 검사, 범위 검사가 계약의 일부가 된다.
그리고 하나 더, observedAttributes에 올리지 않은 속성은 바뀌어도 콜백이 아예 불리지 않는다. 즉 이 감시 목록 자체가 “이 컴포넌트는 어떤 속성에 반응하는가”라는 공개 계약이 된다. 느슨한 인터페이스(문자열 속성)로 바꿨더니 오히려 계약이 늘어났다. 인터페이스를 느슨하게 만들면 검증이 계약으로 승격되는 셈이다.
10. 쪼갠 대가
호스트와 자식은 어떻게 대화하는가
compound로 쪼개는 순간, 상편에서는 존재하지 않던 문제들이 태어난다. 팩토리 시절에는 core와 renderer가 같은 클로저 안에 살았다. 서로를 변수로 직접 참조하니 “통신”이랄 게 없었다. 이제 호스트와 다섯 개의 part는 각자 독립적으로 태어나고, 각자의 타이밍에 살아나는 별개의 요소들이다.
이 장이 하편의 중심이다. 상편 5.6에서 세운 통신 계약(intent / viewModel)이 이 구조에서 살아남는지, 산다면 어떤 모습으로 사는지를 본다.
10.1 첫 난관
자식이 부모를 찾을 때, 부모는 아직 없다
자식 part가 할 일은 단순해 보인다. 부모를 찾아 자신을 등록하고, 상태를 받아 그리는 것.
// <my-prev>의 첫 시도
connectedCallback() {
const host = this.closest('my-pagination');
host.registerPart(this); // 💥 TypeError: host.registerPart is not a function
}이 코드는 터질 수 있다. 왜 터지는지가 중요하다.
9장에서 본 대로 upgrade는 define()이 호출될 때 일어난다. 그런데 define의 순서는 스크립트가 정한다. my-prev가 my-pagination보다 먼저 정의되면, <my-prev>의 connectedCallback이 도는 시점에 부모 <my-pagination>은 아직 미정의 상태다. closest()는 요소를 찾아낸다. DOM 트리에는 있으니까. 하지만 그 요소는 아직 승격되지 않은 빈 껍데기라 registerPart 메서드가 없다.
같은 태그가 시점에 따라 다른 존재라는 것, 9장의 upgrade 타임라인이 여기서 실제 버그로 돌아온 것이다.
첫 번째로 떠오르는 해법은 기다리는 것이다.
// 기각: 왜 하필 0ms인가에 답할 수 없다
setTimeout(() => this.closest('my-pagination').registerPart(this), 0);몇 ms를 주든 근거 없는 임의값이다. 부모의 정의가 그 안에 도착한다는 보장이 어디에도 없다. 빠른 기기에서는 통과하고 느린 기기에서만 터지는, 재현 안 되는 플래키 버그의 전형적인 탄생 경로다.
플랫폼에는 이 상황을 위한 표준 도구가 있다.
// 채택: '정의됨'을 결정적으로 기다린다
async connectedCallback() {
await customElements.whenDefined('my-pagination');
this.closest('my-pagination')?.registerPart(this);
}customElements.whenDefined(name)은 해당 이름이 define되는 순간 resolve되는 Promise다. setTimeout이 확률에 기대는 동안 whenDefined는 사건에 기댄다. 이미 정의돼 있으면 즉시 resolve되니 순서가 어느 쪽이든 안전하다.
10.2 그런데 “정의됨”은 “준비됨”이 아니다
한 겹 더 있다. whenDefined가 보장하는 것은 클래스가 등록됐다는 사실이지, 이 부모 인스턴스의 상태가 준비됐다는 사실이 아니다. 등록 시점의 부모는 아직 current-page 속성을 파싱하지 못했을 수 있다. 그럼 이번엔 “상태 준비됨” 신호를 또 기다려야 하나? 신호 위에 신호가 쌓이기 시작하면 설계가 잘못 가고 있다는 냄새다.
여기서 프레임 자체를 버렸다. “자식이 부모의 준비를 기다린다”가 아니라 “부모가 등록받는 순간 무조건 현재 상태를 준다”로 뒤집는다.
// 호스트 쪽
registerPart(part) {
this.#parts.add(part);
part.paginationStateChanged(this.#viewModel()); // 등록 즉시, 현재 상태를 1회 push
}
#broadcast() { // 상태가 바뀔 때마다
const vm = this.#viewModel();
for (const part of this.#parts) part.paginationStateChanged(vm);
}부모의 상태가 아직 기본값이면? 자식은 기본값으로 한 번 그린다. 잠시 후 진짜 속성이 파싱되면 #broadcast()가 다시 돌고, 자식은 다시 그린다. 이게 성립하는 조건이 딱 하나 있는데, 자식의 렌더가 멱등이어야 한다. 같은 상태로 몇 번을 그려도 결과가 같고, 새 상태가 오면 화면이 그 상태와 일치하게 되는 것.
상편 5.4에서 “같은 viewModel로 몇 번을 호출하든 결과가 같다”고 정해둔 그 성질이다. 그때는 갱신 누락을 막는 장치였는데, 여기서는 타이밍 문제를 통째로 소거하는 장치가 됐다. 렌더가 멱등이면 “언제 그리는가”가 중요하지 않게 되고, 언제가 중요하지 않으면 기다림도 필요 없다. 기다릴 필요가 없으면 타이밍 버그도 없다. 좋은 성질 하나가 설계의 다른 자리에서 두 번째 이자를 지불하는 순간이다.
10.3 통신 프로토콜
두 채널의 진화
이제 전체 프로토콜을 확정할 수 있다. 상편의 두 채널과 나란히 놓으면 이렇다.
| 상편 (팩토리) | 하편 (compound) | 무엇이 달라졌나 | |
|---|---|---|---|
| 하향 채널 | core → renderer, update(viewModel) |
호스트 → 각 part, paginationStateChanged(vm) |
수신자가 1 → N. **방송(broadcast)**이 됐다 |
| 상향 채널 | renderer → core, onIntent(intent) |
part → 호스트, intent 이벤트 (버블링) | 전달 수단이 콜백 → DOM 이벤트 |
| 내용물 | viewModel / intent | 동일 | 바뀐 게 없다 |
채널의 배관은 바뀌었지만 흐르는 데이터는 그대로다. 이게 상편에서 채널을 좁게 유지한 것의 최대 보상이다. 계약이 데이터의 모양으로 정의돼 있으면, 구조가 바뀌어도 계약은 산다.
두 채널의 전달 수단이 서로 다른 것은 의도적이다.
하향은 메서드 호출이다. 호스트는 수신자 명단(#parts)을 정확히 알고 있다. 등록이라는 절차가 있으니까. 대상이 특정된 통신은 직접 호출이 명확하다.
상향은 DOM 이벤트 버블링이다. part는 자기 클릭을 intent로 번역해 이벤트로 쏘아 올릴 뿐, 누가 받는지 모른다.
// <my-prev> 내부: 상편 5.5의 intent 번역이 그대로 산다
this.#button.addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('my-intent', {
detail: { type: 'prev' },
bubbles: true, // light DOM이므로 호스트까지 자연스럽게 버블링된다
}));
});// 호스트 내부: 어느 part에서 왔는지 몰라도 처리할 수 있다
this.addEventListener('my-intent', (e) => {
const target = this.#resolve(e.detail); // 상편 core의 resolve가 그대로 산다
if (target !== null) this.#emitNavigate(target); // controlled: 의도를 바깥으로 중계
});왜 상향만 이벤트인가. 자식이 부모의 참조를 쥐고 메서드를 부르는 순간, 자식은 부모의 API에 결합된다. 반대로 이벤트는 발신자가 수신자를 모른 채 성립한다. 새로운 part를 추가할 때 호스트를 한 줄도 고치지 않아도 되는 건 이 방향 덕분이다. (등록만은 예외적으로 메서드 호출인데, 등록이야말로 “부모의 존재를 확인하는 행위” 그 자체이기 때문이다.)
전체 흐름을 시퀀스로 그리면 이렇다. 위쪽 절반이 초기화(등록의 춤), 아래쪽 절반이 운영(클릭 한 번의 여정)이다.
sequenceDiagram
participant Prev as <my-prev>
participant Host as <my-pagination>
participant App as 소비자 앱
Note over Prev,Host: ─── 초기화: 등록의 춤 ───
Prev->>Prev: await whenDefined('my-pagination')
Prev->>Host: registerPart(this)
Host-->>Prev: paginationStateChanged(vm), 즉시 1회 push
Prev->>Prev: 렌더 (멱등)
Note over Prev,App: ─── 운영: 클릭 한 번의 여정 ───
Prev->>Host: my-intent { type: 'prev' } (버블링)
Host->>App: page-navigate { page: 4 } (화면은 아직 그대로)
App->>Host: current-page="4" 속성 갱신 (승인)
Host->>Host: attributeChangedCallback → viewModel 재계산
Host-->>Prev: paginationStateChanged(vm), 전 part에 방송
Prev->>Prev: 렌더 (변경분만)
10.4 프로토콜의 규칙
상편 5.6의 다섯 규칙이 이 구조에서 어떻게 번역되는지 명문화해두자.
- 상태는 호스트에만 있다. part는 상태를 저장하지 않는다. 받은 viewModel로 그릴 뿐이다. part에 상태가 생기는 순간, 상편 5.2에서 없앤 동기화 책임이 N개의 조각으로 흩어져 부활한다.
- 호스트는 part의 내부를 모른다. 호스트가 아는 것은
paginationStateChanged라는 수신 규약 하나다. part가 안에서<button>을 쓰든<a>를 쓰든 호스트 코드는 바뀌지 않는다. - part는 서로를 모른다.
<my-prev>는<my-pages>의 존재를 모른다. 조각 간 협력이 필요하면 반드시 호스트를 경유한다. - 경계를 넘는 데이터는 여전히 둘뿐이다. 내려가는 viewModel, 올라오는 intent. 이름과 배관은 바뀌었어도 상편의 계약 그대로다.
- 렌더는 멱등이다. 이 성질이 타이밍 문제 전체를 담보한다(10.2).
10.5 part의 DOM은 어디에 붙이나
Shadow DOM의 유혹
part가 자기 버튼을 그리긴 그려야 한다. 그럼 그 버튼을 어디에 붙이나. 선택지는 둘이다. part의 shadow root 안이냐, 그냥 light DOM(일반 DOM 트리)이냐.
Shadow DOM의 격리는 정확히 이해할 필요가 있다. “바깥이 아무것도 못 한다”가 아니다. CSS custom properties와 ::part() 선택자는 경계를 넘도록 설계돼 있다. 즉 격리는 **“컴포넌트가 허락한 것만 바깥이 할 수 있다”**는 뜻이고, 스타일 계약이 명시적이 된다는 장점이 있다.
문제는 그 “허락”의 해상도다. ::part()로 실제 무엇이 되고 안 되는지 브라우저에서 직접 확인해봤다.
my-pages::part(page) → 된다
my-pages::part(page):hover → 된다 (상호작용 의사클래스)
my-pages::part(page)[aria-current="page"] → 안 된다 (속성 선택자 결합 불가)
my-pages::part(page):nth-child(2) → 안 된다 (구조 의사클래스 불가)
my-pages::part(page) span → 안 된다 (part 내부로 자손 선택 불가)이 표가 결정적이었다. shadow를 쓰면 “현재 페이지 버튼만 다르게 칠하고 싶다”는 흔한 요구조차, 라이브러리가 미리 part="page current"처럼 이름을 열거해서 수출해줘야 가능하다. 소비자가 우리가 예상 못 한 스타일을 원하는 순간(세 번째 버튼마다 구분선을 넣고 싶다든가) 벽에 막힌다. 헤드리스의 존재 이유가 “표현은 소비자 마음대로”인데, 표현의 어휘를 라이브러리가 미리 열거해야 한다면 자기모순이다.
light DOM으로 결정했다. part가 그리는 버튼은 일반 DOM에 놓이고, 소비자의 CSS는 속성 선택자든 구조 의사클래스든 자손 선택이든 화력을 전부 그대로 쓴다. 10.3에서 intent 이벤트가 “light DOM이므로 자연스럽게 버블링된다”고 한 것도 이 결정의 부수 이득이다. shadow 경계가 없으니 이벤트도 포커스도 트리 하나 안에서 논다.
참고로 이 결정은 라이브러리의 성격에 따라 뒤집힌다. Shoelace처럼 자기 디자인을 가진(styled) 라이브러리는 지킬 스타일이 있으니 shadow의 격리가 값을 한다. 우리는 지킬 스타일이 없다. 같은 기술의 값어치가 컴포넌트의 성격에 따라 달라진다.
10.6 대가
내부 구조가 공개 API가 된다
공짜는 아니다. light DOM이면 라이브러리가 만든 마크업이 소비자에게 그대로 보이고, 소비자는 거기에 CSS와 querySelector로 의존하기 시작한다. 그 순간 내부 마크업 리팩터링이 전부 breaking change가 된다. 태그 하나 바꿨는데 어느 팀의 화면이 깨지는 상황.
업계의 해법은 기술적 폐쇄가 아니라 명시적 계약이었다. Zag.js와 Ark UI가 쓰는 방식 그대로, 스타일용 데이터 속성을 계약으로 문서화한다.
<!-- 소비자가 의존해도 되는 것: data-part 속성 (공식 계약) -->
<!-- 의존하면 안 되는 것: 태그 구조, 클래스명 (비공식, 언제든 변경 가능) -->
<my-pages>
<ul data-part="list">
<li data-part="item"><button data-part="page" data-page="3">3</button></li>
<li data-part="item"><button data-part="page" data-page="4" aria-current="page">4</button></li>
...
</ul>
</my-pages>/* 소비자 CSS: 계약된 속성으로만 건다 */
[data-part="page"] { /* ... */ }
[data-part="page"][aria-current="page"] { /* 현재 페이지. shadow였다면 불가능했던 결합 */ }정직하게 적어두자면, 이 계약은 문서로만 강제된다. shadow DOM은 기술로 막고 data-part는 약속으로 막는다. 결국 내부 구조에 의존하는 소비자는 나오고(Hyrum의 법칙: 관측 가능한 모든 동작은 누군가 의존하게 된다), 그때 깨지는 걸 막을 수는 없다. 자유도를 위해 강제력을 포기한 것이고, 그 교환을 알고 했다는 것까지가 이 결정이다.
10.7 쪼개니 갈 곳을 잃은 것
랜드마크
이 장의 마지막 문제이자, 다음 장을 여는 문제다.
팩토리 시절 renderer는 맨 바깥에 <nav aria-label="페이지네이션">을 그렸다. 스크린리더 사용자가 랜드마크 단위로 점프할 때 쓰는 이정표다. 그런데 compound로 쪼갠 지금, 그 <nav>는 누가 그리나?
호스트 <my-pagination>은 아무것도 그리지 않는 투명한 컨테이너다. part들은 각자 자기 부분만 안다. <nav>처럼 전체를 감싸는 요소는 어느 조각의 관심사도 아니다. 관심사를 조각으로 나누면, 어느 조각에도 속하지 않는 관심사가 갈 곳을 잃는다.
후보를 하나씩 보자.
소비자에게 넘긴다? “headless니까 <nav>로 감싸는 것도 소비자 몫”이라 할 수 있다. 하지만 소비자가 깜빡하면 접근성이 조용히 무너진다. 화면은 멀쩡해서 아무도 모른 채로. headless가 위임하는 것은 표현이지, 접근성 기준선은 위임할 수 없는 바닥이다. 이 선은 지키기로 했다.
호스트가 <nav>를 안에 그려 넣는다? <my-pagination>이 내부에 <nav>를 만들고 소비자의 자식들을 그 안으로 옮겨 심으면 두 가지가 깨진다. 소비자가 쓴 my-pagination > my-pages 자식 선택자가 무효가 되고(중간에 없던 요소가 끼니까), 소비자가 호스트에 건 display: flex 레이아웃이 <nav>에 막혀 무력화된다. 마크업 구조를 소비자에게 준다는 원칙과 정면충돌한다.
남는 답은 하나다. 호스트 자신이 <nav>가 되면 된다. DOM 구조는 그대로 두고, <my-pagination>이라는 요소 자체에 “너는 네비게이션 랜드마크다”라는 의미를 부여하는 것.
그런데 커스텀 엘리먼트는 브라우저가 의미를 모르는 generic 요소다. 의미를 부여하는 표준 수단은 role 속성인데,
this.setAttribute('role', 'navigation');이 한 줄에는 문제가 셋 딸려 있다. 다음 장에서 본다.
11. ElementInternals
마크업을 건드리지 않고 의미를 부여하다
11.1 setAttribute의 세 가지 문제
호스트가 자기 자신에게 role 속성을 쓰는 방식의 문제를 하나씩 짚자.
첫째, 소비자의 선택을 덮어쓴다. 소비자가 어떤 이유로 <my-pagination role="region">이라고 명시했다면? 우리 코드의 setAttribute가 그걸 navigation으로 밀어버린다. 덮어쓰기 전에 기존 값을 확인하는 guard를 넣을 수 있지만, 속성이 나중에 지워지면 다시 채워야 하나? 감시는 누가 하나? 방어 코드가 자라기 시작한다.
둘째, 남의 감시망을 건드린다. 호스트 요소의 속성은 라이브러리와 소비자가 공유하는 표면이다. 소비자가 프레임워크(React, Vue)로 이 요소를 렌더링하고 있다면, 프레임워크가 관리하는 요소의 속성을 라이브러리가 몰래 바꾸는 셈이다. 소비자의 MutationObserver가 반응하고, 프레임워크의 다음 렌더가 “내가 안 쓴 속성이 있네” 하고 지우거나 경고를 낸다.
셋째, 표면이 오염된다. 소비자가 마크업에 쓰지 않은 속성이 DOM에 나타난다. 사소해 보이지만, “내가 쓴 마크업”과 “실제 DOM”이 다르면 디버깅할 때마다 그 차이의 출처를 추적해야 한다.
세 문제의 공통 원인은 하나다. 속성은 소비자의 영역인데, 라이브러리가 거기에 쓰기를 하고 있다.
11.2 속성을 쓰지 않고 의미를 주는 길
이 문제를 위해 만들어진 표준 API가 ElementInternals다.
class MyPagination extends HTMLElement {
#internals;
constructor() {
super();
this.#internals = this.attachInternals();
this.#internals.role = 'navigation';
this.#internals.ariaLabel = '페이지네이션';
}
}attachInternals()는 커스텀 엘리먼트가 자기 내부 상태에 접근하는 손잡이를 돌려준다. 여기에 role이나 aria*를 설정하면, DOM 속성에는 아무것도 쓰지 않은 채, 접근성 트리에만 의미가 공급된다. 개발자 도구의 Elements 패널을 보면 <my-pagination>에 role 속성이 없다. 하지만 접근성 트리에서는 navigation 랜드마크다.
핵심은 이 의미가 들어가는 계층이다. internals로 설정한 role은 네이티브 요소의 암묵적 시맨틱과 같은 층에 들어간다. <nav>가 속성 없이도 navigation인 것과 정확히 같은 방식으로, <my-pagination>이 속성 없이 navigation이 된다.
그리고 이 계층 구조가 첫 번째 문제(덮어쓰기)를 공짜로 해결한다.
소비자가 직접 쓴
role속성이 internals의 기본 시맨틱을 항상 이긴다. 이 우선순위는 우리가 짠 로직이 아니라 플랫폼 스펙이다.
<nav role="region">이라고 쓰면 region이 이기는 것과 같은 규칙이다. 소비자가 명시하면 물러나고, 명시하지 않으면 기본값이 산다. “있으면 물러난다”는 guard 로직을 한 줄도 짜지 않고 플랫폼 규칙으로 얻는다. 11.1의 세 문제가 전부 사라진다: 속성을 안 쓰니 덮어쓸 일도, 감시망을 건드릴 일도, 표면을 오염시킬 일도 없다.
11.3 ElementInternals는 원래 무엇을 위한 API인가
role 하나 때문에 나온 API는 아니다. 전모를 알아두면 쓸 곳이 더 보인다.
ElementInternals의 출발점은 **폼 연관 커스텀 엘리먼트(form-associated custom elements)**다. <my-select> 같은 커스텀 폼 요소를 만들면 네이티브 <select>가 공짜로 하던 일들(<form> 제출에 값 실리기, 유효성 검사 참여, <label> 연결)이 전부 끊긴다. 이걸 복구하는 통로가 internals다.
class MyField extends HTMLElement {
static formAssociated = true; // 폼 연관 선언
#internals = this.attachInternals();
set value(v) {
this.#internals.setFormValue(v); // <form> 제출에 이 값이 실린다
if (!v) this.#internals.setValidity({ valueMissing: true }, '값을 입력하세요');
else this.#internals.setValidity({});
}
}표면을 정리하면 이렇다.
| 영역 | 대표 멤버 | 하는 일 |
|---|---|---|
| 접근성 시맨틱 | role, ariaLabel, ariaCurrent 등 ARIA 반사 속성 전체 |
속성 없이 접근성 트리에 기본 의미 공급. 우리가 쓴 부분 |
| 폼 연관 | setFormValue(), setValidity(), form, labels, willValidate |
커스텀 요소를 네이티브 폼 요소처럼 동작시키기 |
| 커스텀 상태 | states (CustomStateSet) |
:state(loading) 같은 CSS 선택 가능한 내부 상태 노출 |
세 영역을 관통하는 철학이 하나다. 컴포넌트의 “내부 사정”을, 소비자와 공유하는 속성 표면을 거치지 않고 플랫폼에 직접 알린다. 네이티브 요소들이 태생적으로 갖고 있던 특권(<nav>는 role 속성 없이 navigation이고, <input>은 클래스 없이 :invalid에 매칭된다)을 커스텀 엘리먼트에게 돌려주는 API라고 이해하면 정확하다.
11.4 대가와 제약
이번에도 공짜는 아니다.
- 마크업만 봐서는 의미가 안 보인다. DOM에
role이 없는데 접근성 트리에는 있다. 이걸 모르는 동료가 디버깅하면 “랜드마크가 어디서 나오는 거지?”에서 한참 헤맬 수 있다. 문서화가 필수다. - 측정 도구가 못 볼 수 있다. 일부 개발자 도구·검사 도구는 속성 기반으로만 role을 읽어서 internals가 공급한 시맨틱을 놓친다. “도구에 안 보인다”와 “실제로 없다”를 혼동하면 멀쩡한 코드를 오진하게 된다. 접근성 검증은 어느 계층을 재는 도구인지 알고 써야 한다.
- 브라우저 지원 경계. ARIA를 포함한 ElementInternals는 Safari 16.4+, Firefox 119+부터다. 그 이전 버전 지원이 필요하면
setAttribute폴백을 두는 수밖에 없는데, 그 순간 11.1의 문제들이 폴백 경로에서 되살아난다. - 호출 제약.
attachInternals()는 요소당 한 번만 부를 수 있고, 커스텀 엘리먼트에서만 동작한다.
정리하면, 랜드마크 문제는 풀렸다. 호스트는 DOM 구조도 속성 표면도 건드리지 않으면서 <nav>와 동등한 의미를 가진다. 소비자의 마크업은 소비자의 것으로 남는다.
그런데 이 해법에는 조용한 전제가 하나 깔려 있다. JS가 실행된 뒤의 이야기라는 것. attachInternals()도 internals.role = ...도 전부 JavaScript다. 그럼 JS가 아직 도착하지 않은 시간에는 무슨 일이 일어나는가.
다음 장이 그 시간에 대한 이야기다.
12. SSR
JS가 도착하기 전의 시간
12.1 문제 설정
지금까지의 그림은 전부 클라이언트 렌더링(CSR) 기준이었다. 이제 서버가 HTML을 미리 그려서 내려보내는 상황(SSR)을 생각해보자. 커스텀 엘리먼트로 만들었으니 서버는 이렇게 내려보낼 수 있다.
<!-- 서버가 내려보낸 HTML -->
<my-pagination current-page="3" total-items="195" page-size="10">
<my-prev></my-prev>
<my-pages></my-pages>
<my-next></my-next>
</my-pagination>9장에서 본 upgrade 메커니즘 덕분에, 이 태그들은 JS가 나중에 도착해도 알아서 살아난다. “태그가 먼저, 코드가 나중”이 표준 시나리오로 설계돼 있다는 것, 그게 9장에서 심어둔 복선이었다. 팩토리였다면 서버가 그린 HTML과 클라이언트 초기화 코드를 잇는 배선을 소비자가 직접 짜야 했을 것이다.
그런데 “알아서 살아난다”는 문장은 살아나는 순간 이후만 말해준다. 문제는 그 앞이다. HTML은 이미 화면에 있고 JS는 아직 오는 중인 구간, 네트워크가 느릴수록 번들이 클수록 길어지는 그 구간에 사용자가 보는 것은 무엇인가.
두 타임라인을 나란히 놓고 보자.
CSR ──────────────────────────────────────────────────────▶
빈 화면 빈 화면 JS 도착 → 생성 → 완성
│─────────────│──────────────│─────────────────────│
아무것도 없다가 한 번에 나타난다. 문제도 없지만 보이는 것도 없다.
SSR ──────────────────────────────────────────────────────▶
마크업 보임 마크업 보임 JS 도착 → upgrade → 완성
│─────────────│──────────────│─────────────────────│
⚠️ 이 구간이 문제다: 보이는데 살아있지 않다SSR의 약속은 “JS 전에도 뭔가 보인다”이다. 그런데 우리 컴포넌트에서 그 약속은 세 군데서 어긋난다. 하나씩 보자. 각각 왜 생기는지, 어떻게 풀 수 있는지.
12.2 문제 ①
보이는 게 없다
증상. 위 HTML에서 JS 도착 전 사용자가 보는 것은… 아무것도 없다. 빈 화면이다.
원인. 우리 설계에서 버튼을 그리는 주체는 part의 JavaScript다(10장). <my-pages>는 upgrade되기 전까지 그냥 빈 인라인 요소이고, 안에 버튼이 없다. SSR로 태그를 내려보냈지만, 태그 안의 내용물은 여전히 클라이언트 렌더링인 것이다. SSR의 이득 중 “먼저 보인다”가 통째로 사라진다.
해결. 서버가 태그만이 아니라 part 내부의 마크업까지, 우리가 10.6에서 공개 계약으로 정한 data-part 규격 그대로 미리 그려주는 것이다.
<!-- 서버가 내부까지 그려서 내려보낸다 -->
<my-pages>
<ul data-part="list">
<li data-part="item"><button data-part="page" data-page="1">1</button></li>
<li data-part="item"><button data-part="page" data-page="2">2</button></li>
<li data-part="item"><button data-part="page" data-page="3" aria-current="page">3</button></li>
...
</ul>
</my-pages>이러면 JS 전에도 완성된 모습이 보인다. 대신 클라이언트 쪽에 새 요구가 생긴다. upgrade된 part가 마크업을 지우고 처음부터 다시 그리면 안 된다. 화면이 깜빡이고(이미 그려진 걸 지웠다 다시 그리니), 서버 마크업을 그린 의미가 없어진다. part의 mount는 이렇게 동작해야 한다.
내 안에 이미 규격에 맞는 마크업이 있으면 입양(adopt)한다. 노드를 재사용하고 이벤트만 배선한다. 없으면 그린다.
어디서 본 요구다. 프레임워크의 hydration이 정확히 이 일이다. 서버가 그린 DOM을 버리지 않고 이어받아 살려내는 것. 프레임워크는 이걸 자동으로 해주고, 바닐라에서는 우리가 손으로 설계해야 한다. 다행히 우리에겐 기반이 있다. **렌더가 멱등이라는 규칙(10.4의 다섯째)**이다. “화면을 viewModel과 일치시킨다”는 동작은 빈 컨테이너에서 시작하든 서버 마크업에서 시작하든 같은 코드로 성립한다. 남는 추가 작업은 “이미 있는 노드를 새로 만들지 않고 줍는” mount 단계뿐이다.
교환 관계. 이 해법의 비용은 명확하다. 창 계산 로직이 서버에도 필요해진다. 서버가 [1]~[10]을 그리려면 서버도 슬라이딩 창을 계산해야 하니까. 상편에서 calculator를 순수 함수로, DOM 없이 돌아가게 분리해둔 것이 여기서 세 번째 이자를 지불한다. calculator는 Node.js에서 그대로 import해서 실행된다. DOM을 모르는 코드만이 서버에서 살 수 있다.
12.3 문제 ②
의미가 없다
증상. JS 전 구간에서 스크린리더로 이 페이지를 읽으면, 페이지네이션은 랜드마크 목록에 없다. 12.2를 해결해서 버튼이 보이는 상태라도 마찬가지다.
원인. 11장의 해법인 internals.role = 'navigation'은 JavaScript다. JS 전에 <my-pagination>은 아무 의미 없는 미지의 요소다. 이건 ElementInternals의 결함이 아니라 JS로 의미를 부여하는 모든 방식이 공유하는 구멍이다. setAttribute로 했어도 똑같이 비어 있었을 것이다.
해결. 의미도 마크업에 실어 보내면 된다. 서버가 role을 미리 적는 것이다.
<my-pagination role="navigation" aria-label="페이지네이션" current-page="3" ...>그런데 여기서 11장의 설계가 뜻밖의 방식으로 보답한다. JS가 도착해 upgrade가 일어나면, 컴포넌트는 internals.role = 'navigation'을 또 설정한다. 서버가 쓴 속성과 이중 적용 아닌가? 충돌하지 않는다. 11.2에서 본 우선순위, 즉 속성이 internals를 항상 이긴다는 규칙 덕분에, 서버가 쓴 role 속성이 그대로 유효하고 internals는 얌전히 기본값 층에 머문다. 서버가 role을 안 써준 환경에서는 internals가 메운다.
서버가 쓰면 서버 것이 이기고, 안 쓰면 우리가 메운다. 이 폴백 구조를 코드 한 줄 없이 플랫폼 우선순위 규칙에서 공짜로 얻는다.
만약 11장에서 setAttribute 방식을 골랐다면 여기서 “이미 속성이 있으면 덮어쓰지 않기” 분기를 짰어야 한다. 한 결정의 품질이 다른 결정의 자리에서 판명 나는, 이 시리즈에서 여러 번 본 패턴이다.
남는 것. 이건 라이브러리가 강제할 수 없고 소비자(서버 템플릿)에게 안내해야 하는 계약이다. “SSR을 쓰신다면 호스트에 role="navigation" aria-label="…"을 미리 적어주세요”라고, 문서에 적는 것까지가 우리 몫이다. 10.6의 data-part 계약과 같은 성질이다: 기술이 아니라 문서로 지키는 약속.
12.4 문제 ③
자리가 흔들린다
증상. JS가 도착해 upgrade가 일어나는 순간 페이지가 움찔한다. 아래 있던 콘텐츠가 밀려 내려가거나 올라온다.
원인. 9.1에서 본 성질대로, 미정의 커스텀 엘리먼트의 기본 display는 inline이다. 정의 전에는 인라인이던 요소가 upgrade 후 컴포넌트 스타일(예: display: flex, 높이 40px)을 얻으면서 크기가 변한다. 레이아웃 이동(CLS)이고, SSR처럼 “먼저 보여주는” 전략일수록 이 움찔거림이 사용자 눈에 잘 띈다.
해결. 9.3에서 소개한 :defined를 SSR 관점에서 다시 쓰는 것이다. 정의 전 상태의 크기를 CSS로 미리 예약한다.
my-pagination:not(:defined) {
display: block;
min-height: 40px; /* upgrade 후와 같은 높이를 예약 */
}12.2까지 적용해 서버가 내부 마크업을 그려주는 구성이라면 이 문제는 거의 사라진다. 버튼들이 처음부터 실제 크기로 자리를 차지하고 있으니까. 문제 ①의 해결이 문제 ③을 함께 줄여주는 구조다.
12.5 정리
SSR 구멍의 지도
| 문제 | 원인 | 해결 | 비용 |
|---|---|---|---|
| 보이는 게 없다 | 내용물이 클라이언트 렌더링 | 서버가 data-part 규격대로 내부까지 그림 + 클라이언트는 adopt | 창 계산이 서버에도 필요 (calculator 공유로 해결) |
| 의미가 없다 | role 부여가 JS에서 일어남 | 서버가 role·aria-label을 속성으로 선기입 | 소비자 안내 필요. 속성>internals 우선순위가 충돌 방지 |
| 자리가 흔들린다 | 미정의 요소는 inline | :defined로 자리 예약, 서버 마크업이 근본 완화 |
CSS 한 줄 |
관통하는 원리는 하나다.
JS가 만들 수 있는 것은 JS가 없는 동안엔 없다. SSR 대응이란 “JS가 만들던 것 중 무엇을 마크업으로 옮길 수 있는가”를 하나씩 묻는 일이고, 옮길 수 있는 것의 목록이 곧 그 컴포넌트의 SSR 품질이다.
우리 컴포넌트의 답: 구조(서버 마크업)와 의미(role 속성)는 옮길 수 있다. 동작(이벤트, 상태 전이)은 본질적으로 JS 도착을 기다려야 한다. 그 간격을 줄이는 건 컴포넌트 설계가 아니라 로딩 전략(번들 크기, 스크립트 우선순위)의 몫이다.
13. 하편을 닫으며
무엇이 살아남았나
상편 끝에서 던진 질문 다섯 개에 이제 전부 답이 붙었다.
| 상편의 질문 | 하편의 답 |
|---|---|
| 브라우저는 처음 보는 태그를 만나면? | 미정의 요소로 배치했다가 define 시점에 소급 upgrade (9장) |
| 바닐라 헤드리스에서 DOM은 누가 그리나? | 로직+최소 마크업은 part가, 배치·스타일은 소비자가. 선을 다시 그었다 (8장) |
| 콜백은 어디로 가나? | 속성은 문자열뿐 → 이벤트 out으로 반전, controlled 전용으로 (9장) |
| 조각들은 어떻게 통신하나? | 등록 후 즉시 push + 멱등 렌더, 하향은 방송·상향은 버블링 (10장) |
| JS 전엔 무엇이 보이나? | 서버 마크업 + 선기입 role + :defined. 옮길 수 있는 걸 전부 마크업으로 (12장) |
그리고 상편에서 세운 다섯 규칙의 생사를 확인하는 것으로 시리즈의 원을 닫자.
| 상편의 규칙 | 하편에서의 운명 |
|---|---|
| 상태는 core에만 | 살아남음. core가 호스트로 바뀌었을 뿐. 오히려 controlled 전용이 되며 더 엄격해졌다 |
| 파생값은 저장하지 않는다 | 살아남음. 창 계산은 여전히 매번, 호스트에서만 |
| 화면 변경 경로는 하나 | 살아남음. update(viewModel)가 paginationStateChanged(vm) 방송으로 진화 |
| 경계를 넘는 건 intent와 viewModel뿐 | 살아남음. 배관(메서드→이벤트, 1:1→1:N)은 바뀌었지만 내용물은 그대로 |
| calculator는 순수 함수 | 살아남았고 더 중요해짐. SSR에서 서버와 클라이언트가 같은 코드를 공유하는 유일한 통로 |
구조를 팩토리에서 compound 커스텀 엘리먼트로 갈아엎었는데 규칙은 다섯 개 전부 살아남았다. 우연이 아니라고 생각한다. 규칙을 구현 방식이 아니라 데이터의 모양(intent, viewModel)과 책임의 경계(누가 상태를 갖나, 누가 그리나)로 세워뒀기 때문이다. 구현에 붙은 규칙은 구현과 함께 죽고, 계약에 붙은 규칙은 구현을 갈아타고 산다.
반대로, 새로 치른 비용도 정직하게 적어둔다. 문자열 입구의 검증 부담, 정의 전 구간(FOUC·CLS)의 관리, 문서로만 강제되는 data-part 계약, ElementInternals의 브라우저 지원 경계, 그리고 SSR을 제대로 하려면 서버 템플릿까지 계약에 참여시켜야 한다는 것. 헤드리스 + 커스텀 엘리먼트는 “소비자의 자유”를 사기 위해 “라이브러리의 통제력”을 지불하는 거래였다. 이 거래가 남는 장사인지는 컴포넌트의 소비자가 얼마나 다양한가에 달려 있다. 한 팀이 한 앱에서 쓸 물건이었다면 상편의 팩토리에서 멈추는 게 맞았을 것이다.
다음 글
Lit로 옮기면 무엇이 사라지나
이 시리즈 내내 손으로 만든 것들의 목록을 보자. 속성 문자열 파싱과 검증, 상태 변경 → 방송 배선, 멱등 렌더, 등록 프로토콜, upgrade 타이밍 대응. Lit는 정확히 이 목록을 지우려고 만들어진 라이브러리다. reactive properties가 속성 파싱·감시를, 템플릿 리렌더가 멱등 렌더를, lifecycle이 배선을 대신한다.
같은 컴포넌트를 Lit로 옮기면서 확인하려 한다. 손으로 만들었던 것 중 무엇이 지워지고, 무엇은 지워지지 않고 남는가. 지워지지 않는 것이 있다면, 그게 아마 프레임워크가 어떻게 해줄 수 없는 이 문제의 본질일 것이다. 그리고 그다음 글에서 React까지 가면, 상편 calculator를 한 줄도 고치지 않고 세 세계(바닐라·Lit·React)에서 재사용하는 실험이 완성된다.
다음 글에서 계속.