단일 책임의 원칙: Single Responsibility Principle
단일 책임의 원칙 핵심 키워드는 다음과 같습니다.
- 클래스는 단 한 개의 책임을 가져야 한다.
- 클래스를 변경하는 이유는 단 한 개여야 한다.
- 누가 해당 메서드의 변경을 유발하는 사용자(Actor) 인가?
단일 책임 원칙의 개념은 문장 자체로는 이해하기 쉽지만, 이를 구체적으로 적용하여 설계하는 건 제 입장에서 이해하기가 어려웠습니다. 초기 단계부터 명확한 책임을 도출하기 어려우며, 개발 과정 중 요구사항이 변경되면 이에 따라 클래스의 책임도 변하여 SRP 원칙을 유지하기 어렵습니다.
단순한 예제부터 단계 별로 단일 책임 원칙을 설명하려고 합니다. 저의 주관적인 생각이 들어가기도 해서 틀린 부분이 있다면 지적해 주시면 감사하겠습니다.
단일 책임 원칙을 이해하기 위해 먼저 간단한 예제를 통해 이해해 봅시다.
요구사항
- 주문 정보를 관리하는 시스템을 구현해야 한다.
- 현재 주문을 추가하는 로직과 주문 상태를 변경하는 서비스가 필요하다.
예제코드
잘못된 설계
class Order {
void addOrder(Order order) {
// 주문 추가 로직
}
void updateOrderStatus(int quantity) {
// 주문 상태 업데이트 로직
}
}
위의 Order 클래스는 주문 추가와 주문 상태 업데이트라는 2가지 책임을 가지고 있습니다. 이는 SRP의 클래스는 단 한 개의 책임을 가져야 한다는 원칙에 위배됩니다.
개선된 설계
class Order {
// 주문 정보 속성들
}
class OrderManager {
void addOrder(Order order) {
// 주문 추가 로직
}
}
class OrderStatusManager {
void updateOrderStatus(Order order, OrderStatus status) {
// 주문 상태 업데이트 로직
}
}
이제 Order 클래스는 주문 정보를 저장하는 역할만 가지며, OrderManager 클래스는 주문 추가, OrderStatusManager 클래스는 주문 상태 업데이트라는 각각의 책임을 가지는 것을 볼 수 있습니다. 이를 통해 각각의 역할을 분리하여 단일 책임 원칙을 준수합니다.
도메인 중심 설계와 SRP 원칙
그러나, 한 가지 의문이 생깁니다.
도메인 중심 설계(Domain-Driven Design, DDD)에 익숙한 개발자들에겐 엔티티(Book) 내 객체의 활동(메서드)을 모아서 설계하기 때문입니다. 그렇다면 SRP 원칙과 도메인 중심 설계는 서로 상충되어 보이기도 합니다. 그러나 결론부터 말하자면 서로 상충되는 개념은 아닙니다.
도메인 중심 설계에서는 도메인의 핵심 개념과 규칙을 코드로 명확하게 표현하는 것이 중요합니다. 이 과정에서 엔티티(Entity), 값 객체(Value Object), 서비스(Service) 등의 개념을 사용합니다. 각 개념은 자신의 책임을 명확히 정의하고, 이를 구현합니다.
요구사항
- 주문 관리 시스템: 고객이 주문을 하고, 주문에 대한 결제를 처리하는 시스템을 구현한다.
- 도메일 모델:
- Order(엔티티):
- 책임: 주문 정보와 상태를 관리
- 메서드: 주문 추가, 주문 취소, 주문 상태 변경 등
- PaymentService(도메인 서비스):
- 책임: 결제 처리 로직
- 메서드: 결제 수행, 결제 취소 등
- Order(엔티티):
예제코드
Order 엔티티:
public class Order {
private String orderId;
private List<OrderItem> items;
private OrderStatus status;
public Order(String orderId) {
this.orderId = orderId;
this.items = new ArrayList<>();
this.status = OrderStatus.NEW;
}
public void addItem(OrderItem item) {
items.add(item);
}
public void cancelOrder() {
if (this.status == OrderStatus.NEW) {
this.status = OrderStatus.CANCELLED;
} else {
throw new IllegalStateException("Cannot cancel an order that is already processed.");
}
}
public void completeOrder() {
if (this.status == OrderStatus.NEW) {
this.status = OrderStatus.COMPLETED;
} else {
throw new IllegalStateException("Cannot complete an order that is not new.");
}
}
// 추가적인 주문 관련 메서드들...
}
Order 엔티티는 주문과 관련된 상태와 행위를 관리합니다. 주문 추가, 취소, 완료 등의 책임을 가집니다.
PaymentService 도메인 서비스:
public class PaymentService {
public void processPayment(Order order, PaymentDetails paymentDetails) {
if (order.getStatus() == OrderStatus.NEW) {
// 결제 처리 로직
order.completeOrder();
} else {
throw new IllegalStateException("Payment can only be processed for new orders.");
}
}
public void cancelPayment(Order order) {
if (order.getStatus() == OrderStatus.COMPLETED) {
// 결제 취소 로직
order.cancelOrder();
} else {
throw new IllegalStateException("Payment can only be cancelled for completed orders.");
}
}
}
PaymentService 서비스는 결제 처리와 관련된 행위를 담당합니다. 결제 수행 및 취소 등의 책임을 가집니다.
각각의 클래스는 서로 명확한 책임을 가지며, 단일 책임 원칙에도 부합합니다. 저는 단일 책임 원칙을 이해할 때 하나의 책임이란 무엇인가 이해하기 어려웠습니다. 단일 책임 원칙의 "책임"은 하나의 구체적인 행동보다는 클래스가 수행해야 하는 하나의 일관된 역할이나 기능을 의미합니다. 이는 객체를 하나의 명확한 책임으로 나눈다는 뜻입니다.
도메인 중심 설계와 단일 책임 원칙은 서로 상호 보완적이라고 볼 수 있습니다. 도메인 중심 설계와 단일 책임 원칙은 함께 적용하면 더 나은 도메인 모델을 설계할 수 있습니다.
기존 기능에서 확장이 일어날 때, SRP 원칙
지금까지 단일 책임 원칙을 이해하는 과정을 거쳤습니다. 초기 설계 단계에서 SRP 원칙을 준수하는 방법을 이해하셨을 것이라 생각합니다. 하지만 실무에서는 초기 설계뿐만 아니라 이후 추가 기능 확장 및 요구사항 변경이 빈번히 발생하게 됩니다.
즉, 책임이 변화할 가능성이 있다는 말입니다. 그렇기에 지속해서 한 클래스가 한 책임 만을 갖는 것은 매우 어렵습니다.
요구사항
- 카드 결제 시스템 개발이 완료 되었으며, 추가 기능 확장을 기획 중이다.
- 현재 국내 결제만 지원하며 신한, 우리카드를 통해 결제할 수 있다.
- 추후, 해외 결제 기능이 기획되어 개발이 필요하다.
- 신한카드는 해외 결제가 가능하다.
- 우리카드는 해외 결제가 불가능하다.
기존 카드 결제 시스템
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);
}
}
위 UML과 코드의 이해가 어려우시면 이전 포스팅 DIP, OCP 원칙을 먼저 보시는 것을 권장합니다.
- 클래스의 책임: 해당 카드사의 결제 API를 호출하기 위한 적절한 값을 생성하고, 이를 호출하는 것
- Actor(행위자): 카드 결제를 수행하는 행위자
- 변경의 근원: 카드 결제를 하는 Actor(행위자) 요구사항 변경
SRP 원칙의 "클래스의 변경은 단 하나의 이유로만 일어나야 한다"의 의미는 그 클래스의 책임을 수행하는 Actor의 요구사항 변경에 의해서만 클래스가 변경되어야 한다는 의미입니다.
예를 들어, Actor가 결제 정보를 추가적으로 원한다면 pay() 메서드의 반환 타입 변경이 필요할 수 있습니다. 이 경우, 클래스의 변경은 Actor의 요구사항 변경에 의해 비롯됩니다.
Actor는 단순히 사용자가 아닌 특정 행위를 수행하는 주체로 이해해야 합니다. SRP 원칙에서 "단일 책임"은 단일 Actor를 의미합니다.
따라서 각 클래스는 하나의 Actor의 요구를 충족시키기 위한 책임만을 가져야 합니다. 결론적으로, SRP 원칙을 준수하는 클래스는 특정 Actor의 요구에 대응하는 책임만을 가지며, 다른 Actor의 요구사항에 의해 변경되지 않아야 합니다.
해외 카드 결제가 추가된 설계, SRP 미준수
public interface CardPaymentService {
void pay(CardPaymentRequest req);
void payInternational(CardPaymentRequest req);
}
public class ShinhanCardPaymentService implements CardPaymentService {
//... 국내 결제 생략
@Override
public void payInternational(CardPaymentRequest req) {
shinhanCardApi.payInternational(req);
}
}
public class WooriCardPaymentService implements CardPaymentService {
//... 국내 결제 생략
@Override
public void pay(CardPaymentRequest req) {
throw new UnsupportedOperationException("This payment method is not supported.");
}
}
위와 같이 신한카드는 해외 결제를 할 수 있으나, 우리카드는 해외 결제를 지원하지 않습니다. 각각의 구현 클래스들은 CardPaymentService 인터페이스를 구현하기 때문에 해외 결제 메서드 payInternational()이 추가되어야 합니다.
이는 국내 결제뿐 만이 아닌 해외 결제라는 책임이 하나 더 생긴 것입니다. 즉, 2개의 책임을 가지게 되었으며 2개의 Actor가 생긴 것입니다. 앞서 설명한 SRP 원칙 중 단일 책임 == 1개의 Actor를 위배하게 됩니다.
해외 카드 결제가 추가된 설계, SRP 준수
카드사 별 국내, 해외 결제의 책임을 분리시켜 하나의 Actor는 하나의 책임을 질 수 있게 되었습니다. 카드사 별 국내, 해외 결제 기능이 별로로 관리되기 때문에 향후 카드사가 추가되어도 단일 책임의 원칙을 준수할 수 있게 되었습니다.
🤔 만약 우리카드가 해외 결제를 지원하며, 추가될 카드사들도 해외 결제를 지원한다면?
모든 카드사가 해외 결제를 지원한다면, 해외 결제는 기본적인 기능이 되어 하나의 Actor로 간주할 수 있습니다.
이 경우 CardPaymentService 인터페이스에 payInternational() 메서드를 포함하는 것은 SRP 원칙을 위배하지 않습니다. 그러나 실제 비즈니스는 간단하지 않기에 국내 결제와 해외 결제 기능을 분리하여 책임을 나누는 것이 좋습니다. 이는 향후 시스템 확장이나 변경 시 유연하게 대응할 수 있기 때문입니다.
마치며
이전까지 단일 책임의 원칙을 단순히 "하나의 책임을 가져야한다"로 이해하고 있었습니다. 그러나 SOLID 원칙을 다시 복습하면서 단일 책임의 원칙이 가장 어려운 개념이라는 생각이 들었습니다.
단일 책임의 원칙은 단순히 하나의 책임을 갖는 것 이상의 의미를 내포하고 있습니다. 이 원칙의 핵심은 클래스나 모듈이 변경되는 이유는 오직 하나여야 한다는 것입니다. 이는 시스템의 유지보수성과 확장성을 높이기 위함입니다.
실제 애플리케이션에선 특정 클래스나 모듈이 여러 책임을 지는 경우가 많으며, 이를 분리하고 단일 책임을 부여하는 것은 결코 쉬운 일은 아니라고 생각합니다. 그러나, 단일 책임 원칙을 준수할 때 얻는 이점은 상당하기 때문에 준수하는 것을 추천합니다.
'Spring > Spring Framework' 카테고리의 다른 글
인터페이스 분리 원칙(Interface Segregation Principle, ISP) (0) | 2024.07.03 |
---|---|
리스코프 치환 원칙(Liskov Subsitution Principle, LSP) (0) | 2024.07.01 |
개방 폐쇄 원칙(Open-Close Principle, OCP) (0) | 2024.06.26 |
의존성 역전 원칙(Dependency Inversion Principle, DIP) (0) | 2024.06.25 |
스프링(Spring)이란? (0) | 2024.02.03 |