-
SOLID 원칙은
- SRP(단일 책임 원칙),
- OCP(개방 폐쇄 원칙),
- LSP(리스코프 치환 법칙),
- ISP(인터페이스 분리 원칙),
- DIP(의존성 역전 원칙)
으로 구성됩니다.
객체 지향의 기본은 책임을 객체에게 할당하는 데 있다. 객체를 객체로 존재하게 하는 이유가 책임인데, 단일 책임 원칙은 이 책임과 관련된 원칙이다.
- 단일 책임 원칙은 가장 중요한 원칙 중의 하나이다
- 단일 책임 원칙이 잘 지켜지지 않으면 다른 원칙들도 그 효과가 반감된다
- 중요하지만 가장 어려운 원칙이다
- 한 개의 책임에 대한 정의가 명확하지 않고, 책임을 도출하기 위해서는 다양한 경험이 필요하기 때문이다
- 모듈화가 강해질수록 다른 객체와의 의존/연관성이 줄어든다.
- 객체가 담당하는 동작. 책임이 많아질 수록 해당 객체의 변경에 따른 영향도의 양과 범위가 매우 커진다.
public class DataViewer {
public void display() {
String data = loadHtml();
updateGui(data);
}
public String loadHtml() {
HttpClient client = new HttpClient();
client.connect(url);
return client.getResponse();
}
private void updateGui(String data) {
GuiData guiModel = parseDataToGuiData(data);
tableUI.changeData(guiModel);
}
private GuiData parseDataToGuiData(String data) {
... // 파싱 처리 코드
}
... // 기타 필드 등 다른 코드
}
- 위 코드는 HTTP 프로토콜을 이용해서 데이터를 읽어온 후 화면에 보여주는 기능을 구현한 예시이다
- display 메서드는 loadHtml 메서드에서 읽어 온 HTML 응답 문자열을 updateGui 메서드에 보낸다
- updateGui 메서드는 parseDataToGuiData 메서드를 이용해서 HTML 응답 메시지를 GUI에 보내기 위한 GuiData 객체로 변환한 뒤에 실제 tableUI를 이용해서 데이터를 보여주고 있다
- 한 책임의 변화가 다른 책임의 코드에 영향을 준다.
- 책임의 개수가 많아질수록 한 책임의 기능 변화가 다른 책임에 주는 영향이 비례해서 증가
- 예) DataView Class : "데이터 읽는 책임" 변화가 "화면에 보여주는 책임"까지 영향
- loadHtml() 의 return Value가 char[]로 변경되면 display()와 관련 메서드까지 영향을 받는다.
- 책임이 분리되어 있지 않으면 필요하지 않는 패키지까지 필요하다.
- 예시 클래스DataViewer에서 "데이터를 읽어오는" DataRequiredClient 클래스를 만들어야 한다면필요한 것은 DataViewer 클래스와 HttpClient.jar 파일이다.
- 그러나 실제 사용하지 않는 기능이 의존하는 GuiComp.jar까지 필요하게 된다.
- 데이터를 읽어 오는 데 필요한 dataloader 패키지와 HttpClient 패키지만 필요하며
- 데이터를 읽어 오는 것과 상관없는 GuiComp 패키지나 datadisplay패키지는 포함시킬 필요가 없어진다.
- 기능 변경 요구가 없을 때 수정에 대한 문제가 없다는 것 = 반대로 생각해 보면 책임의 단위는 변화되는 부분과 관련이 있다
- 책임의 단위는 변화되는 부분과 관련된다
- 각각의 책임은 서로 다른 이유로 변경되어야 한다
- 어떻게 서로 다른 이유로 변경되는 것을 알 수 있을까? 메소드를 실행하는 것이 누구인지 살펴보면 됨
- 방법은 바로 메서드를 실행하는 것이 누구인지 확인해 보는 것이다
- 객체의 사용자들이 서로 다른 메서드들을 사용한다면 그들 메서드는 각각 다른 책임에 속할 가능성이 높으므로 책임 분리 후보가 된다.(위 예제처럼 한 클래스에서 데이터 읽기/보여주기 메서드가 있는 경우)
- 클래스가 여러 가지의 메소드를 가진다면, 복수의 책임을 갖는가?
- 클래스가 다중 상속 or 다중 구현을 한다면, 복수의 책임을 갖는가?
- 해당 클래스를 의존하는 사용자(클라이언트)가 여럿이라면 변경되는 이유는 여러가지가 되는가?
- SOLID를 창시한 로버트C 마틴 : 하나의 모듈은 하나의, 오직 하나의 액터에 대해서만 책임져야 한다
- 액터는 시스템이 동일한 방식으로 변경되기를 원하는 사용자 집단을 의미
- 액터는 한 명일수도 있고, 여러 명이 될 수도 있는 것
- SRP를 설계할 때는 거시적인 관점에서 해당 클래스에 어떤 액터가 의존하는지 고려하는 것이 바람직하다
확장에는 열려 있어야 하고, 변경에는 닫혀 있어야 한다, 기능을 변경하거나 확장할 수 있으면서, 그 기능을 사용하는 코드는 수정하지 않는다.
- 확장되는 부분(변화되는 부분)을 interface로 추상화해서 표현
- 개방 폐쇄 원칙의 핵심은 변화하는 부분을 추상화하는 것
- 상속을 통한 확장
public class ZippedResponseSender extends ResponseSender {
public ZippedResponseSender(Data data) {
super(data);
}
@Override
protected void sendBody() {
// 데이터 압축 처리
}
}
- ResponseSender 클래스의 send() 메소드는 헤더와 몸체 내용을 전송하기 위해 sendHeader() 메소드와 sendBody() 메소드를 차례대로 호출하며, 이 두 메소드는 알맞게 HTTP 응답 데이터를 생성합니다.
- 이때, 이 두 메소드는 protected 공개 범위를 갖고 있기 때문에 하위 클래스에서 오버라이딩이 가능
- ZippedResponseSender 클래스는 기존 기능에 압축 기능을 추가해 주는데, 이 기능을 추가하기 위해 ResponseSender 클래스의 코드는 바뀌지 않았다
- ResponseSender 클래스는 확장에는 열려 있으면서 변경에는 닫혀있는 것
- 다운 캐스팅을 한다
- instanceof와 같은 타입 확인 연산자가 사용된다면 해당 코드는 개방 폐쇄 원칙을 위반할 가능성이 높다
- 코틀린에서는
if (is 하위클래스타입)
를 사용하지 말자- 추가 구현 클래스가 생기면
if ( is 추가 구현 클래스 타입)
가 추가로 수정돼야 한다
- 추가 구현 클래스가 생기면
- 따라서 타입 캐스팅 후 실행되는 메소드가 변화 대상인지 확인해야 한다
- 비슷한 if~else 블록이 존재한다.
- 새로운 기능,패턴이 요구될 때마다 if/else 블록이 추가되어 메소드 변경이 필요시되면 ORP 원칙이 깨진것
- 개방 폐쇄 원칙은 유연함에 관련된 원칙
- 변화하는 부분을 추상화함으로써 기존 코드를 수정하지 않고도, 확장을 할 수 있게 만들어 준다
- 따라서, 우리는 개발을 하다가 코드에 대한 변화 요구가 발생하면, 변화와 관련된 구현을 추상화해서 개방 폐쇄 원칙에 맞게 수정할 수 있는지 확인해 보는 습관을 길러야 한다
상위 타입의 객체를 하위 타입의 객체로 치환해도 상위 타입을 사용하는 프로그램은 정상적으로 동작해야 한다
- 개방 폐쇄 원칙은 추상화와 다형성(상속)을 이용해서 구현했는데, 리스코프 치환 원칙은 개방 폐쇄 원칙을 받쳐 주는 다형성에 관한 원칙을 제공한다
- 리스코프 치환 원칙은 다음과 같다
개념적으로 상속 관계에 있는 것처럼 보여도 실제 구현에서는 상속 관계가 아닐 수도 있다.
- LSP의 대표적 예 : 직사각형-정사각형
- 실제 프로그램에서는 사격형과 정사각형을 상속으로 묶을 수 없는 것이다.
- 별개의 타입으로 구현해 주어야 한다.
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;
}
public void getWidth() {
return width;
}
public void getHeight() {
return height;
}
}
• 정사각형을 직사각형의 특수한 경우로 보고, 정사각형을 표현하기 위한 Square 클래스가 Rectangle 클래스를 상속받도록 구현을 했다고 하자. 정사각형은 가로와 세로가 모두 동일한 값을 가져야 하므로, Square 클래스는 Rectangle 클래스의 setWidth() 메서드와 setHeight() 메서드를 재정의해서 가로와 세로 값이 일치하도록 구현하였다.
public class Square extends Rectangle {
@Override
public void setWidth(int width) {
super.setWidth(widht);
super.setHeight(widht);
}
@Override
public void setHeight(int height) {
super.setWidth(height);
super.setHeight(height);
}
}
- Rectangle 클래스 타입을 인자로 받는 다음과 같은 메서드가 있다고 가정하자
- 높이와 폭을 비교해서 높이를 더 길게 만들어 주는 기능(여기서 메서드 실행 후 항상 height 값이 width 보다 커진다고 가정한다)
public void increaseHeight(Rectangle rec) {
if(rec.getHeight() <= rec.getWidth()) {
rec.setHeight(rec.getWidth() + 10);
}
}
- 여기서 위 메서드의 파라미터로 Square 객체가 전달되면, 오버라이딩 된 Square의 setHeight 메서드가 실행되고, 이 결과로 height 과 width의 값이 같아지게 된다
- Square 객체가 파라미터로 전달되는 경우에는 increaseHeight 메서드를 실행해도 높이 값이 폭 값보다 커지지 않는 것이다
- instanceof 연산자를 통해 Square 타입인 경우 걸러낼 수 있지만, instanceof 연산자를 사용한다는 것 자체가 리스코프 치환 원칙을 위반하는 것이다
- increaseHeight 메서드가 Rectangle 클래스의 확장에 열려 있지 않다는 것이다
- 정의된 명세를 지키자
- 변화가 발생하는 부분을 추상화하자
- 리스코프 치환 원칙은 개방 폐쇄 원칙과 관련되어 있다.
- 리스코프 치환 원칙을 어긴다면 확장 시 기존 코드를 계속 수정해주어야 한다.
- 이는 '확장에는 열려있고 변경에는 닫혀있어야 한다' 라는 개방 폐쇄 원칙의 개념에 위반된다
- 상속을 잘 정의하여 치환 가능성을 위배되지 않도록 설계해야
- 클라이언트는 자신이 사용하는 메소드에만 의존해야 한다
- 인터페이스는 그 인터페이스를 사용하는 클라이언트를 기준으로 분리해야 한다.
- 특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다.
- 인터페이스를 분리하게 되면 인터페이스가 명확해지고, 대체 가능성이 높아진다.
- 자동차 인터페이스 → 운전 인터페이스, 정비 인터페이스로 분리 → 이렇게 분리함으로써 정비 인터페이스 자체가 변해도 운전자 클라이언트에 영향을 주지 않음. 사용자 클라이언트 → 운전자 클라이언트, 정비사 클라이언트로 분리
- 사용하지 않는 인터페이스 변경에 의해 발생하는 소스 재컴파일 문제
- 한 클라이언트으로 부터 발생하는 변경의 여파가 다른 클라이언트에 영향을 미치는 문제 => SRP 원칙과 연관됨 = 재사용성에 문제가 생김
- 클래스에 필요한 메소드만 선언할 수 있습니다.
- 재사용성이 높아집니다.
- 용도가 명확한 인터페이스를 제공할 수 있습니다
복합기 예시
- 위처럼 복합기를 프린터와 복사기로 나누어 생각해 볼 수 있다.
- 고수준 모듈은 저수준 모듈의 구현에 의존해서는 안 된다. 저수준 모듈이 고수준 모듈에서 정의한 추상 타입에 의존해야 한다.
- 의존 관계를 맺을 때 자신보다 변하기 쉬운 것에 의존하지 마라
- 고수준 모듈: 어떤 의미 있는 단일 기능을 제공하는 모듈
- 저수준 모듈: 고수준 모듈의 기능을 구현하기 위해 필요한 하위 기능의 실제 구현
- 프로그램의 변경을 어렵게 만든다
- 저수준 모듈이 변경되면, 고수준 모듈에도 변경이 일어나는 문제 때문
- 변동성이 큰 구체(concrete) 클래스를 참조하지마라
- 대신, 추상 인터페이스를 참조하라
- 변동성이 큰 구체 클래스로 부터 파생하지마라
- 상속은 소스코드에 존재하는 모든 관계중 가장 강력한 동시에 뻣뻣해서 변경하기 어렵다
- 구체 함수를 오버라이드 하지말라
- 구체함수는 소스 코드 의존성을 필요로 한다
- 따라서 구체함수를 상속 및 오버라이드 하면 이러한 의존성을 제거할수 없게 되며, 의존성을 상속하게 된다
- 추상 인터페이스 함수로 선언하고, 구현체들에게 각자 용도에 맞게 구현하는게 낫다
-
각 원칙들은 서로 밀접하게 연결되어 있음
-
단일 책임 + 인터페이스 분리 원칙 -> 객체가 많은 책임(기능)을 가지지 않도록 함
-
변경의 여파를 최소화함
-
유연성과 재사용성 높임
-
리스코프 치환 + 의존 역전 원칙 -> 개방 폐쇄 원칙을 지원
-
다형성(리스코프 치환 원칙이 도와줌) + 추상화(리스코프 치환 원칙이 도와줌)를 통해 유연성과 재사용성 높임
-
SOLID 원칙은 사용자 관점에서의 설계/기능 사용을 지향
-
인터페이스 분리 원칙
-
클라이언트 입장 에서 인터페이스 분리
-
의존 역전 원칙
-
저수준 모듈을 사용하는 고수준 모듈 입장에서 추상화 타입을 도출
-
리스코프 치환 원칙
-
사용자에게 기능 명세를 제공하고 이에 대한 기능 구현을 약속