https://www.yes24.com/Product/Goods/126845564
이번 시간에는 자바/스프링 개발자를 위한 실용주의 프로그래밍 4장 - SOLID를 읽고 정리해보겠습니다.
SOLID 원칙의 목적 - 응집도를 높이고 의존성을 낮춘다.
본론에 들어가기에 앞서, 가장 먼저 책에서 말하고자하는 SOLID의 원칙의 목적은 무엇일까?
SOLID 원칙이 추구하는 것은 바로 "객체지향의 설계".
그리고 설계 원칙을 통하여 소프트웨어의 응집도를 높이고 의존성을 낮추는 것을 목표로 함.
SOILD 원칙을 무작정 암기하는 것보다 "응집도를 높이고 의존성을 낮추는 것에 집중"하는 것이 좋다.
유지보수성을 판단하는 세 가지 맥락 - 영향 범위, 의존성, 확장성
설계 관점에서 코드의 유지보수성을 판단하는 세 가지 맥락이 존재한다.
1. 영향범위 : 코드 변경으로 인한 영향 범위가 어떻게 되는가? -> 단일 책임 원칙 적용하기
2. 의존성 : 소프트웨어의 의존성 관리가 제대로 이뤄지고 있는가? -> 의존성 역전 원칙 적용하기
3. 확장성 : 쉽게 확장이 가능한가? -> 개방 폐쇄 원칙 적용하기
1. 단일 책임 원칙(SRP) - 변경에 대한 원칙
클래스를 변경해야할 이유는 단 하나여야 한다.
- 하나의 책임 -> 하나의 변경 사유 -> 변경이 쉬워짐
단일 책임 원칙은 변경과 관련이 있다.
클래스가 하나의 책임만 갖고 있으면 변경으로 인한 영향범위가 줄어들어 변경이 쉬워지기 때문.
단일 책임에서 말하는 책임은 정확히 무엇일까? - 책임의 판단은 달라질 수 있다..
public class Developer {
void createFrontendCode(){
// 프론트엔드 코드 작성
}
void createBackendCode(){
// 백엔드 코드 작성
}
void publishFrontend(){
// 프론트엔드 배포
}
void serveBackend(){
// 백엔드 배포
}
}
누군가는 위의 코드가 단일 책임을 지키지 않았다고 하고, 누군가는 단일 책임을 지켰다고 말할 수 있다.
- 책임의 범위는 판단하는 주체나 상황에 따라 달라진다.
사용자 집단(액터) - 메시지를 전달하는 주체
하나의 모듈은 하나의, 오직 하나의 액터에 대해서만 책임져야한다.
책임을 정확하게 설명하게 위해 액터라는 개념이 필요함.
액터는 모듈의 변경을 요구하는 한 명 이상의 사용자 집단(이해관계자 집단)임
단일 책임 원칙을 이해하려면 책임이 무엇인지 이해하는 것보다 사용자 집단에 집중해야함
- 시스템에서 어떤 모듈이나 클래스를 사용하게될 사용자 집단이 몇 개인지 먼저 확인해야함.
- 똑같은 코드라도 시스템에 따라 사용자 집단이 달라질 수 있음
어떤 클래스를 사용하게될 사용자 집단이 한 개라면 단일 원칙 책임을 지키고 있는 것이고 여러 개라면 위반하고 있는 것이다.
- 위의 코드를 예시로 풀스택 개발자들로 구성되어 있는 사용자 집단이라면 단일 책임 원칙을 지키고 있는 것으로 볼 수 있음
[단일 책임 원칙 챕터 정리]
단일 책임 원칙을 준수하려면 시스템에 존재하는 사용자 집단 먼저 파악해야한다.
사용자 집단을 파악하기 위해서는 문맥과 상황이 필요하다.
-> 클래스가 변경 됐을 때 영향을 받는 사용자 집단이 하나여야한다.
-> 클래스를 변경할 이유는 유일한 사용자 집단의 요구사항이 변경될 때로 제한되어야한다. (다른 이유로 변경 X)
2. 개방 폐쇄 원칙(OCP) - 추상화된 역할에 의존하기
"확장에는 열려있고 변경에는 닫혀있다."
개방 폐쇄 원칙은 추상화된 역할에 의존한다. 로 달성할 수 있음
- > 클래스의 동작을 수정하지 않고 확장할 수 있어야한다.
구현에 의존하는 코드를 작성하면 새로운 요구사항이 생겼을 때 해당 요구사항을 지원할 수 있도록 코드 변경이 발생하게 됨
-> 구현에 의존해버리면 if문으로 처리하는 복잡한 로직이 생성된다
이와 다르게 역할에 집중하여 간접적으로 구현체를 사용하면 기존 코드 변경 없이 요구사항 추가가 가능하다.
class Example {
public static void main(String[] args) {
Calculable food = new Food();
Order order1 = new Order(food); //Calculable이라는 추상화에 의존
order1.calculate();
Calculable drink = new Drink();
Order order2 = new Order(drink); //Calculable이라는 추상화에 의존
order.calculate(); //새로운 요구사항 -> 음료에 대한 주문이 추가되었을 때 기존 코드 변경없이 추가 가능.
}
}
3. 리스코프 치환 원칙(LSP) - 파생 클래스가 의도와 계약을 준수하고 있는가?
"파생 클래스는 기본 클래스를 대체할 수 있어야 한다."
@Setter
@AllArgsConstructor
public class Rectangle {
private long width;
private long height;
public long calculateArea() {
return width * height;
}
}
public class Square extends Rectangle {
public Square(long length) {
super(length, length);
}
}
public class Main {
public static void main(String[] args) {
Square square = new Square(10);
square.setWidth(5);
square.calculateArea(); //25를 기대했지만 50이 출력됨 !
}
}
파생 클래스가 기본 클래스를 대체하지 못하는 리스코프 치환원칙 위반 예시코드
리스코프 치환 원칙에 따르면 파생 클래스가 기본 클래스의 모든 동작을 완전히 대체할 수 있어야함.
그러나 위의 정사각형 클래스는 직사각형 클래스의 모든 동작을 대체할 수 없다.
setter 메서드를 오버라이딩하여 다른 값을 동시에 변경시켜주는 코드도 기본 클래스의 의도를 위반한 것일 수도 있기에 좋지 않다.
setter는 일반적으로 하나의 값을 변경하는 의도를 갖기 때문.
기본 클래스의 의도와 계약 파악 - 테스트 코드 사용 (인터페이스는 계약이고 테스트는 계약 명세이다)
파생 클래스가 기본 클래스를 대체하려면 기본 클래스에 할당된 의도와 계약이 무엇인지 먼저 파악할 수 있어야함.
코드 작성자의 의도를 드러내는 방법은 테스트 코드를 작성하는 것이다.
초기 코드 작성자가 모든 의도를 테스트 코드에 작성해두면 파생 클래스 작성자는 의도를 파악하고 결과를 테스트 해볼 수도 있음.
4. 인터페이스 분리 원칙(ISP) - 비슷한 소스 코드라도 분리를 고민하기
"클라이언트 별로 세분화된 인터페이스를 만들어라"
-> 어떤 클래스가 자신에게 필요하지 않는 인터페이스의 메서드를 구현하거나 의존하면 안된다.
이 원칙은 개발자가 하나의 인터페이스로 모든 것을 해결하려고 할 때 위배 됨 -> SRP와도 연관이 있다.
세분화 된 인터페이스의 예시코드
public class LifecycleBean implements
BeanNameAware,
BeanFactoryAware,
InitializationBean,
DisposableBean {
//..
}
public class AnnotationBeanConfigurerAspect
extends AbstractInterfaceDrivenDependencyInjectionAspect
implements BeanFactoryAware, InitializationBean, DisposableBean { // 위와 비교했을 경우 BeanNameAware이 빠져있음
//..
}
인터페이스가 통합되면 역할이 두루뭉술 해질 수 있다.
따라서 범용성을 갖춘 하나의 인터페이스를 만들기보다 다수의 특화된 인터페이스를 만드는 편이 낫다.
응집도의 종류 - 비슷한 소스 코드는 한 곳에 두는 게 좋지 않나요?
인터페이스를 통합하려는 시도는 응집도를 추구하는 행위일 수 있으나 그것이 곧 응집도를 높이는 결과로 이어지는 것은 아니다.
기능적 응집도 > 순차적 응집도 > 통신적 응집도 > 절차적 응집도 > 논리적 응집도
기능적 응집도 : 모듈 내 컴포넌트들이 같은 기능을 수행하도록 설계된 경우. (역할과 책임의 유사성)
예시) '주문'이라는 모듈을 만들기 보다는 '주문 처리'라는 모듈을 만드는 것, Repository에 CQRS 패턴을 적용하는 것
논리적 응집도 : 같은 목적을 달성하기 위해 논리적으로 연관된 경우. (코드의 유사성)
예시) '회원 관리 모듈'에서 회원 등록, 회원 정보 업데이트, 회원 삭제 등의 작업을 수행한다.
유사한 코드라서 한곳에 모아놓겠다라는 접근은 '논리적 응집도'를 추구하는 방식이며 다른 종류의 응집도보다 낮은 수준의 응집도임
'클래스 분리 원칙'이 아닌 '인터페이스 분리 원칙'인 이유는 역할을 세세하게 나누라는 의미이다.
-> 인터페이스를 분리하라는 말은 '기능적 응집도'를 추구하는 것이라고 볼 수 있다.
객체지향에 익숙하지 않은 개발자는 본능적으로 데이터 위주의 사고하고 비슷한 것을 하나로 묶어 싶어 함
cf) 하지만 항상 원칙은 원칙이므로, 원칙과 효율성 사이의 적절한 중도가 중요하다.
인터페이스 분리와 코드의 재사용성의 상관관계
-> 인터페이스를 사용하면 코드가 구현에 의존하지 않게 된다.
-> 필요에 따라 구현체만 바꾸면 되므로 비즈니스 로직이 여러 곳에서 재사용 가능해진다. -> 의존성과 깊은 연관
5. 의존성 역전 원칙(DIP) - 소프트웨어는 의존하는 객체들의 집합이다.
"구체화가 아닌 추상화에 의존해야한다."
1. 고수준 모듈은 추상화에 의존해야한다.
2. 고수준 모듈이 저수준 모듈에 의존해서는 안 된다.
3. 저수준 모듈은 추상화를 구현해야한다.
의존이란? - 클래스나 인터페이스를 사용하기만 해도 의존이다.
의존 : 다른 객체나 함수를 사용하는 상태 -> 특정 클래스나 인터페이스를 사용하기만 해도 의존임
// Printer 클래스가 Book 클래스를 사용한다.
class Printer{
public void print(Book book){ //의존
//..
}
}
// Book 클래스가 Writer 클래스를 사용한다.
class Book{
private String content; //의존
private Writer writer; //의존
//..
}
// Car 클래스가 Vehicle 인터페이스를 사용한다.
class Car implements Vehicle {
//..
}
그렇기 때문에 소프트웨어는 의존하는 객체들의 집합이다. -> 객체지향에서 객체들은 필연적으로 협력사는데 서로를 사용하기 때문
의존이야말로 소프트웨어 설계의 핵심이며 이 책을 관통하는 주제이다.
cf) 결합도와 의존성은 같은 말
의존성 주입 - 객체 간의 협력 관계를 최소화 하는 방법
의존성 주입은 사실 간단한 기법임. 말 그대로 "필요한 의존성을 외부에서 넣어주는 것"
의존성 주입을 통해서 필수 협력 관계와만 의존을 맺을 수 있다. -> 의존성을 최소화
-> new를 사용하는 것은 Content Coupling 결합이며 정보은닉이라는 설계 목적을 위반하는 것.
-> 상세한 구현 객체에 의존하는 것을 피하고 구현 객체가 인스턴스화 되는 시점을 최대한 뒤로 미룰 것을 권장
의존성 역전 - 의존성 역전은 경계를 만든다. 경계를 기준으로 모듈을 만들 수 있다.
class Restaurant {
Food serve() {
return new HambergerChef().make();
}
}
class Restaurant {
Food serve(Chef chef) {
return chef.make();
}
}
상위 인터페이스를 만들고 두 클래스가 인터페이스에 의존하도록 변경.
=> 추상화를 이용한 간접 의존 형태로 바꿨다. => 의존성이 역전됐다.
의존성 역전을 통하여 경계를 만들어낼 수 있다.
의존관계 화살표가 인터페이스를 향해 의존 관계를 밀어내고 있기 때문에 인터페이스를 중심으로 경계를 만들 수 있다.
-> 경계를 통하여 모듈의 상하관계를 파악할 수 있다.
restaurant 모듈은 hamburger 모듈을 사용하지 않는다. (상위모듈)
hamburger 모듈은 restaurant 모듈을 사용한다. (하위모듈)
상위 모듈은 하위 모듈에 의존하면 안된다.
의존성 역전과 스프링 - 의존성 역전 원칙을 지키고 싶으면 개발자가 설계에 신경 써야한다.
스프링은 의존성 주입을 지원하는 프레임워크이지만 의존성 역전 원칙을 지원하는 프레임워크는 아님
의존성 역전 원칙을 지키고 싶다면 설계부분에서 개발자들이 능동적으로 신경 써야함.
흔히 볼 수 있는 추상화 없는 레이어드 아키텍처는 의존성 역전을 찾아보기 힘든 좋지 않은 구조임.
의존성의 특징 - 의존성 전이를 의존성 역전으로 막을 수 있다.
의존성은 컴포넌트 간의 상호작용을 표현한 것이므로 한 컴포넌트가 변경되거나 영향을 받으면 관련된 다른 컴포넌트에 영향이 감
-> 의존성이 전이된다.
의존성 전이는 화살표의 역방향으로 전이됨
의존성 역전을 적용했더니 C를 변경했을 때 영향을 받는 컴포넌트들이 사라졌다.
-> 의존성 역전으로 의존성 전이를 끊는다.
순환참조를 만들지 마라 -> 순환참조는 의존성 전이의 영향 범위를 확장시킨다.
순환참조가 있는 경우 의존성 전이가 의존성 전이 범위를 확장시킴
(순환참조는 사실상 같은 컴포넌트라는 의미이기 때문에 발생시키지 않을 수 있음)
순환참조를 만들지 않으려면 양방향 참조를 끊어내고 단방향으로 만들어야함
SOLID와 객체지향은 엄밀히 따지면 다르다.
객체지향의 핵심은 역할, 책임, 협력.
SOLID는 객체지향 "설계" -> 객체지향 방법론 중의 하나임 -> 응집도를 높이고 의존성을 낮추는 방법론
cf) 개인적인 의견
엔티티가 Bean을 자동 주입받지 못하는 이유도 순환참조(의존성)을 방지하게 하려는 목적이 아닐까?
(헥사고날 아키텍처에서 도메인 객체는 가장 내부에 있으므로 다른 레이어를 의존해선 안됨)
'컴퓨터 과학 > [책] 자바스프링 개발자를 위한 실용주의 프로그래밍' 카테고리의 다른 글
자바/스프링 개발자를 위한 실용주의 프로그래밍 7장 - 서비스 (0) | 2024.10.07 |
---|---|
자바/스프링 개발자를 위한 실용주의 프로그래밍 5장 - 순환참조 (1) | 2024.09.24 |
자바/스프링 개발자를 위한 실용주의 프로그래밍 3장 - 행동 (0) | 2024.07.18 |
자바/스프링 개발자를 위한 실용주의 프로그래밍 2장 - 객체의 종류 (VO, DTO, DAO, 엔티티) (0) | 2024.07.08 |
자바/스프링 개발자를 위한 실용주의 프로그래밍 1장 - 절차지향과 비교하기 (0) | 2024.07.08 |