본문 바로가기
Engineering WIKI/Docs

객체지향 (LID)

by wonos 2021. 1. 17.

2023.05.27 - [Engineering WIKI/Docs] - SOLID 5가지 설계 원칙

2021.01.17 - [Engineering WIKI/Docs] - 객체지향 (LID)


Liskov Substitution Principle (리스코프 치환 원칙)

  • 자식 클래스는 부모 클래스에서 가능한 행위를 수행할 수 있어야 한다.
    • 자식 클래스가 부모 클래스의 기본 의미를 해치지 않는지!
  • 부모클래스와 자식클래스 사이의 행위에는 일관성이 있어야 한다.
    • (부모 클래스의 인스턴스 대신 자식 클래스의 인스터스를 사용해도 문제가 없어야 한다는 것을 의미한다.)
  • 상속 관계에서는 일반화 관계(IS - A)가 성립해야 한다. 일반화 관계에 있다는 것은 일관성이 있다는 것이다. 따라서 리스코프 치환 원칙은 일반화 관계에 대해 묻는 것 이다.
    • Ex) 도형 클래스와 사각형 클래스가 있고, 사각형 클래스는 도형 클래스의 상속을 받는다고 가정하자.
      • (1) 도형은 둘레를 가지고 있다.
      • (2) 도형은 넓이를 가지고 있다.
      • (3) 도형은 각을 가지고 있다.
      • 일반화 관계(일관성인지 확인하는 방법은 단어를 교체해 보면 알 수 있다.  (1) ~ (3)의 도형이란 단어 대신 사각형을 넣어보자.
      • (1) 사각형은 둘레를 가지고 있다.
      • (2) 사각형은 넓이를 가지고 있다.
      • (3) 사각형은 각을 가지고 있다.
      • (1) ~ (3) 모두 딱히 이상한 부분이 보이지 않는다. 따라서 도형과 사각형 사이에는 일관성이 있다고 할 수 있다.
      • 여기서 원(Circle) 이라는 도형에 대해 생각해보자. 원 클래스 역시 도형 클래스의 상속을 받는다고 가정하자. 앞에서 언급한 (1) ~ (3)의 도형 단어 대신 원을 대입해보자.
      • (1) 원은 둘레를 가지고 있다.
      • (2) 원은 넓이를 가지고 있다.
      • (3) 원은 각을 가지고 있다.
      • 문장을 읽어보면 (3)번 문장이 어색하다는 것을 알 수 있다. 따라서 도형 클래스는 LSP을 만족하지 않은 설계라 할 수 있다. 따라서 (3)문장에 대해서는 일반화 관계가 성립하도록 수정되어야 한다.

Interface Segregation Principle (인터페이스 분리 원칙)

  • 한 클래스는 자신이 사용하지 않는 인터페이스는 구현하지 말아야 한다. 하나의 일반적인 인터페이스 보다는 여러개의 구체적인 인터페이스가 낫다.
  • 이는 다시 말해서, 자신이 사용하지 않는 기능(인터페이스)에는 영향을 받지 말아야 한다.
  • 인터페이스 분리 원칙을 통해 시스템의 내부 의존성을 약화시켜 리팩토링, 수정, 재배포를 쉽게 할 수 있다.
  • SRP는 객체의 단일 책임을 뜻한다면, ISP는 인터페이스의 단일 책임을 의미한다고 보면 됩니다.

  • ISP를 만족하려면 Phone 인터페이스에 call(), sms(), alarm(), calculator() 함수를 모두 정의하는 것보다, Call, Sms, Alarm, Calculator 인터페이스를 각각 정의하여, 3G폰과 스마트폰 클래스에서 4개의 인터페이스를 구현하도록 설계되어야 합니다.

  • 이렇게 설계를 하면, 각 인터페이스의 메서드들이 서로 영향을 미치지 않게 됩니다. 즉, 자신이 사용하지 않는 메서드에 대해서 영향력이 줄어들게 됩니다.

 

  • Bad Code
interface SmartPrinter {
  print();
  fax();
  scan();
}

class AllInOnePrinter implements SmartPrinter {
  print() {
    // ...
  }

  fax() {
    // ...
  }

  scan() {
    // ...
  }
}

class EconomicPrinter implements SmartPrinter {
  print() {
    // ...
  }

  fax() {
    throw new Error('Fax not supported.');
  }

  scan() {
    throw new Error('Scan not supported.');
  }
}

 

  • Good Code
interface Printer {
  print();
}

interface Fax {
  fax();
}

interface Scanner {
  scan();
}

class AllInOnePrinter implements Printer, Fax, Scanner {
  print() {
    // ...
  }

  fax() {
    // ...
  }

  scan() {
    // ...
  }
}

class EconomicPrinter implements Printer {
  print() {
    // ...
  }
}

 


 

Dependency Inversion Principle (의존 역전 원칙)

  • 의존 관계를 맺을 떄, 변화하기 쉬운것 보단 변화하기 어려운 것에 의존해야 한다는 원칙.
  • 저수준 모듈이 고수준 모듈에 의존하게 되는 것
    • Ex) 계층구조 예시
      • 표현 계층 : 사용자의 요청을 받아 응용 영역에 전달함과 동시에 처리 결과를 사용자에게 표시
      • 응용 영역 : 사용자에게 제공해야 할 기능 구현
      • 도메인 영역 : 도메인의 핵심 로직 & 도메인 모델 구현
      • 인프라 계층 : 구현 기술을 다룸 (ex. DB 연동)
      • 각 계층은 위와 같은 특징을 가지고 있으며, 상위 계층은 하위 계층에게 의존하지만, 하위 계층은 상위 계층에 의존하지 않습니다. 하지만 그렇게 되면 인프라 계층에게 종속적인 현상이 많이 일어나게 됩니다.
  • 여기서 말하는 변화하기 쉬운 것 이란 구체적인 것을 말하고, 변화하기 어려운 것이란 추상적인것을 말한다.
    • 변화하기 쉽고 구체적인 것 → 클래스
    • 변화하기 어려운 것 → 인터페이스, 추상 클래스

  • Client 객체는 Cat, Dog, Bird의 crying() 메서드에 직접 접근하지 않고, Animal 인터페이스의 crying() 메서드를 호출함으로써 DIP를 만족할 수 있습니다.

 

  • 기존 코드의 문제
/* A사의 알람 서비스 */
public class A {
  public String beep() {
    return "beep!";
  }
}
/* 서비스 코드*/
public class AlarmService {

  private A alarm;

  public String beep() {
    return alarm.beep();
  }

}
  • 문제점 : 테스트의 어려움
    • 위 코드에서 AlarmService만 온전히 테스트할 수 없습니다. 인프라 계층에 속하는 A가 완벽하게 동작해야만 AlarmService를 테스트할 수 있습니다.
  • 문제점 : 확장 및 변경이 어려움
    • 만약 알람 서비스에 B사가 추가된다면 어떻게 될까요? 아래와 같이 서비스 코드를 변경해야합니다.
/* B사의 알림 서비스 */
public class B {
  public String beep() {
    return "beep";
  }
}
/* 서비스 코드 */
public class AlarmService {

	private A alarmA;
  	private B alarmB;

  	public String beep(String company) {
        if (company.equals("A")) {
            return alarmA.beep();
        } else {
            return alarmB.beep();
        }
    }
}
  • 해결책 : 방금 설명한 기능을 고수준 모듈과 저수준 모듈로 분리하면 아래와 같습니다.
    • 고수준 모듈 : 알림
    • 저수준 모듈 : A사의 알림 서비스, B사의 알림 서비스
    • 지금까지 사용한 방법은 고수준 모듈이 저수준 모듈에 의존하는 방법이지만, DIP를 적용하게 되면 저수준 모듈이 고수준 모듈에 의존해야 합니다. 그러기 위해 사용하는 것이 추상 타입(ex. 인터페이스, 추상 클래스)입니다.
public interface Alarm {
    String beep();
}
  • 저수준 모듈이 상속 받을 Alarm 인터페이스를 만들어줍니다.
/* A사의 알람 서비스 */
public class A implements Alarm {
    @Override
    public String beep() {
    	return "beep!";
    }
}
/* B사의 알림 서비스 */
public class B implements Alarm {
    @Override
    public String beep() {
    	return "beep";
    }
}
  • 그 후에 저수준 모듈들이 Alarm을 구현하게 하면 됩니다. 그렇게 되면 서비스 코드를 아래와 같이 변경할 수 있습니다.
/* 서비스 코드 */
public class AlarmService {
    private Alarm alarm;
    
    public AlarmService(Alarm alarm) {
    	this.alarm = alarm;
    }
    
    public String beep() {
    	return alarm.beep();
    }
}
  • 더 이상 AlarmService는 알람 서비스가 추가된다고 코드를 변경하거나 추가해야 하는 일이 없어집니다.
  • 또한, 알람 관련 객체는 무조건 인터페이스인 Alarm에 의존하기 때문에 Alarm을 Mock 객체로 만들어 다양한 시나리오로 AlarmService 기능을 온전히 테스트할 수 있다는 장점도 가져갈 수 있습니다.