7 ~ 11. 설계 원칙 (SOLID)

누구나 들어봤을 SOLID는 함수와 데이터 구조를 클래스로 배치하는 방법, 그리고 이들 클래스를 서로 결합하는 방법을 설명한다.

이때 클래스란 단어 때문에 혹자는 SOLID가 OOP에서만 가능한 원칙이라고 여길지 모른다.

여기서 클래스는 단순히 함수와 데이터를 결합한 집합을 가리킨다. 소프트웨어 시스템은 모두 이러한 집합을 포함하며, 이러한 집합이 클래스일수도 아닐 수도 있다.

SOLID원칙의 목적은 중간 수준의 소프트웨어 구조가 아래와 같도록 만드는 데 있다.

  • 변경에 유연하다.
  • 이해하기 쉽다.
  • 많은 소프트웨어 시스템에서 사용될 수 있는 컴포넌트의 기반이 된다.

7. SRP : 단일 책임 원칙

역사적으로 전해지는 SRP는 다음과 같다

단일 모듈은 변경의 이유가, 오직 하나뿐이어야 한다.

사실 이 원칙은 아래와 같이 바꿔 말할 수 있다.

하나의 모듈은 하나의, 오직 하나의 사용자 또는 이해 관계자에 대해서만 책임져야 한다.

여기서는 이런 의미보다는 집단, 즉, 해당 변경을 요청하는 그룹, 액터를 가리킨다.

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

우발적 중복

저자가 선호하는 사례는 급여 앱의 Employee 클래스다. 이 클래스는 세 가지 메서드 calculatePayreportHourssave를 가진다.

이 클래스는 SRP를 위반하는데, 이들 세 가지 메서드가 서로 매우 다른 세 명의 액터를 책임지기 때문이다.

  • calculatePay 메서드는 회계팀에서 기능을 정의하며, CFO 보고를 위해 사용한다.
  • reportHours 메서드는 인사팀에서 기능을 정의하고 사용하며, COO 보고를 위해 사용한다.
  • save 메서드는 DBA가 기능을 정의하고, CTO 보고를 위해 사용한다.

즉 해당 클래스는 한 팀에서 결정한 조치가 다른 팀이 의존하는 무언가에 영향을 줄 수 있는 상황이 되었다.

해결책

이 문제의 가장 확실한 해결책은 다양한데, 그 모두가 메서드를 각기 다른 클래스로 이동 시키는 방식이다.

아마 가장 확실한 해결책은 데이터와 메서드를 분리하는 방식이다. 즉, 아무런 메서드가 없는 간단한 데이터인 EmployeeData를 만들어 세 개의 클래스가 공유하도록 한다.

이렇게 하면 세 클래스는 서로의 존재를 모르게 되고, 우연한 중복을 피할 수 있다.

이 해결책의 단점은 세 가지 클래스를 유지 보수 해야한다는 사실이다

이러한 난관을 빠져나가기 위해 퍼사드 패턴을 사용할 수 있다.

🖋️

퍼사드 패턴은 여기 (opens in a new tab)를 참고하자

결론

단일 책임 원칙은 클래스 수준의 원칙이다. 하지만 이보다 상위 두 수준에서도 다시 등장한다.

8. OCP : 개방 - 폐쇠 원칙

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

다시 말해 소프트웨어 개체의 행위는 확장할 수 있어야 하지만, 이때 산출물을 변경해서는 안 된다.

소프트웨어 아키텍처를 공부하는 가장 근본적인 이유가 바로 이 때문이다. 만약 요구사항을 살짝 확장하는 데 소프트웨어를 엄청나게 수정해야 한다면, 그 소프트웨어 시스템을 설계한 아키텍트는 엄청난 실패에 맞닥뜨린 것이다.

다음 아키텍처를 보자

여기서 주목해야 할 점은 이중선은 화살표와 오직 한 방향으로만 교차한다는 사실이다. 이는 모든 컴포넌트 관계는 단방향으로 이뤄진다는 뜻이다. 이들 화살표는 변경으로부터 보호하려는 컴포넌트를 향하도록 그린다.

Interactor가 이처럼 특별한 위치를 차지해야만 하는가? 그 이유는 바로 Interactor가 업무 규칙을 포함하기 때문이다. Interactor는 앱에서 가장 높은 수준의 정책을 포함한다. Interactor 이외의 컴포넌트는 모두 주변적인 문제를 처리한다. 가장 중요한 문제는 Interactor가 담당한다.

이것이 바로 아키텍처 수준에서 OCP가 동작하는 방식이다. 아키텍트는 기능이 어떻게, 왜, 언제 방생하는지에 따라서 기능을 분리하고, 분리한 기능을 컴포넌트의 계층 구조로 조직화 한다.

컴포넌트 계층구조를 이와 같이 조직화하면 저수준 컴포넌트에서 발생한 변경으로 부터 고수준 컴포넌트를 보호할 수 있다.

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

여기에서 필요한 것은 다음과 같은 치환 원칙이다. S타입의 객체 o1 각각에 대응하는 T타입 객체 o2가 있고, T타입을 이용해서 정의한 모든 프로그램 P에서 o2의 자리에 o1을 치환하더라도 P의 행위가 변하지 않는다면, S는 T의 하위 타입이다.

이 설계는 LSP를 준수한다. Billing 앱의 행위가 License 하위 타입중 무엇을 사용하던지 전혀 의존하지 않기 때문이다. 이들 하위 타입은 모두 License 타입을 치환할 수 있다.

아키텍처 관점에서 LSP를 이해하는 최선의 방법은 이 원칙을 어겼을 때 시스템 아키텍처에서 무슨일이 일어나는지 관찰하는 것이다.

정사각형 / 직사각형 문제

LSP를 위반하는 전형적인 문제로는 정사각형 / 직사각형 문제가 있다.

이 예제에서 Square는 Rectangle의 하위 타입으로 적합하지 않는데, Rectangle의 높이와 너비는 서로 독립적으로 변경될 수 있는 반면, Square의 높이와 너비는 반드시 함께 변경되기 때문이다. 아래의 코드를 보면 그 이유가 분명해진다.

Rectangle r = ...
r.setW(5)
r.setH(2)
assert(r.area() == 10);

… 코드에서 Square를 생성한다면 assert문은 실패한다.

이런 형태의 LSP 위반을 막기 위한 유일한 방법은 분기를 통해 Rectangle이 실제로는 Square인지 검사하는 메커니즘을 추가하는 것이다. 하지만 이렇게 하면 User의 행위가 사용하는 타입에 의존하므로 결국 타입을 치환할 수 없게된다.

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

인터페이스 분리 원칙은 위 그림에서 유래했다. 다음 상황에서 다수의 사용자가 OPS 클래스의 오퍼레이션을 사용한다. User1은 오직 op1을 User2는 op2, User3는 op3만을 사용한다고 가정한다.

이러한 문제는 오퍼레이션을 인터페이스 단위로 분리하여 해결할 수 있다.

일반적으로, 필요 이상으로 많은 걸 포함하는 모듈에 의존하는 것은 해로운 일이다. 소스 코드 의존성의 경우 이는 분명한 사실인데, 불필요한 재컴파일과 재배포를 강제하기 때문이다. 하지만 더 고수준인 아키텍처 수준에도 마찬가지 상황이 발생한다.

11. DIP : 의존성 역전 원칙

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

DIP를 논할 때, 운영체제나 플랫폼 같이 안정성이 보장된 환경에 대해서는 무시하는 편이다. 우리는 이들 환경에 대한 의존성은 용납하는데, 변경되지 않는다면 의존할 수 있다는 사실을 이미 알고 있기 때문이다.

예를 들어 String 클래스는 매우 안정적이기 때문에, 엄격하게 통제되므로 의존해도 괜찮다.

안정적인 추상화

추상 인터페이스의 변경이 생기면 이를 구체화한 구현체도 따라서 수정해야한다. 반대로 구체적인 구현체에 변경이 발생해도 인터페이스는 항상, 좀 더 정확히 하자면 대다수의 경우 변경될 필요가 없다. 따라서 인터페이스는 구현체보다 변동성이 낮다.

  • 변동성이 큰 구체 클래스를 참조하지 말라. 대신 추상 인터페이스를 참조하라
  • 변동성이 큰 구체 클래스로부터 파생하지 말라. 정적 타입언어에서 상속은 소스코드에 존재하는 모든 관계중에서 가장 강력한 동시에 뻣뻣해서 변경하기 어렵다. 따라서 상속은 아주 신중하게 사용해야 한다. 동적 타입 언어라면 문제가 덜 되지만, 의존성을 가진다는 사실은 변함이 없다.
  • 구체 함수를 오버라이드 하지 말라. 대체로 구체 함수는 소스 코드 의존성을 필요로 한다. 이러한 의존성을 제거하려면, 차라리 추상 함수로 선언하고 구현체들에서 각자의 용도에 맞게 구현해야 한다.
  • 구체적이며 변동성이 크다면 절대로 그 이름을 언급하지 말라. 사실 이 실천법은 DIP원칙을 다른 방식으로 풀어 쓴 것이다.

팩토리

이 규칙들을 준수하려면 변동성이 큰 구체적인 객체는 특별히 주의해서 생성해야 한다. 이때 추상 팩토리를 사용하곤 한다.

  • 위 그림에서 곡선은 시스템을 구체적인 것들로 부터 추상적인 것들을 분리한다.
  • 소스 코드 의존성은 해당 곡선과 교차할 때 모두 한 방향, 즉 추상적인 쪽으로 향한다.
  • 곡선은 시스템을 추상 컴포넌트, 구체 컴포넌트로 분리한다.
    • 추상 컴포넌트는 애플리케이션의 모든 고수준 업무 규칙을 포함한다.
    • 구체 컴포넌트는 업무 규칙을 다루기 위해 필요한 세부사항을 포함한다.
  • 소스 코드 의존성은 제어흐름과는 반대 방향으로 역전된다. → 의존성 역전(Dependency Inversion)

구체 컴포넌트

그림의 구체 컴포넌트에는 구체적인 의존성이 하나(ServiceFactoryImpl 구체 클래스가 ConcreteImpl 구체 클래스에 의존한다.) 있고, 따라서 DIP에 위배된다. 이는 일반적인 일이다. DIP 위배를 모두 없앨 수는 없다. 하지만 DIP를 위배하는 클래스들은 적은 수의 구체 컴포넌트 내부로 모을 수 있고, 이를 통해 시스템의 나머지 부분과는 분리할 수 있다.

대다수의 시스템은 이러한 구체 컴포넌트를 최소한 하나는 포함할 것이다. 흔히 이 컴포넌트를 메인이라고 부른다.

결론

DIP는 아키텍처 다이어그램에서 가장 눈에 드러나는 원칙이 될 것이다. 그림의 곡선은 이후의 장에서는 아키텍처 경계가 될 것이다. 그리고 의존성은 이 곡선을 경계로, 더 추상적인 엔티티가 있는 쪽으로만 향한다. 추후 이규칙은 의존성 규칙이라 부를 것이다.