원문 : Open-Closed Principle in React: Building Extensible Components
소개
의존성 역전(Dependency Inversion), 인터페이스 분리(Interface Segregation), 리스코프 치환(Liskov Substitution) 원칙에 대해 살펴본 후, 이번에는 현대 리액트 애플리케이션의 관점에서 개방-폐쇄 원칙(Open-Closed Principle, OCP)에 대해 다뤄보겠습니다.
다시 한번, 좋은 소프트웨어 아키텍처의 중요성을 일깨워준 Uncle Bob의 명저 클린 아키텍처에 찬사를 보냅니다! 이 시리즈는 주로 그 책에서 영감을 받았습니다.
개방-폐쇄 원칙은 소프트웨어 구성 요소가 확장에는 열려 있고, 변경에는 닫혀 있어야 한다고 말합니다.
리액트 관점에서 보면, 컴포넌트는 기존 코드를 변경하지 않고도 쉽게 확장할 수 있어야 합니다. 실제로 어떻게 적용되는지 함께 살펴보겠습니다.
폐쇄적인 컴포넌트의 문제점
아래는 흔히 볼 수 있는 안티패턴입니다.
// 이렇게 하지 마세요
const Button = ({ label, onClick, variant }: ButtonProps) => {
let className = "button";
// 각 변형(variant)마다 직접 수정
if (variant === "primary") {
className += " button-primary";
} else if (variant === "secondary") {
className += " button-secondary";
} else if (variant === "danger") {
className += " button-danger";
}
return (
<button className={className} onClick={onClick}>
{label}
</button>
);
};
이 방식은 개방-폐쇄 원칙을 위반합니다. 그 이유는 다음과 같습니다.
- 새로운 variant를 추가할 때마다 컴포넌트를 수정해야 합니다.
- 컴포넌트가 모든 variant에 대해 알고 있어야 합니다.
- variant가 추가될수록 테스트가 점점 더 복잡해집니다.
개방적인 컴포넌트 만들기
개방-폐쇄 원칙을 따르도록 리팩터링해봅시다.
type ButtonBaseProps = {
label: string;
onClick: () => void;
className?: string;
children?: React.ReactNode;
};
const ButtonBase = ({ label, onClick, className = "", children }: ButtonBaseProps) => (
<button className={`button ${className}`.trim()} onClick={onClick}>
{children || label}
</button>
);
// 변형된 컴포넌트가 기본 컴포넌트를 확장합니다
const PrimaryButton = (props: ButtonBaseProps) => <ButtonBase {...props} className="button-primary" />;
const SecondaryButton = (props: ButtonBaseProps) => <ButtonBase {...props} className="button-secondary" />;
const DangerButton = (props: ButtonBaseProps) => <ButtonBase {...props} className="button-danger" />;
이제 기존 코드를 변경하지 않고도 새로운 변형을 쉽게 추가할 수 있습니다.
// 기존 컴포넌트에 손대지 않고 새로운 변형 추가하기
const OutlineButton = (props: ButtonBaseProps) => <ButtonBase {...props} className="button-outline" />;
컴포넌트 합성 패턴
합성(Composition)을 활용한 조금 더 복잡한 예제를 살펴보겠습니다.
type CardProps = {
title: string;
children: React.ReactNode;
renderHeader?: (title: string) => React.ReactNode;
renderFooter?: () => React.ReactNode;
className?: string;
};
const Card = ({ title, children, renderHeader, renderFooter, className = "" }: CardProps) => (
<div className={`card ${className}`.trim()}>
{renderHeader ? renderHeader(title) : <div className="card-header">{title}</div>}
<div className="card-content">{children}</div>
{renderFooter && renderFooter()}
</div>
);
// 기존 코드를 변경하지 않고 확장하기
const ProductCard = ({ product, onAddToCart, ...props }: ProductCardProps) => (
<Card {...props} renderFooter={() => <button onClick={onAddToCart}>Add to Cart - ${product.price}</button>} />
);
확장을 위한 고차 컴포넌트
고차 컴포넌트는 개방-폐쇄 원칙을 따르는 또 다른 방법을 제공합니다.
type WithLoadingProps = {
isLoading?: boolean;
};
const withLoading = <P extends object>(WrappedComponent: React.ComponentType<P>) => {
return ({ isLoading, ...props }: P & WithLoadingProps) => {
if (isLoading) {
return <div className="loader">Loading...</div>;
}
return <WrappedComponent {...(props as P)} />;
};
};
// 사용법
const UserProfileWithLoading = withLoading(UserProfile);
개방-폐쇄 원칙을 따르는 커스텀 훅
커스텀 훅 역시 개방-폐쇄 원칙을 따를 수 있습니다.
const useDataFetching = <T>(url: string) => {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchData();
}, [url]);
const fetchData = async () => {
try {
const response = await fetch(url);
const result = await response.json();
setData(result);
} catch (e) {
setError(e as Error);
} finally {
setLoading(false);
}
};
return { data, error, loading, refetch: fetchData };
};
// 기존 코드의 수정 없이 확장하기
const useUserData = (userId: string) => {
const result = useDataFetching<User>(`/api/users/${userId}`);
// 사용자와 관련된 기능 추가하기
const updateUser = async (data: Partial<User>) => {
// 업데이트 로직
};
return { ...result, updateUser };
};
테스트에서의 이점
개방-폐쇄 원칙을 따르면 테스트가 훨씬 더 간단해집니다.
describe("ButtonBase", () => {
it("renders with custom className", () => {
render(<ButtonBase label="Test" onClick={() => {}} className="custom" />);
expect(screen.getByRole("button")).toHaveClass("button custom");
});
});
describe("PrimaryButton", () => {
it("includes primary styling", () => {
render(<PrimaryButton label="Test" onClick={() => {}} />);
expect(screen.getByRole("button")).toHaveClass("button button-primary");
});
});
핵심 요약
- 변경보다는 합성을 사용하세요 — 프로퍼티와 렌더 함수를 통해 확장하세요.
- 확장하기 쉬운 기본 컴포넌트를 만드세요.
- 재사용 가능한 확장을 위해 고차 컴포넌트와 커스텀 훅을 적극적으로 활용하세요.
- 확장 포인트를 염두에 두세요 — 앞으로 무엇이 변경될 수 있을지 생각해보세요.
- 타입스크립트로 확장에 타입 안정성을 확보하세요.
개방-폐쇄 원칙과 “상속보다 합성”
React 팀이 권장하는 “상속보다 합성” 원칙은 개방-폐쇄 원칙과 완벽하게 일치합니다.
// 상속 기반 접근 방식 (유연성 낮음)
class Button extends BaseButton {
render() {
return (
<button className={this.getButtonClass()}>
{this.props.icon && <Icon name={this.props.icon} />}
{this.props.label}
</button>
);
}
}
// 합성 기반 접근 방식(더 유연하며, 개방-폐쇄 원칙을 따름)
const Button = ({ label, icon, renderPrefix, renderSuffix, ...props }: ButtonProps) => (
<ButtonBase {...props}>
{renderPrefix?.()}
{icon && <Icon name={icon} />}
{label}
{renderSuffix?.()}
</ButtonBase>
);
const DropdownButton = ({ items, ...props }: DropdownButtonProps) => (
<Button {...props} renderSuffix={() => <DropdownIcon />} onClick={() => setIsOpen(true)} />
);
const LoadingButton = ({ isLoading, ...props }: LoadingButtonProps) => (
<Button {...props} renderPrefix={() => isLoading && <Spinner />} disabled={isLoading} />
);
합성 기반 접근 방식은 다음과 같은 장점이 있습니다.
- 프로퍼티와 렌더 함수를 통해 컴포넌트를 확장할 수 있어 개방-폐쇄 원칙을 지킬 수 있습니다.
- 기본 컴포넌트는 변경 없이 유지됩니다.
- 동작의 다양한 조합을 무한히 허용합니다.
- 타입 안전성과 프로퍼티의 투명성을 유지할 수 있습니다.
리액트 팀이 합성을 선호하는 이유는 단순한 스타일의 문제가 아닙니다. 합성을 통해 자연스럽게 개방-폐쇄 원칙을 따르는 확장 가능하고 유지보수가 쉬운 컴포넌트를 만들 수 있기 때문입니다.
결론
개방-폐쇄 원칙은 다소 추상적으로 느껴질 수 있지만, 리액트에서는 컴포넌트를 더 유지보수하기 쉽고 유연하게 만들어주는 실질적인 패턴으로 이어집니다. 앞서 살펴본 SOLID 원칙들과 결합하면, 확장과 유지보수가 쉬운 견고한 아키텍처를 만들 수 있습니다.
시리즈의 마지막 글에서는 단일 책임 원칙(Single Responsibility Principle)에 대해 다룰 예정이니 많은 기대 부탁드립니다!
꿀팁: 다양한 변형이나 동작을 위해 if/else 문을 많이 사용하고 있다면, 아마도 개방-폐쇄 원칙을 위반하고 있는 것일 수 있습니다.
업데이트: 친근한 안내 및 알림
만약 여러분이 소프트웨어 아키텍처에 대한 종합적인 가이드를 기대하셨다면, 이 글은 그런 목적이 아닙니다. 최근 소프트웨어 아키텍처에 대해 연재하고 있는 글들의 목적은, 그동안 제가 너무 쉽게 무시하거나 적용하기를 미뤄왔던 몇 가지 원칙들을 실제로 어떻게 활용할 수 있는지 탐구해보는 데 있습니다.
저는 이 개념들에 대해 완벽하게 이해하고 있다고 주장하지도 않고, 이 원칙들을 모든 상황에 무조건적으로 적용해야 한다고 말하는 것도 아닙니다. 심지어, 제가 제시한 간단한 예시들이 이 원칙들을 구현하거나 설명하는 최선의 방법이라고 생각하지도 않습니다.
그보다는, 고전적인 소프트웨어 엔지니어링 원칙과 현대 개발 실무 사이의 간극을 좁히려는 저의 시도를 기록하고 있을 뿐입니다. 사실 저 자신도 ‘클린 아키텍처’에 얼마나 가까이 다가갈지, 혹은 얼마나 실용적으로 접근할지 아직 결정을 내리지 못했습니다. 하지만 지금은 (대체로) 배우고 탐구하는 과정 자체를 즐기고 있습니다. 그러니 혹시라도 저나 Uncle Bob, 혹은 다른 분들에게 이와 관련해 레딧 등에서 뭐라고 하시기 전에 이 점을 꼭 기억해 주세요 😅
그리고 저와 의미 있고 존중하는 방식으로 의견을 나눠주신 모든 분들께 감사드립니다. 저에게 정말 큰 배움의 기회가 되었습니다.
감사합니다 :)
🚀 한국어로 된 프런트엔드 아티클을 빠르게 받아보고 싶다면 Korean FE Article을 구독해주세요!
'개발 > 번역' 카테고리의 다른 글
| [번역] CSS 길이 단위 이해하기 (1) | 2025.09.02 |
|---|---|
| [번역] React.memo 완벽 해부: 언제 쓸모 있고 언제 쓸모없는가 (10) | 2025.08.11 |
| [번역] 클린 코드의 심리학: 우리가 지저분한 리액트 컴포넌트를 작성하는 이유 (0) | 2025.06.21 |
| [번역] 리액트에서의 의존성 역전: 테스트하기 쉬운 컴포넌트 만들기 (0) | 2025.05.25 |
| [번역] 일반적인 리액트 라이브러리 아키텍처 (0) | 2025.04.17 |