React 개발에 SOLID 원칙 적용하기 - 개방-폐쇄 원칙(OCP)
이전 글에서 SOLID 원칙을 간단하게 소개하고, 그 중 S에 해당하는 SRP를 충족하는 React 개발에 대해 다루었습니다. 이번에는 O에 해당하는 개방-폐쇄 원칙(Open-Closed Principle, OCP)에 대한 소개와 함께, React 개발에 어떻게 적용될 수 있는지 구체적인 예시를 통해 살펴보겠습니다.
개방-폐쇄 원칙의 철학
개방-폐쇄 원칙은 "소프트웨어 개체(클래스, 모듈, 함수 등)는 확장에 대해서는 열려 있어야 하고(Open for Extension), 수정에 대해서는 닫혀 있어야 한다(Closed for Modification)"는 프로그래밍 원칙입니다.
이 원칙이 어렵게 느껴질 수 있지만, 핵심은 다음과 같습니다. 새로운 기능을 추가할 때, 기존 시스템의 코드를 직접 수정하는 대신, 새로운 코드를 추가하는 방식으로 확장이 가능해야 한다는 뜻입니다. 즉, 기존 코드는 안정적으로 유지하면서 기능을 덧붙일 수 있도록 설계해야 합니다.
그럼, 실생활에서 이 원칙을 어떻게 찾아볼 수 있을까요?
실생활에서의 개방-폐쇄 원칙: 멀티탭 이야기

우리가 매일 사용하는 멀티탭을 떠올려봅시다. 멀티탭의 핵심 기능은 전기를 여러 포트로 안전하게 분배하는 것입니다. 우리가 새로운 가전제품(선풍기, 에어컨, 램프 등)을 구매했을 때, 멀티탭 자체를 뜯어내서 가전제품에 맞게 회로를 수정하거나 복잡한 설정 작업을 해주지 않습니다. 그저 플러그를 꽂기만 하면 됩니다.
멀티탭의 기능 자체는 불변하며(Closed), 어떤 가전제품을 연결해서 사용할지는 언제든 확장할 수 있는(Open) 구조입니다.
만약 새로운 가전제품이 나올 때마다 멀티탭을 물리적으로 개조해야 한다면 얼마나 불편할까요? OCP는 이러한 불편함을 해소하고 유연한 확장을 가능하게 하는 설계 지침입니다.
React에서의 개방-폐쇄 원칙
사실 React는 OCP를 근본적으로 잘 지원하는 라이브러리입니다. 특히 children prop을 통한 컴포넌트 합성(Composition) 방식은 OCP를 자연스럽게 따릅니다.
위에 실생활 예시로 들었던 멀티탭을 React 코드로 표현하면 다음과 같습니다.
<PowerStrip>
<Fan />
<AirConditioner />
<Lamp />
</PowerStrip>
PowerStrip 컴포넌트 내부에 전원 분배라는 핵심 기능이 응집되어 있고, children으로 슬롯을 열어두어 다양한 가전제품(컴포넌트)들을 확장하는 방식이죠.
조금 더 구체적인 예시를 통해 React에서 OCP가 어떻게 활용되는지 살펴보겠습니다.
모달 컴포넌트를 만들어보자
디자인 시스템을 구축하면서 Modal 컴포넌트를 만들게 되었다고 가정해봅시다. 모달은 일반적으로 헤더, 바디, 푸터 세 영역으로 구성됩니다.
초기 구현은 다음과 같을 수 있습니다.
function Modal({ isOpen, onClose, title, content, onConfirm }) {
if (!isOpen) return null;
return (
<div className="modal">
<div className="modal-header">
<h2>{title}</h2>
</div>
<div className="modal-body">
<p>{content}</p>
</div>
<div className="modal-footer">
<button onClick={onClose}>취소</button>
<button onClick={onConfirm}>확인</button>
</div>
</div>
);
}
이 코드는 깔끔해 보이지만, 이제부터 요구사항이 하나씩 추가될 때 어떤 문제가 발생하는지 살펴보겠습니다.
요구사항이 추가될 때마다 prop이 늘어난다
"헤더에 아이콘을 넣고 싶어요." → icon prop 추가
"바디에 이미지도 들어가야 해요." → image prop 추가
"푸터 버튼 텍스트를 바꾸고 싶어요." → confirmText, cancelText prop 추가
function Modal({
isOpen,
onClose,
title,
icon, // 헤더에 아이콘
subtitle, // 헤더에 부제목
content,
image, // 바디에 이미지
description, // 바디에 추가 설명
confirmText, // 푸터 확인 버튼 텍스트
cancelText, // 푸터 취소 버튼 텍스트
onConfirm,
// ...
}) {
if (!isOpen) return null;
return (
<div className="modal">
<div className="modal-header">
{icon && <span className="modal-icon">{icon}</span>}
<h2>{title}</h2>
{subtitle && <p className="modal-subtitle">{subtitle}</p>}
</div>
<div className="modal-body">
{image && <img src={image} alt="" />}
<p>{content}</p>
{description && <p className="description">{description}</p>}
</div>
<div className="modal-footer">
<button onClick={onClose}>{cancelText ?? "취소"}</button>
<button onClick={onConfirm}>{confirmText ?? "확인"}</button>
</div>
</div>
);
}
요구사항은 계속 이어집니다. "헤더에 닫기 버튼이요", "바디에 리스트 형태로요", "푸터에 버튼 3개요", "아 푸터 없는 버전도요"...
문제가 보이시나요? 각 영역에 어떤 콘텐츠가 들어올지 예측할 수 없는 상황에서, 새로운 콘텐츠가 추가될 때마다 Modal 컴포넌트 내부를 수정해야 합니다. prop은 끝없이 늘어나고, 조건문은 점점 복잡해지며, 컴포넌트는 비대해집니다. 이는 전형적인 OCP 위반 사례입니다.
이렇게 되면 새로운 기능을 추가할 때마다 기존 코드를 수정해야 하므로, 버그 발생 가능성이 높아지고 유지보수가 매우 어려워집니다.
확장 가능성을 열어두자: 합성 컴포넌트 패턴
헤더, 바디, 푸터에 어떤 콘텐츠가 들어올지 모른다면, 처음부터 그 영역들을 확장 가능하도록 열어두면 됩니다. 여기에 합성 컴포넌트(Compound Components) 패턴을 적용해볼까요?
function Modal({ isOpen, children, onClose }) {
if (!isOpen) return null;
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()}>
{children}
</div>
</div>
);
}
Modal.Header = ({ children }) => <div className="modal-header">{children}</div>;
Modal.Body = ({ children }) => <div className="modal-body">{children}</div>;
Modal.Footer = ({ children }) => <div className="modal-footer">{children}</div>;
이제 Modal을 사용하는 쪽에서 각 영역을 자유롭게 구성할 수 있습니다.
// 기본 확인 모달
<Modal isOpen={isOpenConfirm} onClose={closeConfirm}>
<Modal.Header>
<h2>저장하시겠습니까?</h2>
</Modal.Header>
<Modal.Body>
<p>변경사항이 저장됩니다.</p>
</Modal.Body>
<Modal.Footer>
<Button onClick={closeConfirm}>취소</Button>
<Button onClick={saveChanges}>저장</Button>
</Modal.Footer>
</Modal>
// 아이콘 + 부제목이 있는 경고 모달
<Modal isOpen={isOpenWarning} onClose={closeWarning}>
<Modal.Header>
<WarningIcon />
<h2>정말 삭제하시겠습니까?</h2>
<p className="subtitle">이 작업은 되돌릴 수 없습니다.</p>
</Modal.Header>
<Modal.Body>
<p>삭제되는 항목: {itemName}</p>
</Modal.Body>
<Modal.Footer>
<Button onClick={closeWarning}>취소</Button>
<Button variant="danger" onClick={handleDelete}>삭제</Button>
</Modal.Footer>
</Modal>
// 이미지와 리스트가 있는 정보 모달
<Modal isOpen={isOpenInfo} onClose={closeInfo}>
<Modal.Header>
<h2>새로운 기능을 소개합니다!</h2>
</Modal.Header>
<Modal.Body>
<img src="/feature-preview.png" alt="새 기능 미리보기" />
<ul>
<li>기능 1: 더욱 빨라진 속도</li>
<li>기능 2: 직관적인 UI/UX</li>
<li>기능 3: 맞춤형 설정 제공</li>
</ul>
</Modal.Body>
<Modal.Footer>
<Button onClick={closeInfo}>시작하기</Button>
</Modal.Footer>
</Modal>
// 푸터가 없는 알림 모달
<Modal isOpen={isOpenAlert} onClose={closeAlert}>
<Modal.Header>
<SuccessIcon />
<h2>완료되었습니다!</h2>
</Modal.Header>
<Modal.Body>
<p>3초 후 자동으로 닫힙니다.</p>
</Modal.Body>
</Modal>
이제 어떤 요구사항이 들어와도 Modal 컴포넌트 자체는 수정할 필요가 없습니다. 헤더에 뭘 넣든, 바디에 뭘 넣든, 푸터를 없애든, 사용하는 쪽에서 Modal.Header, Modal.Body, Modal.Footer를 자유롭게 조합하면 됩니다. 기존 코드를 수정하지 않고 새로운 방식으로 확장하는, OCP를 잘 지킨 예시입니다.
합성 컴포넌트 대신 slot prop 방식으로도 유사한 효과를 낼 수 있습니다.
<Modal
isOpen={isOpen}
header={
<>
<WarningIcon />
<h2>경고</h2>
</>
}
body={<p>정말 진행하시겠습니까?</p>}
footer={<Button onClick={close}>확인</Button>}
/>
그렇다면 모든 것을 열어두어야 할까?
여기서 중요한 점이 있습니다. 우리는 헤더, 바디, 푸터의 콘텐츠에 대해서는 확장을 열어두었지만, Modal의 구조적 기능 자체는 여전히 닫혀 있습니다.
예를 들어, "모달 배경을 클릭하면 닫히는 기능"을 추가해야 한다면 어떨까요? 이 시점에는 Modal 컴포넌트 내부를 수정해야 합니다.
function Modal({ isOpen, onClose, children }) {
if (!isOpen) return null;
return (
<>
<div className="modal-backdrop" onClick={onClose} />
<div className="modal" onClick={(e) => e.stopPropagation()}>
{children}
</div>
</>
);
}
이것은 OCP 위반일까요? 아닙니다. 의도적인 설계입니다.
우리는 "헤더, 바디, 푸터 영역에 어떤 콘텐츠가 들어올지 모른다"는 점을 인지했기에, 이 콘텐츠 영역에 대한 확장성은 children을 통해 열어두었습니다. 반면, "모달이 어떤 방식으로 열리고 닫히며, 어떤 공통적인 배경 처리를 할지"는 디자인 시스템 차원에서 통제하고 싶었기에 Modal 컴포넌트 자체의 핵심 로직은 닫아둔 것입니다. 새로운 핵심 기능이 필요하다면, 그때는 의도적으로 컴포넌트를 수정합니다.
OCP의 핵심: 의도적인 개방과 폐쇄
OCP는 "절대 수정하지 마라"가 아닙니다. 확장을 의도한 곳은 열어두고(Open), 그렇지 않은 곳은 닫아두라(Closed)는 원칙입니다.
| 구분 | 확장 가능성 (Open) | 관리 방식 (Closed) | 설계 전략 |
|---|---|---|---|
| 내부 컨텐츠 영역 | 매우 높음 (예측 불가) | 외부 주입 방식 (Consumer) | children 또는 Slot prop 활용 |
| 모달의 핵심 기능 | 낮음 (일관성 및 통제 필요) | 내부 캡슐화 (Provider) | 내부 상태, 이벤트 처리, 공통 UI 로직 관리 |
모든 것을 열어두면 컴포넌트는 너무 유연해져서 오히려 사용하기 어려워지고, 예상치 못한 동작을 유발할 수 있습니다. 반대로 모든 것을 닫아두면 매번 수정해야 해서 유지보수가 힘들어집니다.
어디를 열고 어디를 닫을지 의도적으로 결정하는 것, 그리고 그 결정에 따라 적절한 패턴(합성 컴포넌트, children prop 등)을 사용하는 것이 OCP를 잘 지키는 방법입니다.
이러한 의도적인 설계는 수정으로 인한 사이드 이펙트(Side Effect)를 최소화합니다. 기존 코드를 안정적으로 유지하기 때문에, 새로운 기능을 추가할 때 발생할 수 있는 잠재적인 버그로부터 자유로울 수 있습니다.
마치며
React에서의 OCP는 결국 children과 합성 컴포넌트 패턴을 얼마나 적절하게 활용하느냐가 핵심입니다. 컴포넌트 내부에 모든 케이스를 하드코딩하는 대신, 확장 가능한 슬롯을 열어두는 것이죠.
이렇게 설계된 컴포넌트는 새로운 요구사항이 생겨도 기존 코드를 건드리지 않고 확장할 수 있습니다. 버그 발생 가능성도 줄고, 코드 리뷰도 수월해지며, 무엇보다 컴포넌트가 더 오래 살아남아 견고한 애플리케이션의 기반이 될 수 있습니다.
결국 좋은 설계는 원칙을 맹목적으로 따르는 것이 아니라, 원칙을 '이해한 뒤 선택적으로 적용하는 것'에서 시작됩니다.