
폼(Form) 입력이 거북이처럼 느릴 때: 리렌더링 지옥 탈출기
대용량 폼에서 입력 지연을 해결하는 디바운싱과 최적화 기법

대용량 폼에서 입력 지연을 해결하는 디바운싱과 최적화 기법
느리다고 느껴서 감으로 최적화했는데 오히려 더 느려졌다. 프로파일러로 병목을 정확히 찾는 법을 배운 이야기.

텍스트에서 바이너리로(HTTP/2), TCP에서 UDP로(HTTP/3). 한 줄로서기 대신 병렬처리 가능해진 웹의 진화. 구글이 주도한 QUIC 프로토콜 이야기.

HTML 파싱부터 DOM, CSSOM 생성, 렌더 트리, 레이아웃(Reflow), 페인트(Repaint), 그리고 합성(Composite)까지. 브라우저가 화면을 그리는 6단계 과정과 치명적인 렌더링 성능 최적화(CRP) 가이드.

습관적으로 모든 변수에 `useMemo`를 감싸고 있나요? 그게 오히려 성능을 망치고 있습니다. 메모이제이션 비용과 올바른 최적화 타이밍.

제 서비스인 SaaS 제품에는 '비즈니스 정보 설정'이라는 페이지가 있습니다. 처음 기획할 때는 사업자 번호랑 대표자명 정도만 받을 줄 알았는데, 막상 개발하고 보니 요구사항이 끝도 없이 늘어났습니다.
세어보니 input 태그만 50개가 넘는 거대한 괴물이 되어버렸습니다.
그래도 뭐, 요즘 시대에 입력 좀 많다고 컴퓨터가 힘들어하겠어? 라고 안일하게 생각했습니다.
그런데 라이브 런칭 후, 고객센터로 불만이 접수되었습니다.
"설정 페이지에서 타자가 계속 씹혀요. 한 글자 치면 0.5초 뒤에 나옵니다. 주소 치다가 복장 터져서 못 해먹겠어요."
제 200만 원짜리 최신형 맥북에서는 미세한 딜레이 정도만 느껴졌는데, 5년 된 윈도우 노트북으로 테스트해보니 그 심각성을 알 수 있었습니다. 'A'를 치면... (멈칫) ... 'A'가 나옵니다. 마치 렉 걸린 온라인 게임을 하는 기분이었습니다. 텍스트 입력이 슬라이드 쇼처럼 뚝뚝 끊겼습니다.
분명 React는 빠르다고 했는데, 왜 고작 텍스트 입력 하나 처리를 못 하는 걸까요?
범인은 React DevTools의 Profiler를 켜자마자 잡혔습니다. Profiler의 'Highlight updates when components render' 옵션을 켜고 input에 한 글자를 입력해봤습니다.
번쩍! 화면 전체가 노란색 테두리로 번쩍거렸습니다. 제가 타자를 한 글자 칠 때마다, 50개의 입력 필드와 그 주변의 레이아웃, 버튼들까지 설정 페이지 전체가 리렌더링되고 있었던 겁니다.
제 코드는 대충 이랬습니다. 전형적인 "Monolithic State" 구조였습니다.
/* ❌ 범인은 이 안에 있어 */
function SettingsPage() {
// 50개의 상태를 하나의 객체로 관리
const [formData, setFormData] = useState({
companyName: '',
ceoName: '',
address: '',
bankAccount: '',
managerPhone: '',
// ... 50개의 필드들
});
const handleChange = (e) => {
// 하나를 바꾸면...
setFormData({ ...formData, [e.target.name]: e.target.value });
// 객체 전체가 새로 만들어지는 것과 같음 -> SettingsPage가 리렌더링된다.
};
return (
<form>
<input name="companyName" value={formData.companyName} onChange={handleChange} />
{/* ... 49개의 끔찍한 형제 자매들 */}
<input name="address" value={formData.address} onChange={handleChange} />
</form>
);
}
이 구조의 치명적인 문제점은, 부모 컴포넌트(SettingsPage)의 상태가 바뀌면 모든 자식 컴포넌트(input)가 다시 그려진다는 React의 기본 원칙 때문입니다.
companyName에 'A'라는 글자 하나를 입력했을 뿐인데, React는 나머지 49개의 input도 전부 새로 그릴 준비를 합니다. 물론 VDOM(Virtual DOM)이 비교해서 실제 DOM은 안 바뀐다고 해도, 그 비교 연산(Diffing) 자체가 50번 일어나는 것이 문제입니다. 저가형 기기에서는 이 연산 비용이 0.1초 이상 걸려버린 것이죠.
이 문제를 해결하기 위해 공부하다가 재밌는 비유를 봤습니다.
"폼(Form)은 거대한 아파트고, 입력 필드(Input)는 각각의 방이다. 101호의 전등을 켜고 싶다고 해서 아파트 전체를 허물고 다시 짓는 사람은 없다."
제 코드는 글자 하나 바뀔 때마다 아파트를 재건축하고 있었던 겁니다. 해결책은 확실해졌습니다. 전등 스위치(State)를 각 방(Component) 안으로 옮기거나, 아예 스위치를 쓰지 않는 것입니다.
가장 충격적인 사실은, HTML의 원조 input 태그는 원래 엄청나게 빠르다는 겁니다.
브라우저 네이티브 기능이니까요. React가 useState로 값을 제어(Controlled)하려고 하니까 느려진 것입니다. React가 "너 내가 허락하기 전엔 값 못 바꿔"라고 붙잡고 있는 꼴이죠.
그래서 React의 감시망을 피하는 비제어 컴포넌트(Uncontrolled Component)를 사용하기로 했습니다.
function SettingsPage() {
const nameRef = useRef(); // 상태 변경 시 리렌더링 안 함!
const handleSubmit = (e) => {
e.preventDefault();
console.log(nameRef.current.value); // 제출할 때만 DOM에서 값 읽어오기
};
return (
<form onSubmit={handleSubmit}>
{/* 1. value와 onChange를 뺍니다. */}
{/* 2. ref를 연결합니다. */}
<input ref={nameRef} defaultValue="" />
<button type="submit">저장</button>
</form>
);
}
이렇게 하면 타자를 칠 때 React는 아무 일도 안 합니다. DOM 업데이트는 브라우저가 알아서 하고, React는 신경 끕니다. 입속도가 빛의 속도가 됩니다.
하지만 useRef를 50개나 만들 수는 없잖아요? 유효성 검사(Validation)도 해야 하고요.
useRef로 유효성 검사를 하려면 onChange 이벤트를 또 붙여야 하고... 결국 다시 원점으로 돌아옵니다.
이 모든 귀찮음을 해결해 준 라이브러리가 바로 React Hook Form입니다.
이 라이브러리의 핵심 철학이 바로 "불필요한 리렌더링 제거"입니다.
이름은 Hook Form이지만, 내부적으로는 Uncontrolled Component 패턴을 사용합니다. ref를 등록(register)해서 값을 관리하다가, 진짜 필요할 때(검증 에러 발생, 폼 제출)만 리렌더링을 일으킵니다.
graph TD
User[사용자 입력 'A'] --> Input[Input DOM]
Input -- onChange --> ReactHookForm[React Hook Form 내부 로직]
ReactHookForm -- No Re-render --> ReactHookForm
ReactHookForm -- Only on Error/Submit --> ReRender[컴포넌트 리렌더링]
style User fill:#f9f,stroke:#333
style ReactHookForm fill:#bbf,stroke:#333
코드를 바꿔봤습니다. 놀랍게도 50줄의 useState가 사라지고 훨씬 깔끔해졌습니다.
/* ✅ 구원받은 코드 */
import { useForm } from "react-hook-form";
function SettingsPage() {
const { register, handleSubmit, formState: { errors } } = useForm();
const onSubmit = (data) => console.log(data);
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* register 함수가 ref와 onChange 등을 알아서 주입해줌 */}
<input {...register("companyName", { required: true })} />
{errors.companyName && <span>이름을 입력해주세요</span>}
{/* ... 49개 필드들도 동일하게 */}
<input {...register("address")} />
<button type="submit">저장</button>
</form>
);
}
결과: 타자를 쳐도 SettingsPage 컴포넌트는 리렌더링 되지 않았습니다.
0.5초의 딜레이가 즉시 사라졌습니다. 마치 모래주머니를 차고 뛰다가 벗어던진 기분이었습니다.
또 하나의 병목은 실시간 유효성 검사였습니다. 특히 "이미 가입된 이메일인지 확인"하거나 "사업자 번호 유효성 조회" 같은 무거운 작업이 문제였습니다.
처음엔 멋모르고 onChange에서 API를 호출했습니다.
사용자가 hello@tm까지 쳤는데 "이메일 형식이 틀렸습니다!", "서버 조회 중..." 메시지가 번쩍거리면, 서버에도 부하가 가고 사용자 경험도 엉망이 됩니다.
여기서 Debouncing(디바운싱)을 적용해야 합니다. "사용자가 타자를 멈추고 0.5초가 지나면 그때 검사해라."
비슷해 보이지만 다릅니다. 이 상황에서는 디바운싱이 정답입니다.
function EmailInput() {
const [email, setEmail] = useState("");
// useEffect로 디바운싱 구현
useEffect(() => {
const timer = setTimeout(() => {
if (email) checkEmailDuplicate(email);
}, 500); // 0.5초 대기
// 0.5초 안에 email이 또 바뀌면 이전 타이머 취소 (Clean-up)
return () => clearTimeout(timer);
}, [email]);
return <input value={email} onChange={(e) => setEmail(e.target.value)} />;
}
React Hook Form을 쓴다면 mode: "onChange" 대신 mode: "onBlur"(포커스 잃을 때)를 쓰거나, 별도 훅을 만들어서 적용할 수 있습니다.
React Hook Form 같은 외부 라이브러리를 쓰기 싫다면 어떻게 해야 할까요? 전략적인 State Colocation(상태 배치)만 잘해도 성능을 잡을 수 있습니다.
원칙은 하나입니다: "상태(State)를 최대한 아래로(Leaf Node) 내려라."
50개 필드의 상태를 부모가 다 가지고 있으면 부모가 아픕니다. 각 필드를 독립된 컴포넌트로 만들고, 상태도 그 안에서 관리하세요.
/* 👍 각자도생 패턴 */
function NameInput() {
const [name, setName] = useState(""); // 이 상태 변경은 NameInput만 리렌더링시킴!
return <input value={name} onChange={(e) => setName(e.target.value)} />;
}
function AddressInput() {
const [address, setAddress] = useState(""); // AddressInput만 리렌더링됨
return <input value={address} onChange={(e) => setAddress(e.target.value)} />;
}
function SettingsPage() {
return (
<div>
<h1>설정 페이지 (평온함)</h1>
<NameInput />
<AddressInput />
{/* ... 나머지 48개 */}
</div>
);
}
이렇게 하면 NameInput에서 초당 10번 리렌더링이 일어나도, 바로 옆집 AddressInput이나 부모 SettingsPage는 영향을 받지 않습니다. 이것이 State Isolation(상태 격리)입니다.
물론 나중에 폼 제출할 때 데이터를 어떻게 모으냐는 문제가 생기지만(Context API나 Recoil 등이 필요할 수 있음), 렌더링 성능 면에서는 가장 확실한 방법입니다.
개발자 PC는 보통 고사양(M1, M2 Mac 등)이라 이런 성능 이슈를 놓치기 쉽습니다. 하지만 사용자는 5년 된 노트북, 배터리 절약 모드의 보급형 스마트폰으로 우리 서비스에 접속합니다.
폼 입력 반응 속도는 UX의 기본 중의 기본입니다. 타자를 치는데 글자가 늦게 따라오면 사용자는 "이 사이트 뭔가 불안한데?"라고 느낍니다. 입력한 정보가 날아갈 것 같은 불안감을 주죠. 이는 곧 서비스 신뢰도 하락으로 이어집니다.
여러분의 폼이 거북이처럼 느리다면, 당장 Profiler를 켜보세요. React가 비명을 지르며 불필요한 그림을 50번씩 그리고 있을 겁니다. React Hook Form으로 갈아타거나, State를 격리시켜서 React에게 휴식을 주세요.
한 줄 요약: "폼 성능 최적화의 핵심은 입력할 때 폼 전체가 다시 그려지는 것을 막는 것이다."