본문으로 건너뛰기

© 2026 Molayo

Smashing헤드라인2026. 05. 20. 01:29

Popover API 시작하기

요약

기존의 툴팁 구현 방식은 접근성, 키보드 네비게이션, 이벤트 관리 등 복잡한 문제로 인해 외부 JavaScript 라이브러리에 의존해 왔습니다. 본 기사는 이러한 라이브러리 없이 브라우저의 네이티브 기능인 Popover API를 사용하여 툴팁을 구축하는 방법과 그 필요성을 다룹니다.

핵심 포인트

  • 전통적인 툴팁 구현은 접근성(ARIA), 키보드 포커스, 외부 클릭 처리 등 복잡한 로직을 수반함
  • JavaScript 라이브러리는 이러한 문제를 해결해주지만, 내부 동작을 파악하기 어려운 블랙박스 역할을 함
  • Popover API는 라이브러리 없이 브라우저 네이티브 모델만으로 툴팁과 같은 UI 요소를 관리할 수 있게 함
  • 새로운 API인 만큼 브라우저 지원 현황(Caniuse)을 확인하며 사용하는 것이 권장됨

툴팁(Tooltip)은 당신이 겪을 수 있는 가장 작은 UI 문제처럼 느껴집니다. 그것들은 아주 작고 보통은 숨겨져 있죠. 누군가 툴팁을 어떻게 만드는지 물어본다면, 전통적인 답변은 거의 항상 어떤 JavaScript 라이브러리를 사용하는 것으로 돌아옵니다. 그리고 오랫동안 그것은 합리적인 조언이었습니다.

저 또한 그 조언을 따랐습니다.

겉보기에 툴팁은 단순합니다. 요소에 마우스를 올리거나(Hover) 포커스(Focus)를 주면, 텍스트가 담긴 작은 박스를 보여주고, 사용자가 벗어나면 그것을 숨기면 됩니다. 하지만 실제 사용자들에게 배포하고 나면, 문제점들이 드러나기 시작합니다. 키보드 사용자가 트리거(Trigger)로 Tab 키를 눌러 이동하지만, 툴팁은 전혀 보이지 않습니다. 스크린 리더(Screen reader)는 이를 두 번 읽어주거나, 아예 읽어주지 않습니다. 마우스를 너무 빠르게 움직이면 툴팁이 깜빡거립니다. 작은 화면에서는 콘텐츠를 가려버립니다. Esc 키를 눌러도 닫히지 않습니다. 포커스(Focus)를 잃어버리기도 합니다.

시간이 흐르면서, 제가 작성한 툴팁 코드는 더 이상 제가 관리하고 싶지 않은 무언가로 커져 버렸습니다. 이벤트 리스너(Event listener)가 쌓여갔습니다. 호버(Hover)와 포커스(Focus)를 별도로 처리해야 했습니다. 외부 클릭(Outside click)은 특별한 케이스로 다뤄야 했습니다. ARIA 속성(Attribute)들은 수동으로 동기화해야 했습니다. 작은 수정 하나를 할 때마다 또 다른 로직 계층이 추가되었습니다.

라이브러리들이 도움이 되긴 했지만, 그것들은 내부에서 어떤 일이 일어나고 있는지 완전히 이해하기보다는 제가 우회해서 사용해야 하는 블랙박스(Black box)에 더 가까웠습니다.

그것이 저를 새로운 Popover API를 살펴보게 만든 원동력이었습니다. 라이브러리의 도움 없이 브라우저의 네이티브 모델(Native model)만을 사용하여 단 하나의 툴팁을 다시 구축한다면 어떤 일이 일어날지 보고 싶었습니다.

시작하기에 앞서, 모든 새로운 기능과 마찬가지로 아직 다듬어지고 있는 부분들이 있다는 점을 유의할 가치가 있습니다. 그렇긴 해도, 전체 API의 여러 구성 요소가 유동적이긴 하지만 현재 브라우저 지원은 매우 훌륭합니다. 그동안 Caniuse를 계속 주시해 두는 것이 좋습니다.

"과거의" 툴팁

Popover API가 나오기 전에는 툴팁 라이브러리를 사용하는 것이 지름길이 아니었습니다. 그것은 기본값이었습니다. 브라우저에는 마우스, 키보드, 보조 기술(Assistive technology) 전반에 걸쳐 작동하는 툴팁에 대한 네이티브 개념이 없었습니다. 정확성을 중요하게 생각한다면 유일한 선택지는 라이브러리를 사용하는 것이었고, 그것이 바로 제가 했던 방식입니다.

높은 수준에서 보면, 패턴은 항상 동일했습니다. 트리거 요소(trigger element), 숨겨진 툴팁 요소(tooltip element), 그리고 이 둘을 조정하기 위한 JavaScript가 필요했습니다.

<button class="info">?</button>
<div class="tooltip" role="tooltip">도움이 되는 텍스트</div>

라이브러리는 요소가 호버(hover) 또는 포커스(focus) 시에 나타나고, 블러(blur) 또는 마우스 이탈(mouse leave) 시에 숨겨지며, 스크롤 시 위치를 재조정하거나 크기를 조정할 수 있도록 하는 연결 작업을 처리했습니다.

이 중 어느 것도 우연이 아니었습니다. 이는 단지 웹 플랫폼 기능의 공백을 보완하기 위한 것이었습니다.

라이브러리를 사용한 이유

라이브러리는 저를 대신해 실질적인 작업을 수행하고 있었습니다. 위치 지정(positioning), 뷰포트(viewport) 경계에서의 반전(flipping), 다양한 입력 유형 간의 이벤트 조정(event coordination), 그리고 복잡한 레이아웃 내부에서의 스크롤 인지(scroll awareness) 등이 그것입니다. 위치 지정 작업만으로도 해당 의존성(dependency)을 사용할 가치는 충분했습니다. 스크롤 컨테이너(scroll containers), 변형(transforms), 그리고 반응형 레이아웃(responsive layouts)을 올바르게 처리하는 것은 간단한 일이 아닙니다.

진정한 문제는 시각적인 부분이 아니라 **접근성 동작(accessibility behavior)**에서 나타났습니다. 툴팁은 작동했지만, 항상 그런 것은 아니었습니다. 다음과 같은 부분에서 문제가 발생하기 시작했습니다:

  • 툴팁이 때때로 늦게 나타나거나 아예 나타나지 않았습니다.
  • 빠르게 탭(Tabbing)을 하면 툴팁을 완전히 건너뛸 수 있었습니다.
  • Esc 키를 통한 해제(dismissal)가 신뢰할 수 없었습니다.

또한 호버(hover)와 포커스(focus) 동작을 동기화하려 할 때도 문제에 직면했습니다:

  • 마우스 사용자는 즉각성을 기대합니다.
  • 키보드 사용자는 예측 가능성을 기대합니다.
  • 이 둘을 모두 지원한다는 것은 지연(delays)과 예외 케이스(edge cases)를 의미했습니다.

말할 것도 없이, 보조 기술(assistive technologies), 특히 스크린 리더(screen readers)와 관련된 문제도 있었습니다. 때로는 툴팁이 안내되었고, 때로는 그렇지 않았으며, 때로는 두 번 안내되기도 했습니다.

ARIA 속성(attributes)을 동기화 상태로 유지하려면 수동 업데이트가 필요했습니다. 상태 변경을 하나라도 놓치면, 툴팁은 접근성 트리(accessibility tree)에서 혼란을 주거나 보이지 않게 되었습니다.

이것은 나쁜 코드가 아니었습니다

구현 방식은 테스트를 거쳤고, 라이브러리는 견고했으며, 당시 사용할 수 있는 도구들을 고려했을 때 동작은 합리적이었습니다.

핵심 문제는 코드가 아니었습니다. 웹 플랫폼에 적절한 어포던스(affordances, 행동 유도성)가 부족했다는 점이 문제였습니다.

예를 들어, 브라우저는 해당 요소가 툴팁(tooltip)이라는 것을 알 수 있는 실질적인 방법이 없었습니다. 모든 것은 관습(conventions)에 의해 구축되었습니다. 즉, 일반적인 요소, 이벤트 리스너(event listeners), 수동으로 관리되는 ARIA, 그리고 커스텀 종료 로직(custom dismissal logic)을 사용해야 했습니다.

시간이 흐르면서 툴팁은 취약해질 수 있었습니다. 작은 변화도 위험을 수반했습니다. 사소한 수정이 회귀(regressions)를 일으키기도 했습니다. 더 나쁜 것은, 새로운 툴팁을 추가할 때마다 동일한 복잡성을 그대로 물려받아야 한다는 점이었습니다. 기술적으로는 작동했지만, 결코 안정적이거나 완성된 느낌을 주지 못했습니다.

그것이 제가 브라우저의 네이티브 Popover API를 사용하여 툴팁을 다시 구축하기로 결정했을 당시의 상황이었습니다.

Popover API를 시도했던 순간

제가 Popover API를 사용하기로 전환한 것은 단순히 새로운 것을 실험해보고 싶어서가 아니었습니다. 브라우저가 이미 이해하고 있어야 한다고 믿는 툴팁 동작을 유지 관리하는 것에 지쳤기 때문입니다.

처음에는 회의적이었습니다. 대부분의 새로운 웹 API는 단순함을 약속하지만, 여전히 접착 코드(glue code), 예외 상황 처리(edge-case handling), 또는 당신이 벗어나려고 했던 것과 동일한 복잡성을 조용히 재현하는 폴백 로직(fallback logic)을 요구하기 때문입니다.

그래서 저는 가능한 가장 작은 방식으로 Popover API를 시도해 보았습니다. 그 모습은 다음과 같았습니다:

<!-- popovertarget는 id="tip-1"과의 연결을 생성합니다 -->
<button popovertarget="tip-1">?</button>
<!-- popover="manual": 브라우저가 이를 popover로 관리합니다 -->
...

이벤트 리스너도 없었습니다. 상태 추적(state tracking)도 없었습니다. JavaScript로 처리되는 ARIA 업데이트도 없었습니다. 버튼에 포커스(focus)를 맞추자 툴팁이 나타났습니다. Esc 키를 누르자 툴팁이 사라졌습니다.

즉각적으로 눈에 띈 점들

몇 분 만에 몇 가지 사실이 명확해졌습니다:

열거나 닫기 위한 JavaScript를 전혀 작성하지 않았습니다

브라우저가 HTML을 통해 호출(invocation)을 완전히 처리했습니다. 트리거(trigger)와 툴팁 사이의 관계가 명시적이었습니다.

Esc 키가 그냥 작동했습니다

키 리스너(key listener)를 추가하지 않았습니다. Esc 키를 누르면 툴팁이 제대로 닫혔는데, 이는 브라우저가 popover는 닫을 수 있어야 한다는 점을 이해하고 있기 때문입니다.

ARIA 상태가 자동으로 동기화되었습니다

aria-expanded

속성이 팝오버 (popover)가 열리고 닫힐 때 스스로 업데이트되었습니다. 수동으로 상태를 관리할 필요가 없었으며, 오래된 상태 (stale state)가 남을 위험도 없었습니다.

이 순간, Popover API는 단순한 편의 기능이 아니라 진정한 플랫폼 동작 (platform behavior)처럼 느껴지기 시작했습니다.

저를 가장 놀라게 했던 것은 코드의 감소가 아니라 **책임의 변화 (change in responsibility)**였습니다. 이전에는 제 JavaScript가 명령했기 때문에 툴팁 (tooltip)이 존재했습니다. 이제는 브라우저가 그것이 무엇인지, 그리고 마크업 (markup) 내에서의 역할이 무엇인지 이해하기 때문에 존재합니다. 툴팁은 더 이상 단순히 버튼 근처에 배치된 상자가 아니라, 브라우저의 포커스 모델 (focus model), 접근성 트리 (accessibility tree), 그리고 네이티브 해제 규칙 (native dismissal rules)에 참여하게 되었습니다.

그때부터 저의 Popover API로의 마이그레이션 (migration)이 시작되었습니다.

Invoker Commands 이해하기

popovertargetpopovertargetaction 속성은 HTML의 invoker commands (호출 명령)의 일부로, JavaScript 없이 대화형 요소 (interactive elements)를 제어하는 선언적 (declarative) 방식입니다.

popovertarget="id": 버튼을 팝오버 요소에 연결합니다.
popovertargetaction: 발생할 동작을 지정합니다:

  • show: 팝오버를 열기만 합니다.
  • hide: 팝오버를 닫기만 합니다.
  • toggle (기본값): 팝오버가 닫혀 있으면 열고, 열려 있으면 닫습니다.

이는 동일한 툴팁에 대해 여러 개의 트리거 (trigger)를 가질 수 있음을 의미합니다:

<button popovertarget="help-tip" popovertargetaction="show">
Show Help
</button>
...

브라우저가 기본적인 상호작용을 위해 JavaScript 없이도 모든 것을 조정합니다.

무료로 얻는 접근성 이점

이 부분이 저를 완전히 바꾸어 놓은 지점입니다. 저는 Popover API가 코드를 줄여줄 것이라고 예상했습니다. 하지만 제가 수년간 쫓아다녔던 접근성 버그 (accessibility bugs)의 범주 전체를 제거해 줄 것이라고는 예상하지 못했습니다. 마이그레이션 전에도 제 툴팁 시스템은 적어도 겉보기에는 괜찮아 보였습니다. 키보드 지원이 있었고, ARIA 속성들이 존재했으며, 스크린 리더 (screen reader)도 보통 그에 맞춰 동작했습니다. 하지만 그 "보통"이라는 말이 아주 많은 것을 감당하고 있었습니다.

네이티브 팝오버로 교체하자마자 세 가지가 즉시 변했습니다.

1. 키보드가 "그냥 작동함"

키보드 지원은 여러 계층이 올바르게 정렬되어야만 가능했습니다. 포커스 (focus)가 툴팁을 트리거해야 했고, 블러 (blur)가 이를 숨겨야 했으며, Esc 키를 수동으로 연결해야 했고, 타이밍도 중요했습니다. 만약 하나의 예외 케이스 (edge case)라도 놓치면, 툴팁이 너무 오래 열려 있거나 읽기도 전에 사라져 버리곤 했습니다.

popover 속성을 auto 또는 manual로 설정하면, 브라우저가 기본적인 사항들을 담당합니다. TabShift + Tab은 정상적으로 작동하고, Esc는 매번 툴팁을 닫으며, 추가적인 리스너 (listeners)가 필요하지 않습니다.

<div popover="manual">
Helpful explanation
</div>

제 코드베이스에서 사라진 것은 전역 키다운 (keydown) 핸들러, Esc 전용 정리 (cleanup) 로직, 그리고 키보드 탐색 중의 상태 체크였습니다. 키보드 경험은 제가 유지 관리해야 하는 대상에서 브라우저가 보장하는 영역으로 바뀌었습니다.

2. 스크린 리더 (Screenreader) 예측 가능성

이것이 가장 큰 개선 사항이었습니다. 앞서 설명했듯이, 세심한 ARIA 작업에도 불구하고 동작은 제각각이었습니다. 모든 작은 변화가 위험하게 느껴졌습니다. 적절한 역할 (role)을 가진 팝오버를 사용하면, 어떤 일이 일어날지에 대해 훨씬 더 안정적이고 예측 가능한 모습과 느낌을 줍니다.

<div popover="manual" role="tooltip">
Helpful explanation
</div>

그리고 또 다른 승리 요소가 있습니다. 전환 후에 Lighthouse는 해당 상호작용에 대해 잘못된 ARIA 상태 경고를 표시하지 않았습니다. 이는 주로 제가 실수로 잘못 설정할 수 있는 커스텀 ARIA 상태가 더 이상 존재하지 않기 때문입니다.

3. 포커스 관리 (Focus Management)

포커스는 매우 취약했습니다. 이전에는 다음과 같은 규칙들이 있었습니다: 포커스가 툴팁을 표시하도록 하고, 포커스를 툴팁 내부로 이동시킨 뒤 닫지 않으며, 너무 가까워지면 트리거에 블러를 주고, 툴팁을 닫은 뒤 수동으로 포커스를 복구하는 식입니다. 이는 작동하는 듯하다가도 어느 순간 제대로 작동하지 않았습니다.

Popover API를 사용하면, 브라우저가 포커스가 팝오버 내부로 더 자연스럽게 이동할 수 있는 더 단순한 모델을 강제합니다. 팝오버를 닫으면 포커스가 트리거로 돌아가며, 보이지 않는 포커스 트랩 (focus traps)이나 포커스를 놓치는 순간이 발생하지 않습니다. 그리고 저는 포커스 복구 코드를 추가한 것이 아니라, 오히려 제거했습니다.

Popover API가 여전히 충분하지 않을 수도 있는 부분

Popover API가 제 코드를 단순화하고 의미론적 구조(Semantics)를 개선해 주었지만, 여전히 JavaScript를 완전히 제거하지는 못했습니다. 하지만 이것이 전적으로 나쁜 것은 아닙니다. 변화된 점은 JavaScript가 더 이상 핵심 의존성(Key dependency)이 아니라는 것입니다. 저는 더 이상 플랫폼 기능의 부재를 보완하기 위해 애쓸 필요가 없습니다. 저는 훨씬 더 *의도(Intent)*에 집중하고 있습니다.

다음은 API가 계속해서 개선될 수 있다고 생각되는 몇 가지 부분입니다.

툴팁 타이밍은 여전히 중요합니다

네이티브 Popover는 즉시 열리고 닫힙니다. 이는 대개 예상되는 동작이지만, 우리가 툴팁(Tooltip)이라고 간주하는 것에는 항상 이상적이지는 않습니다. 그런 경우, 마우스를 몇 픽셀 너무 빠르게 움직이거나 트리거를 실수로 스치듯 지나갈 때 즉각적인 해제(Dismissal)가 불안정하게 느껴질 수 있습니다. 툴팁이 번쩍하고 나타났다가 바로 사라지는데, 이는 사용자에게 당혹감을 줄 수 있습니다.

저는 그 타이밍을 제어하고 호버(Hover) 또는 포커스(Focus)와 툴팁 열림 사이에 지연 시간(Delay)을 적용할 수 있기를 바랍니다. 그래서 저는 여전히 약간의 지연 시간을 추가합니다. 달라진 점은 제가 실제로 관리해야 하는 상호작용 로직(Interaction logic)의 양입니다. 이전에는 기본적인 열기 및 닫기 동작조차 JavaScript가 필요했습니다. Popover API, 특히 HTML invoker 명령을 사용하면 그 책임이 다시 브라우저로 넘어갑니다.

<button
popovertarget="help-tip"
popovertargetaction="show">
...

이 시점에서 브라우저는 호출(Invocation), 해제(Dismissal), 그리고 ARIA 상태를 스스로 처리합니다. 툴팁을 나타나게 하거나 사라지게 하기 위해 단순히 JavaScript를 사용할 필요가 없습니다.

JavaScript는 제가 의도적인 동작을 원할 때만 다시 등장합니다. 이 경우에는 툴팁을 숨기기 전의 짧은 지연 시간, 그리고 포인터가 툴팁 안으로 이동할 경우의 취소 동작 같은 것들입니다. 이것은 접근성(Accessibility) 수정에 관한 것이 아니라, 인간의 행동 패턴에 관한 것입니다.

CSS 또한 이 영역을 탐색하기 시작했다는 점은 주목할 만합니다. 새롭게 등장하는 interest/invoker 작업은 CSS에서 직접 진입 및 퇴장 지연(Entry and exit delays)을 표현하는 방법을 도입하고 있으며, 이는 이 작은 부분의 JavaScript를 완전히 제거할 수도 있습니다. 현재로서는 여전히 명령형(Imperatively)으로 처리하고 있지만, 플랫폼의 방향성은 명확합니다.

let hideTimeout;
const show = () => {
clearTimeout(hideTimeout);
...

차이점은 이 로직이 작고 국소적(Local)으로 유지된다는 것입니다. 이것은 더 이상 툴팁(Tooltip)이 어떻게 작동하는지를 정의하지 않습니다. 단지 그것이 어떻게 느껴지는지를 정교하게 다듬을 뿐입니다.

인보커 명령(Invoker Commands)을 이용한 호버 의도(Hover Intent)

브라우저는 누군가가 왜 요소 위에 마우스를 올리거나(Hover) 포커스(Focus)를 하는지 알지 못합니다. 그것이 의도적인 것이었을까요, 아니면 포인터(Pointer)가 그저 지나가는 중이었을까요? 그 부분은 항상 어느 정도의 판단을 필요로 했습니다.

변화된 점은 그 로직이 어디에 위치하느냐입니다. 인보커 명령(Invoker commands)이 핵심적인 열기(Open) 및 닫기(Close) 동작을 처리함에 따라, JavaScript는 더 이상 상호작용 모델(Interaction model)을 소유하지 않습니다. JavaScript는 그 위에 의도(Intent)를 추가할 뿐입니다.

<button
popovertarget="help-tip"
popovertargetaction="show">
...

AI 자동 생성 콘텐츠

본 콘텐츠는 Smashing Magazine의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0