대출톡톡 관리자 페이지에서 답변을 작성하던 중, 모달 안의 “태그 추가” 버튼을 누르면 답글이 그대로 제출돼버리는 버그가 있었어요. 결국 한 줄 차이로 고쳤지만, 이 버그가 알려준 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 디폴트는 가끔 우리 직관과 정반대로 동작해요. 그럴 땐 한 군데에서 명시적으로 막기보다, 디폴트가 발 아래에 있다는 사실 자체를 도구가 매번 알려주게 만드는 쪽이 훨씬 안전하다는 걸 다시 배운 사건이었어요.