리스코프 치환 원칙의 다른 예시들
이전 포스트에서는 아무개 은행
에서의 어린이 계좌 와 적금 계좌 의 계층 구조를 재정의하여 리스코프 치환 원칙을 준수하도록 코드를 수정하였습니다. 수정된 클래스 다이어그램을 살펴보면 다음과 같습니다.
이번 포스트에서는 리스코프 치환 원칙을 좀 더 직관적으로 이해하기 위해 간단한 예시들을 살펴보도록 하겠습니다.
계층 구조의 재정의
객체는 프로그램의 정확성(Correctness)에 영향을 주지 않으면서 해당 하위 유형으로 교체할 수 있어야 합니다.
첫번째 예시로 상위 클래스인 Car
클래스와 하위 클래스인 RacingCar
클래스를 살펴보겠습니다.
public Class Car {
public double getTrunkWidth() {
// return trunk width
}
}
public Class RacingCar extends Car {
@Override
public double getTrunkWidth() {
// 미구현!
}
public double getCockpitWidth() {
// return cockpit width
}
}
Car
클래스는 트렁크의 사이즈를 반환하는getTrunkWidth()
메서드를 가지고 있습니다.RacingCar
클래스는Car
클래스를 상속받았지만, 경주용 자동차는 트렁크가 존재하지 않기에 오버라이딩 한getTrunkWidth()
메서드는 미구현 상태입니다. 대신 조종석의 사이즈를 반환하는getCockpitWidth()
메서드를 가지고 있습니다.
이제 이들을 테스트하는 CarUtils
코드를 작성해보겠습니다.
public class CarUtils {
public static void main(String[] args) {
Car firstCar = new Car();
Car secondCar = new Car();
Car thirdCar = new RacingCar();
List<Car> cars = new ArrayList<>();
cars.add(firstCar);
cars.add(secondCar);
cars.add(thirdCar);
for (Car car : cars) {
System.out.println(car.getTrunkWidth());
}
}
}
CarUtils
클래스는Car
객체를 생성하여 리스트에 추가하고, 리스트의 모든 객체에 대해getTrunkWidth()
메서드를 호출합니다.- firstCar와 secondCar는
Car
클래스의 인스턴스이기 때문에getTrunkWidth()
메서드를 호출할 수 있습니다. 하지만 thirdCar는RacingCar
클래스의 인스턴스이기 때문에getTrunkWidth()
메서드를 호출해도 제대로(correctly) 동작하지 않습니다. - 상위 클래스의 인스턴스를 하위 클래스의 인스턴스로 대체할 수 없는 디자인 상의 허점이 여기서 드러나며, 이는 리스코프 치환 원칙을 위반한 것입니다.
리스코프 치환 원칙을 준수하려면, RacingCar
클래스는 Car
클래스의 하위 유형이 아니어야 합니다. 대신 Car
클래스와 RacingCar
클래스, 두 클래스 모두가 Vehicle
클래스의 하위 유형이어야 합니다. 이를 위해 계층 구조를 재정의하면 다음과 같습니다.
public Class Vehicle {
public double getInteriorWidth() {
// return interior width
}
}
public Class Car extends Vehicle {
@Override
public double getInteriorWidth() {
return getTrunkWidth();
}
public double getTrunkWidth() {
// return trunk width
}
}
public Class RacingCar extends Vehicle {
@Override
public double getInteriorWidth() {
return getCockpitWidth();
}
public double getCockpitWidth() {
// return cockpit width
}
}
Car
클래스와RacingCar
클래스의 공통된 부분은 두 자동차 모두 내부 공간을 가지고 있다는 것입니다. 따라서Car
클래스와RacingCar
클래스 모두를 아우를 수 있는Vehicle
클래스를 만들고, getInteriorWidth() 메서드를 추가하였습니다.Car
클래스는 내부에 트렁크가 존재하기 때문에getInteriorWidth()
메서드를 오버라이딩하여getTrunkWidth()
메서드를 호출하도록 하였습니다.RacingCar
클래스는 내부에 조종석이 존재하기 때문에getInteriorWidth()
메서드를 오버라이딩하여getCockpitWidth()
메서드를 호출하도록 하였습니다.
이제 새로 VehicleUtils
클래스를 작성하여 테스트해보겠습니다.
public class VehicleUtils {
public static void main(String[] args) {
Vehicle firstCar = new Car();
Vehicle secondCar = new Car();
Vehicle thirdCar = new RacingCar();
List<Vehicle> vehicles = new ArrayList<>();
vehicles.add(firstCar);
vehicles.add(secondCar);
vehicles.add(thirdCar);
for (Vehicle vehicle : vehicles) {
System.out.println(vehicle.getInteriorWidth());
}
}
}
- 이전의
CarUtils
와 마찬가지로VehicleUtils
클래스는Vehicle
객체를 생성하여 리스트에 추가하고, 리스트의 모든 객체에 대해getInteriorWidth()
메서드를 호출합니다. - for문의 첫번째와 두번째 순회에서는
Car
클래스의 인스턴스이기 때문에getInteriorWidth()
메서드를 호출하면 내부적으로getTrunkWidth()
메서드가 호출됩니다. - 세번째 순회에서는
RacingCar
클래스의 인스턴스이기 때문에getInteriorWidth()
메서드를 호출하면 내부적으로getCockpitWidth()
메서드가 호출됩니다.
위와 같이 코드를 수정함으로써 우리는 순회하는 객체의 타입에 관계없이 getInteriorWidth()
메서드를 호출할 수 있게 되었습니다. 이전과 다르게 상위 클래스의 인스턴스를 하위 클래스의 인스턴스로 대체할 수 있게 되었으며, 모든 동작이 제대로(correctly) 동작합니다.
계층 구조를 재정의 하면서 우리는 리스코프 치환 원칙을 준수하게 되었습니다. 다음으로는 Tell, Don't Ask 원칙을 통해서 리스코프 치환 원칙을 준수하도록 하는 예시를 살펴보겠습니다.
Tell, Don't Ask 원칙
Tell, Don't Ask 원칙은 객체의 상태를 묻고 이후에 행동을 취하는 것이 아니라, 바로 객체에게 행동을 지시하는 것을 의미합니다. Tell, Don't Ask 원칙에 대해 더 궁금하시다면, 해당 링크를 통해 마틴 파울러의 글을 읽어보시기 바랍니다.
이번에는 Tell, Don't Ask 원칙과 리스코프 치환 원칙을 같이 연관지어서 하나의 예시를 살펴보겠습니다.
- 이마트에서 상품을 판매할 때, 일반 상품 의 경우 10%의 할인율을 적용하고, 자사 제품인 노브랜드 상품 의 경우 15%의 할인율을 적용한다고 가정합니다.
- 일반 상품 인
Product
클래스와 노브랜드 상품 인InHouseProduct
클래스를 만들어보겠습니다.
public class Product {
protected double discountRate;
public getDiscountRate() {
return discountRate;
}
}
public class InHouseProduct extends Product {
public applyExtraDiscount() {
discountRate = discountRate * 1.5;
}
}
Product
클래스는discountRate
필드를 가지고 있으며,getDiscountRate()
메서드를 통해 할인율을 반환합니다.InHouseProduct
클래스는Product
클래스를 상속받고 있으며,getInHouseDiscountRate()
메서드를 통해 노브랜드 상품의 할인율을 반환합니다.
이제 PricingUtils
클래스를 작성하여 테스트해보겠습니다.
public class PricingUtils {
public static void main(String[] args) {
Product p1 = new Product();
Product p2 = new Product();
Product p3 = new InHouseProduct();
List<Product> productList = new ArrayList<>();
productList.add(p1);
productList.add(p2);
productList.add(p3);
for (Product product : productList) {
if (product instanceof InHouseProduct) {
((InHouseProduct) product).applyExtraDiscount();
}
System.out.println(product.getDiscountRate());
}
}
}
PricingUtils
클래스는Product
객체를 생성하여 리스트에 추가합니다.- 리스트를 순회하면서
InHouseProduct
클래스의 인스턴스인지 확인하고,InHouseProduct
클래스의 인스턴스라면applyExtraDiscount()
메서드를 호출하고 할인율을 적용합니다. - 이와 같은 코드 디자인은 좋은 디자인이 아니며, 리스코프 치환 원칙을 위반하고 있습니다.
리스코프 치환 원칙을 위반하는 이유 는 다음과 같습니다.
- 저희는 모든 객체들을 상위 클래스인
Product
클래스 자체로 보고 코드를 동작시켜야 합니다. 하지만 위의 테스트는InHouseProduct
클래스의 인스턴스인지 확인하고,InHouseProduct
클래스의 인스턴스라면applyExtraDiscount()
메서드를 호출하고 할인율을 적용합니다.
그렇다면 어떻게 리스코프 치환 원칙을 준수하도록 코드를 수정할 수 있을까요? 이번에는 Tell, Don't Ask 를 적용하여 코드를 수정해보겠습니다.
public class Product {
protected double discountRate;
public getDiscountRate() {
return discountRate;
}
}
public class InHouseProduct extends Product {
@Override
public getDiscountRate() {
applyExtraDiscount();
return discountRate;
}
public applyExtraDiscount() {
discountRate = discountRate * 1.5;
}
}
이전과 다르게 InHouseProduct
클래스에서 getDiscountRate()
메서드를 오버라이드 하였습니다. getDiscountRate()
메서드를 호출하면 내부적으로 applyExtraDiscount()
메서드가 호출되고 할인율이 적용됩니다. 이제 PricingUtils
클래스를 작성하여 테스트해보겠습니다.
public class PricingUtils {
public static void main(String[] args) {
Product p1 = new Product();
Product p2 = new Product();
Product p3 = new InHouseProduct();
List<Product> productList = new ArrayList<>();
productList.add(p1);
productList.add(p2);
productList.add(p3);
for (Product product : productList) {
System.out.println(product.getDiscountRate());
}
}
}
나머지는 이전과 동일하지만 저희는 더 이상 product
가 InHouseProduct
클래스의 인스턴스인지 확인하고, InHouseProduct
클래스의 인스턴스라면 applyExtraDiscount()
메서드를 호출하고 할인율을 적용하는 코드를 작성할 필요가 없어졌습니다. getDiscountRate()
메서드를 호출하면 내부적으로 applyExtraDiscount()
메서드가 호출되고 할인율이 적용됩니다. 이와 같은 코드 디자인은 Tell, Don't Ask 원칙을 준수하고 있으며, 리스코프 치환 원칙을 위반하지 않습니다.
정리
저희는 위의 모든 과정을 통해서 리스코프 치환 원칙을 지키도록 만드는 두 가지 방법을 알아보았습니다. 개인적으로 리스코프 치환 원칙은 SOLID 원칙들 중에서 가장 이해하기 어려운 원칙이라고 생각합니다. 일반적인 Rectangle-Square 예시에서 벗어나서 다양한 예시를 찾아보는 것이 이 원칙을 이해하는 데에 도움이 될 것이라고 생각합니다. 명세의 위반 관점에서 리스코프 치환 원칙에 대해 작성한 이전 포스트도 한 번씩 읽어보시길 적극 추천드립니다.