프로필

데브고래밥

@devgoraebap

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

Album Art

0:00 0:00
방문자 정보

요즘 관심있는

HDA 시리즈 #3 - 서버 주도 리다이렉트 thumbnail image
46
0

HDA 시리즈 #3 - 서버 주도 리다이렉트

이전 글에서는 부분 업데이트 방식으로 생성/삭제를 구현했다. 성공 시 특정 요소만 교체하고, 실패 시 에러 메시지를 표시하는 방식이었다. 이번 글에서는 더 단순한 접근법을 소개한다.

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

부분 업데이트의 복잡성

이전 방식의 삭제 버튼을 다시 보자.

<button hx-delete="/todos/${todo.getId()}"
        hx-target="closest li"
        hx-swap="outerHTML"
        hx-target-4xx="#HTMX_TODO_DELETE_ERROR">
    삭제
</button>

성공 시 closest li를 찾아 outerHTML로 교체하고, 실패 시 에러 영역에 메시지를 표시한다. 이러한 구현이 필요한 상황이 생기긴하지만, 일반적인 경우에선 단순하게 처리해도 무방하다.

HX-Location 헤더

더 간단한 방법이 있다. 서버에서 HX-Location 헤더를 보내면 HTMX가 해당 URL로 AJAX 요청을 보내고 페이지 내용을 교체한다.

@DeleteMapping("{id}")
public String destroy(
        @PathVariable String id,
        Model model,
        HttpServletResponse response
) {
    try {
        todoService.destroy(id);
    } catch (ResponseStatusException ex) {
        response.setStatus(ex.getStatusCode().value());
        model.addAttribute("errors", List.of(ex.getReason()));
        return "pages/todos/_destroyFail";
    }

    response.setHeader("HX-Location", "/todos");
    return null;
}

성공하면 HX-Location: /todos 헤더와 빈 응답을 반환한다. HTMX는 이 헤더를 감지하면 /todos로 GET 요청을 보내고, 받아온 HTML로 페이지를 교체한다. 브라우저의 전체 페이지 리로드가 아닌 AJAX 요청이므로 화면 깜빡임 없이 SPA처럼 동작한다.

단순해진 클라이언트 코드

HX-Location을 사용하면 클라이언트 코드가 훨씬 간결해진다.

<button hx-delete="/todos/${todo.getId()}"
          hx-target-4xx="#HTMX_TODO_DELETE_ERROR">
      삭제
  </button>

hx-target, hx-swap, closest 모두 필요 없다. 성공하면 서버가 리다이렉트를 지시하고, 실패하면 hx-target-4xx가 에러를 처리한다.

생성 폼도 마찬가지다. 예제를 다루기 위해 생성, 삭제 요청 모두 html 조각을 응답하였지만, 대부분의 생성, 수정, 삭제의 경우 리소스를 변경시키고 목록 조회로 리다이렉트하는 것이 일반적이다. 변경된 내용이 그대로 반영되기 때문에 CUD의 제어도 단순해지고 리다이렉트를 통해 최신 데이터를 가져오는 흐름이 자연스럽기 때문이다.

<form hx-post="/todos"
      hx-target-4xx="#HTMX_TODO_FORM_ERROR">
    <input type="text" name="content" placeholder="할일 입력..">
    <button>추가</button>
</form>
@PostMapping
public String create(
        @Valid @ModelAttribute TodoCreateRequest dto,
        BindingResult bindingResult,
        HttpServletResponse response,
        Model model
) {
    if (bindingResult.hasErrors()) {
        List<String> errors = bindingResult.getFieldErrors()
                .stream().map(x -> x.getField() + ": " + x.getDefaultMessage())
                .toList();
        response.setStatus(HttpStatus.BAD_REQUEST.value());
        model.addAttribute("errors", errors);
        return "pages/todos/_createFail";
    }

    try {
        todoService.save(dto.content());
    } catch (ResponseStatusException ex) {
        response.setStatus(HttpStatus.BAD_REQUEST.value());
        model.addAttribute("errors", List.of(ex.getMessage()));
        return "pages/todos/_createFail";
    }

    response.setHeader("HX-Location", "/todos");
    return null;
}

에러 시 폼 데이터 보존

이 방식의 또 다른 장점은 에러 발생 시 폼 데이터가 보존된다는 것이다.

만약 에러 발생 시에도 redirect:/todos를 사용한다면 브라우저가 전체 페이지를 새로 로드하면서 사용자가 입력한 내용이 모두 사라진다.

반면 hx-target-4xx를 사용하면 에러 응답이 지정된 에러 영역에만 표시된다. 폼 자체는 건드리지 않으므로 입력값이 그대로 유지된다. 사용자는 에러 메시지를 확인하고 입력값을 수정해서 다시 제출할 수 있다.

서버 주도 응답 헤더

HTMX는 HX-Location 외에도 여러 응답 헤더를 제공한다.

헤더 설명
HX-Location AJAX로 페이지 이동 (화면 깜빡임 없음)
HX-Redirect 브라우저 전체 리다이렉트
HX-Refresh 현재 페이지 새로고침
HX-Trigger 클라이언트 이벤트 발생

자세한 내용은 HTMX Response Headers 문서를 참고하자.

클라이언트 기본 에러 타겟

지금까지는 각 요소마다 hx-target-4xx를 지정했다. 하지만 대부분의 에러는 동일한 방식으로 처리하고 싶을 수 있다.

hx-target-4xx는 상속되므로 부모 요소에 한 번만 선언하면 된다.

<body hx-ext="response-targets" hx-target-4xx="#HTMX_DEFAULT_ERROR_MODAL">
    <!-- 기본적으로 모든 4xx 에러는 모달로 표시 -->

    <form hx-post="/todos"
          hx-target-4xx="#HTMX_TODO_FORM_ERROR">
        <!-- 이 폼의 에러만 인라인으로 표시 (오버라이드) -->
    </form>

    <button hx-delete="/todos/${todo.getId()}">
        삭제
        <!-- hx-target-4xx 없음 → body의 설정 상속 → 모달로 표시 -->
    </button>

    <div id="HTMX_TODO_FORM_ERROR"></div>
    <div id="HTMX_DEFAULT_ERROR_MODAL"></div>
</body>

이렇게 하면 특별한 처리가 필요한 경우에만 오버라이드하고, 나머지는 기본 에러 모달을 사용할 수 있다.

컨트롤러 코드 개선

이 섹션부터는 HTMX와 직접 관련된 내용은 아니지만, 코드가 점진적으로 개선되는 과정을 보여준다.

Ruby on Rails나 AdonisJS 같은 프레임워크는 에러 처리와 템플릿 응답에 대한 컨벤션이 내장되어 있다. Rails는 respond_to 블록과 Turbo Stream 자동 매핑을, AdonisJS는 statusPages로 상태 코드별 템플릿 자동 매핑을 제공한다. Spring은 이런 편의 기능을 기본 제공하지 않아서 직접 구현해야 한다. (NestJS로 템플릿 기반 앱을 만들 때도 비슷하게 번거로웠다.)

앞서 본 컨트롤러 코드를 다시 보자. try-catch, 상태 코드 설정, 에러 템플릿 반환이 반복된다.

@PostMapping
public String create(
        @Valid @ModelAttribute TodoCreateRequest dto,
        BindingResult bindingResult,
        HttpServletResponse response,
        Model model
) {
    if (bindingResult.hasErrors()) {
        // 중복: 에러 추출, 상태 코드 설정, 템플릿 반환
        List<String> errors = bindingResult.getFieldErrors()
                .stream().map(x -> x.getField() + ": " + x.getDefaultMessage())
                .toList();
        response.setStatus(HttpStatus.BAD_REQUEST.value());
        model.addAttribute("errors", errors);
        return "pages/todos/_createFail";
    }

    try {
        todoService.save(dto.content());
    } catch (ResponseStatusException ex) {
        // 또 중복
        response.setStatus(HttpStatus.BAD_REQUEST.value());
        model.addAttribute("errors", List.of(ex.getMessage()));
        return "pages/todos/_createFail";
    }

    response.setHeader("HX-Location", "/todos");
    return null;
}

Spring의 @ControllerAdvice를 활용하면 이 중복을 제거할 수 있다.

@ErrorTemplate 어노테이션

먼저 컨트롤러 메서드에 에러 템플릿을 지정할 수 있는 커스텀 어노테이션을 만든다.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ErrorTemplate {
    String value();
}

전역 예외 처리

@ControllerAdvice로 모든 예외를 한 곳에서 처리한다.

@Order(Ordered.HIGHEST_PRECEDENCE)
@ControllerAdvice(annotations = Controller.class)
@RequiredArgsConstructor
public class WebExceptionHandler {

    private static final String DEFAULT_TEMPLATE = "partials/defaultErrorModal";
    private final TemplateEngine templateEngine;

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<String> handleValidationException(
            MethodArgumentNotValidException ex,
            HandlerMethod handlerMethod
    ) {
        List<String> errors = ex.getBindingResult().getFieldErrors()
                .stream().map(x -> x.getField() + ": " + x.getDefaultMessage())
                .toList();

        return renderErrorResponse(errors, ex.getStatusCode(), handlerMethod);
    }

    @ExceptionHandler(ResponseStatusException.class)
    public ResponseEntity<String> handleResponseStatusException(
            ResponseStatusException ex,
            HandlerMethod handlerMethod
    ) {
        List<String> errors = List.of(ex.getReason());
        return renderErrorResponse(errors, ex.getStatusCode(), handlerMethod);
    }

    private ResponseEntity<String> renderErrorResponse(
            List<String> errors,
            HttpStatusCode statusCode,
            HandlerMethod handlerMethod
    ) {
        // 어노테이션이 있으면 해당 템플릿, 없으면 기본 모달
        ErrorTemplate annotation = handlerMethod.getMethodAnnotation(ErrorTemplate.class);
        String templatePath = (annotation != null ? annotation.value() : DEFAULT_TEMPLATE) + ".jte";

        StringOutput output = new StringOutput();
        templateEngine.render(templatePath, Map.of("errors", errors), output);

        return ResponseEntity
                .status(statusCode)
                .contentType(MediaType.TEXT_HTML)
                .body(output.toString());
    }
}

HandlerMethod를 통해 예외가 발생한 컨트롤러 메서드의 어노테이션을 읽을 수 있다. @ErrorTemplate이 있으면 해당 템플릿을, 없으면 기본 에러 모달을 사용한다.

리팩토링된 컨트롤러

이제 컨트롤러는 비즈니스 로직에만 집중할 수 있다.

@PostMapping
@ErrorTemplate("pages/todos/_createFail")
public ResponseEntity<Void> create(@Valid @ModelAttribute TodoCreateRequest dto) {
    todoService.save(dto.content());
    return ResponseEntity.ok()
            .header("HX-Location", "/todos")
            .build();
}

@DeleteMapping("{id}")
public ResponseEntity<Void> destroy(@PathVariable String id) {
    todoService.destroy(id);
    return ResponseEntity.ok()
            .header("HX-Location", "/todos")
            .build();
}
  • create: @ErrorTemplate으로 인라인 에러 템플릿 지정
  • destroy: 어노테이션 없음 → 기본 에러 모달 사용
  • ResponseEntity로 응답을 명시적으로 반환, return null 제거

서비스에서 ResponseStatusException을 던지면 WebExceptionHandler가 자동으로 처리한다. 컨트롤러는 try-catch 없이 깔끔해졌다.

@Service
public class TodoService {

    public Todo save(String content) {
        boolean isDuplicate = todoList.stream()
                .anyMatch(todo -> todo.getContent().equals(content));
        if (isDuplicate) {
            throw new ResponseStatusException(HttpStatus.CONFLICT, "이미 존재하는 할 일입니다");
        }

        var todo = new Todo(content);
        todoList.add(todo);
        return todo;
    }

    public void destroy(String id) {
        Todo todo = todoList.stream()
                .filter(t -> t.getId().equals(id))
                .findFirst()
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "존재하지 않는 할 일입니다"));

        todoList.remove(todo);
    }
}

서비스는 비즈니스 로직에만 집중하고, 에러 발생 시 ResponseStatusException을 던지면 된다. 템플릿 경로나 HTTP 응답 처리는 서비스가 알 필요 없다.

정리

  • HX-Location: 서버에서 AJAX 리다이렉트 지시, SPA처럼 화면 깜빡임 없이 페이지 교체
  • 기본 에러 타겟: body에 hx-target-4xx 설정, 필요한 곳만 오버라이드
  • @ControllerAdvice: 전역 예외 처리로 컨트롤러 코드 간결화
  • @ErrorTemplate: 컨트롤러 메서드별 에러 템플릿 지정, 없으면 기본 모달
  • ResponseEntity: 명시적인 응답 반환으로 return null 제거

다음 글에서는 클라이언트 측에서 요청 중복 방지(debouncing), 요청 중 버튼 비활성화 등 HTMX로 사용자 경험을 개선하는 방법을 다룰 예정이다.