솔직히 고백하면, 저도 얼마 전까지 모달 하나 만들 때마다 z-index: 9999를 타이핑하고 있었습니다. focus trap 로직 직접 짜고, body { overflow: hidden } 토글하고, ESC 키 리스너 달고, ARIA 속성 하나하나 수동으로 박아 넣었죠. 그 코드가 컴포넌트 라이브러리에 200줄쯤 쌓여 있었는데요—브라우저가 이미 그걸 다 해주고 있었다는 걸 알았을 때, 솔직히 좀 허탈했습니다.
1. 모달: <dialog>가 끝낸 z-index 전쟁
dev.to의 한 글이 정확히 이 지점을 찌릅니다. HTML <dialog> 요소의 .showModal() 메서드 하나가 해주는 일을 나열하면: top layer 승격(z-index 무관하게 최상위 렌더링), ::backdrop 생성, 문서 나머지 영역 inert 처리, aria-modal="true" 자동 설정, 포커스 트랩, ESC 닫기 리스너 등록. 사용자 입장에서는 스크린리더가 "dialog"라고 안내하고, Tab 키가 모달 밖으로 빠져나가지 않는 게 '당연한' 경험인데, 그걸 직접 구현하려면 접근성 엣지케이스가 끝도 없었거든요.
특히 인상적인 건 inert의 동작 방식입니다. display: none은 레이아웃과 접근성 트리에서 완전히 제거하고, visibility: hidden은 레이아웃은 유지하되 시각적으로만 숨기는 반면, inert는 보이지만 상호작용과 접근성을 동시에 차단합니다. 배경이 흐릿하게 보이면서도 클릭이 안 되는 그 경험—CSS 한 줄이 아니라 브라우저 레이어에서 처리하는 거였어요. Figma에서 볼 때는 "overlay에 blur 넣으면 되겠지" 싶었는데, 실제 구현에서의 인터랙션 차단은 전혀 다른 문제였던 거죠.
다만, 모든 모달을 당장 교체하라는 건 아닙니다. Drawer나 사이드 패널, 중첩 모달이 필요한 복합 UI, 정교한 열림/닫힘 애니메이션이 필요한 경우는 여전히 프레임워크 추상화가 낫습니다. <dialog>의 닫힌 상태가 display: none이라서, 트랜지션 적용 시 @starting-style이나 requestAnimationFrame 핵을 써야 하는 부분도 좀 거슬리고요. 확인/경고/간단 폼 모달부터 점진적으로 마이그레이션하는 게 현실적입니다.
2. 빌드 전체를 다시 안 돌려도 됩니다: Next.js ISR
두 번째는 렌더링 패턴입니다. SSG의 퍼포먼스와 동적 콘텐츠의 신선함을 동시에 잡겠다는 ISR(Incremental Static Regeneration)은 사실 개념 자체는 간단합니다. export const revalidate = 60 한 줄이면, 60초 이후 첫 요청 시 stale 페이지를 즉시 내려주면서 백그라운드에서 새 버전을 생성하고, 이후 요청부터 갱신된 페이지를 서빙합니다.
사용자 입장에서는 CDN 캐시 히트로 TTFB가 수십 ms 수준이고, 콘텐츠는 최대 revalidate 주기만큼만 지연됩니다. 여기에 On-Demand Revalidation을 결합하면—CMS에서 글 발행 시 API route를 호출해서 해당 경로만 즉시 퍼지—시간 기반 폴링의 한계도 해결됩니다. 수천 페이지 규모 사이트에서 빌드 시간이 선형 증가하던 SSG의 고질적 문제를, 플랫폼 레벨에서 해결한 셈이죠.
프론트엔드 개발자로서 솔직히 신경 쓰이는 건 Core Web Vitals와의 관계입니다. ISR 페이지는 정적 HTML이니 LCP는 좋을 수밖에 없고, JavaScript 하이드레이션 부담도 SSR 대비 가벼워요. 다만, stale 상태에서 보여주는 콘텐츠와 갱신 후 콘텐츠의 시각적 차이가 크면 사용자가 혼란을 느낄 수 있으니, 로딩 스켈레톤이나 subtle한 갱신 인디케이터를 고민해야 할 지점은 남아 있습니다.
3. URL이 데이터베이스입니다: 상태 공유의 재발견
세 번째 패턴은 더 원초적입니다. 앱 상태를 JSON.stringify → btoa로 base64 인코딩해서 URL 쿼리 파라미터에 담는 것. 약 20줄의 코드로 백엔드 없이 설정 공유가 가능해집니다. 받는 쪽에서는 atob → JSON.parse로 복원하면 끝.
"이거 너무 단순한 거 아닌가?" 싶지만, 사용자 입장에서 생각해보면 이게 꽤 강력합니다. 디버깅 시 지원 티켓에 URL만 첨부하면 정확한 상태가 재현되고, 북마크로 특정 설정을 저장할 수 있고, 오프라인에서도 동작합니다. localStorage 프리셋과 결합하면 로컬 저장 + URL 공유라는 이중 레이어가 API 콜 제로로 완성되죠.
물론 gotcha는 있습니다. URL 길이 제한(대부분 브라우저 2,000자 이상 처리하지만 안전 마진 필요), base64의 33% 오버헤드(큰 상태는 pako 같은 gzip 압축 고려), 그리고 base64는 인코딩이지 암호화가 아니라는 점. 민감한 데이터는 절대 URL에 넣으면 안 됩니다. 상태 스키마 버전 관리도 빠트리면 안 되고요—깨진 링크는 사용자 신뢰를 한 번에 날립니다.
시사점: 플랫폼을 믿되, 경계를 알아야 합니다
세 가지 패턴의 공통점은 명확합니다. "직접 만들지 마세요." <dialog>는 모달 인프라를, ISR은 정적·동적 렌더링의 절충을, URL 인코딩은 경량 상태 공유를—각각 플랫폼 또는 프레임워크 수준에서 이미 해결했습니다. 2022~2024 사이에 Popover API, Container Queries, inert 속성 등이 잇달아 안정화되면서, 웹 플랫폼은 "네이티브 UI 프리미티브"의 시대로 확실히 전환했어요.
하지만 예민한 프론트엔드 개발자로서 한 마디 덧붙이면—플랫폼이 '해결했다'와 '우리 프로젝트에 맞다'는 다른 문제입니다. <dialog>의 애니메이션 제약, ISR의 stale 콘텐츠 UX, URL 상태의 보안 한계. 이 경계를 모른 채 "네이티브니까 무조건 좋다"고 달려들면, 커스텀 코드 시절과 다른 종류의 기술 부채가 쌓입니다. 직접 만들지 않되, 왜 안 만들어도 되는지는 px 단위로 알고 있어야 합니다.