리스코프 치환 원칙(Liskov Substitution Principle, LSP)
정의
- "하위 클래스의 객체 또는 인스턴스는 프로그램의 정확성에 영향을 주지 않고 상위 클래스의 인스턴스를 대체할 수 있어야 한다."
- 즉, S가 T의 하위 클래스인 경우, T타입의 객체에 적용되는 것은 S타입의 객체에도 적용될 수 있어야 합니다.
음, 위의 정의만으로는 이해가 어렵습니다. 다시 한 번 더 짚고 넘어가죠.
리스코프 치환 원칙은 소프트웨어에서 상위 클래스 를 사용하는 방식에서 하위 클래스 를 사용하는 방식으로 변경하더라도, 프로그램의 동작과 명세는 변경되지 않아야 함을 의미합니다. 이는 하위 클래스가 상위 클래스에서 예상되는 동작을 동일하게 준수해야 한다는 것을 말하고 있죠. 때문에 하위 클래스는 상위 클래스가 설정한 기대치 즉, 명세를 위반하지 않는 방식으로 기능을 재정의하거나 확장 해야 합니다.
적용
시나리오
아무개 은행
에서 어린이를 위한 어린이 계좌 를 만들었다고 가정해봅시다.아무개 은행
에서는 개방 폐쇄 원칙을 준수하기 위해Account
를 상속받아ChildAccount
를 만들었습니다.
// Account
public class Account {
private double balance;
public Account(double balance) {
this.balance = balance;
}
public void deposit(double amount) {
this.balance += amount;
}
public void withdraw(double amount) {
if (balance < amount) {
System.out.println("잔액이 부족합니다.");
return;
}
this.balance -= amount;
}
}
// ChildAccount
public class ChildAccount extends Account {
public ChildAccount(double balance) {
super(balance);
}
@Override
public void deposit(double amount) {
super.deposit(amount);
}
@Override
public void withdraw(double amount) {
super.withdraw(amount);
}
}
BankWithdrawSevice
라는 클래스도 만들어서 출금도 가능하게 해보죠.
// BankWithdrawService
public class BankWithdrawService {
private Account account;
public BankWithdrawService(Account account) {
this.account = account;
}
public void withdraw(double amount) {
account.withdraw(amount);
}
}
여기서 저희는 '과연 Account 클래스의 명세는 무엇인가?' 를 한 번 생각해 볼 필요성이 있습니다.
Account
클래스의 명세를 한 번 나열해보죠.
deposit
메서드는 잔액에amount
를 더합니다.withdraw
메서드는 고객에게amount
만큼의 현금을 인출하고 잔액에서amount
만큼을 뺍니다. 제약조건은 잔액이amount
보다 작으면 "잔액이 부족합니다."를 출력한다는 것이죠.
이제 ChildAccount
클래스가 Account
클래스의 명세를 준수하고 있는지 확인해보겠습니다.
- 오버라이딩한
deposit
메서드와withdraw
메서드 모두super
키워드를 통해Account
클래스의 메서드를 호출하고 있습니다. 즉,ChildAccount
클래스는Account
클래스의 명세를 준수하고 있는 것을 확인 할 수 있죠.
ChildAccount 클래스 테스트
모든 준비가 완료되었으니, ChildAccount
클래스를 테스트 해보겠습니다.
Account childAccount = new ChildAccount(10000);
BankWithdrawService bankWithdrawService = new BankWithdrawService(childAccount);
bankWithdrawService.withdraw(5000); // 잔고: 5000
bankWithdrawService.withdraw(20000); // "잔액이 부족합니다."
문제없이 잘 동작하는 것을 확인할 수 있네요, 훌륭합니다!
추가된 시나리오
아무개 은행
에서 새로운 적금 계좌 를 만들었다고 가정해봅시다.- 이번에도
아무개 은행
에서는 개방 폐쇄 원칙을 준수하기 위해Account
를 상속받아SavingsAccount
를 만들었습니다.
// SavingsAccount
public class SavingsAccount extends Account {
public SavingsAccount(double balance) {
super(balance);
}
@Override
public void deposit(double amount) {
super.deposit(amount);
}
@Override
public void withdraw(double amount) {
throw new UnsupportedOperationException("출금은 불가능합니다.");
}
}
적금 계좌는 입금은 가능하지만, 출금은 불가능하다고 가정해보겠습니다. 아무래도 돈을 불리려면 그래야겠죠?
SavingsAccount 클래스 테스트
위와 마찬가지로 SavingsAccount
클래스를 테스트 해보겠습니다.
Account savingsAccount = new SavingsAccount(10000);
BankWithdrawService bankWithdrawService = new BankWithdrawService(savingsAccount);
bankWithdrawService.withdraw(5000); // UnsupportedOperationException
겉으로 보기에는 문제없이 잘 동작하는 것 같습니다. 적금 계좌는 입금은 가능하지만, 출금은 불가능하니까요.
하지만, 저희는 Account
클래스의 명세를 다시금 떠올려보면서 SavingsAccount
클래스가 명세를 잘 준수했는가를 생각해볼 필요성이 있습니다.
명세의 관점에서 SavingsAccount 바라보기
위에서 정리했던 Account
클래스의 명세를 다시 가져와보겠습니다.
deposit
메서드는 잔액에amount
를 더합니다.withdraw
메서드는 고객에게amount
만큼의 현금을 인출하고 잔액에서amount
만큼을 뺍니다. 제약조건은 잔액이amount
보다 작으면 "잔액이 부족합니다."를 출력한다는 것이죠.
SavingsAccount
클래스는 Account
클래스의 명세를 잘 준수하고 있을까요? 하나씩 확인해보겠습니다.
deposit
메서드는 잔액에amount
를 더합니다. ( O )SavingsAccount
클래스는withdraw
메서드를 오버라이딩하여 잔액의 인출을 막고, 인출을 시도할 경우UnsupportedOperationException
을 던집니다. ( X )
리스코프 치환 원칙의 위반은 바로 이곳에서 발생합니다. Account
클래스와 하위 클래스들은 모두 withdraw
메서드에서 지정한 동작과 명세를 보장할 것으로 약속했는데, SavingsAccount
클래스는 이 약속을 지키지 않고 있기 때문이죠.
리스코프 치환 원칙을 위반하게된 근본적인 원인
리스코프 치환 원칙을 위반하게된 근본적인 원인은 과연 그럼 무엇일까요?
근본적인 원인은 애초에 SavingsAccount
클래스가 Account
클래스의 행동 하위 유형에 속하지 않았다는 것입니다. 즉, SavingsAccount
클래스는 Account
클래스의 명세를 준수할 수 없었다는 것이죠.
저희는 SavingsAccount
의 대표자를 Account
클래스로 생각하고 설계를 진행하였지만, 그들은 Account(계좌) 라는 용어만 공통적으로 공유하고 있을 뿐, 행동은 공유하고 있지 않았습니다. Robert C. Martin 은 "사물의 대표자는 그들이 대표하는 사물들의 관계를 공유하지 않는다.(The representatives of things do not share the relationship of the things they represent.)" 라고 말합니다.
다시 말해, 저희가 작성하는 코드는 특정 개념을 표현한 것일 뿐이며, 그 개념이 현실 세계의 다른 개념과 특정한 계층적 관계를 가지고 있다고 해서 반드시 코드로 변환할 때 실제 관계가 유지된다는 의미는 아닙니다. 여기서 얻을 수 있는 교훈은 코드의 논리적 구조를 기반으로 계층 구조를 정의하고, 계층 구조를 기반으로 코드를 작성해야 한다는 것입니다. 현실세계의 관계가 소프트웨어의 관계로 그대로 옮겨지는 함정 에 빠지지 말아야 한다는 것이죠.
해결
그렇다면 위의 문제를 리스코프 치환 원칙을 준수하도록 계층 구조를 재정의 해보겠습니다.
우선 Account
클래스를 다음과 같이 deposit
메서드만 가지는 클래스로 변경해보겠습니다.
// Account
public class Account {
private double balance;
public Account(double balance) {
this.balance = balance;
}
public void deposit(double amount) {
this.balance += amount;
}
protected double getBalance() {
return balance;
}
protected void setBalance(double balance) {
this.balance = balance;
}
}
그리고 Account
클래스를 상속받는 WithdrawableAccount
클래스를 다음과 같이 작성해보겠습니다.
// WithdrawableAccount
public class WithdrawableAccount extends Account {
public WithdrawableAccount(double balance) {
super(balance);
}
public void withdraw(double amount) {
if (getBalance() < amount) {
throw new UnsupportedOperationException("잔액이 부족합니다.");
}
setBalance(getBalance() - amount);
}
}
기존의 어린이 계좌는 WithdrawableAccount
클래스를 상속받도록 변경해보겠습니다.
// ChildAccount
public class ChildAccount extends WithdrawableAccount {
public ChildAccount(double balance) {
super(balance);
}
@Override
public void deposit(double amount) {
super.deposit(amount);
}
@Override
public void withdraw(double amount) {
super.withdraw(amount);
}
}
출금이 필요없는 적금 계좌는 Account
클래스를 상속받도록 변경해보겠습니다.
// SavingsAccount
public class SavingsAccount extends Account {
public SavingsAccount(double balance) {
super(balance);
}
@Override
public void deposit(double amount) {
super.deposit(amount);
}
}
마지막으로 BankWithdrawService
클래스를 다음과 같이 변경해보겠습니다.
// BankWithdrawService`
public class BankWithdrawService {
private WithdrawableAccount account;
public BankWithdrawService(WithdrawableAccount account) {
this.account = account;
}
public void withdraw(double amount) {
account.withdraw(amount);
}
}
자, 이제 리스코프 치환 원칙을 준수하도록 계층 구조를 재정의하였습니다. 만들어진 계층 구조를 클래스 다이어그램으로 표현하면 다음과 같습니다.
왜 리스코프 치환 원칙을 지켜야 할까?
- 소프트웨어의 정확성에 영향을 주지 않고 하위 클래스 객체를 상위 클래스 객체를 대신해서 상호 교환적으로 사용할 수 있으므로 소프트웨어 시스템의 견고성, 유연성 및 유지 관리성이 크게 향상됩니다.
- 리스코프 치환 원칙은 보다 일반적이고 재사용 가능한 구성 요소를 생성하여 코드 중복을 줄이고 이해하기 쉬운 직관적인 클래스 계층 구조를 만듭니다.