이 값은 이제 제껍니다

keem·2023년 5월 20일
20
post-thumbnail

최근 NEXTSTEP의 TDD, 클린 코드 with React 과정을 하면서 제어, 비제어 컴포넌트를 다루는 미션이 주어졌습니다. 개념적으로만 이해하고 있던 제어, 비제어 컴포넌트를 직접 코드로 옮기면서 고민해보니 피상적이던 이해들이 좀 더 본질적으로 와닿았던 것 같습니다. 그래서 완전 제어 컴포넌트로 만들었던 폼 제어를 완전 비제어 컴포넌트들로 바꾸면서 얻었던 고민들을 나눠보고자 합니다.

제어 컴포넌트

  • Input 엘리먼트의 값을 리액트가 직접 소유합니다.
  • State와 State를 제어하는 핸들러를 Prop으로 전달하거나 컴포넌트가 소유합니다.
  • 단 하나의 정보의 원천을 가지고 있습니다.
  • Push 방식으로 동작합니다.

제어 컴포넌트를 지향해야 한다?

제어 컴포넌트를 지향하라는 의견을 꽤 자주 들을 수 있습니다. 그 이유를 생각해보자면 몇 가지로 정리할 수 있습니다.

  1. 리액트의 컴포넌트는 ‘상태’를 ‘동기화’하는 UI 라이브러리다.
    이것은 유저에게 보이는 상태를 동기화하는 데에 최적화되어 있다는 의미입니다. 값을 상태로 관리한다는 것은 값이 항상 유저에게 보여야 할 최적의 상태로 존재한다는 것입니다.
  2. 유지보수성이 높다.
    값을 상태로 소유하고, 이를 라이프사이클 메서드를 이용해서 동작하면 코드의 흐름을 이해하고 쉽고, 디버깅이 명료해집니다.
  3. DOM 제어를 직접하지 않는다.
    리액트의 상태를 사용하지 않고 DOM에 관여한다는 것은 예상치 못한 부수효과를 발생할 수 있다는 의미가 됩니다. 그 밖에도 코드가 더러워지거나 라이프 사이클, 가상돔 렌더링이 예상 밖의 동작을 할 가능성이 있습니다.

제어 컴포넌트 예시

아래 코드는 폼 제어를 완전히 제어합니다. 각 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 Element의 값을 DOM이 소유합니다.
  • 리액트는 ‘필요할 때’ 값을 사용할 수 있으며 사용자만 값과 상호작용합니다.
  • useRef를 통해 제어합니다.
  • Pull 방식으로 동작합니다.

비제어 컴포넌트가 필요한 경우

위에서 서술한 제어 컴포넌트의 단점과 맞물려 비제어 컴포넌트의 필요성이 존재합니다. 아래와 같은 예시가 있을 수 있겠네요.

  1. 즉각적인 유효성 검사나 버튼의 disabled 여부를 다루지 않는 경우
  2. 복잡한 폼 컴포넌트를 가지고 있어서 리렌더링에 대한 퍼포먼스 문제가 있는 경우
  3. 상태와 핸들러를 상위 컴포넌트가 소유하면서 Prop Drilling 문제가 발생하는 경우

비제어 컴포넌트 예시

제어 컴포넌트 코드를 비제어 컴포넌트로 바꾼 것입니다. 이제 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>
  )
}

제어 VS 비제어 정리

아래 표에서 가장 핵심적인 개념을 뽑으라면 제어 컴포넌트 = Push, 비제어 컴포넌트 = Pull입니다. 제어 컴포넌트는 Input의 값을 리액트의 상태로 소유합니다. 그리고 Input의 값으로 리액트가 소유한 상태를 밀어 넣습니다. 그래서 Push입니다. 반면 비제어 컴포넌트는 Input의 값을 DOM에서 그대로 가지고 있습니다. 그리고 리액트에서 값이 필요할 때 가져옵니다. 그래서 Pull입니다. 이 개념을 이해하면 제어 컴포넌트와 비제어 컴포넌트를 이해했다고 봐도 무방하다고 생각합니다.

제어 컴포넌트비제어 컴포넌트
지향점Push (값을 Input에 넣습니다)Pull (값을 필요할 때 가져옵니다)
사용성개발자가 항상 상태를 동기화해야 하므로 복잡한 상태를 관리해야하는 어려움이 있다필요할 때 값을 가져오는 Ref를 적극사용 합니다. Ref의 값을 적절히 사용하고, 값을 가져와야 합니다.
포맷팅상태를 변경할 때마다 UI가 업데이트 됩니다. 이러한 자동 업데이트 덕분에 개발자가 별도의 업데이트 코드를 작성할 필요가 없습니다.DOM을 직접 조작하기 때문에 UI 업데이트를 위해 별도의 코드를 작성해야 합니다.
성능값이 변할 때마다 상태가 업데이트 되므로 리렌더가 자주 일어납니다.값 변경에 대한 리렌더링이나 연산 과부하가 적습니다.
동적 핸들링상태 변경에 따른 핸들링이 용이합니다.DOM을 직접 조작하기 때문에 동적 핸들링이 복잡해질 수 있습니다.
관찰상태 중심으로 개발하기 때문에 상태 관찰이 쉽습니다.DOM을 직접 조작하므로 상태 관찰이 어려울 수 있습니다.

비제어 컴포넌트 + Context API

지금까지 개념에 대한 정의였다면 다음 내용들은 실제로 어떤 고민의 과정을 거쳤는지 적어보려고 합니다. 제어, 비제어 컴포넌트 둘 중에 뭐가 더 좋냐라는 질문은 적절하지 않습니다. 여러 상황과 요구 조건을 해결하기 위해 더 적절한 해결 방법을 선택하는 문제라고 생각합니다. 저는 처음에는 모든 Input을 제어 컴포넌트로 만들었다가 점차 모든 컴포넌트를 비제어 컴포넌트로 바꿨습니다. 그 과정을 '선택'의 관점에서 봐주시면 좋을 것 같습니다.

구현 사항

  1. 여러 개의 Input 중 카드 번호, 만료일, 카드 소유자 이름은 즉각 카드 이미지에 유저가 Input에 입력한 값이 보여야 합니다.
  2. 보안코드와 카드 비밀번호는 유저가 입력한 값이 보여질 필요가 없었고, 다음 버튼을 입력하기 전까지는 Validation이 평가될 필요도 없습니다.

왼쪽은 입력 전, 오른쪽은 입력 후 기대하는 이미지입니다. 두 가지 조건을 보고 어떤 폼제어 전략을 세워야겠다는 생각이 드시나요? 제 의식의 흐름을 소개하면 다음과 같았습니다.

  1. All 제어 컴포넌트
    “우선 가장 많이 다뤄왔던 제어 컴포넌트로 전부 다뤄보자!”
  2. 부분적 비제어 컴포넌트
    “카드 번호, 만료일 카드 소유자 이름은 값이 변할 때마다 상태를 가져와야 되니까 제어 컴포넌트로 보안코드, 카드 비밀번호는 값 입력과 동시에 상태로 필요하지 않으니까 비제어 컴포넌트로 제어할 수 있지 않을까?”
  3. All 비제어 컴포넌트
    “현재 상황은 같은 컴포넌트 내에서 값이 필요한 게 아니라 다른 컴포넌트에서 현재 입력되는 값만 필요한 상황이니까 Context API와 조합하면 전부 비제어 컴포넌트로 바꿀 수 있지 않을까?”

문제 상황

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과 같은 폼 라이브러리를 사용하는 것도 좋은 선택이라고 생각합니다. 다만 일련의 과정을 통해 나름의 기준들을 찾을 수 있었고, 폼 라이브러리를 선택할 때에도 이 경험을 바탕으로 더 좋은 선택을 할 수 있을 것이란 자신감을 얻었습니다.

profile
본질이 뭘까?

0개의 댓글