개발/개발서적

SOLID 원칙 - LSP, ISP, DIP

devriver 2021. 10. 21. 00:24

클린 아키텍쳐 3부

LSP 리스코프 치환 원칙

치환 원칙과 하위타입

S 타입의 객체 o1에 대응하는 T 타입의 객체 o2가 있다. T 타입을 이용한 프로그램 P에서 o2의 자리에 o1을 치환하더라도 P의 행위가 변하지 않는다면, S는 T의 하위타입이다.

LSP를 준수하는 설계(상속)

Billing Application ---> License - caleFee() <--- Personal License, Business License extends License

License에는 두 가지 하위타입이 있다. 이 둘 하위 타입은 다른 알고리즘으로 라이센스 비용을 계산한다. Billing Application은 두 하위 타입 중 무엇을 사용하는 지에 전혀 의존하지 않기 때문에 이들 하위타입은 모두 License 타입으로 치환할 수 있다.

LSP를 위반하는 설계(정사각형/직사각형)

User ---> Rectangle - setH(), setW() <--- Square extends Rectangle

Square는 Rectangle의 하위타입으로 적합하지 않다. Rectangle은 높이, 너비가 독립적인 반면, Square의 높이, 너비는 반드시 함께 변경되기 때문이다.

예)

Rectangle r = new Square()  
r.setW(5)  
r.setH(2)  
assert(r.area()) == 10) // 실패. r.area() 값은 4가 된다.

해결 방법

if문을 이용해 Rectangle이 실제로 Square인지 검사한다. 하지만 이는 User 행위가 사용자 타입에 의존하게 되어 타입을 서로 치환할 수 없게 된다.

LSP와 아키텍처

과거에는 LSP는 상속을 사용하도록 가이드하는 방법 정도로 간주했으나 현재는 인터페이스와 구현체에도 적용되는 설계 원칙으로 변모되었다. 잘 정의된 인터페이스와 그 인터페이스 구현체끼리는 상호 치환 가능성이 존재한다.

LSP 위반사례(택시 파견 서비스)

고객은 택시업체의 존재를 알 필요 없고 택시 파견 서비스가 고객에게 택시를 파견하는 상황.

purplecab 택시 소속인 bob을 파견하려면, bob 레코드에 있는 purplecab.com/driver/bob /pickupAddress/24 Maple st. /pickupTime/153 /destination/ORD 형태로 purplecab 서비스의 REST API를 호출해야한다.

그리고

acme 택시 소속인 tom을 파견하려면, tom 레코드에 있는 acme.com/driver/tom /pickupAddress/24 Maple st. /pickupTime/153 /dest/ORD 형태로 acme 서비스의 REST API를 호출해야한다.

두 택시 업체의 REST API 중 destination, dest 가 달라 택시 파견 서비스에서는 if문을 통해 분기처리를 하여 API를 호출해야한다. 즉, 택시 파견 서비스가 각 택시 업체 타입에 의존하게 되고 서로 치환할 수 없게 된다.

어떻게 해결할까?

interface를 하나 만들어 각 택시업체에서 이를 구현하여 동일하게 REST API를 만들도록 한다.


ISP 인터페이스 분리 원칙

필요 이상으로 많은 걸 포함하는 모듈에 의존하는 것은 해로운 일이다.

System S ---> Framework F ---> Database D, 프레임워크가 반드시 D 데이터베이스를 사용해야된다면 S는 F를 의존하고 F는 D를 의존한다. S와는 전혀 관계 없는 기능이 D에 포함되어있다고 가정해보면, 그 기능이 변경되면 D가 바뀌어 F를 심지어는 S를 재배포해야하는 경우가 생길 수 있다.

DIP 의존성 역전 원칙

- DIP가 말하는 유연성이 극대화된 시스템이란 소스 코드 의존성이 추상에 의존하며 구체에는 의존하지 않는 시스템이다.

- 안정된 소프트웨어 아키텍처는 변동성이 큰 구현체에 의존하는 것을 지양하고 안정된 추상 인터페이스를 선호하는 아키텍처라는 뜻이다.

안정된 추상화

인터페이스는 구현체보다 변동성이 낮다. 인터페이스가 수정되면 이를 구현한 구현체를 모두 수정해야하지만, 구현체 변경시에는 그렇지 않다. 인터페이스를 변경하지 않고 구현체에 기능을 추가하는 방법은 무엇일까?

- 변동성이 큰 구체 클래스를 참조하지않기: 대신 추상 인터페이스를 참조하기, 추상 팩토리를 사용하도록 강제하기

- 변동성이 큰 구체 클래스로부터 파생하지않기: 상속은 신중 또 신중하게 사용하기

- 구체 함수를 오버라이드 하지않기

- 구체적이며 변동성이 크다면 절대로 그 이름을 언급하지 않기

팩토리

// 팩토리 사용 안한 경우
interface Service {
  concrete: () => void;
}

class ConcreteImpl implements Service {
  concrete() {
    console.log('concrete!');
  }
}

class Application {
  main() {
      // Application에서 ConcreteImpl 인스턴스를 어떤 식으로든 생성해야한다.
    // ConcreteImpl(구체적인 구현체)와의 의존성이 생겨버리는 데, 이러한 소스코드 의존성을 막기 위해 팩토리를 사용한다.
    const service: Service = new ConcreteImpl();
    service.concrete(); // concrete!
  }
}
// 팩토리 사용 예제
interface ServiceFactory {
  makeSvc:() => Service
}

class ServiceFactoryImpl implements ServiceFactory {
  makeSvc() {
    return new ConcreteImpl();
  }
}

interface Service {
  concrete: () => void;
}

class ConcreteImpl implements Service {
  concrete() {
    console.log('concrete!');
  }
}

class Application {
  main() {
    const serviceFactory = new ServiceFactoryImpl();
    const service: Service = serviceFactory.makeSvc();
    service.concrete(); // concrete!
  }
}

Application, ServiceFactory, Service <--|-- ConcreteImpl, ServiceFactoryImpl

- 왼쪽은 추상 컴포넌트, 오른쪽은 구체 컴포넌트

- 추상 컴포넌트: 애플리케이션의 모든 고수준의 업무 규칙을 포함한다.

- 구체 컴포넌트: 업무 규칙을 다루기 위해 필요한 세부사항을 포함한다.

- 소스코드의 의존성은 추상적인 쪽으로 향한다.

- 제어흐름은 구체적인 쪽으로 흐른다.(소스 코드 의존성과는 반대) 이러한 이유로 의존성 역전이라고 부른다.

구체 컴포넌트

ServiceFactoryImpl 구체 클래스가 ConcreateImpl 구체 클래스에 의존한다. 이는 DIP에 위배되지만 일반적인 일이다. DIP를 위배하는 클래스들은 적은 수의 구체 컴포넌트 내부로 모을 수 있고 이를 통해 시스템의 나머지 부분과 분리할 수 있다. 예) main 함수