개발 공부/SOLID 원칙

SOLID 원칙 과 컴포넌트 설계 원칙 ①

수달하나 2024. 5. 9. 01:02

소스코드 레벨의 설계 원칙

좋은 소프트웨어를 설계하기 위해서 실천해야 하는 원칙들을 SOLID 원칙이라고 한다.

좋은 벽돌로 좋은 아키텍처를 정의하는 것으로 비유하곤 한다.

 

변경에 유연하고 이해하기 쉬우며 많은 소프트웨어 시스템에 사용될 수 있는 컴포넌트의 기반이 되는 많이 원칙들이 존재했고 시간이 지나면서 원칙들이 교체되거나 변경되었다.

2000년대 초반에 오면서 안정화된 최종 버전이 나타났고 각 원칙의 첫 번째 글자들로 SOLID 라는 단어를 만들어 SOLID 원칙이 탄생했다.

 

SRP : 단일 책임 원칙 Single Responsibility Principle

단일 책임 원칙은 프로그램의 작은 수행단위를 모듈이라 하면 모듈은 단 하나의 일만 해야 한다는 의미다. 

하지만 위와 같은 설명은 단일 책임 원칙의 핵심적인 키 포인트를 놓칠 수 있다.

 

SRP의 진짜 원칙은 "하나의 모듈은 오직 하나의 사용주체 혹은 이해관계자에 대해서 하나의 책임을 져야 한다" 라는 것이다.

하나의 사용주체와 이해관계자가 난해하다면 집단, 혹은 해당 변경을 요청하는 하나 이상의 것을 가리킨다고 생각해도 좋다.

위와 같은 구조는 SRP 를 위반하는 구조이다. Employee 는 세 가지의 메서드를 가지고 있고 이 세 가지의 메서드는 서로다른 객체를 책임지기 때문이다. 예를 들어 calculatePay 메서드와 reportHours 메서드가 각각의 객체에서 호출되어 사용할 때 제 3의 메서드인 regularHours 를 호출한다고 가정해보자.

위와 같은 형태는 하나의 객체가 regularHours 메서드의 변경을 요구할 때 나머지 객체의 다른 메서드도 regularHours를 호출하기 때문에 다른 객체로부터의 메서드 변경에 대한 안정성을 보장 받지 못한다. 따라서 여러 객체에 대한 책임이 중복되어 존재하기 때문에 위와같은 구조는 SRP 를 위반하는 구조라고 얘기할 수 있다.

 

결과적으로 메서드를 각기 다른 클래스로 이동 시키는 방식을 통해서 이러한 문제를 해결 할 수 있다.

위와 같은 방식을 통해 각각의 객체들은 서로다른 책임을 가진 메서드를 호출하며 단일 책임을 갖도록 할 수 있다.

물론 각각의 객체를 생성해서 인스턴스화 하고 추적해야 한다는 단점이 있지만 퍼샤드 패턴을 통하여 위와 같은 문제는 해결 할 수 있다.

퍼샤드 클래스가 세 클래스의 객체를 생성하고, 요청된 메서드를 가지는 객체로 위임하는 일을 책임진다면 각각의 객체를 추적해야 하는 상황을 피할 수 있다. 

물론 위와같은 상황은 퍼샤드 클래스가 다른 클래스들의 메서드를 호출하기 위한 객체로 존재한다는 것이 논란거리가 될 수 있지만 적어도 SRP 원칙을 해결하는 것에 있어서 문제는 발생하지 않는다.

 

OCP : 개방 폐쇄 원칙 Open-Closed Principle

개방 폐쇄 원칙의 기본은 소프트웨어의 개체는 확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다는 것이다.

이런 원칙은 클래스 모듈 설계 부분에서도 해당 되지만 아키텍처 컴포넌트 수준에서 OCP 를 고려할 때 훨씬 더 중요한 의미를 가지게 된다.

 

소프트웨어 아키텍처가 훌륭하다면 변경되는 코드의 양이 최소이다.

위는 서로다른 목적으로 변경되는 요소를 적절하게 분리하고(SRP : 단일 책임 원칙), 이들 요소 사이의 의존성을 체계화 함(DIP : 의존성 역전 법칙)으로써 변경량을 최소화 할 수 있다. 

위와 같이 보고서의 생성이 웹 표시를 위한 책임과 프린터 출력을 위한 책임, 두개의 책임으로 분리 된다고 가정 할 때, 두 책임 중 하나에서 변경이 발생하더라도 다른 책임에 변경이 발생하지 않도록 소스 코드의 의존성도 확실히 조직화 해야 한다.

이러한 목적을 달성하기 위해 처리 과정을 클래스로 분할하고 컴포넌트 단위로 구분하는 과정을 거쳐야 한다.

화살표의 방향은 변경으로부터 보호하려는 컴포넌트를 향하도록 그려지며 구분된 관계는 항상 단방향으로 이루어진다.

위와 같은 설계는 Presenter 에서 발생한 변경으로부터 Controller 를 보호할 수 있으며 View 에서 발생한 변경으로 부터 Presenter 를 보호 할 수 있다. 

마찬가자로 Controller 와 Presenter, View 그리고 Database 에서 발생한 어떠한 변경도 Interator 에 영향을 주지 않는다는 것을 의미한다. 

Iterator 가 모든 변경으로부터 영향을 받지 않는 특별한 위치를 차지해야 하는 이유는 해당 어플리케이션에서 가장 높은 수준의 정책을 Iterator 가 포함하도록 설계하기 위함이다.

즉 Iterator 는 비지니스 로직의 핵심을 포함하고 있다는 것이다.

 

기능이 어떻게, 언제, 왜 발생하는지에 따라서 분류하고 분리한 기능을 컴포넌트의 계층구조로 조직화 함으로써 저수준의 컴포넌트에서 발생한 변경으로부터 고수준의 컴포넌트를 보호 할 수 있는 아키텍처를 설계하는 것이 아키텍처 수준에서의 OCP가 동작하는 방식이다.

 

LSP : 리스코프 치환 원칙 Liskov Substitution Principle

리스코프 치환 원칙은 S 타입의 객체 o1, T 타입의 객체 o2 가 있다고 할 때 T타입을 이용해서 정의한 모든 프로그램 P에 o2 자리를 대신하여 o1 객체를 사용하더라도 P 의 행위가 변하지 않는다면 S 는 T 의 타입이라는 것을 의미하고 T 타입을 S 타입으로 치환해도 된다는 것을 의미한다.

 

이 원칙또한 어떻게 하면 원칙을 지킬 수 있으면서 설계할 지 보다 어떻게 하면 원칙을 위반하지 않는지에 대한 부분을 생각해보는것이 더 효율적이다.

 

LSP 를 위한하는 가장 대표적인 예제는 정사각형/직사각형 문제다.

위와 같은 상황에서 Square 는 Rectangle 을 상속받아서 사용하고 있는데 이렇게 상속을 하게 되면 Square가 가지고 있는 모든 변의 길이가 동일하다는 조건을 항상 만족시키기 어렵다. 따라서 각 클래스의 속성을 정확히 파악하지 않은채 상속을 하게 되면 위험성이 발생한다.

위와 같은 문제를 해결하기 if 문을 추가 하는 방식으로 해결 할 수 있겠지만 이러만 방법은 클래스적인 관점에서도 아키텍처의 관점에서도 위험한 발상이기 때문에 애초에 다른 클래스로 구분하는것이 옳다.

예를들어 인터페이스를 하나 구현하고 그 인터페이스를 상속받는 여러 구체 클래스들을 구성하는 방식이 훨씬 더 좋은 방법이다.

 

ISP : 인터페이스 분리 원칙 Interface Segregarion Principle

User1, User2, User3 이 각각 OPS 클래스의 op1, op2, op3 메서드를 각각 사용한다고 했을 때 User1은 op2 와 op3 메서드의 사용 여부와 무관하게 OPS에 의존하게 된다.

이러한 의존성으로 인해서 op2와 op3 가 변경 될 경우 User1도 다시 컴파일 후 새로 배포해야 한다.

의존하지 않는 부분을 분리하기 위해서 U1Ops 와 U2Ops, U3Ops 인터페이스를 통해 각 유저에 의존성을 주입한다면 의존하지 않는 소스코드 변경과 컴파일에 독립적으로 대처할 수 있다.

 

클래스 관점을 넘어서 정적 타입의 언어적인 관점에서 살펴봤을 때 import, use 혹은 include 와 같은 가입 선언문을 통하여 강제적으로 의존성이 발생한다면 재컴파일 혹은 재배포가 강제되는 상황이 무조건 발생하게 되는데 이것은 ISP 원칙이 단순히 클래스 레벨의 수준이 아닌 언어적문제 혹은 아키텍처 적인 문제로 확장해서 생각해볼 가치가 있다는것을 의미한다.

 

DIP : 의존성 역전 원칙 Dependency Injection Principle

의존성 역전 원칙은 소스 코드의 추상에 의존하고 구체에는 의존하지 않는 것을 통해 유연성이 극대화된 시스템을 설계하는 것을 목표로 하고 있다. 

 

추상 인터페이스에 변경이 생기면 이를 구체화 한 구현체들에도 수정이 발생해야 한다.

하지만 구현체에 변경이 발생했다고 해서 추상 인터페이스를 항상 변경해야 하는것은 아니다.

즉 인터페이스는 구현체보다 더 낮은 변동성을 가지고 있다.

 

우리는 구현체보다 변동성이 낮은거로 만족하지 못한 채 더욱 변동성이 낮은 인터페이스를 설계 하기 위해서 노력하고 이것은 곳 안정적인 아키텍처를 설계하는것에 있어서 매우 중요한 부분을 차지한다.

 

DIP 를 고려함에 있어서 다른 원칙들과 마찬가지로 해야 할 것 보다 하지 말아야 할 것을 생각하는것이 좀 더 도움이 된다.

  • 변동성이 큰 구체 클래스를 참조하는 것을 지양해야 한다.
  • 변동성이 큰 구체 클래스로부터 파생하는 것을 지양해야 한다.
  • 구체 함수를 오버라이드 하는것을 지양해야 한다.

위와 같은 규칙들을 준수하기 위해서 추상 팩토리 패턴을 많이 사용하게 된다.

Application 의 목표는 ConcreteImpl 인스턴스를 만드는 것 이다.

이때 Service 인터페이스를 통해 ConcreteImpl 를 구현하고 인스턴스의 생성은 ServiceFactory를 통해 진행한다.

public interface Service {
}

public class ConcreteImpl implements Service{
}

public interface ServiceFactory {
    public Service makeSvc();
}

public class ServiceFactoryImpl implements ServiceFactory {
    @Override
    public Service makeSvc(){
        return new ConcreteImpl();
    }
}

public class Application {
    ServiceFactory serviceFactory;
    public Service getService(){
        return serviceFactory.makeService();
    }
}

 

위와 같은 방식으로 구성하게 되면 추후에 Service의 다른 구현체가 생기더라도 ServiceFactory의 구현체를 만들어 주입시켜 사용하면 Application의 로직은 변경되지 않는다.

물론 실제로 구현체를 생성해야 하는 부분에서는 DIP 위배해야 하는 부분이 생기게 된다.

 

하지만 적어도 Application의 입장에서는 추상 인터페이스에만 의존하기 때문에 DIP 원칙을 지켰다고 할 수 있다.