인트로

안녕하세요 인프랩 프론트엔드 개발자 라비입니다:)

온라인 강의 플랫폼 인프런을 서비스하고 있는 저희는 올해 초 채용 서비스인 랠릿을 런칭했는데요.

Vanilla JS로 개발한 인프런과 달리 React를 사용하여 웹 페이지를 개발하면서, 특히 이력서라는 특성상 복잡한 폼을 다루면서 렌더링 성능 이슈 등 다양한 문제에 직면했고, 이를 해결하기 위한 다양한 고민 과정을 정리하여 글로 공유하려고 합니다.

랠릿에는 한 페이지에 최대 700여개의 인풋창이 렌더링되는 개인 프로필 페이지와 지원서 작성하기 페이지가 존재합니다. 구직자가 이력서를 작성할 때 필요로 하는 최대한 다양한 유형의 정보를 한 페이지에서 입력하고 관리할 수 있도록 하기 위함입니다.

구직자 입장에서는 여러 페이지를 오고가면서 정보를 관리할 필요 없이 단일 페이지에서 자신의 모든 이력을 관리할 수 있기 때문에 편리하지만, Input, Select 등 다양한 Form Control의 값과 유효성 등의 상태를 관리하는 입장에서는 렌더링 성능이슈가 발생할 여지가 다분한 페이지입니다.

요구사항

랠릿에는 개인의 이력을 관리할 수 있는 프로필 페이지와 프로필 정보를 기반으로 실제 기업에 지원하는 지원서 작성 페이지가 존재합니다. 해당 페이지에서 이번 글과 관련있는 핵심적인 기능들은 다음과 같습니다.

  • 유효성 검사: 유저가 Form Control에 대하여 change나 blur 액션을 취할 경우, 현재 입력된 값이 유효하지 않다면 에러 메세지를 보여준다.

  • 동적인 폼 요소: 경력, 교육 등 일부 영역은 동적으로 요소를 추가 또는 삭제하거나, 순서를 변경할 수 있다.

rallit-profile

앞으로 예제에서는 핵심적인 요구사항을 기반으로 설명하기 위해 동적으로 변경되는 경력사항, 그 중에서도 회사 이름만을 구현해보려고 합니다.


Form 컴포넌트 개발하기

1. useState로 ProfileForm 컴포넌트 만들기

먼저 가장 쉽고 일반적인 방식으로 이름과 휴대폰 번호를 입력 받는 폼을 구현하면 다음과 같이 구현할 수 있습니다.

import { useState } from "react";
import _ from "lodash";

const ProfileForm({ initialProfile }) => {
  const [profile, setProfile] = useState(initialProfile);   // (1)

  const handlerChange = ({ target: { name, value }}) => {   // (2)
    const newProfile = _.cloneDeep(profile);
    _.set(newProfile, name, value);
    setProfile(newProfile);
  };

  return (
    <form>
      {profile.companies.map((company, index) => {
        const error = company.name > 0;

        return (
          <div key={company.id}>
            <label>회사 이름</label>
            <input
              name={`companies.${index}.name`}
              value={company.name}
              onChange={handleChange}
            />
            <p>{error}</p>
          </div>
        );
      })}
    </form>
  );
}

(1) useState(initialProfile)

  • ProfileForm 컴포넌트는 prop으로 받은 initialProfile로 useState를 통해 profile 상태를 초기화 및 생성합니다.

(2) handleChange

  • input element에서 change event가 발생할 때마다 element의 name과 value를 활용해 새로운 상태로 업데이트 합니다.
  • 새 객체를 clone하고 값을 변경해주는 기능은 설명의 편의를 위해 lodash를 사용했습니다. setProfile 함수를 호출하면 새로운 상태를 기반으로 갱신된 새 화면을 리렌더링합니다.

Form Control 요소가 위 예시처럼 단순할 경우에는 큰 문제가 없지만, ProfileForm 컴포넌트가 복잡해질수록, 예를 들어 재직 회사 이름 뿐만 아니라 교육, 수상경력 등의 Form Control이 증가하거나 각각의 Form Control이 더 다양한 기능을 갖게 된다면 setProfile을 호출 할 때마다 화면에 리렌더링 해야 할 요소의 수가 늘어나게 됩니다.

문제는 change event처럼 매우 짧은 주기로 반복적으로 일어날 수 있는 이벤트의 경우 유저가 타이핑을 할 때마다 화면에 계속 뚝뚝 끊기는 좋지 않은 UX를 유발한다는 점입니다.

2. Form Control별 상태 분리하기(colocation)

위 문제는 ProfileForm 컴포넌트에서 모든 Form Control 요소들의 상태를 다 갖고 있기 때문에 발생합니다. 유저가 특정 회사의 이름만 수정하더라도 ProfileForm 컴포넌트 전체가 리렌더링되어 버립니다.

따라서 각 Form Control 요소마다 state를 가진 개별 컴포넌트로 분리(colocation)한다면 해당 상태를 필요로 하는 컴포넌트만 독립적으로 렌더링할 수 있습니다.

const Company = ({ name: nameProp, index }) => {
  const [name, setName] = useState(nameProp);

  const handleChange = ({ target: { value }}) => {
    setName(value);
  };

  useEffect(() => {
    setName(nameProp);
  }, [nameProp]);

  const error = name > 0;

  return (
    <div>
      <label>회사 이름</label>
      <input
        name={`companies.${index}.name`}
        value={name}
        onChange={handleChange}
      />
      <p>{error}</p>
    </div>
  );
}

처음에는 단순하게 컴포넌트별로 상태를 분리하는 방법으로 문제를 해결할 수 있다고 생각했지만, 상태를 각 컴포넌트별로 분리할 경우 최종 저장 기능을 구현하는데서 문제가 발생합니다.

유저가 프로필 입력을 마친 후 저장 버튼을 클릭하면 각각의 Form Control state값을 읽어와 profile 데이터를 생성해야 하는데 상위 컴포넌트인 ProfileForm 컴포넌트에서 하위 컴포넌트의 state를 읽어올 수 없습니다.


const ProfileForm({ initialProfile }) => {
  const [profile, setProfile] = useState(initialProfile);

  const handleSubmit = (event) => {
    event.preventDefault();

    // Profile 상태를 어떻게 확인하지?
  };

  return (
    <form onSubmit={handleSubmit}>
      {profile.companies.map((company, index) => (
        <Company
          key={company.id}
          name={company.name}
          index={index}
        >
      ))}
      <button type="submit">저장</button>
    </form>
  );
}

3. 비제어 컴포넌트의 개념 활용하기

비제어 컴포넌트는 우리가 흔히 사용하는 React 앱처럼 상태를 기반으로 입력값을 관리하는 제어 컴포넌트와는 달리 javascript로 element 요소에 값에 직접 접근하는 방식입니다.

입력값을 상태로서 관리하지 않기 때문에 change event가 발생하더라도 화면이 리렌더링되지 않고 상위 컴포넌트에서도 직접 필요한 element에 접근하여 값을 읽어올 수 있습니다.

const ProfileForm = () => {
	const formRef = useRef(null);
	const [profile, setProfile] = useState({ companies: [] });

	const getCompanies= () => {   // (1)
    /**
     * 구현 생략
     * formRef.current.elements를 순회하며
     * value를 읽어 companies 배열을 생성
     **/
	};

  const handleSubmit = (event) => {
    event.preventDefault();
    const profileToSubmit = { companies: getCompanies() };
    console.log(profileToSubmit); // 최종적으로 api 통신할 데이터
  };

	return (
		<form ref={formRef} onSubmit={handleSubmit}>
			{profile.companies.map((company, index) => (
        <Company
          index={index}
          name={company.name}
        >
			)}
			<button type="submit">저장</button>
		</form>
	);
}

(1) getCompanies

  • 저장 버튼을 누르면 submit handler에서는 getCompanies 함수를 호출합니다.
  • form elements의 값을 읽어와 profileToSubmit 데이터를 생성합니다.

초기 랠릿의 프로필 컴포넌트는 위와 같은 방법으로 불필요한 렌더링 이슈를 해결했습니다. 물론 실제 제품은 추가적인 기능으로 보다 복잡한 구조였지만 당시로서는 요구사항을 만족시킬 수 있는 최선의 방법이였다고 판단했습니다.

하지만 안타깝게도 머지않아 현재 구조의 아쉬운점 점들이 곳곳에서 드러나기 시작했습니다.


Form 컴포넌트 마이그레이션

비제어 컴포넌트 방식의 한계

이렇게 colocation과 javascript로 form elements에 직접 접근하는 방식을 통해 입력값이 변할 때마다 Form 컴포넌트 전체가 리렌더링되지 않는다는 문제는 해결했습니다.

하지만 DX 관점에서 꽤나 아쉬운 지점이 있었는데, 바로 관리해야 할 데이터가 여러곳으로 분산된다는 점입니다.

비제어 방식으로 컴포넌트를 구현할 경우 핵심적인 데이터는 4곳으로 분산됩니다.

  • Form: 동적으로 늘고 줄어드는 폼 요소의 상태를 관리합니다.

  • Form Control(Input): 각각의 폼 요소의 입력값을 관리합니다.

  • validation state: Form Control에서 입력된 값을 공유 상태에서 관리하여 최상단 Form 컴포넌트에서도 각 Form Control들의 유효성 정보를 구독할 수 있게 합니다.

  • API request data: Submit 이벤트 발생시 api 요청으로 보내기 위해 각각의 Form Control 값을 읽어와 가공한 profile 데이터입니다.

데이터를 네 군데로 분리하면서 개발자는

  • 특정 기능을 구현하거나 유지보수 할 때마다 학습하고 고려해야 할 지점이 늘어났고,
  • 상위 컴포넌트에서는 관리하고 있지 않은 입력값을 읽어오기 위해 html 요소에 직접 접근하다보니 선언적이던 코드가 점점 명령형으로 작성되어가면서 관리가 어려워지는 문제가 발생했습니다.

예를 들어 유저가 프로필 중 회사 이름을 변경할 경우, Company 컴포넌트에서는

  1. 입력값으로 현재 name state를 업데이트 해주고,

  2. 입력값의 유효성을 검사하여 결과에 따라 공유 상태인 validation state를 업데이트 해주어야 합니다.

const Company = ({ index, name: nameProp }) => {
  const [name, setName] = useState(nameProp);
  const { updateValidation } = useValidations(); // 공유 상태
  const error = name.length < 1 ? '1글자 이상 입력해주세요' : '';

  const handleChange = ({ target: { value: newValue } }) => {
    setValue(newValue);

    const newValidation = validator(value); // validator 함수 구현 생략
    updateValidation(`companies.${index}.name`, newValidation);
  };

  const handleBlur = ({ target: { value: newValue } }) => {
    const newValidation = validator(value);
    updateValidation(`companies.${index}.name`, newValidation);
  };

  useEffect(() => {
    setValue(valueProp);
  }, [valueProp]);

  return (
    <div>
      <label>회사 이름</label>
      <input
        name={`companies.${index}.name`}
        value={name}
        onChange={handleChange}
        onBlur={handleBlur}
      />
      <p>{error}</p>
    </div>
  );
};

handleChange라는 이벤트 핸들러 하나를 구현하더라도 input value는 어디서 관리하는지, 유효성 데이터는 어디에서 관리하는지 두가지 상태의 역할과 목적을 숙지하고 있어야 합니다.

이 문제는 Form Control 요소의 순서 이동 등 여러 값을 동시에 변경하는 기능을 구현할 경우 복잡도가 더욱 증가합니다.

배열로 관리하고 있는 영역의 순서를 바꾼다면 Form 컴포넌트는

  1. Form 엘리먼트에서 현재 배열 요소들의 값을 읽어온 후 필요한 요소들의 순서를 바꿔서 profile state 업데이트
  2. 각 Form Control은 변경된 profile state를 prop으로 전달 받아 각각의 value 업데이트
  3. 에러 UI 역시 적절하게 순서를 바꿔주기 위해 validation state도 순서를 바꿔서 업데이트 해주는 작업이 필요합니다.
const ProfileForm = () => {
	const formRef = useRef(null);
	const [profile, setProfile] = useState({ companies: [] });
 	const { validation, setValidation } = useValidations('companies');

	const getCompanies= () => {
		/** 구현 생략 */
	};

	return (
		<form ref={formRef}>
			{profile.companies.map((company, index) => (
        <Company
          index={index}
          name={company.name}
          onClickMove={(prevIndex, nextIndex) => {   // (1)
            const newCompanies = getCompanies();
            swap(newCompanies, prevIndex, nextIndex);
            setProfile({ ...profile, companies: newCompanies });

            swap(validation, prevIndex, nextIndex);
					  setValidation(validation);
          }}
        >
			)}
			<button
				type="submit"
				disabled={checkIsSubmittable(validations)}>
				제출
			</button>
		</form>
	);
}

(1) onClickMove

  • input value와 validation state를 따로 관리해주어야 하고
  • formRef.current.elements를 순회하며 value값에 접근하여 다시 새 profile 데이터 구조로 가공해주는 코드가 필요합니다.

렌더링 퍼포먼스를 위해 기능과 역할에 따라 분리했던 데이터가 복잡한 구조를 만들면서 개발자에게는 높은 유지보수 비용과 부담을 주게 되었습니다.

결국 Form에서 필요한 데이터는 ’단일 상태로 일관성있게 관리해주어야 하고, 여기서 발생하는 렌더링 이슈는 기술적으로 해결해야 한다.’ 라는 결론에 도달했습니다.


상태 관리 라이브러리의 아쉬움

최상단 Form 컴포넌트에서 프로필의 전체 상태를 관리하면서 하위 컴포넌트들에게 prop-drilling 이슈 없이 상태를 전달하기 위해서는 React의 context API가 필요합니다.

하지만 selector가 없는 React의 context API를 사용할 경우 최상단 state를 업데이트하면 하위의 모든 컴포넌트가 리렌더링된다는 꽤나 치명적인 성능 이슈가 발생합니다.

이 문제를 해결하기 위해 redux와 mobx, recoil 등의 라이브러리 등을 고려해보기도 했습니다.

  1. redux

    • store를 업데이트 하더라도 해당 값을 구독하고 있지 않은 컴포넌트는 리렌더하지 않고 Form을 관리할 수 있습니다.
    • 하지만 Form 컴포넌트 내부에서만 필요로하는 상태를 store라는 전역적인 상태로 관리하는 컨셉이 과연 적절한지에 대한 의문이 남았습니다.
    • 또 redux-toolkit을 사용하면서 많이 나아졌다고는 하지만 여전히 action과 reducer 등 많은 보일러플레이트가 필요하다는 점도 아쉬운 지점이었습니다.
  2. mobx

    • mobx는 redux와 비교하면 과도한 보일러 플레이트 코드가 사라지고 필요에 따라 여러 스토어를 생성하여 상태를 관리할 수 있다는 장점이 있었습니다.

    • 특히 팀 내부적으로 객체지향 프로그래밍에 대한 관심이 높아지던 시기였기 때문에 state를 javascript class로 구현할 수 있어 보다 자연스러운 객체지향 프로그래밍이 가능하다는 특징을 가지고 있는 mobx를 선호하는 의견이 있었습니다.

    • 다만 대부분의 팀원들이 mobx라는 라이브러리를 새로 학습하고 있는 상황에서 팀원 전체적으로 사용할 수 있는 일관성 있는 아키텍처를 정립하기에는 mobx의 구조가 매우 자유롭다는 인상을 받았습니다.

    • 개발 편의를 위해 decorator를 쓰자는 의견과 쓰지 말자는 의견, 입력값과 유효성 관련 데이터를 어떤 구조로 관리할 것인지 등 다양한 의견이 나왔고 어느 방법이 가장 적합한지에 대한 경험과 숙련도가 부족하기 때문에 아직 실제 제품에 적용해보는것은 시기상조라는 생각이 들었습니다.

    • 비즈니스 로직을 관리하는 클래스 내부와 React 컴포넌트에 mobx의 api인 makeAutoObservable, observer 등의 함수가 들어가면서 테스트 코드 작성이 어려워지지는 않을까? 추후에 mobx를 사용하지 않게 되면 변경 범위가 너무 넓어지지 않을까? 등의 걱정도 들었습니다.

  3. recoil

    • recoil은 앞서 언급했던 redux와 mobx에 비해 가볍고 간단하게 사용할 수 있는 상태관리 라이브러리입니다.
    • 기본 React 컨셉과 가장 유사하기 때문에 학습비용이 낮다는 점 또한 큰 장점입니다.
    • 개인적으로는 위 두가지 라이브러리와 비교했을 때 가장 좋아보였지만 단점은 아직 정식 버전이 출시되지 않았고, 비교적 신규 기술이기 때문에 커뮤니티 규모나 안정성 측면에서 아직 제품에서 사용하기에는 우려스럽다는 내부 의견이 다수 있었습니다(zustand, jotai 역시 비슷한 이유로 팀원 전체의 공감을 얻지는 못했습니다).

돌고 돌아 다시 React. 그리고 memo

이쯤 되니 구성원들간 취향에 따라 선호하는 라이브에 대한 의견 차이가 있었고 각 라이브러리 마다 장단점이 있기 때문에 특정 라이브러리가 우리 조직의 모든 문제를 말끔히 해결해 줄 것이라는 확신이 들지 않았습니다.

생각해보니 React에도 상태 관리와 렌더링 최적화를 위한 기능이 이미 존재하는데 우리가 잘 활용하지 못하고 외부 상태관리 라이브러리에 의존하려고 했던것 같다는 생각도 들었습니다.

다시 기본으로 돌아가 React에서 제공하는 기본 api만으로 Form을 구현해보았습니다.

React.memo를 사용해 각 컴포넌트는 prop이 변경되는 경우에만 리렌더링하도록 메모이제이션해주었고, 그 결과 최상위 state에서 전체 프로필 데이터를 관리하더라도 불필요한 렌더링을 최소화 할 수 있었습니다.

기본 api만을 사용하더라도 상태는 최상위 state 하나로 관리하기 때문에 비제어 컴포넌트로 구현한 방식에 비해 관리가 용이하고, 추가적인 라이브러리에 대한 관리나 학습에 대한 비용도 필요하지 않다는 장점이 있었습니다.

하지만 이 방법 역시 아쉬운점이 존재했습니다. memo는 어디까지나 부모 컴포넌트의 state를 전달받는 children 컴포넌트에 대한 메모이제이션일 뿐 본질적으로 다른 라이브러리의 selector와 같이 구독하고 있는 컴포넌트만 리렌더링 하는 방식과 비교하면 렌더링 최적화가 가능한 영역이 제한적이었습니다.

React 공식문서에는 memo에 대해서 다음과 같이 내용이 설명되어있습니다.

React.memo only checks for prop changes. If your function component wrapped in React.memo has a useState, useReducer or useContext Hook in its implementation, it will still rerender when state or context change.

컴포넌트들의 계층구조에서 memo는 useContext hook을 사용하는 컴포넌트보다 하위 children에 위치한 컴포넌트들에 대해서만 메모이제이션을 통해 렌더링 최적화의 효과를 볼 수 있습니다.

이 말은 prop drilling을 회피하기 위해 컴포넌트 전반에 useContext를 적극적으로 사용할 경우, 메모이제이션은 최말단에 존재하는 컴포넌트들을 대상으로만 적용이 가능하다는 의미가 됩니다.

또 prop으로 전달받는 값들에 대해 useCallback이나 useMemo 등을 사용한 적절한 메모이제이션 처리가 필요합니다.

React.memo를 사용하는 방법은 모든 props와 컴포넌트가 재선언되고 리렌더링 되는 시점을 고려하여 개발해야 하기 때문에 개발자의 입장에서는 또 하나의 신경써야 할 관리요소가 늘어난다는 한계가 있었습니다.

단일 출처 상태 기반으로 Form을 개선하기 위해 시도했던 다양한 방법들 중 마지막으로 저희가 검토한 방식이자 최종 선택했던 방법은 바로 react-hook-form 라이브러리입니다.

2부인 다음 글에서는 react-hook-form을 도입하여 랠릿의 ProfileForm 컴포넌트를 어떻게 개선했는지 소개하려고 합니다. 감사합니다:)