폼은 버튼 몇 개, 입력칸 몇 개니까 느릴 일이 없다고들 생각해요. 저도 그랬죠. 그런데 입력칸이 50개짜리 신청서를 만들었더니, 한 글자 칠 때마다 화면이 0.2초씩 멈칫하는 거예요. 타이핑이 화면을 못 따라와요. 폼도 커지고 실시간 검증까지 붙으면 충분히 느려져요. 오늘은 폼이 왜 버벅이는지, 그리고 리렌더, 디바운스, 큰 폼 최적화로 어떻게 가볍게 만드는지 볼게요.
13.1 폼이 느려지는 건 어떤 순간일까요?
폼이 무거워지는 이유는 크게 셋이에요. 첫째, 화면에 올라온 입력 요소가 너무 많을 때예요. 브라우저는 화면 위 DOM(문서 구성 조각) 수만큼 힘을 써요. 둘째, 글자 하나 칠 때마다 화면을 통째로 다시 그릴 때예요. 셋째, 입력할 때마다 무거운 일(검증이나 서버 요청)을 시킬 때고요.
다행히 셋 다 해법이 뚜렷해요. 각각 덜 그리기, 바뀐 것만 그리기, 무거운 일은 몰아서 하기로 풀려요. 참고로 폼이 진짜 느린지 감으로 판단하지 말고, 개발자 도구의 Performance(성능) 탭으로 한번 재보는 걸 권해요. 타이핑할 때 프레임이 뚝뚝 끊기는지, 어떤 함수가 오래 걸리는지 눈으로 보이거든요. 원인을 확인한 다음 고쳐야 헛수고를 안 해요. 하나씩 볼게요.
13.2 입력할 때마다 화면 전체가 다시 그려진다고요?
가장 흔한 원인은 리렌더(re-render, 화면 다시 그리기)예요. 입력값을 어딘가에 담아두고, 한 글자 칠 때마다 폼 전체를 다시 그리는 코드를 짜기 쉽거든요. 칸이 5개면 티가 안 나요. 그런데 50개면 글자 하나에 50개 칸을 통째로 다시 만들죠. 손가락이 화면을 앞질러요.
input.addEventListener("input", () => {
state.value = input.value;
form.innerHTML = render(state);
});
이건 최악이에요. 글자 하나 넣을 때마다 폼 전체 HTML을 새로 만들어 갈아끼우니, 방금 치던 칸의 커서 위치까지 날아가요. 해법은 바뀐 부분만 손대는 거예요. 값이 바뀌어도 다시 그릴 필요 없는 칸은 그대로 두고, 정말 바뀐 한 곳만 갱신해요.
input.addEventListener("input", () => {
state.value = input.value;
errorBox.textContent = validate(input.value);
});
이러면 글자를 쳐도 건드리는 건 에러 문구 한 줄뿐이라 폼이 매끄러워요. 리액트(React) 같은 도구를 쓴다면, 입력 상태를 작은 조각으로 쪼개 그 칸만 다시 그리게 하거나, 아예 비제어 방식으로 브라우저에 맡기는 게 같은 원리예요.
13.3 디바운스는 왜 꼭 필요해요?
실시간 기능을 붙이면 새 문제가 생겨요. 아이디 칸에 중복 확인을 실시간으로 건다고 해봐요. abcdef를 치면 a, ab, abc 순서로 글자마다 서버에 6번 물어봐요. 사용자가 빠르게 치면 서버는 쓸모없는 요청 폭탄을 맞죠. 이걸 막는 게 디바운스(debounce, 잠잠해질 때까지 기다리기)예요.
핵심은 타이핑이 멈추고 나서 딱 한 번 실행하는 거예요. 글자를 칠 때마다 예약을 걸되, 새 글자가 오면 이전 예약을 취소하고 다시 걸어요. 그러다 일정 시간(보통 0.3초) 동안 입력이 없으면 그때 실행돼요.
let timer;
input.addEventListener("input", () => {
clearTimeout(timer);
timer = setTimeout(() => {
checkId(input.value);
}, 300);
});
새 입력이 올 때마다 clearTimeout으로 앞의 예약을 지우고, setTimeout으로 300밀리초 뒤 실행을 다시 걸어요. 결과적으로 abcdef를 다다닥 치면 서버 요청은 6번이 아니라 1번이에요. 서버도 편하고, 사용자는 여전히 실시간처럼 느껴요.
13.4 칸이 수십 개인 폼은 어떻게 가볍게 그려요?
입력칸이 정말 많은 폼, 예를 들어 단계별 신청서라면 접근을 바꿔요. 한 화면에 전부 다 그리지 않는 거예요. 브라우저는 화면에 올라온 DOM 요소 수만큼 무거워지니까요. 50개를 한 번에 띄우는 대신, 단계로 쪼개 지금 단계만 그려요.
흔한 방법이 단계 나누기(step, 여러 화면으로 분할)예요. 1단계에서 기본 정보만 보여주고, 다음을 누르면 그때 2단계를 그려요. 화면에 떠 있는 칸이 늘 10개 안팎으로 유지되니 항상 가벼워요. 안 보이는 단계를 display none으로 숨기기만 하면 DOM에는 그대로 남아 무게가 안 줄어요. 필요할 때 만들어 붙이는 게 진짜 최적화예요.
목록이 아주 긴 경우, 예를 들어 수백 줄짜리 선택 항목이라면 가상 스크롤(virtual scroll, 보이는 만큼만 그리기)을 써요. 화면에 실제로 보이는 10여 줄만 DOM에 만들고, 스크롤하면 그 자리에 내용만 바꿔치기하는 기법이에요. 항목이 1000개든 화면 요소는 늘 몇십 개라 뚝뚝 끊기지 않아요.
13.5 검증은 언제 돌려야 안 버벅여요?
검증도 타이밍이 성능을 좌우해요. 글자 길이 확인 같은 가벼운 검사는 입력할 때마다 해도 괜찮아요. 하지만 정규식(패턴 검사식) 여러 개를 돌리거나 서버에 물어보는 무거운 검사를 글자마다 하면 폼이 무거워지죠.
실무 요령은 이래요. 무거운 검증은 칸에서 포커스가 빠질 때(blur, 다른 칸으로 넘어가는 순간)나 디바운스를 걸어서 해요. 사용자가 다 치기도 전에 이메일 형식이 틀렸어요를 빨간 글씨로 다그치면 성능도 나쁘고 기분도 나빠요. 다 쓰고 넘어갈 때 한 번 확인하는 게 성능과 경험 둘 다 좋아요.
13.6 제출 순간에도 챙길 게 있나요?
느린 네트워크에서 사용자가 보내기를 여러 번 누르는 일이 잦아요. 응답이 안 오니 안 눌렸나 하고 또 누르죠. 그러면 같은 신청이 세 번 들어가요. 성능이자 데이터 문제예요. 제출이 시작되면 버튼을 즉시 잠가야 해요.
form.addEventListener("submit", async (e) => {
e.preventDefault();
button.disabled = true;
await send(new FormData(form));
button.disabled = false;
});
제출이 시작되면 button.disabled = true로 버튼을 잠가 연타를 막고, 응답이 오면 다시 풀어요. 이러면 중복 제출도 없고, 사용자도 지금 처리 중이구나를 눈으로 알죠. 서버에서도 같은 요청을 한 번만 받도록 한 번 더 잠그면 완벽하지만, 화면의 이 한 줄만으로도 흔한 사고는 크게 줄어요.
13.7 오늘 정리
폼은 커지고 실시간 기능이 붙으면 얼마든지 느려진다고 했어요. 가장 흔한 범인은 리렌더, 글자 하나에 폼 전체를 다시 그리는 습관이었죠. 바뀐 한 곳만 손대는 것으로 매끄러워졌고요. 실시간 검색이나 중복 확인은 디바운스로 타이핑이 멈춘 뒤 한 번만 실행해 서버 요청을 확 줄였어요. 칸이 수십 개인 폼은 단계 나누기와 가상 스크롤로 화면 요소 수를 늘 적게 유지했고요. 무거운 검증은 다 쓰고 넘어갈 때 돌리고, 제출 순간엔 버튼을 잠가 중복 제출을 막았죠. 정리하면 폼 성능은 불필요하게 다시 그리지 말고, 무거운 일은 몰아서 한 번만, 이 두 문장으로 요약돼요.