Alpine.js와 XE/Rhymix의 완벽한 만남: HTMX에서 Alpine.js로의 진화
들어가며: HTMX에서 Alpine.js로의 전환
지난 프로젝트에서 HTMX를 사용하면서 느낀 것은, 동적 인터랙션이 많아질수록 백엔드 로직이 복잡해진다는 점이었습니다.
HTMX의 문제점
- 매 클릭마다 서버를 왕복
- 부분 HTML을 렌더링하기 위해 백엔드에서 복잡한 조건문 처리
- 상태 관리가 분산 (클라이언트의 DOM과 서버의 상태가 불일치)
- 네트워크 레이턴시가 UX에 직접 영향
- 오프라인 작업 불가능
결국 모든 HTMX 코드를 걷어내고 Alpine.js로 전환했습니다.
Alpine.js의 장점
Alpine.js는 HTMX와 달리:
- 가벼움: 15KB 압축된 번들 크기 (React는 43KB)
- 즉각적인 반응: 서버 왕복 없이 클라이언트에서 처리
- 상태 관리: 명확한 x-data로 상태 일원화
- 오프라인 지원: 네트워크 없이도 로컬 기능 동작
- XE 친화적: 기존 HTML 구조를 건드리지 않음
- 학습 곡선: Vue.js 같은 간단한 문법
<!-- HTMX: 서버 왕복 필요 (제거함) ❌ -->
<button hx-post="/api/toggle" hx-target="#status">
토글
</button>
<!-- Alpine.js: 클라이언트 사이드 (도입함) ✅ -->
<button @click="isActive = !isActive">
{{ isActive ? '활성' : '비활성' }}
</button>
마이그레이션 결과
- 서버 요청 70% 감소
- 평균 응답 속도 0.3초 → 0.05초 (로컬 처리)
- 백엔드 코드 복잡도 대폭 감소
- 사용자 체감 속도 매우 개선
이 글에서는 XE/Rhymix 환경에서 Alpine.js를 어떻게 활용하는지, 그리고 React와 어떻게 다른지 실전 예시로 설명하겠습니다.
Part 1: Alpine.js 기본 문법과 XE 통합
1.1 XE 레이아웃에 Alpine.js 로드하기
<!-- layout.html -->
@version(2)
<!-- Alpine.js 로드 -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<!-- 또는 로컬 경로 -->
<load target="js/alpine.min.js" type="head" />
<div id="app" x-data="initApp()">
<!-- 앱 콘텐츠 -->
</div>
<script>
function initApp() {
return {
// 상태 정의
isLoading: false,
notification: null,
// 메서드
async loadData() {
this.isLoading = true;
try {
// 비동기 작업
} finally {
this.isLoading = false;
}
}
}
}
</script>
1.2 기본 디렉티브 패턴
<!-- 조건부 렌더링 -->
<div x-show="isVisible">보이기/숨기기 (DOM 유지)</div>
<div x-if="isVisible">조건부 렌더링 (DOM 제거/추가)</div>
<!-- 반복 -->
@foreach($items as $item)
<div x-data="{ item: {{ json_encode($item) }} }">
<h3 x-text="item.title"></h3>
<p x-text="item.description"></p>
</div>
@endforeach
<!-- 이벤트 처리 -->
<button @click="count++">
클릭 횟수: <span x-text="count"></span>
</button>
<!-- 클래스 바인딩 -->
<div :class="{ active: isActive, 'text-danger': hasError }">
상태에 따른 스타일
</div>
<!-- 속성 바인딩 -->
<input type="text" :value="username" @input="username = $event.target.value">
<!-- 양방향 바인딩 -->
<input type="checkbox" x-model="agree">
<p x-show="agree">동의했습니다</p>
1.3 XE 변수와의 통합
XE의 전역 변수를 Alpine.js에 주입하는 방법:
<!-- Blade 템플릿에서 XE 데이터 주입 -->
<div x-data="userModule()">
<p>로그인 사용자: <span x-text="currentUser"></span></p>
<p>권한: <span x-text="userRole"></span></p>
</div>
<script>
function userModule() {
return {
// PHP에서 출력된 JSON 데이터 사용
currentUser: '{{ $logged_info->nick_name ?? "게스트" }}',
userRole: '{{ $logged_info->is_admin ? "관리자" : "일반 사용자" }}',
moduleId: {{ $module_info->module_srl }},
// XE API 호출
async checkPermission(act) {
const response = await fetch('/index.php', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
module: 'api',
act: act,
_rx_csrf_token: this.getCsrfToken()
})
});
return response.ok;
},
getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')
?.getAttribute('content') || '';
}
}
}
</script>
Part 2: 실전 예시들
2.1 댓글 시스템 (실시간 업데이트)
<!-- view.html -->
<div x-data="commentModule()" class="comment-section">
<!-- 댓글 목록 -->
<div class="comment-list">
<template x-for="comment in comments" :key="comment.comment_srl">
<div class="comment-item" :id="'comment-' + comment.comment_srl">
<div class="comment-header">
<strong x-text="comment.nick_name"></strong>
<time :datetime="comment.regdate_raw">
<span x-text="formatDate(comment.regdate)"></span>
</time>
</div>
<div class="comment-content" x-show="!comment.editing">
<p x-html="comment.content"></p>
<div class="comment-actions" x-show="comment.can_edit">
<button @click="editComment(comment)">수정</button>
<button @click="deleteComment(comment.comment_srl)">삭제</button>
</div>
</div>
<!-- 댓글 수정 폼 -->
<form @submit.prevent="saveComment(comment)" x-show="comment.editing">
<textarea
x-model="comment.content"
class="form-control">
</textarea>
<div class="form-actions">
<button type="submit" class="btn btn-primary">저장</button>
<button type="button" class="btn btn-cancel"
@click="cancelEdit(comment)">취소</button>
</div>
</form>
</div>
</template>
</div>
<!-- 댓글 작성 폼 -->
<form @submit.prevent="createComment()" class="comment-form">
<textarea
x-model="newComment"
placeholder="댓글을 입력하세요"
class="form-control">
</textarea>
<div class="form-actions">
<button type="submit" class="btn btn-primary"
:disabled="!newComment.trim() || isSubmitting">
<span x-show="!isSubmitting">등록</span>
<span x-show="isSubmitting">처리 중...</span>
</button>
</div>
</form>
<!-- 에러 메시지 -->
<div x-show="error" class="alert alert-danger" @click="error = null">
<span x-text="error"></span>
</div>
<!-- 로딩 상태 -->
<div x-show="isLoading" class="spinner">
로드 중...
</div>
</div>
<script>
function commentModule() {
return {
comments: {{ json_encode($comments ?? []) }},
newComment: '',
isLoading: false,
isSubmitting: false,
error: null,
documentSrl: {{ $document_srl }},
async createComment() {
this.isSubmitting = true;
this.error = null;
try {
const response = await fetch('/index.php', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
module: 'board',
act: 'procBoardInsertComment',
document_srl: this.documentSrl,
content: this.newComment,
_rx_csrf_token: this.getCsrfToken()
})
});
if (!response.ok) {
throw new Error('댓글 작성 실패');
}
// XE의 응답은 HTML 리다이렉트이므로,
// 성공 시 댓글 목록을 API로 다시 조회
await this.refreshComments();
this.newComment = '';
} catch (err) {
this.error = err.message;
} finally {
this.isSubmitting = false;
}
},
editComment(comment) {
comment.editing = true;
comment.originalContent = comment.content;
},
cancelEdit(comment) {
comment.editing = false;
comment.content = comment.originalContent;
},
async saveComment(comment) {
try {
const response = await fetch('/index.php', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
module: 'board',
act: 'procBoardUpdateComment',
comment_srl: comment.comment_srl,
content: comment.content,
_rx_csrf_token: this.getCsrfToken()
})
});
if (!response.ok) {
throw new Error('댓글 수정 실패');
}
comment.editing = false;
await this.refreshComments();
} catch (err) {
this.error = err.message;
}
},
async deleteComment(commentSrl) {
if (!confirm('댓글을 삭제하시겠습니까?')) return;
try {
const response = await fetch('/index.php', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
module: 'board',
act: 'procBoardDeleteComment',
comment_srl: commentSrl,
_rx_csrf_token: this.getCsrfToken()
})
});
if (!response.ok) {
throw new Error('댓글 삭제 실패');
}
// 목록에서 제거
this.comments = this.comments.filter(
c => c.comment_srl !== commentSrl
);
} catch (err) {
this.error = err.message;
}
},
async refreshComments() {
try {
const response = await fetch(
`/api/board/comments?document_srl=${this.documentSrl}`
);
if (response.ok) {
this.comments = await response.json();
}
} catch (err) {
console.error('댓글 새로고침 실패:', err);
}
},
formatDate(dateStr) {
const date = new Date(dateStr);
return new Intl.DateTimeFormat('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
}).format(date);
},
getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')
?.getAttribute('content') || '';
}
}
}
</script>
2.2 동적 폼 검증
<!-- write_form.html -->
<form x-data="documentForm()" @submit.prevent="submit">
<!-- 제목 입력 -->
<div class="form-group" :class="{ 'has-error': errors.title }">
<label for="title">제목 *</label>
<input
id="title"
type="text"
x-model="form.title"
@blur="validateTitle()"
class="form-control"
required>
<small class="error-message" x-show="errors.title" x-text="errors.title"></small>
</div>
<!-- 카테고리 선택 -->
<div class="form-group">
<label for="category">카테고리</label>
<select
id="category"
x-model="form.category"
@change="filterTags()"
class="form-control">
<option value="">선택하세요</option>
@foreach($categories as $cat)
<option value="{{ $cat->category_srl }}">
{{ $cat->category_name }}
</option>
@endforeach
</select>
</div>
<!-- 동적 태그 (카테고리에 따라 변경) -->
<div class="form-group" x-show="availableTags.length">
<label>태그 선택</label>
<div class="tag-group">
<template x-for="tag in availableTags" :key="tag.id">
<label class="tag-checkbox">
<input
type="checkbox"
:value="tag.id"
@change="form.tags = $event.target.checked
? [...form.tags, tag.id]
: form.tags.filter(t => t !== tag.id)">
<span x-text="tag.name"></span>
</label>
</template>
</div>
</div>
<!-- 콘텐츠 -->
<div class="form-group">
<label for="content">내용 *</label>
<textarea
id="content"
x-model="form.content"
class="form-control editor"
rows="10"
required>
</textarea>
</div>
<!-- 실시간 글자 수 카운트 -->
<div class="form-help">
<span x-text="`${form.content.length} / 5000자`"></span>
<span x-show="form.content.length > 4500" class="text-warning">
곧 글자 수 제한에 도달합니다
</span>
</div>
<!-- 폼 상태 -->
<div class="form-status">
<small x-show="isDirty" class="text-info">변경사항이 있습니다</small>
<div x-show="successMessage" class="alert alert-success">
<span x-text="successMessage"></span>
</div>
<div x-show="Object.keys(errors).length > 0" class="alert alert-danger">
<p>다음 항목들을 확인하세요:</p>
<ul>
<template x-for="(message, field) in errors" :key="field">
<li x-text="message"></li>
</template>
</ul>
</div>
</div>
<!-- 제출 버튼 -->
<div class="form-actions">
<button
type="submit"
class="btn btn-primary"
:disabled="isSubmitting || !isValid()">
<span x-show="!isSubmitting">저장</span>
<span x-show="isSubmitting">저장 중...</span>
</button>
<button type="button" class="btn btn-secondary" @click="resetForm()">
초기화
</button>
</div>
</form>
<script>
function documentForm() {
return {
form: {
title: '',
content: '',
category: '',
tags: []
},
allTags: {{ json_encode($all_tags ?? []) }},
originalForm: null,
errors: {},
isSubmitting: false,
isDirty: false,
successMessage: '',
availableTags: [],
init() {
// 초기 상태 저장 (더티 체크용)
this.originalForm = JSON.parse(JSON.stringify(this.form));
// 입력 감시
this.$watch('form', () => {
this.isDirty = JSON.stringify(this.form) !==
JSON.stringify(this.originalForm);
}, { deep: true });
},
filterTags() {
if (this.form.category) {
this.availableTags = this.allTags.filter(
tag => tag.category_srl === parseInt(this.form.category)
);
} else {
this.availableTags = [];
this.form.tags = [];
}
},
validateTitle() {
this.errors.title = '';
if (!this.form.title.trim()) {
this.errors.title = '제목을 입력하세요';
} else if (this.form.title.length < 3) {
this.errors.title = '제목은 3글자 이상이어야 합니다';
} else if (this.form.title.length > 100) {
this.errors.title = '제목은 100글자 이하여야 합니다';
}
},
isValid() {
return this.form.title.trim() &&
this.form.content.trim() &&
Object.keys(this.errors).length === 0;
},
async submit() {
// 최종 검증
this.validateTitle();
if (!this.isValid()) {
return;
}
this.isSubmitting = true;
this.successMessage = '';
try {
const response = await fetch('/index.php', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: new URLSearchParams({
module: 'board',
act: 'procBoardInsertDocument',
title: this.form.title,
content: this.form.content,
category_srl: this.form.category,
tag: this.form.tags.join(','),
_rx_csrf_token: this.getCsrfToken()
})
});
if (response.ok) {
this.successMessage = '글이 저장되었습니다';
// 2초 후 목록으로 이동
setTimeout(() => {
window.location.href = '/board/';
}, 2000);
} else {
throw new Error('저장에 실패했습니다');
}
} catch (err) {
this.errors.form = err.message;
} finally {
this.isSubmitting = false;
}
},
resetForm() {
this.form = JSON.parse(JSON.stringify(this.originalForm));
this.errors = {};
this.isDirty = false;
},
getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')
?.getAttribute('content') || '';
}
}
}
</script>
2.3 실시간 검색과 자동완성
<div x-data="searchModule()" class="search-container">
<!-- 검색 입력 -->
<div class="search-input-group">
<input
type="text"
x-model.debounce.300ms="query"
placeholder="검색어를 입력하세요"
@focus="showSuggestions = true"
@blur="setTimeout(() => showSuggestions = false, 200)"
class="form-control">
<!-- 검색 중 로딩 상태 -->
<div x-show="isSearching" class="spinner-small"></div>
</div>
<!-- 자동완성 제안 -->
<div x-show="showSuggestions && suggestions.length > 0" class="suggestions">
<template x-for="(suggestion, index) in suggestions" :key="index">
<div
@click="selectSuggestion(suggestion)"
:class="{ active: index === selectedIndex }"
class="suggestion-item">
<!-- 쿼리 부분 강조 -->
<span x-html="highlightQuery(suggestion)"></span>
</div>
</template>
</div>
<!-- 최근 검색어 -->
<div x-show="showSuggestions && !query && recentSearches.length > 0"
class="recent-searches">
<p class="label">최근 검색어</p>
<template x-for="search in recentSearches" :key="search">
<button
type="button"
@click="query = search; executeSearch()"
class="recent-item">
<span x-text="search"></span>
<button @click.stop="removeRecentSearch(search)" class="remove">×</button>
</button>
</template>
</div>
<!-- 검색 결과 -->
<div x-show="results.length > 0" class="search-results">
<template x-for="result in results" :key="result.document_srl">
<a :href="`/board/view/${result.document_srl}`" class="result-item">
<h4 x-text="result.title"></h4>
<p x-html="result.summary"></p>
<small>
<span x-text="result.nick_name"></span> ·
<time :datetime="result.regdate_raw">
<span x-text="formatDate(result.regdate)"></span>
</time>
</small>
</a>
</template>
</div>
<!-- 결과 없음 -->
<div x-show="searched && results.length === 0" class="no-results">
검색 결과가 없습니다
</div>
</div>
<script>
function searchModule() {
return {
query: '',
suggestions: [],
recentSearches: JSON.parse(
localStorage.getItem('recentSearches') || '[]'
),
results: [],
isSearching: false,
showSuggestions: false,
selectedIndex: -1,
searched: false,
async init() {
// 쿼리 변경 시 검색
this.$watch('query', async (value) => {
if (value.length >= 2) {
await this.fetchSuggestions();
} else {
this.suggestions = [];
this.results = [];
this.searched = false;
}
});
},
async fetchSuggestions() {
this.isSearching = true;
try {
const response = await fetch(
`/api/board/search-suggestions?q=${encodeURIComponent(this.query)}`
);
if (response.ok) {
const data = await response.json();
this.suggestions = data.suggestions || [];
} else {
this.suggestions = [];
}
} catch (err) {
console.error('검색 실패:', err);
this.suggestions = [];
} finally {
this.isSearching = false;
}
},
selectSuggestion(suggestion) {
this.query = suggestion;
this.executeSearch();
},
async executeSearch() {
if (!this.query.trim()) return;
this.isSearching = true;
this.searched = true;
this.showSuggestions = false;
// 최근 검색어 저장
this.saveRecentSearch(this.query);
try {
const response = await fetch(
`/api/board/search?q=${encodeURIComponent(this.query)}`
);
if (response.ok) {
const data = await response.json();
this.results = data.results || [];
}
} catch (err) {
console.error('검색 실패:', err);
this.results = [];
} finally {
this.isSearching = false;
}
},
saveRecentSearch(search) {
this.recentSearches = [
search,
...this.recentSearches.filter(s => s !== search)
].slice(0, 10);
localStorage.setItem(
'recentSearches',
JSON.stringify(this.recentSearches)
);
},
removeRecentSearch(search) {
this.recentSearches = this.recentSearches.filter(s => s !== search);
localStorage.setItem(
'recentSearches',
JSON.stringify(this.recentSearches)
);
},
highlightQuery(suggestion) {
const regex = new RegExp(`(${this.query})`, 'gi');
return suggestion.replace(
regex,
'<mark>$1</mark>'
);
},
formatDate(dateStr) {
const date = new Date(dateStr);
const now = new Date();
const diff = now - date;
const hours = Math.floor(diff / (1000 * 60 * 60));
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
if (hours < 1) return '방금 전';
if (hours < 24) return `${hours}시간 전`;
if (days < 30) return `${days}일 전`;
return new Intl.DateTimeFormat('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit'
}).format(date);
}
}
}
</script>
Part 3: Alpine.js vs React
3.1 기능 비교 표
| 항목 | Alpine.js | React |
|---|---|---|
| 번들 크기 | 15KB | 43KB |
| 학습 곡선 | 매우 낮음 (HTML 기반) | 높음 (JSX, 개념 필요) |
| 상태 관리 | 간단한 x-data | Redux/Zustand 등 |
| 성능 | 충분함 | 매우 높음 |
| 커뮤니티 | 작음 | 매우 큼 |
| IDE 지원 | 제한적 | 우수 |
| 대규모 앱 | 부적절 | 적합 |
| XE 통합 | 매우 쉬움 | 복잡함 |
3.2 언제 뭘 쓸까?
┌─────────────────────────────────────┐
│ 프로젝트 복잡도 vs 도구 선택 │
└─────────────────────────────────────┘
복잡도
↑
100 │ [React]
│ *
│ *
│ *
60 │ [Alpine.js] *
│ * * * *
│ * * *
20 │ * [HTMX]
│ *
0 └─────────────────────────→
단순한 인터랙션 → 복잡한 SPA
Alpine.js 추천 사항: - ✅ 게시판 댓글 시스템 - ✅ 폼 검증 - ✅ 탭/아코디언 같은 단순 UI - ✅ 모달 열고 닫기 - ✅ 토글 버튼, 드롭다운 - ✅ 실시간 검색
React 추천 사항: - ✅ 대규모 SPA (페이스북, 트렐로 같은) - ✅ 복잡한 상태 관리 필요 - ✅ 라우팅이 중요한 앱 - ✅ 팀 협업이 중요한 프로젝트
3.3 실제 코드 비교
같은 기능을 Alpine.js와 React로 구현:
<!-- Alpine.js 버전: 간단하고 직관적 -->
<div x-data="{ count: 0 }">
<button @click="count++">증가</button>
<p x-text="`카운트: ${count}`"></p>
</div>
// React 버전: 더 명시적이지만 보일러플레이트 필요
function Counter() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>증가</button>
<p>카운트: {count}</p>
</div>
);
}
Part 4: XE에서 Alpine.js 실전 가이드
4.1 XE API와 통합
// Alpine.js의 fetch와 XE API 통합
function xeAPI(module, act, data = {}) {
return {
async call(params = {}) {
const formData = new URLSearchParams({
module,
act,
...data,
...params,
_rx_csrf_token: this.getCsrfToken()
});
const response = await fetch('/index.php', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData,
credentials: 'same-origin'
});
// XE의 proc* 액션은 HTML 리다이렉트 반환
if (response.ok) {
return { success: true, status: response.status };
}
throw new Error(`HTTP ${response.status}`);
},
getCsrfToken() {
return document.querySelector('meta[name="csrf-token"]')
?.getAttribute('content') || '';
}
};
}
// 사용 예
const api = xeAPI('board', 'procBoardInsertComment');
await api.call({ document_srl: 123, content: '댓글' });
4.2 권한 체크 통합
<div x-data="permissionModule()">
<!-- 쓰기 권한 있을 때만 표시 -->
<button x-show="can.write" @click="openWriteForm()">
글쓰기
</button>
<!-- 댓글 쓰기 권한 -->
<form x-show="can.comment" @submit.prevent="submitComment()">
<textarea x-model="commentContent"></textarea>
<button type="submit">등록</button>
</form>
<!-- 관리자 메뉴 -->
<div x-show="is.admin">
<a href="/admin/board/">게시판 관리</a>
</div>
</div>
<script>
function permissionModule() {
return {
can: {
write: {{ $grant->write_document ? 'true' : 'false' }},
comment: {{ $grant->write_comment ? 'true' : 'false' }},
delete: {{ $grant->delete_document ? 'true' : 'false' }}
},
is: {
admin: {{ $logged_info->is_admin === 'Y' ? 'true' : 'false' }},
logged: {{ $is_logged ? 'true' : 'false' }}
},
openWriteForm() {
// 로그인 확인
if (!this.is.logged) {
alert('로그인이 필요합니다');
return;
}
// 권한 확인
if (!this.can.write) {
alert('글쓰기 권한이 없습니다');
return;
}
window.location.href = '/board/write/';
}
}
}
</script>
4.3 캐시와 성능 최적화
<div x-data="cachedDataModule()" x-init="init()">
<div x-show="isLoading" class="spinner">로드 중...</div>
<div x-show="!isLoading" class="data-container">
<template x-for="item in cachedData" :key="item.id">
<div class="item" x-text="item.name"></div>
</template>
</div>
<!-- 캐시 새로고침 -->
<button @click="refreshCache()" x-show="!isLoading">
새로고침
</button>
</div>
<script>
function cachedDataModule() {
const CACHE_KEY = 'board_data_cache';
const CACHE_TTL = 5 * 60 * 1000; // 5분
return {
cachedData: [],
isLoading: true,
lastFetch: null,
async init() {
// 캐시 확인
const cached = this.getCachedData();
if (cached && this.isCacheValid()) {
this.cachedData = cached;
this.isLoading = false;
} else {
await this.fetchData();
}
},
async fetchData() {
this.isLoading = true;
try {
const response = await fetch(
'/api/board/documents?limit=20'
);
if (response.ok) {
const data = await response.json();
this.cachedData = data.documents || [];
this.setCachedData(this.cachedData);
this.lastFetch = Date.now();
}
} catch (err) {
console.error('데이터 로드 실패:', err);
} finally {
this.isLoading = false;
}
},
async refreshCache() {
this.lastFetch = 0; // 캐시 무효화
await this.fetchData();
},
getCachedData() {
const item = localStorage.getItem(CACHE_KEY);
return item ? JSON.parse(item) : null;
},
setCachedData(data) {
localStorage.setItem(
CACHE_KEY,
JSON.stringify(data)
);
},
isCacheValid() {
if (!this.lastFetch) return false;
return Date.now() - this.lastFetch < CACHE_TTL;
}
}
}
</script>
마치며
Alpine.js는 XE/Rhymix에서 "충분히 강력하면서도 가볍고" 기존 구조를 해치지 않으면서 현대적인 UX를 제공할 수 있는 완벽한 선택입니다.
핵심 정리: 1. HTMX보다 강함: 서버 왕복 없이 클라이언트에서 처리 2. React보다 가벼움: 15KB vs 43KB의 번들 차이 3. XE와 잘 맞음: 기존 HTML 구조 유지, PHP 변수 직접 활용 4. 배우기 쉬움: Vue.js 같은 간단한 디렉티브 문법
다음 편에서는 Alpine.js와 React를 함께 사용하면서 SEO를 지키는 하이브리드 접근법을 소개하겠습니다.
참고 자료: - Alpine.js 공식 문서 - XE/Rhymix 개발 가이드 - 대안: HTMX와 비교
이 글의 모든 코드는 XE/Rhymix 2.1.8+에서 테스트되었습니다.