개방 폐쇄 원칙: Open Close Principle
개방 폐쇄 원칙의 핵심 키워드는 다음과 같습니다.
- 확장에는 열려 있고, 변경에는 닫혀 있다.
- 기능을 추가할 때, 기존 코드를 변경하지 않는다.
개방 폐쇄 원칙은 모듈의 확장과 변경을 관리하는데 용이 하며, 기능이 추가되어도 기존 코드를 변경하지 않습니다.
해당 원칙의 주요 개념을 풀어쓰면 다음과 같습니다.
- 확장: 새로운 타입(클래스)을 추가하여 새로운 기능(요구사항)을 구현할 수 있어야 한다.
- 변경: 새로운 클래스가 추가되어도, 기존 코드를 호출하는 상위 모듈이 수정되지 않아야 한다.
OCP를 설명하기 위해 간단한 예제를 통해 설명하겠습니다.
요구사항
- 카드 결제 시스템을 만들고자 한다.
- 결제를 지원하는 카드는 신한카드 하나만 있다.
- 우리카드 결제 방식이 추가되어 구현해야 한다.
OCP를 준수하지 못한 과정
기존 PaymentController는 ShinhanCardPaymentService를 의존하여 결제를 진행하였습니다. 이후 우리카드 결제 방식이 추가됨으로 WooriCardPaymentService를 만들어 아래와 같이 결제 방식을 추가 구현하고자 합니다.
OCP는 기존 코드를 건드리지 않아야 한다고 했으니, 우리카드를 추가하는 가장 간단한 방법은 아래와 같이 컨트롤러를 추가하는 방법을 생각해볼 수 있습니다.
카드사 별 API 추가
class PaymentController {
private final ShinhanCardPaymentService shinhanCardPaymentService;
private final WooriCardPaymentService wooriCardPaymentService;
@PostMapping("rom/payment/shinhan")
public void pay(@RequestBody ShinhanCardPaymentRequest req) {
shinhanCardPaymentService.pay(req);
}
@PostMapping("rom/payment/woori")
public void pay(@RequestBody WooriCardPaymentRequest req) {
wooriCardPaymentService.pay(req);
}
}
그러나 해당 구조는 매우 좋지 못한 방식입니다. 추후 추가될 카드사 마다 API를 만들어야하기 때문입니다. 이는 확장에 좋지 않은 코드이며, 반복적인 코드가 됩니다.
반복 코드 제거 및 API 통일
class PaymentController {
private final ShinhanCardPaymentService shinhanCardPaymentService;
private final WooriCardPaymentService wooriCardPaymentService;
@PostMapping("/rom/payment")
public void payment(@RequestBody PaymentRequest request) {
switch (request.getType()) {
case CardType.SHINHAN -> shinhanCardPaymentService.pay(request);
case CardType.WOORI -> wooriCardPaymentService.pay(request);
default -> throw new IllegalArgumentException("Unknown card type: " + request.getType());
}
}
static class PaymentRequest {
private CardType type;
private String cardNumber;
private String csv;
//...
}
}
반복적인 코드를 제거하기 위해 기존 RequestBody의 값을 통일하여 처리될 수 있도록 바꿨으며, CardType에 따라 결제 방식을 분기하여 하나의 API로 결제를 진행할 수 있게 되었습니다.
그러나 이 방식도 OCP를 준수하지 못합니다. 이는 상위 모듈이 변경되기 때문입니다. 앞서 말했던 확장(우리카드 결제 방식 추가)이 발생했을 때 해당 코드를 호출하는 상위 모듈(PaymentController)은 변경되서는 안됩니다. 카드가 추가될 때마다 결제를 위한 case를 지속적으로 추가해야 합니다.
결국 결제를 담당하는 XXXCardPaymentService 클래스들을 컨트롤러가 지속적으로 의존성이 이뤄지게 됩니다. 그 결과 컨트롤러 계층은 너무 많은 책임을 가지게 되며, 확장에 어렵고 변경에 취약한 구조를 가지게 됩니다.
OCP를 준수한 과정
OCP를 지키기 위해선 새로운 결제 방식이 추가될 때, 기존의 로직을 수정하지 않고도 새로운 결제 방식을 추가할 수 있어야 합니다. 이를 위해 결제 방식을 추상화하는 인터페이스를 정의하고, 각 결제 방식을 인터페이스를 구현하는 구체 클래스로 만들면 기존의 로직을 변경하지 않습니다.
class PaymentController {
private final Map<CardType, CardPaymentService> cardPaymentServices;
@PostMapping("/rom/payment")
public void pay(@RequestBody CardPaymentRequest req){
CardPaymentService cardPaymentService = cardPaymentServices.get(req.getType());
if (cardPaymentService == null) {
throw new IllegalArgumentException("Unknown card type: " + req.getType());
}
cardPaymentService.pay(req);
}
}
public interface CardPaymentService {
void pay(CardPaymentRequest req);
}
public class ShinhanCardPaymentService implements CardPaymentService {
@Override
public void pay(CardPaymentRequest req) {
shinhanCardApi.pay(req);
}
}
public class WooriCardPaymentService implements CardPaymentService {
@Override
public void pay(CardPaymentRequest req) {
wooriCardApi.pay(req);
}
}
위처럼 의존관계를 인터페이스를 통해 역전시킵니다. 새로운 카드 결제가 추가된다고 해도 컨트롤러는 변경 없이 CardPaymentServices를 통해 CardType을 찾아 필요한 구체 클래스를 가져올 수 있습니다.
즉, 새로운 결제 방식이 추가(확장)되어도 결제를 호출하는 기존 코드는 변경이 발생하지 않습니다. 컨트롤러 계층과 서비스 계층의 결합도를 느슨하게 만듬으로써 OCP를 지길 수 있게 되었습니다.
그렇다면 "CardPaymentServices에 구체 클래스를 누가 주입해주는가?"에 대한 질문이 생깁니다.
스프링 프레임워크를 공부하셨다면, 객체(빈)를 생성하고 관리해주는 기능에 대해 알고 계실 겁니다. 스프링을 사용하면 제어의 역전(IoC) 원칙을 적용하여 Config 클래스를 통해 의존 관계를 설정하고 관리할 수 있습니다.
@Configuration
class CardPaymentServiceConfig {
@Bean
public CardPaymentService shinhanCardPaymentService() {
return new ShinhanCardPaymentService();
}
@Bean
public CardPaymentService wooriCardPaymentService() {
return new WooriCardPaymentService();
}
@Bean
public Map<CardType, CardPaymentService> CardPaymentServices(
PaymentService shinhanCardPaymentService,
PaymentService wooriCardPaymentService) {
return Map.of(
CardType.SHINHAN, shinhanCardPaymentService,
CardType.WOORI, wooriCardPaymentService
);
}
}
마치며
OCP를 공부하면서 DIP와 서로 밀접한 관계를 가지고 있어 두 원칙이 혼동되기도 합니다. 서로 소프트웨어 설계의 유지보수성을 높이기 위한 원칙이긴 하나, 중점을 두는 바와 적용 방법에 차이점이 있습니다.
OCP는 코드의 확장성에 중점을 두며, 기존 코드를 수정하지 않고 확장할 수 있도록 합니다. 이는 주로 상속과 다형성을 활용하여 추상 클래스나 인터페이스를 통해 새로운 기능을 추가함으로써 실현됩니다.
DIP는 모듈 간의 결합도를 낮추어 유연성을 높이는 것을 목적으로 합니다. 이를 위해 의존성 주입과 인터페이스 분리를 통해 상위 모듈과 하위 모듈이 의존하지 않도록 설계합니다. 앞서 설명했던 구체 클래스를 주입해주는 CardPaymentServiceConfig가 의존성 주입의 역할을 합니다. (OCP에서 나온 이유는 코드의 이해를 돕기 위함입니다.)
즉, OCP는 시스템의 확장성을 DIP는 의존성 관리를 강조합니다.
'Spring > Spring Framework' 카테고리의 다른 글
인터페이스 분리 원칙(Interface Segregation Principle, ISP) (0) | 2024.07.03 |
---|---|
리스코프 치환 원칙(Liskov Subsitution Principle, LSP) (0) | 2024.07.01 |
단일 책임의 원칙(Single Responsibility Principle, SRP) (0) | 2024.06.27 |
의존성 역전 원칙(Dependency Inversion Principle, DIP) (0) | 2024.06.25 |
스프링(Spring)이란? (0) | 2024.02.03 |