junyeokk
Blog
vanilla-js-patterns·2024. 09. 01

Vanilla JS 컴포넌트 패턴

프레임워크 없이 JavaScript만으로 웹 애플리케이션을 만들다 보면, 코드가 금방 스파게티가 된다. main.js 하나에 DOM 생성, 이벤트 바인딩, 상태 관리, API 호출이 전부 뒤섞이고, 기능 하나 고치려면 파일 전체를 뒤져야 하는 상황이 벌어진다.

React나 Vue 같은 프레임워크가 해결하는 핵심 문제가 바로 이것이다. UI를 독립적인 컴포넌트로 분리하고, 상태가 변하면 화면이 자동으로 갱신되는 구조를 제공한다. 그런데 이 구조를 반드시 프레임워크가 있어야만 만들 수 있는 건 아니다. Vanilla JS로도 클래스 기반 View/Store 패턴을 적용하면 충분히 체계적인 컴포넌트 구조를 만들 수 있다.


문제 상황: 모든 게 한 파일에

전형적인 Vanilla JS 앱의 초기 상태를 보자.

javascript
// main.js — 모든 로직이 한 곳에
const container = document.querySelector('.card-list');

async function fetchCards() {
  const response = await fetch('/api/cards');
  const data = await response.json();
  renderCards(data.cards);
}

function renderCards(cards) {
  container.innerHTML = '';
  cards.forEach(card => {
    const el = document.createElement('div');
    el.className = 'card';
    el.innerHTML = `
      <input value="${card.title}" />
      <textarea>${card.description}</textarea>
      <button class="delete">삭제</button>
    `;
    el.querySelector('.delete').addEventListener('click', async () => {
      await fetch(`/api/cards/${card.id}`, { method: 'DELETE' });
      fetchCards(); // 전체 다시 로드
    });
    container.appendChild(el);
  });
}

fetchCards();

기능이 적을 때는 이래도 괜찮다. 하지만 카드 수정, 모달, 히스토리, 로그인 같은 기능이 추가되면 이 파일은 수백 줄로 불어나고, 어떤 함수가 어떤 DOM을 건드리는지 추적하기 어려워진다.

문제를 정리하면 이렇다:

  • 관심사 분리 부재: 데이터 로직과 UI 로직이 뒤섞여 있다
  • 상태 추적 불가: 현재 카드 목록이 어디에 저장되어 있는지 명확하지 않다
  • 재사용 불가능: 같은 카드 UI를 다른 곳에서 쓰려면 코드를 복사해야 한다
  • 동기화 문제: 여러 곳에서 같은 데이터를 보여줄 때, 한쪽을 업데이트해도 다른 쪽은 그대로다

해결 방향: View/Store 분리

이 문제를 해결하는 가장 기본적인 패턴은 화면(View)과 데이터(Store)를 분리하는 것이다. MVC, MVP, MVVM 등 다양한 이름으로 불리지만, Vanilla JS에서 실용적으로 적용하는 핵심은 간단하다:

  1. Store: 데이터를 보관하고, API와 통신하고, 데이터가 바뀌면 알린다
  2. View: Store의 데이터를 받아서 DOM을 그리고, 사용자 이벤트를 처리한다

이 두 역할을 명확히 나누기만 해도 코드의 구조가 크게 개선된다.

사용자 액션 → View → Store(데이터 변경) → notify → View(화면 갱신)

Store: 데이터와 비즈니스 로직

Store는 애플리케이션의 상태를 관리하는 단일 진실 공급원(Single Source of Truth)이다. 데이터를 보관하고, CRUD 연산을 수행하고, 데이터가 변경되면 등록된 구독자들에게 알린다.

javascript
사용자 액션 → View → Store(데이터 변경) → notify → View(화면 갱신)

Store 설계에서 주목할 점이 몇 가지 있다.

Private 필드로 캡슐화

#items#observers# 접두사를 사용해서 클래스 외부에서 직접 접근할 수 없게 했다. ES2022에서 표준화된 private class fields 문법이다. 이전에는 클로저나 WeakMap으로 비슷한 효과를 냈지만, # 문법이 훨씬 직관적이다.

왜 중요한가? Store의 데이터를 외부에서 직접 수정하면 notify()가 호출되지 않아서 화면이 갱신되지 않는다. 반드시 Store의 메서드를 통해서만 데이터를 변경하도록 강제해야 한다.

javascript
class ItemStore {
  #items = [];
  #observers = new Set();

  subscribe(observer) {
    this.#observers.add(observer);
    // 구독 해제 함수를 반환
    return () => this.#observers.delete(observer);
  }

  notify() {
    const data = this.getItems();
    this.#observers.forEach(observer => observer(data));
  }

  getItems() {
    return [...this.#items]; // 방어적 복사
  }

  async fetch() {
    const response = await fetch('/api/items');
    const data = await response.json();
    this.#items = data.items;
    this.notify();
  }

  async add(item) {
    const response = await fetch('/api/items', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(item),
    });
    const result = await response.json();
    this.#items.push({ ...item, id: result.id });
    this.notify();
  }

  async remove(itemId) {
    await fetch(`/api/items/${itemId}`, { method: 'DELETE' });
    this.#items = this.#items.filter(item => item.id !== itemId);
    this.notify();
  }
}

옵저버 패턴으로 느슨한 결합

Store는 자신의 데이터를 누가 사용하는지 모른다. subscribe()로 등록된 콜백만 호출할 뿐이다. 이것이 옵저버 패턴의 핵심이다. Store와 View 사이에 직접적인 의존성이 없기 때문에:

  • 하나의 Store에 여러 View를 연결할 수 있다
  • View를 교체해도 Store 코드를 수정할 필요가 없다
  • 테스트할 때 View 없이 Store만 독립적으로 테스트할 수 있다

구독 해제 함수 반환

subscribe()가 구독 해제 함수를 반환하는 패턴은 React의 useEffect cleanup과 동일한 개념이다. View가 제거될 때 구독을 해제하지 않으면 메모리 누수가 발생한다.

javascript
const store = new ItemStore();
// store.#items = []; // SyntaxError! 외부에서 접근 불가
store.fetch(); // OK — 메서드를 통한 변경

방어적 복사

getItems()에서 [...this.#items]로 배열을 복사해서 반환한다. 원본 배열의 참조를 그대로 넘기면 외부에서 push()splice()로 직접 수정할 수 있기 때문이다. 물론 배열 안의 객체까지는 깊은 복사를 하지 않으므로 완벽하지는 않지만, 가장 흔한 실수(배열 자체의 변형)는 막을 수 있다.


View: 화면과 사용자 인터랙션

View는 Store의 데이터를 받아서 DOM을 생성하고, 사용자의 입력을 Store에 전달하는 역할을 한다.

javascript
const unsubscribe = store.subscribe(data => {
  console.log('데이터 변경됨:', data);
});

// 나중에 구독 해제
unsubscribe();

init()과 생성자 분리

생성자에서는 의존성만 주입받고, 실제 초기화(subscribe, fetch)는 init()에서 수행한다. 이렇게 분리하는 이유는:

  1. 생성 시점과 활성화 시점이 다를 수 있다: View 객체를 미리 만들어두고, 필요할 때 init()을 호출할 수 있다
  2. 부수 효과 분리: 생성자에서 네트워크 요청이나 DOM 조작을 하면 테스트하기 어렵다
  3. 의존성 주입: 생성자에서 Store를 받기 때문에 테스트 시 Mock Store를 쉽게 주입할 수 있다

render()의 동작 방식

이 예제에서는 render()가 호출될 때마다 innerHTML = ''로 컨테이너를 비우고 전체를 다시 그린다. 이것은 React의 Virtual DOM과 근본적으로 다르다.

javascript
class ItemListView {
  #container;
  #store;
  #unsubscribe;

  constructor(container, store) {
    this.#container = container;
    this.#store = store;
    this.#unsubscribe = null;
  }

  init() {
    // Store 구독 — 데이터가 바뀌면 render가 자동 호출됨
    this.#unsubscribe = this.#store.subscribe(this.render.bind(this));
    // 초기 데이터 로드
    this.#store.fetch();
  }

  render(items) {
    this.#container.innerHTML = '';

    items.forEach(item => {
      const element = this.#createItemElement(item);
      this.#container.appendChild(element);
    });
  }

  #createItemElement(item) {
    const wrapper = document.createElement('div');
    wrapper.className = 'item';

    const title = document.createElement('h3');
    title.textContent = item.title;

    const description = document.createElement('p');
    description.textContent = item.description;

    const deleteButton = document.createElement('button');
    deleteButton.textContent = '삭제';
    deleteButton.addEventListener('click', () => {
      this.#store.remove(item.id);
    });

    wrapper.append(title, description, deleteButton);
    return wrapper;
  }

  destroy() {
    if (this.#unsubscribe) {
      this.#unsubscribe();
    }
    this.#container.innerHTML = '';
  }
}

이 방식의 장단점:

장점단점
구현이 단순하다모든 DOM을 매번 새로 생성한다
상태 불일치가 없다입력 중인 포커스가 사라진다
이벤트 리스너 누수 없음애니메이션이 끊긴다

아이템이 수십 개 이하이고, 사용자 입력 중에 render()가 호출되지 않는 구조라면 이 방식으로 충분하다. 하지만 대규모 리스트나 실시간 업데이트가 필요하면 부분 업데이트 전략이 필요한데, 이것이 바로 React가 Virtual DOM + Diffing으로 해결하는 문제이기도 하다.

this 바인딩 문제

this.#store.subscribe(this.render.bind(this)) 에서 .bind(this)가 빠지면 render() 안의 thisItemListView 인스턴스를 가리키지 않아서 에러가 발생한다. JavaScript에서 메서드를 콜백으로 전달하면 this 컨텍스트가 유실되기 때문이다.

javascript
render(items) {
  // 방법 1: 전체 교체 (단순하지만 비효율적)
  this.#container.innerHTML = '';
  items.forEach(item => {
    this.#container.appendChild(this.#createItemElement(item));
  });
}

이것은 Vanilla JS 컴포넌트 패턴에서 가장 흔한 버그 원인 중 하나다. 클래스 메서드를 이벤트 핸들러나 콜백으로 넘길 때는 반드시 바인딩을 확인해야 한다.


조립하기: 진입점 설정

Store와 View를 만들었으면 진입점에서 조립한다.

javascript
// ❌ this가 유실됨
store.subscribe(this.render);

// ✅ bind로 this 고정
store.subscribe(this.render.bind(this));

// ✅ 화살표 함수로 감싸기
store.subscribe(data => this.render(data));

Store를 먼저 생성하고, View에 주입하는 순서가 핵심이다. 여러 View가 같은 Store를 공유할 수 있기 때문에 Store는 하나만 만들고 여러 View에 전달하면 된다.

javascript
// main.js
import ItemStore from './store/item.store.js';
import ItemListView from './components/item-list.view.js';

const store = new ItemStore();

const container = document.querySelector('.item-list');
const listView = new ItemListView(container, store);
listView.init();

파일 구조

실제 프로젝트에서는 이런 구조로 파일을 나눈다.

src/ ├── components/ │ ├── Card/ │ │ ├── card.view.js # View 클래스 │ │ ├── card.store.js # Store 클래스 │ │ ├── template.js # DOM 템플릿 생성 함수 │ │ ├── actions.js # 이벤트 핸들러 로직 │ │ └── style.css # 컴포넌트별 스타일 │ ├── Modal/ │ │ ├── modal.view.js │ │ └── actions.js │ └── History/ │ └── history.view.js ├── store/ │ └── app.store.js # 글로벌 Store (필요 시) ├── utils/ │ └── domUtils.js # DOM 유틸리티 ├── hooks/ │ └── useState.js # 커스텀 상태 관리 └── main.js # 진입점, 조립

각 컴포넌트 폴더 안에 View, Store, 템플릿, 액션을 함께 둔다. 기능과 관련된 모든 파일이 한 곳에 모여 있으므로 수정할 때 여러 폴더를 왔다갔다할 필요가 없다. 이것은 React의 컴포넌트별 폴더 구조와도 일맥상통한다.


템플릿 분리

View의 render() 안에서 document.createElement()를 반복하면 코드가 장황해진다. DOM 구조를 생성하는 부분을 별도 함수로 분리하면 가독성이 크게 좋아진다.

javascript
// 하나의 Store, 여러 View
const store = new ItemStore();

const mainList = new ItemListView(
  document.querySelector('.main-list'), store
);
const sidebar = new SidebarView(
  document.querySelector('.sidebar'), store
);

mainList.init();
sidebar.init();

// store.add()를 호출하면 mainList와 sidebar 모두 자동 갱신

여기서 createElement()는 반복되는 DOM 생성 코드를 추상화한 유틸리티 함수다. 이렇게 분리하면 View 클래스는 "언제 그리는지"에만 집중하고, 템플릿 함수는 "어떻게 생겼는지"에만 집중할 수 있다.


이벤트 처리: 액션 분리

사용자 인터랙션이 복잡해지면 이벤트 핸들러 로직도 별도 파일로 분리하는 게 좋다.

javascript
src/
├── components/
│   ├── Card/
│   │   ├── card.view.js      # View 클래스
│   │   ├── card.store.js     # Store 클래스
│   │   ├── template.js       # DOM 템플릿 생성 함수
│   │   ├── actions.js        # 이벤트 핸들러 로직
│   │   └── style.css         # 컴포넌트별 스타일
│   ├── Modal/
│   │   ├── modal.view.js
│   │   └── actions.js
│   └── History/
│       └── history.view.js
├── store/
│   └── app.store.js          # 글로벌 Store (필요 시)
├── utils/
│   └── domUtils.js           # DOM 유틸리티
├── hooks/
│   └── useState.js           # 커스텀 상태 관리
└── main.js                   # 진입점, 조립

액션을 분리하면 동일한 이벤트 로직을 다른 View에서도 재사용할 수 있고, 이벤트 핸들러만 독립적으로 테스트하기도 쉬워진다.


상태 모드: 등록/편집 전환

하나의 컴포넌트가 "표시 모드"와 "편집 모드"를 오가는 패턴도 흔하다. React에서는 useState로 모드를 관리하지만, Vanilla JS에서는 CSS 클래스와 DOM 조작으로 처리한다.

javascript
// template.js
import { createElement } from '../../utils/domUtils.js';

export function createItemTemplate(data = null) {
  const wrapper = createElement('div', 'item');
  const content = createElement('div', 'item-content');

  const input = createElement('input', 'item-title', null, {
    type: 'text',
    placeholder: '제목을 입력하세요',
    maxLength: 50,
    value: data ? data.title : '',
  });

  const textarea = createElement('textarea', 'item-description', null, {
    placeholder: '내용을 입력하세요',
  });
  if (data) textarea.textContent = data.description;

  content.append(input, textarea);
  wrapper.appendChild(content);
  return wrapper;
}

여기서 originalTitle()originalDescription()이 함수 호출인 점에 주목하자. 이 값들은 클로저 기반 useState 훅으로 관리되는데, getter를 함수로 만들어야 항상 최신 값을 가져올 수 있다. 단순 변수로 저장하면 값이 복사되어 이후 업데이트가 반영되지 않는다.

javascript
// actions.js
export function setupItemActions(params) {
  const { element, input, textarea, store, itemId } = params;

  const saveButton = element.querySelector('.save-button');
  const deleteButton = element.querySelector('.delete-button');
  const editButton = element.querySelector('.edit-button');

  saveButton.addEventListener('click', async () => {
    const title = input.value.trim();
    const description = textarea.value.trim();

    if (!title || !description) return;

    if (itemId) {
      await store.update({ id: itemId, title, description });
    } else {
      await store.add({ title, description });
    }
  });

  deleteButton.addEventListener('click', async () => {
    if (confirm('정말 삭제하시겠습니까?')) {
      await store.remove(itemId);
    }
  });

  editButton.addEventListener('click', () => {
    input.disabled = false;
    textarea.disabled = false;
    input.focus();
  });
}

React 컴포넌트와의 비교

이 패턴이 React와 어떻게 대응되는지 비교하면 이해가 빨라진다.

개념Vanilla JS 패턴React
화면 렌더링View 클래스의 render()함수 컴포넌트의 return JSX
상태 관리Store 클래스 (private 필드)useState, useReducer
상태 변경 감지옵저버 패턴 (subscribe/notify)React의 내부 reconciler
부수 효과init(), destroy()useEffect
이벤트 처리addEventListener 직접 등록JSX의 onClick
DOM 생성createElement, template 함수JSX + Virtual DOM
컴포넌트 간 통신같은 Store 공유props, Context, 상태 라이브러리

가장 큰 차이는 DOM 업데이트 전략이다. React는 Virtual DOM으로 변경 부분만 찾아서 업데이트하지만, 이 패턴에서는 전체를 다시 그린다. 그래서 프레임워크 없이 만든 앱은 규모가 커지면 성능 최적화가 어려워지고, 이것이 React 같은 프레임워크가 존재하는 이유이기도 하다.

또 하나의 차이는 선언적 vs 명령적이다. React에서는 "이 상태일 때 UI가 이렇게 보여야 한다"고 선언하면 프레임워크가 알아서 DOM을 맞춰주지만, Vanilla JS에서는 "이 DOM을 이렇게 변경해라"고 직접 명령해야 한다.


장점과 한계

장점

  • 프레임워크 의존성 제로: 번들 크기가 작고, 프레임워크 업데이트에 영향받지 않는다
  • JavaScript 깊은 이해: 클로저, this 바인딩, 이벤트 위임 등 언어 자체를 깊이 이해하게 된다
  • 관심사 분리: 스파게티 코드에서 벗어나 유지보수 가능한 구조를 만들 수 있다
  • 유연성: 프레임워크의 규칙에 얽매이지 않고 프로젝트에 맞는 구조를 자유롭게 설계할 수 있다

한계

  • 규모 확장의 어려움: 컴포넌트가 수십 개를 넘어가면 수동 관리가 비효율적이다
  • DOM 업데이트 비효율: 전체 교체 방식은 대규모 UI에서 성능 문제를 일으킨다
  • 보일러플레이트: Store/View를 매번 만들어야 하므로 반복 코드가 생긴다
  • 생태계 부재: 라우팅, 폼 관리, 서버 상태 관리 등을 모두 직접 구현해야 한다

정리

Vanilla JS 컴포넌트 패턴의 핵심은 Store와 View를 분리하고, 옵저버 패턴으로 연결하는 것이다. Store는 데이터를 관리하고 변경을 알리며, View는 데이터를 받아서 화면을 그린다. 이 단순한 원칙만 지켜도 유지보수 가능한 구조를 만들 수 있다.

이 패턴을 직접 구현해보면 React나 Vue가 내부적으로 어떤 문제를 해결하고 있는지 체감할 수 있다. 상태 관리, 컴포넌트 생명주기, DOM 업데이트 최적화 — 프레임워크가 제공하는 모든 기능이 결국 이런 원시적인 패턴에서 출발했다는 걸 이해하게 된다.