본문 바로가기
빌드노트 검색

빌드노트

폼의 펼치기 버튼이 저장까지 실행하면 type부터 확인한다

폼의 펼치기 버튼이 저장까지 실행되면 type 생략에 따른 기본 제출부터 확인한다. 세 버튼의 click·invalid·submit과 submitter를 브라우저로 비교하고, 필수 입력이 증상을 숨기는 조건과 명시적 제출 호출을 구별한다.

폼 안에 넣은 ‘자세히 보기’ 버튼을 눌렀는데 저장 핸들러까지 실행되거나 다른 페이지로 이동한다면, 그 버튼의 type을 먼저 확인한다. 일반 폼에 둔 <button>은 type을 생략하면 제출 버튼으로 동작할 수 있다. 펼치기만 할 버튼에는 type="button", 저장 버튼에는 type="submit"을 명시한다.

<button type="button">자세히 보기</button>
<button type="submit">저장</button>

펼치기용 click 리스너를 붙였다고 제출 동작이 없어지는 것은 아니다. 클릭 리스너가 화면을 펼친 뒤에도 브라우저는 그 버튼의 기본 동작으로 폼 제출을 진행할 수 있다. HTML 표준의 button 정의 (새 탭에서 열림)에 따르면 생략값은 Auto 상태다. command·commandfor가 없고 부모가 select가 아닌 이번 예제의 버튼은 이 상태에서 제출 버튼으로 분류된다. “모든 button의 생략값은 무조건 submit”이라고 외우기보다 일반 동작 버튼의 역할을 직접 적는 편이 명확하다.

같은 폼에서 type만 달리해 눌러 봤다

실제 서비스 장애 기록이 아니라, 이 글을 위해 만든 합성 폼의 관찰이다. Windows 11의 Chromium 156.0.8078.4를 headless로 실행하고, 같은 폼의 세 버튼을 브라우저 입력으로 클릭했다. 각 버튼에는 같은 펼치기 리스너를 붙였고, click·invalid·submit 이벤트와 제출을 시작한 요소인 submitter를 기록했다. 입력값은 빈 문자열 또는 synthetic-title만 사용했다.

먼저 submit 리스너에서 preventDefault()를 호출해 실제 전송을 막고 비교했다.

  • 필수 입력이 없는 빈 폼: 생략 버튼과 type="submit" 버튼은 click → submit이었다. submitter.id는 각각 클릭한 버튼의 ID였다. type="button"은 click만 기록됐다.
  • required 입력이 비어 있는 폼: 생략·submit 버튼 모두 click → invalid였고 submit은 없었다. 일반 버튼은 여전히 click만 기록됐다.
  • 같은 필수 입력을 채운 폼: 생략·submit 버튼에서 다시 click → submit이 나타났다.
  • 필수 입력은 비워 두고 폼에 novalidate를 설정한 경우: 생략·submit 버튼은 invalid 없이 submit까지 진행했다.

이 차이 때문에 빈 폼에서만 눌러 보면 문제가 숨어 있을 수 있다. 필수 값을 채운 뒤 갑자기 저장이 실행되는 것은 버튼의 역할이 바뀌어서가 아니다. 앞에서는 유효성 검사가 제출 절차를 중단했던 것이다. 표준의 폼 제출 알고리즘 (새 탭에서 열림)도 유효성 검사 실패 시 submit 이벤트를 발생시키기 전에 돌아가도록 정한다. 이 증상을 고치려고 required를 없애거나 novalidate를 추가할 이유는 없다.

전송 취소도 분리해서 확인했다. 필수 값을 채우고 preventDefault()를 하지 않자 생략·submit 버튼은 각각 loopback의 수신점에 GET 요청 한 건을 보냈고, 일반 버튼의 요청은 없었다. 비교 중에는 iframe을 제출 대상으로 두었으며, 생략 버튼 한 번은 현재 창을 대상으로 바꿔 실제로 수신 페이지로 이동하는 것도 확인했다. 여기서 측정한 것은 로컬 요청 도달과 이동이며 서버의 저장 처리까지는 아니다.

내 코드에서는 클릭한 버튼과 submitter를 함께 본다

다음 HTML을 새 파일 form-button.html로 저장하고 브라우저에서 연다. 별도 서버나 라이브러리는 필요 없다. 개발자 도구의 Console을 열고 합성 문자열만 입력한다. 이 예제의 제출 리스너는 기본 전송을 취소한다.

<!doctype html>
<meta charset="utf-8">
<form id="demo">
  <label>제목 <input id="title" required></label>
  <button id="omitted">펼치기(생략)</button>
  <button id="plain" type="button">펼치기(button)</button>
  <button id="save" type="submit">저장(submit)</button>
  <p id="details" hidden>추가 설명</p>
</form>
<script>
const form = document.querySelector('#demo');
const details = document.querySelector('#details');
form.querySelectorAll('button').forEach(button => {
  button.addEventListener('click', () => {
    if (button.id !== 'save') details.hidden = !details.hidden;
    console.log('click', button.id, button.getAttribute('type'), button.type);
  });
});
form.addEventListener('invalid', e => console.log('invalid', e.target.id), true);
form.addEventListener('submit', e => {
  e.preventDefault();
  console.log('submit', e.submitter?.id, e.defaultPrevented);
});
</script>

빈 상태에서 생략 버튼을 누르면 invalid title이 나온다. 제목에 synthetic-title을 입력하고 다시 누르면 다음처럼 바뀐다. 이 짧은 예제도 Chromium에서 따로 실행해 확인했다.

click omitted null submit
submit omitted true

첫 줄의 null은 HTML에 type 속성이 없다는 뜻이다. 그 옆의 submit은 DOM의 button.type 값이다. 소스에 속성이 없어도 이 폼에서는 제출 역할을 갖는다는 것을 함께 볼 수 있다. 마지막 true는 제출 이벤트의 기본 동작을 취소했다는 뜻이며, 제출 리스너가 실행되지 않았다는 뜻은 아니다. SubmitEvent의 submitter 정의 (새 탭에서 열림)는 이 값이 제출을 시작한 버튼을 가리키며, 버튼에서 시작하지 않았으면 null일 수 있다고 설명한다.

type="button" 버튼을 누르면 click plain button button만 나오고 추가 설명만 펼쳐진다. 필수 값을 비우고 다시 눌러도 invalid와 submit은 없다. required를 지우고 새로 열면 빈 입력에서도 생략 버튼의 submit이 나타난다. 내 화면에서 결과가 다르다면 렌더링된 버튼의 type, 연결된 폼, 클릭 리스너의 추가 동작부터 확인한다.

type을 고쳐도 저장되면 호출 경로를 확인한다

일반 버튼의 기본 제출만 원인이라면 type="button"을 지정한 뒤 펼치기와 저장 버튼을 각각 눌러 확인하면 된다. 폼 전체의 제출을 막아 저장 기능까지 끄는 수정은 필요하지 않다. 위 예제의 preventDefault()는 관찰 중 전송을 막기 위한 장치다. 실제로 저장할 폼에서 그대로 사용할지는 제출 처리 방식에 따라 정한다.

다만 type="button"은 자바스크립트가 명시적으로 실행하는 저장까지 막지 않는다. 별도 조건으로 일반 버튼의 클릭 리스너가 저장 버튼을 지정해 form.requestSubmit(...)을 호출하게 하자, 클릭 ID는 plain인데 submitter.id는 save인 제출 이벤트가 기록됐다. 필수 입력을 비우면 이 경로에서도 invalid가 기록됐다. requestSubmit의 정의 (새 탭에서 열림)에 맞는 동작이다. submitter는 마우스로 처음 누른 요소와 항상 같은 것은 아니다.

따라서 타입 수정 뒤에도 저장된다면 펼치기 리스너가 저장 함수를 함께 호출하는지, 상위 클릭 리스너나 requestSubmit 호출이 연결돼 있는지 조사한다. 제출 로그 없이 저장 요청이 나간다면 Network의 요청과 직접 저장 함수를 부르는 경로도 함께 본다. 이 글에서 직접 비교한 것은 네이티브 버튼 클릭과 명시적 requestSubmit 경로다. Enter 키 제출, 프레임워크별 이벤트 처리, 다른 브라우저 엔진과 모바일 화면은 검증하지 않았다.

← 전체 글 보기