리스코프 치환 원칙: 상속 관계를 안전하게 설계하는 기준
리스코프 치환 원칙(LSP)의 행위적 호환성, 계약 조건, 직사각형-정사각형 문제와 결제 처리 설계 예시를 통해 안전한 객체지향 상속을 정리한다.
2026-08-14 · 최초 발행 2025-05-23
부모 타입의 자리에 자식을 넣을 수 있는가
리스코프 치환 원칙(Liskov Substitution Principle, LSP)은 SOLID 원칙 가운데 상속 계층의 안정성을 다루는 기준이다. 바바라 리스코프(Barbara Liskov)가 1987년에 제안했으며, 핵심은 상위 타입의 객체를 하위 타입의 객체로 바꾸어도 프로그램의 정확성이 깨지지 않아야 한다는 데 있다.
따라서 LSP는 단순히 부모 클래스를 상속받았는지를 묻지 않는다. 하위 클래스가 부모 클래스가 약속한 동작을 계속 제공하는지, 즉 행위적으로 호환되는지를 본다. 하위 타입은 상위 타입의 기능을 포함하면서 확장되어야 하며, 클라이언트가 타입 교체를 의식하도록 만들어서는 안 된다.
이 계약이 깨지면 다형성을 사용하던 코드가 특정 하위 타입에서만 실패하거나 예상과 다른 결과를 낸다. 런타임 오류 가능성이 커지고, 코드 재사용성과 확장성도 떨어진다. 결국 호출부마다 타입별 예외 처리가 늘어나 유지보수 난이도까지 높아진다.
상속 계약에서 지켜야 할 범위
LSP를 만족하는 하위 타입은 상위 타입의 공개 계약을 유지해야 한다. 확인할 기준은 다음과 같다.
- 메서드 시그니처는 상위 타입과 호환되어야 한다. 매개변수 유형은 같아야 하며, 반환 유형은 공변성(covariance)을 가질 수 있다.
- 하위 타입은 더 많은 입력을 받을 수 있어야 한다. 상위 타입보다 강한 선행조건(Precondition)을 요구하면 기존 호출자가 사용할 수 없게 된다.
- 결과에 대한 후행조건(Postcondition)은 상위 타입보다 강화할 수 있다. 하위 타입은 더 엄격한 결과를 보장할 수 있다.
- 상위 타입이 전제한 불변조건(Invariant)은 하위 타입에서도 유지되어야 한다.
- 상위 타입 계약에 없던 예외 조건을 하위 타입이 새로 만들면 안 된다.
이 기준은 is-a 관계만으로 상속을 판단하기 어렵다는 점을 보여준다. 구조상 부모와 자식처럼 보이더라도, 실제로 부모처럼 행동하지 못한다면 같은 타입 계층에 두기 어렵다.
직사각형을 정사각형으로 바꿨을 때 생기는 문제
직사각형과 정사각형은 LSP 위반을 설명할 때 자주 쓰이는 사례다. 아래 구조에서는 Square가 Rectangle을 상속한다.
직사각형은 가로와 세로를 독립적으로 변경할 수 있다. 반면 정사각형은 두 값이 항상 같아야 한다. 그래서 Square에서 setWidth() 또는 setHeight()를 호출하면 양쪽 값이 함께 바뀐다.
부모 타입인 Rectangle을 사용하는 코드는 가로와 세로가 독립적으로 바뀐다고 기대한다. 그 위치에 Square를 전달하면 이 기대가 무너지고, 면적 계산 같은 결과도 의도와 달라질 수 있다.
이 경우에는 상속 관계를 없애고, 면적 계산이라는 공통 계약만 Shape로 분리하는 편이 낫다.
Rectangle과 Square는 각각의 상태 규칙을 독립적으로 유지하면서 Shape의 getArea() 계약을 구현한다. 공통 동작이 필요하다는 이유만으로 상태 변경 규칙까지 상속할 필요는 없다.
하위 타입이 예외를 추가하면 호출자는 깨진다
하위 타입이 상위 타입에 없던 예외를 던지는 것도 계약 위반이 될 수 있다.
// 부모 클래스
public class FileReader {
public String readFile(String path) {
// 파일 읽기 구현
return fileContent;
}
}
// 자식 클래스 (LSP 위반)
public class SecureFileReader extends FileReader {
public String readFile(String path) throws SecurityException {
if (!isAuthorized()) {
throw new SecurityException("권한 없음");
}
return super.readFile(path);
}
}
FileReader 타입을 사용하는 호출자는 파일을 읽을 수 있다는 계약을 전제로 작성된다. 그런데 SecureFileReader를 넣었을 때 권한에 따라 새 예외가 발생하면, 기존 호출부는 예상하지 못한 흐름을 처리해야 한다.
계약을 인터페이스로 명확히 표현하거나, 예외를 공통 상위 계층에 정의하는 방법을 고려할 수 있다. 런타임 예외로 전환하는 방안도 검토 대상이지만, 중요한 것은 하위 타입만의 제약을 부모 타입 사용자에게 갑자기 강요하지 않는 것이다.
다형성이 성립하려면 타입 교체가 자연스러워야 한다
LSP는 다형성을 안정적으로 쓰기 위한 이론적 기반이다. 공통 타입을 받는 코드가 어떤 하위 타입을 전달받아도 동일한 계약 안에서 동작해야 한다.
// LSP가 잘 적용된 다형성 예시
public void processShape(Shape shape) {
double area = shape.calculateArea();
// 모든 하위 타입이 문제 없이 동작
}
// 사용 예
Shape circle = new Circle(5);
Shape rectangle = new Rectangle(4, 6);
processShape(circle); // 원활히 동작
processShape(rectangle); // 원활히 동작
이 구조에서는 Circle과 Rectangle이 모두 Shape의 계약을 지킨다. 호출부는 구체 타입을 구분하지 않고 면적을 계산할 수 있으며, 타입 계층의 일관성과 컴파일 타임 안정성도 유지된다.
이런 대체 가능성은 새 파생 클래스를 기존 코드 수정 없이 추가할 수 있게 한다. 전략 패턴이나 템플릿 메서드 패턴처럼 다형성에 의존하는 설계와도 맞물린다. 모의 객체(Mock)를 이용한 테스트와 의존성 주입(DI) 활용도 단순해진다.
결제 처리자의 공통 계약
결제 방식이 달라도 결제 처리, 환불, 결제 검증이라는 공통 작업을 제공한다면 하나의 추상 타입으로 다룰 수 있다.
클라이언트는 구체적인 결제 수단이 아니라 PaymentProcessor 계약에만 의존한다.
// 클라이언트 코드
public class PaymentService {
public void executePayment(PaymentProcessor processor, double amount) {
// 프로세서 타입에 관계없이 일관된 동작 보장
boolean success = processor.processPayment(amount);
if (success) {
saveTransaction(processor, amount);
} else {
handleFailure(processor, amount);
}
}
}
// 사용 예
PaymentProcessor creditCard = new CreditCardProcessor("1234-5678", new Date(), "123");
PaymentProcessor paypal = new PayPalProcessor("user@example.com");
PaymentProcessor crypto = new CryptoCurrencyProcessor("0x1234abcd");
paymentService.executePayment(creditCard, 100.00);
paymentService.executePayment(paypal, 50.00);
paymentService.executePayment(crypto, 75.00);
PaymentService는 처리자의 실제 종류를 알 필요가 없다. CreditCardProcessor, PayPalProcessor, CryptoCurrencyProcessor가 PaymentProcessor의 동작을 일관되게 구현한다면, 각 객체는 서로 교체되어도 결제 서비스의 흐름을 바꾸지 않는다.
설계와 리팩터링에서 확인할 점
상속 계층을 만들 때는 인터페이스와 추상 클래스로 행위 계약을 분명히 남겨야 한다. 주석과 문서화 역시 계약의 일부가 될 수 있다. 특히 하위 타입이 부모보다 좁은 입력만 받는지, 더 많은 예외를 발생시키는지, 부모의 상태 규칙을 바꾸는지를 검토해야 한다.
기존 코드에서는 LSP 위반 패턴을 찾아 점진적으로 개선할 수 있다. 상속이 행위적 호환성을 표현하지 못한다면 객체 합성(Composition)을 대안으로 삼는 편이 안전하다. 상속은 분류 체계가 아니라, 부모 타입을 사용하는 모든 코드에서 자식이 같은 약속을 지킬 수 있을 때 선택할 관계다.