SOLID란
객체지향 프로그래밍에는 5원칙이라고 있다. 바로 5원칙에 대해 알아보자.
4대특징인 추상화, 캡슐화, 다형성, 상속은 알겠는데 도대체 이 5원칙이란게 무엇인가요 ?
먼저 위키백과를 통해 의미를 파악해보도록 하겠다.
# 위키백과 https://ko.wikipedia.org/wiki/SOLID_(%EA%B0%9D%EC%B2%B4_%EC%A7%80%ED%96%A5_%EC%84%A4%EA%B3%84)
5원칙 즉, SOLID란 로버트 마틴이 2000년대 초반에 명명한 객체 지향 프로그래밍 및 설계의 다섯 가지 기본 원칙을 마이클 페더스가 두문자어 기억술로 소개한 것이다.
유지 보수와 확장이 쉬운 시스템을 만들고자 할 때 이 원칙들을 함께 적용할 수 있다는 것.
그리고 이 원칙들은 애자일 소프트웨어 개발과 적응적 소프트웨어 개발의 전반적 전략의 일부라고 한다.
나는 OOP(객체 지향 프로그래밍 약어)의 4대 특징만 알아도 된다고 생각했다.
하지만 그저 완성만이 목적인 시스템을 구축하는 것 보다 설계에서도 좋아야 한다는 것을 늦게나마 깨달았다.
나는 잘못된 지식과 마음을 바로잡기위해 처음부터 되돌아보기로 하였고 초기에 잠깐 스쳤던 SOLID (5원칙)을 알 수 있었다.
# 다른 블로그(https://limkydev.tistory.com/77)
객체 지향의 4대 특성인 캡, 상, 추, 다를 이용해 올바른 객체지향 설계를 도와주는 원칙.
객체 지향의 4대 특성을 잘 한다고 해서 설계를 잘한는 것은 아니다. 물론 이 4가지 특성을 살릴수록 설계는 좋아진다. 그리고 원칙이라는 것을 곁들어야 한다.
SOLID 원칙들은 자기 자신 클래스 안에 응집도는 내부적으로 높이고, 타 클래스들 간 결합도는 낮추는
High Cohesion - Loose Coupling 원칙을 객체 지향의 관점에서 도입했다.
즉, 좋은 소프트웨어는 응집도는 높고 결합도는 낮다. (정처기에서도 나온 문제)
결국 모듈 또는 클래스 당 하나의 책임을 주어 더 독립된 모듈(클래스)을 만들기 위함이다.
이러하게 설계된 소프트웨어는 재사용이 용이하고 수정이 덜하기 때문에 유지 보수가 용이해진다.
객체 지향 설계 5대 원칙 (SOLID)
# 위키백과, 타 블로그
SRP(단일 책임 원칙; Single responsibility principle) : 한 클래스는 하나의 책임만 가져야 한다.
- 어떤 클래스를 변경해야 하는 이유는 오직 하나 뿐이어야 한다
- 클래스의 역할과 책임을 너무 많이 주지 마라
- 클래스를 설계할 때 어플리케이션의 경계를 정하고, 추상화를 통해 어플리케이션 경계 안에서 필요한 속성과 메서드를 선택하여 설계 해야 한다.
* 예를 들어 사람 클래스를 정의하고 사람이라는 클래스 안에 이름, 나이, 혈액형, 병력 등등의 필드들을 써놓고 생성했다.
이럴 경우 병원 어플리케이션에서 사람 클래스명보다 환자라는 클래스명이 어울릴 것이고 관심있는 필드는 이름, 나이, 키 혈액형, 병력 정도 일 것이다.
이렇게 사람이라는 즉, 클래스에 모든 사람에 관련된 모든 기능을 다 넣기보다 목적과 취지에 맞는 속성과 메서드로 구성 해야 한다는 것이다. (관련된 책임만 주라는 것) 이것이 SRP(단일 책임의 원칙)은 추상화와 깊은 관련이 있다는 소리다.
요약하자면 속성(필드)이든 행위(메서드)든 하나의 클래스로만 한꺼번에 구현하는 것이 아닌 역할에 맞게 클래스를 설계 해야 한다.
또 상황에 안맞는 행위가 이뤄지면 그로 인해 다른 속성과 메서드도 영향을 받게 되며 다른 상황에 맞는 행위가 지장 받게 될 수도 있다.
더 나아가 속성, 메서드, 패키지, 모듈, 컴포넌트, 프레임워크에 단일 책임을 주고 독립적으로 모듈화 시키는 것이 바로 SRP(단일 책임 원칙)이다.
OCP(Open/closed principle) : 소프트웨어 요소는 확장에는 열려 있으나 변경에는 닫혀 있어야 한다.
- 소프트웨어 엔티티(클래스, 모듈, 함수 등)는 확장에 대해서는 열려 있어야 하지만 변경에 대해서는 닫혀 있어야 한다.
- 자신의 확장에는 열려 있고, 주변의 변화에 대해서는 닫혀 있어야 한다.
# 타 블로그 예시
A 드론과 연동하는 프로그램을 개발한다고 생각해보자.
하지만 새로이 출시한 B 드론이 더 싸고 성능도 좋다. 그렇다면 A 드론과 연동한 프로그램은 수정이 불가피하다.
이럴 경우 프로그램은 어느 회사의 제품 드론이 연동하던 프로그램의 수정을 최소화 하면서 확장적으로 다른 제품의 드론과 연동할 수 있어야 한다.
이렇듯 직접적으로 각 사의 제품 클래스의 메서드를 호출하고 결합도를 높게 설계 했다면, 확장적이지 못할 뿐더러 많은 수정이 발생돼 유지 보수가 어려워 진다.
이것을 OCP(개방 폐쇄 원칙)에 의거하여 수정해보자.
위 예시와 같이 상위클래스 또는 인터페이스를 중간에 두어 직접적인 연동은 피하게 설계한다.
따라서 드론 프로그램은 코드 수정이 없으면서도 다른 제품과의 연동엔 확정적이게 된다. 상위클래스나 인터페이스는 일종의 완충 장치인 것이다.
각 제품의 드론 클래스는 드론 interface를 implements하고 추상메서드인 날기(), 착지(), 상하좌우조작() 메서드를 오버라이딩해서 재정의 하면 된다.
# 타 블로그( https://wjun.tistory.com/68?category=757787 , https://blog.naver.com/PostView.nhn?isHttpsRedirect=true&blogId=1ilsang&logNo=221105781167&parentCategoryNo=&categoryNo=88&viewDate=&isShowPopularPosts=false&from=postView )
- 코드로 실습하는데에 있어 최대한 정확하게 전달하려고 다른 블로그도 참고했다.
- OCP 원칙을 고려하지 않을 시 새로운 기능을 추가하기 위해선 Client 클래스를 수정해야하는 번거로움이 있다. 그래서 OCP 설계를 고려하면 아래와 같은 도식화를 볼 수 있다.
더 깔끔한 예시
위 설계를 보면 각각의 DataBase에 모두 확장적(개방)이면서 자바어플리케이션입장에서 수정은 폐쇄적인 것 임을 알아야 한다.
이것이 바로 OCP(개방 패쇄 원칙)이다.
- 다른 예제로한 코드를 보자.
// interface
// Figure라는 인터페이스 생성 후 calculator라는 계산을 명시하는 메서드를 정의한다.
public interface Figure {
public double calculator();
}
// Rectangle
// 사각형 클래스를 생성후 Figure 인터페이스를 상속받아 메소드를 오버라이딩 한 후 로직을 정의한다.
public class Rectangle implements Figure{
public double width;
public double length;
@Override
public double calculator() {
return width * length;
// 사각형의 넓이를 구하는 식
}
}
// Circle
// 원 클래스를 생성후 Figure 인터페이스를 상속받아 메소드를 오버라이딩 한 후 로직을 정의한다.
public class Circle implements Figure{
public double radious;
@Override
public double calculator() {
return radious * radious / 3.14;
// 원의 넓이를 구하는 식
}
}
/* 여기까지는 OOP 특징인 다형성이 드러난다. 일반적으로 인터페이스를 상속받아 공통 기능이 필요한
클래스에 메소드를 오버라이딩 하여 각자에 맞는 로직을 정의하는 것이다. */
// Calculation
// 구현된 클래스들을 연결시켜줄 즉, 인터페이스를 받아올 클래스를 생성한다.
public class Calculation {
// 파라미터는 인터페이스인 Figure로 정의한다. 클래스면 2개를 만들어야 함 OCP의 원칙에 위배됨.
public double CalculatorFigure(Figure figure) {
// 파라미터로 가져온 figure의 calculator 메서드 값을 리턴해준다.
return figure.calculator();
}
}
// Client
// 말만 클라이언트다 ㅎㅎ
public class OcpO {
public static void main(String[] args) {
Calculation cal = new Calculation(); // 인터페이스를 받아갈 클래스를 생성한다.
cal.CalculatorFigure(new Circle());
/*
여기서 바로 위의 클래스 안에 있는 메소드를 호출할것인데 파라미터 값을 잘 보자
우리가 어떤 로직을 사용하느냐 그러니까 어떤 클래스를 사용할 것인가를 정해주고
해당 객체를 넣어주면 된다. (Figure를 상속받았기 때문에 가능함)
만약 사각형의 넓이를 사용하고 싶다면 new Rectangle 클래스를 넘겨주면 된다.
*/
}
}
- 그러니까 이 코드 로직의 장점이자 OCP 설계의 장점이 무엇이냐하면 중간에 인터페이스 클래스를 두어서 어떠한 작업이 추가되어도 Client 단은 상관하지 않도록 분리해준 것이다.
- 요약하자면 OCP는 클래스를 변경하지 않고도 대상 클래스의 환경을 변경할 수 있는 설계가 되어야 한다.
** 추가로 저 코드 실행은 되지만 값은 안나온다 나오게 해주려면 아래와 같이 수정해주자.
// 일부만
// Circle
public class Circle implements Figure{
public double radious;
public Circle(double radious) {
this.radious = radious;
}
@Override
public double calculator() {
return radious * radious / 3.14;
}
}
// Rectangle
public class Rectangle implements Figure{
public double width;
public double length;
public Rectangle(double width, double length) {
this.width = width;
this.length = length;
}
@Override
public double calculator() {
return width * length;
}
}
// main
public class OcpO {
public static void main(String[] args) {
Calculation cal = new Calculation();
double val = cal.CalculatorFigure(new Circle(10.0));
System.out.println(val);
}
}
- 나는 로직 테스팅을 위해 생성자를 이용하였다. 다른 방법도 많으니 연습삼아 해보는것을 추천한다.
LSP(개방-폐쇄 원칙; Liskov substitution principle) : 프로그램의 객체는 프로그램의 정확성을 깨뜨리지 않으면서 하위 타입의 인스턴스로 바꿀 수 있어야 한다.
- 서브 타입은 언제나 자신의 기반 타입으로 교체할 수 있어야 한다.
- 부모 클래스와 자식 클래스 사이의 행위가 일관성이 있어야 한다는 의미이다.
- 객체 지향의 상속은 조직도나 계층도가 아닌 분류도가 되어야 한다.
객체 지향의 상속은 다음의 조건을 만족해야 한다.
하위 클래스 is a kind of 상위 클래스 - 하위 분류는 상위 분류의 한 종류다
구현 클래스 is able to 인터페이스 - 구현 분류는 인터페이스 할 수 있어야 한다.
위 두개의 문장대로 프로그래밍을 한다면 리스코프 치환 원칙(LSP)을 잘 지키고 있다고 볼 수 있다.
하지만 이 뜻대로 되지 않은 코드가 존재할 수 있는데 바로 상속이 관한 조직도나 계층도 형태로 구축된 경우다.
헷갈리지 않게 이번에 이해해보자.
아버지를 상위 클래스로 하는 딸이라는 하위 클래스가 있다. 바로 전형적인 계층도 형태이며 객체 지향의 상속을 잘못 적용한 예이다.
아버지만이 할 수 있는 일을 아들이나 딸이 상속, 인터페이스를 받을 수 없다.
아버지 또한 마찬가지이다.
이렇게 보다시피 이름이 춘향이라는 것은 좋지만 아빠의 역할을 맡기는 형태가 된다.
춘향이는 아버지형의 객체 참조 변수이기에 아버지 객체가 가진 행위(메서드)를 할 수 있어야 하는데 춘향이에게 아버지의 어떤 역할을 시킬 수 있을까??
요약하자면 아버지만이 할 수 있는 일을 춘향이도 할 수 있다는 것이다. (잘못된 경우)
그럼 어떤것이 분류도를 만족하는 혹은 LSP 원칙을 만족하는 분류도인가 ??
동물을 상속 또는 인터페이스로 만들 수 있다.
포유류는 동물을 상속받거나 인터페이스를 받을 수 있다.
고래와 박쥐도 포유류를 상속받거나 인터페이스를 받을 수 있다.
바로 이것이다.
누가 봐도 논리적인 흠이 전혀 없다.
펭귄 한마리 이름은 뽀로로이고, 동물의 행위(메서드)를 잇게하는데 전혀 이상함이 없다.
아버지와 딸 구조(계층도, 조직도)는 LSP 원칙을 위배하고 있고
동물과 펭귄 구조(분류도)는 LSP 원칙을 만족하고 있다.
결론은
"하위 클래스의 인스턴스는 상위형 객체 참조 변수에 대입해 상위 클래스의 인스턴스 역할을 하는데 문제가 없어야 한다." ( 로버트 C 마틴 )
ISP(인터페이스 분리 원칙; Interface segregation principle) : 특정 클라이언트를 위한 인터페이스 여러 개가 범용 인터페이스 하나보다 낫다.
- 클라이언트는 자신이 사용하지 않는 메서드에 의존 관계를 맺으면 안된다.
- 상황에 맞는 메서드만 제공해라
# 타 블로그 ( https://limkydev.tistory.com/77 )
ISP 원칙은 SRP(단일 책임의 원칙)과 같은 원인에 대한 다른 해결책을 제시하는 것이다. 너무 많은 책임을 주어 상황과 관련 되지 않은 메서드까지 구현했다면 SRP(단일 책임 원칙)은 그 클래스를 여러개의 클래스로 나누어 버린다.
하지만 ISP(Interface segregation principle)은 해당 클래스를 내비두고 인터페이스 최소주의 원칙에 따라 각 상황에 맞는 기능만 제공하도록 필터링 한다고 생각하면 쉽다.
위의 그림에서 개발 역할만 하는 상황이 있다면 노래하기, 염력사용하기, 서빙하기 와 같이 관련 없는 메서드는 필요가 없다. 다른 역할도 동일하다.
이와 같은 경우 ISP(인터페이스 분할 원칙)을 적용해 각 역할에 맞는 메서드만 제공하도록 해야한다.
이렇게 각 상황에 맞게 인터페이스에서 제공해주는 메서드만 가져와 사용할 수 있다. 코드로도 봐보자.
각각에 맞는 인터페이스를 정의하고 상황에 맞는 메서드를 정의한다.
// 개발자
public interface Developable {
public void develop();
}
// 초능력자
public interface Psychicable {
public void superpower();
}
// 가수
public interface Singerable {
public void sing();
}
// 음식점 직원
public interface Staffable {
public void serving();
}
인터페이스 다중 상속을 통통해 각각의 메서드를 오버라이딩하여 정의해준다.
public class Me implements Developable, Psychicable, Singerable, Staffable{
@Override
public void serving() {
System.out.println("서빙함");
}
@Override
public void sing() {
System.out.println("노래함");
}
@Override
public void superpower() {
System.out.println("초능력씀(염력)");
}
@Override
public void develop() {
System.out.println("개발함");
}
}
여기서 만약 회사에서 개발역할을 해야 한다면
참조 변수 타입을 Developable으로 정의하고 Me 인스턴스를 받으면 자동 타입 변환이 발생하여 Upcasting(업캐스팅)이 되기 때문에 Me 참조 변수는 기본적으로 상위 클래스인 Developable의 역할만 하게 된다.
public class Me{
public static void main(String[] args){
Developable me = new Me(); // 자동 타입 변환 (업캐스팅; upcasting)
me.develop();
}
}
또 다른 예시를 보겠다.
# 타 블로그 ( https://huisam.tistory.com/entry/ISP )
// 스마트폰 공통 기능
public interface Smartphone {
void tell();
void facecall();
}
// 아이폰 기능
public interface Iphonefunction {
void airdrop();
}
// 갤럭시 폰 기능
public interface Galaxyfunction {
void samsungpay();
}
// 아이폰
public class Iphone implements Smartphone, Iphonefunction{
@Override
public void airdrop() {
System.out.println("아이폰끼리 데이터 공유 기능");
}
@Override
public void tell() {
// 공통
System.out.println("전화하기");
}
@Override
public void facecall() {
// 공통
System.out.println("영상통화");
}
}
// 갤럭시 폰
public class Galaxy implements Smartphone, Galaxyfunction{
@Override
public void samsungpay() {
System.out.println("삼성페이 결제");
}
@Override
public void tell() {
// 공통
System.out.println("전화하기");
}
@Override
public void facecall() {
// 공통
System.out.println("영상통화");
}
}
이렇게 상황에 맞는 관련된 메서드만 제한하여 사용할 수 있고 각각의 기능에 맞게 리팩토링도 가능하다.
ISP(인터페이스 분할 원칙)에는 인터페이스 최소 주의와 함께 상위클래스는 풍성할수록 인터페이스는 작을 수록 좋다는 개념도 있다고 한다.
(위에 코드에서 Smartphone을 상위클래스로 해도 괜찮을거 같다는 생각이 든다)
빈약한 상위 클래스인 경우 하위 클래스인 학생과 군인은 같은 속성인 생일, 주민번호와 같은 메서드인 자다(), 소개하다()를 공통적으로 가지고 있는 것을 볼 수 있다.
풍성한 상위 클래스인 경우에는 상위 클래스가 하위 클래스들이 공통으로 가질 수 있는 속성과 메서드를 상속해주고 있다.
위 다이어그램을 토대로 코딩을 하게 되면 빈약한 상위 클래스를 이용하는 것보다 풍성한 상위 클래스를 사용하는 것이 불필요한 형변환이 없고, 사용가능한 부분이 많은 것을 알 수 있다.
ISP원칙의 개념은 이정도로 알고가자 .
DIP(의존관계 역전 원칙; Depenency inversion principle) : 프로그래머는 추상화에 의존해야지 구체화에 의존하면 안된다. 의존성 주입은 이 원칙을 따르는 방법 중 하나다.
- 자주 변경되는 구체(Concrete) 클래스에 의존하지 마라
- 자신보다 변하기 쉬운 것에 의존하지 마라
- 고차원 모듈은 저차원 모듈에 의존하면 안된다. 이 두 모듈 모두 다른 추상회된 것에 의존해야 한다.
- 추상화된 것은 구체적인 것에 의존하면 안된다. 구체적인 것이 추상화된 것에 의존해야 한다.
# 타블로그
마지막 DIP(의존관계 역전의 원칙)은 말 그대로 자신보다 변하기 쉬운 것에 의존하지 말라는 말이다.
좀더 자세하게 보자면 추상클래스 또는 상위클래스는 구체적인 구현클래스 또는 하위클래스에게 의존적이면 안된다.
왜냐면 구체적인 클래스는 코딩에 있어서 가장 전면적으로 노출되고 사용되기 때문에 변화에 민감하다.
만약, DIP에 의해서 설계하지 않는다면, 구체화된 클래스가 수정될 때마다 상위클래스나 추상클래스가 변해야 하는데 또 그 상위 또 그 상위 연속적으로 계속 연관 되어 있는 클래스들이 수정되어야 한다. 따라서 하위클래스나 구체 클래스에게 의존하면 안된다.
DIP(의존관계 역전 원칙)을 적용하지 않는 설계이다.
위의 그림을 보면 자동차는 스노우 타이어를 장착하고 있는데 이 스노우 타이어는 계절의 영향을 받기 때문에 겨울에 눈이나 빙판길이 아닌이상 다른 타이어로 교체하는 것이 마땅하다.
여름에도 스노우 타이어를 달고다닐것은 아니지 않은가 ?
이렇게 자동차라는 클래스가 자신보다 더 변화에 민감한 스노우 타이어를 의존하고 있는 것이다.
아래 그림은 이 의존의 일방적인 방향으로 역전시킨 형태이다.
이런식으로 자신보다 변하기 쉬운 것에 의존하던 것을(스노우 타이어) 추상화 된 인터페이스나 상위클래스(부모; 상속)를 두어 변하기 쉬운 것의 변화에 영향 받지 않게 의존 방향을 역전시켰다.
즉 자동차 클래스는 타이어 인터페이스에 의존하면서 직접적으로 스노우, 일반, 광폭 타이어와 의존하는 것을 피했다.
또 스노우, 일반, 광폭 타이어는 기존에 어떤 것도 의존하지 않았지만, 인터페이스를 의존 해야 한다.
이것이 DIP(의존관계 역전 원칙)이다.
또 한 가지 알아 둘 것은 상위로 갈수록 더 추상적이고 변화에 민감하지 않고 수정 가능성이 낮아진다는 사실도 알아두면 좋다.
지금 이 DIP(의존관계 역전의 원칙)은 OCP(개방-폐쇄의 원칙)과 비슷하지 않은가?
결국은
하나의 설계 원칙 안에 다른 설계 원칙이 녹아져 있는 경우가 많다.
(다 알아야 한다는 것)
직접 배우면서 포스팅하는 글이라 타 블로그의 도움을 많이 받고 나도 스스로 이해하는 경험이었다. 좀 더 시간이 지나면 객체 지향의 특징과 원칙이 몸에 배여 있도록 노력하려고 한다.
그리고 정말 중요한것은 설계를 잘 해야 프로그램 유지 보수 측면에서 굉장히 용이하고 개발 시간과 비용을 절감할 수 있다고 한다. 실무에서도 그렇고 지금도 그렇게 느끼고 있다.
스스로 실습할 수 있을때까지 모두 열공 !
'자바과정 > Java' 카테고리의 다른 글
JVM의 구조 (0) | 2021.10.04 |
---|---|
JVM과 JDK와 JRE (0) | 2021.09.23 |
객체 지향 프로그래밍 4대 특징 (코드 실습) (0) | 2021.09.09 |
객체 지향 프로그래밍이란 (0) | 2021.09.09 |
Java 팀 실습(유니캐스트 Client&Server 에서 서로 메세지 주고받기) - 10일차 (0) | 2021.02.22 |
댓글