SOLID 원칙

728x90

SOLID가 뭐냐구요 ?

SOLID - 천생연분 (feat. 조한이형의 리즈시절)

 

 

네 그 '솔리드' 아닙니다. 

이제부터 자세히 알아봅시다! 

 

SOLID 원칙의 기원

객체지향 타입의 프로그래밍은 소프트웨어 개발의 새로운 설계를 불러왔다. 개발자가 데이터를 같은 목적/기능의 클래스로 묶을 수 있으며, 전체 애플리케이션과 상관없이 단일 목적을위해 이용 할 수 있다.

하지만, 객체지향프로그래밍은 유지보수가 어렵고, 코드가 혼란스러워지는 것 자체를 예방하지는 않는다.

이런 문제점을 보완하고자 하는 취지에서 Robert.C Martin은 다섯 가지 지침을 개발했다. 다섯 가지 원칙을 통해서 개발자는 읽기 쉽고 유지 보수가 쉬운 프로그램을 쉽게 만들 수 있게된다.

다음 다섯 가지 원칙S.O.L.I.D라고 한다. 각 알파벳의 의미는 아래와 같다.

  • S : Single Responsibility Principle (단일 책임 원칙)
  • O : Open-Closed Principle (개방 폐쇄 원칙)
  • L : Liskov Substitution Principle (리스코프 치환 원칙)
  • I : Interface Segregation Principle (인터페이스 분리 원칙)
  • D : Dependency Inversion Principle (의존성 역전 원칙)

각 원칙들에 대해서 설명과 예제를 정리해보자!

1. Single Responsibility Principle (SRP, 단일 책인 원칙)

: 클래스는 단 한개의 책임을 가져야 한다.

디오니소스 : 너 때문에 흥이 다 깨져버렸으니 '책임'져 !

디오니 소스 객체가 오르페우스 객체에게 흥을 돋구라는 메시지를 보내고 있다.

오르페우스 객체가 열심히 책임을 수행하고 있는 모습

우리 불쌍한 오르페우스 객체는 열심히 책임을 수행합니다.

책임의 개수가 많아 질수록 한 책임의 기능 변화가 다른 책임에 주는 영향은 비례해서 증가하게 되며, 이는 결국 코드를 절차 지향적으로 만들어 변경을 어렵게 만든다.
(이 원칙의 적용은 클래스에만 국한되지 않으며, 소프트웨어 컴포넌트와 마이크로 서비스에도 적용된다) 

또한 단일 책임 원칙을 어기면 재사용성이 떨어지게된다.

책임의 단위는 변화되는 부분과 관련이 있다. 각각의 책임은 서로 다른 이유로 변경되고, 서로 다른 비율로 변경되는 특징이 있다. 서로 다른 이유로 바뀌는 책임들이 한 클래스에 함께 포함되어 있다면 이 클래스는 단일 책임 원칙을 어기고 있다고 볼 수 있다.

단일 책임 원칙을 지키는지 확인할 때에는 사용자를 확인해본다. 클래스의 사용자들이 서로 다른 메서드를 사용한다면 그들 메서드는 각각 다른 책임에 속할 가능성이 높고, 따라서 책임 분리 후보가 될 수 있다.

class Animal {
    Animal a;
    String name;
    
    public Animal(String name) {
        this.name = name;
    }
    
    public String getAnimalName() {
        return name;
    }
    
    public void saveAnimal(Animal a) {
        this.a = a;
    }
}

예를 들어 위의 Animal 클래스는 SRP원칙을 위반했다. 
왜 SRP 원칙을 위반했을까?

클래스는 하나의 책임을 가져야 한다고 했다. 여기서 우리는 두가지 책임을 뽑아 낼 수 있는데, 

  1. Animal 데이터베이스의 관리
    saveAnimal이 DB의 Animal 스토리지를 관리하는 동안

  2. Animal 프로퍼티(속성)들의 관리 
    생성자와 getAnimalName은 Animal 프로퍼티를 관리한다. 

어플리케이션이 DB관리기능에 영향을 주도록 변경된다면, 변경사항에 맞춰 Animal 프로퍼티의 사용을 만드는 클래스는 반드시 건드리게 되고 새로 컴파일 해야한다. 

이런 시스템이 SRP를 따르도록, DB에 각 animal을 저장하는 단 하나의 책임을 관리하는 또 다른 클래스를 만든다.

class Animal {
    String name;
    
    public Animal(String name) {
        this.name = name;
    }
}
    
class AnimalDB {
    Animal a;

    public Animal getAnimal() {
        return a;
    }
    
    public saveAnimal(Animal a) {
        this.a = a;
    }
}

클래스들이 같은 이유로 매번 변화하는 변화경향이 있다면, 클래스를 설계할 때, 연관된 기능들을 함께 모으는 것을 목표로 해야한다. 우리는 기능을 분리하도록 노력하고, 기능들은 서로 다른 이유로 변경되어야 한다. 

이런 것을 적절히 응용하면, 우리 어플리케이션은 높은 응집력을 갖게 될 것이다.

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

: 확장에는 열려있어야 하고, 변경에는 닫혀있어야 한다. 

이는 변경의 유연함과 관련된 원칙이며, 구체적으로 풀어보면

  • 기능을 변경하거나 확장할 수 있으면서
  • 그 기능을 사용하는 코드는 수정하지 않는다

OCP를 구현하기 위해서는 확장되는 부분을 추상화해서 표현해야 한다. 또는 상속을 이용할 수도 있다.

OCP가 깨진 경우의 주요 증상으로는 1) 다운 캐스팅을 한다. 2) 비슷한 if-else 블록이 존재한다.

이전의 Animal 클래스를 이용해서 OCP를 파악해보자. 

class Animal {
    String name;
    
    public Animal(String name) {
        this.name = name;
    }
    
    public String getAnimalName() {
        return name;
    }
}

우리는 Animal 리스트를 반복하고, 각 Animal의 울음소리를 반복할 것이다.

// AnimalSound(animals) 함수
new List<Animal> = Arrays.asList(
    new Animal("lion");
    new Animal("mouse");
    );

for (Animal animal : animals) {
    if (animal.name.equals("lion") return "roar";
    if (animal.name.equals("mouse") return "squeak";
}

함수 AnimalSound()는 OCP를 따르지 않고 있다. 왜냐하면 새로운 종의 Animal에 대해서 닫혀있지 않기 때문이다. 

만약, 우리가 새로운 Animal인 Snake를 추가한다면 ... 

// AnimalSound(animals) 함수 
new List<Animal> = Arrays.asList(
    new Animal("lion");
    new Animal("mouse");
    new Animal("snake");
    );

for (Animal animal : animals) {
    if (animal.name.equals("lion") return "roar";
    if (animal.name.equals("mouse") return "squeak";
    if (animal.name.equals("snake") return "hiss";
}

이런식으로 모든 새로운 Animal을 위해서 새로운 로직을 AnimalSound() 함수에 추가해야 할 것이다. 

이건 상당히 간단한 예제이다. 우리의 어플리케이션이 거대해지고 복잡해질 때는 'if' 조건문이 계속해서 반복되며 추가된다는 것을 쉽게 예상할 수 있을 것이다. 

어떻게 하면 AnimalSound가 OCP를 지킬 수 있도록 할까? 

class Animal {
    public String makeSound();
    //...
}
class Lion extends Animal {
    public String makeSound() {
        return "roar";
    }
}
class Squirrel extends Animal {
    public String makeSound() {
        return "squeak";
    }
}
class Snake extends Animal {
    public String makeSound() {
        return "hiss";
    }
}
//... AnimalSound 함수

for (Animal animal : animals) {
    animal.makeSound();
}

//... 

현재 Animal은 가상의 makeSound()를 가지고 있다. 우리는 Animal class를 확장하고 가상의 makeSound()를 구현하고 있는 각각의 animal을 가지고 있다. 

모든 animal은 자신의 makeSound()에서 울음소리에 관한 방법을 구현하고 있다. AnimalSound()는 animals 리스트를 반복하며 makeSound() 메서드를 호출할 뿐이다.

이제, 우리가 새로운 animal을 추가한다면, AnimalSound는 더 이상 변경 할 필요가 없다. 우리가 할 일은 새로운 animal을 animals 리스트에 추가하기만 하면 된다. 

AnimalSound는 이제 OCP 원칙을 따르게 되었다. 

3. Liskov substitution principle (LSP, 리스코프 치환 원칙)

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

직사각형(Rectangle) - 정사각형(Square) 문제를 보자. 정사각형은 직사각형의 특수한 경우로 보고 직사각형을 상속받아 구현했다. 정사각형은 가로와 세로가 모두 동일한 값을 가져야 하므로 setWidth()와 setHeight()에 가로, 세로 값을 일치하도록 구현했다. 그런데 높이와 폭을 비교해서 높이를 더 길게 만들어 주는 기능 increaseHeight()라는 메서드가 있다면 어떻게 될까?

increaseHeight 메서드에서 setHeight 메서드를 호출해서 값을 변경해주더라도 결국은 높이가 폭보다 더 길어지지 않게 된다(setHeight에서 가로,세로 값을 일치하게 하기 때문). 이 문제를 해결하기 위해 instanceOf 연산자를 사용하면 될까? 그렇게 되면 리스코프 치환 원칙을 위반하게 되며, 이는 increaseHeight 메서드가 Rectangle의 확장에 열려있지 않다는 것을 의미한다.

이처럼 개념적으로는 상속관계에 있는 것처럼 보일지라도 실제 구현에서는 상속 관계가 아닐수 도 있다.

또 다른 흔한 예는 상위 타입에서 지정한 리턴 값의 범위에 해당되지 않는 값을 리턴하는 것읻다.

리스코프 치환 원칙은 기능의 명세(또는 계약)에 대한 내용이다. 명시된 내용에서 벗어난 기능을 수행하거나, 값을 리턴하거나, 예외를 발생하는 것이 흔한 위반 사례이다.

하위 타입이 이렇게 명세에서 벗어난 동작을 하게 되면, 이 명세에 기반해서 구현한 코드는 비정상적으로 동작할 수 있기 때문에, 하위 타입은 상위 타입에서 정의한 명세를 벗어나지 않는 범위에서 구현해야 한다.

또한 리스코프 치환 원칙은 확장에 대한 것이다. 리스코프 치환 원칙을 어기면 개방 폐쇄 원칙을 어길 가능성이 높아진다. 향후에 기능을 변경하거나 확장할 때 더 많은 코드를 수정할 가능성이 높아지게 된다.

만약 특정 Item은 어떠한 쿠폰에서 할인이 되지 않는 정책이 생겼고 이 Item을 의미하는 Item 클래스를 상속받는 SpecialItem 클래스를 추가 했다.

이 정책을 위해 Coupon 클래스의 calculateDiscountAmount() 메서드에서 Item의 인스턴스가 SpecialItem 타입이면 0을 리턴하게 했다면 이는 리스코프 치환 원칙을 어긴 것이다. 이는 하위 타입이 상위 타입을 대체할 수 없다는 것을 의미한다. 이런 새로운 종류의 하위 타입이 생길 때 마다 상위 타입을 사용하는 코드가 수정될 가능성이 높아지다.

위 예에서 Item을 확장한 SpecialItem을 추가하는 과정에서 Coupon의 수정은 닫혀있지 못했다.

리스코프 치환 원칙을 어기게 된 이유는 Item에 대한 추상화가 덜 되었기 때문이다. 상품의 가격 할인 가능 여부가 Item 및 그 하위 타입에서 변화되는 부분이 되며, 변화되는 부분을 Item 클래스에 추가함(isDiscountAvailable() 추가)으로서 리스코프 치환 원칙을 지킬 수 있게 된다. 

if (item instanceof SpecialItem) { return 0; } //LSP 위반

if (!item.isDiscountAvailable()) { return 0; } // instanceof 제거

 

4. Interface segregation principle (ISP, 인터페이스 분리 원칙)

:  인터페이스는 그 인터페이스를 사용하는 클라이언트를 기준으로 분리해야 한다. 

C, C++의 경우 각 클라이언트(source 파일)가 필요로 하는 인터페이스(header 파일)를 분리하면, 각 클라이언트가 사용하지 않는 인터페이스에 변경이 발생하더라도 영향을 받지 않게 된다. (하나의 header 파일을 사용했다면 전체 소스가 재컴파일이 필요하게 된다)

용도에 맞게 인터페이스를 분리하는 것은 단일 책임 원칙과도 연결된다. 결국 인터페이스 분리 원칙을 통해 인터페이스와 콘크리트 클래스의 재사용 가능성을 높일 수 있다. 

 

5. Dependency inversion principle (DIP, 의존 역전 원칙)

: 고수준 모듈은 저수준 모듈의 구현에 의존해서는 안 된다.  저수준 모듈이 고수준 모듈에서 정의한 추상 타입에 의존해야 한다. 

고수준 모듈 : 어떤 의미 있는 단일 기능을 제공하는 모듈. 

저수준 모듈 : 고수준 모듈의 기능을 구현하기 위해 필요한 하위 기능의 실제 구현

우리가 원하는 것은 저수준 모듈의 변경사항이 고수준 모듈을 변경시키지 않는 것이다. 이 문제를 해결하기 위해 추상 타입을 도출하여 저수준 모듈이 이를 구현하게 한다. 고수준 모듈은 저수준 모듈을 호출하도록 하면, 고수준 모듈과 저수준 모듈은 추상 타입에 의존하게 할 수 있다. 이것은 고수준 모듈이 저수준 모듈에 의존했던 상황이 역전되는 현상을 일으키고 이런 맥락에서 이 원칙의 이름이 의존 역전 원칙이 된다.

이렇게 고수준 모듈과 저수준 모듈이 모두 추상 타입에 의존하게 만들면, 우리는 고수준 모듈의 변경 없이 저수준 모듈을 변경할 수 있는 유연함을 얻게 된다. 

더보기

🔍 소스 코드 상에서 의존은 역전되었지만, 런타임에서의 의존은 고수준 모듈의 객체에서 저수준 모듈의 객체로 향한다. DIP는 소수 변경의 유연함을 확보할 수 있도록 만들어주는 원칙이지, 런타임에서의 의존을 역전시키는 것은 아니다.

 

 

참고: https://medium.com/@LIP/solid-3b4d0fad39fb

 

SOLID

OOP 설계 원칙

medium.com

 

예제 참고 : http://doublem.org/SOLID_SRP_OCP/

 

모든 개발자가 알아야만 하는 SOLID 원칙 - 1편(SRP/OCP)

객체지향 타입의 프로그래밍은 소프트웨어 개발의 새로운 설계를 불러왔습니다.

doublem.org

 

728x90