Clean-Architecutre #3부 - CLASS 구조 잘짜기 SOLID법칙

Clean-Architecutre #3부 - CLASS 구조 잘짜기 SOLID법칙

SOLID 원칙

07. SRP: 단일 책임 원칙

하나의 함수는 하나의 역할만 가진다로 착각하기 쉽다. 이는 더 저수준의 법칙이다. SRP는 아래와 같다.

하나의 모듈은 오직 하나의 액터에 대해서만 책임져야 한다.

액터 란 무엇인가? 특정 모듈을 사용하여 Update,Read의 행위를 하는 사용자를 말한다.
SRP의 위반을 보면서 반례로 확인해보자.

🌟 징후 1 여러 액터의 중복 사용

여러 액터 가 한 모듈에 접근하여 사용한다면, SRP를 위반한다.

Employee 클래스
+calculatePay - cfo 보고용 메서드
+reportHours - coo 보고용 메서드
+save - cto 보고용 메서드

한 Employee 클래스에 cfo, coo, cto 라는 세 사용 주체가 접근하여 사용한다.
이는 Employee 클래스의 메서드 내용 변경이 발생하면, cfo, coo, cto 모두 영향을 받는다.

🌟 징후 2 병합

서로 다른 목적을 가진 개발자가 한 모듈에 접근하여 수정을 한다면 어떨까?
즉, cfo를 위한 calculatePay를 수정하는 개발자와 coo를 위한 reportHours를 수정하는 개발자가 한 모듈에 접근하여 수정을 한다면 어떨까?
소스코드 변경사항이 충돌하여 병합해야하는 상황이 온다. cfo,coo,cto 사용자들이 어떤 변경이 있는지 서로 영향력을 고민하며 벌벌떨 수 밖에 없다.
-> 이는 모듈의 변경 이유가 하나가 아니기 때문이다.

🌟 해결책

  1. Employee의 Data만 분리하여 빼내고 액터별로 3개의 클래스를 만든다.

CFO용 클래스
+calculatePay - cfo 보고용 메서드   |    
                                    
COO용 클래스
+reportHours - coo 보고용 메서드    |     ------>          Employee 공통 Data 클래스 

CTO용 클래스
+save - cto 보고용 메서드           |

이로써 액터별로 클래스가 생성되어 수정시에 서로의 기능에 영향이 전혀 가지 않는다.
그리고 수정의 목적도 단일하여 서로 충돌나서 병합시 문제가 생기지 않는다.
그런데 3가지 분기된 클래스의 관리가 어려워진다. 이를 해결하기 퍼사드 패턴이 생겼다.

  1. 퍼사드 패턴을 사용하여 Employee 클래스를 하나로 유지하면서 액터별로 메서드를 분리한다.
                                      +-------------------+
                                      |    CFO용 클래스     |
                                      | - calculatePay()  |
                                      +-------------------+
                                                 |
                                                 |
+---------------------+             +-------------------+
| Employee 퍼사드 클래스 |---------->|    COO용 클래스         | 
| + calculatePay()     |             | - reportHours()   |
| + reportHours()      |             +-------------------+
| + save()             |
+---------------------+                          |
                                                 |
                                      +-------------------+
                                      |    CTO용 클래스     |
                                      | - save()          |
                                      +-------------------+
                                               
                                                 
                                                
                                             공통 참조
                                      +-------------------+
                                      | Employee 공통 데이터 |
                                      +-------------------+


Facade 클래스는 종합적으로 액터별로 메서드를 호출한다.
Employee 공통 데이터를 Facade 내부에 넣는 경우도 있다.

class EmployeeFacade {
  private name;
  private positon;

  constructor(name, position) {
    this.name = name;
    this.positon = position;
  }

  calculatePay() {
    return new PayCalculator().caculatePay();
  }

   reportHours() {
     return new HourReporter().reportHours();
  }

  save() {
    return new EmployeeSaver().save();
  }
}

08. OCP: 개방-폐쇄 원칙

소프트웨어 개체는 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다.

한마디로 변경하기 쉬워야 한다. 어떻게?

기본적으로 다른 목적으로 변경이 발생하는 SRP를 지키는 게 설계한다.
그리고 의존성 역전을 통해 변경에 대해 닫혀있게 만든다.

  • 확장에는 열려 있어야 한다.
    • Service에서 Repository를 사용한다. 이때 Repository를 구현한 클래스가 아닌 인터페이스를 사용한다.(의존성 역전)
    • Service에서는 해당 Interface를 사용해서 실제 Repository가 무엇인지 알 필요가 없다.
    • 저수준에서 Jpa던 Mybatis던 Service가 사용하고 있는 고수준 Repository Interface를 구현하여 의존해 사용한다.
    • Service는 원하는 기능을 메서드 수정을 해도 되고 따로 추가하여 Interface에 서술하기만 하면 된다.
  • 변경에는 닫혀 있어야 한다.
    • 특정 컴포넌트 변경에 대해 보호하려면 변경되는 컴포넌트가 보호하려는 컴포넌트를 의존해야 한다.
    • 구현체가 아무리 메서드 내용을 바꿔도 Return만 Interface에 맞춰주면 되기 때문에 Service는 구현체에 휘둘리지 않는다. 도구로써 사용할뿐!
    • ex) Service -> Repository(Interface) <- (의존성 역전) JpaRepositoryImpl or MybatisRepositoryImpl

🌟 정보 은닉

의존성에 대한 방향을 제외하고 컴포넌트끼리의 정보 접근을 제한하기 위해 클래스를 사용하는 경우도 있다.
클래스 A가 클래스 B에 의존하고, 클래스 B가 클래스 C에 의존할 때, 클래스 A는 직접적으로 클래스 C를 사용하지 않더라도 클래스 C에 의존하게 된다. 이를 추이종속성이라고 한다.
컴포넌트별로 내부 참조를 많이 하지 않기 위해 변수의 제한을 두는 클래스이다.

🌟 결론

시스템을 컴포넌트 단위로 분리하고, 저수준 컴포넌트에서 발생한 변경으로부터 고수준 컴포넌트를 보호할 수 있는 형태의 의존성 계층구조여야 한다.


09. LSP: 리스코프 치환 원칙

상위 타입의 객체를 하위 타입의 객체로 치환해도 상위 타입을 사용하는 프로그램은 정상적으로 동작해야 한다.

상위 타입인 fruit이 있고 그 하위타입인 apple,grape 등등이 있다면, fruit의 자리에는 그 모든 하위타입이 들어가도 동일한 기능을 한다.

public int eat(final Fruit fruit) {
    return fruit.getCalories();
}

Fruit 자리에 다른 과일인 하위타입이 들어와도 칼로리는 구해진다. 

초창기에는 상속 으로만 권장되어 왔는데 발전하면서 추상체 Interface또한 같은 원리가 적용되는 것으로 발전했다.
고수준의 정의대로 저수준을 잘 구현해야 추후에 플러그인 식으로 교체 혹은 확장이 가능하고, 소스코드에 예외적인 if문들이 들어가지 않는다.
간단한 치환 약속이 어겨지면 확장 하나에도 무수히 많은 예외들이 고려되어야 한다.
LSP는 OCP를 만족시켜 변화에 더 유연하기 위한 제약이라고 생각이 된다.


10. ISP: 인터페이스 분리 원칙

클라이언트는 딱 자신이 필요한 최소한의 기능에 대한 의존성만 가져야 한다.
이를 위해서는 Interface가 구체적으로 분리되어서 각각 사용할 클라이언트마다 최소한의 의존을 하는게 좋다.

  • ISP를 적용하기 전
interface MultiFunctionPrinter {
    void print();
    void scan();
    void fax();
}

class PrinterClient {
    MultiFunctionPrinter printer;

    public PrinterClient(MultiFunctionPrinter printer) {
        this.printer = printer;
    }

    public void executePrint() {
        printer.print();
    }
}

  • ISP를 적용한 후

interface Printer {
    void print();
}

interface Scanner {
    void scan();
}

interface Fax {
    void fax();
}

class SimplePrinter implements Printer {
    public void print() {
        System.out.println("Printing...");
    }
}

class PrinterClient {
    Printer printer;

    public PrinterClient(Printer printer) {
        this.printer = printer;
    }

    public void executePrint() {
        printer.print();
    }
}


모듈 자체의 기능이 강하게 결합되지 않고 필요에 따라 조화롭게 사용할 수 있도록 분리되었다.


11. DIP: 의존성 역전 원칙

고수준 모듈은 저수준 모듈의 구현에 의존해서는 안 된다.
저수준 모듈이 고수준 모듈에서 정의한 추상 타입에 의존해야 한다.
즉, 자신보다 변하기 쉬운 것에 의존하지 마라

불변성이 보장된 컴포넌트나 클래스는 예외적으로 그냥 의존하여도 상관없다. ex) String
변동성이 큰 구체화된 컴포넌트나 클래스는 Interfaceabstract class에 의존해야 한다.

🌟 안정된 추상화

Interface는 상대적으로 기능에 대한 명세기 때문에 구현체보다 변화에 안정되어있다.
하물며 뛰어난 소프트웨어 설계자와 아키텍트는 Interface의 변동을 최소화 하여 구현체에 기능을 추가하려고 노력한다.

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

  1. 변동성이 큰 구체 클래스를 참조하지 말라 : 대신 추상 인터페이스를 참조하라.
  2. 변동성이 큰 구체 클래스로부터 파생하지 말라 : 상속은 신중하게 사용되어야 한다.
  3. 구체 함수를 오버라이드 하지 말라 : 구체 함수는 소스코드 의존성을 필요로 하므로, 의존성을 상속하게 된다. 차라리 추상함수로 선언하고, 구현체들에서 각자의 용도에 맞게 구현하라.
  4. 구체적이며 변동성이 크다면 절대로 그 이름을 언급하지 말라.

고수준 모듈인 Car는 겨울용 Tire, 여름용 Tire에 의존하지 않는다.
Car는 Tire의 종류가 아닌 추상화된 Tire Interface에 의존한다.
Tire의 종류는 Car에 주입되는 DI에 의해 결정된다.
저수준인 개별 Tire의 구현체들이 고수준인 Tire Interface를 의존하여 의존성 역전 법칙이다.
결과적으로 불변하는 Tire를 의존하는 Car를 불필요한 변화로부터 지키는 방법이다.


참고

이전에 작성했던 SOLID 구체적인 예시화 함께 보시면 더 좋을 것 같습니다.