떠오를 때마다 적어둬요

form 안의 button은 기본이 submit이에요.

태그 추가 버튼이 답글을 제출하던 사건이에요.

대출톡톡 관리자 페이지에서 답변을 작성하던 중, 모달 안의 “태그 추가” 버튼을 누르면 답글이 그대로 제출돼버리는 버그가 있었어요. 결국 한 줄 차이로 고쳤지만, 이 버그가 알려준 HTML의 디테일과 그걸 막기 위한 장치들이 꽤 인상적이었어요.

1. 진단하기

처음에 좁혀야 했던 건 “어떤 액션이 실제로 폼을 제출하고 있는가” 였어요. 답변 작성 영역은 react-hook-form<FormProvider> 로 감싸져 있고, 그 안에 답변 본문 textarea, 태그 매니저, 그리고 태그 추가 모달이 자식으로 들어가 있는 구조예요.

DevTools 의 Event Listeners 패널에서 form 의 submit 이벤트에 breakpoint 를 걸고 다시 클릭해봤어요. “태그 추가” 버튼을 눌렀을 뿐인데 submit 이벤트가 발화하고, 이벤트의 target 이 outer form 자체였어요. 모달 안의 작은 버튼 하나가 form 의 submit 을 트리거하고 있는 상황이었어요.

여기서 의심이 갔어요. HTML 의 <button>type 을 명시하지 않으면 기본값이 submit 이거든요. 모달 내 버튼들 중 type 이 명시된 것은 단 하나도 없었어요. styled-components 로 감싸 모양은 달라 보여도, 결국 DOM 으로 펼쳐지면 모두 type="submit" 으로 동작하고 있었던 거죠. 그 결과 모달 안의 버튼을 누르면 그 클릭 이벤트가 onClick 핸들러를 거친 후에도, 브라우저의 기본 동작이 form 의 submit 을 그대로 호출했던 거예요.

2. 재현하기

원인을 확실히 하려고 라이브러리를 다 걷어내고 최소 재현 코드를 만들어봤어요.

function Demo() {
  return (
    <form onSubmit={(e) => { e.preventDefault(); alert('submitted!') }}>
      <input placeholder="답변 내용" />
      <button onClick={() => alert('태그 추가 클릭')}>
        태그 추가
      </button>
    </form>
  )
}

이 코드에서 “태그 추가” 버튼만 클릭하면, alert 두 개가 연달아 떠요. 먼저 “태그 추가 클릭” 이 뜨고, 그 다음 “submitted!” 가 뜨는 거예요. 즉 onClick 이 실행된 후에도 button 의 기본 submit 동작이 그대로 form 에 전달되고 있다는 뜻이에요.

여기서 <button>type"button" 으로 바꿔보면 두 번째 alert 는 더 이상 뜨지 않아요. 한 줄로 동작이 완전히 갈리는 거죠.

<button type="button" onClick={...}>태그 추가</button>

가설은 정확히 맞았어요. 모달 안 모든 버튼이 같은 함정에 빠져 있었던 거예요.

3. 수정하기

실제 코드 수정도 결국 한 줄짜리예요. 모달 내의 모든 click-only 버튼에 type="button" 을 명시해줬어요.

// before
<CreateButton onClick={handleCreateTag}>추가</CreateButton>
<CancelButton onClick={handleClose}>취소</CancelButton>
<SaveButton onClick={handleSave}>저장</SaveButton>

// after
<CreateButton type="button" onClick={handleCreateTag}>추가</CreateButton>
<CancelButton type="button" onClick={handleClose}>취소</CancelButton>
<SaveButton type="button" onClick={handleSave}>저장</SaveButton>

대안은 두 가지가 더 있었어요. onClick 안에서 e.preventDefault() 를 부르거나, e.stopPropagation() 으로 이벤트 전파를 차단하거나. 둘 다 동일하게 답글 제출은 막아줘요. 다만 그 두 방법은 사고가 일어나기 직전 마지막 순간에 막는 쪽이라, 새 동료가 비슷한 버튼을 한두 개 추가하면 같은 함정에 또 빠지기 쉬워요. 반면 type="button" 은 “이 버튼은 submit 버튼이 아니다” 를 선언으로 못 박는 방식이라, 사고 자체가 시작되지 않아요.

이벤트 전파 버그는 보통 막을 수 있는 지점이 한 군데가 아니에요. 그럴 땐 가능한 한 안쪽, 그러니까 사건이 시작되는 지점에서 막는 게 가장 깔끔해요.

4. 재발 방지하기

이런 류의 버그는 매번 디버깅으로 잡기엔 너무 흔해요. 그래서 팀에 몇 가지 안전장치를 추가했어요.

첫째, ESLint 의 react/button-has-type 규칙을 켰어요. 이 규칙은 <button>type 속성이 없으면 빌드 단계에서 경고를 띄워줘요. 디폴트가 위험하게 동작하는 영역에선 린터에게 맡기는 게 가장 확실해요. 사람의 주의력은 가장 약한 안전장치예요.

둘째, 공통 Button 컴포넌트의 props 타입에서 type 을 required 로 두는 방향도 검토했어요. styled-components 로 만든 버튼이 props 를 forward 할 때, type 이 없으면 타입 에러가 나도록 묶어두면 컴파일 타임에 막을 수 있거든요. 다만 기존 버튼이 너무 많아 한꺼번에 적용하긴 어려워서, 새로 만드는 공용 Button 부터 적용하는 식으로 점진적으로 옮기고 있어요.

셋째, 코드 리뷰 시 <form> 안에 새 버튼이 들어오면 type 을 한 번 더 확인하는 체크리스트를 추가했어요. 그러나 결국 가장 신뢰할 만한 가드는 자동화된 도구라서, 코드 리뷰는 보조 수단 정도로 보고 있어요.

HTML 디폴트는 가끔 우리 직관과 정반대로 동작해요. 그럴 땐 한 군데에서 명시적으로 막기보다, 디폴트가 발 아래에 있다는 사실 자체를 도구가 매번 알려주게 만드는 쪽이 훨씬 안전하다는 걸 다시 배운 사건이었어요.