본문으로 바로가기
검색 (Ctrl+K)

HTML도 추상화된 도구이다

·
---
·
개발

<label>과 <input>의 연결 과정을 브라우저 구현까지 추적하며, HTML이 단순한 마크업이 아닌 추상화된 도구임을 살펴보자.

들어가며

프론트엔드 개발자는 HTML을 가장 기초적인 기술로 생각한다.

React는 추상화이고 브라우저 API는 저수준이지만, HTML은 그보다 아래에 있는 단순한 마크업이라고 생각하기 쉽다. 태그를 작성하면 브라우저가 화면에 요소를 그려주는 정적인 문법처럼 보이기 때문이다.

하지만 브라우저의 구현을 따라가 보면 HTML도 하나의 추상화 계층이라는 사실이 드러난다.

개발자는 선언적인 태그와 속성만 작성하지만, 브라우저는 그 뒤에서 요소를 탐색하고, 요소 사이의 관계를 계산하고, 이벤트와 포커스를 전달하며, 접근성 정보를 구성한다. 우리가 직접 구현하지 않은 동작이 HTML 요소의 기본 동작으로 제공되는 것이다.

<label><input>의 연결은 이를 보여주는 작은 사례다.

<label for="email">이메일</label>
<input id="email" type="email">

우리는 forid에 같은 값을 작성하면 두 요소가 연결된다고 알고 있다. 그러나 브라우저가 어떤 규칙으로 연결 대상을 찾는지, 라벨을 클릭했을 때 입력 요소에 포커스와 클릭이 어떻게 전달되는지, 연결 결과가 label.controlinput.labels 같은 DOM API로 어떻게 노출되는지는 코드만 보고 알기 어렵다.

이 글에서는 <label><input>의 연결 규칙을 WHATWG HTML 스펙에서 확인하고, 해당 규칙이 Chromium의 Blink 엔진에서 어떻게 구현되어 있는지 실제 코드를 따라가보며 딥다이브 해보는 글이다.

이를 통해 평소 단순한 마크업으로 사용하는 HTML이 브라우저 내부의 복잡한 동작을 감춘 추상화된 도구라는 점을 한 단계 더 깊게 살펴보자.


우리가 작성하는 코드는 두 줄이다

이메일 입력 필드에 라벨을 연결하려면 다음과 같이 작성한다.

<label for="email">이메일</label>
<input id="email" type="email">

개발자가 작성한 정보는 두 가지뿐이다.

label.for = "email"
input.id = "email"

표면적으로 보면 같은 문자열을 적었기 때문에 두 요소가 연결된 것처럼 보인다. 그렇다면 브라우저는 이 두 문자열을 보고 실제로 어떤 <input>이 이 <label>의 대상인지 어떻게 판단할까??

이 관계는 JavaScript의 DOM API를 통해 직접 확인할 수 있다.

const label = document.querySelector("label");
const input = document.querySelector("input");

console.log(label.htmlFor);
// "email"

console.log(label.control === input);
// true

console.log(input.labels[0] === label);
// true

결과를 보면 <label>에는 htmlForcontrol이라는 프로퍼티가 있고, <input>에는 labels라는 프로퍼티가 존재한다.

그런데 이 프로퍼티들은 어디에서 정의된 것일까?

label.htmlForlabel.control은 각각 어떤 역할을 하며, input.labels는 어떻게 반대 방향의 관계를 알아내는 것일까?

무엇보다 forid가 같다는 사실이 실제 요소 간 연결로 바뀐다는 것은 정확히 어떤 의미일까?

이 질문에 답하려면 먼저 브라우저 구현이 아니라, 브라우저가 따라야 하는 규칙을 정의한 HTML 스펙부터 확인해야 한다.


연결 규칙은 HTML 스펙에 정의되어 있다.

앞에서 확인한 label.htmlFor, label.control, input.labels는 브라우저가 임의로 제공하는 기능이 아니다. 이 프로퍼티와 연결 방식은 WHATWG HTML 스펙 (새 탭에서 열림)<label> 요소 항목에 아래와 같이 정의되어있다.

위 내용을 그대로 번역해보면 아래와 같다.

<label> 요소는 사용자 인터페이스에서 특정 폼 컨트롤을 설명하는 문구를 나타내며, for 속성을 사용하거나 폼 컨트롤을 <label> 내부에 넣는 방식으로 해당 컨트롤과 연결할 수 있다. 이렇게 연결된 컨트롤을 라벨의 labeled control이라고 한다.

즉, <label>은 단순한 텍스트 요소가 아니라 특정 폼 컨트롤과 의미적인 관계를 가질 수 있는 요소다.

forid를 이용한 명시적 연결

우리가 가장 익숙하게 사용하는 방식은 forid를 맞추는 것이다.

<label for="email">이메일</label>
<input id="email" type="email">

보통은 for="email"id="email"이 같기 때문에 두 요소가 연결된다고 이해한다. 그런데 여기서 아래와 같이 3가지의 생각을 해볼 수 있다.

  • 브라우저는 문서 전체에서 id="email"인 요소를 찾는 걸까?
  • 같은 ID를 가진 요소가 여러 개라면 어떤 요소를 선택할까?
  • ID만 같다면 <div><span>도 라벨의 대상이 될 수 있을까?

먼저 이 질문에 답을 찾기 위해 WHATWG 스펙을 확인해봤다. 스펙에서는 먼저 for 속성에 대해 다음과 같이 설명한다.

“The attribute's value must be the ID of a labelable element in the same tree as the label element.”

이 문장에서 처음 확인할 수 있는 조건은 두 가지이다.

  1. for가 가리키는 요소는 <label>같은 트리 안에 있어야 한다.
  2. 그리고 그 요소는 단순한 HTML 요소가 아니라 labelable element여야 한다.

즉, forid의 문자열이 같다는 사실만으로 연결이 완성되는 것은 아니다.

다음 코드를 보자.

<label for="email">이메일</label>
<div id="email"></div>

forid는 모두 email이다. 하지만 <div>가 라벨과 연결 가능한 요소인지 아직 확인하지 않았다.

스펙 문서에 labelable element 목록을 보면 다음 요소만 해당 범주에 들어간다.

button
hidden 상태가 아닌 input
meter
output
progress
select
textarea
form-associated custom element

<div>는 여기에 포함되지 않기 때문에 문자열은 일치하지만 연결 대상은 존재하지 않는걸로 이해할 수 있다.

const label = document.querySelector("label");

console.log(label.control);
// null

그렇다면 같은 id를 가진 요소가 여러 개 있을 때는 어떻게 될까?

스펙은 이어서 다음과 같이 정의한다.

“If the attribute is specified and there is an element in the tree whose ID is equal to the value of the for attribute, and the first such element in tree order is a labelable element, then that element is the label element's labeled control.”

여기서 새로운 조건이 하나 더 드러나는데, 브라우저는 일치하는 모든 요소를 검사해 적절한 <input>을 골라주는 것이 아니다. 트리 순서상 처음 발견된 요소를 기준으로 판단한다고 되어있다.

다음 HTML을 보자.

<div id="email"></div>

<label for="email">이메일</label>
<input id="email" type="email">

id="email"<input>이 존재하므로 연결될 것처럼 보인다. 하지만 트리 순서상 먼저 등장하는 요소는 <div>다.

브라우저는 첫 번째 요소인 <div>를 찾고, 해당 요소가 labelable element인지 검사한다. <div>는 labelable element가 아니므로 연결은 실패한다.

const label = document.querySelector("label");
const input = document.querySelector("input");

console.log(label.control);
// null

console.log(label.control === input);
// false

여기서 중요한 점은 뒤에 있는 <input>을 다시 찾아 연결하지 않는다는 점이다. 첫 요소에 연결이 실패할 경우 이 연결은 무조건 끊기게 되어있다.

스펙 문서를 따라가며 확인한 명시적 연결 규칙은 다음과 같다. (암시적 연결규칙도 따로 있음 → for 속성을 붙이지 않고 연결하는 방법)

label의 for 값을 읽는다
→ 같은 트리에서 해당 ID를 가진 첫 요소를 찾는다
→ 그 요소가 labelable element인지 확인한다
→ 조건을 만족하면 labeled control로 결정한다
→ 만족하지 않으면 연결하지 않는다

이제 forid가 같으면 연결된다는 단순한 이해보다는 for 값과 같은 ID를 가진 첫 번째 요소가 같은 트리에 존재하고, 그 요소가 labelable element일 때 <label>의 labeled control이 된다 라고 설명할 수 있다.

여기까지가 HTML 스펙문서에 정의되어있는 규칙이다. 그렇다면 브라우저는 이 규칙을 어떤 코드로 실행하고 있을까? 먼저 Chromium 저장소에서 스펙의 DOM 인터페이스 이름인 HTMLLabelElement를 검색해보자.


브라우저는 label의 연결 대상을 어떻게 찾을까?

label 태그의 스펙문서 (새 탭에서 열림)를 보면 interface가 아래와 같이 정의되어있는 것을 확인할 수 있다.

그렇다면 브라우저 소스에서도 비슷한 이름으로 구현되어 있지 않을까? Chromium 저장소에서 HTMLLabelElement를 검색하면 해당 코드 (새 탭에서 열림)를 찾을 수 있다.

해당 파일의 60번째 줄을 보면 controlForBinding() (새 탭에서 열림) 이라는 함수를 찾아볼 수 있는데, 이 내부 구현을 자세히 살펴보자.

  1. 함수는 먼저 <label>for 속성을 읽어 control_id라는 변수에 저장한다.
const AtomicString& control_id = FastGetAttribute(html_names::kForAttr); // for 속성 읽기
  1. for 속성이 있는지 검사하고, for 속성이 없다면 내부 자식 요소를 트리 순서대로 순회해서 labelable element를 만나면 그 즉시 엘리먼트를 반환한다. (이건 암시적 연결에 해당한다.)
  if (control_id.IsNull()) { // for 속성이 없다면
    for (HTMLElement& element : Traversal<HTMLElement>::DescendantsOf(*this)) { // 내부자식 요소 순회
      if (element.IsLabelable()) { //labelable element 라면
        if (!element.IsFormControlElement()) {
          UseCounter::Count(
              GetDocument(),
              WebFeature::kHTMLLabelElementControlForNonFormAssociatedElement);
        }
        return &element; // 그 요소 반환
      }
    }
    return nullptr; 
  }

3. 다음은 명시적 연결을 찾는 코드이다. for 속성이 존재하는 경우 <label> 내부 자식 요소를 순회하지 않는다. 대신 현재 <label>이 속한 TreeScope에서 control_id, 즉 for 속성값과 같은 id를 가진 요소를 찾는다.

if (Element* element = GetTreeScope().getElementById(control_id)) {
  // for 값과 같은 id를 가진 요소를 찾는다.
  if (auto* html_element = DynamicTo<HTMLElement>(*element)) {
    // 찾은 요소가 HTMLElement인지 확인한다.
    if (html_element->IsLabelable()) {
      // label과 연결 가능한 요소인지 확인한다.
      if (!html_element->IsFormControlElement()) {
        UseCounter::Count(
            GetDocument(),
            WebFeature::kHTMLLabelElementControlForNonFormAssociatedElement);
      }

      return html_element;
      // 조건을 만족한 요소를 labeled control로 반환한다.
    }
  }
}

연결 대상을 찾은 뒤에는 무엇을 할까?

이 함수의 흐름을 정리하면, for 속성이 없을 때는 <label> 내부의 첫 번째 labelable element를 찾고, for 속성이 있을 때는 같은 TreeScope에서 해당 ID를 가진 요소를 찾은 뒤 labelable element인지 확인한다.

즉, 브라우저는 <label><input>의 관계를 별도의 고정된 연결 정보로 저장해두는 것이 아니라, 현재 DOM 구조와 속성값을 기준으로 연결 대상을 찾아 반환한다고 볼 수 있다.

그런데 연결 대상을 찾는 것만으로 다음 동작까지 설명되지는 않는다.

<label for="terms">약관에 동의합니다</label>
<input id="terms"type="checkbox">

라벨 텍스트를 클릭하면 체크박스의 상태가 바뀐다.

const label = document.querySelector("label");
const checkbox = document.querySelector("#terms");

console.log(checkbox.checked);
// false

label.click();

console.log(checkbox.checked);
// true

우리는 다음과 같은 이벤트 리스너를 작성하지 않았다.

label.addEventListener("click", () => {
  checkbox.focus();
  checkbox.click();
});

그런데도 라벨 클릭이 체크박스의 포커스와 클릭으로 이어진다. 그렇다면 연결 대상을 찾은 뒤, 라벨의 클릭은 실제 컨트롤에 어떻게 전달될까?


연결은 요소 탐색으로 끝나지 않는다

같은 html_label_element.cc 파일에서 이벤트 처리와 관련된 코드를 찾으면 DefaultEventHandlerInternal() (새 탭에서 열림)이라는 함수를 확인할 수 있다.

void HTMLLabelElement::DefaultEventHandlerInternal(Event& evt)

함수는 먼저 현재 이벤트가 클릭인지 확인하고, 앞에서 살펴본 Control()을 호출해 연결된 요소를 가져온다.

if (evt.type() == event_type_names::kClick && !processing_click_) {
  HTMLElement* element = Control();

  if (!element)
    return;

연결된 컨트롤이 없다면 더 이상 처리하지 않고, 이후 여러 예외 조건을 검사한 뒤, 연결된 컨트롤에 포커스를 전달한다.

if (element->IsMouseFocusable() ||
    element->IsShadowHostWithDelegatesFocus()) {
  element->Focus(...);
}

마지막으로 해당 컨트롤에 simulated click을 발생시킨다.

element->DispatchSimulatedClick(&evt);

이 두 코드가 라벨을 클릭했을 때 체크박스가 활성화/ 비활성화 되는 핵심코드이다.

사용자가 label 클릭
→ label의 기본 이벤트 처리
Control()로 연결된 요소 탐색
→ 연결된 요소에 focus 전달
→ simulated click 발생
→ checkbox 상태 변경

즉, 라벨과 입력 요소 사이에 별도의 JavaScript 이벤트 리스너가 등록되는 것이 아니다. <label> 자체의 기본 이벤트 처리 로직이 현재 연결된 컨트롤을 찾아 동작을 전달한다.

컨트롤 자체를 클릭한 경우

여기서 단순히 모든 라벨 클릭에 DispatchSimulatedClick()을 실행하면 문제가 생긴다. 그 이유를 자세히 살펴보자.

다음과 같이 <input><label> 내부에 있을 수 있다.

<label>
<input type="checkbox">약관에 동의합니다
</label>

사용자가 체크박스를 직접 클릭하면 해당 클릭 이벤트는 부모인 <label>로 버블링된다.

이때 라벨이 다시 체크박스에 simulated click을 전달하면 체크박스가 두 번 토글된다.

input 직접 클릭
→ 체크 상태 변경
→ 이벤트가 label로 버블링
→ label이 다시 input 클릭
→ 체크 상태가 원래대로 돌아감

Blink는 이를 막기 위해 이벤트 대상이 이미 연결된 컨트롤 내부인지 확인한다.

if (element->IsShadowIncludingInclusiveAncestorOf(*target_node))
  return;

이 조건을 만족하면 라벨은 추가 클릭을 전달하지 않는다.

라벨 내부의 다른 인터랙티브 요소를 클릭한 경우

라벨 내부에 링크나 버튼이 있는 경우도 별도로 처리한다.

<label for="terms">
  약관에 동의합니다
  <a href="/terms">약관 보기</a>
</label>

<input id="terms" type="checkbox">

사용자가 링크를 클릭했는데 체크박스까지 토글되면 안 된다.

Blink는 이벤트 경로에 별도의 interactive content가 포함되어 있는지 검사한다.

if (IsInInteractiveContent(evt))
  return;

해당 조건을 만족하면 컨트롤에 클릭을 전달하지 않는다.

텍스트를 드래그한 경우

라벨 문구를 복사하려고 드래그했을 때도 체크박스가 토글되면 안 된다.

그래서 Blink는 selection 상태와 클릭 횟수를 확인하고, 단일 클릭이 텍스트 드래그에 의해 발생한 것으로 판단되면 처리를 중단한다.

if (mouse_event->ClickCount()==1)return;

이 구현을 통해 다음 상황이 구분된다.

사용자 동작컨트롤 클릭컨트롤 포커스
라벨 일반 클릭전달전달
입력 요소 직접 클릭추가 전달 안 함입력 요소 기본 동작
라벨 내부 링크 클릭전달 안 함전달 안 함
라벨 텍스트 드래그전달 안 함전달 안 함
라벨 더블클릭전달포커스 안 함

개발자가 작성하는 코드는 <label> 한 줄이지만, 브라우저는 연결 대상 탐색뿐 아니라 이벤트 경로, 중복 클릭, 텍스트 선택, 포커스 정책까지 함께 처리한다.

HTML이 단순해 보이는 이유는 동작이 단순해서가 아니라, 이 복잡한 처리가 브라우저 내부에 감춰져 있기 때문이다.


HTML도 추상화된 도구다

지금까지 <label><input>이 연결되는 과정을 스펙과 Chromium 구현에서 따라가 봤다.

개발자가 작성하는 코드는 다음 두 줄뿐이다.

<label for="email">이메일</label>
<input id="email" type="email">

하지만 브라우저 내부에서는 for 속성을 읽고, 같은 TreeScope에서 ID가 일치하는 요소를 찾고, 해당 요소가 labelable element인지 확인한 뒤 연결 대상을 결정한다. 라벨이 클릭되면 다시 연결된 컨트롤을 찾고, 포커스와 simulated click을 전달하며, 입력 요소를 직접 클릭한 경우나 텍스트를 드래그한 경우 같은 예외도 함께 처리한다.

개발자는 이 로직을 직접 구현하지 않는다. <label>for라는 선언적인 인터페이스를 사용할 뿐이다.

이것이 HTML이 제공하는 추상화다.

추상화는 내부 복잡성을 없애는 것이 아니라, 사용자가 직접 다루지 않아도 되는 계층으로 옮긴다. <label>이라는 단순한 태그 뒤에는 DOM 탐색, 타입 판별, 이벤트 경로, 포커스 처리, 선택 영역 처리 같은 브라우저 로직이 숨어 있다.

평소에는 이 구현을 몰라도 <label>을 사용할 수 있다. 하지만 예상과 다르게 동작하거나 스펙의 경계를 확인해야 할 때는, 선언적인 HTML 아래에서 어떤 규칙과 코드가 실행되는지 살펴볼 필요가 있다.

HTML이 단순해 보이는 이유는 내부가 단순해서가 아니다.

브라우저가 그 복잡성을 감추고 있기 때문이다.