3. Form Performance Traps

React Compiler, 렌더링 중 ref 읽기, Controller와 resolver, 재귀 스키마

  • Haram Lee
  • 2026-09-10
  • studies / Topics / React Forms

3.1 React Compiler and Manual Memoization

1편에서 렌더링은 컴포넌트 함수를 다시 실행하는 것이라고 했다. 함수가 다시 실행되면 그 안에서 만든 객체와 함수도 매번 새로 만들어진다. 그래서 예전에는 비싼 계산을 useMemo로, 자식에게 넘기는 함수를 useCallback으로 감쌌다. 폼에서는 resolver와 스키마가 대표적이었다.

tsx
// 렌더마다 스키마를 다시 조립하지 않도록 감싸던 코드
const resolver = useMemo(() => zodResolver(buildSchema(fields)), [fields]);

React Compiler는 이 일을 빌드 시점에 자동으로 한다. 컴포넌트를 분석해서 어떤 값이 어떤 입력에 의존하는지 계산하고, 입력이 같으면 이전 결과를 재사용하는 코드를 넣는다. 컴파일러를 켠 프로젝트에서는 새 코드에 useMemo와 useCallback을 직접 쓸 일이 거의 없다. 리뷰에서 이걸 지적받았다. 컴파일러가 이미 하는 일을 손으로 한 번 더 적으면 코드만 길어지고, 읽는 사람은 “여기는 왜 따로 감쌌지?“를 고민하게 된다.

이미 있는 useMemo는 굳이 걷어내지 않아도 된다. 컴파일러는 기존 수동 메모이제이션을 존중하므로 지운다고 빨라지지 않고, 지우는 과정에서 동작이 바뀔 위험만 생긴다. 새로 쓰는 코드에서만 쓰지 않으면 된다.

3.2 Reading a Ref During Render

컴파일러는 모든 컴포넌트를 최적화하지 않는다. React의 규칙을 어기는 코드를 만나면 그 컴포넌트를 통째로 최적화에서 뺀다. 컴파일러의 가정이 깨지면 메모이제이션이 틀린 결과를 낼 수 있기 때문이다. 에러도 경고도 없이 조용히 빠진다.

그 규칙 중 하나가 렌더링 중에 ref.current를 읽지 않는 것이다. 1편에서 ref는 바뀌어도 리렌더를 일으키지 않는다고 했다. 거꾸로 말하면 React는 ref가 언제 바뀌었는지 모른다. 렌더링 결과가 ref 값에 의존하면, 컴파일러는 그 결과를 언제 다시 계산해야 할지 알 수 없다.

tsx
function FieldList({ fields }: { fields: Field[] }) {
  const lastEditedRef = useRef<string | null>(null);

  return (
    <ul>
      {fields.map((field) => (
        // 렌더링 중에 ref를 읽는다. 이 컴포넌트는 컴파일러 최적화에서 빠진다
        <li key={field.id} data-active={lastEditedRef.current === field.id}>
          {field.label}
        </li>
      ))}
    </ul>
  );
}

이런 컴포넌트는 부모가 리렌더될 때마다 같이 다시 그려진다. 입력 칸 옆에 목록이 붙은 화면이라면, 키를 하나 칠 때마다 목록 전체가 다시 그려진다. 고치는 방법은 둘이다. 화면에 쓰는 값이면 ref가 아니라 state로 둔다. 화면에 안 쓰는 값이면 ref를 읽는 위치를 이벤트 핸들러나 effect 안으로 옮긴다.

이 규칙은 eslint-plugin-react-hooks가 잡아 준다. 문제는 컴파일러를 도입하기 전에 쓴 코드가 많은 프로젝트다. 기존 코드에서 경고가 쏟아지니 그 규칙을 꺼 두는 경우가 있다. 그러면 새 코드가 같은 실수를 해도 lint가 말해 주지 않는다. 그 상태라면 “왜 이 컴포넌트만 메모이제이션이 안 되지?” 싶을 때 렌더링 중에 ref를 읽는 곳부터 찾아본다. React DevTools에서 컴파일러가 최적화한 컴포넌트에는 표시가 붙으므로, 표시가 없는 컴포넌트가 후보다.

3.3 Controller Mounts Run the Resolver

큰 폼에서 접혀 있던 고급 설정을 펼치거나 목록의 항목을 처음 선택하면 화면이 수백 ms 멈추는 문제를 겪었다. 키 입력은 멀쩡했다. 원인은 RHF가 Controller를 마운트할 때마다 resolver를 실행하는 데 있었다.

RHF의 formState는 구독한 값만 추적한다. 폼이 formState.isValid를 구독하고 있으면, 새 필드가 등록될 때마다 RHF는 폼이 여전히 유효한지 다시 계산한다. resolver를 쓰는 폼에서 그 계산은 폼 값 전체를 스키마 전체로 검증한다는 뜻이다. 칸 하나가 마운트됐을 뿐인데 검증은 폼 전체를 대상으로 실행된다.

tsx
const form = useForm({ resolver: zodResolver(schema), mode: "onChange" });
const { isValid } = form.formState; // 이 구독 때문에 필드 등록마다 전체 검증이 돈다

return (
  <Disclosure label="고급 설정">
    {/* 펼치는 순간 Controller 14개가 마운트되고, resolver도 14번 돈다 */}
    {advancedFields.map((f) => (
      <Controller key={f.key} name={f.key} control={form.control} render={renderField} />
    ))}
  </Disclosure>
);

그래서 비용은 “검증 한 번의 비용 × 마운트되는 Controller 수"가 된다. 검증 한 번이 1ms 미만인 폼에서는 14번 실행돼도 차이를 느낄 수 없다. 검증 한 번이 수십 ms인 폼에서는 펼칠 때마다 멈춘다. 같은 화면 구조인데 어떤 폼은 멀쩡하고 어떤 폼만 느렸던 이유가 이것이었다. 차이는 화면이 아니라 스키마에 있었다.

3.4 Measure Which Path Is Slow

이 문제를 처음에는 키 입력 경로에서 찾았다. 키 입력 쪽을 고쳤는데 펼칠 때 멈추는 증상은 그대로였다. 느린 경로가 키 입력인지 마운트인지를 먼저 구분했어야 했다.

구분하는 방법은 resolver를 감싸서 몇 번 실행되는지, 얼마나 오래 걸리는지 재는 것이다.

ts
let calls = 0;
let totalMs = 0;

const baseResolver = zodResolver(schema);
const countingResolver: typeof baseResolver = async (values, context, options) => {
  const start = performance.now();
  const result = await baseResolver(values, context, options);
  calls += 1;
  totalMs += performance.now() - start;
  return result;
};

이 resolver를 연결하고 동작을 하나씩 해 보면 숫자가 나온다. 항목이 수십 개이고 재귀 노드가 수백 개인 폼에서 재 보니, 접힌 영역을 펼치는 클릭 한 번에 resolver가 열 번 넘게 실행됐다. 검증을 건너뛰게 바꾸면 같은 클릭에 걸리는 시간이 수십 분의 1로 줄었다. 클릭 처리 시간의 대부분이 검증이었고, 검증 시간의 대부분은 스키마의 한 부분에서 나왔다.

3.5 The Cost of Recursive Schemas

느린 부분은 재귀 스키마였다. 중첩 타입 정의처럼, 노드가 자식 노드 목록을 가질 수 있는 트리를 검증하는 스키마다.

ts
type TypeNode =
  | { name: string; type: "string" | "int" | "bool" }
  | { name: string; type: "array"; items: TypeNode }
  | { name: string; type: "object"; fields: TypeNode[] };

const typeNode: z.ZodType<TypeNode> = z.lazy(() =>
  z.union([
    z.object({ name: z.string(), type: z.enum(["string", "int", "bool"]) }),
    z.object({ name: z.string(), type: z.literal("array"), items: typeNode }),
    z.object({ name: z.string(), type: z.literal("object"), fields: z.array(typeNode) }),
  ]),
);

z.lazy는 재귀를 쓰기 위해 필요하다. 문제는 z.union과 겹칠 때다. union은 가지를 앞에서부터 하나씩 시도한다. 노드 하나를 검증할 때 맞는 가지를 찾을 때까지 다른 가지를 시도하고, 그 안에서 다시 자식 노드마다 같은 일을 한다. 트리가 깊고 넓을수록 비용이 커진다. 그리고 resolver는 매번 폼 전체를 검증하므로, 사용자가 건드리지도 않은 항목의 트리까지 매번 처음부터 다시 파싱한다. 여기에 3.3에서 본 곱셈이 더해지면 펼칠 때마다 수백 ms가 걸린다.

pydantic이라면 서버에서 요청 하나당 한 번 검증하고 끝난다. 클라이언트 폼에서는 같은 검증이 입력마다, 마운트마다 실행된다. 스키마의 비용을 보는 기준이 달라져야 한다.

3.6 Caching Validation per Object

고친 방법은 트리 검증 결과를 항목 단위로 캐시하는 것이었다. 바뀌지 않은 항목의 트리는 이전 결과를 쓴다.

ts
const treeResultCache = new Map<string, boolean>();
const typeTree = z.array(typeNode);

const cachedTypeTree = z.array(z.unknown()).superRefine((nodes, ctx) => {
  const key = JSON.stringify(nodes);
  let ok = treeResultCache.get(key);
  if (ok === undefined) {
    ok = typeTree.safeParse(nodes).success;
    treeResultCache.set(key, ok);
  }
  if (!ok) {
    ctx.addIssue({ code: z.ZodIssueCode.custom, message: "타입 정의가 올바르지 않습니다" });
  }
});

캐시 키를 무엇으로 잡을지가 중요하다. 객체 참조를 키로 쓰는 WeakMap이 더 싸지만, 폼 라이브러리가 값을 제자리에서 고치면 참조는 그대로인데 내용이 바뀌어 틀린 결과를 돌려준다. 값을 바꿀 때 항상 새 객체로 바꾼다는 보장이 있을 때만 참조를 키로 쓴다. 직렬화한 문자열을 키로 쓰면 그 걱정이 없고, 직렬화 비용은 union을 따라 내려가는 파싱보다 훨씬 싸다.

대가도 있다. 위 코드는 트리 안의 어느 노드가 틀렸는지 path를 잃는다. 틀렸을 때만 원래 스키마로 한 번 더 검증해 자세한 에러를 붙이면 된다. 틀린 경우는 드무니 비용 문제가 되지 않는다. 캐시가 끝없이 커지지 않도록 크기 상한을 두는 것도 잊지 않는다.

isValid 구독을 없애는 방법도 있다. 제출 버튼을 isValid로 막지 않고 handleSubmit에서만 검증하면 마운트마다 실행되는 검증이 사라진다. 다만 버튼 활성 상태로 미리 알려 주는 UX를 포기해야 한다. 이번에는 UX를 유지하고 검증 자체를 싸게 만드는 쪽을 골랐다.

3.7 What I Took Away

  • 렌더링은 함수 재실행이다. 컴파일러가 메모이제이션을 맡으면 손으로 감쌀 일은 줄지만, 규칙을 어긴 컴포넌트는 조용히 빠진다. 렌더링 중에 ref를 읽는 코드가 대표적이다.
  • RHF는 isValid를 구독하면 필드가 등록될 때마다 폼 전체를 검증한다. 비용은 검증 한 번의 비용에 마운트 수를 곱한 값이다.
  • 같은 화면 구조에서 어떤 폼만 느리다면 화면보다 스키마를 본다. z.lazy와 z.union이 겹친 재귀 스키마는 검증 한 번이 수십 ms까지 커진다.
  • 고치기 전에 느린 경로부터 구분한다. 키 입력인지 마운트인지에 따라 고칠 곳이 다르다. resolver를 감싸 호출 수와 시간을 세면 바로 보인다.
Discussion