Alpine.js - HTML에 자바스크립트 양념 뿌리기

jQuery를 기억하시나요? <script> 태그 하나 떨어뜨리면 마법처럼 HTML이 살아 움직이던 그 시절 말이에요. $("button").click(...) 한 줄이면 클릭 이벤트가 붙었고 $(".menu").slideToggle() 한 방이면 메뉴가 스르륵 열렸죠. 빌드 도구도 없고 설정 파일도 없고 node_modules 블랙홀도 없었던 아름답고 단순한 시대였습니다.

그런데 어느 순간 웹 개발이 복잡해졌어요. React, Vue, Svelte 같은 메타 프레임워크가 등장하면서 컴포넌트 기반 개발이 대세가 됐는데요. 분명 강력하지만 서버에서 렌더링한 HTML에 드롭다운 하나 달려고 해도 프로젝트를 처음부터 셋업해야 하는 상황이 종종 생깁니다. “나는 그냥 이 버튼 누르면 메뉴 열리게만 하고 싶은데…” 하는 생각이 들 때가 있지 않나요?

바로 그런 순간을 위해 Alpine.js가 태어났습니다.

Alpine.js가 뭔가요?

Alpine.js는 “HTML에 자바스크립트를 양념처럼 뿌리는” 경량 프레임워크입니다. 공식 사이트에서도 스스로를 “jQuery for the modern web”이라고 소개하고 있어요. Laravel의 Livewire를 만든 Caleb Porzio가 개발했는데, 수년간 Vue + Laravel 조합으로 일하다가 “대부분의 페이지에는 SPA가 과하다”는 결론에 이르렀다고 해요.

Alpine.js의 핵심 철학은 HTML 우선(HTML-first)입니다. 별도의 자바스크립트 파일을 만들지 않고, HTML 속성으로 바로 동작을 선언하는 거예요. Tailwind CSS가 HTML 안에서 스타일을 정의하듯, Alpine.js는 HTML 안에서 동작을 정의합니다.

숫자로 보면 이 라이브러리의 성격이 더 분명해지는데요. gzip 압축 기준으로 약 7KB밖에 안 되고 API 전체가 15개 디렉티브, 6개 매직 프로퍼티, 2개 메서드로 구성돼요. jQuery(32KB)보다 훨씬 작고 React + ReactDOM(32KB)과는 비교조차 안 되는 크기죠.

시작하기

Alpine.js를 쓰는 가장 간단한 방법은 CDN <script> 태그 하나를 추가하는 겁니다.

<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js"></script>

이게 전부예요. 빌드 도구도 설정 파일도 필요 없습니다. jQuery 시절의 그 간편함이 고스란히 살아 있죠?

npm으로 설치할 수도 있습니다.

bun add alpinejs
npm install alpinejs
main.js
import Alpine from "alpinejs";

Alpine.start();

여기서 중요한 건 Alpine.start()를 호출하기 전에 스토어나 플러그인 같은 확장을 모두 등록해야 한다는 점입니다.

첫 번째 Alpine 컴포넌트

Alpine.js의 시작점은 x-data 디렉티브입니다. 이 속성이 붙은 HTML 요소는 하나의 독립적인 Alpine 컴포넌트가 됩니다.

<div x-data="{ count: 0 }">
  <button x-on:click="count++">+1</button>
  <span x-text="count"></span>
</div>

이 코드가 하는 일을 하나씩 뜯어볼게요.

x-data="{ count: 0 }"는 이 <div> 안에서 쓸 반응형 데이터를 선언합니다. x-on:click="count++"는 버튼을 클릭하면 count를 1 증가시키고요. x-text="count"count 값이 바뀔 때마다 자동으로 텍스트를 업데이트합니다.

별도의 자바스크립트 파일 없이 HTML 속성만으로 반응형 카운터가 완성됐어요. jQuery로 같은 걸 만들었다면 어땠을까요?

let count = 0;
$("#increment").click(function () {
  count++;
  $("#display").text(count);
});

jQuery 코드는 명령형(imperative)입니다. “이 버튼을 찾아서, 클릭 이벤트를 붙이고, 카운트를 올린 다음, 저 요소를 찾아서 텍스트를 바꿔라.” 반면 Alpine.js 코드는 선언형(declarative)이에요. “이 버튼을 클릭하면 count가 올라가고, 이 텍스트는 count를 보여준다.”

이 차이가 Alpine.js의 핵심입니다. jQuery처럼 <script> 태그 하나로 시작하면서도, Vue처럼 반응형(reactive)으로 작동하는 거예요.

핵심 디렉티브

Alpine.js의 디렉티브는 Vue.js를 써보신 분이라면 매우 익숙하게 느끼실 거예요. 실제로 Alpine.js의 반응성 엔진은 내부적으로 Vue의 reactive()effect()를 사용하고 있거든요.

바인딩과 이벤트: x-bind, x-on

x-bind는 HTML 속성을 데이터에 연결합니다. 축약형으로 :을 쓸 수 있어요. x-on은 이벤트 리스너를 붙입니다. 축약형으로 @을 쓸 수 있고요.

<div x-data="{ open: false }">
  <button @click="open = !open" :aria-expanded="open">메뉴</button>
  <nav x-show="open" @click.outside="open = false">
    <a href="/about">소개</a>
    <a href="/contact">연락처</a>
  </nav>
</div>

@click.outside라는 수식어(modifier)가 눈에 띄죠? Alpine.js는 .prevent, .stop, .window, .debounce, .throttle 같은 이벤트 수식어를 기본 제공합니다. 자바스크립트 이벤트 처리를 직접 하려면 코드가 꽤 길어지는데 Alpine.js는 수식어 하나로 끝나요.

조건부 렌더링: x-show, x-if

x-show는 CSS의 display 속성으로 요소를 숨기고 보여줍니다. DOM에서 아예 제거하려면 x-if를 사용하면 됩니다.

<div x-data="{ tab: 'a' }">
  <button @click="tab = 'a'">탭 A</button>
  <button @click="tab = 'b'">탭 B</button>

  <div x-show="tab === 'a'">탭 A의 내용</div>
  <template x-if="tab === 'b'">
    <div>탭 B의 내용 (DOM에서 완전히 제거/추가됨)</div>
  </template>
</div>

x-show는 단순히 display: none을 토글하기 때문에 빠르지만 x-if는 DOM 노드 자체를 추가/제거합니다. 자주 토글되는 요소에는 x-show가, 초기에 숨겨져 있다가 조건이 맞을 때만 나타나는 요소에는 x-if가 맞아요. 주의할 점은 x-if는 반드시 <template> 태그에 써야 한다는 겁니다.

양방향 바인딩: x-model

폼 요소와 데이터를 양방향으로 연결하려면 x-model을 씁니다.

<div x-data="{ query: '' }">
  <input x-model="query" placeholder="검색어를 입력하세요" />
  <p x-show="query.length > 0">
    "<span x-text="query"></span>"로 검색 중...
  </p>
</div>

input, textarea, select, checkbox, radio 등 모든 폼 요소를 지원하고 .debounce, .lazy, .number 같은 수식어도 쓸 수 있습니다.

반복 렌더링: x-for

리스트를 렌더링할 때는 x-for를 사용합니다. x-if와 마찬가지로 <template> 태그에 써야 해요.

<div x-data="{ fruits: ['사과', '바나나', '체리'] }">
  <template x-for="fruit in fruits">
    <div x-text="fruit"></div>
  </template>
</div>

트랜지션: x-transition

x-show와 함께 쓰면 요소가 나타나고 사라질 때 부드러운 애니메이션 효과를 줄 수 있습니다.

<div x-show="open" x-transition>서서히 나타나고 사라지는 내용</div>

기본값은 페이드 + 스케일 효과인데 수식어로 세밀하게 조절할 수 있어요.

<div
  x-show="open"
  x-transition.opacity
  x-transition:enter.duration.300ms
  x-transition:leave.duration.200ms
>
  페이드만 적용, 진입 300ms / 퇴장 200ms
</div>

jQuery의 .slideUp(), .fadeIn() 같은 애니메이션 메서드와 비교해보면 Alpine.js의 선언적 접근이 얼마나 직관적인지 느낄 수 있죠.

매직 프로퍼티

Alpine.js는 $로 시작하는 매직 프로퍼티를 통해 유용한 유틸리티를 제공합니다.

<div x-data="{ name: '' }">
  <input x-ref="nameInput" x-model="name" />
  <button @click="$refs.nameInput.focus()">입력란 포커스</button>
  <button @click="$dispatch('greeting', { name })">인사하기</button>
</div>

$refsx-ref로 이름 붙인 DOM 요소에 바로 접근할 수 있게 해주고, $dispatch는 커스텀 이벤트를 발생시킵니다. querySelector로 요소를 찾아서 조작하는 것보다 훨씬 간결하죠.

$el(현재 요소), $watch(데이터 변경 감지), $nextTick(DOM 업데이트 후 실행), $store(전역 스토어 접근) 같은 매직 프로퍼티도 있어요.

전역 상태 관리: Alpine.store

컴포넌트 간에 상태를 공유해야 할 때는 Alpine.store를 사용합니다.

<script>
  document.addEventListener("alpine:init", () => {
    Alpine.store("darkMode", {
      on: false,
      init() {
        this.on = window.matchMedia(
          "(prefers-color-scheme: dark)"
        ).matches;
      },
      toggle() {
        this.on = !this.on;
        document.documentElement.classList.toggle("dark", this.on);
      },
    });
  });
</script>

<button @click="$store.darkMode.toggle()">
  <span x-text="$store.darkMode.on ? '라이트 모드' : '다크 모드'"></span>
</button>

init() 메서드는 스토어가 등록될 때 자동으로 호출되니까 초기값 설정 로직을 넣기 좋습니다.

재사용 가능한 컴포넌트: Alpine.data

여러 곳에서 같은 패턴이 반복된다면 Alpine.data로 컴포넌트를 등록해두면 됩니다.

Alpine.data("dropdown", () => ({
  open: false,
  toggle() {
    this.open = !this.open;
  },
  close() {
    this.open = false;
  },
}));

등록한 컴포넌트는 x-data에 이름만 적으면 됩니다.

<div x-data="dropdown">
  <button @click="toggle()">메뉴</button>
  <ul x-show="open" @click.outside="close()">
    <li>항목 1</li>
    <li>항목 2</li>
  </ul>
</div>

이렇게 하면 드롭다운 로직을 한 곳에서 관리하면서 여러 페이지에서 재사용할 수 있어요.

실전 예제: 검색 필터

지금까지 배운 걸 조합해서 좀 더 실용적인 예제를 만들어 볼까요?

<div
  x-data="{
    query: '',
    items: ['Alpine.js', 'React', 'Vue.js', 'Svelte', 'Angular', 'jQuery'],
    get filtered() {
      if (!this.query) return this.items;
      const q = this.query.toLowerCase();
      return this.items.filter(item => item.toLowerCase().includes(q));
    }
  }"
>
  <input x-model="query" placeholder="프레임워크 검색..." />
  <ul>
    <template x-for="item in filtered" :key="item">
      <li x-text="item"></li>
    </template>
  </ul>
  <p x-show="filtered.length === 0">검색 결과가 없습니다.</p>
</div>

get filtered()는 getter로 정의한 계산된 속성(computed property)입니다. queryitems가 바뀔 때마다 자동으로 다시 계산돼요. React의 useMemo나 Vue의 computed와 비슷한 역할을 하지만, 자바스크립트의 기본 getter 문법을 그대로 사용한다는 점이 다릅니다.

jQuery에서 Alpine.js로

둘 다 <script> 태그 하나로 시작할 수 있다는 공통점이 있지만 접근 방식은 완전히 다릅니다.

jQuery로 아코디언 메뉴를 만든다고 생각해보세요.

$(".accordion-header").click(function () {
  const $content = $(this).next(".accordion-content");
  $(".accordion-content").not($content).slideUp();
  $content.slideToggle();
  $(".accordion-header").not(this).removeClass("active");
  $(this).toggleClass("active");
});

“헤더를 찾아서 클릭 이벤트를 붙이고 다음 요소를 찾아서 토글하고 다른 건 접고 클래스를 바꿔라.” 동작 하나를 위해 여러 요소를 찾아다니며 명령을 내려야 해요.

Alpine.js로는 같은 UI를 이렇게 만들 수 있습니다.

<div x-data="{ active: null }">
  <div>
    <button @click="active = active === 1 ? null : 1">섹션 1</button>
    <div x-show="active === 1" x-transition.opacity>
      섹션 1의 내용입니다.
    </div>
  </div>
  <div>
    <button @click="active = active === 2 ? null : 2">섹션 2</button>
    <div x-show="active === 2" x-transition.opacity>
      섹션 2의 내용입니다.
    </div>
  </div>
</div>

데이터(active)가 UI의 상태를 대변하고 나머지는 Alpine.js가 알아서 처리합니다. 요소를 찾을 필요도 없고 클래스를 수동으로 토글할 필요도 없어요.

이게 명령형과 선언형의 차이예요. jQuery는 “어떻게(how) 하라”를 기술하고, Alpine.js는 “무엇(what)이 보여야 하는가”를 기술합니다.

다른 도구들과의 궁합

Alpine.js는 혼자 쓰기보다 다른 도구들과 함께 쓸 때 더 빛나는데요.

Tailwind CSS와는 특히 찰떡궁합입니다. Tailwind가 “HTML 안에서 스타일을 선언”하는 것처럼, Alpine.js는 “HTML 안에서 동작을 선언”하거든요. 실제로 Caleb Porzio는 Alpine.js를 “Tailwind for JavaScript”라고 표현하기도 했습니다.

HTMX와도 역할이 자연스럽게 나뉘어요. HTMX가 서버와의 통신이나 부분 페이지 갱신 같은 서버 측 인터랙션을 맡고, Alpine.js가 드롭다운, 모달, 탭 전환 같은 클라이언트 측 인터랙션을 맡는 구조입니다.

서버 렌더링 프레임워크와도 잘 어울려요. Laravel/Blade, Django, Rails, Astro 등 어떤 백엔드에서 HTML을 렌더링하든 Alpine.js를 얹어서 인터랙티브하게 만들 수 있습니다. Astro에는 아예 공식 Alpine.js 통합이 있을 정도입니다.

언제 Alpine.js를 써야 할까?

Alpine.js가 딱 맞는 상황이 있고 다른 선택이 나은 상황이 있습니다.

서버에서 렌더링한 HTML에 드롭다운이나 모달, 탭 같은 간단한 인터랙션을 추가할 때 Alpine.js가 딱이에요. 정적 사이트나 마케팅 페이지에 몇 가지 위젯을 넣을 때도 안성맞춤이고요. WordPress나 Shopify 같은 CMS 플랫폼을 확장하거나 jQuery로 작성된 레거시 코드를 현대화할 때도 좋습니다.

반면에 복잡한 SPA(Single Page Application)를 만들거나 라우팅과 코드 스플리팅이 필요하거나 대규모 상태 관리가 요구되는 프로젝트라면 React, Vue, Svelte 같은 프레임워크가 더 맞습니다. Alpine.js 자체도 이런 용도를 위해 만들어진 게 아니니까요.

정리하면 Alpine.js는 “프레임워크가 필요한 것 같지는 않은데 바닐라 자바스크립트로 다 짜기엔 반복이 많고 귀찮은” 그 사이 지점에 딱 맞는 도구예요.

마치며

jQuery가 2006년에 등장했을 때 웹 개발자들은 환호했습니다. 복잡한 DOM API를 간결하게 다룰 수 있게 해줬으니까요. Alpine.js는 20년이 지난 지금, 같은 문제를 현대적인 방식으로 풀고 있어요. <script> 태그 하나로 시작하는 간편함은 그대로 가져가면서, 반응형 데이터 바인딩이라는 강력한 무기를 장착한 거죠.

jQuery 없이 바닐라 자바스크립트만으로 HTML을 조작하는 것도 물론 가능합니다. 하지만 상태 관리와 DOM 업데이트를 수동으로 하다 보면 코드가 금방 복잡해지죠. 웹 컴포넌트가 프레임워크 없는 컴포넌트화를 지향한다면 Alpine.js는 프레임워크 없는 반응성을 지향합니다.

Alpine.js에 대해 더 깊이 알고 싶다면 공식 문서GitHub 저장소를 방문해보세요.

This work is licensed under CC BY 4.0 CC BY

Discord