5. 객체 지향 설계 5원칙 - SOLID
개요
요리로 비유해서 객체 지향의 4대 특성이 주방기구라면 주방기구를 올바르게 사용하는 방법을 SOLID 라고 할 수 있다.
객체 지향 설계의 5원칙, 바로 SOLID는 아래 5가지 원칙의 앞 머리 알파벳을 따서 부르는 이름이다.
- SRP(Single Responsibility Principle): 단일 책임 원칙
- OCP(Open Closed Principle): 개방 폐쇄 원칙
- LSP(Liskov Substitution Principle): 리스코프 치환 원칙
- ISP(Interface Segregation Principle): 인터페이스 분리 원칙
- DIP(Dependency Inversion Principle): 의존 역전 원칙
이 원칙들은 응집도는 높이고 결합도는 낮추라는 고전 원칙을 객체 지향의 관점에서 재정립한 것이다.
💡 결합도는 모듈(클래스) 간의 상호 의존 정도로서 결합도가 낮으면 모듈 간의 상호 의존성이 줄어들어 객체의 재사용이나 수정, 유지보수가 용이하다.
응집도는 하나의 모듈 내부에 존재하는 구성 요소들의 기능적 관련성으로, 응집도가 높은 모듈은 하나의 책임에 집중하고 독립성이 높아져 재사용이나 기능의 수정, 유지보수가 용이하다.
SOLID는 객체지향 프로그램을 구성하는 다양한 곳에 적용되는 것이기에 애매모호하거나 보는 사람의 관점에 따라 다르게 해석될 수 있는 소지가 있다. SOLID 자체는 제품이 아닌 개념이기에 그렇다.
1. SRP - 단일 책임 원칙
“어떤 클래스를 변경해야 하는 이유는 오직 하나뿐이어야 한다.” - 로버트 C. 마틴
다음 그림과 같이 남자라고 하는 클래스와 남자 클래스에 의존하는 다양한 클래스가 있다고 생각해보자.
남자 클래스는 가지고 있는 역할이 너무 많아보이지 않는가?
이런 경우 아래와 같이 역할(책임)을 분리하는 것이 단일 책임 원칙이다.
단일 책임 원칙은 잘된 경우보다 잘못된 경우를 살펴보는 것이 이해하는 데 좋다.
- 속성이 SRP를 지키지 못하는 경우
남자는 반드시 군대를 가고, 여자는 절대로 군대를 가지 않는다고 가정해보자. 그런데 사람 클래스에 군번 속성이 있다면 어떻게 될까?
class 사람 {
String 군번;
...
}
...
사람 로미오 = new 사람();
사람 줄리엣 = new 사람();
줄리엣.군번 = "1573042009";
사람형 참조 변수 줄리엣이 가진 군번 속성에 값을 할당하거나 읽어 오는 코드를 제제할 방법이 없다.
이 소스를 단일 책임 원칙을 적용해서 리팩터링해보자.
class 사람 {
...
}
class 남자 extends 사람 {
String 군번;
}
class 여자 extends 사람 {
...
}
사람 로미오 = new 남자();
사람 줄리엣 = new 여자();
로미오.군번 = "1573042009";
이렇게 남자 클래스와 여자 클래스가 사람 클래스를 상속한 후 차이점만 각각 구현해도 되고, 공통점이 없다면 사람 클래스를 제거해도 된다.
- 메서드가 SRP를 지키지 못하는 경우
class 강아지 {
final static Boolean 수컷 = true;
final static Boolean 암컷 = false;
Boolean 성별;
void 소변보다() {
if(this.성별==수컷) {
//한쪽 다리를 들고 소변을 본다.
} else {
//뒷다리 두 개를 굽혀 앉은 자세로 소변을 본다.
}
}
}
강아지 클래스의 소변보다() 메서드가 수컷 강아지의 행위와 암컷 강아지의 행위를 모두 구현하려고 하기에 단일 책임 원칙을 위배하고 있는 것이다.
이 코드를 리팩터링해보자.
abstract class 강아지 {
abstract void 소변보다()
}
class 수컷강아지 extends 강아지 {
void 소변보다() {
//한쪽 다리를 들고 소변을 본다.
}
}
class 암컷강아지 extends 강아지 {
void 소변보다() {
//뒷다리 두 개를 굽혀 앉은 자세로 소변을 본다.
}
}
단일 책임 원칙과 가장 관계가 깊은 것은 바로 모델링 과정을 담당하는 추상화이다.
애플리케이션의 경계를 정하고 추상화를 통해 클래스를 선별하고 속성과 메서드를 설계할 때 반드시 단일 책임 원칙을 고려하는 습관을 들이자.
또한 리팩터링을 통해 코드를 개선할 때에도 단일 책임 원칙을 적용할 곳이 있는지 꼼꼼히 살피자.
2. OCP - 개방 폐쇄 원칙
“소프트웨어 엔티티(클래스, 모듈, 함수 등)는 확장에 대해서는 열려 있어야 하지만 변경에 대해서는 닫혀 있어야 한다.” - 로버트 C.마틴
개방 폐쇄 원칙은 다양한 곳에서 다양하게 이야기되고 있으니 딱 꼬집어서 예를 들기가 쉽지는 않다.
조금 억지스럽지만 몇 가지 예제를 통해 개방 폐쇄 원칙을 이해해보자.
어느 날 한 운전자가 마티즈를 구입했다. 그리고 열심히 마티즈에 적응했다고 해보자. 그리고 훗날 그 운전자에게 쏘나타가 생겼다.
차량을 바꿨더니 운전자에게 영향이 간다. 물론 현실 세계에서는 어느 정도 변화가 있겠지만 객체 지향 세계에서는 다른 해법이 있다.
이렇게 상위 클래스 또는 인터페이스를 중간에 둠으로써 다양한 자동차가 생긴다고 해도 운전자는 운전 습관에 영향을 받지 않게 되는 것이다.
다양한 자동차가 생긴다고 하는 것은 자동차 입장에서 자신의 확장에는 개방되어 있는 것이고, 운전자 입장에서는 주변의 변화에 폐쇄돼어 있는 것이다.
- JDBC
JDBC를 사용하는 클라이언트는 데이터베이스가 오라클에서 MySQL로 바뀌더라도 Connection을 설정하는 부분 외에는 따로 수정할 필요가 없다.
Connection 설정 부분을 별도의 설정 파일로 분리해두면 클라이언트 코드는 단 한 줄도 변경할 필요가 없다.
자바 애플리케이션은 데이터베이스라는 주변의 변화에 닫혀 있고, 데이터베이스를 교체한다는 것은 데이터베이스가 자신의 확장에는 열려 있는 것이다.
- 자바
각 운영체제별 JVM과 목적 파일(.class)이 있기에 개발자는 다양한 구동 환경에 대해서는 걱정하지 않고 본인이 작업하고 있는 개발 PC에 설치된 JVM에서 구동되는 코드만 작성하면 된다.
개발자가 작성한 소스코드는 운영체제의 변화에 닫혀 있고, 각 운영체제별 JVM은 확장에 열려 있는 구조가 되는 것이다.
3. LSP - 리스코프 치환 원칙
“서브 타입은 언제나 자신의 기반 타입(base type)으로 교체할 수 있어야 한다.” - 로버트 C.마틴
상속에 대해 설명하면서 객체 지향에서의 상속은 조직도나 계층도가 아닌 분류도가 돼야 한다고 했다.
객체 지향의 상속은 다음의 조건을 만족해야 한다.
- 하위 클래스 is a kind of 상위 클래스 - 하위 분류는 상위 분류의 한 종류다.
- 구현 클래스 is able to 인터페이스 - 구현 분류는 인터페이스할 수 있어야 한다.
위 두 개의 문장대로 구현된 프로그램이라면 이미 리스코프 치환 원칙을 잘 지키고 있다고 할 수 있다.
하지만 위 문장대로 구현되지 않은 코드가 존재할 수 있는데 바로 상속이 조직도나 계층도 형태로 구축된 경우다.
아버지를 상위 클래스(기반 타입)로 하는 딸이라는 하위 클래스(서브 타입)가 있다고 하자.
전형적인 계층도 형태이며, 객체 지향의 상속을 잘못 적용한 예다.
아버지 춘향이 = new 딸()
딸을 하나 낳아서 이름을 춘향이라 한 것까지는 좋은데 아빠의 역할을 맡기고 있다.
춘향이는 아버지형 객체 참조 변수이기에 아버지 객체가 가진 행위(메서드)를 할 수 있어야 하는데 아버지의 어떤 역할을 시킬 수 있을까?
이번에는 분류도 형태인 경우를 보자.
동물 뽀로로 = new 펭귄()
펭귄 한 마리가 태어나 뽀로로라 이름 짓고 동물의 행위(메서드)를 하게 하는데 전혀 이상함이 없다.
이런 경우를 리스코프 치환 원칙을 만족한다고 한다.
4. ISP - 인터페이스 분리 원칙
“클라이언트는 자신이 사용하지 않는 메서드에 의존 관계를 맺으면 안 된다.” - 로버트 C.마틴
단일 책임 원칙(SRP)과 인터페이스 분할 원칙(ISP)은 같은 문제에 대한 두 가지 다른 해결책이라고 볼 수 있다.
프로젝트 요구사항과 설계자의 취향에 따라 단일 책임 원칙이나 인터페이스 분할 원칙 중 하나를 선택해서 설계할 수 있다. 하지만 특별한 경우가 아니라면 단일 책임 원칙을 적용하는 것이 더 좋은 해결책이라고 할 수 있다.
앞서 상위 클래스는 풍성할수록 좋고, 인터페이스는 작을수록 좋다고 했다.
- 빈약한 상위 클래스를 이용하는 경우
public class Driver {
public static void main(String[] args) {
사람 김학생 = new 학생("김학생", new Date(2000, 01, 01), "20000101-1234567", "20190001");
사람 이군인 = new 군인("이군인", new Date(1998, 12, 31), "19981231-1234567", "19-12345678");
System.out.println(김학생.이름);
System.out.println(이군인.이름);
System.out.println(김학생.생일); //사용불가
System.out.println(이군인.생일); //사용불가
System.out.println(((학생) 김학생).생일); //캐스팅 필요
System.out.println(((군인) 이군인).생일); //캐스팅 필요
System.out.println(김학생.주민등록번호); //사용불가
System.out.println(이군인.주민등록번호); //사용불가
System.out.println(((학생) 김학생).주민등록번호); //캐스팅 필요
System.out.println(((군인) 이군인).주민등록번호); //캐스팅 필요
}
}
- 풍성한 상위 클래스를 이용하는 경우
public class Driver {
public static void main(String[] args) {
사람 김학생 = new 학생("김학생", new Date(2000, 01, 01), "20000101-1234567", "20190001");
사람 이군인 = new 군인("이군인", new Date(1998, 12, 31), "19981231-1234567", "19-12345678");
System.out.println(김학생.이름);
System.out.println(이군인.이름);
System.out.println(김학생.생일);
System.out.println(이군인.생일);
System.out.println(김학생.주민등록번호);
System.out.println(이군인.주민등록번호);
System.out.println(김학생.학번); //사용불가
System.out.println(이군인.군번); //사용불가
System.out.println(((학생) 김학생).학번); //캐스팅 필요
System.out.println(((군인) 이군인).군번); //캐스팅 필요
}
}
풍성한 상위 클래스를 이용하는 경우 빈약한 상위 클래스를 이용하는 경우에 비해 사용 불가능한 경우나 불필요한 형변환이 적다.
인터페이스는 그 역할에 충실한 최소한의 기능만 공개하라는 것이 이 시대 객체 지향 스승들의 가르침이라는 것을 꼭 명심하자.
인터페이스는 “~할 수 있는(is able to)”이라는 기준으로 만드는 것이 정석이라는 것을 기억하자.
5. DIP - 의존 역전 원칙
“고차원 모듈은 저차원 모듈에 의존하면 안 된다. 이 두 모듈 모두 다른 추상화된 것에 의존해야 한다.” ”추상화된 것은 구체적인 것에 의존하면 안 된다. 구체적인 것이 추상화된 것에 의존해야 한다.” ”자주 변경되는 구체(Concrete) 클래스에 의존하지 마라.” - 로버트 C.마틴
다음과 같이 자동차가 스노우타이어에 의존하는 관계가 있다.
자동차는 한 번 사면 몇 년은 타야 하는데 스노우타이어는 계절이 바뀌면 일반 타이어로 교체해야 한다.
자동차 자신보다 더 자주 변하는 스노우타이어에 의존하기에 부서지기 쉬움이라는 나쁜 냄새를 풍기고 있는 것이다.
이렇게 자동차가 추상화된 타이어 인터페이스에만 의존하게 함으로써 타이어가 변경돼도 자동차는 이제 그 영향을 받지 않는 형태로 구성된다.
이처럼 자신보다 변하기 쉬운 것에 의존하던 것을 추상화된 인터페이스나 상위 클래스를 두어 변하기 쉬운 것의 변화에 영향받지 않게 하는 것이 의존 역전 원칙이다.
정리 - 객체 지향 세계와 SOLID
SOLID는 객체 지향을 올바르게 프로그램에 녹여내기 위한 원칙이다. 따라서 객체지향 4대 특성을 제대로 이해해야 SOLID를 제대로 이해하고 활용할 수 있음을 기억하자.
SOLID를 이야기할 때 빼놓을 수 없는 것이 SoC다. SoC는 관심사의 분리(Separation Of Concerns)의 머리글자다.
관심이 같은 것끼리는 하나의 객체 안으로 또는 친한 객체로 모으고, 관심이 다른 것은 가능한 한 따로 떨어져 서로 영향을 주지 않도록 분리하라는 것이다.
하나의 속성, 하나의 메서드, 하나의 클래스, 하나의 모듈, 또는 하나의 패키지에는 하나의 관심사만 들어 있어야 한다는 것이 SoC다.
SOLID 원칙을 적용하면 소스 파일의 개수는 더 많아지는 경향이 있다.
하지만 SOLID 원칙을 적용함으로써 얻는 혜택에 비하면 늘어나는 소스 파일 개수에 대한 부담은 충분히 감수하고도 남을 만하다.
객체 지향은 현실 세계를 모델링한다고 이야기했다. 여기서 두 가지를 생각해 볼 수 있다.
객체 지향 세계는 현실 세계 같아야 한다는 것이 하나이고, 또 다른 하나는 모델링을 통해 추상화됐다는 것이다.