[우아한테크코스] - 로또 미션 step2 회고

jiny·2024년 2월 27일
1
post-thumbnail

Intro

첫 웹 애플리케이션 미션인 로또 미션이 생각보다 빠르게 마무리 되었다.

웹 애플리케이션을 어떤 구조로 만들지, 만드는 과정에서 어떤 어려움이 있었는지에 대해 이야기 해보려고 한다.

Try

Web Component

why web component?

이전(next-step)에는 바닐라 js로 웹 애플리케이션을 만들 때 아래와 같이 구현했었다.

  1. index.html에 모든 element를 모두 넣어넣는다.

  2. MVC 패턴으로 구현을 진행하며 View 에서는 DOM을 취득하여 조작하는 레이어를 분리한다.

이렇게 구현했을 때 문제점은 아래와 같았다.

  1. index.html에 있는 모든 template 들이 어떤 영역의 template인지 파악하기 어렵다.

  2. controller의 책임이 너무 많아진다. (view에 데이터 전달, domain - controller 간 비즈니스 로직 주고 받기 등)

그래서 작은 단위로 나눠 각각의 컴포넌트가 스스로 이벤트 핸들러와 render할 template 들을 관리하기 위한 방법 들을 고민했다.

그 결과 Web Component를 사용하게 되었다.

Web Component를 사용할 땐 아래와 같은 순서를 가지고 구현했다.

  1. 구현할 화면을 작은 단위로 분리한다.

  2. 분리한 컴포넌트들은 각각 담당하는 기능에 맞게 렌더링과 이벤트 바인딩을 진행한다.

  3. 즉, 각 컴포넌트에서 이벤트가 발생하면 컴포넌트 내부에 가지고 있는 핸들러를 통해 기능이 구현되는 형태다.

그래서 모든 컴포넌트들이 사용할 수 있는 BaseComponent 생성이 필연적이라고 생각했다.

// BaseComponent.js
class BaseComponent extends HTMLElement {
  connectedCallback() {
    this.render();
    this.setEvent();
  }

  disconnectedCallback() {
    this.removeEvent();
  }

  render() {}

  setEvent() {}

  removeEvent() {}

  getTemplate() {}

  emit(eventType, detail) {
    const customEvent = new CustomEvent(eventType, {
      bubbles: true,
      detail,
    });

    this.dispatchEvent(customEvent);
  }

  on({ target, eventName }, eventHandler) {
    target.addEventListener(eventName, eventHandler);
  }

  off({ target, eventName }, eventHandler) {
    target.removeEventListener(eventName, eventHandler);
  }
}

export default BaseComponent;

BaseComponent는 아래와 같이 구성되어있다.

  • connectedCallback : HTMLElement 존재하는 메서드를 오버라이딩한 것으로 DOM에 연결되었을 때 실행되는 생명주기 메서드이다. (주로 컴포넌트 렌더링이나 이벤트 핸들러 등록에 사용 된다.)
  • disconnectedCallback : HTMLElement 존재하는 메서드를 오버라이딩한 것으로 DOM에서 제거되었을 때 실행되는 생명주기 메서드다.
  • render : 리액트의 클래스 컴포넌트와 비슷하게, 컴포넌트가 가지고 있는 template string을 DOM에 commit하는 메서드다.
  • setEvent : 컴포넌트가 렌더링 될 때 컴포넌트의 이벤트 핸들러를 DOM에 등록하는 메서드다.
  • removeEvent : disconectedCallback이 호출 될 때 이벤트 핸들러를 DOM에서 제거하기 위한 메서드다.
  • getTemplate : 스스로 렌더링이 되는 것이 아닌 누군가에 의해 렌더링 되어야 할 경우 호출하기 위한 getter 함수다. (여기서는 PurchasedLotto에 사용되는 메서드다.)
  • emit : 데이터 바인딩을 custom event로 하고 있기 때문에, 이 custom event를 dispatch 하기 위한 메서드다.
  • on : 이벤트 핸들러를 등록하기 위한 추상화 메서드다.
  • off : 이벤트 핸들러를 제거하기 위한 추상화 메서드다.

즉, 현재 사용 중인 BaseComponent는 connectedCallback에서 각 컴포넌트의 렌더링과 이벤트 핸들러 설정을 담당하고 있다.

그렇다면 connectedCallback이 어떻게 동작하길래 render와 setEvent를 통해 웹 컴포넌트 내 이벤트 핸들러와 html 요소가 보여지게 되는 것일까?

Web Component의 동작 방식

Web Component 동작 방식을 이해하기 전 브라우저의 렌더링에 대해 짧게 살펴볼 필요가 있다.

  1. 브라우저의 렌더링 엔진은 HTML을 파싱하여 DOM tree로 변환한다.

    • 이 때, HTML parser가 link 태그를 만난다면 css 파싱을 시작한다.
    • 만약 script 태그를 만난다면 html 파싱을 멈추고 js 파싱을 시작한다.
  2. HTML, CSS, JS를 파싱하여 DOM과 CSSOM이 만들어졌다면 이를 통해 Render Tree를 생성한다.

  3. Render Tree를 토대로 레이아웃 트리를 구성하여 DOM 트리와 스타일 시트를 기반으로 어떤 요소를 어디에 배치할지 결정한다.

  4. Paint 과정을 통해 DOM이 브라우저에서 그려진다. (Paint 이후 reflow와 repaint 과정이 이루어지기도 한다.)

자 이제 index.html을 살펴보자.

<!DOCTYPE html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <title>🎱 행운의 로또</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Document</title>
  </head>

  <body>
    <lotto-app></lotto-app>
    <script type="module" src="./src/step2-index.js"></script>
  </body>
</html>

로또 미션에서 사용한 index.html을 그대로 가지고 왔다.

이 html 파일을 브라우저에 올라가게 되면 html parser는 html 코드를 하나씩 파싱하기 시작한다.

그러다 lotto-app을 만나게 되면 lotto-app에 존재하는 contectedCallback을 실행하게 된다.

// LottoApp.js
import BaseComponent from '../BaseComponent/BaseComponent.js';
import styles from './LottoApp.module.css';

class LottoApp extends BaseComponent {
  render() {
    this.innerHTML = `
        <header class=${styles.navBar}>
          <h1 class="title">🎱 행운의 로또</h1>
        </header>
        <main class=${styles.mainContainer}>
          <header class="${styles.mainHeader} title">🎱 내 번호 당첨 확인 🎱</header>
          <purchased-lotto-form></purchased-lotto-form>
          <purchased-lotto-section class="close"></purchased-lotto-section>
          <winning-detail-form class="close"></winning-detail-form>
        </main>
        <footer class=${styles.footer}>
          <p class="caption">Copyright 2023. woowacourse</p>
        </footer>
        <winning-statistics-modal class="close"></winning-statistics-modal>
    `;
  }
  
  setEvent() {
    this.on({ target: document, eventName: 'reset' }, () => this.render());
  }

}

customElements.define('lotto-app', LottoApp);

HTML 파싱 중 LottoApp을 parser가 만나 document의 DOM으로 만들어질 때 호출 되고, connectedCallback의 render 메서드와 setEvent 메서드가 실행되어 컴포넌트 내 template 들이 innerHTML을 통해 DOM에 추가 되고, 이벤트 리스너가 설정되는 형태다.

정리하면 아래와 같은 형태다.

  1. HTML parser는 HTML을 파싱하다 LottoApp을 마주친다.

  2. LottoApp의 인스턴스가 생성되어 DOM 트리에 추가 된다.

  3. 이 과정에서 DOM에 연결된 직후 connectedCallback이 실행된다.

  4. LottoApp의 render 메서드와 setEvent가 실행되어 template이 DOM에 추가되고, 이벤트 리스너가 설정된다.

나머지 컴포넌트도 LottoApp 처럼 parser가 파싱할 때 connectedCallback이 호출되어 렌더링 & 이벤트 바인딩 되는 형태다.

CSS modules vs Pure CSS vs Shadow DOM

이번 미션 내 스타일링 방식에 대해 CSS를 제대로 작성 해본적이 없어 3가지 방식을 찾아보고 의사 결정 속에서 CSS Modules 방식을 채택했다.

우선 Pure CSS 방식을 먼저 제외 시켰는데, 가장 큰 이유는 CSS 충돌의 이유였다.

/* purchasedLotto.css */
p {
  display: flex;
  align-items: center;
}

/* purchasedLottoForm.css */
p {
  display: flex;
  align-items: flex-end;
}

pure css를 사용할 때, 서로 다른 디렉터리의 css 파일 내에서 동일한 selector에 대한 스타일이 서로 다를 경우, 늦게 실행되는 쪽의 css로 덮어씌워진다.

지금 당장 겹치는 css가 없다고 해도, 유지 보수를 한다고 했을 때 애플리케이션의 복잡도가 커질 경우 pure css의 충돌 위험성은 리스크가 꽤 크다고 생각해서 선택지에서 제외하게 되었다.

그 다음으로 고려했던 방식은 Shadow DOM 이었다.

이번 미션에서 Web Component를 사용하고 있는 만큼 mdn에서 해당 기술의 핵심 기술로 Shadow DOM을 소개하고 있기 때문에 고려해보게 되었다.

Shadow DOM에 대한 설명은 [JavaScript] - Web Component (with 문벅스 애플리케이션)에 따로 기술해두었기 때문에 생략하려고 한다.

Shadow DOM을 제외 시켰던 이유는 ‘복잡성’ 이었다.

class MyCustomElement extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });

    this.shadowRoot.innerHTML = `
      <style>
        .text {
          color: green;
          font-size: 18px;
        }
      </style>
      <div class="text">Shadow DOM 내부의 텍스트</div>
    `;
  }
}

자바스크립트 내에서 외부에서 Shadow DOM 사용 여부를 알려주기 위해 attachShadow 메서드를 호출해야 한다.

또한, 일반 DOM과 shadowRoot를 구분해야 한다는 점도 사용하면서 불편하다고 느꼈다.

마지막으로 개발자 도구에서 css와 dom tree가 동시에 보여 진다는 점이 SRP을 위배한다고 느껴졌다.

style을 볼 수 있는 탭이 따로 있음에도 저렇게 style 태그와 dom element를 동시에 봐야하는게 번거롭다고 느껴졌다.

그래서 CSS Module 방식을 선택했다.

CSS Modules works by compiling individual CSS files into both CSS and data. The CSS output is normal, global CSS, which can be injected directly into the browser or concatenated together and written to a file for production use. The data is used to map the human-readable names you've used in the files to the globally-safe output CSS. - CSS Modules -

CSS Modules는 한 파일 내에서 CSS와 데이터를 컴파일 하는 방식으로 동작이 이루어진다.

CSS 모듈은 각각의 .module.css 파일에 정의된 클래스 이름을 고유한 값으로 변환하며 보통 웹팩(Webpack)과 같은 모듈 번들러와 css-loader와 같은 로더를 사용하여 수행된다.

****css-loader는 CSS 모듈을 처리할 때 고유한 식별자(ex - [filename][local]_[hash:base64:5])를 생성하여, 클래스 이름이 전역적으로 충돌하는 것을 방지한다.

또한, 위 사진 처럼 dom element와 style을 구별하여 확인할 수 있기 때문에 관심사 별로 쉽게 확인할 수 있다.

물론, “class 네이밍이 왜 저래”라고 생각할 수도 있다.

하지만 우측 상단에 있는 링크를 클릭하면 위 사진 처럼 어떤 클래스명의 css 인지 쉽게 확인할 수 있다.

정리하자면 나는 아래와 같은 이유로 CSS Modules를 채택해 사용했다.

  1. 다른 방법들 보다 직관적으로 개발자 도구 탭을 활용 가능 하다. (직관성)
  2. CSS 충돌이 발생하지 않는다. (안정성)

데이터 바인딩

웹 애플리케이션을 만들면서 많이 고민했던 부분 중 하나는 데이터 바인딩이었다.

3가지의 선택지를 고를 수 있었다.

  1. 부모 컴포넌트에 모든 이벤트 핸들러와 함수 들을 저장해놓고 자식 컴포넌트에 props로 전달하는 방식

  2. 전역 store를 만들어(createStore) 관리하는 방식

  3. custom event를 dispatch 시켜 전달 받는 방식

우선, 첫 번째의 경우 현재 구조가 props로 전달할 수 없는 구조였기 때문에 선택지에서 빠르게 제외시켰다.

두 번째의 경우 앱이 정말 작은 애플리케이션이었기 때문에 전역으로 담을 상태가 많지 않다고 느꼈다.

앱을 구현할 때 하나의 상태가 필요하다고 느꼈었다.

  1. 당첨 번호 입력 폼에서 당첨 통계를 계산한다.

  2. 이 때 계산하기 위해선 PurchasedLottoSection에서 사용했던 구매 로또 금액 및 구매 로또 번호들이 필요하다.

  3. 즉, WinningLottoDetailForm에서 상태가 필요하다고 느껴졌다.

하지만 이 하나의 상태를 정의하기 위해 pub-sub 구조의 전역 store를 만드는게 불필요한 리소스라고 생각이 들어 제외하게 되었다.

그래서 남은 선택지인 event dispatch 방식을 사용하게 되었다.

예시

// PurchasedLottoForm.js
#handleSubmitBuyLottoPrice(event) {
  try {
    // ...
    this.emit(CUSTOM_EVENT_TYPE.buyLottoPrice, Number(purchasedLottoPrice));
  } catch (error) {
    this.#handleError(error);
  }
}

그래서 최종적으로 emit이라는 event dispatch 메서드를 만들어 커스텀 이벤트를 발생시킬 때 데이터를 함께 넘겨주었다.

// PurchasedLottoSection.js
setEvent() {
  this.on(
    { target: document, eventName: CUSTOM_EVENT_TYPE.buyLottoPrice },
    this.#handleRenderPurchasedLottoSection.bind(this),
  );
}

#handleRenderPurchasedLottoSection(event) {
  this.#updateBuyLottoDetail(event);

  this.#lottosTemplate = this.#createLottosTemplate();

  this.classList.remove('close');

  this.connectedCallback();
}

그래서 PurchasedLottoSection은 등록했던 buyLottoPrice 이벤트가 dispatch 되어, 핸들러 함수인 handleRenderPurchasedLottoSection가 실행된다.

이를 통해 Form으로 부터 받은 데이터를 가지고 해당 컴포넌트에서 로또 번호들과 구입한 로또 갯수를 렌더링하게 되는 구조다.

learn

css variables

리뷰어 분에게 이런 피드백을 받게 되었다.

css 파일을 만들어 프로젝트를 한번도 해보지 않았기에(역시 모든 기술을 써보는 연습이 필요한 거 같다.) css를 만드는 부분이 부족하다고 생각했고, 정당했던 피드백이었다.

우선, css variables에 대한 정의를 찾아보았다.

사용자 지정 속성(CSS 변수, 종속 변수)은 CSS 저작자가 정의하는 개체로, 문서 전반적으로 재사용할 임의의 값을 담습니다. 사용자 지정 속성은 전용 표기법을 사용해 정의하고, (--main-color: black;) var() 함수를 사용해 접근할 수 있습니다. - mdn -

예시 코드(실제로 구현한 코드)를 살펴보자.

예시

:root {
  --gray-scale-1: #ffffff;
  --gray-scale-2: #fcfcfd;
  --gray-scale-3: #b4b4b4;
  --gray-scale-4: #8b8b8b;
  --gray-scale-5: #000000;
  --lotto-primary: #4e5ba6;

  --modal-z-index: 2;
}

:root라는 기호의 경우 스타일 시트 전역을 의미한다.

또한, --<variables>의 형태로 특정 css value에 대한 변수를 설정할 수 있다.

나의 경우 특정 컴포넌트의 z-index 값과 디자인 시스템의 color 들에 대해 전역 변수들을 설정해 두었다.

/* PurchasedLottoForm.module.css */
.purchasedButton {
  width: 5.6rem;
  height: 3.6rem;
  background-color: var(--lotto-primary);
  border-radius: 0.4rem;
  color: var(--gray-scale-1);
}

var 내 지정해둔 css variables를 추가하는 형태로 공통된 값을 공유하며 사용할 수 있었다.

이렇게 공통으로 사용하는 css 값들에 대해 변수로 네이밍 함으로써 나중에 변경이 발생할 때 빠르게 대응할 수 있다는 장점이 있겠다는 생각이 들었다.

HTMLFormElement.reset()

// WinningDetailForm.js
#initWinningDetailInputs() {
  $$(COMPONENT_SELECTOR.winningNumberInputs).forEach((winningNumberInputElement) => {
    winningNumberInputElement.value = '';
  });

  $(COMPONENT_SELECTOR.bonusNumberInput).value = '';
}

이전에는 input들에 대해 querySelectorAll로 접근하여 모든 값들을 빈 값으로 일일히 설정하도록 했었다.

그러다 리뷰어님께 다음과 같은 리뷰를 받게 되었다.

실제로 모든 Input들을 forEach로 순회할 때 O(n)의 시간을 필요로 하다는 점과, 코드의 복잡성이 더 늘어난다는 단점이 있었다.

#initWinningDetailInputs() {
  const form = $(COMPONENT_SELECTOR.winningDetailForm);

  form.reset();
}

HTMLFormElement의 reset 메서드를 사용하게 되면 form에 존재하는 모든 input 들의 값을 초기화 할 수 있었다.

이번 미션에서 modal을 숨길 때 아래의 3가지 스타일 속성을 고려해볼 수 있었다.

  1. opacity : 0
  2. display : none
  3. visiblity : hidden

나는 결론만 말하자면 미션 구현에선 visiblity 속성을 사용했지만 접근성 문제가 있다는 것을 알게 되었고 다른 방법 들을 찾아보게 되었다.

지금 부터 그 이유에 대해 살펴보자.

요소를 숨기는 방법

이번 미션에서 modal을 숨길 때 아래의 3가지 스타일 속성을 고려해볼 수 있었다.

  1. opacity : 0
  2. display : none
  3. visiblity : hidden

나는 결론만 말하자면 미션 구현에선 visiblity 속성을 사용했지만 접근성 문제가 있다는 것을 알게 되었고 다른 방법 들을 찾아보게 되었다.

지금 부터 그 이유에 대해 살펴보자.

opacity : 0

opacity의 경우 요소의 투명도를 낮추기만 하기 때문에, 이벤트 적용이 가능하다는 단점이 있다.

예시를 통해 살펴보자.

// ...

class WinningStatisticsModal extends BaseComponent {
  // ...

  setEvent() {
    this.on({ target: this, eventName: 'click' }, () => {
      alert('hi');
    });
  }

  // ...
}

customElements.define('winning-statistics-modal', WinningStatisticsModal);

modal을 클릭했을 때 click 이벤트를 걸어 alert를 띄우는 로직을 추가해보았다.

위와 같이 모달이 없는 상태에서도 이벤트가 적용되기 때문에 선택지에서 먼저 제외하게 되었다.

display : none

레이아웃에 영향을 주지 않도록 요소의 표시를 제거합니다.(요소가 없는 것처럼 문서가 렌더링됨). 모든 하위 요소도 표시를 제거합니다. - mdn -

즉, 해당 속성을 적용한 element는 개발자 도구 상에선 요소가 존재하지만 레이아웃이 변경되는 특징이 있다.

예시를 통해 살펴보자.

<!DOCTYPE html>
<html lang="ko">
  <style>
    body {
      height: 1000px;
      display: flex;
      align-items: center;
      justify-content: center;
    }

    .container {
      border: 1px solid black;
      display: flex;
      width: 1200px;
      height: 200px;
      justify-content: space-around;
      align-items: center;
    }

    .first {
      width: 300px;
      height: 100px;
      background-color: black;
    }

    .second {
      width: 300px;
      height: 100px;
      background-color: green;
    }

    .third {
      width: 300px;
      height: 100px;
      background-color: blue;
    }
  </style>
  <body>
    <div class="container">
      <div class="first box visible">1</div>
      <div class="second box close">2</div>
      <div class="third box visible">3</div>
    </div>
  </body>
</html>

다음과 같은 html이 있다고 가정해보자. (close와 visible 스타일은 일단 제외)

.close {
  display: none;
}

.visible {
  display: block;
}

만약 display로 요소를 숨길 경우, 개발자 도구 상에는 요소가 남아있지만, 실제로 찍어보면 마치 요소가 dom에서 제거된 것 처럼 레이아웃이 제거된다.

visiblity : hidden

visibility CSS 속성은 문서의 레이아웃을 변경하지 않고 요소를 보이거나 숨깁니다. - mdn -

즉, 해당 속성을 적용한 element는 개발자 도구 상에서도 남아 있으며, 레이아웃 또한 유지되는 특징이 있다.

.close {
  visibility: hidden;
}

.visible {
  visibility: visible;
}

visiblity 속성의 경우 레이아웃이 유지되기 때문에, 개발자 도구 상의 요소와 일치하는 것을 알 수 있다.

즉, 나는 visiblity를 통해 요소를 직관적으로 확인하는 것이 혼란을 방지할 수 있다 생각해 visiblity 속성을 사용하여 modal을 구현했다.

display와 visiblity의 문제

visiblity와 display 속성은 아래와 같은 단점이 있다는 사실을 알게 되었다.

visibility 값을 hidden으로 설정한 요소는 접근성 트리에서 제외됩니다. 즉 해당 요소와, 그 모든 자손 요소는 스크린 리더가 읽지 않습니다. - mdn -

display 값을 none으로 설정한 요소는 설정하면 접근성 트리에서 해당 요소가 제거됩니다. 즉 해당 요소와, 그 모든 자손 요소는 스크린 리더가 읽지 않습니다. - mdn -

그렇다면 접근성을 고려하기 위해선 어떤 속성을 사용해야 할까?

네이버 뉴스 페이지에서 해답을 찾아볼 수 있었다.

네이버는 blind라는 class를 통해 요소를 숨기는 것을 알 수 있었다.

.blind {
  overflow: hidden;
  position: absolute;
  clip: rect(0, 0, 0, 0);
  clip-path: polygon(0 0, 0 0, 0 0);
  width: 1px;
  height: 1px;
  margin: -1px;
}

blind를 살펴보면 다음과 같은 속성으로 이루어져 있는 것을 알 수 있다.

각 속성을 확인해보자.

  • position: absolute
    • 레이아웃에 영향을 주지 않도록 한다.
  • overflow: hidden
    • 요소의 크기를 벗어나는 콘텐츠를 숨긴다.
  • clip-path: polygon(0 0, 0 0, 0 0)
    • 요소가 화면에 보이는 영역을 0으로 지정한다.
  • width, height: 1px
    • 넓이와 높이 값을 최소(1px)로 하여, 스크린리더가 인식할 수 있도록 한다.
  • margin: -1px
    • 요소의 크기 만큼 역마진을 주어, 요소가 차지하는 공간을 제거한다.

이전 html에 해당 속성을 적용해보자.

.close {
  overflow: hidden;
  position: absolute;
  clip-path: polygon(0 0, 0 0, 0 0);
  width: 1px;
  height: 1px;
  margin: -1px;
}

마치 display : none 처럼 레이아웃은 유지되지 않지만 요소가 잘 숨겨지는 것을 알 수 있다.

Lotto 애플리케이션 또한 적용해볼 때 크게 문제가 없는 것을 알 수 있었다.

단, visiblity : hidden 처럼 레이아웃이 유지되기 위한 방법이 필요해진다면 그 때 더 고민해봐야 할 거 같다.

NodeList과 HTML Collection

const $inputs = document.getElemenetsByTagName('input')
const $otherInputs = document.querySelectorAll('input')

html에 존재하는 모든 input들을 가져오는 방법은 위와 같이 크게 2가지 방법이 있다.

하지만, getElemenetsByTagName와 querySelectorAll가 반환하는 자료구조는 서로 다르기 때문에 잘 알고 사용해야 한다.

공통점과 차이점에 대해 살펴보자.

공통점

  1. 여러 개의 요소 들을 반환하는 DOM Collection 객체

  2. 유사 배열 객체이면서 이터러블하다.

즉, 두 배열 모두 for-ofspread 문법 적용이 가능하다는 특징이 있다.

이제 차이점에 대해 살펴보자.

차이점

  1. NodeList는 non-live 하지만 HTMLCollection은 live 한 객체다.

  2. NodeList는 forEach로 순회가 가능 하지만 HTMLCollection은 불가능하다.

예시를 통해 살펴보자.

<!DOCTYPE html>
<head>
  <style>
    .red { color: red; }
    .blue { color: blue; }
  </style>
</head>
<html>
  <body>
    <ul id="fruits">
      <li class="red">Apple</li>
      <li class="red">Banana</li>
      <li class="red">Orange</li>
    </ul>
    <script>
      // class 값이 'red'인 요소 노드를 모두 탐색하여 HTMLCollection 객체에 담아 반환한다.
      const $elems = document.getElementsByClassName('red');
      // 이 시점에 HTMLCollection 객체에는 3개의 요소 노드가 담겨 있다.
      console.log($elems); // HTMLCollection(3) [li.red, li.red, li.red]

      // HTMLCollection 객체의 모든 요소의 class 값을 'blue'로 변경한다.
      for (let i = 0; i < $elems.length; i++) {
        $elems[i].className = 'blue';
      }

      // HTMLCollection 객체의 요소가 3개에서 1개로 변경되었다.
      console.log($elems); // HTMLCollection(1) [li.red]
    </script>
  </body>
</html>

getElementsByClassName을 통해 className이 red인 모든 요소에 대해 className 값을 blue로 변경하면, 모든 li 요소가 파란색으로 렌더링 될 것으로 보이지만 예상과 다르게 동작하는 것을 알 수 있다.

각 iteration을 자세히 살펴보자.

  1. 첫 번째 반복(i === 0)
    • $elems[0]은 첫 번째 li 요소로써, 예상 대로 동작하며, 이 때 첫 번째 li 요소는 class 값이 blue로 변경되었으므로 getElementsByClassName 메서드 인자로 전달한 red와 일치하지 않아 $elems에서 실시간으로 제거된다.
  2. 두 번째 반복(i === 1)
    • 첫 번째 li 요소는 $elems에서 제거되어 $elems[1]은 세 번째 li 요소기 때문에 blue로 변경되고 $elems에서 제거된다.
  3. 세 번째 반복(i === 2)
    • $elems에 남은 요소는 두 번째 li 요소 노드만 남아 for문 내부에서 $elems.length가 1로 평가된다. 하지만, i가 2이므로 i < $elems.length; 식이 false로 평가되어 반복이 종료 되기 때문에 2번째 요소 노드는 아무런 변화도 하지 않는다.

즉, 각 반복마다 HTMLCollection$elems가 영향을 받아 예상과 다르게 동작하게 되었던 것이다.

위 예시 처럼 HTMLCollection는 실시간으로 노드 객체의 상태 변경을 반영하여 요소를 제거하기 때문에 for 문을 통해 노드 객체 상태를 변경 시 주의 해야 한다.

NodeList도 live 하지만, document.querySelectorAll에서 반환하는 NodeList는 정적인 객체를 반환하므로 걱정할 필요는 없다.

const $inputs = document.getElemenetsByTagName('input')

$inputs.forEach($input => {
	console.log($input.value)
}) // 불가능

const $otherInputs = document.querySelectorAll('input')

$otherInputs.forEach($otherInput => {
	console.log($otherInput.value)
}) // 가능

마지막으로, NodeList는 인터페이스 자체에 forEach가 존재하여 사용이 가능하지만, HTMLCollection은 그렇지 않기 때문에 불가능하다.

2개의 댓글

comment-user-thumbnail
2024년 2월 27일

지니 좋은 내용 공유해주셔서 감사합니다! 🙂

1개의 답글