Chapter 4. SOLID
SOLID는 객체지향에서 좋은 설계와 아키텍처에 빠질 수 없는 개념이다. 2000년대 초반에 고안한 5가지 원칙을 지칭하는 말로, 아래와 같은 원칙을 앞글자만 따서 부른다.
- 단일 책임 원칙 (SRP: Single Responsibility Principle)
- 개방 폐쇄 원칙 (OCP: Open-Closed Principle)
- 리스코프 치환 원칙 (LSP: Liskov Substitution Principle)
- 인터페이스 분리 원칙 (ISP: Interface Segregation Principle)
- 의존성 역전 원칙 (DIP: Dependency Inversion Principle)
각 원칙은 객체지향 언어에서 좋은 설계를 얻기 위해 개발자가 지켜야 할 규범과 같다. 또한, 각 원칙의 목표는 소프트웨어의 유지보수성과 확장성을 높이는 것이다.
소프트웨어의 유지보수성과 확장성을 높이는 것은 무슨 뜻인가?
설계 관점에서 코드의 유지보수성을 판단할 때 사용할 수 있는 실무적인 3가지 맥락이 있다.
- 영향 범위: 코드 변경으로 인한 영향 범위가 어떻게 되는가?
- 의존성: 소프트웨어의 의존성 관리가 제대로 이뤄지고 있는가?
- 확장성: 쉽게 확장 가능한가?
SOLID는 이 질문에 답을 알려주는 원칙인 것 같다. SOLID를 따르는 코드는 코드 변경으로 인한 영향 범위를 축소할 수 있고, 의존성을 제대로 관리하며, 기능 확장이 쉽다. 그래서 코드의 유지보수성이 올라간다.
4.1 SOLID 소개
4.1.1 단일 책임의 원칙
클래스를 변경해야 할 이유는 단 하나여야 한다.
클래스에 너무 많은 책임이 할당돼서는 안 되며, 단 하나의 책임만 있어야 한다. 클래스는 하나의 책임만 갖고 있을 때 변경이 쉬워진다. 하나의 클래스에 너무 많은 코드가 작성되어 있으면 제대로 분할되지 않은 경우가 많고, 가독성도 떨어진다. 더불어 어떤 메서드가 어디까지 영향을 주고 있는지 알 수 없게 된다는 것이 문제이다.
단일 책임 원칙은 '변경'과 연결되며, 변경으로 인한 영향 범위를 최소화하는 것이 이 원칙의 목적이다. 클래스는 하나의 책임만을 가져야 한다는 말은 클래스를 변경해야 할 이유는 단 하나여야 한다고도 할 수 있다. 소프트웨어는 복잡계이므로 빈번하게 들어오는 요구사항 변경을 효율적으로 처리하는 것이 중요하다. 따라서 외부의 변경 요청에도 소프트웨어의 항상성을 유지하려는 것이 이 원칙의 가장 큰 목적이다.
그렇다면 책임은 무엇일까?
class Developer {
public String createFrontendCode() {} // 프론트엔드 코드
public String publishFrontend() {} // 프론트엔드 서비스 배포
public String createBackendCode() {} // 백엔드 코드
public String serverBackend() {} // 백엔드 서비스 배포
}
케이스 1. 책임을 프론트엔드, 백엔드 개발자로 분류 (프론트엔드: 코드, 배포/ 백엔드: 코드, 배포)
케이스 2. 책임을 프론트엔드, 백엔드 개발자, 시스템 운영자로 분류 (프론트엔드: 코드/ 백엔드: 코드/ 시스템: 둘 다 배포)
케이스 3. 책임을 시스템 개발자로 분류 (시스템 개발자 혼자 다)
이처럼 책임을 가지고 바라보는 기준이 다 다를 수 있다.
하나의 모듈은 하나의, 오직 하나의 액터에 대해서만 책임져야 한다.
액터(actor)는 메시지를 전달하는 주체이다. 단일 책임 원칙에서 말하는 책임은 액터에 대한 책임이다. 메시지를 요청하는 주체가 누구냐에 따라 책임이 달라질 수 있다. 이처럼 어떤 모듈이나 클래스가 담당하는 액터가 혼자라면 단일 책임 원칙을 지키는 것이고, 여럿이면 위배된다.
단일 책임 원칙의 목표
- 클래스가 변경됐을 때 영향을 받는 액터가 하나여야 한다.
- 클래스를 변경할 이유는 유일한 액터의 요구사항이 변경될 때로 제한되어야 한다.
4.1.2 개방 폐쇄 왼칙
클래스의 동작을 수정하지 않고 확장할 수 있어야 한다.
이 원칙은 확장에는 열려 있고, 변경에는 닫혀 있어야 한다. 이 원칙의 주된 목적은 기존 코드를 수정하지 않으면서도 확장 가능한 시스템을 만드는 것이다. 코드를 확장하고자 할 때 취할 수 있는 최고의 전략은 기존 코드를 아예 건드리지 않는 것이다.
Order 클래스가 Food라는 구현체를 직접 사용할 경우, BrandProduct라는 새로운 요구사항이 들어오게 되면 코드를 변경해야 한다. 하지만 Order에서 Calculabe이라는 역할을 사용하고, Food를 Calculabe로 구현했다면 BrandProduct라는 새로운 요구사항이 온다면 Calculabe라는 역할로 구현만 하면 된다.
OCP 목표는 확장하기 쉬우면서 변경으로 인한 영향 범위를 최소화하는 것이다. 이 목표는 소프트웨어 설계에서 매우 중요한 가치이다. OCP 원칙은 코드를 추상화된 역할에 의존하게 만듦으로써 이를 달성할 수 있다.
4.1.3 리스코프 치환 원칙
파생 클래스는 기본 클래스를 대체할 수 있어야 한다.
이 원칙은 기본 클래스의 계약을 파생 클래스가 제대로 치환할 수 있는지 확인하라는 원칙이다.
@Getter
@Setter
@AllArgsConstructor
class Rectangle {
protected long width;
protected long heigth;
public long calculateArea() {
return width*length;
}
}
class Square extends Rectangle {
public Square(long length) {
super(length, length);
}
}
Rectangle r = new Square(10);
r.setHeight(5);
System.out.println(r.calculateArea()) // 50 : 원하는 결과 안나옴
이 예시는 대표적인 리스코프 치환 원칙의 위반 사례이다. 파생 클래스가 기본 클래스의 모든 동작을 완전히 대체할 수 있어야 하지만 현재는 정사각형은 직사각형 클래스의 모든 동작을 완전히 대체하지 못한다.
우선 파생 클래스가 기본 클래스를 대체할 수 있는지 파악하기 위해 기본 클래스의 할당된 의도를 파악해야 한다.
- getWidth를 호출하면 너비 값 반환
- getHeight를 호출하면 높이 값 반환
- setWidth를 호출하면 너비 값 변경
- setHeight를 호출하면 높이 값 변경
- calculateArea를 호출하면 넓이 값 계산
class Square extends Rectangle {
public Square(long length) {
super(length, length);
}
@Override
public void setHeight(long height) {
super.width = height;
super.heigth = height;
}
@Override
public void setWidth(long width) {
super.width = width;
super.heigth = width;
}
}
의도를 지키고자 오버라이딩을 해도 사실 Square는 기본 클래스를 대처할 수 없다. setHeigth를 호출했을 때 width까지 바뀌는 게 기본 클래스의 의도가 아니기 때문이다.
Rectangle로 코드를 구현해서 setWidth()를 호출하고 getWidth()를 호출했을 때 바뀌었다면 버그를 찾기도 힘들고, Rectangle에서 의도하는 바도 아닐 것이다. 작성자 의도를 파악하기 위해서는 직접 물어볼 수 있다. 하지만 이는 커뮤니케이션 비용을 발생하기도 하고, 작성자가 없을 수도 있기에 좋은 방법은 아니다. -> 좋은 방법: 테스트 코드
4.1.4 인터페이스 분리 원칙
클라이언트별로 세분화된 인터페이스를 만들어라
인터페이스 분리 원칙은 클라이언트가 자신이 사용하지 않는 인터페이스에 의존하지 않아야 한다는 원칙이다. 즉, 어떤 클래스가 자신에게 필요하지 않은 인터페이스의 메서드를 구현하거나 의존하지 않아야 한다. 따라서 인터페이스의 크기를 작게 유지하고, 클래스가 필요한 기능에만 집중할 수 있다.
이 원칙은 개발자들이 하나의 인터페이스로 모든 것을 해결하려고 할 때 위배된다. 따라서 단일 책임 원칙과도 관련이 있다.
public class LifeCycleBean implements A, B, C, D {
}
오픈 소스를 보다 보면 인터페이스를 세분화해서 많은 인터페이스를 구현한 경우가 있다. A와 B가 유사해서 합친다고 가정했을 때 NewBean이라는 기능은 A가 필요 없고, B만 필요한 경우가 있다. 이 경우에 NewBean은 필요 없는 A까지 구현해야 한다. 따라서 범용성을 갖춘 하나의 인터페이스를 만들기보다는 다수의 특화된 인터페이스를 만드는 것이 낫다.
통합된 인터페이스는 역할이 두리뭉실해진다. 정의가 지나치게 넓어져 모든 인터페이스가 모이게 되면 변경이 어려워진다. 이는 역할이 모호해지면서 여러 액터들을 상대하기 때문이다. 이러한 이유로 인터페이스 분리가 제대로 지켜지지 않은 코드는 단일 책임 원칙도 위배할 확률이 크다.
'비슷한 인터페이스를 하나로 통합해서 관리해서는 안된다'라고 말하고 있다. 하지만 '비슷한 인터페이스를 통합한다'는 그렇게까지 부정적인 말은 아니다. 오히려 '인터페이스를 통합하고 한 곳으로 모은다는 건 응집도가 높아지니 더 좋은 것'이라 할 수 있다.
맞다. 실제 인터페이스를 통합하려는 시도는 응집도를 추구하는 행위일 수 있다. 하지만 그것이 곧 응집력이 높아지는 결과로 이어지는 것은 아니다. 응집도라는 개념은 '유사한 코드를 한 곳에 모은다'에서 끝나는 것이 아니기 때문이다.
- 기능적 응집 (Functional Cohesion) : 모듈 내 컴포넌트들이 같은 기능을 수행하도록 설계된 경우. 즉, 모듈이 어떤 목적을 가지고 있는 컴포넌트들은 그 목적을 달성하기 위해 협력하며, 오직 관련된 작업만 수행하는 경우이다. (ex. 주문 처리 모듈을 만들어 컴포넌트 구성 시 주문이라는 도메인을 다루기보다 주문을 처리하는 것만을 목적, 데이터보다 역할과 책임 측면에서 바라보는 응집도)
- 순차적 응집 (Sequential Cohesion) : 모듈 내의 컴포넌트들이 특정한 작업을 수행하기 위해 순차적으로 연결된 경우. 즉, 어떤 컴포넌트 출력이 다음 컴포넌트의 입력으로 사용되는 형태를 말한다. (ex. DB에서 데이터 검색 후 검색 결과 가공하는 모듈)
- 통신적 응집 (Communicational Cohesion) : 모듈 내의 컴포넌트들이 같은 데이터나 정보를 공유하고 상호 작용할 때 이에 따라 모듈을 구성하는 경우. 즉, 모듈 내 컴포넌트들이 메시지를 주고받는 형태나 공유 데이터에 따라 구성되면 통신적 응집도를 추구했다고 한다. (ex. 이메일 전송 모듈을 구성할 때 이메일은 통신에 사용되는 특수한 프로토콜, 데이터 형식도 발신자, 수진자, 제목, 본문 등..)
- 절차적 응집 (Procedural Cohesion) : 모듈 내의 요소들이 단계별 절차를 따라 동작하도록 설계된 경우. 즉, 모듈의 요소들이 단계별로 연결돼 전체적인 기능을 수행하는 것이다. (ex. 계산기 모듈에서 입력, 연산 수행, 결과 출력.. 단계별)
- 논리적 응집 (Logical Cohesion) : 모듈 내의 요소들이 같은 목적을 달성하기 위해 논치적으로 연관된 경우. 즉, 모듈의 요소들이 서로 관련된 동작을 수행하지만 특정한 순서나 데이터의 공유가 필요하지 않는다. (ex. 회원 관리 모듈에서 회원 등록, 정보 업데이트, 삭제..)
일반적으로 기능적> 순차적> 통신적> 절차적> 논리적 순으로 높다고 평가한다. 유사한 코드라서 한 곳에 모아두겠다는 논리적 응집도며, 낮은 수준의 응집도를 추구하는 것이다. 하지만 인터페이스 분리 원칙이 추구하는 것은 무엇인가? 역할과 책임을 분리하고 역할을 세세하게 나누는 것이라 할 수 있다. 따라서 기능적 응집도를 추구한다 할 수 있다.
흔한 예시로는 Repository를 Reader, Writer로 분리하거나, 더 나아가 CRUD를 기준으로 분리할 수 있다.
범용적인 인터페이스를 만들지 않고, 세분화된 인터페이스를 만드는 것이 좋다. 인터페이스를 분리했을 때 코드의 재사용성이 높아진다.라고 할 수 있지만, 무작정 분리한다고 좋은 것도 아니다. 이미 기존 인터페이스를 통해 잘 개발해 왔고, 분리할 대 얻을 수 있는 장점은 많겠지만 결국 원칙일 뿐이다. 원칙과 효율성 사이에서 잘 조절해야 한다.
인터페이스와 코드의 재사용성
인터페이스를 이용해 코드의 재사용성을 높인다는 말의 의도는 인터페이스 자체의 재사용성을 높이려는 것이 아니다. 오히려 인터페이스를 사용하는 코드의 재사용성을 높이려는 것이다. 인터페이스를 사용하는 코드는 구현에 의존하지 않으므로 바뀌지 않는 비즈니스 로직을 여러 곳에서 재사용할 수 있다. 필요에 따라 구현체만 바꾸면 되기 때문이다.
4.1.5 의존성 역전 원칙
구체화가 아닌 추상화에 의존해야 한다.
- 상위 모듈은 하위 모듈에 의존해서는 안되며, 상위 모듈과 하위 모듈 모두 추상화에 의존해야 한다.
- 추상화는 세부 사항에 의존해서는 안 된다. 세부 사항이 추상화에 의존해야 한다.
- 고수준 모듈은 추상화에 의존해야 한다.
- 고수준 모듈이 저수준 모듈에 의존해서는 안된다.
- 저수준 모듈은 추상화를 구현해야 한다.
4.2 의존성
의존: 다른 객체나 함수를 사용하는 상태
class Printer { public void print(Book book){} } -> Printer가 Book을 사용한다.
class Book { private Writer writer; ..} -> Book이 Writer를 사용한다.
class Car implements Vehivle {} -> Car이 Vehicle을 사용한다.
단순히 사용하기만 해도 의존하는 것이다. 그렇기 때문에 소프트웨어는 의존하는 객체들의 집합이라고 할 수 있다. 객체지향에서 객체는 필연적으로 협력하는 데 서로를 사용하기 때문이다. 의존이야 말로 소프트웨어 설계의 핵심이다.
결합이란?
의존과 마찬가지로 사용하기만 해도 결합이 생긴다 할 수 있다. 하지만 결합은 어떻게 되어있느냐에 따라 강결합과 약결합으로 평가된다. 이처럼 결합이 얼마나 되는지 '결합도'로 평가한다. 결합도는 의존성과 같은 의미이며, 소프트웨어에서는 결합도가 약할수록 좋다고 평가한다.
의존성이 어려운 개념은 아니지만, 약한 의존 상태로 만들고 유지하는 것은 어렵다.
불필요한 의존
불필요한 의존은 다양한 문제를 만든다. 따라서 코드를 작성하면서 끊임없이 질문해야 한다.
- Printer가 Book에 의존하는 것은 자연스럽나? 만약 Printer에 Paper(논문)도 출력할 수 있게 Printer.print(paper)를 개발해 달라는 요구사항이 생기면 어떻게 해야 하나?
- Book이 변경될 때 Printer가 영향을 받지 않는다고 보장할 수 있나?
- String content만 받게 하는 건 어떤가? 아니면 Printable이라는 인터페이스를 만들어 매개변수로 받게 한다면?
4.2.1 의존성 주입
@Autowired를 사용하는 것 or 스프링의 도움 없이는 의존성 주입을 할 수 없다는 것은 모두 오해이다.
의존성 주입은 단순히 필요한 의존성을 외부에서 넣어주는(주입) 것이다. 따라서, 생성자를 생성할 때, 필드에서, 세터에서와 같이 다양한 방법으로 주입할 수 있다.
의존성 주입은 의존성 자체를 제거하지 않는다. 의존성을 약화시킨다.
의존성을 완전히 제거하는 것은 불가능하다. 객체지향 소프트웨어는 객체와 시스템의 협력으로 만들어지기 때문에 객체가 협력하는 데 필요한 기본적인 의존이 있다. 따라서 의존성을 약화시킬 뿐이다. 의존성은 의존 개수를 줄임과 동시에 불필요한 강한 의존이 생기지 않게 해 준다.
new 사용을 자제하라
new를 사용하는 것은 사실상 하드 코딩이고, 강한 의존성을 만든다. 이는 곧 추상 타입과 관계없이 고정 객체를 사용하겠다는 의미이다. 따라서 new를 사용하면 다른 객체가 사용될 여지가 사라진다. new를 사용하는 것은 Content coupling에 해당하는 결합이며 정보 은닉이라는 설계 목적을 위반하는 사례이다.
하지만 new는 어딘가는 사용되어야 한다. 객체를 인스턴스화하지 않는다면 시스템은 아무런 동작이 없을 것이다. 따라서 키워드 자체를 부정하는 것이 아니라, 상세한 구현 객체에 의존하는 것을 피하고, 구현 객체가 인스턴스화되는 시점을 최대한 미루라는 의미이다.
또한, 의존성 주입은 @Autowired 만으로 가능한 것이 아니다. 프레임 워크 도움 없이도 가능하며, 그냥 필요한 객체나 값을 외부에서 넣어주면 의존성 주입이다.
4.2.2 의존성 역전
의존성 주입은 Dependency Injection, 의존성 역전은 Dependency Inversion으로 다르다. A -> B 객체 A가 객체 B를 사용한다 하면 이처럼 표현할 수 있다. 이를 Order -> Food 라고 둔다면 Order는 Food에 의존한다. 하지만 Calculabe라는 인터페이스를 두고
Order -> Calculabe 처럼 Calculabe라는 인터페이스에 Order가 의존하도록 바꾸고, Food는 Calculabe를 구현했다면, Food는 Calculabe에 의존한다. (Order -> Calculabe <<- Food)
기존의 두 클래스 (Order, Food)가 인터페이스에 의존하도록 바꿨다. 이를 추상화를 이용한 간접 의존 형태로 바꿨다고 말할 수 있다. 그리고 이 상황을 의존성을 역전시켰다라고 한다.
Food에 화살표가 들어오는 방향에서 나가는 방향으로 바뀌었다. 화살표는 의존 방향을 나타내기에 이런 상황을 보고 의존의 방향이 역전됐다 할 수 있다. 의존성이 가지고 있는 의존성 전이라는 특징 때문에 이 변화는 큰 변화이다.
의존성 역전은 화살표의 방향을 바꾸는 기법이다.
리스코프 치환 원칙에서는 인터페이스는 계약이라 했다. 계약은 곧 정책이다. 따라서 인터페이스를 구현하는 객체는 세부 사항으로 볼 수 있다. 인터페이스는 규격이고 객체는 그 규격에 맞춰 만들어진 구현체이기 때문이다.
의존성 역전을 적용하고 나면 코드가 추상에 의존하는 형태로 바뀐다. 따라서 의존성 원칙은 세부 사항에 의존하지 않고 정책에 의존할 수 있다. 의존성 역전은 경계를 만드는 기법이며, 모듈의 범위를 정하고 상하 관계를 표현하는 데 사용할 수 있는 수단이다. 둘 다 인터페이스를 의존하고 있기 때문에 인터페이스 중심으로 경계를 만들 수 있다.
의존성 역전을 통해 생긴 경계는 곧 모듈의 경계로 사용될 수 있다. 따라서 의존성 역전은 상위 모듈이 하위 모듈에 의존하지 않게 하고 싶을 때 사용할 수 있는 기법 중 하나이다. 서로를 의존하는 것이 이상한 현상은 아니지만, 상위 모듈이 하위 모듈에 의존하는 것은 이상하다. 하위 모듈의 변경에 상위 모듈이 영향을 주기 때문이다.
이 말이 곧 SOLID의 의존성 역전 원칙이다.
- 상위 모듈은 하위 모듈에 의존해서는 안되며, 상위 모듈과 하위 모듈 모두 추상화에 의존해야 한다.
- 추상화는 세부 사항에 의존해서는 안 된다. 세부 사항이 추상화에 의존해야 한다.
따라서 구현보다 추상에 의존하는 것이 좋다. 추상에 의존할 때 설계는 유연해지고 변경은 자유로워진다.
4.2.3 의존성 역전과 스프링
스프링의 의존성 주입(Dependency Injection)을 지원하는 프레임워크인가?
스프링은 의존성 역전 원칙(Dependency Inversion Principle)을 지원하는 프레임워크인가?
스프링은 의존성 주입을 지원하는 프레임워크지만 의존성 역전 원칙을 지원하는 프레임워크는 아니다. 스프링을 사용한다 해서 의존성 역전 원칙이 지켜지는 것은 아니다. 의존성 역전 원칙은 설계의 영역이다.
보통 Spring을 배우면서 프로젝트를 만들면 컴포넌트 간 호출관계가 다음과 같을 것이다. Controller -> Service -> JpaRepository
이 과정에서 의존성 역전은 볼 수 없다.
물론 책을 끝까지 읽으면 결국 이 정도까지의 추상화가 꼭 정답이라고 말하진 않는다. 그래도 이런 아키텍처를 보면서 Controller -> Service -> JpaRepository 이런 틀에 박힌 아키텍처에서는 벗어나야 한다.
4.2.4 의존성이 강조되는 이유
유지보수성을 판단할 때 세 가지 맥락이 있다.
1. 영향 범위 : 코드 변경으로 인한 영향 범위가 어떻게 되는가?
-> 영향 범위에 문제가 있다면 응집도를 높이고 적절히 모듈화 해서 단일 책임 원칙을 준수하는 코드를 만든다.
2. 의존성 : 소프트웨어에서 의존성 관리가 제대로 이뤄지고 있는가?
-> 의존성에 문제가 있다면 의존성 주입과 의존성 역전 원칙 등을 적용해 약한 의존 관계를 만든다.
3. 확장성 : 쉽게 확장 가능한가?
-> 확장성에 문제가 있다면 의존성 역전 원칙을 이용해 개방 폐쇄 원칙을 준수하는 코드로 만든다.
결국 소프트웨어 설계를 잘하고 싶다면 코드를 변경하거나 확장할 때 영향받는 범위를 최소화할 수 있어야 한다. 따라서 최소화하기 위해선 의존성을 잘 다뤄야 한다.
변경으로 인한 영향 범위를 축소하는 것이 목표이고, 의존성을 잘 관리하는 것은 이 목표를 달성하기 위한 방법이다.
의존성을 잘 관리한다는 것은 무슨 의미일까? 의존성은 다른 객체나 시스템을 사용한다는 의미이고, 이는 객체 간의 협력과 시스템 간의 협력이 곧 의존성이라는 소리다. 소프트웨어에서 의존성을 아예 없애는 것은 불가능하다. 따라서 의존성을 잘 관리한다는 것은 불필요한 의존성을 줄이는 것이다. (의존성을 끊는 것)
의존성을 끊는 것은?
의존성은 '의존성 전의'라는 큰 특징을 가지고 있다. 의존성은 한 컴포넌트가 변경되거나 영향을 받으면 관련된 다른 컴포넌트에도 영향이 간다. 이렇게 연쇄적으로 영향을 주는 것을 의존성 전이라고 한다.
이렇게 의존성은 화살표 역방향으로 전이된다. 따라서 이러한 특징 때문에 소프트웨어 설계가 중요하다. 사진처럼 C컴포넌트에 의존성 역전을 적용해서 의존성 역전이 의존성 전이를 끊는 방식이 있다. 결국 이는 이 글? 이 책 내내 말한 자바 인터페이스가 구현을 가져서는 안 되는 이유까지 갈 수 있다.
추가로 의존성에 관해서 순환 참조(circular reference)를 만들지 말라는 격언이 있다. 순환 참조는 의존성 전이의 영향 범위를 확장시키는 주범이다.
만약 위 사진에서 C->B 가 없으면 순환참조는 아니다. 없을 경우 B 컴포넌트의 영향 범위는 A 하나이고, C컴포넌트는 모든 컴포넌트가 영향 범위에 속한다. 물론 C 컴포넌트 영향 범위는 이때도 상당히 높다. 하지만 위 사진처럼 양방향 참조가 만들어져 순환 참조가 생긴다면 B나 C가 변경될 경우 전 범위가 영향 범위에 속하게 된다. 이처럼 순환 참조는 의존성 전의 범위를 확장시킨다.
순환 참조는 사실상 같은 컴포넌트라는 선언이다.
순환 참조는 복잡한 의존성 그래프를 유도하고 의존성 전의 범위를 넓히는 주범이다. 의존 그래프에 사이클이 생겨서는 안 된다. 따라서 순환 참조를 만들지 않으려면 양방향 참조를 끊어내고 단방향으로 만들어야 한다. 시스템에 존재하는 모든 의존 방향을 단방향으로 만들어 문제가 발생했을 때 원인이 어디인지 추적 가능해야 한다.
4.3 SOLID와 객체지향
SOLID한 코드는 객체지향적인 코드다?
SOLID 원칙이 추구하는 것은 객체지향 "설계"이다. 따라서 SOLID와 객체지향이 추구하는 방향은 조금은 다르다.
객체지향의 핵심은 역할, 책임, 협력이다. 하지만 SOLID는 객체지향 방법론 중 하나로 변경에 유연하고 확장할 수 있는 코드를 만드는 데 초점을 둔다. 따라서, SOLID는 설계 원칙이며, 설계 원칙은 응집도를 높이고 의존성을 낮추는 방법에 집중한다.
엄밀히 말해 SOLDI를 추구하는 것이 곧 객체지향으로 이어지는 것은 아니기에, SOLID를 따르기 전에 객체지향의 본질인 역할, 책임, 협력을 제대로 이해하고 구현을 함께 고려해야 한다. 우리는 원칙이나 패턴을 맞추는 것에 익숙하지만 소프트웨어는 복잡계로, 요구사항과 전부 일치하는 해결책은 존재하지 않는다. 따라서 SOLID의 목표를 고민하는 것이 좋다. SOLID의 목표는 높은 응집도와 낮은 결합도이다.
4.4 디자인 패턴
소프트웨어에는 많은 디자인 패턴이 있다. 디자인 패턴은 소프트웨어 설계를 하면서 자주 만나게 되는 문제 상황을 정의하고, 이를 해결할 수 있는 모범 설계 사례를 모아 놓은 것이다. 4인조라는 공학자에 의해 설계된 패턴은 총 23가지로 크게 생성 패턴, 구조 패턴, 행동 패턴으로 분리할 수 있다.
물론 패턴을 외우고 있다면 문제를 해결하는 데 도움이 될 수는 있다. 하지만 결국 중요한 것은 문제를 해결하는 것이지, 패턴을 적용하는 것이 아니므로 외울 필요는 없다. 패턴은 문제 인식, 해결 과정, 해결 방법을 정리한 것이다.
Chapter4를 마치며
SOLID 원칙을 떠나서 결국 가장 중요한 것은 객체지향적으로 어떻게 객체들을 잘 다루느냐인 것 같다. SOLID 원칙도 객체지향을 잘 다루기 위한 방법론 중 하나일 뿐이니까...
객체지향에서 중요한 건 결국 책임을 어떻게 나누고, 어디에 할당하느냐라는 점이다. 더불어 이 책임을 단순히 객체에 직접 부여하는 것이 아니라, 추상화된 역할에 할당해 확장성과 유연성을 고려하는 것이 객체지향 프로그래밍의 핵심이다. (-> Chapter1에서 나온 정리이다.)
또한, SOLID 원칙은 우리가 이 목표에 한 걸음 더 가까워지기 위한 하나의 도구일 뿐이다.
이 책을 읽으며 돌아봤을 때, 전반적으로 Spring을 사용하면서 의존성이 강하면 안 되며, 의존성 주입까지는 어느 정도 잘 적용하 있다고 생각했다. 실제 내 프로젝트들에서도 의존성 주입은 문제없이 적용되고 있었다. 그러나 Spring을 입문할 때 제공되는 단편적인 예제들은 주로 JPA나 ORM, JDBC와 같은 특정 DB 접근 기술에 고정된 대표적인 구현만 보여주다 보니, Controller → Service → DetailRepository 구조가 자연스럽게 형성된다. 이는 해당 예제들이 그 기술에 의존하도록 설계되어 있기 때문에, 의존성이 한 방향으로 전이되는 문제를 깊게 고민해보지 않게 만드는 한계가 있다. 이 구조 자체가 잘못되었다기보다는, 기술에 고정된 예제에서는 의존성 관리 측면을 고려하지 않게 되는 것이다. 어쩌면 이는 객체 간 협력과 책임 분담에 대한 깊은 이해가 부족했기 때문일지도 모른다. 실제로 내 이전 프로젝트에서는 JPARepository 구현체에 직접 의존하는 코드가 존재하지만, 프로젝트의 기간이나 특성상 확장성과 유지보수성에 큰 문제가 없었기에 그 코드가 반드시 잘못됐다고 단정할 수는 없다. 다만, 단순히 코드를 가져와 구현하는 것과, 의존성이라는 관점에서 설계 방향을 신중하게 선택하는 것은 분명 다른 문제라고 생각한다.
앞으로 이와 관련해서 프로젝트의 설계를 변경한 사항 포스팅에서 다룰 예정이다.. 아마두!!!!!
이번 장을 마무리하면서, '책임의 명확한 할당'과 '의존성의 효과적인 관리'만 잘 이루어져도 객체지향 프로그래밍은 충분히 유연하고 확장성 있는 방향으로 나아갈 수 있다고 생각한다. 결국, 객체지향은 거창한 무언가가 아니라 객체 간의 협력과 책임 분리를 얼마나 잘 해내느냐의 문제인 것 같다.
'책책책 책을 읽어요 > 자바,스프링 개발자를 위한 실용주의 프로그래밍' 카테고리의 다른 글
[자바/스프링 개발자를 위한 실용주의 프로그래밍] Chapter 6. 안티패턴 (2) | 2025.05.04 |
---|---|
[자바/스프링 개발자를 위한 실용주의 프로그래밍] Chapter 5. 순환 참조 (1) | 2025.04.15 |
[자바/스프링 개발자를 위한 실용주의 프로그래밍] Chapter 3. 행동 (2) | 2025.04.06 |
[자바/스프링 개발자를 위한 실용주의 프로그래밍] Chapter 2. 객체의 종류 (0) | 2025.03.16 |
[자바/스프링 개발자를 위한 실용주의 프로그래밍] Chapter 1. 절차지향과 비교하기 (2) | 2025.03.15 |