Compound Components 패턴은 컴포넌트의 구성 요소를 조합하여 더 복잡한 기능을 만드는 방법이다.
이 패턴은 하나의 상위 수준 컴포넌트가 여러 개의 하위 컴포넌트로 구성되어 있는 경우에 적합하다. 각 하위 컴포넌트들은 서로 간의 관계를 공유하며, 부모 컴포넌트의 상태와 데이터에 접근할 수 있다. 이 패턴은 불필요한 Props Drilling 없이 표현적이고 선언적인 컴포넌트를 만들 수 있다.
Compound Components 패턴의 주요 특징은 다음과 같다.
고립된 구성: 각 하위 컴포넌트는 독립적으로 작동하며, 부모 컴포넌트의 직접적인 관계를 가지지 않는다. 이로 인해 컴포넌트 간의 결합도가 낮아지고, 재사용성과 유지보수성이 향상된다.
공유 상태: 하위 컴포넌트들은 상위 수준 컴포넌트의 상태와 데이터에 접근할 수 있다. 이를 통해 다양한 하위 컴포넌트들이 동일한 상태를 활용하며, 같은 상태에 대해 반응적으로 동작할 수 있다.
유연한 구성: 하위 컴포넌트들은 여러 개의 컴포넌트로 조합될 수 있다. 이로 인해 사용자는 원하는 형태로 컴포넌트들을 구성하여 사용할 수 있다.
예를 들어, 토글 버튼과 해당 버튼을 활성화시키는 컨텐츠 패널이 서로 연결되는 기능을 구현할 때 컴파운트 컴포넌트 패턴을 사용할 수 있다. 이때, 토글 버튼과 컨텐츠 패널은 각각 독립적인 컴포넌트로 구성되지만, 부모 컴포넌트에서 이 두 개의 하위 컴포넌트를 함께 그룹화하여 사용할 수 있다. 또한, 토글 버튼이 클릭되었을 때 컨텐츠 패널의 표시 여부를 조절할 수 있게 된다.
import React from "react";
import { Counter } from "./Counter";
function Usage() {
const handleChangeCounter = (count) => {
console.log("count", count);
};
return (
<Counter onChange={handleChangeCounter}>
<Counter.Decrement icon="minus" />
<Counter.Label>Counter</Counter.Label>
<Counter.Count max={10} />
<Counter.Increment icon="plus" />
</Counter>
);
}
export { Usage };
Inversion of Control: 1/4
구현 복잡도: 1/4
React Bootstrap과 Reach UI 라이브러리에서 이 패턴을 사용한다.
Inversion of Control이란?
일반적으로 프로그램에서는 제어 흐름이 애플리케이션 코드에서 결정된다. 하지만 IoC를 적용하면 이러한 제어 흐름의 결정 권한이 프레임워크나 컨테이너에게 넘어가게 된다. 즉, 개발자가 프레임워크나 컨테이너에게 어떤 객체를 사용하고자 하는지만 알려주면, 프레임워크나 컨테이너가 객체의 생성과 의존성 주입을 담당하여 애플리케이션의 제어 흐름을 결정하게 된다.IoC는 애플리케이션의 유연성, 재사용성, 테스트 용이성을 향상시키는데 도움이 되며, 모듈 간의 결합도를 낮추고 유지 보수를 용이하게 하는데 기여한다.
Control Props 패턴은 React 컴포넌트에서 자식 컴포넌트에게 제어를 위임하는 디자인 패턴이다.
이 패턴을 사용하면 부모 컴포넌트가 자식 컴포넌트의 동작을 컨트롤하고 상태를 변경할 수 있다.
Control Props 패턴의 핵심 아이디어는 자식 컴포넌트가 동작하는 방식을 완전히 부모 컴포넌트에게 위임하는 것이다. 자식 컴포넌트는 부모로부터 함수(props)를 받아와서 이를 이벤트 핸들러 또는 콜백으로 사용한다. 이렇게 하면 자식 컴포넌트가 어떤 동작을 수행하거나 상태를 변경할 때 부모 컴포넌트가 제어할 수 있다.
이러한 패턴은 특히 양식(form) 컴포넌트나 모달 컴포넌트와 같이 부모 컴포넌트가 자식 컴포넌트를 제어해야 하는 상황에서 유용하게 사용된다. 부모 컴포넌트는 자식 컴포넌트의 상태를 추적하거나 자식 컴포넌트의 동작을 트리거하는데 사용될 수 있다.
Control Props 패턴은 컴포넌트 간 협력과 상호작용을 가능하게 하며, 제어 가능성과 재사용성을 제공한다. 그러나 구조가 복잡해지고 결합도가 증가할 수 있다는 단점도 있다.
import React, { useState } from "react";
import { Counter } from "./Counter";
function Usage() {
const [count, setCount] = useState(0);
const handleChangeCounter = (newCount) => {
setCount(newCount);
};
return (
<Counter value={count} onChange={handleChangeCounter}>
<Counter.Decrement icon={"minus"} />
<Counter.Label>Counter</Counter.Label>
<Counter.Count max={10} />
<Counter.Increment icon={"plus"} />
</Counter>
);
}
export { Usage };
Counter
동작에 직접 영향을 미칠 수 있다.지나친 제어: 너무 많은 제어를 부모 컴포넌트로부터 받는 경우, 자식 컴포넌트가 너무 의존적이 되어 코드의 재사용성이 떨어질 수 있다. 이 경우, 컴포넌트 간의 협력이 잘못 설계되었을 수 있으므로 주의해야 한다.
컴포넌트 간 결합도 증가: 컴포넌트 간의 결합도가 증가할 수 있다. 이는 컴포넌트의 재사용성과 유지보수성을 저하시킨다.
Inversion of Control: 2/4
구현 복잡도: 1/4
Material UI 라이브러리에서 해당 패턴을 사용한다.
Custom Hook 패턴은 특정 기능을 수행하는 로직을 함수로 묶어서 재사용할 수 있게 만드는 것을 의미한다.
함수 이름은 "use"로 시작해야 한다. 이렇게 함으로써 React는 해당 함수가 Custom Hook이라는 것을 인식하고, 특정 규칙에 따라 Hook을 사용할 수 있게 된다.
Custom Hook 내에서는 다른 Hook을 사용할 수 있다. 즉, 상태 관리를 위해 useState, useEffect와 같은 기본 Hook들을 사용할 수 있다.
import React from "react";
import { Counter } from "./Counter";
import { useCounter } from "./useCounter";
function Usage() {
const { count, handleIncrement, handleDecrement } = useCounter(0);
const MAX_COUNT = 10;
const handleClickIncrement = () => {
// custom logic
if(count < MAX_COUNT) {
handleIncrement();
}
};
return (
<>
<Counter value={count}>
<Counter.Decrement
icon={"minus"}
onClick={handleDecrement}
disabled={count === 0}
/>
<Counter.Label>Counter</Counter.Label>
<Counter.Count/>
<Counter.Increment
icon={"plus"}
onClick={handleClickIncrement}
disabled={count === MAX_COUNT}
/>
</Counter>
<button onClick={handleClickIncrement} disabled={count === MAX_COUNT}>
Custom increment btn 1
</button>
</>
);
}
export { Usage };
로직 재사용: 로직을 추상화하고, 다른 컴포넌트에서도 동일한 로직을 재사용할 수 있다.
코드 간소화: 컴포넌트 내부에서 로직을 묶어서 사용하므로, 컴포넌트 자체는 간결해지고 목적에 집중할 수 있다.
이름 충돌: Custom Hook을 여러 곳에서 사용하면 이름 충돌이 발생할 수 있으므로 함수 이름을 유의하여 지정해야 한다.
Hook 규칙: 일반적인 함수와 다르게 Hook 규칙을 따라야 하므로, 이를 준수하는 것이 중요하다.
Inversion of Control: 2/4
구현 복잡도: 2/4
React table과 React hook form 라이브러리에서 이 패턴을 사용한다.
Props Getters 패턴은 컴포넌트의 props를 조작하거나 확장하기 위해 사용된다.
이 패턴은 주로 고차 컴포넌트(Higher-Order Component, HOC)와 함께 사용되며, 컴포넌트 속성(props)를 조작하는데 유용하다. 엄청난 통제권을 주긴 하지만, 그만큼 컴포넌트를 이용하기 어렵게 만든다.
이런 복잡도를 감추기 위해 props getters의 목록을 제공한다. 이는 유저가 올바른 JSX요소에 접근할 수 있도록 의미있는 이름을 사용해야 한다.
부모 컴포넌트에서 자식 컴포넌트로 props를 전달한다.
고차 컴포넌트를 사용하여 자식 컴포넌트의 props를 가로채고 필요에 따라 조작한다.
가로챈 props와 함께 원래의 props를 자식 컴포넌트로 전달한다.
import React from "react";
import { Counter } from "./Counter";
import { useCounter } from "./useCounter";
const MAX_COUNT = 10;
function Usage() {
const {
count,
getCounterProps,
getIncrementProps,
getDecrementProps
} = useCounter({
initial: 0,
max: MAX_COUNT
});
const handleBtn1Clicked = () => {
console.log("btn 1 clicked");
};
return (
<>
<Counter {...getCounterProps()}>
<Counter.Decrement icon={"minus"} {...getDecrementProps()} />
<Counter.Label>Counter</Counter.Label>
<Counter.Count />
<Counter.Increment icon={"plus"} {...getIncrementProps()} />
</Counter>
<button {...getIncrementProps({ onClick: handleBtn1Clicked })}>
Custom increment btn 1
</button>
<button {...getIncrementProps({ disabled: count > MAX_COUNT - 2 })}>
Custom increment btn 2
</button>
</>
);
}
export { Usage };
가시성 부족: getter는 컴포넌트를 더 쉽게 통합할 수 있도록 추상화를 제공하지만, 동시에 더 불투명하고 "마법"같은 요소를 만들기도 한다. 개발자들은 노출된 getter props와 해당 내부 로직을 올바르게 오버라이드하기 위해 충분한 이해력을 갖추어야 한다.(이 부분에 TypeScript가 도움이 될 수 있다.)
복잡성: 컴포넌트의 구조가 더 복잡해질 수 있다. 여러 고차 컴포넌트가 사용되거나 여러 단계의 props 전달이 필요할 수 있다.
이해 어려움: 개발자들이 고차 컴포넌트와 Props Getters 패턴에 익숙하지 않은 경우 이해하기 어려울 수 있다.
Inversion of Control: 3/4
구현 복잡도: 3/4
React table과 Downshift 라이브러리에서 이 패턴을 사용한다.
State Recuder 패턴은 상태 관리를 효과적으로 구현하는 디자인 패턴 중 하나다.
IoC 측면에서 가장 진보된 패턴이다. 이 패턴은 유저에게 컴포넌트를 내부적으로 제어할 수 있는 더 발전된 방법을 제시한다.
이 패턴은 상태 변경 로직을 재사용 가능한 함수로 분리하여 컴포넌트 내부에서 관리하는 것보다 더 유연하고 관리하기 쉬운 방법을 제공한다.
일반적으로 React에서 상태는 useState 훅이나 클래스 컴포넌트의 state를 사용하여 관리된다. 하지만 상태 변경 로직이 컴포넌트 내부에 밀접하게 결합되어 있으면 해당 컴포넌트를 재사용하기 어렵고, 상태 변경 로직을 공유하거나 테스트하기도 어렵게 된다.
state Recuder 패턴은 이러한 문제를 해결하기 위해 사용된다. 이 패턴에서는 상태 변경 로직을 별도의 리듀서 함수로 분리하고, 해당 함수를 컴포넌트에 전달하여 상태를 관리한다. 이 리듀서 함수는 현재 상태와 액션을 인자로 받아 새로운 상태를 반환한다.
import React from "react";
import { Counter } from "./Counter";
import { useCounter } from "./useCounter";
const MAX_COUNT = 10;
function Usage() {
const reducer = (state, action) => {
switch (action.type) {
case "decrement":
return {
count: Math.max(0, state.count - 2) // The decrement delta was changed for 2 (Default is 1)
};
default:
return useCounter.reducer(state, action);
}
};
const { count, handleDecrement, handleIncrement } = useCounter(
{ initial: 0, max: 10 },
reducer
);
return (
<>
<Counter value={count}>
<Counter.Decrement icon={"minus"} onClick={handleDecrement} />
<Counter.Label>Counter</Counter.Label>
<Counter.Count />
<Counter.Increment icon={"plus"} onClick={handleIncrement} />
</Counter>
<button onClick={handleIncrement} disabled={count === MAX_COUNT}>
Custom increment btn 1
</button>
</>
);
}
export { Usage };
이 예시는 Custom hook 패턴에 State reducer 패턴을 적용했지만, Compound components 패턴에 적용하여 사용할 수도 있다.
구현 복잡성: 이 패턴은 개발자에게 가장 복잡한 패턴이다.
가시성 부족: 모든 리듀서의 동작은 변경될 수 있으므로 구성 요소의 내부 논리에 대한 충분한 이해가 필요하다.
Inversion of Control: 4/4
구현 복잡도: 4/4
Downshift 라이브러리가 이 패턴을 사용한다.
이 5가지 React 패턴을 통해 우리는 "Inversion of Control"이라는 개념을 활용하는 다양한 방법을 알아보았다.
이들은 유연하고 적응 가능한 컴포넌트를 만들기 위한 강력한 방법을 제공한다. 하지만 개발자들에게 더 많은 제어 권한을 주면 컴포넌트가 "plug and play"적인 마인드에서 멀어지게 된다. 따라서 적절한 패턴을 절적한 필요에 맞게 선택해야 한다.
plug and play란?
plug and play는 컴퓨터 용어로, 장치나 소프트웨어가 추가적인 설정 없이 자동으로 작동되는 기능을 의미한다. 예를 들어, 새로운 키보드를 컴퓨터에 연결하면 컴퓨터가 자동으로 키보드를 인식하고 사용할 수 있도록 설정해 주는 것이 plug and play다. plug and play 기능은 사용자에게 편의성과 간편함을 제공한다.
다음 그림은 모든 패턴을 "통합 복잡성(Integration complexity)"과 "제어 역전(Inversion of control)"에 따라 분류한다.
참고
유용한 리액트 패턴 5가지
5가지 고급 반응 패턴
[리액트 디자인 패턴] Compound Component Pattern (합성 컴포넌트 패턴) 알아보기
[React] 02. 리액트 디자인 패턴 (Control Props Pattern)