SOLID Principle
SRP (Single Responsibility Principle)
단일 책임 원칙은 쉽게 접할 수 있는 원칙이다.
그렇지만 반대로 쉽게 접할 수 있다고 해서 쉽게 적용 할 수 있는 부분도 아니다.
그 이유는 바로 "책임" 이라는 의미가 엄청 모호하다는 것이다. 상황, 시점 등등에 따라 사람이든 다른 무엇이든 해야하는 것이 다르기 때문이다.
그렇다면 소프트웨어 설계 관점에서 책임은 무엇일까.
고민해보면 역시 소프트웨어 요구사항을 생각할 수 밖에 없는 것 같다.
즉, 소프트웨어 자체가 요구 사항에 기반하여 객체, 메소드가 만들어 지기 때문에 시나리오를 책정하고 그에 따라 구분되는 각 구성 요소의 역할들이 "책임" 이지 않나 싶다.
하지만 SRP의 더 큰 문제라 할 수 있는 지점은 나름대로 책임을 구분하여 분리하였는데 나중에 보니 객체 수만 늘어나는 상황이다.
복잡도만 커지고 효율성이 매우 떨어질 수 있다.
이런 걸 보면 매우 단순한 SRP 이지만 적용은 생각보다 쉽지 않을 수 있다는 것이다.
OCP (Open-Closed Principle)
개방 폐쇄 원칙에 대해 다루는 것이 OCP이다.
말이 조금 어렵게 나와서 그렇지 사실상 확장성을 고려 (개방), 변경 최소화 (폐쇄) 라고 얘기하는 것이 맞을 것 같다.
(물론 변경 사항을 막아야 한다고는 하지만...)
여기서 확장성을 고려한다면 OOP에서는 당연스럽게 추상화를 통한 상속이 등장 할 수 밖에 없다.
그게 아니라면 무엇이 확장이라고 생각할 수 있을까? 이외에는 크게 생각나는 부분이 없어서..
디자인 패턴이나 기본적인 OOP 책만 봐도 다형성을 바탕으로 하는 기능 추가들이 쉽게 이루어지는 것을 찾아볼 수 있다.
그런데 추상화를 통한 상속의 개념에 대해 따져 볼 필요는 분명이 있다.
1) 확장이 발생할 거는 어떻게 알 수 있을까?
2) 수정 사항이 생길 수 있는 부분은 어떻게 알 수 있을까?
3) 추상화는 다 좋은 것일까? 코드 복잡성이 높아질텐데
4) 등등..
이론적으로는 상속이 좋을 순 있지만 데드라인이 언제나 버티고 있는 실무에서는 개발자를 혼돈의 도가니로 빠뜨릴 수 있는 여지가 분명이 있다.
사실 책을 보면 경험을 바탕으로 할 수 밖에 없다고는 하는데 이건 너무 무책임한 말인 것 같고... 그나마 접근해 볼 수 있다면
결국!!
소프트웨어 요구사항, 즉 이해관계자의 요구사항으로 귀결된다.
여러 객체 간에 공통되는 기능이 많다면 묶어서 추상화하고 (관련 기능이 더 추가 될 수도 있으니) 나머지 알 수 없는 부분은 남겨 놓고 기다리는게 좀 더 현명하지 않나 싶다.
처음부터 거창하게 만들어 놓았는데 정작 사용하지 않으면... 개발에 들인 공수가 너무 아까우니..
물론 개발자의 성향상 코드가 있는데 기다리기만 하는 것은 좀 쑤시니.. 리팩토링 및 코드 정리를 하면서 전체적인 구조를 잡아가는 것도 좋은 방안인 것 같다.
몇몇 책에서는 알 수 없을 때 TDD를 바탕으로 접근하면 추상화 할 수 있는 부분을 더 쉽게 찾고 정리 할 수 있다고는 하지만... 개발자의 선택인 것 같다.
참고로 추상화는 당시 개발하는 사람에게는 쉬워 보이지만 이후 합류하는 개발자에게는 "Welcome to Hell"이 될 수 있다.
때문에 어설프게 했다가는 도리어 유지 보수만 더 어려워 질 수 있기 때문에 지속적으로 코드 리뷰 및 리팩토링을 통해 적당한 선(어렵지만..ㅜㅜ)을 지키는게 중요한 것 같다.
LSP (Liskov Substitution Principle)
SOLID 중 L에 해당하는 LSP이다. 풀어보면 무슨 치환 원칙인데 확실히 한 번에 이해되는 것은 아닌 것 같다.
다만, 생각해 볼 점은 LSP는 파생 클래스가 베이스 클래스로 치환이 가능해야 한다는 의미를 가지고 있다는 점이다.
보통 OOP에서 조금 고급스럽게 쓰기 위해 디자인 패턴을 적용하든 하지 않든, 추상 클래스에 기반한 파생 클래스 (상속 받은 클래스)를 사용한다. 그런데 아무런 정책이나 무작정 쓰게 되면 나중에 도리어 알아보기도 쉽지 않고 유지 보수하는데만 시간이 꽤 걸리게 된다. (내가 왜 이렇게 만들었지라는 자괴감과 함께...)
무작정 추상 클래스, 파생 클래스를 사용하게 되면 나중엔 파생 클래스가 추상 클래스를 먹어 버릴(굳이 추상 클래스 없이 파생 클래스 만으로도 유지가 가능할 때) 수도 있다.
여기서 LSP를 등장시킬 수 있는데.. 개인적인 생각으로는 LSP는 "추상 클래스를 잘 만들고 잘 쓰자" 인 것같다.
추상 클래스를 왜 만들게 될까?
1) 공통 기능을 묶고 싶을 때
2) Mock 클래스를 쉽게 만들고 싶을 때
3) ..등등
이 외에도 다양한 이유가 있을 수 있지만 기본적으로 추상 클래스가 "추상" 이라고 불리는 것처럼 1)번과 같이 앞으로 추가 될 수 있는 또는 현재 산발 될 수 있는 기능을 한데 묶는데 사용된다. 이를 통해 추상 클래스 만으로 파생 클래스의 기능을 한 눈에 볼 수 있고 덧붙여 가독성 및 유지 보수성을 높일 수 있다.
이것이 추상 클래스를 사용하는 본 목적인데 중요한 문제는 계속되는 요구사항으로 인해 파생 클래스의 파생 클래스, 공통된 부분이 뒤죽박죽인데도 억지로 묶인 추상 클래스가 발생한다는 점이다.
때문에 LSP는 추상 클래스가 파생 클래스를 모두 아우를 수 있도록 명시한다.
(여기서 잠깐 OCP도 확인 할 수 있다. 즉, 파생 클래스를 아우를 수 있다는 말은 확장은 간편하게 수정은 최대한 멀리 둔다는 것이므로 LSP가 OCP를 적용하는 선원칙이 될 수 있다.)
아무튼 다시 돌아와서.. 요구 사항으로 인해 변경, 수정이 생기면 무작정 현재 있는 클래스에서 어떻게든 해봐야지가 아니라 이후의 유지 보수를 고려해서 공통으로 뽑아 낼 수 있으면 뽑아 내면서 추상 클래스를 올바르게 유지해야 한다.
물론 내부적인 tradeoff로 약간의 복잡성이 생길 수 있다는 것은 고려해야 하지만, 중요한 것은 파생 클래스 단독적으로 존속하는 것이 아닌 항상 추상 클래스의 범위를 확인해가며 코드를 유지해야 한다는 것이다.
ISP (Interface Segregation Principle)
인터페이스 분리 원칙으로 과도한 추상화 보다는 인터페이스를 잘게 분리해서 사용하는 것을 의미한다.
그런데 "잘게" 인터페이스를 분리한 다는 것에 의문을 가질 수 있다. 어느 정도 비지니스 영역에 속하기도 하지만 결국엔
1) 추상화된 인터페이스가 파생 클래스의 모든 속성을 포함하고 있는 것인지
2) 파생된 클래스의 사용할 필요가 없는 부분까지 인터페이스로 추상화한 것이 아닌지
등등을 살펴보아야 한다.
기존 클래스를 유지 보수 하다보면, 여러 파생 클래스 중 몇 부분만 사용하고 있는 것을 편하게 공통 변수 또는 메소드로 올리기도 하는데 이는 이미 LSP를 위반 할 뿐 만 아니라 이후 유지보수가 점점 어려워지게 하는 원인이 될 수도 있다. (잠깐의 편함이 미래에 헬이 될 수도..)
따라서 조금 코드의 양이 많아질지라도 유지보수 관점에서는 분리하는 것이 낫다.
일반적인 예로써 ISP를 위해 사용하는 방법은 어댑터 패턴 (디자인 패턴 중 하나) 또는 다중 상속을 사용한다. 그러나 각각 추가 메모리 필요 및 복잡한 상위 참조 등등의 문제가 있을 수 있다. 그러므로 인터페이스 분리 방법은 해당 작업을 수행하는 개발자의 몫이고 여기서 중요한 부분은 인터페이스의 비대화를 막는 것이다. (다른 말로 유지보수를 위해 인터페이스를 다이어트 시켜야 한다.)
DIP (Dependency Inversion Principle)
의존성 역전 원칙으로 말 그대로 현재 사용하고 있는 객체 간의 의존성을 180도 바꿔야 한다는 것이다.
잠깐, 현재 사용하고 있는 의존성이 무엇일까. 많은 사람들이 (몇몇 책에 따르면..) 이제껏 상위 모듈이 항상 하위 모듈에 의존하는 형태로 사용하고 있다고 한다.
(여기서 상위 모듈은 비즈니스 모듈이고 하위 모듈은 디바이스 코어 모듈이다.)
즉, 프린트 관련 코드에서 비즈니스 모듈이 프린터에 따라 항상 영향을 받는다는 것이다.
때문에 상위 모듈은 어떤 하위 모듈을 사용하더라도 변경되서는 안된다는 소위 의존성 역전 원칙을 정의한 것이다.
사실 DIP 이전에 Dependency Injection이라는 말을 더 많이 듣기도 했을 것이다. 개인적으로는 2개의 개념이 거의 비슷하다고 생각한다. 결과적으로 코드 상에서는 Dependency Injection를 통해 DIP를 취할 수 밖에 없으니까.
다시 돌아와서 DIP는 하위 모듈의 클래스 정의 즉, 인터페이스를 상위 모듈에서 이미 제공한다는 것이다. 때문에 하위 모듈은 해당 인터페이스에만 맞춰서 (상속 받아서) 구현하면 되고 상위 모듈은 인터페이스를 토대로 상위 모듈을 개발하면 된다.
Conclusion
SOLID 원칙은 효과적으로 소프트웨어를 설계하는 방법론이다. 무조건 이것을 따라야 할 필요는 없지만 그래도 따르려고 노력한다면 더 나은 소프트웨어가 만들어짐에는 분명하다.
그러나 5가지 원칙에서도 볼 수 있듯이 아니 소프트웨어 특성상 절대적인 기준이라는 것이 없다.
그래서 개인적인 결론은 일단 요구 사항을 바탕으로 분리하고, 유지 보수 복잡도가 올라갈 것 같으면 분리하고 내려갈 것 같으면 합치는 방향으로 가야하지 않을까 싶다.
이렇게 적으면서도 엄청 추상적인 것처럼 들리긴 하다만, 소프트웨어에서는 "이거다" 라고 할 수 있는 답이 없으니 많이 부딪혀 보고 경험해 보는 수 밖에 없다.