스프링 타임리프 + HTMX

오늘은 자동차보안연구소 'V2X-PKI' SW 개발자, 이형석님이 써주신 글입니다 💻 아우토크립트 V2X-PKI팀은 자동차를 위한 인증서 시스템(PKI, 공개 키 기반 구조) 전반을 개발, 운영하는 조직입니다.

문제

PKI 시스템은 암호/정보보안과 관련된 시스템이기 때문에 백엔드의 기술적 복잡성이 매우 높습니다. 반면, 인증서를 발행하는 것이 중심인 시스템이기 때문에 프론트엔드 페이지(어드민 페이지)에 대한 요구사항은 상대적으로 적습니다. 안정적인 시스템을 유지하기 위해 V2X-PKI팀은 스프링을 사용하고 있습니다. 프론트엔드는 (아이러니하게도) 요구사항이 적다 보니 오히려 기술 관리가 잘 되지 않아, 리액트, , 스벨트 등이 혼재된 상태였습니다.

이런 상황을 개선하고 프론트엔드 관리의 부담을 줄이기 위해 V2X-PKI팀에서 도입한 방법을 소개해드리겠습니다.

아이디어

스프링에 적용할 수 있는 템플릿 엔진으로는 타임리프가 잘 알려져 있습니다. 스프링과 타임리프(이하 스프링 타임리프)로도 웹 페이지를 개발할 수 있습니다만, 이는 MPA(Multi-Page Application)에 적합한 방식입니다.

MPA는 페이지 간 이동을 전제로 하기 때문에, 최신 웹 페이지들과 같은 수준의 반응성을 잘 보여주지 못합니다. 이러한 MPA의 단점을 해결해주는 기술로 HTMX가 있습니다.

백엔드 개발자를 한순간에 풀스택 개발자로 만들어준다는 HTMX 😎

V2X-PKI팀에서는 원래 사용하고 있던 스프링 타임리프 구성에 HTMX를 연결하는 방식을 시도해 보았습니다. 이를 통해 팀의 빌드 프로세스에서 자바스크립트 부분(Node.js)을 없애고자 하였습니다.

결과물 소개

기술 구성:

기존에 주력으로 사용하고 있던 기술 구성은 다음과 같습니다:

  • 개발 언어: 자바
  • 웹 프레임워크: 스프링
    • 템플릿 엔진: 타임리프

추가한 주요 기술은 다음과 같습니다:

  • HTMX: 웹 페이지의 반응성 개선을 위해 사용

그 외에, 디자인 및 편의성을 위해 추가한 기술은 다음과 같습니다:

  • 웹 컴포넌트: Node.js 없이 커스텀 컴포넌트를 구현하기 위해 사용
  • tailwindcss: CSS 프레임워크. 디자인을 위해 사용. Node.js 없이 Standalone CLI로도 사용 가능
  • Alpine.js: 클라이언트에서의 연산이 필요한 경우 사용(모달 창 구현 등)

디자인 가이드라인

아래 디자인 가이드라인을 충족시킬 수 있도록 하였습니다:

V2X-PKI 디자인 가이드라인

쇼케이스

스프링 타임리프와 HTMX로 개발하는 경우가 아직 많지 않기 때문에, 개발자가 페이지를 만들기 위한 정보를 얻기가 어려울 것이라고 생각했습니다.
개발 편의성을 위해, 개발을 수행할 때 참고하기 위한 용도의 쇼케이스 페이지를 만들었습니다.

쇼케이스 페이지. 이 페이지의 코드를 베껴서 개발하면 됩니다

어떤 페이지를 만들 수 있나요?

스프링 타임리프 + HTMX 구성으로 구현한 웹 페이지의 데모 영상입니다:

0:00
/0:48

데모 영상

이제 데모 영상의 내용을 어떻게 구현하였는지 설명하겠습니다.

그래서 어떻게 개발하면 될까요?


페이지 만들기

다음 페이지를 예로 들어, 페이지를 작성하는 방법을 소개하겠습니다:

샘플 페이지

해당 페이지의 html 코드는 다음과 같습니다(example_canonical_info.html):당 페이지의 html 코드는 다음과 같습니다(example_canonical_info.html):

<!doctype html>
<html
    lang="en"
    xmlns:th="http://www.thymeleaf.org"
    xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
    layout:decorate="~{layouts/base_layout}"
>
<ac-page-title layout:fragment="page-heading" x-cloak th:text="#{canonicalInfo.page}">
</ac-page-title>
<ac-container layout:fragment="content" x-cloak>
  <div x-data="{registerModalOpen: false}">
   <ac-sub-title th:text="#{canonicalInfo.page.title.registered}"></ac-sub-title>
   <ac-button color="primary" on-click="registerModalOpen = true">
      <span th:text="#{button.add}">Add
    </ac-button>
    <th:block th:replace="~{fragments/eca/canonical_key_registration_modal}">
    </th:block>
  </div>
  <th:block th:replace="~{fragments/eca/canonical_key_info_table :: canonical_key_info_table}">
  </th:block>
</ac-container>
</html>

국제화:

  • th:text를 통해 국제화 메시지가 적용된 것을 확인할 수 있습니다
    • 예: <ac-sub-title th:text="#{canonicalInfo.page.title.registered}"></ac-sub-title>
  • 메시지는 messages.properties, errors.properties를 통해 관리할 수 있습니다

웹 컴포넌트:

  • V2X-PKI 전용으로 개발한 ac-page-title, ac-container, ac-button 등을 사용하고 있습니다

타임리프 레이아웃:

  • layout:decorate: 어떤 레이아웃을 적용할 것인지 지정합니다
  • layout:fragment: 적용된 레이아웃의 layout:fragment 자리가 해당 element로 대체됩니다
  • th:replace: 해당 element가 replace에 지정된 대상으로 대체됩니다

layout:decorate에 base_layout이 지정되어 있으므로, 페이지의 기본 레이아웃은 base_layout.html 파일을 따릅니다. base_layout.html 의 내용 중 일부가 현재 파일의 layout:fragment 가 적용된 내용으로 대체됩니다(현재 파일의 head는 레이아웃 파일의 head 내용에 자동으로 추가됩니다).

모달 창은 example_canonical_info.html과 다른 파일에 있고, th:replace를 통해 example_canonical_info.html 페이지에 삽입되었습니다. 다음 부분입니다:

<th:block th:replace="~{fragments/eca/canonical_key_registration_modal}">
</th:block>

canonical_key_registration_modal.html 파일을 통해 입력 처리 부분 코드를 소개하겠습니다.

입력 처리

canonical_key_registration_modal.html:

  <!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<ac-modal openFlag="$data.registerModalOpen" th:fragment="canonical_key_registration_modal" x-cloak>
 <ac-modal-title th:text="#{canonicalInfo.modal.title.register}">
  </ac-modal-title>
  <ac-modal-body>
    <form
        id="modal-form"
        hx-post="/api/eca/canonical-key"
        hx-target="#canonical-key-info-table"
        hx-swap="outerHTML"
        hx-swap-oob="true"
        th:object="${canonicalInfo}"
    >
     <!-- ... Global Error 처리부 생략 ... -->
      <!-- Canonical Id Field -->
      <ac-input
          th:label="#{canonicalInfo.canonicalId}"
          th:field="*{canonicalId}"
          th:errorclass="error"
          required
      >
        <div th:errors="*{canonicalId}" class="err"></div>
      </ac-input>
      <!-- Canonical Id Field 끝 -->
      <!-- Public Key Hex Field -->
      <ac-input
          th:label="#{canonicalInfo.publicKeyHex}"
          th:field="*{publicKeyHex}"
          th:errorclass="error"
          required
      >
        <div th:errors="*{publicKeyHex}" class="err"></div>
     </ac-input>
      <!-- Public Key Hex Field 끝 -->
      <!-- ... 중간 부분 생략 ... -->
    </form>
  </ac-modal-body>
  <ac-modal-footer>
    <div>
      <ac-button
          color="red"
          type="button"
          on-click="$data.registerModalOpen = false"
          th:text="#{button.close}"
      >Close</ac-button>
      <ac-button 
          color="primary" 
          type="submit" 
          form="modal-form" 
          th:text="#{button.register}"
	  >Register</ac-button>
    </div>
  </ac-modal-footer>
</ac-modal>
</html>

모달창 on/off:

  • 모달창의 on/off는 Alpine.js 를 통해 구현하였습니다
    • example_canonical_info.htmlx-data="{registerModalOpen: false}" 부분을 통해 모달창 on/off용 플래그가 적용되어 있습니다
    • on-click="registerModalOpen = true", on-click="$data.registerModalOpen"과 같이 플래그가 설정되면, ac-modalopenFlag="$data.registerModalOpen"을 통해 모달창이 on/off 됩니다
    • (openFlag 부분은 웹 컴포넌트에서 <div x-show="${openFlag}" ... 와 같이 구현되어 있습니다)
  • Alpine.js 는 남용되지 않도록, 모달창의 on/off 등 클라이언트 측에서 처리하는 것이 편한 부분에 한정적으로 적용하도록 구성하였습니다

입력 처리:

  • 사용자 입력은 HTMX를 통해 처리됩니다:
<ac-modal-body>
    <form
        id="modal-form"
        hx-post="/api/eca/canonical-key"
        hx-target="#canonical-key-info-table"
        hx-swap="outerHTML"
        hx-swap-oob="true"
        th:object="${canonicalInfo}"
    >
  

폼이 제출되면 /api/eca/canonical-key 로 요청이 보내집니다. 이 요청에 대한 응답은 html 값으로 내려오는데, 내려온 값은 hx-target에 지정되어 있는 #canonical-key-info-table을 대체합니다(대체해야 하는 부분이 여러 곳인 경우 HTMX의 OOB(out-of-band) 기능을 사용할 수 있습니다. OOB에 대한 설명은 생략하겠습니다).

폼 제출로부터의 동작은 다음과 같습니다:

  • 폼이 제출됨 →
  • 제출된 값을 통해 /api/eca/canonical-key 코드에서 새 html을 생성하여 사용자에게 내려줌 →
  • hx-target에 지정된 #canonical-key-info-table의 내용물이 내려온 html로 대체됨

페이지를 그리는 주체는 서버이지만, 웹 페이지의 사용자가 보기에는 페이지 전환 없이 자연스럽습니다.

/api/eca/canonical-key


@PostMapping
public String registerCanonicalKeyInfo(
	@ModelAttribute("canonicalInfo") @Validated EcaCanonicalInfoRequestDto request,
	BindingResult bindingResult,
	Model model) {
	if (bindingResult.hasErrors()) {
		model.addAttribute("canonicalInfos", service.getAllCanonicalKeyInfos());
		return "fragments/eca/canonical_key_info_table :: canonical_key_info_table";
	}
	service.register(request);
	model.addAttribute("canonicalInfos", service.getAllCanonicalKeyInfos());
	return "fragments/eca/canonical_key_info_table :: canonical_key_info_table";
}

/api/eca/canonical-key는 제출된 폼을 확인 후, 에러가 있으면 에러 정보를 포함하여 html을 생성하여 반환하고, 에러가 없다면 제출된 값을 저장한 뒤 html을 생성하여 반환합니다.

에러는 Bean Validation이나 Validator 등을 통해 검증할 수 있습니다. 검증 결과는 아래 부분과 같이 bindingResult를 통해 이용할 수 있습니다:

if (bindingResult.hasErrors()) {
		model.addAttribute("canonicalInfos", service.getAllCanonicalKeyInfos());
		return "fragments/eca/canonical_key_info_table :: canonical_key_info_table";
	}

정리

스프링 타임리프HTMX를 더하여 자바스크립트(Node.js) 빌드 프로세스를 제거할 수 있었습니다.
이 구성으로 유의미한 시간 동안 제품을 운영해본 뒤, 운영 후기로 돌아오도록 하겠습니다.


기타 세부사항

프로젝트 설정


스프링 + HTMX

dependencies {
    implementation("io.github.wimdeblauwe:htmx-spring-boot-thymeleaf:3.5.0")
    implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
    implementation("nz.net.ultraq.thymeleaf:thymeleaf-layout-dialect:3.2.0")
    developmentOnly("org.springframework.boot:spring-boot-devtools")
}

스프링과 HTMX를 연결하기 위해 위와 같이 의존성 설정을 하였습니다.

스프링 타임리프 + 웹 컴포넌트

<form th:object="${data}">
  <input th:field="*{field}" />
</form>

스프링에는 타임리프를 편리하게 쓰기 위한 th:field 기능이 있습니다. 그런데 이 th:field 기능은 AbstractSpringFieldTagProcessor 에 정의된 태그들(input, select, textarea 등)만 지원하기 때문에, 커스텀 웹 컴포넌트인 ac-input, ac-select 등을 인식하지 못했습니다.

이 문제는 AbstractSpringFieldTagProcessor를 상속하는 AutocryptAbstractSpringFieldTagProcessorWrapper를 구현하여 해결하였습니다.

tailwindcss

Node.js 의존성 없이 standalone CLI tool(tailwindcss) 를 통해 사용하였기 때문에, 아래 명령어를 사용하여 개발을 진행해야 합니다.

./tailwindcss -i {input_css_file.css} -o {output_css_file.css} --watch

디자인을 수정하는 경우가 아니라면 사용하지 않아도 됩니다.