최근 NEXTSTEP의 TDD, 클린 코드 with React 과정을 하면서 제어, 비제어 컴포넌트를 다루는 미션이 주어졌습니다. 개념적으로만 이해하고 있던 제어, 비제어 컴포넌트를 직접 코드로 옮기면서 고민해보니 피상적이던 이해들이 좀 더 본질적으로 와닿았던 것 같습니다. 그래서 완전 제어 컴포넌트로 만들었던 폼 제어를 완전 비제어 컴포넌트들로 바꾸면서 얻었던 고민들을 나눠보고자 합니다.
제어 컴포넌트를 지향하라는 의견을 꽤 자주 들을 수 있습니다. 그 이유를 생각해보자면 몇 가지로 정리할 수 있습니다.
아래 코드는 폼 제어를 완전히 제어합니다. 각 Input의 값을 리액트의 상태로 소유하고, Input의 변화가 있을 때 마다 setState로 상태를 변경합니다. 상태가 변경되면 각 Input 컴포넌트는 리렌더링이 되면서 변경된 상태를 반영합니다.
function InputContainer() {
const [name, setName] = useState()
const [password, setPassword] = useState()
const [nickname, setNickname] = useState()
const onChange = (newValue) => {
// Some Logic...
}
const onSubmit = () => {
// Some Logic...
}
return (
<form onSubmit={onSubmit}>
<Input value={name} onChange={onChange} />
<Input value={password} onChange={onChange} />
<Input value={nickname} onChange={onChange} />
<SubmitInput value="Submit" />
</form>
)
}
하지만 여전히 제어 컴포넌트의 단점이 존재합니다. 예를 들어 Input 엘리먼트의 값을 리액트가 소유한다면 상위 컴포넌트에서 모든 Input 엘리먼트의 값을 소유하고, 이를 Prop으로 넘겨주면서 Prop Drilling 문제가 발생할 수 있습니다. 상황이 조금만 복잡해진다면 컴포넌트 간의 의존성을 강하게 해서 재사용성을 떨어뜨릴 수 있으며 이는 코드 베이스의 유지보수가 어려워진다는 것을 뜻하기도 합니다.
또한 상태가 변경될 때마다 컴포넌트의 리렌더링이 발생하면서 퍼포먼스에도 악영향을 미칠 수 있습니다. 사용자의 입력마다 렌더링이 발생하기 때문에, 상태 업데이트와 렌더링 사이의 지연이 UX에 부정적인 영향을 끼칠 수 있습니다. 특히 입력 상태가 많고 복잡한 폼 제어의 경우 이러한 문제가 더욱 심각해질 수 있습니다.
위에서 서술한 제어 컴포넌트의 단점과 맞물려 비제어 컴포넌트의 필요성이 존재합니다. 아래와 같은 예시가 있을 수 있겠네요.
제어 컴포넌트 코드를 비제어 컴포넌트로 바꾼 것입니다. 이제 Input 컴포넌트의 값은 리액트의 상태로 존재하지 않습니다. useRef로 DOM을 가지고 있고, '필요할 때' ref를 통해 Input의 값을 가져올 수 있습니다. 아래 예시에서는 onSubmit 함수에서 ref 값을 가져올 수 있습니다.
function InputContainer() {
const nameRef = useRef<HTMLInputElement>(null)
const passwordRef = useRef<HTMLInputElement>(null)
const nicknameRef = useRef<HTMLInputElement>(null)
const onSubmit = () => {
// Some Logic...
}
return (
<form onSubmit={onSubmit}>
<Input ref={nameRef} />
<Input ref={passwordRef} />
<Input ref={nicknameRef} />
<SubmitInput value="Submit" />
</form>
)
}
아래 표에서 가장 핵심적인 개념을 뽑으라면 제어 컴포넌트 = Push, 비제어 컴포넌트 = Pull입니다. 제어 컴포넌트는 Input의 값을 리액트의 상태로 소유합니다. 그리고 Input의 값으로 리액트가 소유한 상태를 밀어 넣습니다. 그래서 Push입니다. 반면 비제어 컴포넌트는 Input의 값을 DOM에서 그대로 가지고 있습니다. 그리고 리액트에서 값이 필요할 때 가져옵니다. 그래서 Pull입니다. 이 개념을 이해하면 제어 컴포넌트와 비제어 컴포넌트를 이해했다고 봐도 무방하다고 생각합니다.
제어 컴포넌트 | 비제어 컴포넌트 | |
---|---|---|
지향점 | Push (값을 Input에 넣습니다) | Pull (값을 필요할 때 가져옵니다) |
사용성 | 개발자가 항상 상태를 동기화해야 하므로 복잡한 상태를 관리해야하는 어려움이 있다 | 필요할 때 값을 가져오는 Ref를 적극사용 합니다. Ref의 값을 적절히 사용하고, 값을 가져와야 합니다. |
포맷팅 | 상태를 변경할 때마다 UI가 업데이트 됩니다. 이러한 자동 업데이트 덕분에 개발자가 별도의 업데이트 코드를 작성할 필요가 없습니다. | DOM을 직접 조작하기 때문에 UI 업데이트를 위해 별도의 코드를 작성해야 합니다. |
성능 | 값이 변할 때마다 상태가 업데이트 되므로 리렌더가 자주 일어납니다. | 값 변경에 대한 리렌더링이나 연산 과부하가 적습니다. |
동적 핸들링 | 상태 변경에 따른 핸들링이 용이합니다. | DOM을 직접 조작하기 때문에 동적 핸들링이 복잡해질 수 있습니다. |
관찰 | 상태 중심으로 개발하기 때문에 상태 관찰이 쉽습니다. | DOM을 직접 조작하므로 상태 관찰이 어려울 수 있습니다. |
지금까지 개념에 대한 정의였다면 다음 내용들은 실제로 어떤 고민의 과정을 거쳤는지 적어보려고 합니다. 제어, 비제어 컴포넌트 둘 중에 뭐가 더 좋냐라는 질문은 적절하지 않습니다. 여러 상황과 요구 조건을 해결하기 위해 더 적절한 해결 방법을 선택하는 문제라고 생각합니다. 저는 처음에는 모든 Input을 제어 컴포넌트로 만들었다가 점차 모든 컴포넌트를 비제어 컴포넌트로 바꿨습니다. 그 과정을 '선택'의 관점에서 봐주시면 좋을 것 같습니다.
![]() | ![]() |
---|
왼쪽은 입력 전, 오른쪽은 입력 후 기대하는 이미지입니다. 두 가지 조건을 보고 어떤 폼제어 전략을 세워야겠다는 생각이 드시나요? 제 의식의 흐름을 소개하면 다음과 같았습니다.
1번, 2번 상황은 큰 문제가 없었습니다. 위 예제와 같이 단순 구현만 하면 됐으니까요. 하지만 문제는 모든 Input을 비제어 컴포넌트로 만드는 상황에서 있었습니다. 비제어 컴포넌트를 사용하는 목적 중 하나는 리렌더링 최소화입니다. 값을 상태로 제어하는 순간 상태가 변할 때 마다 변화가 있는 컴포넌트는 리렌더링이 발생합니다. 하지만 Context API를 사용하자, 값의 변화가 있을 때마다 Context를 사용하는 모든 컴포넌트가 리렌더링이 발생하는 상황이 벌어졌습니다. 이런 상황이라면 굳이 비제어 컴포넌트를 사용할 필요가 없습니다.
Context의 State와 Dispatch를 분리하는 방식으로 해결했습니다. 아래는 Provider 코드입니다. 카드의 상태를 가지고 있는 CardStateContext와 카드의 상태를 변경하는 CardDispatchContext를 분리하고 각각의 컨텍스트를 필요한 곳에서 사용합니다.
function CardProvider({ children }: PropsWithChildren) {
const [state, dispatch] = useReducer(cardReducer, INITIAL_CARD_STATE)
return (
<CardStateContext.Provider value={state}>
<CardDispatchContext.Provider value={dispatch}>{children}</CardDispatchContext.Provider>
</CardStateContext.Provider>
)
}
function App() {
return (
<CardProvider>
<RouterProvider router={router} />
</CardProvider>
)
}
이렇게 구분한 이유는 카드의 상태를 변경하는 Dispatch 함수가 동작할 때, 폼에서 리렌더링이 발생하는 상황을 막기 위함입니다. 아래 코드 예시를 보면 PreviewCard 컴포넌트에서는 CardStateContext를 사용하고, CardForm 컴포넌트의 하위 컴포넌트들은 CardDispatchContext를 사용합니다. 이로써 카드의 상태가 변경되어도 CardForm 내부의 컴포넌트는 리렌더링이 발생하지 않습니다.
<PayssionApp>
<PageTitle title="카드 추가" buttonElement={<BackButton />} />
<PreviewCard /> // -> CardStateContext를 사용하는 컴포넌트
<CardForm> // -> CardDispatchContext를 사용하는 컴포넌트
<CardForm.CardNumbers />
<CardForm.CardExpiredDate />
<CardForm.CardOwner />
<CardForm.CardSecurityCode />
<CardForm.CardPassword />
</CardForm>
</PayssionApp>
Context의 핵심 키워드는 생성 - 제공 - 소비의 흐름입니다. createContext 메서드로 컨텍스트를 생성하고, Provider로 제공하며, useContext로 소비합니다. 이러한 관점으로 소비, 즉 컨텍스트의 사용처를 생각해보면 됩니다. 카드 이미지를 보여주는 PreviewCard 컴포넌트에서는 State 컨텍스트를, Input을 통해 Ref의 값을 반영하는 CardForm 컴포넌트에서는 Dispatch 컨텍스트를 소비하면 됩니다.
이렇게 State, Dispatch를 분리해서 사용하니까 폼제어 컴포넌트에서는 리렌더링이 발생하지 않으면서 카드 컴포넌트에서는 정상적으로 상태가 변경됩니다. 비제어 컴포넌트로 제어를 하면서도 요구 사항을 충족했습니다. 해당 방법 뿐만 아니라 useImperativeHandle, useDebounce 등의 Hook을 사용하는 방법도 존재합니다. 사실 React Hook Form과 같은 폼 라이브러리를 사용하는 것도 좋은 선택이라고 생각합니다. 다만 일련의 과정을 통해 나름의 기준들을 찾을 수 있었고, 폼 라이브러리를 선택할 때에도 이 경험을 바탕으로 더 좋은 선택을 할 수 있을 것이란 자신감을 얻었습니다.