SOLID의 목적은 중간 수준의 소프트웨어 구조가 아래와 같도록 만드는 데 있다.
여기서 '중간 수준'이란 프로그래머가 원칙을 모듈 수준에서 작업할 때 적용할 수 있다는 뜻.
클린 코드가 건물을 짓기 위한 좋은 벽돌이라면, SOLID 원칙은 이 벽과 방에 올바르게 벽돌을 배치하는 방법을 알려주는 원칙이라고 볼 수 있다.
SOLID!! 예전에 Eli와 Bart가 SOLID가 뭔지 아세요? 라고 했던 기억이 난다.
"어,, 테두리 속성 아닌가요,, 솔리드" 가 나의 대답이였는데. 그때 "Clean Architecture 에 나와요 나중에 읽어보세요" 해서 얼핏 찾아봤던 기억이 있는데 이제서야 Clean Architecture를 읽으며 정리를 시작한다.
대부분 이 원칙을 듣는다면 모든 모듈이 단 하나의 일만 해야 한다 는 의미로 알고 있을것이다. 역사적으로도 단일 모듈은 변경의 이유가 하나, 오직 하나뿐 이어야 한다. 라고 기술되어있다.
하지만 밥아저씨는 SRP 의 최종 버전을
💡 하나의 모듈은 하나의, 오직하나의 **액터** 에 대해서만 책임져야한다.
= 클래스의 역할과 책임을 너무 많이 주지 마라.
즉 클래스를 설계할 때 어플리케이션의 경계를 정하고, 추상화를 통해 어플리케이션 경계 안에서 필요한 속성과 메서드를 선택하여 설계 해야 한다.
책임이란?
액터란 이해 관계를 가진, 변경을 요청하는 집단을 말한다.
예를 들면, Student(학생) 클래스가 수강 과목을 추가하거나 조회하고, 데이터베이스에 객체 정보를 저장하거나 데이터베이스에서 객체 정보를 읽는 작업도 처리하고, 성적표와 출석부에 출력하는 일을 실행한다고 가정한다.
사실 클래스가 여러 개로 나눠지면, 클라이언트 쪽(클래스를 사용하는 쪽) 입장에서는 알아야될 클래스가 많아진다. 클라이언트는 액터보다, 유저의 행동에 따라 로직을 짤 수 있기 때문이다.이럴 때는 퍼사드 패턴으로 액터를 외부로 숨길 수 있다.
<public class Student {
public void getCourses(){}
public void addCourse(Course course){}
public void save(){}
public void Student load(){}
public void printOnReportCard(){}
public void printOnAttendanceBook(){}
}
위의 코드는 학생 클래스가 너무 많은 책임을 수행하고있ㄷ. 학생 클래스에서 가장 잘할 수 있는 것을 생각해보면 수강 과목을 추가하거나 조회하는 것이다.
나머지 기능들은 학생클래스가 아닌 다른 클래스에서 더 잘할 수 있는 여지가 많다. 그래서 학생 클래스는 수강과목을 조회하고, 추가하는 책임만 수행하는 것이 바람직함.
public class Student {
public void getCourses(){} //학생 클래스에서 잘할 수 있는 것
public void addCourse(Course course){} //학생 클래스에서 잘할 수 있는 것
// public void save(){} //다른 클래스에서 잘할 수 있는 것
// public void Student load(){} //다른 클래스에서 잘할 수 있는 것
// public void printOnReportCard(){} //다른 클래스에서 잘할 수 있는 것
// public void printOnAttendanceBook(){} //다른 클래스에서 잘할 수 있는 것
}
💡 단일 책임 원칙은 메서드와 클래스 수준의 원칙이다.
하지만 밥아저씨는 이보다 상위의 두 수준에서도 다른 형태로 다시 등장한다고 한다.
컴포넌트 수준에서는 → 공통 폐쇄 원칙
아키텍처 수준에서는 → 경계의 생성을 책임지는 변경의 축이된다.
개방-폐쇄 원칙은 1988년에 버트란트 마이어가 만들었다고한다.
💡 **OCP** "소프트웨어 개체는 확장에 열려 있어야 하고, 변경에는 닫혀 있어야한다."
= 자신의 확장에는 열려 있고, 주변의 변화에 대해서는 닫혀 있어야 한다.
OCP의 목표
목표를 달성하기위해서는
SoundPlayer는 음악을 재생해주는 클래스이고 기본적으로 wav 파일을 재생할 수 있다. 여기서 만약 다른 파일 형식 mp3 파일을 재생하도록 요구사항이 변경 되었을 경우, SoundPlayer의 play 메서드를 수정해야 한다면?
<class SoundPlayer {
void play() {
System.out.println("paly wav");
}
}
public class Client {
public static void main(String[] args) {
SoundPlayer sp = new SoundPlayer();
sp.play();
}
}
위의 예제에서 OCP를 만족시키기 위해서는 play 메서드를 인터페이스로 분리해야 한다. SoundPlayer 클래스에서는 playAlgorithm 인터페이스를 멤버 변수로 만들고 play() 함수에는 인터페이스를 상속받아서 구현된 클래스의 play를 실행하도록 한다.
<interface playAlgorithm {
public void play();
}
class Wav implements playAlgorithm {
@Override
public void play() {
System.out.println("paly wav");
}
}
class Mp3 implements playAlgorithm {
@Override
public void play() {
System.out.println("paly Mp3");
}
}
class SoundPlayer {
private playAlgorithm file;
public void setFile(playAlgorithm file) {
this.file = file;
}
public void play() {
file.play();
}
}
public class Client {
public static void main(String[] args) {
SoundPlayer sp = new SoundPlayer();
sp.setFile(new Wav());
sp.setFile(new Mp3());
sp.play();
}
}
1988년 바바라 리스코프는 하위 타입을 아래와 같이 정의 했다.
💡 서브 타입은 언제든 자신의 기반 타입으로 교체할 수 있어야 한다.
= 하위 클래스의 인스턴스는 상위형 객체 참조 변수에 대입해 상위 클래스의 인스턴스 역할을 하는데 문제가 없어야 한다.
= 즉 Upcating 된 객체 참조 변수가 논리적으로 그 역할이 문제가 없어야 한다.
객체 지향에서의 상속은 조직도나 계층도가 아닌 분류도가 되어야 한다.
하위클래스 is a kinid of 상위 클래스 - 하위 분류는 상위 분류의 한 종류다.
구현클래스 is able to 인터페이스 - 구현 분류는 인터페이스 할 수 있어야 한다.
하지만 문장대로 구현되지 않은 코드가 존재 할 수 있는데 바로 상속이 조직도나 계층도 형태로 구축된 경우이다.
아버지 춘향이 = new 딸();
춘향이는 아버지형의 객체 참조 변수기에 아버지 객체가 가진 행위(메서드)를 할 수 있어야하는데 춘향이 에게 아버지의 어떤 역할이 가능할까?
동물 뽀로로 = new 펭귄();
펭귄 한마리 이름은 뽀로로이고, 동물의 행위(메서드)를 잇게 하는데 전혀 이상이 없다.
아버지 - 딸 구조(계층도/조직도)는 리스코프 치환 원칙을 위배하고 있는 것이고, 동물 - 펭귄 구조(분류도)는 리스코프 치환 원칙을 만족하는 것이다.
💡 하위 클래스의 인스턴스는 상위형 객체 참조 변수에 대입해 상위 클래스의 인스턴스 역할을 하는데 문제가 없어야 한다.
💡 클라이언트는 자신이 사용하지 않는 메서드에 의존 관계를 맺으면 안된다.
= 상황과 관련 있는 메서드만 제공해라.
책에서는 다음과 같이 예를드는데.
User1
은 오직 op1
을 User2
는 op2
만을, User3
은 op3
만 사용한다 하지만 Ops 라는 메서드에 들어있어서
User1
같은 경우는 op2
와 op3
을 사용하지 않음에도 두 메서드에 의존하게 된다.
User1
는 U10ps
와 op1
에 의존하지만 OPS에는 의존하지 않게된다. 따라서 OPS에서 수정은 User1
에 관계없는 수정이면 불필요한 컴파일과 재배포를 막을 수 있다.
여기서 얻을 수 있는 교훈은 "불필요한 점을 실은 무언가에 의존하면 예상치 못한 문한 문제를 일으킬 수 있다" 는 것
💡 의존성은 추상에 의존해야 하며, 구체에 의존하지 않아야 한다.
= 자신보다 변하기 쉬운 것에 의존하지 마라.
이것을 규칙으로보기엔 비현실적이다. 소프트웨어 시스템이라면 구체적인 많은 장치에 반드시 의존하기 때문이다.
우리가 의존하지 않도록 피하고자 하는 것은 바로 변동성이 큰 구체적인 요소다. 그리고 이 구체적인 요소는 우리가 열심히 개발하는 중이라 자주 변경될 수 밖에 없는 모듈들이다.
안정된 아키텍처는 변동성이 큰 구현체에 의존하는 일은 지양하고, 안정된 추상 인터페이스에 의존해야 한다.
실천법은 다음과 같이 요약할 수 있다.
<// A사의 알람 서비스
*/
public class A {
public String beep() {
return "beep!";
}
}
// 서비스 코드
public class AlarmService {
private A alarm;
public String beep() {
return alarm.beep();
}
}
<//B사의 알림 서비스
public class B {
public String beep() {
return "beep";
}
}
// 서비스 코드
public class AlarmService {
private A alarmA;
private B alarmB;
public String beep(String company) {
if (company.equals("A")) {
return alarmA.beep();
} else {
return alarmB.beep();
}
}
}
지금까지 사용한 방법은 고수준 모듈이 저수준 모듈에 의존하는 방법이지만, DIP를 적용하게 되면 저수준 모듈이 고수준 모듈에 의존해야 한다. 그러기 위해 사용하는 것이 추상 타입(ex. 인터페이스, 추상 클래스)이다.
<public interface Alarm {
String beep();
}
// A사의 알람서비스
public class A implements Alarm {
@Override
public String beep() {
return "beep!";
}
}
// B사의 알람서비스
public class B implements Alarm {
@Override
public String beep() {
return "beep";
}
}
저수준 모듈들이 Alarm을 구현하게 하면 된다. 그렇게 되면 위의 서비스 코드를 아래와 같이 변경할 수 있다.
<// 서비스 코드
public class AlarmService {
private Alarm alarm;
public AlarmService(Alarm alarm) {
this.alarm = alarm;
}
public String beep() {
return alarm.beep();
}
}
더이상 AlarmService는 알람 서비스가 추가된다고해서 코드를 변경하거나 추가할 일이 없음.
코드를 다이어그램으로 나타내면 위와 같다.
저수준 모듈이 고수준 모듈에 의존하게 되는 것을 DIP(의존관계 역전 원칙)라고 한ㄷㅏ.