주절주절 서론에, 다섯가지 원칙을 다 내용과 예시까지 포함하여 설명하다보니 글이 길어요 ! 특정 원칙에 대해서만 궁금하신 분은 우측의 목차 바로가기를 통해서 원하는 원칙으로 바로 이동하시면 됩니다 ! 🙂
무려 반년만에 작성하는 디자인패턴 글 ! SOLID 입니다 !
사실 저는 책, 블로그, 교안, 공식 문서 등등 여러가지를 참고하면서 공부하고, 그걸 정리하는 건 아이패드에 하는게 편하더라구요 ! 노트북은 보면서 아이패드에 정리할 수 있는 것도 좋고, 나중에 추가되는 내용들을 적당한 곳에 자유롭게 추가할 수 있는 것도 좋아요 !
그래서 위에처럼 CS를 아이패드에 정리해왔는데, 이제 거의 다 정리가 끝난 것 같아서 복습한다는 마음으로 다시 읽어보면서 하나씩 블로그로 옮겨볼 생각입니다 ! 화이팅 ❤️🔥
시간이 지나도 유지보수와 확장이 쉬운 소프트웨어를 만들기위해 사용되는 원칙
💡 아래와 같이 다섯개의 원칙이 있고, 각 앞글자를 따서 SOLID라고 한다
그럼 이제 다섯개의 원칙들이 각각 어떤 내용들인지 하나씩 알아보자 !
하나의 클래스는 하나의 책임만을 가진다는 원칙
이름 그대로 하나의 클래스가 하나의 책임만을 가져야 한다는 원칙인데, 그렇기 때문에 어떤 클래스를 병경해야 하는 이유는 오직 하나여야 한다는 것이다 !
단일 책임 원리를 통해서 얻을 수 있는 효과이자 장점은 아래와 같다.
조금 더 명확하게 이해하고 넘어가기 위해서 예시를 살펴보자 !
위의 클래스 다이어그램을 보면, Student 클래스가 Student와 관련한 정보뿐 아닌 compare하는 역할까지 가지고 있는 것을 확인할 수 있다 !
만일 비교 조건이 학생 name이었다가 학생 major로 바뀐다면, 사실 Student 클래스 자체는 전혀 변화가 필요 없는데 compare로 인해서 해당 클래스에 변화가 발생하게 되는 것이다.
위와 같은 문제를 방지하기 위하여 compare에 대한 책임을 Student 클래스에서 아래와 같이 분리할 수 있다.
비교하는 것 자체를 Comparable 인터페이스로 정의해두고 비교 조건에 따라서 이를 확장한 클래스를 SortStudentByName과 같이 만들어서 사용하는 것이다.
이렇게 책임을 분리해두면, 위와 같이 비교 조건이 mojor로 바뀌었을 때, Comparable을 확장한 SortStudentByMajor 클래스만 추가하면 되고, 다른 클래스들에는 전혀 영향을 주지 않는다 ! 😀 굿 !
확장에는 열려있고, 변경에는 닫혀있는 설계 원리. 즉 기존의 코드를 변경하지 않고 ( close ), 기능을 수정하거나 추가할 수 있는 ( open ) 설계 원칙
OCP를 만족하지 않으면, 기존의 기능을 변경하거나 새로운 기능을 추가하는 것이 쉽지 않아진다. 이는 곧 유지보수가 어려워짐과 유연성이 낮아짐을 의미하게 된다.
OCP를 만족하기 위해서는 변하는 부분과 변하지 않는 부분을 구분하여 설계해야 한다.
( 🤷🏻♀️ 도대체 이걸 어떻게 구분하는데? 할 수도 있지만 앞으로 소개할 패턴들을 보면 '아, 이건 변할 수 있어서 이렇게 빼두는구나' 이런 느낌을 받을 수 있을 것이다 ! 그런 부분은 강조해서 표시해 보겠습니다 ! )
💡 OCP는 다형성(polymorphism)과 추상화(abstraction)으로 이룰 수 있는데, 또 이는 각각 아래애서 설명할 LSP와 DIP가 기반이 된다. > 아래의 나머지 원칙도 읽어보면 무슨 말인지 이해할 수 있을 것이다 !
OCP도 이해를 돕기위해 예시를 살펴보자 !
위의 클래스 다이어그램을 보면, HRMgr는 직원들의 직무에 다라서 incSalary를 할 수 있다. 또한 모든 사원들에 대해서 한 번에 수행할 수도 있다. 그래서 각 직무에 따른 incSalary를 정의해 사용하고 있다.
만일 직원의 종류가 더 늘어난다면 어떻게 될까 ?! HRMgr에 새로운 직무에 대한 incSalary를 추가해야하고, 혹 incAll에서 각 직무에 따라 분기로 호출하고 있었다면 해당 함수에도 수정이 필요할 것이다. 이것은 새로운 기능을 추가하기 위해서 기존의 부분에 수정이 필요하므로 OCP를 위배한 것이다.
그렇다면 OCP를 만족하기 위해서는 어떻게 해야할까 ?!
위의 수정된 클래스 다이어그램처럼 직원을 추상화하여 정의해두고, 각각의 하위 타입에 incSalary를 구현하는 것이다. 또한 incAll에서도 분기처리가 필요없이 emp.incSalary만 해주면 해당 하위타입에 맞는 incSalary가 수행된다.
이와 같이 수정한다면 새로운 직무가 추가될 때, 새로운 하위타입을 추가하고 incSalary를 구현하면 되기 때문에 새로운 기능 추가를 위해서 기존의 부분에는 수정이 전혀 필요하지 않다.
프로그램의 객체는 프로그램의 정확도를 깨뜨리지 않으면서 하위타입의 객체로 바꿀 수 있어야 한다는 원칙
즉 상위 타입의 객체를 하위 타입의 객체로 치환하여도 상위 타입을 사용하던 프로그램이 문제 없이 정상 동작해야 한다는 것이다.
나는 리스코프 치환 원칙이 바로 이해가 가지 않아서 다양한 예제들을 찾아보다가 이해가 바로 되는 예제를 발견했다 ! 리스코프 치환 원칙의 대표적인 예시인 정사각형-직사각형 예제이다.
위와 같은 사실은 우리가 정의를 통해서 익히 알고있다.
그런데 만약 직사각형이 정사각형보다 더 큰 개념이라고 직사각형을 상속하면 어떻게 될까 ?
public class Rectangle {
private int width;
private int height;
public void setWidth(final int width) {
this.width = width;
}
public void setHeight(final int height) {
this.height = height;
}
public int getWidth() {
return width;
}
public int getHeight() {
return height;
}
}
public class Square extends Rectangle {
@Override
public void setWidth(final int width) {
super.setWidth(width);
super.setHeight(width);
}
@Override
public void setHeight(final int height) {
super.setWidth(height);
super.setHeight(height);
}
}
위와 직사각형과 정사각형 클래스를 정의했다. 정사각형 클래스는 직사각형 클래스를 상속받았고, 너비 높이가 같아야 하기 때문에 너비 혹은 높이를 재정의 한다면 나머지 값도 같게 정의되게끔 오버라이딩 해주었다.
이제 위의 클래스를 이용하여 코드를 작성해보자 !
예를들어 아래와 같이 직사각형 클래스를 이용해서, 세로가 가로보다 1만큼 크게 변형해주는 함수를 작성했다고 하자.
public void increaseHeight(final Rectangle rectangle) {
if (rectangle.getHeight() <= rectangle.getWidth()) {
rectangle.setHeight(rectangle.getWidth() + 1);
}
}
직사각형을 상속받은 정사각형도 해당 함수의 대상이 될 수 있으나, 정사각형 객체에서 위의 코드를 실행한 경우 위의 메소드가 목표하는 세로가 가로보다 1만큼 커지는 변형의 목적을 달성하지 못하게 된다.
그러므로 목표를 달성하기 위해서 아래와 같이 서브타입에 의존해서 instanceOf를 사용해야만 하게되는 것이다.
public void increaseHeight(final Rectangle rectangle) {
if (rectangle instanceof Square) {
throw new IllegalStateException();
}
if (rectangle.getHeight() <= rectangle.getWidth()) {
rectangle.setHeight(rectangle.getWidth() + 1);
}
}
즉 직사각형 객체를 사용하는 increaseHeight를 하위 타입인 정사각형 객체로 치환했을 때, 정확도가 깨지게 되므로 LSP를 위반한 예제가 되는 것이다.
내가 왜 처음에 LSP를 제대로 이해를 못 했나 생각해보면, 위처럼 잘못된 예제는 사실 상속의 개념을 잘 이해했다면 만들어서는 안되는 예제이기 때문이었다 !
이처럼 LSP는 기능의 명세 자체에 대한 원칙인 것이다. 상속을 잘 정의해라 ~ 하는 원칙 ! 👆
위의 예제에서 직사각형의 setHeight는 매개변수 값으로 높이를 변경하는 함수인 것이지 너비가 변경돼서는 안되는 것이다. 하지만 정사각형은 변경해야하므로 직사각형을 상속받아 정사각형을 정의해서는 안됐던 것이다 !
위의 예제들에서도 알 수 있듯이 LSP를 위배하면, instanceOf를 사용해서 하위타입을 알아야하는 코드를 작성하게 되고, 이는 OCP를 위배하는 결과까지 초래하게 되어 확장을 어렵게 할 수 있다.
OCP를 위한 다형성 측면의 기반 제공
클라이언트는 자신이 사용하지 않는 메소드에 의존관계를 맺으면 안된다는 원칙
즉 불필요한 결합을 피해서 의존성을 낮추라는 원칙이다.
이를 지켜야 객체가 비대해지는 것도 방지할 수 있고, 변경에도 유연해질 수 있다.
간단하고, 당연한 법칙이지만 예시를 보고 확실하게 알고 넘어가보자 !
위와 같은 클래스 다이어그램처럼 만일 EnrollmentReportGenerator는 1번 메소드들 getName, getDate만을 사용하고, AccountReceivable은 2번 메소드들 prepareInvoice, postPayment만을 사용한다고 해보자.
EnrollmentReportGenerator는 1번 메소드들만 사용하지만 2번 메소드들까지 의존관계를 맺게되어 2번 메소드들만 변경되어도 불필요한 영향을 받게된다. AccountReceivable도 마찬가지로 2번 메소드들만 사용하지만 1번 메소드들까지 의존관계를 맺고있어 1만 변경되어도 불필요한 영향을 받게 되는 것이다.
이를 해결하기 위해서 위와 같이 interface로 관련 메소드들을 분리하고 이에 의존하게 하여 불필요한 의존관계를 해제하였다 !
고수준 모듈이 저수준 모듈에 의존해서는 안되고, 저수준 모듈이 고수준 모듈의 추상 타입에 의존해야 한다는 원칙
쉽게 말하면 변하기 쉬운 것 보다는 잘 변하지 않는 추상체에 의존하라는 원칙이다.
이제 추상체에 의존하라는 건 쉽게 이해가 가는데, 고수준 모듈이 저수준 모듈을 사용하는데 어떻게 저수준 모듈이 고수준 모듈에 의존하지 ? 라는 생각이 들었다.
그래서 이것 저것 찾아보다가 예전에 들었던 학교 강의 노트를 꺼내들었더니 아래와 같은 그림이 있었고 단번에 이해가 갔다.
위의 그림을 보면 실제로는 고수준 모듈인 policy가 mechanism을 사용하지만 각 모듈간에 interface를 활용함으로 저수준 모듈이 고수준 모듈의 추상체에 의존하게끔 된다.
이렇게 구현한다면 변경이 자주 발생하는 저수준 모듈, 구체적인 타입들에서 변화가 발생해도 고수준 모듈이나 추상체는 변화가 없을 수 있다. 추상화를 통해서 OCP를 달성하게 되는 것이다.
OCP를 위한 추상화 측면의 기반 제공
나는 안드로이드 개발을 주로 하고 있으니까 최근 안드로이드 프로젝트에서 클린 아키텍처 기반으로 아키텍처를 잡으면서 PRND의 아키텍처 룰을 참고했다. 해당 이미지를 보면서 DIP의 좋은 실제 예시가 있어서 이미지를 일부 가져와서 예시를 들어보고자 한다. ( 사실 해당 아키텍처뿐 아니라 좋은 아키텍처, 클린 아키텍처에서 DIP는 중요한 요소가 된다. )
위의 예시를 보면 domain 레이어의 usecase가 data 레이어의 repository를 사용하는 것인데, repository interface를 둠으로 data 레이어의 구현체가 도메인 레이어의 interface를 의존하고 domain 레이어의 usecase는 repository interface, 즉 추상체에 의존하고 있는 것을 확인할 수 있다 !
오늘은 SOLID 포스팅을 쭉 해보았다 ! 다섯가지를 모두 정리하고 예시를 들다보니 글이 길어졌다 ! 다음 포스팅부터는 다양한 디자인패턴들을 정리해보겠다 !
참고자료 1 :
https://velog.io/@cchloe2311/%EB%94%94%EC%9E%90%EC%9D%B8%ED%8C%A8%ED%84%B4-SOLID-%EC%9B%90%EC%B9%99
참고자료 2 :
https://steady-coding.tistory.com/383