React에서 View의 렌더링 관심사 분리를 위한 VAC 패턴 소개

시작하며

FE개발에서 View는 정보의 시각화 뿐만 아니라 사용자와 상호작용하는 역할을 포함하고 있습니다.
그래서 View를 개발하는 것은 크게 아래와 같이 세 가지 영역으로 나눌 수 있습니다.

  1. 사용자의 상효작용을 처리하는 UI 기능 개발(JS 등)
  2. 데이터나 상호작용 결과를 시각화하는 렌더링 처리(마크업, CSS 등)
  3. 비즈니스 로직, UI 기능, 렌더링 처리의 통합(React, Redux 등)

네이버를 비롯한 많은 개발조직들은 디자인 영역과의 원할한 협업을 위해 2번에 해당하는 UI개발(마크업 개발) 영역과 1, 3번에 해당하는 FE개발 영역으로 역할이 나뉘어 있습니다.
하지만 React에서는 컴포넌트를 개발할 때 JSX로 렌더링을 처리하는 방식을 주로 사용하기 때문에 JS와 마크업이 혼합된 형태로 개발이 됩니다.
따라서 FE개발자와 UI개발자가 JSX를 함께 관리하게 되면서 종종 코드 충돌이 생기고, FE개발자가 개발을 완료한 컴포넌트에 대해서 UI개발자가 JSX를 수정하는 것이 쉽지 않은 문제가 있습니다.

물론 React 환경에서도 효율적으로 코드를 설계하고 관리하기 위해서 비즈니스 로직과 View의 관심사를 분리하는 방법에 대한 고민이 많이 있었는데, Custom Hook을 활용하거나 Presentational과 Container 컴포넌트 패턴이나 BLoC 패턴 같은 디자인 패턴들이 존재하고 있습니다.
그러나 대부분은 비즈니스 로직의 관점에서 관심사를 분리하고 있어 View를 설계하는데 도움은 되지만 역할 분담에 따른 협업 과정에서 발생하는 문제를 해결하는데 있어서는 개발자의 경험에 따른 차이가 발생하게 됩니다.

이 글에서는 React 환경에서 UI개발자와 FE개발자의 협업 문제를 해결하기 위해 View 컴포넌트에서 JSX 영역을 분리하는 VAC 패턴에 대해 소개하고, Presentational과 Container 컴포넌트 패턴과 어떻게 다른지 설명합니다.

VAC Pattern

VAC는 View Asset Component의 약자로 렌더링에 필요한 JSX와 스타일을 관리하는 컴포넌트를 의미합니다.
VAC 패턴은 View 컴포넌트에서 JSX 영역을 Props Object로 추상화하고, JSX를 VAC로 분리해서 개발하는 설계 방법입니다.
이런 설계는 비즈니스 로직 뿐만 아니라 UI 기능 같은 View 로직에서도 렌더링 관심사를 분리하는데 목적이 있습니다.

VAC는 다음과 같은 특징을 가지고 있습니다.

  • 반복이나 조건부 노출, 스타일 제어와 같은 렌더링과 관련된 처리만을 수행합니다.
  • 오직 props를 통해서만 제어되며 스스로의 상태를 관리하거나 변경하지 않는 stateless 컴포넌트입니다.
  • 이벤트에 함수를 바인딩할 때 어떠한 추가 처리도 하지 않습니다.

VAC는 state를 가질 수 없지만 state를 가진 컴포넌트를 자식으로 가지는 것은 가능합니다. 이 경우 VAC는 부모 컴포넌트와 자식 컴포넌트 중간에서 개입하지 않고 단순히 props를 전달하는 역할만 합니다.

구현 예제

간단한 SpinBox UI 컴포넌트를 통해 VAC를 만드는 방법을 알아봅시다.

const SpinBox = () => {
  const [value, setValue] = useState(0);

  return (
    <div>
      <button onClick={() => setValue(value - 1)}>-</button>
      <span>{value}</span>
      <button onClick={() => setValue(value + 1)}>+</button>
    </div>
  );
};

예제는 +, – 버튼을 클릭하면 값이 1씩 증가하거나 감소하는 UI 기능을 포함하고 있습니다.

Props Object 정의

View 컴포넌트에서 JSX를 추상화한 Props Object를 생성하고 JSX에서 사용할 상태정보나 이벤트 핸들러를 정의합니다.

const SpinBox = () => {
  const [value, setValue] = useState(0);

  // JSX를 추상화한 Props Object
  const props = {
    value,
    onDecrease: () => setValue(value - 1),
    onIncrease: () => setValue(value + 1),
  };

  // JSX의 유무는 중요하지 않음
  return <div></div>;
};

JSX를 VAC로 분리

JSX영역을 분리하여 VAC로 만듭니다. 이때 View 컴포넌트에 생성한 Props Object 속성을 참고하여 VAC의 Props를 정의합니다.
반대로 이미 만들어진 VAC를 View 컴포넌트에 적용할 때는 VAC의 Props를 참고하여 View 컴포넌트의 Props Object 속성을 정의합니다.

// VAC
const SpinBoxView = ({ value, onIncrease, onDecrease }) => (
  <div>
    <button onClick={onDecrease}>-</button>
    <span>{value}</span>
    <button onClick={onIncrease}>+</button>
  </div>
);
// View Component
const SpinBox = () => {
  const [value, setValue] = useState(0);

  const props = {
    value,
    onDecrease: () => setValue(value - 1),
    onIncrease: () => setValue(value + 1),
  };

  // JSX를 VAC로 교체
  return <SpinBoxView {...props} />;
};

VAC Debugger 활용

만약 VAC가 개발되기 전에 Props Object를 테스트해보고 싶다면 VAC Debugger를 활용할 수 있습니다.

// VAC Debugger
import VAC from "react-vac";

const SpinBox = () => {
  const [value, setValue] = useState(0);

  const props = {
    value,
    onDecrease: () => setValue(value - 1),
    onIncrease: () => setValue(value + 1),
  };

  // VAC Debugger로 Props Object 테스트
  return <VAC name="SpinBox" data={props} />;
};

VAC Debugger를 사용하여 Todo List를 테스트하는 예제

Props Object를 사용하는 이유

Props Object와 VAC를 사용하지 않고 직접 변수를 선언해 JSX 영역에서 UI 기능의 의존성을 줄이는 것도 가능합니다.
하지만 이렇게 개발 하는 경우는 UI 기능이 복잡해서 변수나 hook이 많을 때 어떤 것을 JSX에서 사용하는지 한눈에 파악하기가 어렵고 디버깅 하기가 번거롭습니다. 또 View 컴포넌트 내에서 JSX를 관리하고 있어서 간단한 상태처리의 경우 무의식중에 JSX에서 바로 적용할 가능성이 있습니다.

// View Component
const SpinBox = () => {
  const [value, setValue] = useState(0);

  // JSX에서 사용할 값을 미리 선언하여 JSX에 적용
  const onDecrease = () => setValue(value - 1);
  const onIncrease = () => setValue(value + 1);

  return (
    <div>
      <button onClick={onDecrease}>-</button>
      <span>{value}</span>
      <button onClick={onIncrease}>+</button>
    </div>
  );
};

잘못된 VAC 적용 예시

다음과 같이 View 컴포넌트의 기능이나 상태 제어에 VAC가 관여해서는 안됩니다.

// View Component
const SpinBox = () => {
  const [value, setValue] = useState(0);

  const props = {
    value,
    step: 1,
    handleClick: (n) => setValue(n),
  };

  // VAC에서 value를 제어하는 행위에 관여
  return <SpinBoxView {...props} />;
};
// VAC
const SpinBoxView = ({ value, step, handleClick }) => (
  <div>
    <button onClick={() => handleClick(value - step)}>-</button>
    <span>{value}</span>
    <button onClick={() => handleClick(value + step)}>+</button>
  </div>
);

올바른 VAC는 핸들러를 이벤트에 바인딩만 할 뿐, 무엇을 하는지에 대해서 관여하지 않습니다.

// VAC
const SpinBoxView = ({ value, onIncrease, onDecrease }) => (
  <div>
    <button onClick={onDecrease}>-</button>
    <span>{value}</span>
    <button onClick={onIncrease}>+</button>
  </div>
);

역할에 따른 작업 공간의 분리

예를 들어 SpinBox의 최소 값을 0이하로 설정할 수 없는 조건과 증가 감소 버튼을 둥글게 처리하는 디자인 수정이 발생했다고 가정해봅시다.

일반적인 상황

FE개발자는 자신의 로컬에서 SpinBox 컴포넌트의 감소 버튼 핸들러를 onClick={() => setValue(Math.max(value - 1, 0))} 형태로 기능을 수정합니다.

const SpinBox = () => {
  const [value, setValue] = useState(0);

  return (
    <div>
      <button onClick={() => setValue(Math.max(value - 1, 0))}>-</button>
      <span>{value}</span>
      <button onClick={() => setValue(value + 1)}>+</button>
    </div>
  );
};

UI 개발자는 자신의 로컬에서 SpinBox 컴포넌트의 버튼에 style을 적용할 className="round"를 추가합니다.

const SpinBox = () => {
  const [value, setValue] = useState(0);

  return (
    <div>
      <button className="round" onClick={() => setValue(value - 1)}>
        -
      </button>
      <span>{value}</span>
      <button className="round" onClick={() => setValue(value + 1)}>
        +
      </button>
    </div>
  );
};

이제 서로의 결과물을 저장소에 push 하면 다음 부분에서 충돌이 발생합니다.

// FE 수정
<button onClick={() => setValue(Math.max(value - 1, 0))}>-</button>

// UI 수정
<button className="round" onClick={() => setValue(value - 1)}>-</button>

VAC Pattern 적용

FE개발자는 로컬에서 View 컴포넌트의 Props Object에 있는 onDecrease를 수정합니다.

// VAC Debugger
import VAC from "react-vac";

const SpinBox = () => {
  const [value, setValue] = useState(0);

  const props = {
    value,
    onDecrease: () => setValue(Math.max(value - 1, 0)),
    onIncrease: () => setValue(value + 1),
  };

  // VAC Debugger로 Props Object 테스트
  return <VAC name="SpinBox" data={props} />;
};

UI개발자는 VAC에서 style을 적용합니다.

// VAC
const SpinBoxView = ({ value, onIncrease, onDecrease }) => (
  <div>
    <button className="round" onClick={onDecrease}>
      -
    </button>
    <span>{value}</span>
    <button className="round" onClick={onIncrease}>
      +
    </button>
  </div>
);

FE개발자는 View 컴포넌트에서 기능을 수정하였고, UI개발자는 VAC에서 JSX를 수정하여 충돌이 발생하지 않습니다.

렌더링에 직관적인 상태 관리

SpinBox 기능이 0 ~ 10 범위만 사용하도록 증가 감소 버튼의 disabled 상태를 처리한다고 가정해봅시다.

일반적인 상황

JSX에 직접 disabled 조건을 처리합니다.

const SpinBox = () => {
  const [value, setValue] = useState(0);

  return (
    <div>
      <button disabled={value < 1} onClick={() => setValue(value - 1)}>
        -
      </button>
      <span>{value}</span>
      <button disabled={value > 9} onClick={() => setValue(value + 1)}>
        +
      </button>
    </div>
  );
};

VAC Pattern 적용

Props Object에서 VAC 속성을 정의하여 사용하기 때문에 렌더링에 더 직관적인 형태로 상태를 관리할 수 있습니다.

View 컴포넌트에서는 JSX에 어떻게 상태가 적용되는지 신경 쓸 필요가 없으며, VAC(JSX) 관점에서는 어떤 조건에서 버튼이 활성/비활성 되는지를 파악할 필요가 없습니다.

// View Component
const SpinBox = () => {
  const [value, setValue] = useState(0);

  const props = {
    value,
    disabledDecrease: value < 1,
    disabledIncrease: value > 9,
    onDecrease: () => setValue(value - 1),
    onIncrease: () => setValue(value + 1),
  };

  // JSX를 VAC로 교체
  return <SpinBoxView {...props} />;
};
// VAC
const SpinBoxView = ({ value, disabledDecrease, disabledIncrease, onIncrease, onDecrease }) => (
  <div>
    <button disabled={disabledDecrease} onClick={onDecrease}>
      -
    </button>
    <span>{value}</span>
    <button disabled={disabledIncrease} onClick={onIncrease}>
      +
    </button>
  </div>
);

VAC의 props 네이밍은 데이터 친화적인 형태 보다는 렌더링에 직관적인 형태로 사용하는 것이 좋습니다. isMax, isMin 보다는 disabledDescrease, disabledIncrease가 렌더링에서 어떤 역할을 하는지 유추하기 더 쉽습니다.

또 여러 정보를 사용하는 경우 개별로 전달하는 것 보다는 조합된 결과만 전달하는 것이 좋습니다. 로그인 한 상태에서 작상자 본인이면 수정 버튼을 노츨하는 조건을 처리한다고 했을 때, isLogin, isOwner을 각각 전달 받아 VAC 내에서 isLogin && isOwner 형태로 사용하는 것 보다는 showEditButton: isLogin && isOwner 형태로 처음부터 조합해서 전달하는 것이 좋습니다.

Presentational 컴포넌트와 VAC

VAC 패턴은 Container 컴포넌트에 로직을 위임하는 설계 방식을 따르기 때문에 Presentational과 Container 컴포넌트 패턴의 한 종류라고 볼 수 있습니다. 때문에 VAC가 Presentational 컴포넌트와 동일한 역할을 하는 것 처럼 혼동되는 경우가 있으나, 두 컴포넌트의 근본적인 차이는 컴포넌트가 View 로직(UI 기능, 상태 관리)을 가질수 있는지 여부입니다.

Presentational 컴포넌트는 상황에 따라 View와 관련된 state를 가지고 스스로 상태를 제어하는 것을 허용하지만, VAC는 stateless 컴포넌트로 스스로의 상태를 제어하지 않고 항상 부모 컴포넌트에서 Props Object를 통해 관리합니다. 따라서 VAC는 Presentational 컴포넌트보다 더 구체적인 기준을 제시하여 JSX를 처리하는 컴포넌트 관점에서 일관성 있는 설계를 하는데 도움을 줍니다.

Presentational 컴포넌트

  • 비즈니스 로직과 View의 관심사 분리가 목적
  • Container 컴포넌트에서 비즈니스 로직을 관리하고 Presentational 컴포넌트를 제어
  • Presentational 컴포넌트는 View 로직(UI 기능, 상태 관리)과 렌더링을 담당

VAC

  • View 로직(UI 기능, 상태 관리)과 렌더링(JSX)의 관심사 분리가 목적
  • View 컴포넌트가 VAC의 Container 컴포넌트 역할을 하며 JSX를 추상화한 Props Object를 관리하여 VAC를 제어
  • VAC는 JSX, Style을 관리하여 렌더링 처리

끝으로

지금까지 소개한 VAC 패턴은 JSX 영역을 View 컴포넌트에서 독립적으로 관리하기 위한 목적을 가지고 만들어진 설계 방법입니다. 이런 이유로, 비즈니스 로직과 View의 관심사를 분리하는 여러 기법이나 패턴을 적용했음에도 여전히 JSX 관리로 인해 UI개발자와 FE개발자가 협업에 어려움이 있다면 VAC 패턴을 활용해보는 것은 어떨까합니다.

참고자료


0개의 댓글

답글 남기기

Avatar placeholder

이메일 주소는 공개되지 않습니다. 필수 항목은 *(으)로 표시합니다