React로 개발을 하다 보면 이런 고민들을 하게 됩니다.
"props가 너무 많은거 아닌가...?"
"코드가 너무 반복되는거 아닌가...?"
"나중에 컴포넌트를 수정해야 하면 어떡하지....?"
이러한 문제들을 해결하기 위해서
바로 컴포넌트 패턴을 알아야 합니다.
컴포넌트와 추상화
컴포넌트 단위로 개발하면,
재사용성과 관심사의 분리, 응집도 있는 로직, 유연한 코드로 개발이 가능합니다!!
제어의 역전(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
상태관리 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 컴포넌트는 여러 문제점들이 있습니다.
컴포넌트 패턴을 이용해서 문제를 해결하는 방법을 알아보겠습니다!
잘못된 추상화란 무엇인가요? (feat. props)
추상화를 위해 props를 많이 넘겨주면 아래 문제점들을 겪게 됩니다...🥲
1. 개발자가 props가 어떤 역할을 하는지 파악하기 어려워짐
2. 파악하기 어려운 props를 설명해주기 위한 주석이나 문서 작성 및 관리가 필요함
3. 요구사항이 복잡해질수록 기괴한 props명이 나올 확률 증가
4. 컴포넌트를 변경하기 어려움
고차 컴포넌트는 컴포넌트를 인자로 받아서 새로운 컴포넌트를 반환하는 함수입니다.
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로 전달받아 사용하는 패턴으로,
구성요소 자체는 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만 전달하기 때문에 컴포넌트의 렌더링 방식을 컨트롤할 수 있음<Component/>)이 아닌 함수 형태의 호출(() => (<Component/>))로 렌더링을 하면 리액트에서 컴포넌트로 인식하지 않기 때문에, hook과 같이 컴포넌트에서만 쓸 수 있는 기능을 사용할 때는 주의가 필요여러 컴포넌트를 하나의 단위로 묶어서 사용하는 패턴으로
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>
자유도가 크다? 사용자가 의존하면 왜 자유도를 주면 안되나요?
자식 컴포넌트를 조합하고 순서를 변경할 수 있어서 UI 자유도가 큰 패턴입니다.
암묵적인 룰을 지키지 않고 개발한다면 사이드 이펙트가 발생할 수 있습니다🥲
아래처럼<Counter.Button />을 생략한다면 카운트를 감소시킬 방법이 없겠죠...?<Counter> <Counter.Count /> <Counter.Button type="increment" /> </Counter>이 부분은
defaultProps를 통해 기본적인 동작을 제공하거나, 생략된 컴포넌트를 자동으로 채워서 해결이 가능합니다!
유연성과 예측 가능성 사이의 균형을 유지하는 것이 합성 컴포넌트 패턴을 성공적으로 사용하는 핵심‼️
상태를 컴포넌트 내부에서 관리하지 않고, 부모 컴포넌트가 상태를 관리하며 자식 컴포넌트는 상태와 동작을 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} />
);
};
useState, handleChange 세 곳을 모두 체크해야 함로직을 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>
);
};
필요한 상태 값과 콜백을 자동으로 묶어서 전달하는 패턴으로,
제어 컴포넌트 패턴에서 상태 값과 콜백 함수를 각각 정의하고 전달해야 하는 중복 문제를 해결합니다.
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>
);
};
<button {...getButtonProps("increment", { className: "custom-class" })}>
+
</button>| 패턴 | 장점 | 단점 |
|---|---|---|
| 제어 컴포넌트 패턴 | - 부모가 상태와 로직을 관리하여 유연성 증가 - 로직 확장 용이 - 상태 일관성 유지 | - 상태와 콜백 함수가 많아질수록 관리 복잡성 증가 - 콜백 정의 중복 문제 발생 |
| 커스텀 Hook 패턴 | - 로직 분리로 깔끔한 코드 - 재사용성 높음 - 테스트 용이 | - Hook이 복잡해질 수 있음 - 상태 공유가 필요한 경우 추가 처리 필요 - 로직과 UI 연결 필요 |
| Props Getter 패턴 | - 콜백 정의 중복 문제 해결 - 사용자 편의성 증가 - 로직 캡슐화 | - 콜백 수를 줄이지는 못함 - 코드 가독성 저하 가능성 - 추상화의 한계 |
상태 관리의 복잡성과 유연성을 동시에 해결하기 위해 사용되는 패턴으로,
컴포넌트 외부에서 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}만 전달하면 되기 때문에, 사용자는 훨씬 간결하게 컴포넌트를 사용 가능| 상황 | 추천 패턴 | 이유 |
|---|---|---|
| 주니어 개발자 | Custom Hook | - 이해하기 쉽고 직관적 - React 기본 개념만으로 사용 가능 - 디버깅이 쉬움 |
| 복잡한 폼 컴포넌트 | Compound Components | - 관련 컴포넌트를 그룹화 가능 - 내부 상태 관리가 용이 - 유연한 레이아웃 구성 가능 |
| 공통 로직이 많은 경우 | HOC | - 로직 재사용성이 높음 - 기존 컴포넌트를 수정하지 않고 기능 확장 가능 - 관심사 분리가 깔끔 |
| 매우 유연한 컴포넌트가 필요한 경우 | Render Props | - 최대한의 커스터마이징 자유도 - 동적인 렌더링 로직 구현 가능 - 타입 안정성 확보 용이 |
| 대규모 프로젝트 | Compound Components + Custom Hook | - 상태 관리와 로직을 분리하여 유지보수 용이 - 관련 컴포넌트를 논리적으로 그룹화하여 복잡성 감소 |
| 소규모 프로젝트 | Custom Hook | - 간단하고 직관적인 로직 분리 가능 - 불필요한 복잡성을 줄이고 개발 속도 향상 |
| 라이브러리 개발 | HOC, Render Props | - 높은 재사용성과 확장성 제공 - 다양한 사용자 시나리오에 대응 가능 |
| 성능이 중요한 경우 | Custom Hook, HOC | - 불필요한 렌더링 방지 - 상태와 동작을 효율적으로 분리하여 성능 최적화 |
컴포넌트 패턴에는 정답이 없기 때문에 꼭!! 열심히 하고 알맞게 컴포넌트 패턴을 도입하는 것이 좋을 것 같습니다 :)
Design Principles – React
이제부터 이 컴포넌트는 제 겁니다 | 카카오엔터테인먼트 FE 기술블로그
유용한 리액트 패턴 5가지
안녕하세요 채현님!
4주차 아티클 작성하시느라 고생 많으셨습니다.
사실 컴포넌트 패턴이라는 것을 공부해본 적이 없어서, 평소 작성하던 코드가 어떤 패턴에 해당하는지도 모르고 막연히 작성했던 것 같습니다. 하지만 채현님의 아티클 덕분에 컴포넌트 패턴에 대해 처음부터 차근차근 이해할 수 있었고, 앞으로의 개발에 있어 많은 도움이 될 것 같습니다.
왜 컴포넌트 패턴을 적용해야 하는지, 각각의 패턴이 가진 장단점과 적용해야 하는 이유를 예제 코드와 함께 깔끔하게 정리해 주신 부분이 인상 깊었습니다. 특히 마지막에 패턴별로 장단점을 정리한 표와 함께, 실제 상황에 따른 추천 패턴을 상세히 작성해주신 부분은 앞으로도 많은 도움이 될 것 같습니다.
평소 과제를 할 때는 이러한 컴포넌트 패턴 적용은 크게 고려하지 않았지만, 이번 합동 세미나나 앞으로 있을 앱잼 같은 볼륨이 큰 프로젝트, 또는 협업 환경에서 특히 중요하게 다뤄야 할 내용이라는 점을 새삼 깨달았는데요, 이번 주차에 배운 내용을 바탕으로 조금씩 패턴을 적용해보고 싶습니다.
앞으로도 패턴 관련하여 모르는 부분이 생길 때마다 이 아티클을 다시 찾아오게 될 것 같아요.
좋은 아티클 작성해주셔서 감사드리고, 이번 주도 수고 많으셨습니다!
안녕하세요 채현님! 양질의 아티클 감사합니다.
다양한 컴포넌트 패턴에 대한 전반적인 소개와 더불어 적절한 예시 코드 덕분에 어려운 내용임에도 불구하고 술술 읽어나갈 수 있었습니다. 제어의 역전(IoC)에 대한 내용까지 소개해주셔서 컴포넌트 설계에 있어서 어떠한 점을 고려해야 할지 고민해볼 수 있는 시간이었습니다. 특히, 각 컴포넌트 패턴 별로 장-단점을 설명해주고, 상황별로 추천하는 패턴을 표로 요약해주신 부분은 매우 인상깊었습니다. 저장해놓고 나중에 필요할 때 찾으러 와야겠어요.
정성스럽게 작성된 글 감사합니다! 😊