리액트 컴포넌트 설계 패턴을 이해하는 방법
오늘은 리액트 컴포넌트 설계에 대한 이야기를 해보려고 합니다. 개발자의 본질적인 역할은 ‘코드로 문제를 해결하는 것’입니다. 리액트 생태계가 성숙해짐에 따라 우리는 단순히 화면을 그리는 것을 넘어, 어떻게 하면 더 유지보수하기 쉽고 재사용 가능한 구조를 만들 것인가라는 문제에 직면하게 되었습니다.
동일한 요구사항이라도 개발자마다 컴포넌트를 작성하는 스타일은 천차만별입니다. 본인이 작성한 코드의 장점을 설명할 수는 있어도, 그것이 어떤 정형화된 패턴인지, 어떤 모델에서 파생되었는지 명확히 인지하지 못하는 경우가 많습니다. 패턴의 언어를 이해한다는 것은 동료 개발자와의 커뮤니케이션 비용을 줄이고, 더 견고한 설계를 가능하게 합니다.
그런 관점에서 오늘은 리액트의 유명한 컴포넌트 설계 패턴들을 톺아보고, 각 패턴이 어떤 문제를 해결하는지 알아보겠습니다.
비결정론적 UI 문제
지금까지의 프론트엔드 개발은 결정론적인 UI를 구현하는 데 집중해왔습니다. 하지만 요구사항이 복잡해질수록 UI는 비결정론적인 특성을 띠게 됩니다. 사용자 인터랙션에 따라 화면의 상태가 복잡하게 얽히고, 비즈니스 로직과 UI 표현이 뒤섞이면서 코드의 예측 가능성이 떨어지게 됩니다.
지금까지의 컴포넌트 설계는 다음과 같은 비결정론적 문제들로 인해 어려움을 겪어왔습니다.
- 모호성: 데이터 흐름과 UI 표현이 명확히 구분되지 않음
- 불확실성: 컴포넌트의 재사용 범위가 상황에 따라 달라짐
- 복잡성: Props Drilling 등으로 인해 변수들이 복잡하게 얽힘
이러한 문제를 해결하기 위해 리액트 생태계에서는 ‘관심사의 분리’와 ‘추상화’를 기반으로 한 다양한 설계 패턴들이 등장하게 되었습니다.
컴포넌트 설계의 진화
리액트 컴포넌트는 초기 모델부터 지금까지 꾸준히 발전해왔습니다. 이를 세 가지 단계로 분류해볼 수 있습니다.
- 초기 단계 (Monolithic): 하나의 컴포넌트 내에 데이터 호출, 비즈니스 로직, UI 표현이 모두 포함된 형태입니다.
- 분리 단계 (Separation): 비즈니스 로직(Container)과 UI 표현(Presentational)을 분리하여 관리하기 시작했습니다.
- 유연성 단계 (Abstraction): Render Props나 Compound Components와 같이 제어권을 사용자에게 넘겨주어 재사용성을 극대화하는 단계입니다.
중요한 점은 어떤 패턴이 절대적으로 우월한 것이 아니라, 상황에 맞게 상호 보완적으로 사용해야 한다는 점입니다. 이제 구체적인 패턴들을 하나씩 살펴보겠습니다.
1. 관심사의 분리: Container & Presentational
리덕스나 모벡스를 사용하는 앱에서 흔히 볼 수 있는 패턴입니다. 앱은 여러 컴포넌트가 중첩된 구조인데, 이때 비즈니스 로직과 통신하는 컴포넌트와 UI를 표현하는 컴포넌트를 엄격히 분리합니다.
- Container: 앱의 핵심 로직과 통신하며 UI 컴포넌트에게 데이터를 전달합니다.
- Presentational: 전달받은 데이터를 화면에 그리는 역할만 수행합니다. 외부 라이브러리 사용을 최소화하여 재사용성을 극대화합니다.
2. 제어권의 위임: Render Props
컴포넌트의 render props에 함수를 전달하여, 무엇을 어떻게 렌더링할지를 외부에서 결정하도록 만드는 패턴입니다.
예를 들어, 마우스의 위치를 추적하는 로직을 가진 MouseTracker가 있다면, 위치 정보만 제공하고 실제 렌더링은 사용하는 측에 맡기는 방식입니다.
// MouseTracker.tsx 구현부
const MouseTracker = ({ render }) => {
const [pointer, setPointer] = useState({ x: 0, y: 0 });
const handleMouseMove = (event) => {
setPointer({ x: event.clientX, y: event.clientY });
};
return <div onMouseMove={handleMouseMove}>{render(pointer)}</div>;
};
// 사용부
<MouseTracker render={props => <PuppyPointer {...props} />} />
이 패턴은 로직은 공유하되 UI는 상황마다 다르게 가져가야 할 때 유용합니다. 다만 중첩이 심해지면 Prop Drilling이 발생할 수 있으므로 주의가 필요합니다.
3. 암묵적 상태 공유: Compound Components
서로 의존 관계가 있는 부모-자식 컴포넌트들 사이에서 상태 관리를 용이하게 하는 패턴입니다. 마치 HTML의 <select>와 <option> 태그처럼, 사용자는 내부 구현을 몰라도 암묵적인 상태 공유를 통해 컴포넌트를 조합할 수 있습니다.
주로 React.Context를 활용해 구현하며, 탭(Tab) 시스템이 대표적인 예시입니다.
export const Tabs = ({ children }) => {
const [tabIndex, setTabIndex] = useState(0);
const value = { tabIndex, setTabIndex };
return (
<TabContext.Provider value={value}>
<div className="tabs-container">{children}</div>
</TabContext.Provider>
);
};
// 내부적으로 Context를 구독하는 하위 컴포넌트들
export const TabBar = ({ idx }) => {
const { setTabIndex } = useTabContext();
return <div onClick={() => setTabIndex(idx)}>{idx + 1}번 탭</div>;
};
이 패턴을 사용하면 인터페이스가 매우 직관적이게 되며, 컴포넌트의 계층 구조가 복잡해지더라도 유연하게 대처할 수 있습니다.
4. 복잡성 관리: Props Collection
Render Props 패턴의 변형으로, 자식 컴포넌트에 전달해야 할 많은 Props를 하나의 그룹으로 묶어 전달하는 패턴입니다. 웹 접근성을 위한 속성(aria-attributes)이나 반복되는 이벤트 핸들러를 관리할 때 효과적입니다.
const Toggle = ({ children }) => {
const [isOpen, setOpen] = useState(false);
// 공통적으로 적용될 속성들을 그룹화
const togglerProps = {
"aria-expanded": isOpen,
onClick: () => setOpen(!isOpen)
};
return children({ isOpen, togglerProps });
};
// 사용 시에는 Spread Operator로 간결하게 적용
<Toggle>
{({ isOpen, togglerProps }) => (
<>
<button {...togglerProps}>토글 버튼</button>
{isOpen && <div>내용이 열렸습니다.</div>}
</>
)}
</Toggle>
사용자는 내부적인 복잡한 속성들을 일일이 알 필요 없이, 설계자가 준비해둔 props bundle을 그대로 주입하기만 하면 됩니다.
마치며
이번 글에서는 리액트에서 컴포넌트의 재사용성과 유지보수성을 높이기 위한 다양한 설계 패턴들을 알아보았습니다.
최신 기술의 변화 속도는 매우 빠릅니다. 리액트 18이 나오고 새로운 훅들이 등장하는 환경 속에서 기술을 따라가는 것도 중요하지만, 우리가 작성하는 코드의 기초가 되는 ‘패턴’을 곱씹어보는 시간 역시 큰 가치가 있습니다.
디자인 패턴은 정답이 아닙니다. 하지만 패턴을 이해하고 활용하는 과정에서 우리는 더 나은 형태의 아키텍처를 고민하게 되고, 이는 결국 장기적인 생산성 향상으로 이어집니다. 여러분도 현재 프로젝트의 성격에 맞는 최적의 패턴을 찾아 적용해보는 시간을 가져보시길 바랍니다.
Reference:
- React 공식 문서: Render Props
- Kent C. Dodds: Advanced React Component Patterns