SOLID에 대해서 이전의 글이 있으나, 사내 스터디에서 SOLID 부분을 맡게 되었고 다시 한번 보면서 디깅하는 시간을 갖도록 하기로 하였다. 이전 글 https://kbcoding.tistory.com/63
SOLID
단일 책임 원칙(SRP : Single Responsibility Principle)
개방 폐쇄 원칙(OCP : Open-Closed Principle)
리스코프 치환 원칙(LSP : Liskov Substitution Principle)
인터페이스 분리 원칙(ISP : Interface Segregation Principle)
의존성 역전 원칙(DIP : Dependency Inversion Principle)
단일 책임 원칙(SRP : Single Responsibility Principle)
“클래스를 변경해야 할 이유는 단 하나여야 합니다” - 로버트 C. 마틴
단일 책임 원칙은 클래스에 너무 많은 책임이 할당돼서는 안되며, 단 하나의 책임은 있어야 한다고 말한다. 클래스는 하나의 책임만 갖고 있을 때 변경이 쉬워진다. 즉, 클래스를 변경해야할 이유는 단 하나여야 한다.
“하나의 모듈은 하나의, 오직 하나의 액터에 대해서만 책임져야 한다” - 로버트 C. 마틴
액터는 메시지를 전달하는 주체로 단일 책임의 원칙에서 책임은 액터를 의미한다. 메시지를 요청하는 주체가 누구냐에 따라 책임이 달라질 수 있다.
똑같은 코드일지라도 시스템에 따라 액터가 다를 수 있다. 즉, 어떤 클래스를 사용하게 될 액터가 한 명이라면 단일 책임 원칙을 지키고 있는 것이고 여럿이라면 위반하고 있는 것이다.
- 어떤 모듈이나 클래스가 담당하는 액터가 혼자라면 단일 책임 원칙을 지키고 있는 것입니다.
- 어떤 모듈이나 클래스가 담당하는 액터가 여럿이라면 단일 책임 원칙에 위배된다.
단일 책임 원칙을 이해하려면 시스템에 존재하는 액터를 먼저 이해해야 한다. 그리고 그러기위한 문맥과 상황이 필요하다.
단일 책임 원칙의 목표
클래스가 변경됐을 때 영향받을 액터가 하나여야한다.
클래스를 변경할 이유는 유일한 액터의 요구사항이 변결될 때로 제한되어야 한다.
개방 폐쇄 원칙(OCP : Open-Closed Principle)
“클래스의 동작을 수정하지 않고 확장할 수 있어야한다” - 로버트 C.마틴
확장에는 열려 있고 변경에는 닫혀 있어야한다고도 표현한다. 이 원칙은 기존 코드를 수정하지 않으면서도 확장이 가능한 시스템을 만드는 것이다. 그렇다면, 기존 코드를 수정하지 않으면서도 확장이 가능한 시스템을 만들어야한다. 특히나 변경하려는 코드를 사용하는 액터가 여럿이라면 더더욱 그렇다.
규모가 큰 시스템에서는 코드를 변경하는 것은 쉬운 일이 아니고 특히나 변경하려면 코드를 사용하는 액터가 여럿이라면 더더욱 어렵다. 코드를 확장하고자 할 때 취할 수 있는 최고의 전략은 기존 코드를 아예 건드리지 않는 것이다.
리스코프 치환 원칙(LSP : Liskov Substitution Principle)
“파생 클래스는 기본 클래스를 대체할 수 있어야 한다” - 로버트 C.마틴
바바라 리스코프에 의해 고안되어 리스코프 치환 원칙이라고 불리는 이 원칙은 한 문장으로 정의하면, 기본 클래스의 계약을 파생 클래스가 제대로 치환할 수 있는 확인하는 것이다.
코드 작성자의 의도를 드러낼 수 있는 테스트 코드를 사용하여 초기 코드 작성자가 생각하는 모든 의도를 테스트 코드를 만들어두는 것이 좋은 해결책이 될 것이다.
그렇게 할 수 있다면 파생 클르새를 작성하는 개발자는 테스트를 보고 초기 코드 작성자의 의도를 파악할 수 있을 것이고, 기본 클래스로 작성된 테스트에 파생 클래스를 추가해 테스트 해볼 수 있다. 인터페이스는 계약이며, 테스트는 계약 명세이다.
인터페이스 분리 원칙(ISP : Interface Segregation Principle)
“클라이언트별로 세분화된 인터페이스를 만드세요” - 로버트 C.마틴
클라이언트가 자신이 사용하지 않는 인터페이스에 의존하지 않아야 한다는 원칙이다. 즉, 어떤 클래스가 자신에게 필요하지 않은 인터페이스의 메서드를 구현하거 의존하지 않아야 한다. 이를 통해 인터페이스의 크기를 작게 유지하고 클래스가 필요한 기능에만 집중할 수 있다.
이 원칙은 개발자들이 하나의 인터페이스로 모든 것을 해결하려고 할때 위배된다. 이 원칙은 단일 책임 원칙과도 밀접한 관련이 있다.
public class LifecycleBean implements
BeanNameAware, BeanFactoryAware, InitializingBean, DisposableBean {
// ...
}
스프링 프레임워크의 LifecucleBean 클래스의 코드 일부로 Lifecucle Bean 클래스는 4개의 인터페이스를 구현하고 있다. 그런데 간혹 이런 오픈소스 코드를 보다 보면 이러한 궁금증이 생긴다.
- 인터페이스를 세분화한 이유가 무엇인가?
- BeanNameAware와 BeanFactoryAware 인터페이스는 BeanAware라는 인터페이스로 합칠 수 있을 것 같은데 이를 굳이 분리한 이유가 무엇인가?
2번에서 BeanAware로 합쳤다면 어떠한 일이 벌어졌을까? BeanAware라는 인터페이스로 합쳤다고 생각하고 아래의 코드를 참고해보자.
public aspect AnnotationBeanConfigurerAspect
extends AbstractlnterfaceDrivenDependencylnjectionAspect implements BeanFactoryAware, Initia1izingBean, DisposableBean {
// ...
}
위의 코드는 스프링 프레임워크에 있는 코드이다. AnnotationBeanConfigurerAspect 클래스는 BeanNameAware와 BeanFactoryAwarer 인터페이스 중 BeanFactoryAware 인터페이스만 구현한다. BeanNameAware 인터페이스는 구현하지 않고 있는 것이다.
작가의 추정
작가의 추정으로는 실제로 구현해야 하는 기능이 BeanFactoryAware 인터페이스만으로 충분했기 때문이다. 그런데 만약 인터페이스를 분리하지 않고 BeanAware라는 형태로 통합했다면 AnnotationBeanConfigurerAspect 클래스를 작성하는 개발자 입장에서는 구현할 수도 없고 구현할 필요도 없는 BeanNameAware 인터페이스의 메서드를 추가로 구현하느라 애를 먹었을 것이다.
통합된 인터페이스는 구현체에 불필요한 구현을 강요할 수도 있다. 따라서 범용성을 갖춘 하나의 인터페이스를 만들기보다 다수의 특화된 인터페이스를 만드는 편이 더 났다.
내 생각
클라이언트별로 세분화된 인터페이스를 만드는 것이 유지보수성에서는 매우 좋은 선택일 것 같다. 그렇다면, 너무 과도한 세분화된 인터페이스는 만드는 것이 묘수인가? 그건 절대로 아니라고 생각한다. 현대 시대에는 매우 많은 인터페이스로 이루어지면서 시대가 거듭될 수록 세상에 존재하는 코드의 수는 기하급수적으로 늘어난다고 생각한다. 이를 정리하기 위해서는 개발자가 통합을 해야할 것과 하지 말아야할 것을 명확하게 구분하여 개발을 하는 것이 개발자의 자세라고 생각한다. (밑줄 친 부분이 가장 어렵다)
스프링 프로젝트에서 ‘Aware’라는 단어가 포함된 인터페이스는 앞에 나온 타깃에 변화가 있을때 실행되는 콜백 메서드를 구현하는데 사용된다. BeanNameAware는 빈 이름과 관련된 활동이 있을 때, 실행되는 콜백 메서드를 구현할 수 있는 의미가 되는데 그러면 BeanAware가 있따면 무슨 의미로 해석될까? 빈과 관련된 모든 활동을 구독하겠다는 의미가 된다. 그래서 Bean*Aware 패턴을 따르는 모든 인터페이스가 이곳으로 모이게 되고 그로 인해 액터가 많이 모이게 되며 변경이 어려워진다.
위의 내용으로는 인터페이스를 통합하는 것이 안좋다는 의견으로 보인다. 그렇다면, 응집도를 높이는 것과 상반되는 것이 아닌가?
인터페이스를 통합하는 것이 응집도 측면에서 장점이 될 수 있으나, 응집도의 종류가 다양한데 이 중 어느것을 추구하는 지를 확인해 볼 수 있습니다.
- 기능적 응집도: 모듈 내 컴포넌트들이 같은 기능을 수행하도록 설계된 경우를 말한다.
- 순차적 응집도: 모듈 내 컴포넌트들이 특정한 작업을 수행하기 위해 순차적으로 연결된 경우를 말한다.
- 통신적 응집도: 모듈 내 컴포넌트들이 같은 데이터나 정보를 공유하고 상호 작용할 때 이에 따라 모듈을 구성하는 경우를 의미한다.
- 절차적 응집도: 모듈 내의 컴포넌트들이 단계별로 절차를 따라 동작하도록 설계된 경우를 나타낸다.
- 논리적 응집도: 모듈 내의 요소들이 같은 목적을 달성하기 위해 논리적으로 연견된 경우를 말한다.
일반적으로 기능적 > 순차적 > 통신적 > 절차적 > 논리적 순으로 응집도가 높다고 평가한다. ‘유사한 코드라서 한곳에 모아 놓겠다’의 접근 방식은 논리적 응집도를 추구하는 방식이며 이는 다른 응집도의 종류에서 낮은 수준의 응집도를 추구하는 결과를 가져온다.
반면에 인터페이스 분리 원칙은 역할을 나누는 것으로 기능적 응집도를 추구하는 것이다.
PS. JPA Repository는 하나의 인터페이스로 개발을 잘해오고 있다.
결론, 인터페이스를 분리하자. 그렇다고 무분별한 분리는 오히려 개발 난이도를 상승시킬 수 있으며 범용성을 위한 인터페이스 분리가 오히려 범용성을 해칠 수 있다.
의존성 역전 원칙(DIP : Dependency Inversion Principle)
“구체화가 아닌 추상화에 의존해야 한다” - 로버트 C. 마틴
의존: 다른 책체나 함수를 사용하는 상태
의존성
첫째, 상위 모듈은 하위 모듈에 의존해서는 안된다. 상위 모듈과 하위 모듈 모두 추상화에 의존해야한다.
둘째, 추상화는 세부 사항에 의존해서는 안된다. 세부 사항이 추상화에 의존해야한다.
의존이라는 것은 사용하기만 해도 의존하는 것이다. 그렇기 때문에 소프트웨어는 의존하는 객체들의 집합이라고 볼 수 있으며 객체지향에서 객체들은 필연적으로 협력하는 데 서로를 사용하기 때문이다. 이를 컴퓨터 공학에서는 결합(Coupling)이라고도 부른다. 소프트웨어 세계에서는 결합도가 약할수록 좋다고 평가한다.
이를 해결하기 위해 나온 기법중 하나가 의존성 주입으로(Dependency Injection)이다.
‘new 사용을 자제하라’
코드를 작성하면서 new를 사용하는 것은 사실상 하드 코딩이고 강한 의존성을 만드는 대표적인 사례이다. new는 파생클래스를 인스턴스화해서 할당한다면 이는 해당 변수에 추상 타입과 관계 없이 고정된 객체를 사용하겠다는 의미이다. 즉, new를 사용하면 더 이상 다른 객체가 사용될 여지가 사라진다.
그렇기 때문에 직접 인스턴스를 생성하는 것은 선택의 여지가 사라져서 사실상 하드코딩으로 인식된다. 그렇다면, 무조건적으로 new는 안좋은 것인가? 그렇진 않지만 new는 최후의 선택지로 남겨두는 것이 좋다.
의존성 역전
상위 인터페이스를 만들고 기존의 두 클래스가 인터페이스에 의존하도록 바꾸는 것을 추상화를 이용한 간접 의존 형태로 바꿨다고 하며 이를 이존성을 역전시켰다 라고 한다.
기존에 의존을 받던 입장의 클래스를 인터페이스로 분리하여 의존을 받던 입장에서 하던 입장으로 분리 되는 것을 의미한다.
Ex)
변경 전 식당 → 치킨요리사
변경 후 식당 → <> 요리사 ← 치킨 요리사
코드가 추상에 의존하는 형태로 바뀌며 의존성 역전은 세부 사항에 의존하지 않고 정책에 의존하도록 코드를 작성하라 라는 말로 바꿔서도 설명이 가능하다.
Reference.