Controlled Component의 함정

gangmin·2026년 5월 24일

React

목록 보기
9/9
post-thumbnail

Slider를 만들다 무한 루프에 빠진 이유

디자인 시스템의 Slider, RangedSlider, MultiRangedSlider를 리팩토링하면서 단순히 “값을 props로 받고 onChange로 올리면 controlled component지”라고 생각했다.
그런데 실제로 thumb를 움직이자 컴포넌트가 무한 렌더링에 빠졌다. 😵‍💫

문제는 controlled component 자체가 아니라, 값 동기화 방향을 구분하지 않은 것이었다.

Controlled Component는 단순히 value/onChange가 아니다

React에서 input은 value를 props로 넘기면 controlled input이 된다. 값의 source of truth는 컴포넌트 내부가 아니라 부모 state다. React 공식 문서에서도 <input>value를 제어하면 React state와 동기화되는 controlled input이 된다고 설명한다.

Slider도 마찬가지다.

<Slider
  value={value}
  handleChangeValue={setValue}
/>

이 구조에서 부모는 value를 내려주고, Slider는 사용자 조작이 발생하면 handleChangeValue로 새 값을 올린다. 여기까지만 보면 문제가 없어 보인다.

문제가 된 구조

초기 구현은 대략 이런 흐름이었다.

const [handle, setHandle] = useState(value);

useEffect(() => {
  handleChangeValue(handle);
}, [handle]);

useEffect(() => {
  setHandle(value);
}, [value]);

겉으로 보면 자연스럽다.

  • 내부 handle이 바뀌면 부모에게 알린다.
  • 부모 value가 바뀌면 내부 handle을 맞춘다.

하지만 이 구조는 양방향 동기화다.

사용자 드래그
-> setHandle
-> useEffect(handle)
-> handleChangeValue
-> 부모 setState
-> value prop 변경
-> useEffect(value)
-> setHandle
-> useEffect(handle)
-> ...

특히 같은 state를 서로 다른 Slider가 공유하거나, min/max/step이 다른 Slider가 같은 value를 바라보면 문제가 더 쉽게 터진다. 한쪽 Slider는 값을 정규화해서 부모에 올리고, 다른 Slider는 다시 자기 기준으로 정규화해서 올린다. 그러면 값이 계속 왕복한다.

React의 useEffect는 렌더링 이후 외부 시스템과 동기화하기 위한 Hook이다. 공식 문서에서도 Effect는 렌더링 결과를 외부 시스템과 맞추는 용도라고 설명한다.

여기서 내가 놓친 점은, 부모와 동기화하는 값 변경사용자 조작으로 발생한 값 변경을 같은 effect에서 처리했다는 것이다.

해결 방향: 변경의 출처를 구분하기

핵심은 이거였다.

props 때문에 내부 state가 바뀐 경우에는 부모 callback을 다시 호출하지 않는다.
사용자 조작으로 내부 state가 바뀐 경우에만 부모 callback을 호출한다.

그래서 ref를 하나 두었다.

const shouldNotifyChangeRef = useRef(false);
const lastEmittedValueRef = useRef(handle);

사용자가 thumb를 드래그하거나 키보드로 값을 바꿀 때만 플래그를 켠다.

const calculateThumbPosition = (...) => {
  const nextValue = getSliderValueFromPosition(position, { min, max, step });

  if (nextValue === handle) return;

  shouldNotifyChangeRef.current = true;
  setHandle(nextValue);
};

그리고 handle 변경 effect에서는 이 플래그를 확인한다.

useEffect(() => {
  if (!shouldNotifyChangeRef.current) return;

  shouldNotifyChangeRef.current = false;
  if (lastEmittedValueRef.current === handle) return;

  lastEmittedValueRef.current = handle;
  handleChangeValue(handle);
}, [handle, min, max]);

반대로 props로 내려온 value를 내부 state에 맞출 때는 부모에게 다시 알리지 않는다.

useEffect(() => {
  const normalizedValue = normalizeSliderValue(value, { min, max, step });

  setHandle((prev) => {
    if (prev === normalizedValue) return prev;
    return normalizedValue;
  });
}, [value, min, max, step]);

이렇게 하면 데이터 흐름이 명확해진다.

사용자 조작 -> 내부 state 변경 -> 부모 callback 호출
부모 props 변경 -> 내부 state 동기화 -> callback 호출 안 함

RangedSlider와 MultiRangedSlider에서도 같은 문제가 있었다

RangedSlidermin, max 두 값을 가진다.
MultiRangedSlider는 values 배열을 handle 위치 배열로 변환한다.

예를 들어 3 range에서는 이런 관계가 있다.

values = [30, 35, 35]
handles = [30, 65]

여기서도 같은 문제가 생겼다.

handles 변경
-> values 계산
-> 부모 onChange
-> values prop 변경
-> handles 재계산
-> 다시 onChange

해결 방식은 동일했다.

  • props에서 온 값으로 내부 state를 맞출 때는 emit하지 않는다.
  • 사용자가 handle을 움직인 경우에만 emit한다.
  • 같은 값이면 setState도 하지 않는다.
const isSameValues = (prev: number[], next: number[]) =>
  prev.length === next.length && prev.every((value, idx) => value === next[idx]);

배열은 참조가 매번 새로 만들어질 수 있으므로, 단순히 prev === next로는 부족했다. 실제 값 비교가 필요했다.

여기서 배운 점

controlled component는 “부모가 값을 들고 있다”는 의미만으로 끝나지 않는다.
실제로는 값의 변경 방향을 설계하는 일에 가깝다.

이번 Slider 작업에서 배운 기준은 이렇다.

  • 사용자 이벤트로 생긴 변경만 onChange로 올린다.
  • props 동기화로 생긴 내부 state 변경은 다시 emit하지 않는다.
  • 내부 state와 props state를 둘 다 둘 거라면, 둘의 역할을 명확히 나눈다.
  • 같은 값이면 setState를 하지 않는다.
  • 배열 값은 참조 비교가 아니라 값 비교가 필요하다.
  • useEffect는 “모든 state 변경에 반응하는 곳”이 아니라 “정말 동기화가 필요한 곳”에만 쓴다.

마무리

처음에는 Slider의 무한 루프가 단순한 dependency array 문제라고 생각했다.
하지만 실제 원인은 dependency가 아니라 동기화 책임이 섞인 구조였다.

UI 컴포넌트는 보기보다 상태 흐름이 복잡하다. 특히 Slider처럼 내부 인터랙션이 있고, 동시에 외부에서 controlled value를 받는 컴포넌트는 더 그렇다.

이번 작업 이후로 controlled component를 만들 때는 먼저 이 질문을 하게 됐다.

이 state 변경은 사용자 조작에서 온 것인가, props 동기화에서 온 것인가?

이 질문 하나가 무한 루프를 막아줬다.

참고 자료

2개의 댓글

comment-user-thumbnail
2026년 5월 24일

슬라이더 고수시군요

1개의 답글