본문으로 건너뛰기

React 개발에 SOLID 원칙 적용하기 - 리스코프 치환 원칙(LSP)

· 약 7분
방경민
방경민
Frontend Developer

이전 글에서 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를 위반한다면?

만약 NetworkErrormessage를 다르게 동작시킨다면 어떨까요?

// ❌ 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를 적용할 필요는 없습니다. 다만 상속이나 확장이 일어나는 지점에서 한 번쯤 떠올려볼 만한 원칙입니다. 결국 좋은 설계는 원칙을 맹목적으로 따르는 것이 아니라, 원칙을 이해한 뒤 선택적으로 적용하는 것에서 시작됩니다.