HDA 시리즈 #5 - 모달을 활용한 수정 기능
이번 글에서는 모달을 활용한 수정 기능을 구현한다. 모달은 HDA에서 구현 방식이 다양해서 고민이 많았던 부분이다. 이번 글에서는 가장 단순한 방식인 "서버에서 모달 전체를 렌더링"하는 방법을 먼저 다루고, 그 한계점과 개선 방향을 살펴본다.
예제 코드: https://github.com/dev-goraebap/hypermedia-driven-demo/tree/step-05
모달 컨테이너 준비
먼저 index.jte에 모달이 렌더링될 컨테이너를 준비한다.
<!-- 일반적인 4xx 에러들은 이 에러 모달을 사용하게 됨 -->
<div id="HTMX_DEFAULT_ERROR_MODAL"></div>
<!-- 수정 모달이 렌더링될 위치 -->
<div id="HTMX_EDIT_MODAL"></div>
이 빈 컨테이너에 서버에서 렌더링한 모달 템플릿이 들어가게 된다.
수정 버튼
각 투두 아이템에 수정 버튼을 추가한다.
<li class="p-3 border-b flex justify-between">
<p>${todo.getContent()}</p>
<div class="flex gap-2">
<button hx-get="/todos/${todo.getId()}/edit"
hx-target="#HTMX_EDIT_MODAL"
hx-swap="innerHTML"
class="text-xs text-yellow-500">
수정
</button>
<button hx-delete="/todos/${todo.getId()}"
class="text-xs text-red-500">
삭제
</button>
</div>
</li>
수정 버튼을 클릭하면 hx-get으로 서버에 요청을 보내고, 응답으로 받은 HTML을 #HTMX_EDIT_MODAL에 삽입한다.
모달 템플릿
서버에서 렌더링하는 모달 템플릿 edit.jte를 살펴보자.
@param com.example.demo.app.domain.Todo todo
<div>
<%-- 모달 오버레이 --%>
<div class="fixed inset-0 bg-black/70" onclick="this.parentNode.remove()"></div>
<%-- 모달 박스 --%>
<div class="fixed left-[50%] translate-x-[-50%] top-[50%] translate-y-[-50%]
w-full max-w-md rounded-xl p-4 bg-white">
<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>
</div>
모달 템플릿의 주요 특징:
- 오버레이 클릭 시
this.parentNode.remove()로 모달을 닫는다. hx-put으로 PUT 요청을 보낸다.value="${todo.getContent()}"로 기존 값을 input에 채워넣는다.hx-target-4xx="#HTMX_TODO_EDIT_ERROR"로 에러를 모달 내부에 표시한다.
컨트롤러 구현
수정 폼을 보여주는 edit과 실제 수정을 처리하는 update 메서드를 구현한다.
@GetMapping("{id}/edit")
public String edit(
@PathVariable String id,
Model model
) {
Todo todo = todoService.getTodo(id);
model.addAttribute("todo", todo);
return "pages/todos/edit";
}
@PutMapping("{id}")
@ErrorTemplate("pages/todos/_editFail")
public ResponseEntity<Void> update(
@PathVariable String id,
@Valid @ModelAttribute TodoFormRequest dto
) throws InterruptedException {
Thread.sleep(3000L);
todoService.update(id, dto.content());
return ResponseEntity.ok()
.header("HX-Location", "/todos")
.build();
}
edit은 해당 투두를 조회해서 모달 템플릿에 전달한다. update는 수정을 처리하고 HX-Location으로 목록 페이지로 리다이렉트한다.
DTO 재사용
수정 요청의 형식이 생성 요청과 동일하므로, 기존 TodoCreateRequest를 TodoFormRequest로 이름을 변경하여 공유한다.
public record TodoFormRequest(
@NotBlank
@Length(min = 2, max = 30)
String content
) {
}
서비스 구현
public void update(String id, String content) {
Todo todo = todoList.stream()
.filter(t -> t.getId().equals(id))
.findFirst()
.orElseThrow(() -> new ResponseStatusException(
HttpStatus.NOT_FOUND, "존재하지 않는 할 일입니다"));
todo.update(content);
}
ID로 투두를 찾아 내용을 업데이트한다. 존재하지 않으면 404 에러를 던진다.
에러 처리
유효성 검증 실패 시 _editFail.jte가 렌더링된다.
@param java.util.List<String> errors = null
<div id="HTMX_TODO_EDIT_ERROR" hx-swap-oob="true">
@if(errors != null && !errors.isEmpty())
<div class="bg-red-300 text-red-700 p-3 rounded-xl mt-4">
@for(var error: errors)
<p>${error}</p>
@endfor
</div>
@endif
</div>
hx-swap-oob="true"로 모달 내부의 에러 영역만 교체한다.
모달 닫기
수정이 성공하면 HX-Location: /todos 헤더로 페이지를 리다이렉트한다. 목록 페이지가 다시 렌더링되면서 #HTMX_EDIT_MODAL이 빈 상태로 초기화되므로, 모달이 자연스럽게 사라진다.
이 방식의 한계
구현은 간단하지만, 사용자 경험 측면에서 문제가 있다.
모달 자체가 서버 요청이다보니, 서버 응답이 오래 걸릴수록 사용자는 수정 버튼을 클릭한 후 모달이 뜨기까지 화면이 멈춰있는 듯한 느낌을 받는다. 이후 섹션에서 다룰 상단 프로그레스바로 어느 정도 완화할 수 있지만, 일반적인 모달의 사용자 경험과는 맞지 않다.
개선 방향
이 문제를 해결하려면 모달 오버레이 레이아웃은 클라이언트에서 즉시 띄우고, 내용만 서버에서 가져와야 한다.
SPA 프레임워크에서는 마크업을 작성하고 API를 호출해서 데이터를 받아 바인딩하면 된다. 하지만 서버 측 SSR에서는 생각을 달리해야 한다.
정리
- 서버에서 모달 전체 렌더링: 가장 단순한 구현 방식
- hx-put: PUT 메서드로 수정 요청
- HX-Location: 성공 시 페이지 리다이렉트로 모달 자동 닫힘
- 한계점: 서버 응답까지 모달이 열리지 않아 UX 저하
- 모달을 닫을 때 부드러운 CSS 효과를 주기 매우 어려움
다음 글에서는 자바스크립트를 적당히 섞어서 클라이언트측과 서버측의 작업을 나누는 방식으로 모달 기능을 개선할 예정이다.
이 글은 Hypermedia-Driven Applications 시리즈의 일부입니다.