이 장에서는 평범한 폼(Form) 예제가 추가 요구사항을 만나며 어떻게 점점 더 많은 책임을 떠안게 되는지를 추적한다. 이 과정에서 입력값 외의 판단 결과까지 상태로 관리하려는 접근이 왜 폼을 단순하게 만들지 못하는지, 그리고 책임을 나누는 기준이 왜 중요한지를 살펴본다.

몇 개의 입력 필드만으로 구성된 초기 폼은 단순하다. 하지만 검증 규칙이 추가되고, 에러 메시지가 생기며, 필드 간 조건이 얽히기 시작하는 순간 상황은 달라진다. 개발자는 입력값뿐 아니라 검증 결과, 에러 상태, 버튼 활성 조건까지 컴포넌트 안으로 끌어들이게 된다. 문제는 이 판단들이 하나둘 늘어날수록, 서로 영향을 줄 수 있는 책임들이 한 곳에 모이기 시작한다는 점이다.

처음에는 단순히 값이 올바른지를 확인하던 코드가, 점점 어떤 조건에서 어떤 상태를 갱신해야 하는지를 끊임없이 관리해야 하는 방향으로 흘러가기 시작한다. 이때부터 폼은 더 이상 사용자의 입력을 받는 UI가 아니라, 여러 책임을 동시에 감당해야 하는 판단의 집합에 가까워진다.

1. 상태 정의와 책임의 붕괴

이 장에서는 상태를 어떻게 정의하느냐에 따라, 폼이 얼마나 빠르게 복잡해질 수 있는지를 살펴본다.

1.1. 상태가 상태를 낳는 구조

간단한 폼을 구현할 때는 일반적으로 입력값을 상태로 두고, 필요에 따라 검증 결과나 에러 메시지를 차례로 추가하게 된다. 이번 절에서는 이러한 방식으로 작성된 폼 예제를 통해 겉보기에는 정상적으로 동작하지만, 내부적으로는 구조적 취약점을 갖는 이유를 살펴본다.

폼 예제의 요구사항은 다음과 같다.

import { useEffect, useState } from "react";

export default function NameForm() {
  const [name, setName] = useState("");
  const [isValid, setIsValid] = useState(false);
  const [error, setError] = useState("");

  // name이 바뀔 때마다 "수동"으로 상태를 동기화해줘야 함
  useEffect(() => {
    if (name.length < 2) {
      setIsValid(false);
      setError("이름은 2글자 이상이어야 합니다.");
    } else {
      setIsValid(true);
      setError("");
    }
  }, [name]);

  return (
    <form>
      <input value={name} onChange={(e) => setName(e.target.value)} />
      {!isValid && <p>{error}</p>}
    </form>
  );
};

코드 2_1

이 코드에서는 입력값(name)과, 그 값으로부터 계산될 수 있는 검증 결과(isValid, error)가 각각 별도의 상태로 관리된다.

위 구조에서 name은 입력 이벤트를 통해 즉시 갱신되지만, 그에 의존하는 isValid, error는 올바른 값을 유지하기 위해 useEffect와 같은 방식으로 반드시 수동 동기화를 거쳐야 한다. 이처럼 입력값(원천 데이터)과 그 판단 결과(파생 값)를 독립적인 상태로 관리하는 방식은, 두 값 사이의 일관성을 개발자가 직접 책임지게 만든다.

그 결과, 조건이 늘어나고 로직이 복잡해질수록 상태 간의 일관성을 유지하기가 점점 어려워지고, 개발자의 판단이 어긋나는 순간 사용자는 데이터와 UI의 불일치를 겪게 된다.

결국 이는 상태를 잘못 정의한 문제에서 비롯된다. 다음 절에서는 개발자가 상태 갱신을 직접 관리하지 않도록 상태를 정의하는 방법에 대해 알아본다.