프로필

데브고래밥

@devgoraebap

스택오버플로우의 단골손님이였던 Claude를 채찍질하는 개발자

Album Art

0:00 0:00
방문자 정보

요즘 관심있는

HDA 시리즈 #4 - UX 개선: 로딩 상태와 실시간 검증 thumbnail image
38
0

HDA 시리즈 #4 - UX 개선: 로딩 상태와 실시간 검증

이전 글에서는 HX-Location@ControllerAdvice로 서버 코드를 정리했다. 이번 글에서는 클라이언트 측 사용자 경험을 개선한다. 요청 중 버튼 비활성화, 로딩 인디케이터, 실시간 중복 체크를 구현해본다.

예제 코드: https://github.com/dev-goraebap/hypermedia-driven-demo/tree/step-04

요청 중 버튼 비활성화

폼 제출 중에 버튼을 연속 클릭하면 중복 요청이 발생할 수 있다. HTMX의 hx-disabled-elt 속성을 사용하면 요청 중에 특정 요소를 비활성화할 수 있다.

<form hx-post="/todos"
      hx-target-4xx="#HTMX_TODO_FORM_ERROR"
      hx-disabled-elt="find button">
    <input type="text" name="content" placeholder="할일 입력..">
    <button>추가</button>
</form>

find button은 폼 내부의 버튼을 찾아 요청 중에 disabled 속성을 추가한다. 요청이 완료되면 자동으로 해제된다.

로딩 효과 확인을 위한 지연 추가

로딩 효과를 눈으로 확인하려면 서버 응답이 어느 정도 지연되어야 한다. 개발 중에는 컨트롤러에 임의의 지연을 추가해볼 수 있다.

@PostMapping
@ErrorTemplate("pages/todos/_createFail")
public ResponseEntity<Void> create(@Valid @ModelAttribute TodoCreateRequest dto) throws InterruptedException {
    Thread.sleep(3000L); // 3초 지연

    todoService.save(dto.content());
    return ResponseEntity.ok()
            .header("HX-Location", "/todos")
            .build();
}

Thread.sleep()InterruptedException을 던질 수 있으므로 메서드 시그니처에 선언하거나 try-catch로 감싸야 한다. 물론 실제 배포 시에는 제거해야 한다.

로딩 인디케이터

HTMX는 요청이 진행 중일 때 요청을 트리거한 요소의 부모에 htmx-request 클래스를 자동으로 추가한다. 이를 활용해 CSS만으로 로딩 상태를 표현할 수 있다.

<style>
    /* 기본 상태: 로딩 텍스트 숨김 */
    button span.loading {
        display: none;
    }

    /* 요청 중: 기본 텍스트 숨김 */
    .htmx-request button span.default {
        display: none;
    }

    /* 요청 중: 로딩 텍스트 표시 */
    .htmx-request button span.loading {
        display: inline;
    }
</style>

<form hx-post="/todos"
      hx-target-4xx="#HTMX_TODO_FORM_ERROR"
      hx-disabled-elt="find button">
    <input type="text" name="content" placeholder="할일 입력..">
    <button>
        <span class="default">추가</span>
        <span class="loading">로딩중..</span>
    </button>
</form>

요청중에 form 요소에 htmx-request 클래스가 생성되므로 JavaScript 없이 CSS만으로 로딩 상태 전환이 가능하다.

실시간 중복 체크

사용자가 입력할 때마다 서버에 중복 여부를 확인하는 기능을 추가한다. 매 키 입력마다 요청하면 서버에 부담이 되므로 디바운싱을 적용한다.

<input id="contentInput"
       hx-get="/todos/check-duplicate"
       hx-trigger="keyup changed delay:500ms"
       hx-target="#HTMX_DUPLICATE_CHECK_BOX"
       hx-target-4xx="#HTMX_DUPLICATE_CHECK_BOX"
       hx-swap="outerHTML"
       hx-disabled-elt="#HTMX_TODO_ADD_BTN"
       type="text"
       name="content">

<div id="HTMX_DUPLICATE_CHECK_BOX"></div>

주요 속성들을 살펴보자.

  • hx-get="/todos/check-duplicate": GET 요청을 보낸다. input의 name이 content이므로 자동으로 ?content=입력값 쿼리스트링이 추가된다.
  • hx-trigger="keyup changed delay:500ms": keyup 이벤트 발생 후 값이 변경되었고(changed), 500ms 동안 추가 입력이 없을 때(delay) 요청을 보낸다.
  • hx-targethx-target-4xx: 성공/실패 응답 모두 같은 영역에 렌더링한다.
  • hx-disabled-elt="#HTMX_TODO_ADD_BTN": 중복 체크 요청 중에 추가 버튼을 비활성화한다.

조건부 트리거

hx-trigger에는 이벤트 필터 조건을 추가할 수 있다. 예를 들어 최소 2자 이상일 때만 요청을 보내고 싶다면:

hx-trigger="keyup[target.value.length >= 2] changed delay:500ms"

대괄호 [] 안에 JavaScript 표현식을 작성하면, 조건이 참일 때만 이벤트가 발생한다. 필터는 이벤트 이름 바로 뒤에 위치해야 하고, changeddelay 같은 modifier는 그 뒤에 온다.

다만 주의할 점이 있다. 필터 조건이 거짓이면 이벤트 자체가 무시되어 이전에 예약된 요청도 취소되지 않는다. 예를 들어 "abc"를 입력하고 500ms 내에 전부 지우면, 조건이 거짓이 되어 새 이벤트는 무시되지만 이전 타이머는 그대로 실행된다. 이런 엣지 케이스는 서버에서 빈 값을 적절히 처리하거나, 별도의 JavaScript로 해결해야 한다.

서버 측 컨트롤러는 다음과 같다.

@GetMapping("check-duplicate")
@ErrorTemplate("pages/todos/_duplicateCheckBox")
public String checkDuplicate(
        @Valid @ModelAttribute TodoCheckDuplicateRequest request,
        Model model
) {
    boolean result = todoService.checkDuplicate(request.content());
    model.addAttribute("isDuplicated", result);
    return "pages/todos/_duplicateCheckBox";
}

Out of Band Swap

이전에도 OOB에 대해 언급했는데, 상황에 따라 유용하게 쓰이므로 다시한번 다룬다.

여기서 한 가지 요구사항이 있다. 중복이 감지되었을 때 "추가" 버튼도 비활성화하고 싶다. 하지만 중복 체크 응답은 #HTMX_DUPLICATE_CHECK_BOX 영역만 교체하는데, 버튼은 그 바깥에 있다.

프론트엔드 프레임워크에서는 상태 관리를 통해 이런 "다른 영역의 상태를 변경하는" 문제를 해결한다. HTMX에서는 Out of Band(OOB) Swap이 이 역할을 한다.

OOB Swap은 응답에 hx-swap-oob="true" 속성이 있는 요소가 포함되면, hx-target과 관계없이 해당 요소를 DOM에서 같은 ID를 가진 요소와 교체한다.

@param java.util.List<String> errors = null
@param boolean isDuplicated = false

<div id="HTMX_DUPLICATE_CHECK_BOX">
    <%-- 유효성 검증에서 에러가 나면 여기 표시 --%>
    @if(errors != null && !errors.isEmpty())
        @for(var error: errors)
            <span class="text-xs text-red-500">${error}</span>

            <%-- 클릭할 수 없는 버튼으로 OOB 교체 --%>
            <button id="HTMX_TODO_ADD_BTN"
                    hx-swap-oob="true"
                    disabled="disabled"
                    class="p-3 bg-blue-500 text-white flex-4 rounded-xl disabled:opacity-75">
                추가
            </button>
        @endfor
    <%-- 유효성을 통과했다면 정상적으로 중복여부 표시 --%>
    @else
        @if(isDuplicated)
            <span class="text-xs text-red-500">중복되는 컨텐츠입니다.</span>

            <%-- 클릭할 수 없는 버튼으로 OOB 교체 --%>
            <button id="HTMX_TODO_ADD_BTN"
                    hx-swap-oob="true"
                    disabled="disabled"
                    class="p-3 bg-blue-500 text-white flex-4 rounded-xl disabled:opacity-75">
                추가
            </button>
        @else
            <span class="text-xs text-blue-500">등록 가능한 컨텐츠 입니다.</span>

            <%-- 정상적인 버튼 반환 --%>
            <button id="HTMX_TODO_ADD_BTN"
                    hx-swap-oob="true"
                    class="p-3 bg-blue-500 text-white flex-4 rounded-xl disabled:opacity-75">
                <span class="default">추가</span>
                <span class="loading">로딩중..</span>
            </button>
        @endif
    @endif
</div>

버튼 마크업이 템플릿에 중복되는 게 신경 쓰일 수 있다. JTE의 @template으로 컴포넌트화할 수 있지만, 예제의 이해를 위해 풀어서 작성했다.

hx-disabled-elt에서 find button 대신 #HTMX_TODO_ADD_BTN처럼 ID를 직접 지정하는 이유도 OOB Swap 때문이다. OOB로 교체하려면 고유한 ID가 필요하다. 

이번 예제에 완성된 화면은 다음과 같다.

Alpine.js에 대하여

이번 단계에서 구현한 "다른 영역의 상태 제어"는 Alpine.js 같은 경량 JavaScript 라이브러리를 사용하면 더 간단하게 처리할 수 있다. Alpine.js는 SSR과 궁합이 좋고, HTMX와 함께 자주 사용된다. 

해당 시리즈에서 alpinejs를 다루진 않을 예정이다..

어떤 방식이 더 나은지는 상황에 따라 다르다. HDA 방식의 일관성을 중시한다면 OOB Swap처럼 서버가 UI 상태를 제어하는 방식이 맞고, 클라이언트 인터랙션이 복잡해진다면 Alpine.js 도입을 고려할 수 있다.

필자의 생각으로는 적절한 JavaScript 사용은 현실적으로 필요한 부분이라고 생각한다. HTMX만으로 모든 것을 해결하려 하기보다, 상황에 맞게 판단하는 것이 좋다.

정리

  • hx-disabled-elt: 요청 중 특정 요소 비활성화로 중복 요청 방지
  • htmx-request: HTMX가 자동 추가하는 클래스, CSS로 로딩 상태 표현
  • hx-trigger delay: 디바운싱으로 불필요한 요청 감소
  • hx-trigger [조건]: 이벤트 필터로 특정 조건에서만 요청 발생
  • hx-swap-oob: 응답 타겟 외부 영역도 함께 업데이트

다음 글에서는 모달을 활용한 수정 기능을 구현해본다. 모달은 필자가 HDA를 사용하여 웹사이트를 구현하면서 방식이 다양해서 고민이 많았던 부분이다. UX와 DX 사이에서 적절한 균형을 찾기 위해 여러 패턴을 시도해봤는데, 그 경험을 공유할 예정이다.