htmx 4 마이그레이션, 속성 상속과 이벤트 이름 회귀를 어떻게 막나
htmx 4 마이그레이션에서 가장 먼저 경계할 것은 요청 실패가 아니다. 부모에 둔 hx-target이 자식 버튼에 더는 상속되지 않아 응답이 엉뚱한 DOM에 들어가고, 옛 htmx:afterRequest 리스너가 오류 없이 침묵하는 회귀다. 네트워크가 200으로 끝나도 화면과 후처리는 이미 달라질 수 있다.
이 글은 2026년 8월 29일 Linux, Node.js 18.19.1, npm 9.2.0, Playwright 1.55.0의 Chromium 140.0.7339.16에서 htmx 2.0.10과 4.0.0을 같은 최소 페이지로 비교한 결과를 바탕으로 한다. hx-target과 after-request 이벤트, 공식 검사기 출력은 직접 비교했지만 history, 실제 서버의 헤더 수신, 오류 응답 swap, Firefox와 WebKit은 검증하지 않았다. 정적 검사와 브라우저 회귀 테스트를 별도 통과 조건으로 두는 방법까지 확인할 수 있다.
20초 핵심 요약
- 무엇: htmx 4에서는 부모 속성에
:inherited를 명시하고 이벤트 이름을htmx:after:request같은 새 형식으로 바꿔야 한다. - 왜: 요청이 성공해도 응답이 버튼 안에 들어가거나 옛 이벤트 후처리가 실행되지 않는 조용한 회귀가 생긴다.
- 어떻게:
upgrade-check로 정적 문제를 없앤 뒤 같은 fixture를 2.0.10과 4.0.0에서 실행해 최종 DOM과 이벤트 trace를 비교한다.
첫 실패는 정적 검사에서 8건으로 드러났다
4.0 검증에는 버전을 명시해야 한다. 조사 시점인 2026년 8월 29일 npm 배포 태그는 latest=2.0.10, next=4.0.0이어서 버전 없는 npm install htmx.org는 아직 2.x를 설치한다. 검사기도 [email protected]으로 고정해 실행했다.
$ npx [email protected] upgrade-check -- ./checker-fixture.html
Scanning 1 file(s)...
Found 8 issue(s) in 1 of 1 file(s).
...:1: [inheritance] hx-headers needs :inherited suffix ...
...:1: [inheritance] hx-target needs :inherited suffix ...
...:1: [inheritance] hx-confirm needs :inherited suffix ...
...:4: [renamed-attr] hx-disable → rename to hx-ignore ...
...:7: [removed-attr] hx-vars is removed → use hx-vals with js: prefix
...:7: [removed-attr] hx-prompt is removed ...
...:9: [old-event] old event name "htmx:afterRequest" → "htmx:after:request"
...:10: [old-api] htmx.addClass() is removed → use element.classList.add()
EXIT_CODE=1

종료 코드 1의 원인은 한 종류가 아니다. 2.x의 암묵 상속, 이름과 의미가 바뀐 속성, 제거된 속성과 API, 옛 이벤트 이름이 한 fixture에 함께 남아 있었다. 이 출력은 수정 목록을 만드는 출발점으로 유용하지만, 경고를 0으로 만드는 것만으로 화면 동작이 보존됐다고 판정할 수는 없다.
Vue나 Svelte처럼 검사기의 기본 대상 확장자 밖에 템플릿이 있다면 공식 검사기의 --ext 옵션으로 대상 확장자를 추가해야 한다. 다만 래퍼 함수나 동적으로 조합한 JavaScript 문자열까지 모두 찾는다고 가정해서는 안 된다.
요청 성공만 보면 hx-target 회귀를 놓친다
2.x에서는 부모의 hx-target, hx-confirm, hx-headers 등이 자식 요청 요소에 암묵적으로 적용됐다. 4.0은 상속할 속성에 :inherited를 붙이는 명시적 방식으로 바뀌었다. 모든 속성에 이 접미사를 쓸 수 있고 자식에서 값을 더할 때는 :append를 지원하며, 이전의 hx-disinherit와 hx-inherit는 제거됐다.
차이는 같은 응답 <span id="done">SWAPPED</span>을 반환하는 최소 페이지에서 확인됐다. 2.0.10, 수정 전 4.0.0, hx-target:inherited로 고친 4.0.0을 같은 Chromium에서 실행한 결과다.
$ node ./browser-compare.cjs
/v2 {"target":"SWAPPED","button":"Go","trace":["htmx:afterRequest"]}
/v4-implicit {"target":"INITIAL","button":"SWAPPED","trace":["htmx:after:request"]}
/v4-fixed {"target":"SWAPPED","button":"Go","trace":["htmx:after:request"]}
EXIT_CODE=0

수정 전 4.0도 요청 자체는 성공했다. 그러나 부모의 hx-target="#result"가 버튼에 전달되지 않아 #result는 INITIAL로 남고 버튼 안쪽이 SWAPPED로 바뀌었다. 그래서 회귀 테스트는 HTTP 성공 여부가 아니라 응답이 들어간 최종 대상과 남아 있어야 할 요소를 검사해야 한다.
상속 수정은 부모 속성을 무작정 복제하는 작업이 아니다. 자식에게 실제로 전달할 hx-headers, hx-target, hx-confirm, hx-include, hx-boost를 먼저 골라 :inherited를 붙인다. htmx.config.implicitInheritance = true는 초기 전환을 위한 임시 완화책으로만 쓰고 제거 일정을 함께 둬야 숨은 의존성이 다시 남지 않는다.
이벤트는 이름과 payload를 함께 검사해야 한다
4.0 이벤트는 htmx:phase:action[:sub-action] 형태로 정리됐다. 대표적으로 htmx:beforeRequest는 htmx:before:request, htmx:afterRequest는 htmx:after:request, htmx:configRequest는 htmx:config:request로 바뀐다. 비교 fixture에서는 옛 after-request 리스너가 예외를 던지지 않고 호출만 되지 않았다.
더 위험한 건 이런 침묵이다. 로딩 상태 제거, 추적, 파라미터 주입, 응답 후처리는 네트워크 요청과 별개로 멈출 수 있기 때문이다. 이름을 고친 뒤에는 이벤트 발생 여부뿐 아니라 순서와 event.detail 구조, 서버가 받은 값까지 검사해야 한다. 초기 v4에서 htmx:config:request의 detail.parameters 변화가 보고된 사례도 있어 단순 문자열 치환만으로 끝내기 어렵다.
XHR progress나 validation 계열처럼 제거된 이벤트는 새 이름이 없는 경우도 있다. 여러 오류 이벤트는 htmx:error로, HTTP 오류는 htmx:response:error로 합쳐졌다. 이런 항목은 rename 목록이 아니라 대체 동작을 정하는 별도 작업으로 분리한다.
제거·이름 변경은 충돌 순서부터 정한다
hx-disable은 특히 순서가 중요하다. 2.x의 hx-disable은 4.0에서 hx-ignore로 이름이 바뀌었고, 기존 hx-disabled-elt가 새 hx-disable이 됐다. 먼저 옛 hx-disable을 hx-ignore로 옮긴 뒤 hx-disabled-elt를 새 이름으로 바꿔야 같은 이름에 서로 다른 의미가 섞이지 않는다.
나머지는 공식 변경표를 기준으로 하나씩 치환한다. hx-vars는 hx-vals="js:..."로 옮기고, 제거된 htmx.addClass()는 표준 element.classList.add()로 바꾼다. 수정 후 같은 fixture에서 검사기를 다시 실행한 결과는 다음과 같았다.
$ npx [email protected] upgrade-check -- ./checker-fixture.html
Scanning 1 file(s)...
Found 0 issue(s) in 0 of 1 file(s).
EXIT_CODE=0
0건은 정적 변환이 끝났다는 판정이다. 브라우저 동작까지 같다는 판정은 앞의 DOM·이벤트 비교가 따로 담당한다.
history와 서버 헤더는 배포 전 별도 게이트다
4.0은 클라이언트 저장소의 DOM 스냅샷 캐시를 제거했다. 뒤로 가기 때 원 URL을 다시 요청해 <body> 또는 [hx-history-elt]를 교체하므로, 일회성 URL과 만료된 세션, POST 뒤 redirect, 서버 렌더링 결과가 재요청에서도 성립하는지 확인해야 한다. 2.x의 저장소를 일률적으로 localStorage라고 부르는 것은 부정확하므로, 이전 판단에서는 “DOM 스냅샷 캐시 제거”로 이해하는 편이 안전하다.
이 글의 비교 실험에서는 뒤로 가기를 직접 실행하지 않았다. 실제 서비스에서는 다음 항목을 2.x 기준선과 대조해야 한다.
- 뒤로 가기에서 원 URL이 다시 요청되고 기대한 영역만 교체되는가
- 만료된 인증 상태와 POST 뒤 redirect가 유효한 화면으로 끝나는가
- 서버가 받는
HX-Source와HX-Target의 존재 여부와 값 형식이 예상과 같은가 - 4xx·5xx 응답, OOB 교체 순서, 60초 timeout이 기존 운영 의도와 맞는가
공식 최종 문서에서는 HX-Trigger가 HX-Source로 바뀌고 HX-Target 값 형식도 tagName#id로 달라진다고 설명한다. 초기 alpha에서 요청 헤더 누락이 보고됐다가 복원된 기록도 있으므로, 서버 프레임워크가 이 헤더를 파싱한다면 단순 존재 확인 대신 정확한 형식을 계약 테스트해야 한다.
채택 기준은 검사 0건과 브라우저 일치의 교집합이다
안전한 이전은 다음 순서로 진행한다.
- htmx 2.x 버전을 고정하고 핵심 요청의 URL, 최종 대상 DOM, 이벤트 순서, history 동작을 기준선으로 저장한다.
npx [email protected] upgrade-check -- ./templates를 실행하고 종료 1과 모든 진단을 CI 실패로 취급한다.- 필요한 부모 속성만 명시 상속으로 바꾸고, 이름 변경과 제거 API를 공식 표에 따라 수정한다.
- 이벤트 이름을 바꾼 뒤 payload와 발생 순서를 검사한다.
- 4.0.0을 명시적으로 설치해 2.x와 같은 브라우저 테스트를 실행한다.
- 정적 경고 0건, 핵심 DOM과 이벤트 기대값 일치, history와 서버 헤더 테스트 통과를 각각 확인한다.
하나라도 다르면 4.0 배포를 보류한다. 롤백을 위해 2.x 고정 버전과 수정 전 템플릿을 별도 배포 단위로 보존하고, implicitInheritance=true 같은 호환 설정은 제거 시점이 있는 임시 조치로 관리한다. Chromium 최소 fixture에서 얻은 결과를 모든 브라우저와 복잡한 서비스 템플릿의 보장으로 확대해서도 안 된다.
지금 바로 적용할 팀은 정적 검사와 동일 fixture 이중 실행을 CI의 서로 다른 게이트로 둘 수 있다. history와 서버 헤더를 아직 대조하지 못했다면 검사기 0건이어도 배포 단계로 넘어가지 않는 편이 맞다. 공식 변경 목록을 열어 서비스별 history와 오류 응답 테스트 항목을 회귀 테스트에 추가하는 것이 다음 행동이다.
참고 링크
- htmx 4.0.0 공식 발표
- What’s New in htmx 4
- htmx.org npm 버전 목록
- htmx 2.0.10 changelog
- 요청 헤더 변경 이슈 #3496
htmx:config:requestpayload 이슈 #3748- htmx 2.0.10과 4.0.0 비교 진입점
제휴·협찬은 없다.