[React] 리액트 컴포넌트 패턴

또이·2024년 11월 19일
1

왜 컴포넌트 패턴이 필요한가요?

React로 개발을 하다 보면 이런 고민들을 하게 됩니다.

"props가 너무 많은거 아닌가...?"
"코드가 너무 반복되는거 아닌가...?"
"나중에 컴포넌트를 수정해야 하면 어떡하지....?"

이러한 문제들을 해결하기 위해서
바로 컴포넌트 패턴을 알아야 합니다.

컴포넌트 패턴의 장점

  1. 재사용성이 좋다
  2. 유지보수성이 좋다
  3. 로직과 UI를 깔끔하게 분리할 수 있다
  4. 확장성이 좋다

컴포넌트와 추상화

컴포넌트 단위로 개발하면,
재사용성과 관심사의 분리, 응집도 있는 로직, 유연한 코드로 개발이 가능합니다!!

제어의 역전(IoC)와 컴포넌트 패턴

IoC란?

제어의 역전(Inversion of Control)은 프로그래밍에서 API를 사용하는 쪽으로 특정 역할을 넘기는 패턴입니다.
쉽게 말해 "작업의 실행 흐름을 개발자가 직접 제어하는 것이 아니라, 사용자에게 위임하는 것"으로,
페이지를 개발할 때 컴포넌트를 조합하여 만드는 것을 제어의 역전이라고 볼 수 있습니다.

JS Array로 보는 제어의 역전

map, forEach, filter, reduce가 제어의 역전의 예시입니다.

// 일반 filter
const dogs = filterDogs(animals);

// 제어역전 filter
const dogs = animals.filter(animal => animal.species === 'dog');

filterDogs를 호출할 때는 필터링 로직이 바뀌면 파라미터를 넘겨줘야 하지만,
아래 코드처럼 작성하면 필터링 기능만 제공하고 어떻게 필터링할지는 개발자에게 맡깁니다.
따라서 필터링 로직에 변화가 생기더라도 기존 filter 함수는 그대로 남아있습니다.

컴포넌트를 사용하는 개발자에게 컴포넌트의 제어권을 넘겨줌으로써
개발자가 원하는대로 컴포넌트를 컨트롤 할 수 있도록 컴포넌트를 설계해야 합니다.

여기서 잠깐‼️컴포넌트에서의 사용자 🆚 개발자

  • 컴포넌트 개발자: 컴포넌트를 설계하고 구현하는 사람
  • 컴포넌트 사용자: 만들어진 컴포넌트를 실제 사용하는 개발자
  • 렌더링 IoC

    • 컴포넌트는 기본적으로 props와 state를 내부의 로직들로 가공하여 최종적으로 화면에 렌더링
    • 개발자는 단지 렌더링 요소에 포함될 props만 전달할뿐, 렌더링 결과물은 컴포넌트 내부에 의해서만 결정되면, 이미 사용 중이던 컴포넌트를 수정함으로써 사이드 이펙트가 발생 가능
  • 상태관리 IoC

    • 컴포넌트 내부의 해당 상태의 변화를 처리하는 로직들을 컴포넌트 외부에서 컨트롤할 수 있도록 설계해야 합니다.

Counter로 알아보는 리액트 컴포넌트 패턴

const Counter= () => {
  const [count, setCount] = useState(0);

  const increment = () => setCount(prev => prev + 1);
  const decrement = () => setCount(prev => prev - 1);

  return (
    <div className="counter">
      <button onClick={decrement}>-</button>
      <span>{count}</span>
      <button onClick={increment}>+</button>
    </div>
  );
};

현재 Counter 컴포넌트는 여러 문제점들이 있습니다.

  1. 카운터의 최대/최소값을 설정할 수 없다
  2. 커스텀이 어렵다
  3. 로직 재사용이 어렵다

컴포넌트 패턴을 이용해서 문제를 해결하는 방법을 알아보겠습니다!

잘못된 추상화란 무엇인가요? (feat. props)

추상화를 위해 props를 많이 넘겨주면 아래 문제점들을 겪게 됩니다...🥲
1. 개발자가 props가 어떤 역할을 하는지 파악하기 어려워짐
2. 파악하기 어려운 props를 설명해주기 위한 주석이나 문서 작성 및 관리가 필요함
3. 요구사항이 복잡해질수록 기괴한 props명이 나올 확률 증가
4. 컴포넌트를 변경하기 어려움

고차 컴포넌트 (HOC)

고차 컴포넌트는 컴포넌트를 인자로 받아서 새로운 컴포넌트를 반환하는 함수입니다.

const withCounter = ( WrappedComponent ) => {
  return function WithCounter(props) {
    const [count, setCount] = useState(0);

    const increment = () => setCount(prev => prev + 1);
    const decrement = () => setCount(prev => prev - 1);

    return (
      <WrappedComponent
        {...props}
        count={count}
        onIncrement={increment}
        onDecrement={decrement}
      />
    );
  };
};
const CounterDisplay= ({ count, onIncrement, onDecrement }) => (
  <div className="counter">
    <button onClick={onDecrement}>-</button>
    <span>{count}</span>
    <button onClick={onIncrement}>+</button>
  </div>
);

const EnhancedCounter = withCounter(CounterDisplay);

장점

  • 로직을 재사용성 증가
  • 기존 컴포넌트를 수정하지 않고 기능 확장 가능

단점

  • props의 출처를 추적하기 어려움
  • 여러 HOC를 조합하면 복잡해짐

이번에는 렌더링 IoC를 위한 컴포넌트 패턴을 적용해봅시다!

Render Props 패턴

컴포넌트의 렌더링 함수를 props로 전달받아 사용하는 패턴으로,
구성요소 자체는 render prop을 호출하기만 합니다.

const Counter = ({ render }) => {
  const [count, setCount] = useState(0);

  const increment = () => setCount(prev => prev + 1);
  const decrement = () => setCount(prev => prev - 1);

  return <>{render({ count, increment, decrement })}</>;
};
<Counter
  render={({ count, increment, decrement }) => (
    <div className="counter">
      <button onClick={decrement}>-</button>
      <span>{count}</span>
      <button onClick={increment}>+</button>
    </div>
  )}
/>

장점

  • 렌더링 로직을 커스텀 가능
    • render prop만 전달하기 때문에 컴포넌트의 렌더링 방식을 컨트롤할 수 있음
  • 컴포넌트 로직 재사용이 용이함
    • 동일한 렌더링 방식이 연속적으로 사용되는 컴포넌트(ex. 리스트, 테이블)에서 유용함

단점

  • 복잡한 조건의 렌더링 방식에는 제한적
  • 콜백 지옥에 빠질 수 있음
  • 컴포넌트 형태의 호출(<Component/>)이 아닌 함수 형태의 호출(() => (<Component/>))로 렌더링을 하면 리액트에서 컴포넌트로 인식하지 않기 때문에, hook과 같이 컴포넌트에서만 쓸 수 있는 기능을 사용할 때는 주의가 필요

합성 컴포넌트 패턴 (Compound Components)

여러 컴포넌트를 하나의 단위로 묶어서 사용하는 패턴으로
Render Props보다 더 컴포넌트스럽게 렌더링을 컨트롤할 수 있습니다.
대부분 로직은 컴포넌트에 포함되며, Context API를 통해 states와 handler를 Children 컴포넌트 간에 공유합니다.

const CounterContext = createContext(null);

const Counter = ({ children }) => {
  const [count, setCount] = useState(0);

  const increment = () => setCount(prev => prev + 1);
  const decrement = () => setCount(prev => prev - 1);

  return (
    <CounterContext.Provider value={{ count, increment, decrement }}>
      {children}
    </CounterContext.Provider>
  );
};

const CounterButton = ({ type }) => {
  const context = useContext(CounterContext);
  if (!context) throw new Error('Must be used within Counter');

  return (
    <button onClick={type === 'increment' ? context.increment : context.decrement}>
      {type === 'increment' ? '+' : '-'}
    </button>
  );
};

const CounterDisplay = () => {
  const context = useContext(CounterContext);
  if (!context) throw new Error('Must be used within Counter');

  return <span>{context.count}</span>;
};

Counter.Button = CounterButton;
Counter.Display = CounterDisplay;

export default Counter;
<Counter>
  <Counter.Button type="decrement" />
  <Counter.Display />
  <Counter.Button type="increment" />
</Counter>

장점

  • 컴포넌트 구조가 명확하고 직관적
    • Render Props처럼 props로 넘겨주는 것보다 복잡도가 낮음
  • 유연한 커스터마이징 가능
  • props drilling 문제 해결

단점

  • 컴포넌트 구조가 복잡해질 수 있음
  • 높은 자유도를 가짐
    • 사용자가 컴포넌트를 사용하는 것에 의존한다면 큰 자유도를 주면 안됨

자유도가 크다? 사용자가 의존하면 왜 자유도를 주면 안되나요?

자식 컴포넌트를 조합하고 순서를 변경할 수 있어서 UI 자유도가 큰 패턴입니다.
암묵적인 룰을 지키지 않고 개발한다면 사이드 이펙트가 발생할 수 있습니다🥲
아래처럼 <Counter.Button />을 생략한다면 카운트를 감소시킬 방법이 없겠죠...?

<Counter>
  <Counter.Count />
  <Counter.Button type="increment" />
</Counter>

이 부분은 defaultProps를 통해 기본적인 동작을 제공하거나, 생략된 컴포넌트를 자동으로 채워서 해결이 가능합니다!
유연성과 예측 가능성 사이의 균형을 유지하는 것이 합성 컴포넌트 패턴을 성공적으로 사용하는 핵심‼️


상태 IoC를 위한 컴포넌트 패턴도 알아봅시다!

제어 컴포넌트 패턴 (Controlled Props Pattern)

상태를 컴포넌트 내부에서 관리하지 않고, 부모 컴포넌트가 상태를 관리하며 자식 컴포넌트는 상태와 동작을 props를 통해 전달받아 사용하는 패턴입니다.

컴포넌트 내부에 정의된 state나 useState 상태 값과 해당 상태 값을 변경하는 로직들을 사용하지 않고, 프로퍼티를 통해 외부에서 들어온 상태 값과 콜백 함수를 사용함으로써 외부에서 컴포넌트의 상태를 컨트롤할 수 있도록 합니다!

SSOT (Single Source of Truth)를 통해 상태를 부모 컴포넌트에서 관리합니다.

SSOT..?

  • 상태는 항상 외부(부모 컴포넌트)에 있고, 컴포넌트는 상태를 변경하는 역할
  • 데이터의 출처가 하나라서 상태가 일관되게 유지
const Counter = ({ count, onIncrement, onDecrement }) => {
  return (
    <div className="counter">
      <button onClick={onDecrement}>-</button>
      <span>{count}</span>
      <button onClick={onIncrement}>+</button>
    </div>
  );
};
const CounterContainer = () => {
  const [count, setCount] = useState(0);

  const increment = () => setCount(prev => prev + 1);
  const decrement = () => setCount(prev => prev - 1);

  return (
    <Counter count={count} onIncrement={increment} onDecrement={decrement} />
  );
};

장점

  • 더 많은 통제권
    • 부모가 상태와 로직을 관리해서 유연성이 증가
  • 로직 확장 용이
    • 상태를 부모에게 직접 관리 => 커스텀 로직(ex. 로컬 스토리지 저장, API 연동)을 쉽게 추가할 수 있음

단점

  • 상태와 콜백 함수가 많아질수록 관리 복잡성이 증가
    • 상태 값이 많고, 이를 컨트롤하기 위한 콜백 함수가 많아지면, 오히려 정의해야하는 함수와 컴포넌트의 props가 많아져서 사용성에 문제
    • 각 상태와 핸들러를 계속해서 정의해야 함
    • Props Getter 패턴으로 해결할 수 있음!!!
  • 상태 관리와 이벤트 핸들링이 부모에서 이루어지기 때문에 사용 코드, useState, handleChange 세 곳을 모두 체크해야 함

커스텀 Hook 패턴(Custom Hook)

로직을 Hook으로 분리하여 더 많은 통제권으로 재사용성을 높이는 패턴으로,
로직이 렌더링하는 부분과 분리되기 때문에 여러 컴포넌트에서 동일한 로직을 쉽게 재사용할 수 있습니다.

const useCounter = (initialValue = 0) => {
  const [count, setCount] = useState(initialValue);

  const increment = useCallback(() => setCount(prev => prev + 1), []);
  const decrement = useCallback(() => setCount(prev => prev - 1), []);

  return {
    count,
    increment,
    decrement
  };
};
const Counter = () => {
  const { count, increment, decrement } = useCounter();

  return (
    <div className="counter">
      <button onClick={decrement}>-</button>
      <span>{count}</span>
      <button onClick={increment}>+</button>
    </div>
  );
};

장점

  • 로직을 깔끔하게 분리할 수 있음
  • 여러 컴포넌트에서 동일한 로직을 쉽게 재사용
  • 로직을 독립적으로 테스트하기 쉬움

단점

  • 복잡한 로직의 경우 Hook이 너무 커질 수 있음
  • 상태 공유가 필요한 경우 추가적인 처리가 필요함(ex. Context)

Props Getter 패턴

필요한 상태 값과 콜백을 자동으로 묶어서 전달하는 패턴으로,
제어 컴포넌트 패턴에서 상태 값과 콜백 함수를 각각 정의하고 전달해야 하는 중복 문제를 해결합니다.
navtive props를 직접 노출하지 않고, props getters의 목록을 제공합니다.

사용자는 필요한 props들을 쉽게 넣을 수 있고, 중복되는 로직의 콜백 함수를 재정의 하지 않아도 되고, 오직 필요한 콜백 함수만을 오버라이딩하여 컴포넌트를 사용할 수 있습니다.

const useCounter = (initialValue = 0) => {
  const [count, setCount] = useState(initialValue);

  const increment = () => setCount(prev => prev + 1);
  const decrement = () => setCount(prev => prev - 1);

  const getButtonProps = (type, overrides = {}) => {
    const handlers = {
      increment: { onClick: increment, "aria-label": "Increment" },
      decrement: { onClick: decrement, "aria-label": "Decrement" },
    };

    return {
      ...handlers[type],
      ...overrides, // 사용자 정의 props 병합
    };
  };

  return { count, getButtonProps };
};
const Counter = () => {
  const { count, getButtonProps } = useCounter();

  return (
    <div className="counter">
      <button {...getButtonProps("decrement")}>-</button>
      <span>{count}</span>
      <button {...getButtonProps("increment")}>+</button>
    </div>
  );
};

장점

  • 중복 제거
  • 필요한 props만 추가로 정의가 가능함
    <button {...getButtonProps("increment", { className: "custom-class" })}>
      + 
    </button>

단점

  • 근본적으로 콜백함수 props가 많다는 문제를 해결해 주지는 않음
  • getter를 통한 추상화는 사용하기 쉽지만, 함수가 복잡해지면 직관적이지 않기 때문에 코드를 이해하거나 유지보수하기 어려워짐

패턴장점단점
제어 컴포넌트 패턴- 부모가 상태와 로직을 관리하여 유연성 증가
- 로직 확장 용이
- 상태 일관성 유지
- 상태와 콜백 함수가 많아질수록 관리 복잡성 증가
- 콜백 정의 중복 문제 발생
커스텀 Hook 패턴- 로직 분리로 깔끔한 코드
- 재사용성 높음
- 테스트 용이
- Hook이 복잡해질 수 있음
- 상태 공유가 필요한 경우 추가 처리 필요
- 로직과 UI 연결 필요
Props Getter 패턴- 콜백 정의 중복 문제 해결
- 사용자 편의성 증가
- 로직 캡슐화
- 콜백 수를 줄이지는 못함
- 코드 가독성 저하 가능성
- 추상화의 한계

State Reducer 패턴

상태 관리의 복잡성과 유연성을 동시에 해결하기 위해 사용되는 패턴으로,
컴포넌트 외부에서 reducer를 정의하여 내부 로직을 오버라이드해서 상태와 동작을 제어할 수 있습니다.
useReducer을 활용하여 상태를 관리하며, 외부에서 전달된 reducer를 결합하여 동작을 확장하거나 수정할 수 있습니다.

const counterReducer = (state, action) => {
  switch (action.type) {
    case "INCREMENT":
      return state + 1;
    case "DECREMENT":
      return state - 1;
    case "SET":
      return action.value ?? 0;
    default:
      return state;
  }
};

const Counter = ({ reducer }) => {
  const [count, dispatch] = useReducer(
    (state, action) => reducer(state, action, counterReducer), 
    0
  );

  return (
    <div>
      <button onClick={() => dispatch({ type: "DECREMENT" })}>-</button>
      <input
        value={count}
        onChange={(e) => dispatch({ type: "SET", value: Number(e.target.value) })}
      />
      <button onClick={() => dispatch({ type: "INCREMENT" })}>+</button>
    </div>
  );
};
const customReducer = (state, action, defaultReducer) => {
  switch (action.type) {
    case "INCREMENT":
      return state + 2; // 커스텀
    default:
      return defaultReducer(state, action);
  }
};

const App = () => {
  return <Counter reducer={customReducer} />;
};

장점

  • 더 많은 통제권
    • 모든 내부 동작들이 외부에서 접근가능하고 오버라이드할 수 있음
    • 컴포넌트 사용자는 원하는 대로 동작하는 새로운 reducer를 작성하여 프로퍼티로 넘겨줌으로써, 해당 컴포넌트 컨트롤 가능
  • 컴포넌트의 props에 대해 신경쓰지 않아도 됨
    • 컴포넌트의 콜백 함수가 변경되거나 새로운 콜백 함수가 추가된다고 하더라도 결국 {reducer}만 전달하면 되기 때문에, 사용자는 훨씬 간결하게 컴포넌트를 사용 가능

단점

  • 컴포넌트 내부 로직에 대한 깊은 이해가 필요함

결론

어떻게 패턴을 선택하나요?

상황추천 패턴이유
주니어 개발자Custom Hook- 이해하기 쉽고 직관적
- React 기본 개념만으로 사용 가능
- 디버깅이 쉬움
복잡한 폼 컴포넌트Compound Components- 관련 컴포넌트를 그룹화 가능
- 내부 상태 관리가 용이
- 유연한 레이아웃 구성 가능
공통 로직이 많은 경우HOC- 로직 재사용성이 높음
- 기존 컴포넌트를 수정하지 않고 기능 확장 가능
- 관심사 분리가 깔끔
매우 유연한 컴포넌트가 필요한 경우Render Props- 최대한의 커스터마이징 자유도
- 동적인 렌더링 로직 구현 가능
- 타입 안정성 확보 용이
대규모 프로젝트Compound Components + Custom Hook- 상태 관리와 로직을 분리하여 유지보수 용이
- 관련 컴포넌트를 논리적으로 그룹화하여 복잡성 감소
소규모 프로젝트Custom Hook- 간단하고 직관적인 로직 분리 가능
- 불필요한 복잡성을 줄이고 개발 속도 향상
라이브러리 개발HOC, Render Props- 높은 재사용성과 확장성 제공
- 다양한 사용자 시나리오에 대응 가능
성능이 중요한 경우Custom Hook, HOC- 불필요한 렌더링 방지
- 상태와 동작을 효율적으로 분리하여 성능 최적화

컴포넌트를 설계할 때 고려할 부분들

  • 재사용성과 독립성: 컴포넌트는 다른 사람의 코드와 조합 가능하고, 독립적으로 작동
  • 일관된 추상화: 복잡성을 줄이고, 공통 패턴을 통합하기
  • 성능 최적화: React의 스케줄링과 지연 업데이트를 활용해 불필요한 연산을 줄이기
  • 디버깅 가능성: 명확한 props와 state 구조 유지
  • 실용적 설계: 너무 이상적이거나 복잡한 설계보다는, 현실적이고 이해 가능한 구조 선택

컴포넌트 패턴에는 정답이 없기 때문에 꼭!! 열심히 하고 알맞게 컴포넌트 패턴을 도입하는 것이 좋을 것 같습니다 :)

참고

Design Principles – React
이제부터 이 컴포넌트는 제 겁니다 | 카카오엔터테인먼트 FE 기술블로그
유용한 리액트 패턴 5가지

profile
또이의 개발새발 개발일기

2개의 댓글

comment-user-thumbnail
2024년 11월 20일

안녕하세요 채현님! 양질의 아티클 감사합니다.
다양한 컴포넌트 패턴에 대한 전반적인 소개와 더불어 적절한 예시 코드 덕분에 어려운 내용임에도 불구하고 술술 읽어나갈 수 있었습니다. 제어의 역전(IoC)에 대한 내용까지 소개해주셔서 컴포넌트 설계에 있어서 어떠한 점을 고려해야 할지 고민해볼 수 있는 시간이었습니다. 특히, 각 컴포넌트 패턴 별로 장-단점을 설명해주고, 상황별로 추천하는 패턴을 표로 요약해주신 부분은 매우 인상깊었습니다. 저장해놓고 나중에 필요할 때 찾으러 와야겠어요.
정성스럽게 작성된 글 감사합니다! 😊

답글 달기
comment-user-thumbnail
2024년 11월 20일

안녕하세요 채현님!
4주차 아티클 작성하시느라 고생 많으셨습니다.

사실 컴포넌트 패턴이라는 것을 공부해본 적이 없어서, 평소 작성하던 코드가 어떤 패턴에 해당하는지도 모르고 막연히 작성했던 것 같습니다. 하지만 채현님의 아티클 덕분에 컴포넌트 패턴에 대해 처음부터 차근차근 이해할 수 있었고, 앞으로의 개발에 있어 많은 도움이 될 것 같습니다.

왜 컴포넌트 패턴을 적용해야 하는지, 각각의 패턴이 가진 장단점과 적용해야 하는 이유를 예제 코드와 함께 깔끔하게 정리해 주신 부분이 인상 깊었습니다. 특히 마지막에 패턴별로 장단점을 정리한 표와 함께, 실제 상황에 따른 추천 패턴을 상세히 작성해주신 부분은 앞으로도 많은 도움이 될 것 같습니다.

평소 과제를 할 때는 이러한 컴포넌트 패턴 적용은 크게 고려하지 않았지만, 이번 합동 세미나나 앞으로 있을 앱잼 같은 볼륨이 큰 프로젝트, 또는 협업 환경에서 특히 중요하게 다뤄야 할 내용이라는 점을 새삼 깨달았는데요, 이번 주차에 배운 내용을 바탕으로 조금씩 패턴을 적용해보고 싶습니다.

앞으로도 패턴 관련하여 모르는 부분이 생길 때마다 이 아티클을 다시 찾아오게 될 것 같아요.
좋은 아티클 작성해주셔서 감사드리고, 이번 주도 수고 많으셨습니다!

답글 달기