사랑해요 SOLID
3차 프로젝트 발표에서 만난 튜터님이 좋은 개발자인지 보는 기준으로 SOLID를 지켰는가를 꼽는다고 언급했었습니다. 대충 말만 들어봤지 뭔지 잘 몰라서 준비해봤습니다.
객체 지향 프로그래밍과 설계에 관련한 다섯 가지 기본 원칙을 로버트 마틴이 제시했고, 두문자를 따서 SOLID라고 명명한 사람은 마이클 페더스입니다. 내용이 더 중요하겠지만 보통 창시자의 이름을 언급하기 때문에 적어봅니다. 특기할만한 것은 SOLID는 일전에 찬홍님이 발표하신 소프트웨어 개발 방법론인 애자일 전략의 일부라는 점입니다.
단일 책임 원칙에서 책임이란 단어는 객체와 연관지어 생각하면 좋습니다. 클래스가 하나의 객체만을 책임져야 한다는 식으로 말입니다. 클래스는 하나, 책임도 하나입니다. 이렇게 하는 이유는 책임 영역을 확실하게 구분지을 수 있기 때문입니다. 그럼 왜 여러 책임을 하나로 하는 것이 좋을까요?
public class Coffee {
public void getOrder(){}
public void addOrder(){}
public void save(){}
public Coffee load(){}
public void printOnOrderCard(){}
}
위와 같이 커피라는 한가지 클래스에 주문을 조회, 추가, 저장한 뒤 저장한 주문을 불러오고 프린트 하는 여러 책임을 주었다고 가정해봅니다. 이 경우에 주문에 변경이 생긴다면 변경하고 수정해야 할 부분이 매우 많아집니다.
단일 책임 원칙은 이를 막기 위해 세워진 원칙이며, 객체간의 응집도는 높이고 결합도를 낮추기 때문에 "객체 지향" 프로그래밍에도 매우 적합한 원칙이라고 할 수있습니다.
하지만 실제로 클래스를 설계하다보면 복잡하고 다양한 상황이 중첩되는 경우가 발생하므로 이 원칙을 지키기가 쉽지 않다고 합니다. 따라서 가장 기본이 되면서 신경써야 하는 원칙이라고 할 수 있겠습니다.
cf)
결합도: 모듈이 다른 요소와 얼마나 강력하게 연결되어 있는지, 혹은 의존적인지를 나타내는 척도. 결합도가 높으면 연관된 클래스의 변경사항에 민감하게 영향을 받는다. 따라서 유지보수가 어렵고 나중에 클래스를 재사용하기도 힘들어진다.
응집도: 프로그램의 한 요소가 기능을 수행하기 위해 얼마만큼의 연관된 책임이 뭉쳐있는지를 나타내는 척도. 특정 목적을 위해 밀접하게 연관된 독립적인 모듈들이 모여서 구현되어 있으면 응집도가 높다고 본다.
확장에는 개방, 변경에는 폐쇄적이어야 한다는 원칙입니다. 기존 구성 요소나 코드를 수정하거나 변경하지는 않으면서도 기능(클래스, 모듈, 함수)의 확장에는 열려 있음을 의미합니다. 이 원칙을 잘 지키기 위해서는 추상화가 중요하다고 합니다.
class Coffee extends Beverages(){
public Coffee(String bean, CoffeeSpec spec){
this.bean = new bean;
this.spec = spec;
}
private CoffeeSpec spec;
}
class CoffeeSpec extends BeveragesSpec(){
}
class Tea extends Beverages(){
public Tea(String leaf, TeaSpec spec){
this.leaf = new leaf;
this.spec = spec;
}
private TeaSpec spec;
}
class TeaSpec extends BeveragesSpec(){
}
위와 같이 커피와 차라는 클래스를 작성할 때 공통되는 속성을 추상화하여 담아낼 수 있는 Beverages라는 인터페이스를 생성했습니다. 이러한 인터페이스의 분리는 새로운 음료 종류가 추가되는 경우 유연한 대처가 가능하지만 기존의 코드는 변경하지 않을 수 있습니다. 정리하자면 아래의 3가지 기준에 의해 개방 폐쇄 원칙을 적용해볼 수 있습니다.
이 원칙은 부모 클래스와 자식 클래스의 행위에 일관성이 있어야한다는 원칙입니다. 일관성이 담보되면 부모클래스에서 할 수 있는 행위를 자식 클래스에서도 행할 수 있게 됩니다. 자식 클래스의 인스턴스는 부모 클래스 객체 참조 변수에 대입해 부모 클래스의 인스턴스 역할을 하는 데 문제가 없어야 합니다.
즉, Upcasting 된 객체 참조 변수가 논리적으로 그 역할을 하는 데 문제가 없어야 한다는 뜻입니다.
예를 들면 음료라는 부모 클래스와 커피라는 자식 클래스가 있다고 가정합니다.
커피 클래스는 음료 클래스를 상속받습니다.
음료 클래스의 특징은 다음과 같습니다.
(1) 음료는 마실 수 있다.
(2) 음료는 기호품이다.
(3) 음료는 카페에서 판매한다.
이제 음료라는 단어 대신에 자식 클래스인 커피를 넣어 봅니다.
(1) 커피는 마실 수 있다.
(2) 커피는 기호품이다.
(3) 커피는 카페에서 판매한다.
이렇게 대체했을 때 그 성질이나 특징이 그대로 적용된다면 음료와 커피 사이에는 일관성이 있다고 볼 수 있고, LSP가 지켜진다고 할 수 있습니다.
변화하기 어려운 추상적인 것에 의존해야 하며, 변화하기 쉽고 구체적인 것에 의존하지 말아야 한다는 원칙입니다. 즉 추상화된 인터페이스에 의존하는 것이 구체화된 클래스에 의존하는 것보다 더 이 원칙을 잘 지키는 것이라고 할 수 있습니다. 이 원칙은 의존성 주입(DI)와 관련되어 있는 중요한 원칙이기도 합니다.
추상이라는 말은 일반적인 언어의 용례와는 다르게,(적어도 개발 분야에서는)
각 클래스나 메소드에서 공통적으로 적용될 수 있는 것을 뽑아낸 정수라고 이해해 볼 수 있습니다. core한 것, 가장 핵심이면서 공통적인 속성이나 기능을 묶되, 그에 관련된 정보만을 표현하고 기타의 정보는 감추는 것입니다.
예를 들어 "고객이 커피를 주문한다"는 명제에서 "고객"과 "주문한다"는 자주 변하지 않는 개념적인 것이고 "커피"는 구체적이면서 종류와 상황에 따라 달라질 수 있는 것입니다. 그러므로 커피를 추상화 한다는 것은 좀 더 상위의 개념이면서 커피라는 대상의 속성을 표현할 수 있고, 다른 것으로도 쉽게 대체 가능한 "음료"라는 단위를 설정하는 것을 의미합니다.
그렇게 추상화 하고 난 뒤, "음료" 인터페이스나 abstract class를 만들어 의존시키는 것이 좋겠습니다.
public abstract Class Beverages{
public abstract String toString();
}
public class Cumtomer{
private Beverages beverages;
public void setBeverages(Beverages beverages){
this.beverages = beverages;
}
public void order(){
System.out.println(beverages.toString());
}
}
public class Coffee extends Beverages{
@Override
public String toString(){
return "Coffee";
}
}
public class Cafe {
public static void cafe(String[] args){
Customer customer = new Customer();
customer.setBeverages(new Coffee());
customer.order();
}
}
클래스는 그 클래스가 사용하지 않는 인터페이스로부터 분리되어야 한다는 원칙입니다. 인터페이스는 여러가지 기능을 가지고 있을 수 있지만, 어떤 클래스가 해당 인터페이스를 의존할 경우 다른 기능에는 영향을 받지 않아야 합니다.
예를 들면 복합기가 있다고 할 때 복사를 하고 싶은 사람은 팩스나 프린트 기능에 영향받지 않아야 합니다.
따라서 복합기라는 인터페이스를 구현한다면 복사 기능, 팩스 기능, 프린트 기능을 각각 독립된 여러개의 인터페이스로 구현해 서로 영향을 끼치지 않도록 설계해야 한다고 합니다.