React 컴포넌트를 만들다 보면 기능을 확장하거나 변경해야 하는 상황이 자주 생깁니다.
예시부터 볼게요.
세팅 옵션 컴포넌트 (출처: github)
- [UI] 아래 디자인과 같은 컴포넌트를 만들어주세요. 제목과 내용은 항상 들어갑니다. 여러 줄의 카드를 세로로 출력할 거에요.
- [api] 요청은 put으로 보내주세요. 값이 변경되면 그냥 변경된 key와 boolean 값 쌍을 포함해서 전체 객체를 payload로 보내주시면 됩니다.

위와 같은 조건으로 세팅 옵션 컴포넌트를 만들었다고 생각해봅시다. 체크박스와 두 줄의 문단이 있는 컴포넌트입니다.
여러 줄의 카드를 출력할 수 있게 배열과 ul, li 요소로 만들었어요.
// 컴포넌트
const ListItem = (props: 어떤배열타입) => {
return (
<li>
<input type="checkbox" checked={props.isChecked} />
<div>
<label>{props.label}</label>
<p>{props.desc}</p>
</div>
</li>
);
};
// 컴포넌트를 사용하는 곳
<ul>
{어떤배열.map((props) => (
<ListItem key={props.id} {...props} />
))}
</ul>;
그럼 이제 어떤 배열의 요소가 2개라면 아래처럼 출력되겠죠.

그런데 이렇게 두 번째 문단의 오른쪽에 링크를 추가하는 경우가 있어요. 어렵지 않아요. 전달할 props에 link를 추가합니다.

const ListItem = (props: 어떤배열타입) => {
return (
<li>
<input type="checkbox" checked={props.isChecked} />
<div>
<label>{props.label}</label>
<div>
<p>{props.desc}</p>
{props.link && <a href={props.link}>Learn More</a>} // 👈 여기 추가!
</div>
</div>
</li>
);
}
(new!) [UI] 임시 기능을 표시하는 UI가 추가되었습니다!
새로 추가된 preview 타입의 옵션에는 라벨을 붙여야 하네요. 어떤배열에 isPreview 키를 추가하거나, 혹은 확장성을 고려해서 labels이라는 배열을 추가해 라벨을 출력할 수 있습니다. labels는 타입으로 정의하면 안정성이 있을거에요. 튜플도 좋구요.
type LabelType = "Preview"
그런데 기존 Learn More로 통일되어 있던 링크 텍스트에 변화가 생겼습니다. 그리고 2개예요. 받아들입시다.

const ListItem = (props: 어떤배열타입) => {
return (
<li>
<input type="checkbox" checked={props.isChecked} />
<div>
<div>
<label>{props.title}</label>
{props.labels && props.labels.map((label) => <label>{label}</label>)} // 👈 이 줄을 추가!
</div>
<div>
<p>{props.desc}</p>
{props.link && <a href={props.link}>Learn More</a>} // ❌ 링크는 이대로는 안됩니다!
</div>
</div>
</li>
);
};
여기서 약간 곤란해집니다. props를 그대로 받아서 출력하는 지금 형태라면 링크를 구현하기 위해 텍스트에서 이를 추출할 수 있는 형태로 만들거나, 다른 형태로 보내야해요. 링크가 2개 이상이고, 각 링크마다 고유한 텍스트(label)와 링크가 필요해요.
props를 그대로 출력하는 지금 구조를 유지하려면 markdown 구문처럼 링크를 파싱할 수 있도록 작성하거나, desc 부분을 배열로 변경해서 desc: [{text: "", link: ""}, {text: ""}] 각 문단 또는 링크 포함 설명을 배열로 관리하게 만들어야 합니다.
어찌되었든 문자열 데이터를 변경해야 하겠네요.
(new!) [UI] 더 눈에 잘 띄는 링크 버튼이 필요해요.
우리의 세팅 옵션 컴포넌트 내부에 어딘가 토스트 메시지처럼 생겼지만 링크 버튼이 있는 무언가의 뭔가가 추가되었어요. 그럼 이제.. 이 박스를 출력하기 위한 데이터를 props에 추가해볼게요.

const ListItem = (props: 어떤배열타입) => {
return (
<li>
<input type="checkbox" checked={props.isChecked} />
<div>
<div>
<label>{props.title}</label>
{props.labels && props.labels.map((label) => <label>{label}</label>)}
</div>
<div>
<p>{parseDescWithLinks(props.desc)}</p>
</div>
{props.navBanner && ( // 👈 구현 완료!
<div>
<div>
<p>{props.navBanner.title}</p>
<span>{props.navBanner.desc}</span>
</div>
<a href={props.navBanner.link}>{props.navBanner.buttonText}</a>
</div>
)}
</div>
</li>
);
};
navBanner를 출력하는 코드를 ListItem 컴포넌트 내부에서 작성하지 말고, props에 컴포넌트 째로 value로 넣어서 props.navBanner && props.navBanner와 같이 출력하면 ListItem에서 보이는 jsx 코드의 길이는 줄어들거에요. 그런데 의문이 듭니다. 첫 번째 경우로 돌아가봅시다.

바로 위의 컴포넌트는 navBanner를 출력하는 코드를 필요로 할까요? labels은요?
앞으로도 변경 사항이 있을 때마다 컴포넌트 내에 계속 새로운 분기점을 추가하고, props의 데이터 형태를 변경해야할까요? 만약 아까처럼 desc 데이터의 형태를 문자열에서 배열로 바꿔야 하는 경우라면, 기존에 desc에 의존하는 컴포넌트들도 수정해야할 수 있습니다.
지금은 괜찮지만 3달 후에 우리가(혹은 코드를 보는 다른 사람이) 사이드 이펙트 없이 빠르게 리팩토링을 마칠 것이고 UI와 로직에 전혀 오류가 없다고 장담할 수 있나요? 물론 테스트 코드 없이 리팩토링을 하는 것은 눈 가리고 떡 써는 일이라고 할 수 있겠죠. 여건이 따라준다면 지금부터 테스트코드를 작성할 수 있어요. 하지만...
(new!) [UI] 이번엔 내부에 드롭다운 추가가 필요해요.
(new!) [api] 드롭다운 선택값은 기존 객체에 select라는 키에 추가해주세요.
(new!) [UI] 이 컴포넌트는 눈에 띄어야 하니까 border 색상이 빨간색입니다.
(new!) ...
(new!) ...

컴포넌트를 작성하는 방법을 알아보고, 좀 더 경우에 맞게 작성한다면 매번 컴포넌트 내부를 리팩토링 하지 않고 좀 더 유연하게 확장하면서 유지보수성과 재사용성이 크게 향상시킬 수 있습니다.
이제 본론입니다.
React, TypeScript 환경에서 컴포넌트를 확장하는 5가지 방법을 SOLID 원칙을 기반으로 살펴보며, 특히 OCP가 뭔지 구체적인 예시와 함께 알아보겠습니다.
SOLID를 쉽게 알아볼 수 있는 레퍼런스
https://codegym.cc/ko/groups/posts/ko.232.solid-jaba-keullaeseu-seolgyeui-daseos-gaji-gibon-wonchig
SOLID 원칙이란 뭘까요? 자바를 공부하신 분들은 다들 아실 것 같아요🤔
SOLID는 객체 지향 설계에서 유지보수성과 확장성을 높이기 위한 5가지 핵심 원칙의 약자입니다.
2000년대 초반에 Robert Martin(Uncle Bob)에 의해 정리되었으며, 개발자가 더 깔끔하고 유연한 코드를 작성할 수 있도록 하는 원칙입니다.
컴포넌트를 유연하게 확장하는 기본적인 방법
- OCP: props를 통해 새로운 형태로 확장할 때, 컴포넌트 내부 코드는 수정하지 않음
// Button.tsx
type ButtonProps = {
variant?: 'default' | 'primary' | 'danger';
size?: 'sm' | 'md' | 'lg';
children: React.ReactNode;
};
export const Button = ({ variant = 'default', size = 'md', children }: ButtonProps) => {
return <button className={`btn ${variant} ${size}`}>{children}</button>;
};
// 사용
<Button variant="primary" size="lg">확인</Button>
<Button variant="default" size="md">확인</Button>
컴포넌트 안에 자식 콘텐츠를 주입하여 다양한 표현 가능
- SRP: 레이아웃을 구성하는 컴포넌트와 콘텐츠는 각자 책임을 가짐
// Modal.tsx
type ModalProps = {
children: React.ReactNode;
};
export const Modal = ({ children }: ModalProps) => (
<div className="modal">{children}</div>
);
// 사용
<Modal>
<h2>공지사항</h2>
<p>서비스 점검 예정입니다.</p>
</Modal>
상태 관리나 비즈니스 로직을 별도로 추출하여 재사용성과 분리된 책임 구현
- SRP: UI와 상태/로직을 분리
- DIP: 컴포넌트는 추상화된 로직 훅을 사용, 구현 로직에는 의존하지 않음
// useToggle.ts
export const useToggle = (initial = false): [boolean, () => void] => {
const [value, setValue] = React.useState(initial);
const toggle = () => setValue((v) => !v);
return [value, toggle];
};
// 사용
const ToggleButton = () => {
const [isOn, toggle] = useToggle();
return <button onClick={toggle}>{isOn ? '켜짐' : '꺼짐'}</button>;
};
컴포넌트를 조합하여 새로운 기능을 만들되, 각 구성 요소는 자신의 책임에만 집중
- SRP: Card는 레이아웃만, 내부 콘텐츠는 외부에서 제어
- OCP: Card 자체는 수정하지 않고 새로운 형태로도 조합 가능.
// Card.tsx
type CardProps = {
children: React.ReactNode;
};
export const Card = ({ children }: CardProps) => (
<div className="card">{children}</div>
);
export const CardHeader = ({ children }: CardProps) => (
<div className="card-header font-bold text-lg mb-2">{children}</div>
);
export const CardBody = ({ children }: CardProps) => (
<div className="card-body mb-2">{children}</div>
);
// 사용
<Card>
<CardHeader>공지사항</CardHeader>
<CardBody>이번 주 금요일 오후 6시에 서버 점검이 있습니다.</CardBody>
<button className="btn btn-primary">확인</button>
</Card>
기존 컴포넌트를 감싸서 로깅, 인증 등 부가 기능 추가
- OCP: 기존의 컴포넌트는 변경 없이 기능 추가
- DIP: 고수준 컴포넌트가 HOC를 통해 추상화된 기능에 의존하게 되어, 구체적인 구현(로깅 등)과 분리됨
// withLogging.tsx
function withLogging<P>(Component: React.ComponentType<P>) {
return (props: P) => {
console.log('렌더링 중:', Component.name);
return <Component {...props} />;
};
}
// 사용
const LoggedButton = withLogging(Button);
<LoggedButton variant="danger">삭제</LoggedButton>
함수형 prop을 넘겨서 내부 상태나 로직을 외부에 노출하고 외부에서 뷰를 결정
(HOC와 달리, Render Props는 props로 함수를 넘겨 내부에서 호출하여 콘텐츠를 결정)
- DIP: 컴포넌트가 직접 렌더링하지 않고, 렌더 함수에 위임
- ISP: 필요한 정보만 외부에 제공하여 사용자가 필요한 만큼만 구현
// MouseTracker.tsx
type MousePosition = { x: number; y: number };
type MouseTrackerProps = {
render: (pos: MousePosition) => React.ReactNode;
};
export const MouseTracker = ({ render }: MouseTrackerProps) => {
const [pos, setPos] = React.useState<MousePosition>({ x: 0, y: 0 });
return (
<div onMouseMove={(e) => setPos({ x: e.clientX, y: e.clientY })}>
{render(pos)}
</div>
);
};
// 사용
<MouseTracker render={({ x, y }) => <h1>{x}px, {y}px</h1>} />
Header / Body / Footer와 같은 슬롯 구조로 확장 가능하게 설계
(Vue의<slot>에서 영감을 받은 형태)
- SRP: Card는 UI 레이아웃에만 집중
- OCP: 구조를 유지하면서 유연한 내용 변경 가능
// Card.tsx
type CardProps = {
Header: React.ReactNode;
Body: React.ReactNode;
Footer?: React.ReactNode;
};
export const Card = ({ Header, Body, Footer }: CardProps) => (
<div className="card">
<div className="card-header">{Header}</div>
<div className="card-body">{Body}</div>
{Footer && <div className="card-footer">{Footer}</div>}
</div>
);
// 사용
<Card
Header={<h1>제목</h1>}
Body={<p>본문</p>}
Footer={<button>닫기</button>}
/>
| 확장 방식 | 설명 | 적용 원칙 |
|---|---|---|
| Props 확장 | 기본적인 기능 확장 방법 | OCP |
| Children | 부모 컴포넌트가 자식 내용을 커스터마이징 | SRP |
| Custom Hook | 상태/로직을 분리하고 재사용 가능 | SRP, DIP |
| Composition | 조합으로 책임 분리 및 유연성 | SRP, OCP |
| HOC | 감싸서 기능 추가 | OCP, DIP |
| Render Props | 함수형 prop으로 제어 | DIP, ISP (또는 OCP) |
| Slot-like Props | 영역을 나눠 콘텐츠 주입 | SRP, OCP |
Props, Children, Composition, HOC, Render Props, Slot 등 다양한 확장 방식은 각각의 상황에 따라 선택할 수 있습니다! 유지보수가 쉬운 컴포넌트 아키텍처를 만들기 위한 실질적인 도구가 됩니다.
고민의 해답이 SOLID에서 출발할 수도 있다는 걸 다시 한 번 느꼈습니다.
결국, 중요한 건 ~어떤 상황에서 어떤 패턴을 쓰면 좋은 설계로 이어지는가~를 이해하는 것입니다.