HDA 시리즈 #6 - 모달 UX 개선
이전 글에서 서버에서 모달 전체를 렌더링하는 기본 방식을 구현했다. 동작은 하지만 사용자 경험에 아쉬운 부분이 있었다. 이번 글에서는 HTMX의 생명주기 이벤트를 활용해 클라이언트에서 모달을 열고 서버에서 내용만 가져오는 개선된 방식을 구현해본다.
예제 코드: https://github.com/dev-goraebap/hypermedia-driven-demo/tree/step-06
기본 방식의 한계
가장 단순한 모달 구현은 수정 버튼을 클릭하면 서버에서 모달 전체(오버레이 + 박스 + 폼)를 렌더링해서 보내는 것이다. 구현은 간단하지만 사용자 경험에 문제가 있다.
서버 응답이 오래 걸리면 사용자는 수정 버튼을 클릭한 후 아무 반응 없이 기다려야 한다. 일반적인 모달은 클릭 즉시 열리는 것이 자연스러운데, 서버 응답을 기다리는 동안 화면이 멈춰있는 듯한 느낌을 준다. 또한 모달을 닫을 때 부드러운 애니메이션 효과를 주기도 어렵다.
개선 방향
이 문제를 해결하려면 역할을 나눠야 한다.
- 클라이언트: 모달 껍데기(오버레이 + 박스)를 즉시 표시, 애니메이션 처리
- 서버: 모달 내부 콘텐츠(폼)만 응답
HTMX의 생명주기 이벤트를 활용하면 이 구조를 깔끔하게 구현할 수 있다.
모달 컨테이너 구조
먼저 HTML에 모달 껍데기를 미리 준비해둔다. 처음에는 숨겨져 있다가 JavaScript로 표시/숨김을 제어한다.
<!-- partials/modal.jte -->
<div class="hidden" id="modalContainer">
<%-- 모달 오버레이 --%>
<div id="modalOverlay" class="fixed inset-0 bg-black/70" onclick="Modal.close()"></div>
<%-- 모달 박스 --%>
<div id="HTMX_MODAL"
class="fixed left-[50%] translate-x-[-50%] top-[50%] translate-y-[-50%] w-full max-w-md rounded-xl p-4 bg-white">
로딩중...
</div>
</div>
여기서 핵심은 HTMX_MODAL이다. 수정 버튼에서 hx-swap="innerHTML"을 사용하면 서버 응답이 이 영역 안에만 들어간다. 서버는 모달 전체가 아니라 폼 내용만 보내면 된다.
수정 버튼
<button hx-get="/todos/${todo.getId()}/edit"
hx-target="#HTMX_MODAL"
hx-swap="innerHTML"
class="text-xs text-yellow-500">
수정
</button>
서버 응답 - 폼만 보내기
서버의 모달 템플릿은 폼 내용만 포함한다. 오버레이나 모달 박스는 이미 클라이언트에 있으니까.
<%-- edit.jte - 폼 내용만 --%>
@param com.example.demo.app.domain.Todo todo
<div>
<form hx-put="/todos/${todo.getId()}"
hx-target-4xx="#HTMX_TODO_EDIT_ERROR"
hx-disabled-elt="find button"
class="flex flex-col gap-4">
<label for="updateContentInput" class="w-full">
<input id="updateContentInput"
type="text"
name="content"
value="${todo.getContent()}"
placeholder="할일 입력"
class="p-3 border rounded-xl w-full">
</label>
<button class="bg-blue-500 text-white p-3 rounded-xl">
<span class="default">변경</span>
<span class="loading">로딩중..</span>
</button>
</form>
<div id="HTMX_TODO_EDIT_ERROR"></div>
</div>
htmx:beforeRequest로 모달 즉시 열기
이제 핵심인 JavaScript 부분이다. 모달 관련 로직을 담은 modal.js 파일을 src/main/resources/static 폴더에 생성하고, HTML에서 로드한다.
<!-- index.jte의 head 부분 -->
<script type="module" src="/modal.js"></script>
HTMX는 요청을 보내기 전에 htmx:beforeRequest 이벤트를 발생시킨다. 이 이벤트를 감지해서 모달을 즉시 열 수 있다.
// modal.js
class Modal {
static open() {
const container = document.querySelector('#modalContainer');
container.classList.replace('hidden', 'block');
container.animate([
{opacity: 0},
{opacity: 1}
], {duration: 200, easing: 'ease-out'});
}
// ...
}
window.addEventListener('htmx:beforeRequest', (event) => {
// 요청의 target이 HTMX_MODAL이면 모달을 연다
if (event.detail.target.id === 'HTMX_MODAL') {
Modal.open();
}
});
수정 버튼을 클릭하면 HTMX가 서버 요청을 보내기 직전에 이 이벤트가 발생한다. 모달은 즉시 열리고, "로딩중..." 텍스트가 보이다가 서버 응답이 오면 폼으로 교체된다. 사용자 입장에서는 클릭 즉시 반응이 있으니 훨씬 자연스럽다.
참고로 예제에서는 전역 이벤트 리스너를 사용했지만, 특정 요소에만 적용하고 싶다면 hx-on::before-request 속성을 사용할 수도 있다. 다만 모달이나 토스트 메시지처럼 여러 곳에서 공통으로 사용하는 UI는 전역으로 관리하는 게 더 편하다.
htmx:beforeOnLoad로 부드러운 모달 닫기
모달에서 수정을 완료하면 서버는 HX-Location: /todos 헤더로 리다이렉트한다. 문제는 HTMX가 이 헤더를 받으면 즉시 페이지를 이동시켜버려서 모달 닫힘 애니메이션을 보여줄 틈이 없다는 것이다.
htmx:beforeOnLoad 이벤트를 활용하면 응답 처리 전에 개입할 수 있다. Modal 클래스에 close() 메서드를 추가한다.
class Modal {
static open() {
// ... open 구현
}
static close() {
const container = document.querySelector('#modalContainer');
const content = document.querySelector('#HTMX_MODAL');
return new Promise((resolve) => {
container.animate([
{opacity: 1},
{opacity: 0}
], {
duration: 200,
easing: 'ease-out'
}).onfinish = () => {
container.classList.replace('block', 'hidden');
content.innerHTML = '로딩중...';
resolve();
};
});
}
}
close() 메서드는 Promise를 반환한다. 애니메이션이 끝나면 resolve되므로, 호출하는 쪽에서 완료를 기다릴 수 있다.
이제 htmx:beforeOnLoad 이벤트에서 이 메서드를 활용한다.
window.addEventListener('htmx:beforeOnLoad', async (event) => {
const xhr = event.detail.xhr;
const hxLocation = xhr.getResponseHeader('HX-Location');
const isModalOpened = document.querySelector('#modalContainer')?.classList.contains('block');
if (hxLocation && isModalOpened) {
event.preventDefault(); // HTMX의 기본 처리를 막음
await Modal.close(); // 애니메이션 완료까지 대기
htmx.ajax('GET', hxLocation, {target: 'body', swap: 'innerHTML'});
}
});
await Modal.close()로 애니메이션 완료를 기다린 후 리다이렉트를 진행한다.
요청 취소 문제
여기까지 하면 잘 작동하는 것 같지만, 한 가지 문제가 더 있다. 서버 응답을 3초 정도로 늦춰놓고 이런 시나리오를 테스트해보자.
- A 아이템의 수정 버튼 클릭 → 모달 열림
- (응답 오기 전에) 닫기 버튼 클릭 → 모달 닫힘
- B 아이템의 수정 버튼 클릭 → 모달 열림
- A의 응답이 뒤늦게 도착 → B 모달에 A 내용이 표시됨
문제의 원인은 모달을 닫아도 이전 요청이 취소되지 않는다는 것이다. 모달은 닫혔지만 HTMX 요청은 여전히 진행 중이고, 응답이 오면 그대로 화면에 반영해버린다.
hx-trigger 속성 vs htmx.trigger() 함수
해결하기 전에 혼동하기 쉬운 개념을 짚고 가자. HTMX에는 비슷한 이름의 두 가지가 있다.
- hx-trigger 속성: HTML에서 언제 요청을 시작할지 정의 (click, submit 등)
- htmx.trigger() 함수: JavaScript에서 특정 요소에 이벤트를 발생시킴
<!-- hx-trigger: 클릭 시 요청 시작 -->
<button hx-get="/data" hx-trigger="click">
// htmx.trigger(): JavaScript로 이벤트 발생
htmx.trigger(element, 'htmx:abort');
이름만 같고 완전히 다른 개념이다.
htmx:abort로 요청 취소
HTMX는 요청을 시작한 요소와 XMLHttpRequest 객체를 연결해서 관리한다. 해당 요소에 htmx:abort 이벤트를 발생시키면 연결된 요청이 취소된다.
class Modal {
static #currentTrigger = null; // 요청을 시작한 요소 저장
static open(triggerElt) {
this.#currentTrigger = triggerElt;
// 모달 열기...
}
static close() {
// 진행 중인 요청 취소
if (this.#currentTrigger) {
htmx.trigger(this.#currentTrigger, 'htmx:abort');
this.#currentTrigger = null;
}
// 모달 닫기...
}
}
window.addEventListener('htmx:beforeRequest', (event) => {
if (event.detail.target.id === 'HTMX_MODAL') {
Modal.open(event.detail.elt); // 요청 시작한 요소 전달
}
});
open()에서 요청을 시작한 버튼 요소를 저장해두고, close()에서 그 요소에 abort 이벤트를 발생시킨다. 이제 모달을 닫으면 진행 중인 요청도 함께 취소된다.
막간 - 알아두면 좋은 것들
JavaScript 파일 수정이 바로 반영 안 될 때
Spring Boot에서 JavaScript 파일을 수정했는데 브라우저에서 변경이 안 보인다면, 애플리케이션이 빌드된 target 폴더의 파일을 서빙하기 때문이다. 개발 중에 소스 폴더의 파일을 바로 서빙하려면 application.properties에 다음을 추가한다.
spring.web.resources.static-locations=file:src/main/resources/static/,classpath:/static/
Node.js 기반 프레임워크에 익숙하다면 당연히 되는 거 아닌가 싶겠지만, Spring Boot는 기본적으로 컴파일된 결과물을 서빙한다. (그렇다. 필자의 이야기다)
Web Animations API
예제에서 사용한 element.animate()는 Web Animations API로, 모든 모던 브라우저에서 지원된다. 예전에는 CSS 트랜지션이나 jQuery를 썼어야 했는데, 이제는 네이티브 JavaScript로 간단하게 애니메이션을 구현할 수 있다.
다른 방식과의 비교
모달을 여는 방법은 여러 가지가 있다. JavaScript로 직접 모달을 열고 fetch로 데이터를 가져온 뒤 DOM을 조작하는 방식도 가능하다. 하지만 이 방식은 JavaScript의 복잡도가 높아지고, HTMX의 선언적인 특성을 살리기 어렵다.
이번 글에서 다룬 방식은 HTMX의 생명주기 이벤트와 자연스럽게 연계되면서도 필요한 부분만 JavaScript로 제어한다. 개인적으로 이 균형이 개발 경험 측면에서 가장 좋았다.
정리
- htmx:beforeRequest: 요청 전에 모달을 즉시 열어 반응성 확보
- htmx:beforeOnLoad: 응답 처리 전에 개입해 부드러운 모달 닫기
- htmx.trigger(element, 'htmx:abort'): 진행 중인 요청 취소
- 클라이언트에서 모달 껍데기, 서버에서 내용만 응답하는 구조
이 방식은 HTMX를 사용하면서 사용자 경험을 챙기고 싶을 때 꽤 괜찮은 접근이라고 생각한다. JavaScript를 어차피 쓰게 되더라도 HTMX와 잘 연계되는 코드를 짜기 위해 고민을 많이 했는데, 현재는 이 패턴에 만족하면서 쓰고 있다. 실제로 이 블로그도 같은 방식으로 모달을 구현했다. 모달뿐 아니라 토스트 메시지 같은 전역 UI 제어에도 이 패턴을 자주 활용하게 된다.
더 좋은 방법이 생기면 시리즈에 추가로 다룰 예정이다.
다음 글 예고
다음 글에서는 다른 페이지를 만들어보면서 부드러운 페이지 전환에 대해 다룰 예정이다.
이 글은 Hypermedia-Driven Applications 시리즈의 일부입니다.