개발자 포포

[OOP] SOLID를 만족한다는 것

popo.se 2024. 9. 17. 10:33

이 글은 과거에 운영하던 블로그에서 옮겨온 글 입니다. (2021.11.20. 작성됨)


객체지향 설계의 5원칙 - SOLID

  • SRP(Single Responsibility Principle) 단일 책임 원칙하나의 클래스는 하나의 책임만 가져야 한다.
  • OCP(Open-Closed Principle) 개방/폐쇄의 원칙하나의 역할 단위는 확장에는 열려 있어야 하고 변경에 대해서는 닫혀 있어야 한다.
  • LSP(Liskov Substitution Principle) 리스코프 치환 원칙상위 클래스는 하위 클래스로 치환될 수 있어야 한다.
  • ISP(Interface Segregtion Principle) 인터페이스 분리 원칙클라이언트는 사용하지 않는 매서드에 의존하지 않아야 한다.
  • DIP(Dependency Inversion Principle) 의존관계 역전 원칙상위 계층 모듈은 하위 계층 모듈의 변화에 영향을 받아선 안된다. 이를 위해,1)상위 계층은 하위 계층의 구체화에 의존하면 안되고 추상화에 의존해야 한다.2)추상화된 것은 구체화에 의존하면 안된다. 구체화된 것이 추상화에 의존해야 한다.

SOLID를 만족한다는 것

비즈니스는 사용자에게 최적의 서비스를 제공하기 위해 계속해서 변화하고 확장하는 특성이 있습니다.

이러한 특성으로 개발자는 변화하는 비즈니스를 안정적으로 서비스 하기 위해 시스템을 유연하게 확장하고 영향도를 최소화하는 것을 목표로 시스템을 설계해야 합니다.

 

SOLID 원칙은 바로 이를 실현하기 위해 정의되었습니다.각각의 원칙은 서로 상호 보완적이고 재사용과 유지보수라는 공통의 목표를 가집니다. 그런데 실제로 각 원칙을 만족하는지는 어떤 기준으로 판단할 수 있을까요? 그리고 만족하기 위해선 어떻게 설계해야 할까요?

이에 대한 고민으로 저는 SOLID를 다음과 같이 해석해보았습니다.

SRP의 한 가지 책임

SRP는 “책임”이라는 단위로 모듈(메서드, 클래스, 패키지)을 분리합니다. 그리고 특정 “책임”과 관련한 변화가 생기면 해당 “책임”을 구현한 모듈에 한하여 변경이 발생 하는 것을 목적으로 합니다.

 

그렇다면 “하나의 책임”을 가졌다는 것은 어떻게 판단할 수 있을까요?

 

“책임”은 하나의 변경단위를 가지고 있는 비즈니스적 경계라고 생각해볼 수 있습니다. 

변경단위는 특정 한 액터(Actor. 사용자가 특정 Role을 수행할 때 Actor라고 부름)의 요구사항 변경이 발생할 수 있는 단위를 말합니다. 동일한 액터로 변경되는 것들을 동일 모듈에, 서로 다른 이유로 변경되어야 하는 것들 다른 모듈에 존재해야합니다. 하나의 단위 모듈이 여러 개의 액터를 처리하게 된다면, 이 모듈에는 여러개의 변경단위가 존재하고 있음을 의미합니다.

 

예를 들어, 사용자가 입력한 내용을 바탕으로 게임 등록을 처리하는 클래스가, 아래와 같은 일을 한다고 가정해봅시다.

  1. 사용자가 입력하는 게임 이름에 유효값 체크 정규식 로직 추가 -> 게임 객체 관리
  2. DB를 Oracle에서 Mysql로 변경 -> DB에 게임 정보 등록
  3. 사용자에게 알림을 push가 아닌 문자로 발송하도록 변경 -> 정상 입력을 사용자에게 push

하나의 클래스에서 위와 같은 세개의 역할을 함께 하게되면 세 개의 독립적인 변경 단위가 존재한다는 것을 의미합니다. 위와 같이 동일 모듈에 독립적인 변경 단위가 함께 있는 경우, 하나의 변경점만 가질 수 있도록 모듈을 분리해야 합니다.

OCP는 지속적으로 변화해 나가는 것

OCP를 지키기 위해서는 추상화와 다형성을 고려하여 설계해야 합니다.

  • 확장이 필요한 행위를 추상화하여 느슨한 결합으로 만들어야 합니다.
  • 기존 소스코드를 수정하는 일 없이 새로운 코드를 추가함으로써 새로운 기능을 추가하도록 시스템을 만들어야 합니다.
  • A가 B를 참조하는 경우, A 모듈 수정 없이 B를 확장할 수 있어야 합니다.

OCP를 처음부터 완벽히 지키기는 어렵습니다.

 

확장을 위해 모든 것을 interface로 추상화 하기에는 비용적, 시간적인 현실적인 문제들이 있기 때문입니다. 또한 미래를 예측할 수 없기 때문에 어떤 모듈이 어떻게 확장될 수 있을지는 개발단계에서 판단하기가 어렵습니다.

그렇기 때문에 처음부터 완벽한 설계를 한다고 생각하기보다는, 지속적으로 OCP를 지키기 위해 노력하고 리팩토링 하는 방법이 더 현실적일 수 있다 생각합니다.

LSP는 꼼꼼히 따져보아야 한다

LSP는 객체지향의 근간이 되는 개념입니다.

객체지향의 상속, 추상화 설계가 LSP 성립 시에 가능하기 때문입니다. LSP를 잘 지켰는가는 객체지향적으로 "상속" "추상화를" 잘 지켰는가에 달렸습니다.

 

이를 위해 아래 LSP 정의를 준수하여 설계하여야 합니다.

 

시그니처 표준

  • 하위 타입 매소드 파라미터의 반공변성
  • 하위타입 매소드 리턴 타입의 공변성
  • 하위 타입에서는 새로운 예외를 발생할 수 없다.(상위 타입에서 발생시킨 예외의 하위 타입인 경우를 제외하고)

행동 조건

  • 하위 타입에선 선행조건이 강화될 수 없다
  • 하위 타입에선 후행 조건이 약화될 수 없다
  • 상위형의 불변조건은 반드시 유지되어야한다.

위 정의를 LSP 원칙이 적용이 필요한 곳은 상속과 추상화 개념이 적용되는 1)인터페이스-구체화 클래스 관계 2)상위클래스-하위클래스 관계입니다. 상속과 추상화 개념을 적용하였을때 LSP 원칙에 위배된다면, 상위타입-하위타입 관계가 적합하지 않을 수 있습니다.

이런 경우엔 컴포지션 방식으로 문제를 다르게 풀어야 할 수 있습니다.

 

예를들어, 흔히 상위타입-하위 타입이라 생각할 수 있는 "직사각형"-"정사각형" 관계를 상속관계로 표현해 봅시다.

// 직사각형 클래스public class Rectangle {
    private int width;
    private int height;

    public Rectangle(int width, int height) {
        this.width = width;
        this.height = height;
    }

    public void setWidth(int width) {
        this.width = width;
    }

    public void setHeight(int height) {
        this.height = height;
    }

    public int getWidth() {
        return width;
    }

    public int getHeight() {
        return height;
    }

    public int getArea(){
        return width*height;
    }
}

// 정사각형 클래스public class Square extends Rectangle{

 public Square(int width, int height) {
        super(width, height);
    }
  ...
}

...
public static void main(String[] args){
      int width=5;
        int height=3;
        Rectangle rectangle = new Rectangle(width,height);
        Rectangle square = new Square(width,height);
}

우리는 "모든 정사각형은 직사각형이다"는 명제를 배웠기 때문에 Sqaure는 당연히 Retangle의 하위 타입이라 생각 할 수 있습니다.

 

하지만 생성자를 호출할 때 부터 이상함이 보입니다.

다음과 같이 선언하면, "정사각형은 모든 변의 길이가 같다"라는 객체의 특성에 위배됩니다.

Rectangle square = new Square(width,height);

객체의 특성에 위배되지 않기 위해서는 아래와같이 체크 로직이 필요하게 됩니다.

public Square(int width, int height) {
      validaeIsSameLength(width,height);
        super(width, height);
    }

하지만 이는 LSP의 원칙중 "선행조건은 강화될수 없다"하는 규약과 상충됩니다.

 

그렇다면 Squre는 width와 height를 받을 것이 아니고 한 변의 길이만으로 객체를 생성하면 될 것 같습니다.

public Square(int side) {
        super(side,side);
}
...
int side=5;
Rectangle square = new Square(5);

이렇게 하면 객체 생성에는 문제가 없어보입니다.

 

하지만 정말 문제가 없는 걸까요?

상속받은 하위 타입은 상위 타입 기능과 속성을 모두 제공하면서 확장되어야 하는 개념인데 위와 같은 설계는 상위 타입의 특성을 완벽히 구현해내지 못하면서 가로 세로 길이가 다를수 있다는 속성을 제한하고 있습니다. 하위타입이 상위타입의 특성을 완전히 대체하지 못하는 것으로 보입니다.

 

이번에는 Rectangle의 width와 height을 변경하는 기능을 추가해봅시다.

public class Rectangle {
    private int width;
    private int height;

...
public void setWidth(int width) {
    this.width = width;
}

public void setHeight(int height) {
    this.height = height;
}

Square가 이것을 상속받으면 다음과 같은 일이 벌어집니다.

Rectangle rectangle = new Rectangle(5,3);
Rectangle square = new Square(5);
rectangle.setWidth(7);// -> width:7 , height:3
square.setWidth(7);// -> width:7 , height:5

Square가 다시 정사각형이란 정의를 벗어나게 되어버렸습니다.

그럼 아래와 같이 setWidth와 setHeight을 오버라이딩 한다면 어떨까요?

// 정사각형 클래스public class Square extends Rectangle{

  ...
  @Override
    public void setWidth(int width) {
        super.setWidth(width);
        super.setHeight(width);
    }

    @Override
    public void setHeight(int height) {
        super.setHeight(height);
        super.setWidth(height);
    }

}

괜찮은 방법인 것 같지만 여기에는 세가지 문제가 있습니다.

 

첫번째로, 상위 타입을 대체하지 못하게 됩니다.

하위타입에서 setWidth를 오버라이딩하며 height까지 변경하도록 함으로써, 상위타입과는 전혀 다르게 동작하게 됩니다.

 

두번째로, SRP를 어기게 됩니다.

setWidth와 setHeight는 별개의 책임인데도 각 매소드에서 두가지 일을 동시에 하려 합니다.

 

세번째로, OCP를 어기게 됩니다.

극단적인 예로, 상위타입에서 height의 타입이 String으로 변경되었다고 가정해봅시다. Square는 Rectangle에서 정의한 setWidth의 변경 단위가 아니지만 setWidth에서 setHeight를 호출하는 부분을 수정해야만 하게 됩니다.

 

위와 같은 문제들로 Rectangle과 Square는 상속 관계로 설계하는 것이 적합하지 않다는 결론이 나오게 됩니다.

LSP 원칙이 성립하는지는 위와 같이 생각보다 따져볼 것이 많습니다. 단순히 "is a" 관계로 표현된다고 하여 LSP 원칙이 성립되지 않기 때문입니다.

LSP 원칙을 기반으로 하위타입이 상위타입을 확장하여 상위타입을 완전히 대체할 수 있는 지에 대해 판단하여야 합니다.

ISP는 어떻게 지킬 수 있을까?

ISP는 SRP 개념과도 연관 되어있습니다.

ISP를 지킬 수 있는 방법은 사용하지 않는 인터페이스에 의존하지 않는 것입니다. 너무나 당연한 명제처럼 보이고, 사용하지 않는 인터페이스에 의존할 일이 있을까? 생각할 수 있습니다. ISP가 지켜지지 않는다는 것은 하나의 인터페이스가 여러개의 변경단위를 가지지 않는 것을 뜻합니다. 인터페이스에 변경단위가 여러개 존재하고 이에따라 Actor가 여러개가 존재하게 된다면, 각각의 Actor는 해당 Actor와 관련없는 다른 변경단위에도 의존하게 됩니다. 이는 변경이 발생하면 관련이 없는 Actor에게도 영향이 미쳐 수정량의 증가, 독립적인 개발/배포가 불가, 잦은 재컴파일,배포 등의 결과를 가져옵니다.

 

ISP를 지키기 위해서는 인터페이스가 하나의 변경단위만 가지도록 SRP를 준수하는 것이 선행되어야 합니다.

DIP는 어떻게 하면 될까?

스프링 IoC(ID 컨테이너)는 DIP를 잘 지켜 설계 효율을 극대화한 예 입니다.

상위 레벨의 모듈이 하위 레벨의 구체화된 모듈을 참조하지 않도록 하는 것이 중요합니다. 이를 위해서는 구체화된 모듈의 추상화가 필요합니다. 상위 레벨 모듈은 추상화된 인터페이스를 참조하도록 하고 실제 구현 객체는 런타임에 외부에서 전달해주도록 합니다.

이렇게 구현하면 상위 레벨 모듈은 능동적으로 특정 구현 객체를 참조하지 않고, 외부로부터 의존을 주입받아 수동적인 참조가 가능해집니다.

 

[참고]

wiki - SOLID

클린 코더스 강