HDA 시리즈 #1 - HTMX로 부분 업데이트 구현하기
이전 글에서 전통적인 MPA 폼 처리의 한계를 살펴봤다. 이번 글에서는 HTMX를 도입해서 페이지 깜빡임 없이 부분 업데이트를 구현하는 방법을 알아본다.
예제 코드: https://github.com/dev-goraebap/hypermedia-driven-demo/tree/step-01
HTMX 적용하기
HTMX는 CDN으로 간단하게 추가할 수 있다. (설치 문서)
<script src="https://cdn.jsdelivr.net/npm/htmx.org@2.0.8/dist/htmx.min.js"
integrity="sha384-/TgkGk7p307TH7EXJDuUlgG3Ce1UVolAOFopFekQkkXihi5u/6OCvVKyz1W+idaz"
crossorigin="anonymous"></script>
CDN 방식은 빠르게 시작하기에 좋지만, 운영 환경에서는 로컬 파일로 포함시키거나 에셋 파이프라인을 통해 번들링하는 방식을 권장한다. 이 부분은 이후 글에서 다룰 예정이다.
HTMX 기본 속성
폼에 HTMX 속성을 추가해서 AJAX 요청으로 전환한다.
<form hx-post="/todos"
hx-target="#HTMX_TODOLIST"
hx-swap="beforeend"
hx-trigger="submit"
class="w-full flex gap-2">
<input type="text" name="content" placeholder="할일 입력.." />
<button>추가</button>
</form>
<div id="HTMX_TODO_FORM_ERROR"></div>
<ul id="HTMX_TODOLIST">
...
</ul>
다음은 HTMX가 적용된 화면이다.
hx-post
폼 데이터를 지정된 URL로 AJAX POST 요청을 보낸다. 기존 action 속성 대신 사용하며, 페이지 전체를 새로고침하지 않고 비동기로 요청을 처리한다. (공식 문서)
hx-target
서버 응답을 삽입할 대상 요소를 CSS 선택자로 지정한다. 기본값은 요청을 보낸 요소 자신이다. 위 예제에서는 #HTMX_TODOLIST를 타겟으로 지정했다. (공식 문서)
hx-swap
응답을 타겟에 어떻게 삽입할지 결정한다. 기본값은 innerHTML(타겟 내부 교체)이다. (공식 문서)
innerHTML: 타겟 내부를 응답으로 교체 (기본값)outerHTML: 타겟 자체를 응답으로 교체beforeend: 타겟의 마지막 자식으로 추가afterbegin: 타겟의 첫 번째 자식으로 추가beforebegin: 타겟 앞에 추가afterend: 타겟 뒤에 추가
위 예제에서는 beforeend를 사용해 새 Todo 아이템을 목록 끝에 추가한다.
hx-trigger
요청을 발생시킬 이벤트를 지정한다. 요소 타입에 따라 기본값이 다르다. (공식 문서)
input,textarea,select:changeform:submit- 그 외:
click
위 예제에서 hx-trigger="submit"은 form의 기본값이므로 생략해도 동일하게 동작한다. 명시적으로 보여주기 위해 작성했다.
컨트롤러 변경
HTMX 방식에서는 전체 페이지가 아닌 부분 템플릿을 반환한다.
@PostMapping
public String create(
@Valid @ModelAttribute TodoCreateRequest dto,
BindingResult bindingResult,
Model model
) {
if (bindingResult.hasErrors()) {
List<String> errors = bindingResult.getFieldErrors()
.stream().map(x -> x.getField() + ": " + x.getDefaultMessage())
.toList();
model.addAttribute("errors", errors);
return "pages/todos/_createFail";
}
try {
Todo todo = todoService.save(dto.content());
model.addAttribute("todo", todo);
} catch (ResponseStatusException ex) {
model.addAttribute("errors", List.of(ex.getMessage()));
return "pages/todos/_createFail";
}
return "pages/todos/_createSuccess";
}
기존 MPA 방식과 비교하면:
RedirectAttributes대신Model사용- 리다이렉트 대신 부분 템플릿 반환
- 성공 시
_createSuccess, 실패 시_createFail반환
PRG 패턴이 필요 없는 이유
이전 글에서 PRG(Post-Redirect-Get) 패턴이 필요한 이유를 설명했다. POST 요청 후 페이지를 그대로 렌더링하면, 사용자가 새로고침할 때 브라우저가 마지막 요청(POST)을 다시 실행하기 때문이다.
HTMX를 사용하면 이 문제가 발생하지 않는다. HTMX 요청은 AJAX로 처리되어 브라우저의 주소창과 히스토리에 영향을 주지 않는다. 사용자가 새로고침하면 브라우저는 현재 URL의 GET 요청을 실행하므로, POST가 중복 실행되지 않는다.
폼 값 유지가 필요 없는 이유
MPA 방식에서는 에러 발생 시 리다이렉트가 발생하므로 폼 입력값이 사라졌다. 이를 해결하기 위해 Flash Attribute에 DTO를 담아 다시 전달해야 했다.
HTMX 방식에서는 페이지 전체가 새로고침되지 않으므로, 에러가 발생해도 폼의 입력값이 그대로 유지된다. 서버에서 별도로 입력값을 돌려줄 필요가 없다.
부분 템플릿 구조
_item.jte - 재사용 가능한 컴포넌트
단일 Todo 아이템을 렌더링하는 템플릿이다.
@param com.example.demo.app.domain.Todo todo = null
<li class="p-3 border-b flex justify-between">
<p>${todo != null ? todo.getContent() : "" }</p>
</li>
이 템플릿은 두 곳에서 재사용된다.
index.jte: 초기 목록 렌더링 시 반복문에서 사용_createSuccess.jte: 새 아이템 추가 시 사용
@for(var todo: todoList)
@template.pages.todos._item(todo = todo)
@endfor
이처럼 JTE의 템플릿 호출 기능을 활용하면 서버 사이드에서 재사용 가능한 컴포넌트를 만들 수 있다. (JTE 템플릿 문서)
_createSuccess.jte - 성공 응답
@param com.example.demo.app.domain.Todo todo = null
<!-- 에러박스가 표시되었을 수도 있기 때문에 에러박스 초기화 -->
<div id="HTMX_TODO_FORM_ERROR" hx-swap-oob="true"></div>
<!-- 투두 아이템을 Form의 htmx 요청에 따라 #HTMX_TODOLIST의 마지막 자식 요소로 추가 -->
@template.pages.todos._item(todo = todo)
성공 응답은 두 부분으로 구성된다.
- OOB로 에러 박스 초기화 (이전에 에러가 표시됐을 수 있으므로)
- 새 아이템 (메인 타겟에 삽입됨)
폼에 값을 입력하고 제출하면 페이지 깜빡임 없이 목록 끝에 아이템이 추가되는 것을 확인할 수 있다.
_createFail.jte - 실패 응답
@param java.util.List<String> errors = null
<div id="HTMX_TODO_FORM_ERROR" hx-swap-oob="true">
@if(errors!=null && !errors.isEmpty())
<div class="border-l-4 border-red-500 bg-red-300 text-red-700 p-3 rounded-xl mt-4">
@for(var error: errors)
<p>${error}</p>
@endfor
</div>
@endif
</div>
실패 응답은 에러 박스만 OOB로 업데이트한다. 메인 타겟에는 아무것도 추가되지 않는다.
유효성 검사 실패나 중복 데이터 입력 시 에러 메시지가 표시되는 것을 확인할 수 있다. 이때 폼의 입력값은 그대로 유지된다.
에러가 표시된 상태에서 다시 정상적인 값을 입력하면 에러 박스가 사라지고 아이템이 추가된다.
hx-swap-oob 동작 방식
hx-swap-oob="true" 속성이 있는 요소는 메인 타겟이 아닌 페이지의 같은 ID를 가진 요소를 직접 교체한다. 이를 Out of Band swap이라고 한다. (공식 문서)
중요한 점은 OOB 요소는 메인 swap에서 제외된다는 것이다. 따라서 _createFail.jte를 반환해도 #HTMX_TODOLIST에는 아무것도 추가되지 않고, #HTMX_TODO_FORM_ERROR만 교체된다.
정리
HTMX를 도입하면서 달라진 점을 정리하면:
- 페이지 깜빡임 해결: AJAX로 요청하므로 페이지 전체가 새로고침되지 않음
- PRG 패턴 불필요: 브라우저 히스토리에 영향 없음
- 폼 값 자동 유지: 페이지가 유지되므로 입력값이 사라지지 않음
- 부분 업데이트: 변경된 부분만 서버에서 받아 교체
- 컴포넌트 재사용: 템플릿을 분리해서 여러 곳에서 재사용
다음 글에서는 삭제 기능을 이어서 추가하도록 하겠다.
이 글은 Hypermedia-Driven Applications 시리즈의 일부입니다.