객체지향 언어의 역사는 50년 이상의 세월이 흘렀다. 이런 많은 세월 동안 수많은 시행착오와 베스트 프랙티스 속에서 객체지향 설계(OOD: Object Oriented Design)의 정수라고 할 수 있는 5원칙이 집대성 되었는데, 이것이 바로 SOLID이다. SOLID는 아래 5가지 원칙의 앞 머리 알파벳을 따서 부르는 이름이다.
이 원칙들은 응집도를 높이고 결합도를 낮추는 고전 원칙을 객체 지향의 관점에서 재정립 한 것이라고 할 수 있다.
결합도(coupling)와 응집도(cohesion)
결합도: 모듈(클래스) 간의 상호 의존 정도.
응집도: 하나의 모듈 내부에 존재하는 구성 요소들의 기능적 관련성.
SOLID 원칙은 물리적으로 존재하는 것이 아니라 개념적으로 존재하는 것이다. 따라서 사람마다 다르게 해석될 수 있다.
단일 책임 원칙(Single Responsibility Principle)이란 클래스는 하나의 책임만 담당해야 한다는 원칙이다. 이 원칙이 지켜지지 않은 예시들을 보면서 더 자세히 설명해보겠다.
나쁜 예시1
class 사람 { String 군번; ... } ... 사람 로미오 = new 사람(); 사람 줄리엣 new 사람(); 줄리엣.군번 = "70000784"; // 이건?
줄리엣은 여자라 군대를 가지 않았다. 따라서 군번이 존재해선 안되는 것이다. 따라서 사람 클래스를 여자, 남자 클래스로 분할하고, 남자 클래스에만 군번 속성을 갖게 리팩토링 하면 SRP 원칙을 잘 준수하는 코드를 만들 수 있다. 그리고 이때 여자와 남자의 공통점은 사람이라는 클래스에 만들고 이를 상속 받으면 된다.
나쁜 예시2
class 강아지 { final static Boolean 수컷 = true; final static Boolean 암컷 = false; Boolean 성별; void 소변보다() { if(this.성별 == 수컷) { // 한쪽 다리를 들고 소변을 본다. } else { // 뒷다리 두 개를 굽혀 앉은 자세로 소변을 본다. } } }
강아지 클래스는 강아지가 수컷이냐 암컷이냐에 따라 소변보다() 메서드에서 분기 처리가 진행되는 것을 볼 수 있다. 즉, 강아지 클래스는 수컷, 암컷 강아지의 메서드를 모두 구현하고 있다. 이는 단일 책임 원칙에 위배하고 있다는 것이다.
좋은 예시 2
abstract class 강아지 { abstract void 소변보다(); } class 수컷강아지 extends 강아지 { void 소변보다() { // 한쪽 다리를 들고 소변을 본다. } } class 암컷강아지 extends 강아지 { void 소변보다() { // 뒷다리 두 개를 굽혀 앉은 자세로 소변을 본다. } }
이렇게 나누어준다면 단일 책임 원칙을 준수할 수 있다.
개방 폐쇄 원칙(Open Closed Principle)이란, "소프트웨어 엔티티(클래스, 모듈, 함수 등)는 확장에 대해서는 열려 있어야 하지만 변경에 대해서는 닫혀 있어야 한다." 라는 원칙이다. 이 원칙을 조금 더 간단하게 의역해보면 "자신의 확장에는 열려 있고, 주변의 변화에 대해서는 닫혀 있어야 한다." 라는 의미이다. 현실 세계에서의 OCP 원칙 적용 사례를 보면 좀 더 쉽게 이해할 수 있을 것이다.
현실 세계의 예시 1
운전자(소프트웨어 엔티티)는 기존에 몰던 자동차의 차종이 바뀌어도(확장됨) 운전을할 수 있다.
즉, 자동차는 항상 운전자에게 맞춰진다.
현실 세계의 예시 2
JAVA로 만든 소프트웨어(소프트웨어 엔티티)는 운영체제의 종류와 상관없이(확장됨) JVM을 통해 실행할 수 있다.
즉, JVM은 항상 JAVA 소프트웨어에게 맞춰진다.
현실 세계의 예시 3
손님(소프트웨어 엔티티)은 편의점 직원이 바뀌어도(확장됨) 물건을 구매할 수 있다.
즉, 편의점 직원은 항상 손님에게 맞춰진다.
현실 세계의 예시들을 객체지향적으로 구현하기 위해서는 소프트웨어 엔티티 사이에 인터페이스가 존재해야 한다.
예시1도 운전자와 자동차 사이에 운전하다() 라는 인터페이스를 만들어 어떤 운전자도 운전하다() 라는 것만 익히면 앞으로 운전하다() 라는 인터페이스를 상속한 어떤 자동차든 운전할 수 있게 된다. 이렇게 되면 자동차 입장에선 개방에는 열려있고, 운전자 입장에선 주변 변화에 대해서 폐쇄되어 있게 된다.
리스코프 치환 원칙(Liskov Substitution Principle)이란 "서브 타입은 언제나 자신의 기반 타입(base type)으로 교체할 수 있어야 한다." 는 의미이다.
상속에 대해 설명하면서 객체 지향에서의 상속은 조직도나 계층도가 아닌 분류도가 돼야 한다고 했다. 객체 지향의 상속은 다음의 조건을 만족해야 한다.
위 두 개의 문장대로 구현된 프로그램이라면 이미 리스코프 치환 원칙을 잘 준수하고 있다. 하지만 상속이 조직도나 계층도 형태로 구축되었다면 이는 LSP 원칙을 잘 준수하지 못하는 코드이다.
"인터페이스하다"는 다음을 의미한다.
- AudoCloseable - 자동으로 닫힐 수 있어야 한다.
- Appendable - 덧붙일 수 있어야 한다.
- Cloneable - 복제할 수 있어야 한다.
- Runnable - 실행할 수 있어야 한다.
다음은 LSP 원칙의 예시이다.
좋은 예시 1) 동물(기반 타입) ← 펭귄(서브 타입) 상속 관계
동물 뽀로로 = new 펭귄();
나쁜 예시 1) 아버지(기반 타입) ← 딸(서브 타입) 상속 관계
아버지 춘향이 = new 딸(); // ??
우선 동물-펭귄 상속 관계는 전혀 문제가 없으므로 LSP 원칙을 잘 준수하고 있다. 하지만 아버지-딸 관계는 문제가 발생한다. 춘향이는 딸인데 아버지의 역할을 시킬 수 있단 말인가? 따라서 이는 LSP 원칙에 위배되는 코드이다.
LSP 원칙은 계층도/조직도의 경우가 아닌, 분류도에서 지켜질 수 있다.
인터페이스 분리 원칙(Interface Segregation Principle)이란 "클라이언트는 자신이 사용하지 않는 메서드에 의존 관계를 맺으면 안 된다." 라는 의미이다.
인터페이스 분리 원칙은 다른 말로 인터페이스 최소주의 원칙이다. 인터페이스를 제공할 땐 최소한의 메서드만 제공해야 한다는 것을 의미한다다. 그리고 최소주의에 기반하여 상위 클래스는 풍성할수록 좋고, 인터페이스는 작을수록 좋다.
빈약한 상위 클래스 예시
// 빈약한 상위 클래스 public class 사람 { String 이름; public void 먹다() {...} } // 풍성한 하위 인터페이스들 public class 학생 extends 사람 { String 생일; String 주민등록번호; String 학번; public void 자다() {...} public void 소개하다() {...} public void 공부하다() {...} } public class 군인 extends 사람 { String 생일; String 주민등록번호; String 군번; public void 자다() {...} public void 소개하다() {...} public void 훈련하다() {...} }
풍성한 상위 클래스 예시
// 풍성한 상위 클래스 public class 사람 { String 이름; String 생일; String 주민등록번호; public void 먹다() {...} public void 자다() {...} public void 소개하다() {...} } // 빈약한 하위 인터페이스들 public class 학생 extends 사람 { String 학번; public void 공부하다() {...} } public class 군인 extends 사람 { String 군번; public void 훈련하다() {...} }
이처럼 인터페이스는 "~할 수 있는(is able to)"이라는 기준으로 만드는 것이 정석이다.
마지막으로 ISP 원칙과 SRP 원칙은 같은 문제에 대한 두 가지 다른 해결 방법이다. 책임과 역할을 분리하여 유지보수성과 가독성을 높이는 전략이고, 이 두 가지 방법 모두 해결이 가능하다. 하지만 특별한 경우가 아니라면 SRP 원칙을 사용하는 것이 더 좋은 해결책이다.
의존 역전 원칙(Dependency Inversion Principle)이란 "자신보다 변하기 쉬운 곳에 의존하지 마라." 라는 의미입니다. 예시를 들어보겠습니다.
자동차는 스노우타이어에 의존합니다. 하지만 스노우타이어는 겨울이 끝나면 일반 타이어로 교체해야 한다. 이때, 자동차는 그 영향에 그대로 노출되어 있다. 이는 외부 환경에 부서지기 쉬운 나쁜 코드다.
이를 해결하기 위해서 자동차와 스노우타이어 사이에 타이어 라는 인터페이스를 두고 자동차는 타이어 클래스를, 스노우타이어도 타이어 인터페이스를 의존하도록 합니다. 이렇게 되면 스노우타이어는 타이어 인터페이스에 역으로 의존하게 된다. 이를 DIP 원칙, 의존 역전 원칙 이라고 부른다.
이때 OCP 원칙과 상당히 유사한 부분이 있는데, 객체 지향 4대 특성(추상화, 상속, 다형성, 캡슐화)을 활용하기 때문에 결국 비슷비슷한 코드의 모습으로 구현될 수 밖에 없다.
SOLID를 이야기 할 때 빼놓을 수 없는 것이 SoC다. SoC는 관심사의 분리(Separation Of Concerns)의 머리글자이다. 관심이 같은 것끼리는 하나의 객체 안으로 또는 친한 객체로 모으고, 관심이 다른 것은 가능한 한 따로 떨어져 서로 영향을 주지 않도록 분리하라는 것이다. 하나의 속성, 하나의 메서드, 하나의 클래스, 하나의 모듈, 또는 하나의 패키지에는 하나의 관심사만 들어 있어야 한다는 것이다. Soc를 적용하면 자연스럽게 SOLID 하게 코딩을 하게 된다.
다음은 SOLID 원칙을 다시 한 번 정리해보겠습니다.
SOLID 원칙을 적용하면 소스파일의 개수는 더 많아지지만 이를 통해 얻는 이점이 더 크다.
참조
[도서] 스프링 입문을 위한 자바 객체지향의 원리와 이해 5장