들어가며 — 관리자 페이지에서 jQuery와 React 사이에 끼었다

관리자 페이지를 개발하면서 흔히 마주치는 고민이 있다. jQuery로 DOM을 조작하다 보면 양방향 데이터 바인딩의 부재 때문에 코드가 여기저기 흩어지고 유지보수가 어려워진다. 그렇다고 React나 Vue.js를 도입하자니 간단한 관리자 페이지에는 번들 크기, 빌드 도구, 학습 곡선이 부담스럽다. 학습 곡선은 새 도구를 쓸 수 있게 되기까지 배워야 하는 양을 말한다. 배울 것이 많을수록 「학습 곡선이 가파르다」고 한다.

React와 Vue.js는 이 글 끝까지 비교 대상으로 계속 나온다. 둘 다 화면을 조각으로 나눠 만들고, 데이터가 바뀌면 화면을 자동으로 다시 그려 주는 도구다. 강력하지만 쓰려면 빌드 과정이 따라온다.

바로 이 지점에서 Alpine.js가 빛을 발한다.

“Think of it like jQuery for the modern web” - Alpine.js 공식 문서

출처: Alpine.js 공식 문서


Alpine.js는 HTML 속성만으로 반응형 화면을 만드는 경량 라이브러리다

Alpine.js는 경량 JavaScript 라이브러리로, React나 Vue의 반응형(Reactive) 특성과 선언적(Declarative) 렌더링을 제공하면서도 훨씬 낮은 비용으로 쓸 수 있다. 렌더링은 데이터를 받아 화면 모양(HTML)으로 만들어 내는 일이다. 이 글에는 서버 렌더링, 조건부 렌더링, 리스트 렌더링이 계속 나오는데 뜻은 용어 페이지에 모아 두었다.

핵심 특징

  • 15개의 디렉티브, 6개의 속성, 2개의 메서드로 대부분의 기능을 만든다. 디렉티브는 x-data, x-show처럼 HTML 태그에 붙이는 x-로 시작하는 속성이다. 여기서 말하는 「6개의 속성」은 HTML 태그의 속성(type="text" 같은 것)과 다른 것으로, $store처럼 $로 시작하는 Alpine.js 전용 값(매직 속성)이다. 메서드는 Alpine.data(), Alpine.store()처럼 페이지 어디서든 부를 수 있는(전역) 함수다. 이 글에서는 속성 중 $store 하나, 메서드 중 Alpine.data()와 Alpine.store()를 쓴다. 정확히 어느 여섯 개 · 두 개를 세었는지는 확인하지 않았다
  • CDN으로 바로 쓸 수 있다 (빌드 도구가 필요 없다)
  • HTML에 직접 적는 선언적 방식이다
  • 약 15KB (gzip 압축 후) 크기로 가볍다
  • 반응형 데이터 바인딩을 지원한다

출처: Alpine.js GitHub

graph LR A[jQuery] -->|"한계: 양방향 바인딩 부재"| B[고민] C[React/Vue] -->|"과도함: 번들 크기, 학습 곡선"| B B -->|"최적의 균형"| D[Alpine.js] style A fill:#2c3e50,stroke:#34495e,stroke-width:2px,color:#ecf0f1 style C fill:#2c3e50,stroke:#34495e,stroke-width:2px,color:#ecf0f1 style B fill:#34495e,stroke:#2c3e50,stroke-width:2px,color:#ecf0f1 style D fill:#16a085,stroke:#1abc9c,stroke-width:3px,color:#ecf0f1

페이지에 넣는 「CDN 한 줄」은 이것이다

빌드 도구 없이 시작한다는 말은, HTML의 <head> 안에 아래 한 줄을 넣으면 끝이라는 뜻이다. <head>는 화면에 보이지 않는 페이지 설정(불러올 스크립트, 제목 등)을 적는 HTML 앞부분이고, 화면에 보이는 내용은 뒤의 <body>에 적는다. 아래는 Alpine.js 공식 문서의 설치 안내에 있는 줄이다.

<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
  • <script src="...">: 이 주소에서 자바스크립트 파일을 받아 실행하라는 태그다. 주소가 CDN이라 내 서버에 파일을 올리지 않아도 된다. 주소 속 npm/alpinejs는 자바스크립트 라이브러리 저장소(npm)에 올라간 alpinejs 패키지라는 뜻이고, @3.x.x는 버전 자리다. 그대로 적으면 3번대의 최신 버전을 받아 온다. 공식 문서는 운영 환경에서는 x 자리까지 숫자로 채운 정확한 버전 번호로 고정하라고 권한다(지금 최신 번호가 무엇인지는 확인하지 않았다)
  • defer: HTML을 끝까지 다 읽은 뒤에 이 스크립트를 실행하라는 뜻이다. Alpine.js는 HTML 안의 x- 속성을 찾아다니며 일하므로, HTML이 다 읽힌 뒤에 시작해야 한다

출처: Alpine.js 설치 문서


Alpine.js는 jQuery의 수동 동기화와 React/Vue의 빌드 부담 사이에서 나왔다

jQuery 시대의 문제점

jQuery는 DOM 조작에는 탁월했지만, 지금의 웹 개발에서는 한계가 뚜렷하다.

  • 양방향 데이터 바인딩 부재 → 변수가 바뀌면 화면의 어느 태그를 고칠지 사람이 직접 찾아 고쳐야 한다
  • 복잡한 UI 상태 관리 → 같은 값을 다루는 코드가 여러 곳에 흩어진다
  • 이벤트 핸들러 지옥 → 「버튼을 누르면 이 함수를 실행해라」 같은 함수(이벤트 리스너)를 요소마다 따로 걸다 보면, 어떤 클릭이 어떤 화면을 바꾸는지 한눈에 안 보여 고치기 어려워진다

React/Vue의 과도함

React와 Vue.js는 강력하지만, 간단한 페이지에는 오버엔지니어링(필요한 것보다 크고 무거운 도구를 들여오는 것)이다.

  • webpack, Vite 등 빌드 도구가 필요하다 — 둘 다 여러 자바스크립트 파일을 번들 하나로 묶어 주는 프로그램 이름이다
  • 번들 크기가 크다 (React: ~40KB, Vue: ~33KB)
  • 학습 곡선 — JSX, Virtual DOM, 컴포넌트 시스템을 새로 배워야 한다. 컴포넌트는 화면 한 덩어리를 데이터와 동작까지 묶은 단위다(컴포넌트)
  • 단순 서버 렌더링 페이지에는 맞지 않는다 — 서버가 이미 HTML을 다 만들어 보내는 페이지(SSR)에 버튼 몇 개를 움직이려고 화면 전체를 브라우저 쪽 코드로 다시 짜야 하기 때문이다

Alpine.js의 철학

Alpine.js는 이런 문제를 풀려고 나왔다.

  • 선언적 마크업 → 마크업은 HTML 태그로 적은 화면 코드를 말한다. 그 태그에 「이렇게 움직여라」를 직접 적는다
  • 반응형 데이터 바인딩 → x-model로 양방향 바인딩을 한다
  • 최소한의 학습 곡선 → 디렉티브 15개면 충분하다
  • 바로 쓸 수 있다 → 위의 CDN 한 줄이면 시작한다

출처: Alpine.js 공식 문서


Alpine.js 핵심 사용법 — 디렉티브 네 묶음

아래 예제는 전부 HTML이다. <div>는 영역 하나를 묶는 상자, <input>은 입력창, <p>는 문단, <button>은 버튼이다. 태그 안의 type="text" 같은 것은 그 태그의 속성이다. HTML 자체는 HTML 용어 페이지에 있다.

1. 기본 데이터 바인딩

<div x-data="{ message: 'Hello Alpine.js!' }">
    <input type="text" x-model="message">
    <p x-text="message"></p>
</div>

설명:

  • x-data: 컴포넌트의 상태를 정의한다. 상태는 화면이 지금 어떤 모습이어야 하는지를 정하는 값이다
  • x-model: 양방향 데이터 바인딩
  • x-text: 텍스트 내용을 설정한다

한 줄씩 따라가기

  • x-data="{ message: 'Hello Alpine.js!' }": 중괄호는 자바스크립트의 객체 리터럴이다. 클래스 없이 그 자리에서 message라는 필드 하나를 가진 객체를 만든다. 이 div 안쪽이 하나의 컴포넌트가 되고, 안의 태그들은 message를 쓸 수 있다
  • <input type="text" x-model="message">: 입력창과 message를 양쪽으로 묶는다. 처음에는 입력창에 Hello Alpine.js!가 들어 있고, 사용자가 글자를 치면 그 값이 곧바로 message에 들어간다
  • <p x-text="message"></p>: 문단의 글자를 message 값으로 채운다

그래서 입력창에 글자를 치면, 치는 대로 아래 문단의 글자가 따라 바뀐다. jQuery였다면 「입력이 바뀌면 문단을 찾아 글자를 바꿔라」를 직접 적어야 했던 부분이다.

출처: Alpine.js x-data 문서

2. 조건부 렌더링 (모달/토글)

모달은 현재 화면 위에 떠서 닫기 전까지 뒤의 화면을 막는 작은 창이다.

<div x-data="{ open: false }">
    <button @click="open = !open">모달 열기</button>
    
    <div x-show="open" @click.outside="open = false">
        <div class="modal-content">
            모달 컨텐츠
            <button @click="open = false">닫기</button>
        </div>
    </div>
</div>

포인트:

  • x-show: CSS의 display 속성으로 보이기/숨기기를 한다. CSS는 화면의 생김새를 정하는 언어이고, display: none이 걸리면 그 요소는 화면에서 안 보인다
  • @click: 클릭을 기다렸다가 적어 둔 코드를 실행하는 이벤트 리스너다(x-on:click의 줄임 표기)
  • @click.outside: 이 요소 바깥을 클릭했을 때 실행한다
  • class="modal-content": 자바의 class와 다른 HTML 속성이다. CSS에서 이 이름으로 요소를 골라 모양을 입힐 때 쓰는 꼬리표다

x-show vs x-if:

  • x-show: 요소가 DOM에 그대로 남고, display 속성만 바뀐다 (자주 켜고 끄는 경우)
  • x-if: 요소를 DOM에서 아예 지우거나 새로 만든다 (첫 화면 렌더링 성능이 중요한 경우). 조건이 거짓이면 그 요소를 처음부터 만들지 않으므로, 페이지가 처음 뜰 때 브라우저가 만들 요소가 줄어든다

출처: Alpine.js x-show 문서

3. 리스트 렌더링 & 필터링

아래 예제의 <table>은 표, <thead>는 표 머리, <tbody>는 표 몸통, <tr>은 한 줄, <th>·<td>는 머리 칸·보통 칸이다.

<div x-data="{
    search: '',
    users: [
        { id: 1, name: 'Alice', email: 'alice@example.com' },
        { id: 2, name: 'Bob', email: 'bob@example.com' },
        { id: 3, name: 'Charlie', email: 'charlie@example.com' }
    ],
    
    get filteredUsers() {
        return this.users.filter(user => 
            user.name.toLowerCase().includes(this.search.toLowerCase())
        )
    }
}">
    <input type="text" x-model="search" placeholder="사용자 검색...">
    
    <table>
        <thead>
            <tr>
                <th>이름</th>
                <th>이메일</th>
            </tr>
        </thead>
        <tbody>
            <template x-for="user in filteredUsers" :key="user.id">
                <tr>
                    <td x-text="user.name"></td>
                    <td x-text="user.email"></td>
                </tr>
            </template>
        </tbody>
    </table>
</div>

핵심 포인트:

  • get filteredUsers(): 계산된 속성(Computed Property)이다 (자동 의존성 추적)
  • x-for: 배열을 반복해 그린다 (반드시 <template> 태그에 쓴다)
  • :key: 각 항목의 고유 식별자 (성능 최적화)

한 줄씩 따라가기

  • search: '': 검색창에 입력한 글자를 담아 두는 변수다. 처음에는 빈 문자열이다.
  • users: [ {...}, {...}, {...} ]: 자바스크립트는 대괄호 []로 배열을 만든다(자바의 {1, 2, 3} 배열 초기화나 List.of(...)와 같은 일). 안에 든 { id: 1, name: 'Alice', ... } 하나하나는 사용자 한 명을 나타내는 객체 리터럴이다.
  • get filteredUsers() { ... }: 앞에 get을 붙이면 일반 변수가 아니라 읽을 때마다 다시 계산해서 돌려주는 값(Computed Property)이 된다. filteredUsers()처럼 괄호를 붙여 부르지 않고 filteredUsers라는 이름 그대로 쓰면 그때마다 안의 코드가 다시 실행된다. search나 users가 바뀌면 Alpine.js가 이 값을 쓰는 화면을 자동으로 다시 그린다 — 그래서 “자동 의존성 추적”이라 부른다.
  • this.users.filter(user => user.name.toLowerCase().includes(this.search.toLowerCase())): users 배열에서 이름에 검색어가 들어간 사용자만 걸러 새 배열을 만든다. user => ...는 자바스크립트의 화살표 함수로, 자바 람다의 user -> ...와 같은 것이다. 양쪽을 소문자로 바꾼 뒤 비교하는 이유는 대소문자를 구분하지 않고 찾기 위해서다.
  • <template x-for="user in filteredUsers" :key="user.id">: <template>은 브라우저가 화면에 그리지 않는 틀 태그다. Alpine.js는 이 틀 안의 <tr>을 filteredUsers의 항목 수만큼 복사해 붙인다. 틀 자체가 화면에 남으면 안 되므로 x-for는 <template>에 붙인다.
  • :key="user.id": 맨 앞의 콜론(:)은 x-bind:의 줄임 표기로, 따옴표 안을 글자가 아니라 자바스크립트 값으로 읽으라는 뜻이다. 그래서 key에 "user.id"라는 글자가 아니라 각 사용자의 id 값(1, 2, 3)이 들어간다. 항목마다 이름표를 붙이는 셈이다. 검색어가 바뀌어 목록이 달라질 때 Alpine.js는 이 이름표로 「이미 그려 둔 줄 중 그대로 쓸 줄」을 알아보고, 전부 지웠다 다시 그리지 않는다.

출처: Alpine.js x-for 문서

4. API 호출 패턴

여기서 API는 서버가 데이터를 내주는 창구이고, /api/users는 이 페이지를 내려준 같은 서버에서 사용자 목록을 JSON으로 돌려주는 주소라고 가정한다. 이 주소를 만드는 서버 코드는 이 글에서 다루지 않는다.

<div x-data="{
    users: [],
    loading: false,
    error: null,
    
    async fetchUsers() {
        this.loading = true;
        this.error = null;
        
        try {
            const response = await fetch('/api/users');
            if (!response.ok) throw new Error('데이터 로딩 실패');
            this.users = await response.json();
        } catch(e) {
            this.error = e.message;
        } finally {
            this.loading = false;
        }
    }
}" x-init="fetchUsers()">
    
    <button @click="fetchUsers()" :disabled="loading">
        <span x-show="!loading">새로고침</span>
        <span x-show="loading">로딩 중...</span>
    </button>
    
    <div x-show="error" x-text="error" class="error"></div>
    
    <ul x-show="!loading && !error">
        <template x-for="user in users" :key="user.id">
            <li x-text="user.name"></li>
        </template>
    </ul>
</div>

실무 패턴:

  • x-init: 컴포넌트가 처음 시작될 때 한 번 실행한다
  • :disabled: 속성 값을 변수에 묶는다(x-bind:disabled의 줄임 표기). disabled가 켜진 버튼은 눌리지 않는다
  • 로딩/에러 상태를 나눠 두어 UX를 좋게 한다

한 줄씩 따라가기

  • async fetchUsers() { ... }: 함수 앞에 async를 붙이면 그 함수 안에서 await를 쓸 수 있다. await가 붙은 줄은 응답이 올 때까지 이 함수 안에서만 멈춰 기다리고, 기다리는 동안 페이지의 다른 코드는 그대로 움직인다.
  • const response = await fetch('/api/users'): fetch로 /api/users 주소에 요청을 보낸다. await가 붙었으므로 서버가 응답을 줄 때까지 이 줄에서 기다린 뒤, 응답이 오면 response에 담아 다음 줄로 넘어간다. const는 다시 대입하지 않을 변수를 만드는 키워드로, 자바의 final 지역 변수와 같다. 이 글의 다른 코드에 나오는 const도 같은 뜻이다.
  • if (!response.ok) throw new Error('데이터 로딩 실패'): 응답의 HTTP 상태 코드가 성공 범위(200번대)가 아니면 response.ok가 거짓이 되고, 이때 에러를 일부러 발생시켜 아래 catch로 보낸다.
  • this.users = await response.json(): 응답은 부가 정보 칸(헤더)과 실제 내용인 본문으로 나뉜다. 이 줄은 그 본문을 JSON으로 해석해 users 배열에 담는다. 이 줄이 끝나야 화면의 x-for가 새 목록을 그린다.
  • catch(e) { this.error = e.message }: 위에서 에러가 나면 이 블록이 받아 error 변수에 메시지를 담고, 화면의 x-show="error"가 그 메시지를 보여준다.
  • finally { this.loading = false }: 성공하든 실패하든 마지막에 한 번은 실행돼 로딩 표시를 끈다.
  • x-show="error", x-show="!loading && !error": 자바와 달리 자바스크립트는 조건 자리에 boolean이 아닌 값도 넣을 수 있다(참 같은 값 · 거짓 같은 값). error가 null이면 거짓, 메시지 문자열이 들어 있으면 참으로 친다. 그래서 에러가 있을 때만 에러 칸이 보이고, 로딩도 에러도 아닐 때만 목록이 보인다.

Spring Boot + Thymeleaf 화면에 Alpine.js를 얹는 구조

프로젝트 구조 — static은 그대로 내려가고 templates는 Thymeleaf를 거친다

Spring Boot는 자바로 웹 서버를 만드는 프레임워크이고, Thymeleaf는 Spring Boot에서 서버가 HTML을 만들어 브라우저로 내려보낼 때 쓰는 템플릿 엔진이다. Alpine.js는 이 Thymeleaf가 만들어 준 HTML 위에 그대로 얹어서 쓸 수 있다. 아래는 그 둘을 함께 쓰는 프로젝트 구조다.

src/main/resources/
├── static/
│   └── js/
│       ├── alpine.min.js      # Alpine.js 라이브러리
│       ├── common.js           # 공통 유틸리티
│       ├── api.js              # API 호출 공통 로직
│       └── pages/
│           ├── users.js        # 사용자 관리 페이지
│           └── products.js     # 상품 관리 페이지
└── templates/
    ├── layout/
    │   └── default.html        # 공통 레이아웃
    └── pages/
        ├── users.html          # 사용자 관리 페이지
        └── products.html       # 상품 관리 페이지
  • src/main/resources/: Spring Boot 프로젝트에서 자바 코드가 아닌 파일을 두는 자리다
  • static/: 여기 둔 파일은 서버가 손대지 않고 그대로 내려보낸다(정적 파일). static/js/api.js는 브라우저에서 /js/api.js 주소로 받는다. 브라우저에서 실행될 자바스크립트는 전부 여기 둔다
  • templates/: 여기 둔 HTML은 Thymeleaf가 먼저 읽어 th: 속성 자리에 서버 값을 채운 뒤 내려보낸다
  • alpine.min.js: Alpine.js 파일을 CDN에서 받지 않고 내 서버에 직접 둔 것이다. min은 공백과 주석을 빼 크기를 줄인 축소 파일이라는 표시다. 앞에서는 CDN 한 줄로 시작했는데, 이 구조는 파일을 프로젝트 안에 넣었다. 왜 바꿨는지는 원래 글에 적혀 있지 않다. 외부 CDN에 기대지 않고 내 서버만으로 동작하게 하려는 선택으로 읽히지만, 그 이유는 확인하지 않았다
graph TB A[Thymeleaf Template] -->|"서버 사이드 렌더링"| B[HTML 생성] B -->|"초기 데이터 주입"| C[Alpine.js 컴포넌트] C -->|"사용자 인터랙션"| D[상태 변경] D -->|"반응형 렌더링"| E[DOM 업데이트] C -->|"API 호출"| F[Spring Boot REST API] F -->|"JSON 응답"| C style A fill:#2c3e50,stroke:#34495e,stroke-width:2px,color:#ecf0f1 style B fill:#34495e,stroke:#2c3e50,stroke-width:2px,color:#ecf0f1 style C fill:#16a085,stroke:#1abc9c,stroke-width:3px,color:#ecf0f1 style D fill:#27ae60,stroke:#2ecc71,stroke-width:2px,color:#ecf0f1 style E fill:#2980b9,stroke:#3498db,stroke-width:2px,color:#ecf0f1 style F fill:#8e44ad,stroke:#9b59b6,stroke-width:2px,color:#ecf0f1

그림을 위에서부터 읽으면 이렇다. 서버의 Thymeleaf가 HTML을 완성해 보낸다(SSR). 그 HTML 안에 서버가 처음 값을 미리 넣어 둘 수 있다(초기 데이터 주입, 뒤의 「초기 데이터 주입」 절). 브라우저에서는 Alpine.js 컴포넌트가 클릭과 입력을 받아 상태를 바꾸고, 바뀐 상태대로 DOM을 다시 그린다. 새 데이터가 필요하면 Spring Boot의 REST API에 요청을 보내 JSON으로 받는다.

패턴 1: Alpine.data()로 컴포넌트를 이름 붙여 등록한다

앞의 예제들은 x-data="{ ... }" 안에 상태와 함수를 전부 적었다. 이 방식은 같은 컴포넌트를 다른 페이지에서 또 쓰려면 HTML 속에 든 긴 코드를 통째로 복사해야 한다. Alpine.data()는 그 내용을 자바스크립트 파일에 이름을 붙여 등록한다. 그러면 어느 HTML에서든 x-data="userManager"처럼 이름만 적어 같은 컴포넌트를 쓸 수 있다.

users.html (Thymeleaf 템플릿)

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <script src="/js/alpine.min.js" defer></script>
    <script src="/js/common.js"></script>
    <script src="/js/api.js"></script>
    <script src="/js/pages/users.js"></script>
</head>
<body>
    <div x-data="userManager">
        <input type="text" x-model="search" placeholder="검색...">
        
        <table>
            <template x-for="user in filteredUsers" :key="user.id">
                <tr>
                    <td x-text="user.name"></td>
                    <td x-text="user.email"></td>
                </tr>
            </template>
        </table>
    </div>
</body>
</html>

한 줄씩 따라가기

  • <!DOCTYPE html>, <html>, <head>, <body>: HTML 파일의 기본 뼈대다. 첫 줄은 「이 파일은 HTML이다」라는 표시, <html>은 문서 전체, <head>는 화면에 안 보이는 설정, <body>는 화면에 보이는 내용이다.
  • xmlns:th="http://www.thymeleaf.org": 이 HTML 파일 안에서 th:로 시작하는 속성을 쓴다고 알리는 줄이다. Thymeleaf는 이 줄이 없어도 th: 속성을 처리한다. 이 줄은 주로 편집기가 th: 속성을 오류로 표시하지 않게 하려고 넣는다.
  • <script src="/js/alpine.min.js" defer></script>: Alpine.js 라이브러리를 읽어 온다. defer가 붙어 있어서 HTML을 다 읽은 뒤에 실행된다.
  • <script src="/js/common.js">, api.js, pages/users.js: 이 셋에는 defer가 없다. 그래서 브라우저가 HTML을 읽다가 이 줄을 만나는 즉시 실행한다. 결과적으로 실행 순서는 common.js → api.js → users.js → (HTML을 다 읽은 뒤) Alpine.js가 된다. 이 순서가 중요하다. users.js는 「Alpine.js가 시작할 때 userManager를 등록해 달라」는 부탁을 걸어 두는 파일이라, Alpine.js가 시작하기 전에 실행돼 있어야 한다.
  • <div x-data="userManager">: 아래 users.js에서 등록할 userManager라는 이름의 컴포넌트를 이 div에 연결한다.

이 파일에는 th: 속성이 없다 — Thymeleaf는 서버가 처음 HTML을 만들 때만 관여하고, 그 뒤 화면에서 값이 바뀌는 건 전부 Alpine.js가 맡기 때문이다.

users.js (Alpine.js 컴포넌트)

이 코드는 API.get(...)과 showToast(...)를 부른다. 둘 다 바로 뒤의 패턴 2(api.js)와 패턴 3(common.js)에서 만든다. 위 HTML에서 두 파일이 users.js보다 먼저 읽히므로 여기서 쓸 수 있다.

document.addEventListener('alpine:init', () => {
    Alpine.data('userManager', () => ({
        users: [],
        search: '',
        loading: false,
        error: null,
        
        // 초기화 (컴포넌트 마운트 시 자동 실행)
        async init() {
            await this.fetchUsers();
        },
        
        // Computed Property
        get filteredUsers() {
            if (!this.search) return this.users;
            
            return this.users.filter(user => 
                user.name.toLowerCase().includes(this.search.toLowerCase()) ||
                user.email.toLowerCase().includes(this.search.toLowerCase())
            );
        },
        
        // API 호출
        async fetchUsers() {
            this.loading = true;
            this.error = null;
            
            try {
                const response = await API.get('/api/users');
                this.users = response.data;
            } catch(e) {
                this.error = e.message;
                showToast('사용자 목록을 불러오는데 실패했습니다.', 'error');
            } finally {
                this.loading = false;
            }
        }
    }));
});

한 줄씩 따라가기

  • document.addEventListener('alpine:init', () => { ... }): document는 브라우저가 지금 페이지 전체를 가리키려고 주는 객체다. addEventListener로 「alpine:init이라는 일이 생기면 이 함수를 실행해 달라」고 걸어 둔다. alpine:init은 Alpine.js가 시작하면서 화면의 x-data를 처리하기 직전에 알리는 신호다. 그 순간에 등록해야 Alpine.js가 x-data="userManager"를 만났을 때 이 이름을 안다.
  • Alpine.data('userManager', () => ({ ... })): userManager라는 이름으로 컴포넌트를 등록한다. 두 번째 인자는 객체를 돌려주는 화살표 함수다. () => ({ ... })처럼 객체를 소괄호로 감싼 이유는, 감싸지 않으면 자바스크립트가 {를 객체가 아니라 함수 몸통의 시작으로 읽기 때문이다. 함수로 넘기므로 x-data="userManager"를 쓴 곳마다 이 함수가 새로 불려 각자의 객체를 받는다.
  • users, search, loading, error: 앞 예제들과 같은 상태 값이다.
  • async init() { await this.fetchUsers(); }: 객체에 init이라는 이름의 메서드가 있으면 Alpine.js가 컴포넌트를 화면에 붙이는 순간(마운트) 알아서 한 번 불러 준다. 이름이 정해져 있는 약속이다. 여기서는 처음 뜰 때 사용자 목록을 받아 온다.
  • get filteredUsers(): 앞의 3번 예제와 같은데, 검색어가 비어 있으면 거르지 않고 전체를 돌려주고, 이름뿐 아니라 이메일로도 찾는다.
  • const response = await API.get('/api/users'): 패턴 2의 API.get을 부른다. 돌아오는 값은 { data: ..., status: ... } 모양의 객체라서 다음 줄에서 response.data를 꺼낸다.
  • showToast(...): 실패하면 패턴 3의 showToast로 화면 구석에 알림을 띄운다.

출처: Alpine.js Alpine.data() 문서

패턴 2: 공통 API 모듈이 모든 요청에 CSRF 헤더를 붙인다

api.js

const API = {
    // CSRF 토큰 가져오기 (Thymeleaf 메타 태그에서)
    getCsrfToken() {
        return document.querySelector('meta[name="_csrf"]')?.content || '';
    },
    
    getCsrfHeader() {
        return document.querySelector('meta[name="_csrf_header"]')?.content || 'X-CSRF-TOKEN';
    },
    
    // 공통 fetch 래퍼
    async request(url, options = {}) {
        const defaultOptions = {
            headers: {
                'Content-Type': 'application/json',
                [this.getCsrfHeader()]: this.getCsrfToken()
            },
            credentials: 'same-origin'
        };
        
        const config = {
            ...defaultOptions,
            ...options,
            headers: {
                ...defaultOptions.headers,
                ...options.headers
            }
        };
        
        try {
            const response = await fetch(url, config);
            
            if (!response.ok) {
                // HTTP 에러 처리
                if (response.status === 401) {
                    window.location.href = '/login';
                    return;
                }
                
                throw new Error(`HTTP ${response.status}: ${response.statusText}`);
            }
            
            return {
                data: await response.json(),
                status: response.status
            };
        } catch(error) {
            console.error('API 요청 실패:', error);
            throw error;
        }
    },
    
    // 편의 메서드들
    get(url) {
        return this.request(url, { method: 'GET' });
    },
    
    post(url, data) {
        return this.request(url, {
            method: 'POST',
            body: JSON.stringify(data)
        });
    },
    
    put(url, data) {
        return this.request(url, {
            method: 'PUT',
            body: JSON.stringify(data)
        });
    },
    
    delete(url) {
        return this.request(url, { method: 'DELETE' });
    }
};

한 줄씩 따라가기

  • getCsrfToken() / getCsrfHeader(): 서버가 HTML 메타 태그에 심어 둔 CSRF 토큰 값과, 그 값을 어느 헤더 이름으로 보내야 하는지를 읽어 온다. 헤더는 요청 본문 앞에 붙는 부가 정보 칸이다. 메타 태그에 값을 심는 쪽은 뒤의 「실무 적용 가이드」에서 다룬다.
  • document.querySelector('meta[name="_csrf"]'): 페이지에서 name="_csrf"인 meta 태그를 하나 찾는다. 괄호 안은 CSS 선택자 문법이고, 대괄호는 「이 속성 값을 가진 태그」라는 뜻이다.
  • ?.content || '': ?.는 옵셔널 체이닝이다. 태그를 못 찾아 null이면 에러 없이 undefined가 된다. undefined는 자바에 없는 값으로, 「값이 아직 정해지지 않았다」는 뜻이다. 이 글에서는 null과 똑같이 「없다」로 읽으면 된다. 뒤의 ||는 왼쪽이 거짓 같은 값이면 오른쪽 값을 쓰라는 뜻이라, 토큰이 없으면 빈 문자열, 헤더 이름이 없으면 'X-CSRF-TOKEN'을 쓴다.
  • async request(url, options = {}): 모든 요청이 거쳐 가는 함수다. options = {}는 기본값 매개변수라서, options를 안 넘기면 빈 객체가 들어간다.
  • 'Content-Type': 'application/json': 「내가 보내는 본문은 JSON이다」라고 서버에 알리는 헤더다(Content-Type).
  • [this.getCsrfHeader()]: this.getCsrfToken(): 객체의 이름 자리에 대괄호를 쓰면, 대괄호 안을 실행한 결과가 이름이 된다. 헤더 이름이 서버마다 다를 수 있어서 메타 태그에서 읽은 이름을 그대로 쓴다.
  • credentials: 'same-origin': 요청 주소가 지금 페이지와 같은 오리진이면 로그인 정보를 담은 쿠키를 같이 보내라는 설정이다.
  • const config = { ...defaultOptions, ...options, headers: {...} }: ...는 펼침 문법이다. 기본값을 먼저 펼치고 호출할 때 넘긴 options를 뒤에 펼쳐서, 같은 이름이면 넘긴 값이 이긴다. 그런데 headers는 객체 안의 객체라서, 바깥만 펼치면 options.headers가 있을 때 기본 헤더(Content-Type, CSRF) 묶음이 통째로 갈아 끼워져 사라진다. 그래서 headers만 한 번 더 따로 펼쳐 두 묶음을 합친다.
  • if (response.status === 401) { window.location.href = '/login'; return; }: 응답의 HTTP 상태 코드가 401이면 “로그인하지 않았다”는 뜻이라, 로그인 페이지로 이동시킨다. window.location.href에 주소를 넣으면 브라우저가 그 주소로 넘어간다. 이때 return;은 아무 값도 돌려주지 않으므로 호출한 쪽은 undefined를 받는다. 그래서 users.js의 response.data는 undefined에서 필드를 읽으려다 에러가 나 catch로 간다. 페이지가 어차피 로그인 화면으로 넘어가는 중이라 이 에러는 잠깐 생겼다 사라진다.
  • throw new Error(`HTTP ${response.status}: ${response.statusText}`): 백틱 문자열은 템플릿 리터럴이고, ${} 자리에 값이 들어간다. statusText는 상태 코드에 붙은 짧은 글(예: Not Found)이다. 401이 아닌 다른 실패는 이렇게 에러로 던져서, 이 함수를 호출한 쪽의 catch가 처리하게 한다.
  • console.error(...): 브라우저 콘솔에 빨간 글로 찍는다. 찍은 뒤 throw error로 에러를 다시 던져 호출한 쪽에도 알린다.
  • get / post / put / delete: HTTP 메서드별 줄임 함수다. GET은 가져오기, POST는 새로 만들기, PUT은 통째로 바꾸기, DELETE는 지우기다. body는 요청에 실어 보내는 본문이다.
  • JSON.stringify(data): 자바스크립트 객체를 JSON 글자로 바꾼다. 요청 본문은 글자로 보내야 하기 때문이다.

패턴 3: 공통 유틸리티는 페이지마다 쓰는 작은 함수를 모은다

common.js

// 날짜 포맷팅
function formatDate(date, format = 'YYYY-MM-DD') {
    const d = new Date(date);
    const year = d.getFullYear();
    const month = String(d.getMonth() + 1).padStart(2, '0');
    const day = String(d.getDate()).padStart(2, '0');
    
    return format
        .replace('YYYY', year)
        .replace('MM', month)
        .replace('DD', day);
}

// 토스트 메시지 (Alpine.js와 함께 사용)
function showToast(message, type = 'info') {
    // Alpine.store를 사용한 전역 상태 관리
    if (window.Alpine && Alpine.store('toast')) {
        Alpine.store('toast').show(message, type);
    } else {
        // fallback
        alert(message);
    }
}

// 숫자 포맷팅
function formatNumber(num) {
    return new Intl.NumberFormat('ko-KR').format(num);
}

한 줄씩 따라가기

  • new Date(date): 넘겨받은 값으로 날짜 객체를 만든다. 자바의 LocalDateTime과 비슷한 역할이다.
  • d.getMonth() + 1: 자바스크립트의 getMonth()는 1월을 0으로, 12월을 11로 돌려준다. 그래서 사람이 읽는 달로 맞추려고 1을 더한다.
  • String(...).padStart(2, '0'): 글자 수가 2보다 적으면 앞에 '0'을 채운다. 3월이면 '3'이 '03'이 된다.
  • format.replace('YYYY', year)...: 형식 글자 YYYY-MM-DD의 각 자리를 실제 연·월·일로 바꿔 2026-03-05 같은 글자를 만든다.
  • showToast(message, type = 'info'): 화면 구석에 잠깐 뜨는 토스트 메시지를 띄운다. 실제로 띄우는 일은 바로 다음 패턴 4의 Alpine.store('toast')가 맡는다.
  • if (window.Alpine && Alpine.store('toast')): window는 브라우저에서 전역 변수가 모두 붙어 있는 객체다. Alpine.js가 로드돼 있고, toast 저장소도 등록돼 있을 때만 그쪽으로 넘긴다.
  • else { alert(message); }: fallback은 「원래 방법이 안 될 때 쓰는 대신할 방법」이다. Alpine.js가 아직 없으면 브라우저 기본 알림 창(alert)으로라도 보여 준다.
  • new Intl.NumberFormat('ko-KR').format(num): 숫자를 한국식으로 세 자리마다 쉼표를 찍은 글자로 바꾼다. 1234567이 1,234,567이 된다.

패턴 4: Alpine.store로 페이지 전체가 같이 쓰는 상태를 둔다

x-data의 상태는 그 div 안에서만 보인다. 토스트처럼 어느 컴포넌트에서 띄우든 화면 한 곳에 나타나야 하는 값은 전역 상태로 둬야 한다. Alpine.store()가 그 자리다.

// common.js 또는 별도 파일
document.addEventListener('alpine:init', () => {
    // 토스트 메시지 전역 상태
    Alpine.store('toast', {
        visible: false,
        message: '',
        type: 'info',
        
        show(message, type = 'info') {
            this.message = message;
            this.type = type;
            this.visible = true;
            
            setTimeout(() => {
                this.visible = false;
            }, 3000);
        }
    });
    
    // 사용자 정보 전역 상태
    Alpine.store('auth', {
        user: null,
        isAuthenticated: false,
        
        setUser(user) {
            this.user = user;
            this.isAuthenticated = !!user;
        }
    });
});

한 줄씩 따라가기

  • Alpine.store('toast', { ... }): toast라는 이름으로 전역 저장소를 만든다. 패턴 1의 alpine:init과 같은 이유로, Alpine.js가 시작하기 직전에 등록한다.
  • show(message, type): 메시지와 종류를 넣고 visible을 참으로 바꿔 토스트를 띄운다.
  • setTimeout(() => { this.visible = false; }, 3000): 3000밀리초(3초) 뒤에 안의 함수를 한 번 실행하라는 브라우저 함수다. 3초 뒤 토스트가 사라진다.
  • Alpine.store('auth', { ... }): 로그인한 사용자 정보를 담는 저장소다. 이 글의 다른 코드는 이 저장소를 쓰지 않는다. 토스트 말고도 이런 값을 전역 상태로 둘 수 있다는 예시로만 남겨 둔 것이다.
  • this.isAuthenticated = !!user: !를 두 번 붙이면 값을 boolean으로 바꾼다. user가 null이면 false, 객체가 들어 있으면 true가 된다.

레이아웃에서 토스트 사용

레이아웃은 여러 페이지가 함께 쓰는 공통 틀 HTML이다(앞의 구조에서 layout/default.html). 토스트 칸은 거기 한 번만 둔다.

<div x-data 
     x-show="$store.toast.visible"
     x-transition
     :class="'toast toast-' + $store.toast.type"
     x-text="$store.toast.message">
</div>

한 줄씩 따라가기

  • x-data: 값이 비어 있다. 이 div는 자기 상태가 없지만, Alpine.js는 x-data가 붙은 곳 안에서만 x- 속성을 처리하므로 「여기도 Alpine.js 영역이다」라고 표시만 해 둔다.
  • x-show="$store.toast.visible": $store는 Alpine.store()로 만든 저장소들을 HTML에서 부르는 이름이다. 토스트의 visible이 참일 때만 보인다.
  • x-transition: 보였다 사라질 때 갑자기 바뀌지 않고 서서히 나타나고 사라지게 한다.
  • :class="'toast toast-' + $store.toast.type": type이 error면 class가 toast toast-error가 된다. 종류마다 CSS로 색을 다르게 입히려는 것이다.
  • x-text="$store.toast.message": 토스트에 메시지 글자를 채운다.

출처: Alpine.js Alpine.store() 문서


실무 적용 가이드 — CSRF 토큰, 초기 데이터, 폼 제출

Spring Security CSRF 토큰은 레이아웃 메타 태그로 넘긴다

Spring Security는 로그인 여부와 권한을 검사해 주는 Spring의 보안 기능이고, CSRF는 로그인해 둔 사용자 몰래 다른 사이트가 요청을 대신 보내게 만드는 공격이다. Spring Security는 기본적으로 이 CSRF를 막는 토큰 검사를 켜 두므로, Thymeleaf 레이아웃에 서버가 발급한 토큰 값을 먼저 심어 둬야 한다.

Thymeleaf 레이아웃 헤더

<head>
    <meta name="_csrf" th:content="${_csrf.token}"/>
    <meta name="_csrf_header" th:content="${_csrf.headerName}"/>
</head>

_csrf는 내가 만든 값이 아니다. Spring Security가 요청마다 CSRF 토큰 객체를 _csrf라는 이름으로 넣어 두고, Thymeleaf는 그 이름으로 꺼내 쓴다. token은 토큰 값, headerName은 그 값을 실어 보낼 헤더 이름이다.

th:content="${_csrf.token}"은 서버가 가진 토큰 값을 이 메타 태그의 content 속성 자리에 채워 넣으라는 뜻이다 — th:로 시작하는 속성을 Thymeleaf가 해석한다는 점은 앞서 「패턴 1」의 users.html 설명에서 살펴본 것과 같다.

이렇게 하면 api.js가 CSRF 토큰을 읽어서 모든 요청에 포함시킨다.

초기 데이터는 script 태그의 인라인으로 넣는다

서버에서 이미 만들어 둔 목록을 화면이 뜨자마자 쓰고 싶을 때가 있다. 이때는 Thymeleaf가 그 값을 HTML 안에 직접 찍어 넣게 할 수 있다. [[${users}]]처럼 대괄호 두 개로 감싼 것을 인라인 표현식이라 부르고, 그 자리가 서버가 가진 users 값으로 바뀐다. 이 값은 Controller(서버에서 요청을 처음 받아 어떤 화면에 무슨 값을 넘길지 정하는 자바 클래스)가 화면에 넘긴 사용자 목록(List)이라고 가정한다. Controller 쪽 코드는 이 글에서 다루지 않는다.

인라인 표현식은 HTML 속성 값 안에서는 동작하지 않는다. 원래 이 글은 아래 모양을 먼저 소개했다.

<!-- 동작하지 않는다 -->
<div x-data="userManager" 
     x-init="users = [[${usersJson}]]">
    <!-- 컴포넌트 내용 -->
</div>

Thymeleaf 소스(org.thymeleaf.inline.IInliner)를 보면 인라인 처리는 태그 사이의 글자, CDATA(<![CDATA[ ... ]]>로 감싼, 태그로 해석하지 말라고 표시한 글자 덩어리), 주석 세 곳에만 걸린다. 속성 값은 대상이 아니다. 그래서 [[${usersJson}]]가 바뀌지 않고 글자 그대로 브라우저에 간다. 소스로 확인했고, 실제로 띄워 보지는 않았다.

그래서 <script> 태그 안에서 값을 받는다.

Thymeleaf → Alpine.js 데이터 전달

<script th:inline="javascript">
    const initialUsers = /*[[${users}]]*/ [];
</script>

<div x-data="userManager" x-init="users = initialUsers">
    <!-- 컴포넌트 내용 -->
</div>

한 줄씩 따라가기

  • th:inline="javascript": 이 <script> 태그 안에서는 Thymeleaf가 자바스크립트 문법에 맞춰 값을 끼워 넣는다. 자바 List를 넘기면 자바스크립트 배열 모양으로 바꿔 적는다(Thymeleaf의 StandardJavaScriptSerializer가 맡는다. 프로젝트에 자바 객체를 JSON으로 바꾸는 라이브러리 Jackson이 있으면 그것으로 바꾸고, 없으면 Thymeleaf 자체 변환기가 List를 배열로 적는다. 둘 다 소스로 확인했다). 이미 JSON 글자로 만든 String을 넘기면 배열이 아니라 따옴표로 감싼 문자열이 되므로, 목록 자체를 넘긴다.
  • /*[[${users}]]*/ []: 자바스크립트 주석처럼 보이지만 Thymeleaf가 이 자리를 users 값으로 바꿔치기한다. 주석 표시(/* */) 안에 넣어 두는 이유는, Thymeleaf를 거치지 않고 이 파일이 그대로 열려도(예: 서버 없이 미리보기) 문법 오류 없이 뒤에 적어 둔 빈 배열 []가 그대로 쓰이게 하기 위해서다.
  • x-init="users = initialUsers": Alpine.js 컴포넌트가 시작될 때 위에서 만든 initialUsers 값을 users 변수에 넣는다.

주의할 점이 하나 있다. 패턴 1의 userManager는 init()에서 fetchUsers()로 목록을 다시 받는다. 서버 응답은 시간이 걸려 나중에 도착하므로, 도착하는 순간 x-init이 넣어 둔 초기 목록을 덮어쓴다. 초기 데이터를 넣는다면 init()에서 처음 받아 오는 호출을 빼야 같은 목록을 두 번 받지 않는다.

폼 제출 예제 — 저장 버튼이 API.post를 부른다

<form>은 입력 칸들을 묶어 한 번에 제출하는 태그, <label>은 입력 칸 옆의 이름표, type="email"은 이메일 모양만 받는 입력 칸이다.

<form x-data="{
    form: { name: '', email: '' },
    errors: {},
    submitting: false,
    
    async submit() {
        this.submitting = true;
        this.errors = {};
        
        try {
            await API.post('/api/users', this.form);
            showToast('저장되었습니다.', 'success');
            this.form = { name: '', email: '' };
        } catch(e) {
            if (e.errors) {
                this.errors = e.errors; // 서버 유효성 검사 에러
            } else {
                showToast('저장에 실패했습니다.', 'error');
            }
        } finally {
            this.submitting = false;
        }
    }
}" @submit.prevent="submit()">
    
    <div>
        <label>이름</label>
        <input type="text" x-model="form.name">
        <span x-show="errors.name" x-text="errors.name" class="error"></span>
    </div>
    
    <div>
        <label>이메일</label>
        <input type="email" x-model="form.email">
        <span x-show="errors.email" x-text="errors.email" class="error"></span>
    </div>
    
    <button type="submit" :disabled="submitting">
        <span x-show="!submitting">저장</span>
        <span x-show="submitting">저장 중...</span>
    </button>
</form>

한 줄씩 따라가기

  • form: { name: '', email: '' }: 입력 칸 두 개의 값을 담는 객체다. 아래 x-model="form.name", x-model="form.email"로 입력 칸과 묶인다.
  • errors: {}: 칸마다의 오류 메시지를 담을 자리다. errors.name에 글자가 들어 있으면 그 칸 아래 <span>이 보인다.
  • @submit.prevent="submit()": 폼이 제출되면(저장 버튼을 누르면) submit()을 부른다. .prevent는 브라우저가 원래 하는 일, 즉 페이지를 통째로 새로 불러오며 제출하는 동작을 막는다. 페이지를 그대로 둔 채 자바스크립트로 보내기 위해서다.
  • await API.post('/api/users', this.form): 패턴 2의 API.post로 입력값을 JSON으로 보낸다. 성공하면 토스트를 띄우고 입력 칸을 비운다.
  • if (e.errors) { ... }: 서버는 받은 값이 규칙에 맞는지(이름이 비지 않았는지, 이메일 모양인지) 검사한다. 이를 유효성 검사라 한다. 서버가 그 검사에서 칸별 오류를 돌려줬으면 그걸 화면에 나눠 보여 주려는 분기다. 그런데 지금의 api.js는 실패할 때 new Error(...)만 던지고 errors를 붙이지 않는다. 그래서 이 예제 그대로면 이 분기는 참이 되지 않고, 늘 else의 「저장에 실패했습니다」 토스트로 간다. 칸별 오류를 받으려면 api.js가 실패 응답의 본문을 읽어 에러 객체에 errors를 붙여야 하는데, 그 코드는 이 글에 없다.
  • :disabled="submitting": 보내는 동안 버튼을 눌리지 않게 해서 두 번 저장되는 것을 막는다.

Alpine.js의 한계점과 주의사항

사용하면 안 되는 경우

대규모 SPA (Single Page Application)

  • 주소마다 다른 화면을 보여 주는 라우팅(라우트)이 복잡한 경우
  • 수십 개 이상의 뷰(화면)/페이지를 관리해야 하는 경우
  • → React Router, Vue Router(각각 React와 Vue에서 주소별 화면 전환을 맡는 라이브러리) 같은 본격적인 라우터가 필요하다

복잡한 상태 관리가 필요한 경우

  • 여러 컴포넌트 사이의 복잡한 의존성
  • 시간 여행 디버깅(상태가 바뀐 기록을 되감아 과거 시점 화면으로 돌아가 보는 디버깅), 상태 추적이 필요한 경우
  • → Redux, Vuex, Pinia 같은 상태 관리 라이브러리가 필요하다 (여러 컴포넌트가 함께 쓰는 데이터를 한곳에 모아 두고, 누가 언제 바꿨는지 추적하게 해 주는 도구다)

대량의 DOM 업데이트

  • 수천 개의 항목을 렌더링하는 경우
  • Virtual DOM 최적화가 필요한 경우
  • → React나 Vue의 최적화 기법이 필요하다

TypeScript 강타입이 필요한 경우 — 강타입은 자바처럼 변수마다 타입을 정해 두고, 타입이 안 맞으면 실행 전에 잡아 주는 것을 말한다

  • Alpine.js는 TypeScript를 기본으로 지원하지 않는다
  • JSDoc(자바스크립트 주석에 타입을 적는 방식, 자바의 Javadoc과 비슷하다)으로 일부 보완할 수 있지만 한계가 있다

성능 고려사항

x-show vs x-if 선택:

  • 자주 켜고 끄는 경우: x-show (DOM에 유지, display만 변경)
  • 첫 화면 렌더링 성능이 중요한 경우: x-if (DOM에서 완전히 제거/추가). 이유는 앞의 「2. 조건부 렌더링」에 있다

대량 리스트 렌더링:

  • 100개 이하: 문제 없음
  • 100~500개: 필터링/페이지네이션(한 화면에 일부만 보여 주고 나머지는 다음 페이지로 넘기기) 고려
  • 500개 이상: Virtual Scrolling(화면에 보이는 줄만 실제로 그리고, 스크롤할 때 갈아 끼우는 기법) 또는 다른 프레임워크 고려

Computed Property 작성:

원래 이 자리에는 「중간 변수에 담으면 비효율적이고 메서드 체이닝이 효율적이다」라는 비교가 있었다. 메서드 체이닝은 a.filter(...).sort(...)처럼 메서드가 돌려준 값에 바로 다음 메서드를 점으로 이어 부르는 쓰는 법이다. 그 비교는 틀렸다. 아래 두 코드는 filter → sort → map을 똑같이 한 번씩 돌고, 중간 결과를 변수 이름에 담느냐 마느냐만 다르다. 비용은 같다.

// 중간 변수에 담는다
get filteredUsers() {
    const filtered = this.users.filter(...)
    const sorted = filtered.sort(...)
    const mapped = sorted.map(...)
    return mapped;
}

// 메서드 체이닝 — 하는 일은 위와 같다
get filteredUsers() {
    return this.users
        .filter(...)
        .sort(...)
        .map(...);
}
  • ...는 생략 표시다. 그 자리에 3번 예제의 user => ... 같은 화살표 함수가 들어간다
  • filter: 조건이 참인 항목만 남긴 새 배열을 만든다
  • sort: 배열을 정렬한다
  • map: 항목마다 함수를 적용한 결과로 새 배열을 만든다

둘 중 무엇을 고를지는 읽기 편한 쪽을 고르면 된다. 성능에서 기억할 것은 앞의 3번 예제에서 본 대로 getter는 읽을 때마다 안의 코드를 다시 돈다는 점이다.

Thymeleaf와 함께 쓸 때 주의할 점

속성 충돌 방지:

<!-- 동작하지 않는다 -->
<div th:attr="x-data={ users: ${users} }">

<!-- 이렇게 한다 — 앞의 「초기 데이터 주입」 방식 -->
<script th:inline="javascript">
    const initialUsers = /*[[${users}]]*/ [];
</script>
<div x-data="userManager" x-init="users = initialUsers">

th:attr은 「이 태그에 이름=값 속성을 서버에서 만들어 붙여라」라는 Thymeleaf 속성이다. 그래서 첫 번째는 th:attr 값 전체를 Thymeleaf가 먼저 자기 표현식으로 해석한다. 그런데 { users: ... }는 자바스크립트 객체 문법이라 Thymeleaf가 그대로 받아들인다는 보장이 없다. 해석이 되더라도 ${users}는 JSON 배열이 아니라 자바 List를 글자로 바꾼 값이 들어가 Alpine.js가 읽을 수 없다. 정확히 어떤 오류가 나는지는 실행해 보지 않아 확인하지 않았다. 원래 이 자리의 「올바른 방법」은 x-init="users = [[${usersJson}]]"이었는데, 앞 절에서 본 대로 속성 값 안의 인라인 표현식은 처리되지 않아 <script> 방식으로 바꿨다.

XSS 보안:

<!-- 위험: HTML 인젝션 가능 -->
<div x-html="userInput"></div>

<!-- 안전: 텍스트로만 출력 -->
<div x-text="userInput"></div>

userInput은 사용자가 입력한 글이 담긴 상태 값이라고 가정한다. x-html은 값을 HTML로 해석해 그 안에 넣는다. 사용자가 <script> 같은 태그를 입력하면 그대로 HTML이 되어 다른 사용자의 화면에서 실행될 수 있다(HTML 인젝션). x-text는 값을 글자 그대로만 보여 주므로 태그도 그냥 글자로 보인다.


Alpine.js를 선택해야 하는 경우

서버 사이드 렌더링 기반 프로젝트

  • Spring Boot + Thymeleaf
  • Django + Templates (파이썬 웹 프레임워크와 그 템플릿)
  • Rails + ERB (루비 웹 프레임워크와 그 템플릿)

관리자 페이지, 대시보드

  • 조회성 페이지가 많다
  • CRUD 작업 위주다
  • 복잡한 라우팅이 필요 없다

기존 jQuery 프로젝트의 점진적 마이그레이션 (한 번에 갈아엎지 않고 조금씩 옮기기)

  • jQuery와 함께 쓸 수 있다
  • 페이지별로 조금씩 바꾼다

빌드 도구 없이 빠르게 시작

  • CDN 한 줄로 시작한다
  • 프로토타이핑(제대로 만들기 전에 동작하는 시험판을 빨리 만들어 보기)에 맞다

학습 곡선을 줄이는 것이 중요할 때

  • HTML/CSS/JS만 알면 바로 쓸 수 있다
  • 새 팀원이 익숙해지는 시간(온보딩)이 줄어든다

결론 — 간단한 서버 렌더링 페이지에는 Alpine.js로 충분하다

Alpine.js는 jQuery와 React 사이의 공백을 메우는 라이브러리다. 라이브러리는 내 코드가 필요할 때 불러 쓰는 도구 묶음이고, 프레임워크는 코드를 어디에 어떻게 둘지까지 정해 둔 틀이다. 공식 사이트는 스스로를 프레임워크라고 부르지만, Alpine.js는 내 HTML 구조를 바꾸라고 요구하지 않고 필요한 태그에 속성만 붙여 쓰므로 이 글에서는 라이브러리라고 부른다.

핵심 메시지:

  • 간단한 관리자 페이지에는 Alpine.js가 최적이다
  • Spring + Thymeleaf와 잘 맞는다
  • 빠른 개발, 낮은 학습 곡선, 가벼운 번들 크기
  • 대규모 SPA나 복잡한 상태 관리에는 맞지 않는다

적재적소의 기술 선택이 중요하다. 모든 프로젝트에 React/Vue가 필요한 것은 아니다. Alpine.js로 충분한 경우가 생각보다 많다.


참고 자료


자신만의 철학을 만들어가는 중입니다.
최상단으로 이동했습니다!
확대 이미지

댓글남기기