객체 지향 설계의 5원칙 S.O.L.I.D
SOLID 원칙이란 객체지향 설계에서 지켜줘야 할 5개의 소프트웨어 개발 원칙( SRP, OCP, LSP, ISP, DIP )을 말한다
- SRP(Single responsibility principle) : 단일 책임 원칙
- OCP(Open-closed principle) : 개방-폐쇄 원칙
- LSP(Liskov substitution principle) : 리스코프 치환 원칙
- ISP(Interface segregation principle) : 인터페이스 분리 원칙
- DIP(Dependency inversion principle) : 의존관계 역전 원칙
SOLID 설계 원칙은 oop의 4가지 특징(추상화, 상속, 다형성, 캡슐화)와 더불어, 객체 지향 프로그래밍의 단골 면접 질문 중 하나이다. 또한 여러 디자인 패턴(Design Pattern)들이 SOLID 설계 원칙에 입각해서 만들어진 것이기 때문에, 표준화 작업에서부터 아키텍처 설계에 이르기까지 다양하게 적용되어 근간이 되는 SOLID 원칙에 대해 탄탄하게 알아볼 필요가 있다.
본래 좋은 소프트웨어란 변화에 대응을 잘 하는 것을 말한다.
예를 들어 갑자기 고객사에 기획에 없는 추가적인 요청을 하면 엄~청 짜증날지라도 결국 구현을 해야하는데, 이러한 변화에 큰 애로사항없이 잘 대응하기 위해선 소프트웨어 설계 근간이 좋아야 한다.
좋은 설계란 시스템에 새로운 요구사항이나 변경사항이 있을 때, 영향을 받는 범위가 적은 구조를 말한다. 그래서 시스템에 예상하지 못한 변경사항이 발생하더라도, 유연하게 대처하고 이후에 확장성이 있는 시스템 구조를 만들 수 있다.
즉, SOLID 객체 지향 원칙을 적용하면 코드를 확장하고 유지 보수 관리하기가 더 쉬워지며, 불필요한 복잡성을 제거해 리팩토링에 소요되는 시간을 줄임으로써 프로젝트 개발의 생산성을 높일 수 있다.
참고로 SOLID는 어떠한 특정 프로그래밍 언어 혹은 프레임워크를 위해 만든 원칙이 아니다.
SOLID는 프레임워크도 라이브러리의 패턴도 아니며, 특정 기술에 국한되지 않는다. 그래서 TypeScript 또는 Java 와 같은 선호하는 프로그래밍 언어나 프레임워크에 원칙을 자유롭게 적용할 수도 있다. (왠만한 언어는 oop를 지원하니 그대로 이론 원칙을 적용이 가능하다)
※ 참고
Q. SOLID 원칙을 적용하는 순서가 있나?
- SOLID라는 단어는 철자법과 눈에 띄게 만들기 위해 이러한 방식으로 배열 되었을 뿐이다. 따라서 원칙 순서 같은 것은 없다.
Q. 프로젝트에 반드시 5가지 원칙을 모두 적용해야 하는가?
- 프로젝트에 적용할 원칙의 수는 코드의 구성에 따라 다르다고 보면 된다. 각 원칙은 특정 문제를 해결하기 위한 지침일 뿐이며, 만일 코드에 해당 문제가 없으면 원칙을 적용할 이유가 없다.
S.O.L.I.D의 용어의 개념 이론들은 모두 우리가 자바의 클래스 객체 지향를 배울때 익혔던 추상화, 상속, 인터페이스, 다형성 ..등 개념들을 재정립한 것으로 보면 된다. 그래서 원칙을 읽다 보면 어딘가 익숙한 개념과 당연하게 여겨져와서 쓰였던 원리들을 다시 복기 하는 것과 같은 느낌을 받을 것이다.
그리고 이 5가지 원칙들은 서로 독립된 개별적인 개념이 아니라 서로 개념적으로 연관 되어 있다. 원칙 끼리 서로가 서로를 이용하기도 하고 포함하기도 한다.
SRP(Single Responsibility Principle) - 단일 책임 원칙
- 단일 책임 원칙은 클래스(객체)는 단 하나의 책임만 가져야 한다는 원칙
- 여기서 '책임' 이라는 의미는 하나의 '기능 담당'으로 보면 된다.
- 즉, 하나의 클래스는 하나의 기능을 담당하여 하나의 책임을 수행하는데 집중되도록 클래스를 따로따로 여러개 설계하라는 원칙이다.
- 만일 하나의 클래스에 기능(책임)이 여러개 있다면 기능 변경(수정) 이 일어났을때 수정해야할 코드가 많아진다.
예를 들어 A를 고쳤더니 B를 수정해야하고 또 C를 수정해야하고, C를 수정했더니 다시 A로 돌아가서 수정해야 하는, 마치 책임이 순환되는 형태가 되어버린다.
따라서 SRP 원칙을 따름으로써 한 책임의 변경으로부터 다른 책임의 변경으로의 연쇄작용을 극복할 수 있게 된다. - 최종적으로 단일 책임 원칙의 목적은 프로그램의 유지보수 성을 높이기 위한 설계 기법이다.
- 이때 책임의 범위는 딱 정해져있는 것이 아니고, 어떤 프로그램을 개발하느냐에 따라 개발자마다 생각 기준이 달라질 수 있다. 따라서 단일 책임 원칙에 100% 해답은 없다.
아래와 같은 클래스가 있다고 하자.
class Human{
public void Sing() {
// 노래한다
}
public void Study() {
// 공부한다
}
public void Cook() {
// 요리한다
}
}
Human이라는 클래스는 너무 많은 책임을 가지고 있다.
그러므로 아래와 같이 분리하는 것이 좋다.
class Singer{
public void Sing() {
// 노래한다
}
}
class Student{
public void Study() {
// 공부한다
}
}
class Chef{
public void Cook() {
// 요리한다
}
}
하지만 책임이라는 용어는 너무 애매하다.
- 책임은 클 수 있고, 작을 수도 있다.
- 문맥과 상황에 따라 다르다.
그래서 이를 변경을 기준으로 다음과 같이 생각하면 더 용이하다.
"어떤 변화에 의해 클래스를 변경해야 하는 이유는 오직 하나뿐이어야 한다"
따라서 SRP 원리를 적용하면 책임 영역이 확실해지기 때문에 한 책임의 변경에서 다른 책임의 변경으로의 연쇄작용에서 자유로울 수 있다. 즉, 변경이 있을 때 파급 효과가 적을수록 단일 책임 원칙을 잘 따른 것이다.
- EX: UI 변경, 객체의 생성과 사용을 분리.
OCP(Open-Closed Principle) - 개방-폐쇄 원칙
- OCP 원칙은 클래스는 '확장에 열려있어야 하며, 변경에는 닫혀있어야 한다' 를 뜻한다.
- 기능 추가 요청이 오면 클래스 확장을 통해 손쉽게 구현하면서, 확장에 따른 클래스 수정은 최소화 하도록 프로그램을 작성해야 하는 설계 기법이다.
- [ 확장에 열려있다 ] - 새로운 변경 사항이 발생했을 때 유연하게 코드를 추가함으로써 큰 힘을 들이지 않고 애플리케이션의 기능을 확장할 수 있음
- [ 변경에 닫혀있다 ] - 새로운 변경 사항이 발생했을 때 객체를 직접적으로 수정을 제한함.
- 어렵게 생각할 필요없이, OCP 원칙은 "추상화를 통한 관계 구축 권장"을 의미하는 것이다.
- 즉, 다형성과 확장을 가능케 하는 객체지향의 장점을 극대화하는 기본적인 설계 원칙
개방-폐쇄 원칙을 적용하기 위한 중요 메커니즘은 추상화와 다형성이다.
아래 예시를 살펴보자
//Car car = new K3("K3 자동차");
Car car = new Sonata("쏘나타");
뭔가 이상하다. 분명 변경에는 닫혀있어야한다고 했는데, K3에서 Sonata로 변경한데 있어 주석을 통해 코드를 변경했다. 다형성을 사용했는데도 코드를 변경함으로써 OCP 원칙을 위반하고 말았다.
그럼 어떻게 해야할까? 이를 해결하기위해 객체 생성, 관계수립을 해주는 설정자가 필요하다.
이를 위해 스프링에서는 DI 컨데이터가 설정자 역할을 한다.
LSP(Liskov Substitution Principle) - 리스코프 치환 원칙
- LSP 원칙은 서브 타입은 언제나 기반(부모) 타입으로 교체할 수 있어야 한다는 원칙이다.
- 쉽게 말하면 LSP는 다형성 원리를 이용하기 위한 원칙 개념으로 보면 된다.
- 간단히 말하면 리스코프 치환 원칙이란, 다형성의 특징을 이용하기 위해 상위 클래스 타입으로 객체를 선언하여 하위 클래스의 인스턴스를 받으면, 업캐스팅된 상태에서 부모의 메서드를 사용해도 동작이 의도대로 흘러가야 하는 것을 의미하는 것이다.
- 따라서 기본적으로 LSP 원칙은 부모 메서드의 오버라이딩을 조심스럽게 따져가며 해야한다.
왜냐하면 부모 클래스와 동일한 수준의 선행 조건을 기대하고 사용하는 프로그램 코드에서 예상치 못한 문제를 일으킬 수 있기 때문이다.
※ 참고
자바에선 대표적으로 Collection 인터페이스를 LSP의 예로 들수있다.
Collection 타입의 객체에서 자료형을 LinkedList에서 전혀 다른 자료형 HashSet으로 바꿔도
add() 메서드를 실행하는데 있어 원래 의도대로 작동되기 때문이다.
한마디로 다형성 이용을 위해 부모 타입으로 메서드를 실행해도 의도대로 실행되도록 구성을 해줘야 하는 원칙이라 이해하면 된다.
public void myData() {
// Collection 인터페이스 타입으로 변수 선언
Collection data = new LinkedList();
data = new HashSet(); // 중간에 전혀 다른 자료형 클래스를 할당해도 호환됨
modify(data); // 메소드 실행
}
public void modify(Collection data){
list.add(1); // 인터페이스 구현 구조가 잘 잡혀있기 때문에 add 메소드 동작이 각기 자료형에 맞게 보장됨
// ...
}
다른 예시를 들어 아래와 같은 경우를 보자.
public interface Bird{
public void fly() {
// 하늘을 난다
}
}
public class Eagle implements Bird{
public void fly() {
// 하늘을 난다
}
}
public class Tiger implements Bird{
public void fly() {
// 하늘을 난다
}
}
독수리는 조류의 한 종류라 Bird 인터페이스의 fly() 규약을 지킬 수 있다고 하자. 이는 Eagle IS-A Bird라고 할 수 있다.
호랑이는 포유류의 한 종류라 Bird 인터페이스의 fly() 규약을 지킬 수 없다고 하자. Tiger 클래스는 LSP를 위반했다고 볼 수 있다.
LSP를 지키기 위해서는 상속을 통한 재사용을 기반 클래스와 서브 클래스 사이에 IS-A 관계가 있는 경우로만 제한해야 한다.
즉, 자식클래스는 부모 클래스의 책임을 무시하거나 재정의하지 않고 확장만 수행하도록 해야 LSP를 만족한다.
리스코프 치환 원칙은 다형성과 확장성을 극대화하며, 개방-폐쇄 원칙을 구성한다.
ISP(Interface Segragation Principle) - 인터페이스 분리 원칙
- ISP 원칙은 인터페이스를 각각 사용에 맞게 끔 잘게 분리해야한다는 설계 원칙이다.
즉, 하나의 일반적인 인터페이스보다 여러 개의 구체적인 인터페이스가 낫다는 뜻이다. - SRP 원칙이 클래스의 단일 책임을 강조한다면, ISP는 인터페이스의 단일 책임을 강조하는 것으로 보면 된다.
- 즉, SRP 원칙의 목표는 클래스 분리를 통하여 이루어진다면, ISP 원칙은 인터페이스 분리를 통해 설계하는 원칙.
- ISP 원칙은 인터페이스를 사용하는 클라이언트를 기준으로 분리함으로써, 클라이언트의 목적과 용도에 적합한 인터페이스 만을 제공하는 것이 목표이다.
- 다만 ISP 원칙의 주의해야 할점은 한번 인터페이스를 분리하여 구성해놓고 나중에 무언가 수정사항이 생겨서 또 인터페이스들을 분리하는 행위를 가하지 말아야 한다.
(인터페이스는 한번 구성하였으면 왠만해선 변하면 안되는 정책 개념)
DIP(Dependency Inversion Principle) - 의존관계 역전 원칙
- DIP 원칙은 어떤 Class를 참조해서 사용해야하는 상황이 생긴다면, 그 Class를 직접 참조하는 것이 아니라 그 대상의 상위 요소(추상 클래스 or 인터페이스)로 참조하라는 원칙
- 쉽게 이야기해서 구현 클래스에 의존하지 말고, 인터페이스에 의존하라는 뜻
다른 말로는 구체적인 것이 추상화된 것에 의존해야 한다. 자주 변경되는 구체 클래스에 의존하지마라. - 의존 관계를 맺을 때 변화하기 쉬운 것 또는 자주 변화하는 것보다는, 변화하기 어려운 것 거의 변화가 없는 것에 의존하라는 것
※ 참고
의존 역전 원칙의 지향점은 각 클래스간의 결합도(coupling)을 낮추는 것이다.
참고