검증 로직은 렌더 중에 계산하세요
비밀번호 일치 검사를 Effect에 맡겼더니 한 글자마다 두 번씩 그려질 때 보세요.
회원가입 폼에서 비밀번호와 비밀번호 확인이 같은지 검사하려고 React의 useEffect를 꺼냈던 적이 있어요. 두 입력값을 보고 isMatch 라는 state를 갱신하는 코드요. 잘 돌아가는 것 같은데 프로파일러를 켜보면 글자 하나 칠 때마다 컴포넌트가 두 번씩 그려지고 있더라고요. 비밀번호 일치처럼 다른 state에서 계산되는 값은 state에 담아 Effect로 동기화할 게 아니라, 렌더 중에 그냥 계산하면 되는 값이에요.
비밀번호 검증을 Effect에 맡겼더니
흔히 이렇게 씁니다. 두 비밀번호를 state로 들고, 그 둘이 같은지를 또 다른 state isMatch 에 넣어둬요. 그리고 입력이 바뀔 때마다 useEffect로 isMatch 를 다시 맞춰주죠.
function SignupForm() {
const [password, setPassword] = useState("");
const [confirm, setConfirm] = useState("");
const [isMatch, setIsMatch] = useState(false);
// 안티패턴, 파생 값을 Effect로 state에 동기화
useEffect(() => {
setIsMatch(password === confirm && password !== "");
}, [password, confirm]);
return (
<form>
<input value={password} onChange={(e) => setPassword(e.target.value)} />
<input value={confirm} onChange={(e) => setConfirm(e.target.value)} />
{isMatch ? <p>비밀번호가 일치해요</p> : <p>아직 일치하지 않아요</p>}
</form>
);
}언뜻 문제없어 보여요. 근데 isMatch 는 password 와 confirm 만 있으면 언제든 다시 구할 수 있는 값이에요. state를 바꾸면 리렌더가 시작되는데, 그 얘기는 리액트가 언제 다시 그리는지 정리한 글에서 다뤘어요. 여기서는 그 리렌더가 한 번이 아니라 연쇄로 번지는 게 문제예요.
한 번의 입력이 두 번 그려지는 과정
onChange 로 password 가 바뀌면 React가 컴포넌트를 한 번 그려요. 이때 isMatch 는 아직 옛날 값이에요. 그려진 결과가 DOM에 반영(commit)되고 나서야 Effect가 돌고, 그 안에서 setIsMatch 가 호출돼요. state가 또 바뀌었으니 React는 처음부터 다시 그려요. 한 번의 키 입력이 두 번의 렌더가 되는 거예요.
React 공식 가이드는 이 추가 렌더를 두고 이렇게 적어요.
"If your Effect also immediately updates the state, this restarts the whole process from scratch!" - react.dev
Effect가 곧장 state를 또 바꾸면, 방금 끝낸 렌더 과정을 처음부터 통째로 다시 돌리는 셈이에요. 화면에 보일 결과는 똑같은데 일만 두 배로 한 거죠.
게다가 Effect는 보통 브라우저가 갱신된 화면을 칠한 다음에 실행돼요. 그래서 아주 짧은 순간이지만 isMatch 가 옛 값인 채로 한 프레임 보였다가 갱신되는 깜빡임이 생길 수 있어요.
Effect 안에서 곧바로 state를 바꾸는 건, 공식 가이드 표현을 빌리면 콘센트를 자기 자신에 꽂는 것과 비슷해요.
검증 값은 렌더 중에 계산하면 끝
해법은 isMatch 라는 state를 없애는 거예요. 두 입력값이 있으면 일치 여부는 렌더 중에 한 줄로 구할 수 있거든요.
"When something can be calculated from the existing props or state, don't put it in state. Instead, calculate it during rendering." - react.dev
이미 가진 값으로 구할 수 있으면 state에 넣지 말고 그릴 때 계산하라는 거예요. 그러면 동기화할 state가 사라지니 Effect도, 추가 렌더도 같이 사라져요.
const passwordsMatch = password === confirm 한 줄이면 돼요. 아래 데모에서 직접 입력해보세요. Effect도 isMatch state도 없는데 메시지가 입력 즉시 따라와요.
코드가 짧아진 것보다, 같은 입력에 렌더가 한 번으로 줄어든 게 핵심이에요. password 가 바뀌면 그릴 때 passwordsMatch 를 다시 계산할 뿐, state를 한 번 더 건드리지 않으니 연쇄가 끊겨요.
제출할 때 검증은 이벤트 핸들러로
그럼 8자 이상인지, 빈 칸은 없는지 같은 제출 검증은 어디서 할까요. 이것도 Effect로 감시하고 싶어지는데, Effect가 실행되는 시점에는 사용자가 무슨 버튼을 눌렀는지 알 수가 없어요. 제출 검증은 화면을 그리는 일이 아니라 사용자 동작에 대한 반응이라, 제출 핸들러 안에서 처리하는 게 맞아요.
검증 로직이 handleSubmit 안에 모여 있으니, "가입 버튼을 눌렀을 때만" 이라는 조건이 코드에 그대로 드러나요. 입력값을 state로 들고 있을지 ref로 둘지 고민된다면 제어와 비제어 입력을 먼저 보면 결이 잡혀요.
공식 린트도 이걸 막아요
이게 한 사람의 취향이 아니라는 증거가 하나 있어요. React 팀이 만든 eslint 규칙 중에 Effect 안에서 곧바로 state를 바꾸는 걸 잡아내는 set-state-in-effect 가 있거든요. 공식 문서는 이 패턴이 전체 렌더 사이클을 다시 시작하게 만들어서 성능을 깎는다고 설명해요.
"Setting state immediately inside an effect forces React to restart the entire render cycle." - react.dev
Effect 안에서 곧장 state를 set하면 React가 렌더 사이클을 통째로 다시 돌리게 된다는 거예요. 그래서 데이터 변환은 컴포넌트 최상단에서, 그릴 때 하라고 권하고요.
물론 파생 계산이 정말 무거우면 그릴 때마다 다시 구하는 게 부담일 수 있어요. 그때는 Effect가 아니라 useMemo 가 자리예요. 다만 무겁다고 느끼기 전에 감싸면 대부분 빗나가니, useMemo가 필요한 순간에서 측정 먼저 하는 쪽을 권해요. 비밀번호 비교 정도는 그냥 렌더 중에 계산하면 충분하고요.
자주 묻는 질문
답을 펼치기 전에 스스로 답해보세요