오늘은 자동차보안연구소 'V2X-PKI' SW 개발자, 이형석님이 써주신 글입니다 💻 아우토크립트 V2X-PKI팀은 자동차를 위한 인증서 시스템(PKI, 공개 키 기반 구조) 전반을 개발, 운영하는 조직입니다.
문제
PKI 시스템은 암호/정보보안과 관련된 시스템이기 때문에 백엔드의 기술적 복잡성이 매우 높습니다. 반면, 인증서를 발행하는 것이 중심인 시스템이기 때문에 프론트엔드 페이지(어드민 페이지)에 대한 요구사항은 상대적으로 적습니다. 안정적인 시스템을 유지하기 위해 V2X-PKI팀은 스프링을 사용하고 있습니다. 프론트엔드는 (아이러니하게도) 요구사항이 적다 보니 오히려 기술 관리가 잘 되지 않아, 리액트, 뷰, 스벨트 등이 혼재된 상태였습니다.
이런 상황을 개선하고 프론트엔드 관리의 부담을 줄이기 위해 V2X-PKI팀에서 도입한 방법을 소개해드리겠습니다.
아이디어
스프링에 적용할 수 있는 템플릿 엔진으로는 타임리프가 잘 알려져 있습니다. 스프링과 타임리프(이하 스프링 타임리프)로도 웹 페이지를 개발할 수 있습니다만, 이는 MPA(Multi-Page Application)에 적합한 방식입니다.
MPA는 페이지 간 이동을 전제로 하기 때문에, 최신 웹 페이지들과 같은 수준의 반응성을 잘 보여주지 못합니다. 이러한 MPA의 단점을 해결해주는 기술로 HTMX가 있습니다.
V2X-PKI팀에서는 원래 사용하고 있던 스프링 타임리프 구성에 HTMX를 연결하는 방식을 시도해 보았습니다. 이를 통해 팀의 빌드 프로세스에서 자바스크립트 부분(Node.js)을 없애고자 하였습니다.
결과물 소개
기술 구성:
기존에 주력으로 사용하고 있던 기술 구성은 다음과 같습니다:
- 개발 언어: 자바
- 웹 프레임워크: 스프링
- 템플릿 엔진: 타임리프
추가한 주요 기술은 다음과 같습니다:
- HTMX: 웹 페이지의 반응성 개선을 위해 사용
그 외에, 디자인 및 편의성을 위해 추가한 기술은 다음과 같습니다:
- 웹 컴포넌트: Node.js 없이 커스텀 컴포넌트를 구현하기 위해 사용
- tailwindcss: CSS 프레임워크. 디자인을 위해 사용. Node.js 없이 Standalone CLI로도 사용 가능
- Alpine.js: 클라이언트에서의 연산이 필요한 경우 사용(모달 창 구현 등)
디자인 가이드라인
아래 디자인 가이드라인을 충족시킬 수 있도록 하였습니다:
쇼케이스
스프링 타임리프와 HTMX로 개발하는 경우가 아직 많지 않기 때문에, 개발자가 페이지를 만들기 위한 정보를 얻기가 어려울 것이라고 생각했습니다.
개발 편의성을 위해, 개발을 수행할 때 참고하기 위한 용도의 쇼케이스 페이지를 만들었습니다.
어떤 페이지를 만들 수 있나요?
스프링 타임리프 + HTMX 구성으로 구현한 웹 페이지의 데모 영상입니다:
이제 데모 영상의 내용을 어떻게 구현하였는지 설명하겠습니다.
그래서 어떻게 개발하면 될까요?
페이지 만들기
다음 페이지를 예로 들어, 페이지를 작성하는 방법을 소개하겠습니다:
해당 페이지의 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.html에
x-data="{registerModalOpen: false}"
부분을 통해 모달창 on/off용 플래그가 적용되어 있습니다 on-click="registerModalOpen = true"
,on-click="$data.registerModalOpen"
과 같이 플래그가 설정되면,ac-modal
의openFlag="$data.registerModalOpen"
을 통해 모달창이 on/off 됩니다- (
openFlag
부분은 웹 컴포넌트에서<div x-show="${openFlag}" ...
와 같이 구현되어 있습니다)
- example_canonical_info.html에
- 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
디자인을 수정하는 경우가 아니라면 사용하지 않아도 됩니다.