
리스코프 치환 원칙은 상속 문법을 예쁘게 쓰는 규칙이 아닙니다. 핵심은 더 단순합니다. 부모 타입을 기대하는 코드가 자식 타입으로 바뀌어도 같은 기대를 유지할 수 있어야 한다는 뜻입니다.
그래서 LSP (Liskov Substitution Principle)를 이해할 때는 extends보다 먼저 계약을 봐야 합니다. 메서드 이름이 같고 컴파일이 된다고 해서, 치환 가능한 설계가 되는 것은 아닙니다.
실무에서 LSP가 자주 오해되는 이유도 여기 있습니다. 많은 설명이 상속 구조 자체에 머무르지만, 실제 문제는 입력 조건이 달라지고, 반환 의미가 바뀌고, 예외 정책이 흔들리는 순간에 생깁니다.
리스코프 치환 원칙(LSP) 핵심
LSP의 실무형 정의는 이렇게 기억하면 충분합니다. 상위 타입을 기대하는 코드는 하위 타입으로 바뀌어도 사용법을 다시 배우지 않아야 한다.
이 문장에서 중요한 것은 타입 이름이 아니라 기대 가능한 동작입니다. 예를 들어 PaymentProcessor를 받는 코드는 어떤 구현이 오더라도 성공과 실패가 일관된 방식으로 돌아오고, 호출 순서가 갑자기 늘어나지 않기를 기대합니다.
- 결제 요청 결과가 일관된 방식으로 돌아온다
- 실패 시 예외나 반환 규칙을 예측할 수 있다
- 구현체가 바뀌어도 호출 순서와 사전 준비 절차가 크게 달라지지 않는다
만약 어떤 구현은 바로 호출해도 되고, 어떤 구현은 반드시 사전 초기화를 해야 하며, 어떤 구현은 실패 시 false를 반환하고 다른 구현은 런타임 예외를 던진다면 호출 코드는 구현별 분기를 알아야 합니다. 이때는 이미 치환 가능성이 약해진 상태입니다.
오해가 생기는 이유
LSP가 자주 오해되는 첫 번째 이유는 상속 문법과 함께 배우기 때문입니다. Animal, Bird, Penguin 같은 예시는 문법 설명에는 편하지만, 계약 설명에는 부족합니다.
두 번째 이유는 is-a 문장에 너무 기대기 때문입니다. 자연어에서 맞는 문장이 코드 설계에서도 곧바로 맞는 것은 아닙니다. 중요한 것은 자식이 부모처럼 보이느냐가 아니라, 부모를 기대하는 코드가 자식을 받아도 안정적인가입니다.
세 번째 이유는 컴파일이 통과하면 구조도 괜찮다고 착각하기 쉽기 때문입니다. 하지만 LSP 위반은 문법 오류보다 의미 오류로 드러나는 경우가 많습니다. 테스트와 운영에서야 비로소 호출 코드가 구현별 예외 처리를 달리해야 하는 상황이 보이곤 합니다.
계약 체크포인트
여기서 말하는 계약은 거창한 프레임워크 기능이 아닙니다. 호출자가 타입을 보고 합리적으로 기대하는 약속입니다. 보통 아래 네 가지를 먼저 보면 충분합니다.
- 어떤 입력을 받아도 되는가
- 호출 후 무엇을 보장하는가
- 실패는 어떤 방식으로 드러나는가
- 부작용은 어디까지 발생하는가
하위 타입이 상위 타입보다 더 까다로운 입력 조건을 요구하거나, 덜 믿을 수 있는 결과를 주거나, 예상 밖의 예외와 부작용을 늘리기 시작하면 LSP 관점에서 위험 신호로 볼 수 있습니다.
전제조건 강화 사례
가장 흔한 문제는 하위 타입이 더 까다로운 입력 조건을 요구하는 경우입니다. 파일 저장 역할을 나타내는 인터페이스가 있다고 가정해 보겠습니다. 호출자는 Storage라면 일반적인 파일 저장이 가능하다고 기대합니다.
public interface Storage {
void save(FileData file);
}그런데 어느 구현은 PNG 파일만 받고, 크기도 2MB 이하만 허용한다면 이야기가 달라집니다.
public class ImageOnlyStorage implements Storage {
@Override
public void save(FileData file) {
if (!file.isPng()) {
throw new IllegalArgumentException("PNG only");
}
if (file.sizeInMb() > 2) {
throw new IllegalArgumentException("Max 2MB");
}
// 저장 로직
}
}이 구현이 틀렸다는 뜻은 아닙니다. 문제는 Storage라는 상위 계약이 너무 넓었는데, 하위 타입이 들어오면서 갑자기 사용 조건이 더 까다로워졌다는 점입니다. 이런 경우에는 상속보다 역할 분리가 더 자연스럽습니다.
사후조건 약화 사례
두 번째는 반환 결과의 의미가 구현마다 달라지는 경우입니다. 호출하는 쪽이 할인 금액은 항상 0 이상이라고 믿고 있다면, 같은 시그니처 아래에서 음수를 반환하는 구현은 계약을 흔듭니다.
public interface DiscountPolicy {
int discountAmount(Order order);
}
public class NegativeFeePolicy implements DiscountPolicy {
@Override
public int discountAmount(Order order) {
return -3000;
}
}이 시점부터 호출 코드는 음수 방어 로직을 추가해야 합니다. 타입은 같지만 의미는 이미 달라졌습니다. 즉 하위 타입은 메서드 시그니처만 맞추는 것이 아니라, 기존 결과 해석 방식도 유지해야 합니다.
예외 정책 변경 사례
세 번째는 실패 방식이 구현마다 달라지는 경우입니다. 어떤 저장소 구현은 데이터를 못 찾으면 null을 반환하고, 다른 구현은 같은 상황에서 예외를 던진다면 서비스 계층은 구현별 분기를 알아야 합니다.
public interface UserRepository {
User findById(long id);
}
public class StrictUserRepository implements UserRepository {
@Override
public User findById(long id) {
throw new IllegalStateException("User not found");
}
}실무에서는 이 문제가 외부 API 래퍼, 캐시, 결제 모듈, 파일 저장소에서 자주 나타납니다. 같은 인터페이스 아래에 구현을 넣어두었지만 실패 시나리오를 각자 다르게 설계해 둔 경우입니다.
상속 판단 질문
상속이 자연스러운지는 클래스 다이어그램보다 호출 코드 관점에서 봐야 합니다. 아래 질문에 자꾸 아니오가 나온다면 상속보다 조합이나 역할 분리가 더 잘 맞을 가능성이 큽니다.
- 부모 타입을 쓰는 코드가 자식 타입으로 바뀌어도 방어 분기를 추가하지 않아도 되는가
- 자식 타입이 부모보다 더 많은 사전 준비를 요구하지 않는가
- 반환값의 의미가 부모 계약과 같은가
- 실패 방식과 예외 정책이 크게 다르지 않은가
- 메서드 이름은 같지만 실제 부작용 범위가 커지지 않는가
- 자식 타입이 들어오면서 호출 순서 규칙이 새로 생기지 않는가
상속이 자연스럽다는 말은 부모 API를 그대로 믿고 써도 된다는 말과 거의 같다. 이 기준이 잡히면 상속을 덜 감정적으로 보게 됩니다.
조합이 더 나은 순간
LSP를 생각하다 보면 왜 상속보다 조합이 낫다는 말이 자주 나오는지도 이해됩니다. 조합은 보통 ‘부모 역할을 그대로 대체한다’는 약속을 덜 요구하기 때문입니다.
- 일부 기능만 공유하고 전체 역할은 같지 않을 때
- 구현별 제약 조건이 크게 다를 때
- 기능 조합이 자주 달라질 때
- 상위 타입의 약속을 억지로 끌고 가면 예외 분기가 늘어날 때
관련해서 더 넓게 보면 상속 vs 조합 글도 함께 읽기 좋습니다.
Java List 사례
Java List 문서는 일부 연산을 optional operation으로 설명하고, 변경 불가능한 리스트에서는 변경 메서드를 호출하면 UnsupportedOperationException이 발생한다고 명시합니다. 이 사례는 현실의 API 설계가 늘 교과서처럼 단순하지 않으며, 그래서 더더욱 계약을 문서로 분명하게 적는 일이 중요하다는 점을 보여줍니다.
즉 이 사례를 단순히 ‘맞다/틀리다’로 보기보다, 타입 이름만으로 모든 기대를 다 담기 어려울 때 문서와 사용 규칙이 함께 필요하다는 교훈으로 읽는 편이 실무적으로 더 유익합니다.
실무 체크리스트
- 상속 전에 먼저 부모 타입의 사용 시나리오를 적어본다
- 구현체가 바뀌어도 호출 코드가 그대로 유지되는지 본다
- 구현체별 예외 처리 분기가 늘어나면 계약이 흔들린다고 의심한다
- 일부 자식만 특별 취급해야 한다면 역할 분리를 먼저 검토한다
- UnsupportedOperationException 같은 메시지가 자주 등장하면 상위 추상화가 과한지 의심한다
이렇게 보면 LSP는 추상적인 표어가 아니라 호출 코드가 안정적인지 검사하는 체크리스트가 됩니다.
마무리 정리
리스코프 치환 원칙은 상속 금지 규칙이 아닙니다. 그리고 부모-자식 관계면 다 된다는 허용 규칙도 아닙니다. 핵심은 하나입니다. 부모 타입을 기대하는 코드가 자식 타입을 받아도 같은 기대를 유지할 수 있어야 합니다.
그래서 상속이 자연스러운지는 클래스 다이어그램보다 계약을 봐야 판단할 수 있습니다. 입력 조건이 더 까다로워지지 않는지, 결과 의미가 바뀌지 않는지, 실패 방식이 달라지지 않는지, 호출 순서가 추가되지 않는지를 보는 편이 훨씬 정확합니다.
결국 LSP는 객체지향 설계에서 상속을 예쁘게 꾸미는 원칙이 아니라, 치환 가능한 추상화를 고르는 원칙입니다. 함께 읽으면 좋은 글로는 SOLID 원칙, 실무에서는 어떻게 봐야 할까, 상속 vs 조합, 객체지향 설계에서 결합도가 중요한 이유가 있습니다. 외부 참고로는 Liskov substitution principle 정리와 Oracle Java List API 문서를 볼 수 있습니다.