1. 개방-폐쇄 원칙(OCP)
OCP란 확장에는 열려있어야 하고 수정에는 닫혀있어야 한다는 원칙이다. 이전 장에서의 영화예매와 할인정책에서 알 수 있다. 만약 중복할인이라는 기능을 추가하면 기존코드를 손대지 않은 채 정상적으로 작동한다. 이에 따라 애플리케이션의 동작을 확장했다. 따라서 '확장에는 열려있다'. 또한 기존 코드를 수정없이 새로운 클래스(중복할인)를 추가하는 것만으로 할인 정책을 확장하였으므로 '수정에는 닫혀 있다'.
의존성 관점에서 개방-폐쇄 원칙을 따르는 설계란 컴파일타임 의존성은 유지하면서 런타임 의존성의 가능성을 확장하고 수정할 수 있는 구조라고 할 수 있다. 개방-폐쇄 원칙의 핵심은 추상화에 의존하는 것이다.
2. 생성 사용 분리
결합도가 높아질수록 OCP를 따르는 구조를 설계하기가 어려워진다. 알아야하는 지식이 많으면 결합도가 높아진다. 특히 객체 생성에 대한 지식은 과도한 결합도를 초래하는 경향이 있다. 물론 객체 생성을 피할 수는 없다. 부적절한 곳에서 객체를 생성하는 것이 문제다. 유연하고 재사용 가능한 설계를 원한다면 객체와 관련된 두 가지 책임을 서로 다른 객체로 분리해야 한다.
하나는 객체를 생성하는 것이고, 다른 하나는 객체를 사용하는 것이다. 즉 객체에 대한 생성과 사용을 분리해야 한다. 만약 클라이언트가 특정 영화의 금액을 반환하는 메서드를 구현한다고 가정해보자. 코드는 이러할 것이다.
public class Client {
public Money getIronManFee() {
Movie ironMan = new Movie("아이언맨", ..., new %할인(...));
return ironMan.getFee();
}
}
2.1 FACTORY
Movie의 인스턴스를 생성하는 동시에 메시지(getFee())도 함꼐 전송하는 것을 알 수 있다. 즉 Client는 생성과 사용의 책임을 함께 지니고 있다는 것이다. 이처럼 생성과 사용을 분리하기 위해 객체 생성에 특화된 객체를 FACTORY라고 부른다. 생성의 책임을 FACTORY로 넘겨주고 생성된 인스턴스를 반환 받아 사용하면 된다.
public class Factory {
public Movie createIronMan() {
return new Movie("아이언맨", ..., new %할인(...));
}
}
public class Client {
private Factory factory;
public Client(Factory factory) {
this.factory = factory;
}
public Money getIronManFee() {
Movie ironMan = factory.createIronMan();
return ironMan.getFee();
}
}
FACTORY를 사용하면 Client는 오직 사용과 관련된 책임만 지고 생성과 관련된 어떤 지식도 가지지 않을 수 있다.
2.2 PURE FABRICATION
이전 5장에서 책임 할당 원칙을 패턴의 형태로 기술한 GRASP 패턴에 대해 알아봤다. 도메인 모델은 정보 전문가를 찾기 위해 참조할 수 있는 일차적인 재료다. 어떤 책임을 할당하고 싶다면 제일 먼저 도메인 모델 안의 개념 중에서 적절한 후보가 존재하는지 찾아봐야 한다. 하지만 FACTORY는 도메인 모델에 속하지 않는다. 결합도를 낮추고 재사용성을 높이기 위해 도메인 개념에 할당되어 있던 객체 생성 책임을 도메인 개념과 상관없는 가공의 객체로 이동시킨 것이다.
크레이그 라만은 시스템을 객체로 분해하는 데는 크게 두 가지 방식이 존재한다고 설명한다.
- 표현적 분해 : 시스템을 '무엇'으로 구성되어 있는지에 초점을 둔다. 시스템의 세부 요소들, 그리고 그 요소들이 어떻게 서로 관련되어 있는지를 분석한다. 예를 들어, 데이터 모델링에서는 표현적 분해를 사용하여 시스템의 데이터 구조와 관계를 파악할 수 있습니다.
- 행위적 분해 : 시스템이 '어떻게' 동작하는지에 초점을 둔다. 시스템의 기능적 행위와 프로세스를 분석합니다. 예를 들어, 프로세스 모델링에서는 행위적 분해를 사용하여 시스템의 작업 흐름과 작업 간의 순서를 파악할 수 있습니다.
즉, 표현적 분해를 통해 시스템의 구조를 이해하고, 행위적 분해를 통해 그 구조가 어떻게 동작하는지를 이해함으로써, 시스템의 전체적인 동작과 구조를 더 잘 이해할 수 있다. 어떤 행동을 추가하려고 할때 이 행동을 책임질 도메인 개념이 존재하지 않는다면 PURE FABRICATION을 추가하고 이 객체에게 책임을 할당하라고 한다. 그렇게 추가된 PURE FABRICATION는 특정한 행동을 표현하는 것이 일반적이다. 따라서 표현적 분해보다 행위적 분해에 의해 생성되는 것이 일반적이다. PURE FABRICATION는 정보전문가 패턴에 따라 책임을 할당한 결과가 바람직하지 않을 경우 대안으로 사용된다. 앞써 다루었던 FACTORY를 예를 들수 있다.
3. 의존성 주입
한 객체가 다른 객체 없이는 제 기능을 수행할 수 없을 때, 이 '의존성'을 외부에서 주입하는 것
// 의존성 주입을 하지 않은 코드
class OrderService {
private OrderRepository orderRepository = new OrderRepository();
public List<Order> getOrders() {
return orderRepository.findAll();
}
}
// 의존성 주입을 하는 코드
class OrderService {
private OrderRepository orderRepository;
public OrderService(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
public List<Order> getOrders() {
return orderRepository.findAll();
}
}
3.1 SERVICE LOCATOR 패턴
의존성 주입 외에도 의존성을 해결할 수 있는 다양한 방법이 있다. 그중에서 널리 사용되는 대표적인 방법이다. SERVICE LOCATOR패턴은 애플리케이션에서 사용하는 서비스의 인스턴스를 생성하고 관리하는 역할을 하며 의존성을 해결할 객체들을 보관하는 일종의 저장소다. 솔직히 처음 들어본다. 그래서 조금 찾아봤다.
// Service Locator 사용예시
class ServiceLocator {
private static Map<String, Object> services = new HashMap<>();
static {
services.put("orderRepository", new OrderRepository());
}
public static Object getService(String serviceName) {
return services.get(serviceName);
}
}
class OrderService {
public List<Order> getOrders() {
OrderRepository orderRepository = (OrderRepository) ServiceLocator.getService("orderRepository");
return orderRepository.findAll();
}
}
3.1.1 장단점
장점
- 재사용성, 교체,변경 용의성 : 모든 서비스의 중앙 레지스트리를 유지하기 때문에 재사용하기 쉽다. 또한 서비스의 구현을 변경하거나 교체해야 할 때 Service Locator만 수정하면 된다.
- 유연성 : 의존성을 코드 내에 직접 정의하지 않기 때문에 Service Locator를 통해 기존코드 변경없이 새로운 서비스 사용가능
단점
- 의존성이 명시적이지 않다. 코드만 보고 어떤 서비스를 의존하는지 확인이 어렵다.
- 캡슐화 위배 : 의존성을 구현 내부로 감추도록 강요하는 Service Locator는 캡슐화를 위반할 수밖에 없다.
3.1.2 이걸 언제 사용할까? 왜 사용할까?
- 런타임에서 의존성을 바꿔야 하는 경우: 애플리케이션의 요구사항이 변화하거나, 다른 환경에서 애플리케이션을 실행해야 할 때, Service Locator 패턴을 사용하면 런타임에서 서비스의 구현체를 쉽게 변경할 수 있다. 예를 들어, 개발 환경에서는 메모리 DB를 사용하고, 운영 환경에서는 실제 DB를 사용하는 경우, Service Locator를 사용하면 환경에 따라 적절한 데이터베이스 서비스를 제공할 수 있다.
- 동일한 인터페이스를 가진 여러 서비스 중 하나를 선택해야 하는 경우: 여러 서비스가 동일한 역할을 수행하지만 세부 구현이 다른 경우, Service Locator를 사용하면 런타임에서 적절한 서비스를 선택할 수 있다. 예를 들어, 여러 종류의 결제 서비스(카드 결제, 계좌 이체, 휴대폰 결제 등)가 있는 경우, 사용자의 선택에 따라 적절한 결제 서비스를 Service Locator에서 가져올 수 있다.
- 서비스의 생명 주기를 관리해야 하는 경우: 서비스가 특정 생명 주기를 가지고 있거나, 다수의 클라이언트가 동일한 서비스 인스턴스를 공유해야 하는 경우, Service Locator가 이를 관리하면 코드의 복잡성을 줄일 수 있다.
4. 의존성 역전 원칙(DIP)
- 상위 모듈은 하위 모듈에 의존해서는 안 된다. 둘 다 추상화에 의존해야 한다.
- 추상화는 세부 사항에 의존해서는 안 된다. 세부 사항이 추상화에 의존해야 한다.
핵심은 '추상화에 의존해라'이다. 즉, 구체 클래스에 의존하지 말고 인터페이스나 추상 클래스에 의존을 해야 한다. 이렇게 하면 각 모듈 간의 결합도를 낮추고, 유연성과 재사용성을 높일 수 있다.
예를 들어, '음악 플레이어'라는 상위 모듈이 'MP3 플레이어'라는 하위 모듈에 직접 의존하는 경우, MP3 포맷 외의 다른 포맷(예: AAC, FLAC 등)을 지원하려면 코드를 변경해야 한다. 하지만 '음악 플레이어'가 '음악 재생'이라는 추상화(인터페이스)에 의존하고, 'MP3 플레이어', 'AAC 플레이어', 'FLAC 플레이어' 등이 이 인터페이스를 구현한다면, 새로운 음악 포맷을 지원하려면 해당 포맷을 재생하는 클래스를 추가하면 되므로 코드의 변경 없이 기능을 확장할 수 있다.
'Study > 오프젝트' 카테고리의 다른 글
11) 합성과 유연한 설계 (0) | 2024.01.17 |
---|---|
10) 상속과 코드 재사용 (0) | 2024.01.10 |
08) 의존성 관리하기 (0) | 2024.01.02 |
07) 객체 분해 (0) | 2023.12.27 |
06) 메시지와 인터페이스 (0) | 2023.12.15 |