각 원칙은 소프트웨어의 유지보수성과 확장성을 높이는 것이다. 이를 판단하기 위한 세가지는 다음과 같다.
- 영향범위
- 의존성
- 확장성
SOLID 소개
1. 단일책임원칙
단일 책임 원칙 목표
- 단일 책임 원칙의 목표 클래스가 변경됐을 때 영향을 받는 액터가 하나여야 한다.
- 클래스를 변경할 이유는 유일한 액터의 요구사항이 변경될 때로 제한되어야 합니다.
책임에 대해 각자 의견이 다를 수 있다. 단일 책임을 정의하려면 시스템에 존재하는 엑터들을 이해해야 한다. 같은 업무임에도 불구하고 어떤 조직은 혼자서, 어떤 조직은 다수가 임한다.
2. 개방 폐쇄 원칙
- 코드를 확장하고자 할 때 취할 수 있는 최고의 전략은 기존 코드를 아예 건드리지 않는 것이다.
- 추상화 된 행동에 의존한다. 즉, 인터페이스에 의존한다면 신규 요구사항에 대해 변경보다는 인터페이스를 의존하는 구현체를 생성하게 될 것이다. 이는 코드 수정이 아닌 추가에 가깝다.
3. 리스코프 치환 원칙
- 기본 클래스의 계약을 파생 클래스가 제대로 치환해라.
리스코프 치환 원칙의 단골 예제인 “‘정사각형이 직사각형을 상속 받았을 때 가로/세로 길이 변경을 어떻게 해야 하는지? “로 시작한다.
직사각형은 기본 클래스이고 정사각형은 파생 클래스이다. 직사각형은 가로, 세로의 길이를 각각 수정할 수 있다. 반면에 정사각형은 길이가 같게 수정되어야 한다. 그럼 어떻게 해야 알맞게 수정한 것인가? 그것의 정답은 나와있지 않다. 하지만 이 문제의 해답을 찾아낼 수 있다. 기본 클래스를 만든 원작자의 의도를 아는 것 이다. 즉, 기본 클래스의 의도를 파악하는 것이다.
의도를 파악하는 방법
- 원작자에게 물어본다.
- 테스트 코드르 확인한다.
인터페이스 분리 원칙
인터페이스 잘 분리하기를 설명하기 위해 아래 예제 코드를 첨부했다.
- BeanNameAware를 구현하면 자신이 속한 Spring 컨테이너에서 사용되는 이름을 알 수 있다
- BeanFactoryAware를 구현하면 자신이 속한 Spring 컨테이너에 대한 참조를 얻을 수 있다.
public interface BeanNameAware extends Aware {
void setBeanName(String name);
}
public interface BeanFactoryAware extends Aware {
void setBeanFactory(BeanFactory beanFactory) throws BeansException;
}
위의 두 인터페이스를 구현하면 Spring 컨테이너에 의해 관리되는 Bean 의 추가 정보나 기능에 접근 할 수 있다. 여기서 위 2개의 인터페이스를 Bean*Aware한개로 합치면 어떨까?
합치지 않는 것이 좋다. 같은 기능을 할 것처럼 보여 인터페이스를 묶는다면, 필요로 하지 않는 기능까지 구현해야 하는 상황이 벌어진다.
AnnotationBeanConfigurerAspect클래스를 보자. 해당 클래스는 BeanFactoryAware만 구현하고 BeanNameAware는 구현하지 않는다. 그 이유는 단순히 BeanFactoryAware만 필요로 하기 때문이다.
public aspect AnnotationBeanConfigurerAspect
extends AbstractlnterfaceDrivenDependencylnjectionAspect
implements BeanFactoryAware, Initia1izingBean, DisposableBean {
}
그렇다면 “같은 기능을 할 것 처럼” 같은 모호한 기능들을 어떻게 나누어야 할까?
답은 응집도에 있다.
흔히 말하는 “결합도는 낮추고 응집도는 높힌다”라는 말에서 응집도 개념을 상기해보자.
응집도 (우선순위 높을 수록 응집도가 높음,)
- 기능적 응집도
- 오직 관련된 기능만 수행하도록 설계함. 에를들어 ‘주문’이라는 범용적인 도메인 보다는 ‘주문처리’같이 목적을 명확히 하는 모듈
- 순차적 응집도
- 특정 작업을 수행하기 위해 순차적으로 연결된 경우 이들끼리 모듈을 구성. DB 조회 후 결과를 가공하는 모듈이 있다면 함께 구성
- 통신적 응집도
- 메세지 형태나 공유 데이터에 따라 구성. 예를들어 이메일 전송 모듈을 구성할 때를 볼 수 있음. 프로토콜, 형식, 발시자, 수신자 등 정해져 있음
- 절차적 응집도
- 요소들이 단계별 절차를 따라 동작하도록 설계된 경우. 예를들어 계산기 모듈에서, 입력, 연산, 출력 단계를 구성할 수 있음.
- 논리적 응집도
- 같은 목적을 달성하기 위해 논리적으로 연관된 경우. 회원 관리 모듈에서 회원 등록, 업데이트, 삭제 작업을 수행하는 요소들로 구성
방금 전 예제로 돌아가서 ‘유사한 코드라서 한 곳에 모아 놓겠다’는 ‘논리적 응집도’를 추구한 방식이다. 낮은 수준의 응집도를 추구한 결과이다.
4. 의존성 역전 원칙
의존성 역전 원칙은 가볍게 보고 뒤에서 다시 다룬다.
- 고수준 모듈은 추상화에 의존해야 한다.
- 고수준 모듈은 저수준 모듈에 의존하면 안된다.
- 저수준 모듈은 추상화를 구현해야 한다.
의존성
- 의존은 무엇일까? 사용하기만 해도 의존하는 것이다. 함수를 호출하거나, 인터페이스를 구현하거나 등등.
- 의존을 표현하는 또 다른 용어는 결합(coupling)이다.
- 약한 의존 상태라는 것은 결합도를 낮춘 상태이다.
의존성 주입
결합도를 나주는 기법 중 하나는 의존성 주입이다. 의존성 주입은 무엇일까? 우리가 작성하는 많은 코드들이 의존성 주입에 해당한다. 아래 코드를보자
생성자 주입
class Robot{
private final Battery battery;
public Robot(Battery battery){
this.battery = battery;
}
}
수정자 주입
@Setter
class Robot{
private Battery battery;
}
의존성 주입은 의존을 제거하는 것이 아니다. 의존을 약화시키는 것이다. 소프트웨어는 객체와 시스템 간 협력으로 만들어지기 때문에 의존을 제거하는 것은 불가능하다. 그럼 어떻게 의존을 약화시킬 수 있을까? new 사용을 자제하면 된다. “new를 사용하는 것은 하드코딩이다.”라고 표현을 한다.
아래 코드를 보자.Meat meat = new Beef();라는 코드가 들어가는 순간 Meet의 종류는 Beef로 고정되어 버린다. 이것은 결합도를 높히는 행위이다.
@Setter
class HamberChef{
...
public Food make(
Bread bread,
Vegetable vegetable
){
Meat meat = new Beef();
return Hamber.builder()
.bread(bread)
.vegetable(vegetable)
.meat(beef)
}
}
new 사용을 코드 어딘가에서는 사용해야한다. 다만, 인스턴스화되는 시점을 최대한 뒤로 미루라는 것이다.
의존성 역전
“대부분의 소프트웨어 문제는 의존성 역전으로 해결 가능하다.” 라는 말이 있을 정도로 중요한 부분이다. 의존성 역전을 알아보자.
쉽게말해 의존성은 화살표의 방향을 바꾸는 기법이다.
[Restaurant → HamburgerChef]
위 처럼 레스토랑이 햄버거 쉐프를 의존하면 어떻게 될까? 햄버거 쉐프가 아닌 스파게티 쉐프로 변경이 될 때 레스토랑에도 변경이 일어난다. 상위 모듈이 하위 모듈에 의존했을 때 생기는 문제이다.
[Restaurant → Chef<I> ← HamburgerChef]
그래서 이 사이에 인터페이스를 둠으로 써 의존을 역전시킨다. 이렇게 되면 레스토랑이 Chef 인터페이스에 의존하기 때문에 햄버거쉐프가 또 다른 쉐프로 변경되더라도 레스트랑 소스에는 영향이 없다. 인터페이스에 맞는 교체 대상을 바꾸어 주기만 하면 되기 때문이다.
다음으로 이해할 것은 “의존성 역전은 경계를 만든다. “이다.
의존성 역전은 경계를 만드는 기법이며, 모듈의 범위를 정하고 상하 관계를 표현하는데 쓸 수 있다. 레스토랑과 햄버거쉐프 클래스는 인터페이스쪽으로 의존하고 있다. 이 인터페이스를 중심으로 경계 기준을 정할 수 있고, 이것으로 모듈을 나눌 수 있다.
여기서 식당과 쉐프의 모듈이 분리된다면, 해당 인터페이스는 어느 모듈에 속해야할까?
[ Restaurant → Chef<I> ] ← [ HamburgerChef]
결론은 위와 같다. 식당은 상위 모듈이고 햄버거 쉐프는 하위 모듈이다. 하위 모듈에 인터페이스를 두게되면 처음 보았던 것 처럼 상위가 하위를 의존하는 구조가 된다.
의존성 역전의 원칙을 다시 살펴보자
- 상위 모듈은 하위 모듈에 의존해서는 안된다. 상위 모듈과 하위 모듈 모두 추상화에 의존해야 한다.
- 추상화는 세부사항에 의존해서는 안된다. 세부사항이 추상화에 의존해야한다.
의존성 역전과 스프링
- 스프링은 의존성 주입(DI)을 지원하는 프레임워크이지만 의존성 역전(DIP)을 지원하지 않는다.
- DIP는 설계의 영역이다. 이 원칙을 지키고 싶다면 설계 부문에서 개발자들이 능동적으로 신경 써야한다.
- 저자가 제안한 구조가 있는데 2부에서 다룬다.
의존성이 강조되는 이유
코드를 변경하거나 확장할 떄 영향받는 범위를 최소화할 수 있어야 한다. 이를 해내기 위한 방법을 지금까지 알아 보았습니다.
- 영향 범위에 문제가 있을 때
- 응집도를 높이고 적절히 모듈화해서 단일 책임 원칙을 준수하는 코드를 만든다.
- 의존성에 문제가 있을 때
- DI, DIP등을 적용해 약한 의존 관계를 만든다.
- 확장에 문제가 있을 때
- DIP를 이용해 개방 폐쇄 원칙을 준수하는 코드로 만든다.
왜 의존성이 중요할까 의존성이 갖고 있는 특징 중 하나 인 의존성 전이를 보자. 의존성은 전파된다. 한 컴포넌트의 수정으로 인해 그것을 의존하는 여러 컴포넌트들까지 연쇄적으로 말이다.
[A → B → C → D]
위와 같이 의존관계가 있을 때 C가 수정된다면 A, B가 영향을 받는다. 의존성은 화살표의 역방향으로 전이 되기 때문이다. 그렇기 때문에 소프트웨어 설계가 중요해진다.
[A → B → C<I> → D]
해당 문제를 위와 같이 의존성 역전을 통해 해결할 수 있다. C컴포넌트 상위 인터페이스를 만들고 그것을 구현하게 된다면 C 컴포넌트 코드 변경에 아무 컴포넌트도 영향을 받지 않을 것이다.
“추상화를 적용했다.”, “변경으로 인한 영향 범위를 축소했다.” 라고 할 수 있다.
[A → B ↔︎ C ← D ← E]
다음으로 순환참조를 보자. B와C는 서로를 의존하고 있다. B를 수정하게 되면 모든 컴포넌트에 영향이 있으며, C를 수정해도 모든 컴포넌트에 영향이 있다. 때문에 설계 시 순환 참조를 만들지 말라는 것이다.
여기서 하나더 인사이트를 얻어가자면, 순환참조는 사실상 같은 컴포넌트라는 것이다.
SOLID와 객체지향
암기하여 사용하기 보다는 이 이론들의 목표를 고심하는 것이 핵심이다.
- 객체지향의 핵심은 역할, 책임, 협력이다.
- SOLID의 핵심은 변경에 유연하고 확장할 수 있는 코드이다. 즉, 높은 응집도와 낮은 결합도.
디자인패턴
디자인 패턴은 암기 영역이 아니다. 저자는 “의존성을 고민하기 시작했더니 디자인 패턴이 적용됐다.”라고 한다.
디자인 패턴은 의존성을 잘 관리하기 위한 기법이다. 예제를 외우려 하지 말고 어떤 문제를 어떻게 해결하는지 이해하는 것이 필요하다.
adapter pattern을 보자. 호환되지 않는 코드(adapter)가 있을 때 기존코드를 변경하지 않고 해당 코드를 사용하는 방법이다.
의존성과 의존성 전이가 무엇인지 파악했기 때문에, 이제는 패턴을 이해할 수 있다.
- 설계자는 클라이언트와 어댑터 사이의 연결을 끊고 싶었다.
- 클라이언트가 어댑터에 의존하는 상황을 피하고 싶었다.
- adapter가 변경돼도 클라이언트의 코드가 변경되지 않길 원했다.
- 추상화를 통해 클라이언트 코드의 재사용성을 높혔다.
패턴은 도구일 뿐이면, 더 중요한 것은 문제인식, 해결과정, 해결방법이다.
참조
'기술도서 > 자바스프링 개발자를 위한 실용주의 프로그래밍' 카테고리의 다른 글
자바스프링 개발자를 위한 실용주의 6. 안티패턴 (0) | 2024.11.09 |
---|---|
자바스프링 개발자를 위한 실용주의 5. 순환참조 (0) | 2024.11.09 |
자바스프링 개발자를 위한 실용주의 3. 행동 (0) | 2024.11.09 |
자바스프링 개발자를 위한 실용주의 2. 객체 종류 (0) | 2024.11.09 |
자바스프링 개발자를 위한 실용주의 1. 절차지향비교 (0) | 2024.08.11 |