SOLID 원칙

김준석·2020년 12월 16일
0

... five design principles intended to make software designs more understandable, flexible, and maintainable. - wikipedia

소프트웨어 설계를 이해하기 쉽고, 유연하며 유지보수가 용의하도록 하기위한 5가지 설계 원칙을 말한다. 5가지 원칙은 SRP(단일 책임 원칙), OCP(개방-폐쇄 원칙), LSP(리스코프 치환 원칙), ISP(인터페이스 분리 원칙), DIP(의존관계 역전 원칙), 그리고 SOLID는 5가지의 앞자를 딴 단어이다.

1. SRP(Single Responsibility Principle) - 단일 책임 원칙

한 클래스는 하나의 책임만 가져야 한다.

왜 한 클래스는 하나의 책임만 가져야 할까?

단적인 예로 설명하겠다. 보고서를 편집하고 출력하는 모듈이 있다고 생각해보자.

public class ReportModule {
    
    // ...

    public void run() {
        String data = compile();
        print(data);
    }

    public String compile() {
        // ...
    }

    public void print(String data) {
        // ...
    }
}

이 모듈은 단순히 실행만 하는 것뿐만 아니라 compile과 print의 책임도 가지고 있다. 이렇게 여러 책임을 가지고 있으면 어떠한 문제점이 있을까? 만약 compile()의 return 값이 String이 아닌 다른 객체가 나온다고 생각해보자. 그러면 print()도 수정이 되야할 것이다.

Martin defines a responsibility as a reason to change, and concludes that a class or module should have one, and only one, reason to be changed - wikipedia

Martin가 말한 "클래스 또는 모듈에는 변경해야 할 하나의 이유가 있어야한다"를 위반하고 있다. 따라서 단일 책임 원칙을 지키지 않고 있다.

그러면 어떻게 설계하면 단일 책임 원칙을 지킬 수 있을까?

public class ReportCompile {
    
    // ...

    public Compile compile() {
        // ...
    }
}

public class ReportPrint {
    
    // ...

    public void print(Compile data) {
        // ...
    }
}

public class ReportModule {
    
    // ...

    public void run() {
        Compile data = reportCompile.compile();
        reportPrint.print(data);
    }
}

Compile를 추상화하여 사용한다고 가정하자. 이전과 다르게 ReportCompile의 로직이 수정된다고 해도 ReportPrint의 로직이 바뀌지 않는다.

단일 책임 원칙을 지키기 위해 코드를 작성하게 되면 "하나의 책임"이라는 것이 모호할 때가 있다.

"중요한 기준은 변경이다. 변경이 있을 때 파급 효과가 적으면 단일 책임 원칙을 잘 따른 것" - 김영한(스프링 핵심 원리 - 기본편)

맞는 말이다. 5원칙이 나온 이유를 보면 소프트웨어 설계를 이해하기 쉽고, 유연하며 유지보수가 용의하도록 하기 위해서이다. 이를 생각할 때 변경에 대한 파급효과가 크다는 것은 유연하지 않고, 유지보수가 어렵다는 뜻이다. 이를 생각하며 코드를 작성한다면 SRP를 잘 지킬 수 있을 것이다.

2. OCP(Open-Closed Principle) - 개방-폐쇄 원칙

소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다.

이게 무슨 소리일까? User가 Car를 가지고 있다고 하자.

if ("K9".equals(car)) {
    // ...
}
if ("Avante".equals(car)) {
    // ...
}

User가 어떤 자동차를 탈지 모른다. car에 대한 조건문을 만들어서 책임을 부여하게 되면 자동차의 종류가 늘어날 때마다 코드를 수정하게 된다.

하지만 확장하려면 당연히 변경해야 하지 않나?🤔🤔

다형성을 활용해보자.

인터페이스를 사용하여 Car를 정의했다.

public class Main {
    public static void main(String[] args) {
        User user1 = new User(new K9());
        User user2 = new User(new Avante());
    }
}

public class User {

    private Car car;
    
    public User(Car car) {
        this.car = car;
    }
    
    // ...
}

이렇게 설계하면 자동차의 종류마다 해당하는 기능을 설계할 수 있고, 자동차의 종류가 늘어나도(확장) 기존코드의 수정은 일어나지 않는다. 이런식으로 OCP를 지키면 변경에 용이하고 유연하게 설계를 할 수 있다.

3. LSP(Liskov Substitution Principle) - 리스코프 치환 원칙

프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다.

🤔🤔🤔뭐지?

상위 타입의 Animal가 있다고 가정하자.

public void doSomething(Animal animal) {
    animal.crying();
}

현재 doSomething() 메서드는 상위 타입의 Animal 객체를 사용하고 있는데, 하위 타입 Dog 객체로 변경해도 정상적으로 작동해야 한다는 말이다.

public void doSomething(Dog animal) {
    animal.crying();
}

LSP가 제대로 지켜지지 않으면 다형성에 기반한 OCP 역시 지켜지지 않았다는 것이다. 따라서 LSP를 지키는 것은 중요하다.

당연하다고 생각할 수 있지만 이해하기 쉽게 LSP를 지키지 못한 예시를 들어보겠다. 직사각형 클래스가 있다고 가정하자.

public class Rectangle {

    protected int width;
    protected int height;

    public int getWidth() {
        return width;
    }

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

    public int getHeight() {
        return height;
    }

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

특수한 경우로 정사각형이 직사각형을 상속받는다고 가정하자. 정사각형은 너비, 높이가 똑같기 때문에 setWidth()과 setHeight()를 오버라이드하였다.

public class Square extends Rectangle {

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

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

여기까지는 문제가 없어 보인다. 여기서 직사각형의 비율을 맞춰주는 함수가 있다고 하면 어떨까?

public void increaseHeight(Rectangle rectangle) {
    rectangle.setHeight(rectangle.getWidth() * 2);
}

하지만 정사각형은 너비와 높이가 같기 때문에 정사각형일때 저 함수는 사용되면 안된다.

public void increaseHeight(Rectangle rectangle) {
    if (rectangle instanceof Square) {
        throw new NotSupportedException();
    }
    rectangle.setHeight(rectangle.getWidth() * 2);
}

이렇게 되면 하위 타입(Square)을 사용할 때 정상적으로 작동하지 않는다. 또한, increaseHeight() 메서드가 확장에 열려있지 않다.

또 다른 예시를 보자. InputStream를 사용한다고 가정하자.

// InputStream의 read() 메서드는 스트림 끝에 도달하면 -1를 리턴한다.
while (inputStream.read(data) != -1) {
	// ...
}

만약 InputStream을 상속받고 있는 MyInputStream가 있다고 가정하자. MyInputStream의 read() 메서드는 스트림 끝에 도달하면 0을 리턴한다고 하자. 그럴 때 상위 타입(InputStream)에서 하위 타입(MyInputStream)으로 사용될 때 어떠한 문제점이 있을까? while문은 무한루프를 돌 것이다.

이제까지 예시처럼 흔히 발생하는 위반 사례로 다음과 같이 있다.

  • 명시된 명세에서 벗어난 값을 리턴한다.
  • 명시된 명세에서 벗어난 익셉션을 발생한다.
  • 명시된 명세에서 벗어난 기능을 수행한다.

하위 타입은 상위 타입에서 정의한 명세를 벗어나지 않는 범위에서 구현해야 한다. - 개발자가 반드시 정복해야 할 객체 지향과 디자인 패턴(최범균)

4. ISP(Interface Segregation Principle) - 인터페이스 분리 원칙

특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다.

단일 책임 원칙과 유사하게 인터페이스도 구체적이고 작은 단위들로 분리시키는 것이다. OCP에서 사용한 인터페이스 Car로 예를 들어보자.

Car에는 주차, 후진, 중립, 운전이 있을 것이다. 이는 SRP과 연관지를 수 있다. 하나에 여러가지 기능이 섞여 있을 경우 한 기능의 변화로 인해 다른 기능이 영향을 받을 가능성이 높아진다. 따라서 각 클라이언트에 의해 인터페이스의 변경이 발생하더라도 영향을 받지 않도록 만들어야 한다.

ISP를 지킨다면 이렇게 수정될 것이다.

인터페이스 Car가 가지고 있는 역할을 각 인터페이스 Parking, Reverse, Neutral, Drive로 분리하였다. 이러면 Drive에 대한 변경 요청이 있을 경우 인터페이스 Drive만 영향이 미칠 것이고, 다른 인터페이스에는 미치지 않을 것이다.

각 클라이언트가 사용하는 기능을 중심으로 인터페이스를 분리함으로써, 클라이언트로부터 발생하는 인터페이스 변경의 여파가 다른 클라이언트에 미치는 영향을 최소화할 수 있게 된다. 또한, 분리가 잘 된 인터페이스는 재사용성을 높여 주는 효과도 갖는다.

5. DIP(Dependency Inversion Principle) - 의존관계 역전 원칙

프로그래머는 추상화의 의존해야지, 구체화에 의존하면 안된다. 의존성 주입은 이 원칙을 따르는 방법 중 하나다.

OCP의 예시로 쉽게 말하면 인터페이스 Car에 의존해야지, 구현체인 K9이나 Avante에 의존하면 안 된다는 말이다. 왜 추상화에 의존해야할까?

만약 User가 구현체 K9에 의존한다고 해보자. 추가 요구사항으로 User는 다양한 자동차 종류를 가질 수 있고, 자동차 종류가 추가되었다고 한다. 그러면 User는 K9을 의존하고 있기 때문에 다른 자동차로 유연하게 변경이 어렵다.

OCP에서 사용한 방식처럼 인터페이스를 활용하면 이 원칙을 만족할 수 있다. 변경에 대한 유연함을 가지기 위해 필요한 원칙이다.

profile
내 몸에는 꼰대의 피가 흐른다.

0개의 댓글