포스트

엔터키가 쿼리를 멋대로 던질 때 — 폼 기본 동작과 불필요한 요청 차단

브라우저의 암묵적 폼 제출과 키 이벤트 기본 동작이 유발하는 원치 않은 서버 요청을 서버 부하 관점에서 진단하고 차단하는 설계.

엔터키가 쿼리를 멋대로 던질 때 — 폼 기본 동작과 불필요한 요청 차단

그 주에 본 현상은 “아무도 검색 버튼을 안 눌렀는데 무거운 메인 쿼리가 두 번씩 실행되는” 일이었다. 추상화하면 브라우저의 기본 동작(default behavior)이 사용자가 의도하지 않은 HTTP 요청을 만들어낸다는 문제다. PRG 패턴(중복 제출 방지)이나 요청 중복 제거 윈도우와 달리, 여기선 애초에 그 요청이 왜 발생했는가 — 폼과 키 이벤트의 암묵적 제출 자체에 초점을 둔다. 프론트 버그처럼 보이지만, 서버 입장에선 명백한 부하 문제다.

핵심: button의 기본 타입은 submit이다

HTML <form> 안의 <button>은 type을 안 적으면 type="submit"이 기본값이다. 즉 폼 안에서 “검색 조건 추가” 같은 보조 버튼을 무심코 <button>추가</button>로 두면, 그 버튼을 누르는 순간 폼이 제출되어 페이지 전체 요청(또는 메인 검색 쿼리)이 날아간다. 개발자는 “추가” 동작만 의도했는데 브라우저는 “제출”로 해석한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
<!-- 함정: type 미지정 → 암묵적 submit. 누르면 폼 전송 -->
<form action="/search" method="get">
  <input name="q">
  <button>조건 추가</button>          <!-- 의도치 않은 submit! -->
  <button type="submit">검색</button>
</form>

<!-- 처방: 제출 의도가 없는 버튼은 type="button"으로 명시 -->
<form action="/search" method="get">
  <input name="q">
  <button type="button">조건 추가</button>
  <button type="submit">검색</button>
</form>

엔터키의 암묵적 제출

두 번째 트리거는 키 이벤트다. 텍스트 입력 필드에 포커스가 있을 때 엔터를 누르면 폼이 제출된다. 이건 버그가 아니라 HTML 명세의 “implicit submission” 동작이다. 입력 중 무심코 엔터를 치는 사용자, 자동완성 확정 후 엔터, 혹은 단일 입력 필드 폼에서는 특히 자주 발생한다. 결과적으로 의도치 않은 시점에 검색 요청이 서버로 날아간다.

스페이스/엔터가 포커스된 버튼을 활성화하는 것도 같은 계열이다. 포커스가 submit 버튼에 가 있는데 스페이스를 누르면 제출된다.

서버 부하 관점의 진단

이 현상이 위험한 이유는 메인 쿼리가 무거울 때 드러난다. 검색 화면의 제출이 페이징·집계·다중 조인을 도는 비싼 쿼리라면, 의도치 않은 제출 한 번이 그대로 DB 부하다. 사용자가 빠르게 두 번 엔터를 치거나, 버튼 오작동으로 연달아 제출되면 같은 무거운 쿼리가 중복 실행된다.

진단의 출발점은 서버 액세스 로그다. 동일 파라미터의 동일 엔드포인트 요청이 수십~수백 ms 간격으로 두 번 찍히면, 사용자의 더블클릭이거나 폼 기본 동작 중복이다. 로그의 Referer/요청 간격으로 “사람이 의도한 두 번”인지 “기본 동작이 만든 두 번”인지 구분한다.

차단 설계: 단계별 방어

방어는 발생 지점에 가깝게 거는 게 싸다.

1) 발생 차단(가장 싸다). 제출 의도 없는 버튼에 type="button"을 명시한다. 단일 필드 폼의 엔터 제출이 문제면, 제출 핸들러에서 기본 동작을 막고 의도한 동작만 수행한다(event.preventDefault()). JS는 최소한으로.

2) 중복 흡수(클라). 연타로 인한 중복은 제출 직후 버튼 비활성화 + 입력 디바운스로 흡수한다. 자동 검색(타이핑마다 조회)이라면 디바운스가 필수다.

3) 서버 방어(최후의 보루). 클라를 신뢰하지 않는다는 원칙상, 서버도 중복 요청에 견뎌야 한다. 같은 사용자의 동일 요청을 짧은 윈도우 동안 멱등하게 처리하거나, 무거운 조회 결과를 짧은 TTL로 캐시해 중복 제출이 같은 캐시를 때리게 한다.

1
2
3
4
5
6
// 서버: 무거운 검색 결과를 짧은 TTL로 캐시 → 직후 중복 제출은 캐시 히트
@Cacheable(cacheNames = "searchResult",
           key = "#req.cacheKey()")   // 정규화된 조건 = 키
public Page<Item> search(SearchReq req) {
    return repository.search(req);     // 무거운 메인 쿼리
}

이렇게 하면 “엔터 두 번”이 들어와도 두 번째는 DB가 아니라 캐시를 친다. 발생을 못 막더라도 비용은 죽인다.

운영 함정

함정 1 — GET 검색 폼의 중복은 멱등이라 안심? 아니다. GET이 멱등이라 상태는 안 깨진다. 하지만 비용은 멱등이 아니다. 무거운 조회는 호출할 때마다 자원을 쓴다. “멱등하니 괜찮다”는 안전성과 부하를 혼동한 오판이다.

함정 2 — JS로 다 막으려다 빌드/접근성 깨짐. 모든 키 이벤트를 JS로 가로채면 접근성(키보드 사용자)이 망가지고, 인라인 스크립트는 빌드 압축 함정도 부른다. 가장 견고한 1차 방어는 마크업의 type 명시라는 정공법이다.

핵심 요약

  • 폼 안 <button>의 기본 타입은 submit, 입력 필드의 엔터는 암묵 제출이다. 둘 다 원치 않는 요청을 만든다.
  • GET이라 멱등이어도 무거운 쿼리는 호출마다 비용이다. 멱등 ≠ 무비용.
  • 방어는 마크업 type 명시(발생 차단) → 디바운스/비활성화(중복 흡수) → 서버 단기 캐시(비용 차단) 순으로 계층화한다.

면접 한 줄 Q&A Q. 아무도 버튼을 안 눌렀는데 검색 쿼리가 두 번 실행된다. 원인 후보는? A. 폼 내 버튼의 암묵적 submit 타입, 입력 필드의 엔터 암묵 제출, 더블클릭. 발생 지점은 마크업 type으로, 비용은 서버 단기 캐시로 막는다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.