React 개발에 SOLID 원칙 적용하기 - 리스코프 치환 원칙(LSP)
이전 글에서 SOLID 원칙 중 O에 해당하는 OCP를 충족하는 React 개발에 대해 다루었습니다. 이번에는 L에 해당하는 리스코프 치환 원칙(Liskov Substitution Principle, LSP)에 대해 이야기해보려 합니다. React 개발에서 이 원칙이 어떻게 적용될 수 있는지 함께 살펴볼게요.
리스코프 치환 원칙의 철학
리스코프 치환 원칙은 1987년 바바라 리스코프가 제안한 객체지향 설계 원칙입니다. 핵심은 간단합니다: "부모 타입이 사용되는 곳에 자식 타입을 넣어도 프로그램이 정상적으로 동작해야 한다."
function process(parent: Parent) {
parent.someMethod();
}
// 자식 객체를 넣어도 문제없이 동작해야 함
process(new Child());
상속의 안전성을 보장하는 게 이 원칙의 핵심입니다. 자식 클래스가 부모를 확장할 때 새로운 기능을 추가하는 건 자유지만, 부모가 약속한 기존 동작(계약)을 깨뜨려서는 안 됩니다. 이래야 다형성을 안심하고 사용할 수 있죠.
실생활에서 이 원칙을 어떻게 찾아볼 수 있을까요?
실생활에서의 리스코프 치환 원칙: 해피버거 이야기

전국에 500개 지점을 가진 햄버거 프랜차이즈 "해피버거"를 떠올려볼까요?
해피버거 본사는 모든 지점이 반드시 지켜야 할 계약을 정의합니다. 버거 세트를 주문하면 버거, 감자튀김, 음료가 나와야 하고, 원하면 포장도 가능해야 합니다. 이건 "해피버거"라는 브랜드가 고객에게 한 약속입니다.
interface HappyBurgerContract {
burgerSet(burger: string): {
burger: string;
side: "감자튀김";
drink: string;
};
packaging(order: Order): PackagedOrder;
}
각 지점은 이 계약을 지키면서 자유롭게 확장할 수 있습니다. 24시간 영업을 하거나, 드라이브스루를 추가하거나, 지역 한정 메뉴를 출시하는 식으로요. 본사 계약만 지키면 뭘 추가하든 상관없습니다.
그런데 어떤 지점이 "저희는 포장 안 됩니다"라고 한다면 어떨까요?
// ❌ LSP 위반: 포장 기능 제거
class 이상한지점 implements HappyBurgerContract {
burgerSet(burger: string) {
return { burger, side: "감자튀김" as const, drink: "cola" };
}
packaging(order: Order): PackagedOrder {
throw new Error("저희 매장은 포장 안 됩니다");
}
}
고객은 해피버거 간판만 보고 들어갑니다. 어떤 지점이든 "해피버거니까 포장 당연히 되겠지"라고 기대하죠. 그런데 주문 후에야 "저희 매장은 포장 안 돼요"라는 말을 듣습니다. 급하게 포장해서 나가려던 고객은 당황할 수밖에 없습니다.
function 주문하기(store: HappyBurgerContract) {
const set = store.burgerSet("치즈버거");
store.packaging(set); // 💥 Error: 저희 매장은 포장 안 됩니다
}
주문하기(new 강남점()); // ✅ 정상
주문하기(new 부산점()); // ✅ 정상
주문하기(new 이상한지점()); // ❌ 계약 위반
이제 고객은 해피버거 간판을 못 믿습니다. 다음부터는 매번 "여기 포장 되죠?"라고 확인해야 합니다. 브랜드에 대한 신뢰가 깨진 거죠.
LSP도 똑같습니다. 부모 타입을 믿고 사용하는 코드가 있는데, 어떤 자식이 "저는 이 기능 안 돼요"라고 하면 그 순간 타입에 대한 신뢰가 무너집니다.
React에서의 리스코프 치환 원칙
솔직히, React에서는 전통적인 OOP만큼 LSP가 자주 적용되지 않습니다. React는 클래스 상속보다 합성(Composition)을 선호하기 때문이죠.
// 상속 대신 합성
function IconButton({ icon, children, ...props }) {
return (
<Button {...props}>
{icon}
{children}
</Button>
);
}
// 상속 대신 children
<Card>
<CardHeader />
<CardBody />
</Card>;
// 상속 대신 hooks
function useAuth() {
/* ... */
}
function useApi() {
/* ... */
}
하지만 클래스 상속이나 인터페이스 확장이 일어나는 곳에서는 여전히 LSP가 유효합니다. React 프로젝트에서 흔히 마주치는 두 가지 케이스를 살펴보겠습니다.
케이스 1: Error 클래스 확장
API 통신을 하다 보면 다양한 에러 상황을 마주하게 됩니다. 이때 커스텀 에러 클래스를 만들어 사용하는 경우가 많죠. Axios의 AxiosError가 대표적입니다.
에러 클래스 계층 구조 만들기
// 기본 API 에러
class ApiError extends Error {
constructor(
message: string,
public statusCode: number,
public endpoint: string
) {
super(message);
this.name = "ApiError";
}
}
// 인증 관련 에러
class AuthError extends ApiError {
constructor(message: string, endpoint: string) {
super(message, 401, endpoint);
this.name = "AuthError";
}
}
// 권한 관련 에러
class ForbiddenError extends ApiError {
constructor(endpoint: string) {
super("접근 권한이 없습니다.", 403, endpoint);
this.name = "ForbiddenError";
}
}
// 네트워크 에러
class NetworkError extends ApiError {
constructor() {
super("네트워크 연결을 확인해주세요.", 0, "");
this.name = "NetworkError";
}
}
이 에러들은 모두 Error를 상속하므로, Error 타입을 받는 곳 어디서든 사용할 수 있습니다.
LSP가 지켜진 경우
// 전역 에러 핸들러
function handleError(error: Error) {
// 모든 Error 하위 클래스에서 message 접근 가능
console.error(error.message);
toast.error(error.message);
}
// React Query의 onError 콜백
const { mutate } = useMutation({
mutationFn: updateUser,
onError: (error: Error) => {
// ApiError든, AuthError든, NetworkError든 동일하게 처리
handleError(error);
},
});
// Error Boundary에서의 처리
class ErrorBoundary extends React.Component {
componentDidCatch(error: Error) {
// 어떤 Error 하위 클래스가 와도 message는 존재함
logToService(error.message);
}
}
어떤 에러가 들어오든 error.message에 안전하게 접근할 수 있습니다.
LSP를 위반한다면?
만약 NetworkError가 message를 다르게 동작시킨다면 어떨까요?
// ❌ LSP 위반: message를 제대로 설정하지 않음
class BadNetworkError extends Error {
constructor() {
super(); // message를 전달하지 않음
this.name = "NetworkError";
}
// message 대신 다른 메소드 사용
get errorMessage() {
return "네트워크 연결을 확인해주세요.";
}
}
이렇게 되면 handleError 함수에서 error.message가 빈 문자열이 되어 사용자에게 아무 메시지도 보여주지 못합니다. Error를 상속했지만 Error의 계약(message 속성 사용)을 지키지 않았기 때문입니다.
케이스 2: HTML 네이티브 Props 확장
디자인 시스템을 구축하거나 공용 컴포넌트를 설계할 때 HTML 네이티브 요소의 props를 확장하는 패턴을 자주 사용합니다. 여기서도 LSP가 중요합니다.
Button 컴포넌트 만들기
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "danger";
size?: "sm" | "md" | "lg";
isLoading?: boolean;
}
function Button({
variant = "primary",
size = "md",
isLoading,
disabled,
children,
...props
}: ButtonProps) {
return (
<button
className={cn("btn", `btn-${variant}`, `btn-${size}`)}
disabled={disabled || isLoading}
{...props}
>
{isLoading ? <Spinner size={size} /> : children}
</button>
);
}
이 Button 컴포넌트는 ButtonHTMLAttributes를 확장했습니다. 즉, 네이티브 <button>이 받는 모든 props를 그대로 지원해야 합니다.
LSP가 지켜진 경우
// 네이티브 button의 모든 속성이 정상 동작
<Button onClick={handleClick}>클릭</Button>
<Button disabled>비활성화</Button>
<Button type="submit">제출</Button>
<Button form="my-form">폼 연결</Button>
<Button onMouseEnter={handleHover}>호버</Button>
// 폼에서 사용할 때도 네이티브 button처럼 동작
<form id="login-form" onSubmit={handleSubmit}>
<Input name="email" />
<Input name="password" type="password" />
<Button type="submit">로그인</Button>
</form>
ButtonHTMLAttributes를 확장했으니, 네이티브 <button>처럼 자연스럽게 사용할 수 있어야 합니다.
LSP를 위반하는 케이스들
실무에서 자주 보이는 LSP 위반 사례들입니다.
// ❌ 위반 1: onClick을 가로채서 원래 동작을 무시
function BadButton({ ...props }: ButtonProps) {
return (
<button
{...props}
{/* props 내부의 onClick을 덮어씀 */}
onClick={() => {
analytics.track("button_click");
}}
/>
);
}
// 클릭해도 모달이 열리지 않음
<BadButton onClick={() => setIsOpen(true)}>열기</BadButton>;
// ❌ 위반 2: type prop을 무시
function BadSubmitButton({ type, ...props }: ButtonProps) {
return (
// type을 항상 "button"으로 고정
<button {...props} type="button" />
);
}
// Enter 키를 눌러도 폼이 제출되지 않음
<form onSubmit={handleSubmit}>
<BadSubmitButton type="submit">제출</BadSubmitButton>
</form>;
// ❌ 위반 3: disabled 상태에서도 클릭 이벤트 발생
function BadButton({ disabled, onClick, ...props }: ButtonProps) {
return (
<button
onClick={onClick} // disabled여도 onClick 실행됨
style={{ opacity: disabled ? 0.5 : 1 }}
{...props} // props에 disabled 포함되지 않음
/>
);
}
// 클릭하면 disabled 인데도 삭제가 실행됨
<BadButton disabled onClick={handleDangerousAction}>
삭제
</BadButton>;
올바른 확장 방법
추가 기능을 넣되 기존 계약은 유지하는 방법입니다.
// ✅ 올바른 확장: 기존 동작을 유지하면서 기능 추가
function Button({ onClick, disabled, ...props }: ButtonProps) {
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
// 추가 동작
analytics.track("button_click");
// 원래 onClick도 반드시 호출
onClick?.(e);
};
return (
<button
onClick={handleClick}
style={{ opacity: disabled ? 0.5 : 1 }}
disabled={disabled} // disabled 그대로 전달
{...props}
/>
);
}
LSP를 지키지 않으면 발생하는 문제
LSP 위반의 핵심 문제는 타입에 대한 신뢰가 무너진다는 점입니다.
// Error를 받는 유틸리티 함수
function logError(error: Error) {
// 모든 Error에 message가 있다고 신뢰
logger.error(error.message);
}
// ButtonProps를 받는 컴포넌트
function FormActions({
submitButton,
}: {
submitButton: ReactElement<ButtonProps>;
}) {
// type="submit"이 정상 동작한다고 신뢰
return <div className="form-actions">{submitButton}</div>;
}
LSP가 지켜지지 않으면 "어떤 하위 타입이 들어올지" 매번 방어적으로 체크해야 합니다.
// LSP가 없다면 이런 코드가 필요해짐
function logError(error: Error) {
if (error instanceof NetworkError) {
// NetworkError는 message가 비어있을 수도...
console.error(error.errorMessage);
} else if (error instanceof AuthError) {
// AuthError는 또 다른 방식일 수도...
console.error(error.authMessage);
} else {
console.error(error.message);
}
}
타입 시스템의 장점은 사라지고, 코드는 점점 복잡해지죠.
| 구분 | LSP 준수 | LSP 위반 |
|---|---|---|
| 타입 신뢰도 | 부모 타입으로 안전하게 사용 가능 | 매번 실제 타입 확인 필요 |
| 코드 복잡도 | 단순한 다형성 활용 | instanceof 분기문 증가 |
| 확장성 | 새 하위 타입 추가 용이 | 새 타입마다 분기 추가 필요 |
| 버그 가능성 | 낮음 | 높음 (예상치 못한 동작) |
마치며
리스코프 치환 원칙은 SRP나 OCP에 비해 React에서 자주 마주치는 원칙은 아닙니다. React가 상속보다 합성을 선호하고, 함수형 컴포넌트와 훅 기반으로 발전해왔기 때문이죠.
하지만 extends 키워드가 보이는 순간, 즉 Error 클래스를 확장하거나 HTML 네이티브 props를 확장할 때는 LSP가 여전히 유효한 가이드라인이 될 수 있습니다.
"이 확장이 부모의 계약을 잘 지키고 있는가?" 클래스나 인터페이스를 확장할 때 이 질문을 던져보는 것만으로도 더 견고한 코드를 작성할 수 있습니다.
모든 상황에 LSP를 적용할 필요는 없습니다. 다만 상속이나 확장이 일어나는 지점에서 한 번쯤 떠올려볼 만한 원칙입니다. 결국 좋은 설계는 원칙을 맹목적으로 따르는 것이 아니라, 원칙을 이해한 뒤 선택적으로 적용하는 것에서 시작됩니다.