안녕하세요! 개발자 stark입니다.
이번 포스팅은 SOLID 원칙 중 첫 번째인 단일 책임 원칙(Single Responsibility Principle, SRP)에 대한 내용입니다. 굉장히 흔한 지식이지만 로버트 마틴이 작성한 SRP 관련 글을 읽던 중 "각 소프트웨어 모듈은 변경의 이유가 하나여야 한다."라는 다소 추상적인 개념을 보게 되었고 제가 이걸 조금이라도 더 쉽게 이해하고 싶다는 생각이 들어 정리하며 작성하게 되었습니다.
참고로 SRP를 '클래스가 한 가지의 일만 해야 한다.'라고 착각하는 경우가 많습니다. 저도 이런 생각을 했던 적이 있었는데 실무에서 코드를 작성하다 보면 잘못된 이해 때문에 오히려 코드를 복잡하게 작성하고 있었다는 것을 알게 되었습니다. 잘못된 이해를 가지고 코드를 작성하다 보면 오히려 불필요한 메서드 분리로 클래스 개수만 늘어나고 전체적인 구조는 더 복잡해지는 현상이 발생했습니다.
그러니 지금부터 로버트 마틴이 설명한 '단일 책임 원칙(SRP)'이 어떤 것인지 이해해 봅시다.
단일 책임 원칙 (SRP)
"클래스는 오직 하나의 이유로만 변경되어야 한다" - 로버트 C. 마틴
이 문장이 정확히 의미하는 바는 무엇일까요? 핵심은 '변경의 이유'가 무엇인지 이해하는 것입니다.
로버트 마틴은 이 '변경의 이유'를 액터(actor)의 관점에서 바라봐야 한다고 설명합니다. 여기서 '액터'란 시스템에 변경을 요청할 수 있는 '사용자 그룹'을 말합니다. 예를 들어 아래와 같은 그룹들이 액터라고 볼 수 있습니다.
- 비즈니스 팀: 상품 로직, 할인 정책 등
- 데이터 엔지니어링 팀: DB 스키마, 쿼리 최적화
- 보안 팀: 인증, 권한 관리
- 마케팅 팀: 알림, 이메일 템플릿
즉, 단일 책임 원칙(SRP)은 "한 클래스는 오직 하나의 액터(사용자)만을 위한 책임을 가져야 한다."는 의미입니다. 이것이 바로 "변경의 이유가 하나"라는 말의 실체입니다. 지금부터 로버트 마틴의 글을 살펴봅시다.
아래의 내용은 Clean Coder 블로그에 적혀있는 Robert C. Martin의 글 내용입니다. (인용)
- "The Single Responsibility Principle (SRP) states that each software module should have one and only one reason to change."
-> 단일 책임 원칙(SRP)은 각 소프트웨어 모듈이 변경해야 하는 단 하나의 이유를 가져야 한다고 명시합니다. - And this gets to the crux of the Single Responsibility Principle. This principle is about people.
-> 그리고 이는 단일 책임 원칙의 핵심에 도달합니다. 이 원칙은 사람에 관한 것입니다. - However, as you think about this principle, remember that the reasons for change are people. It is people who request
-> 하지만 이 원칙을 생각하면서 변화의 원인은 바로 '사람'이라는 점을 기억하세요. 변화를 요구하는 사람들입니다.
제 생각에 로버트 마틴이 말하는 '변화의 원인은 바로 사람'의 의미는 '엑터(사용자 그룹)에 의해서 클래스가 변경되는 것만 허용되어야 한다.'를 표현한 게 아닐까 생각합니다. 저에게는 이 블로그에 적힌 로버트 마틴의 말들이 쉬운 듯 어렵게 느껴졌습니다.
하단의 Clean Coder 블로그에 있는 로버트 마틴의 글을 읽어보세요!
Clean Coder Blog
The Single Responsibility Principle 08 May 2014 In 1972 David L. Parnas published a classic paper entitled On the Criteria To Be Used in Decomposing Systems into Modules. It appeared in the December issue of the Communications of the ACM, Volume 15, Number
blog.cleancoder.com
실전 SRP 위반 사례 분석
아래 코드를 살펴봅시다. 이것은 전형적인 SRP 위반 사례입니다.
- 주문 서비스 클래스 안에서 수많은 액터들이 필요로 하는 작업이 진행되고 있습니다.
@RequiredArgsConstructor
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentGateway paymentGateway;
private final EmailSender emailSender;
private final InventoryRepository inventoryRepository;
@Transactional
public OrderResult processOrder(OrderRequest request) {
// 주문 유효성 검사 (비즈니스 팀 관심사)
validateOrder(request);
// 주문 DB 저장 (데이터 팀 관심사)
Order order = mapToEntity(request);
orderRepository.save(order);
// 결제 처리 (결제 팀 관심사)
PaymentResult payment = paymentGateway.processPayment(
request.getPaymentInfo(), order.getTotalAmount());
if (!payment.isSuccessful()) {
throw new PaymentFailedException(payment.getMessage());
}
// 재고 업데이트 (물류 팀 관심사)
for (OrderItem item : order.getItems()) {
inventoryRepository.decreaseStock(item.getProductId(), item.getQuantity());
}
// 이메일 발송 (마케팅 팀 관심사)
emailSender.sendOrderConfirmation(order.getCustomerEmail(), order);
return new OrderResult(order.getId(), "주문이 성공적으로 처리되었습니다.");
}
// 기타 메소드들...
}
주문 클래스는 다음과 같은 형태로 메서드가 선언되어 있습니다.
이 클래스는 최소 5개의 서로 다른 액터(사용자 그룹)를 위한 책임을 갖고 있어서 SRP를 완전히 위반하고 있습니다. 각 팀이 요구사항 변경을 요청할 때마다 이 클래스를 수정해야 하고, 한 팀의 변경이 다른 팀의 기능에 영향을 줄 위험이 큽니다.
- 비즈니스 팀: "주문 유효성 검사 규칙을 변경해 주세요."
- 데이터 팀: "DB 스키마가 변경되어 저장 로직을 수정해 주세요."
- 결제 팀: "새로운 결제 게이트웨이를 추가해 주세요."
- 재고 팀: "재고 변경 로직을 수정해 주세요."
- 마케팅 팀: "이메일 템플릿을 업데이트해 주세요."
갑자기 비즈니스팀(그룹)이 유효성 검사 규칙을 변경했다고 가정해 봅시다.
위와 같이 주문의 유효성을 검사하는 로직이 변경되면서 이전에는 잘 동작하던 주문 저장과 재고 감소 로직에 영향을 줄 수도 있습니다. 즉, 한 액터의 요구사항이 다른 액터(사용자 그룹)의 기능을 망가뜨릴 위험이 있는 것입니다.
SRP를 적용한 우아한 리팩토링
이제 SRP에 따라 '액터(사용자 그룹)'별로 책임을 분리해 보겠습니다. 다음과 같이 변경될 것입니다.
그럼 이제 코드로 만들어봅시다.
- 주문 유효성 검증 (비즈니스 팀)
@RequiredArgsConstructor
@Component
public class OrderValidator {
private final ProductRepository productRepository;
public void validate(OrderRequest request) throws InvalidOrderException {
// 주문 최소 금액 검증
if (request.getTotalAmount().compareTo(BigDecimal.ZERO) <= 0) {
throw new InvalidOrderException("주문 금액은 0보다 커야 합니다.");
}
// 상품 존재 여부 검증
for (OrderItemRequest item : request.getItems()) {
if (!productRepository.existsById(item.getProductId())) {
throw new InvalidOrderException("존재하지 않는 상품입니다: " + item.getProductId());
}
// 수량 검증
if (item.getQuantity() <= 0) {
throw new InvalidOrderException("상품 수량은 0보다 커야 합니다.");
}
}
}
}
- 주문 저장 (데이터 팀)
@RequiredArgsConstructor
@Repository
public class OrderRepository {
private final JdbcTemplate jdbcTemplate;
public Order save(Order order) {
// 주문 저장 로직
// ...
return order;
}
// 다른 데이터 접근 메소드들...
}
- 결제 처리 (결제 팀)
@RequiredArgsConstructor
@Component
public class PaymentProcessor {
private final PaymentGateway paymentGateway;
private final TransactionLogger transactionLogger;
public PaymentResult processPayment(PaymentInfo paymentInfo, BigDecimal amount) {
PaymentResult result = paymentGateway.processPayment(paymentInfo, amount);
// 결제 트랜잭션 로깅
transactionLogger.logTransaction(
new TransactionLog(paymentInfo.getPaymentMethod(), amount, result.isSuccessful())
);
return result;
}
}
- 재고 관리 (물류 팀)
@RequiredArgsConstructor
@Component
public class InventoryManager {
private final InventoryRepository inventoryRepository;
public void updateStock(List<OrderItem> items) throws InsufficientStockException {
for (OrderItem item : items) {
int currentStock = inventoryRepository.getStockByProductId(item.getProductId());
if (currentStock < item.getQuantity()) {
throw new InsufficientStockException(
"재고 부족: 상품 ID " + item.getProductId() +
", 요청: " + item.getQuantity() +
", 현재 재고: " + currentStock
);
}
inventoryRepository.decreaseStock(item.getProductId(), item.getQuantity());
}
}
}
- 알림 발송 (마케팅 팀)
@RequiredArgsConstructor
@Component
public class OrderNotifier {
private final EmailSender emailSender;
private final NotificationTemplateProvider templateProvider;
public void sendOrderConfirmation(String email, Order order) {
String emailContent = templateProvider.getOrderConfirmationTemplate(order);
emailSender.send(email, "주문 확인 #" + order.getId(), emailContent);
}
}
오케스트레이션 서비스
- 이제 이들을 조화롭게 사용하는 서비스를 구현해 보겠습니다.
@RequiredArgsConstructor
@Service
public class OrderService {
private final OrderValidator orderValidator;
private final OrderRepository orderRepository;
private final PaymentProcessor paymentProcessor;
private final InventoryManager inventoryManager;
private final OrderNotifier orderNotifier;
@Transactional
public OrderResult processOrder(OrderRequest request) {
// 1. 주문 유효성 검증
orderValidator.validate(request);
// 2. 주문 엔티티 생성 및 저장
Order order = mapToEntity(request);
Order savedOrder = orderRepository.save(order);
// 3. 결제 처리
PaymentResult paymentResult = paymentProcessor.processPayment(
request.getPaymentInfo(),
savedOrder.getTotalAmount()
);
if (!paymentResult.isSuccessful()) {
throw new PaymentFailedException(paymentResult.getMessage());
}
// 4. 재고 업데이트
inventoryManager.updateStock(savedOrder.getItems());
// 5. 주문 확인 이메일 발송
orderNotifier.sendOrderConfirmation(savedOrder.getCustomerEmail(), savedOrder);
return new OrderResult(savedOrder.getId(), "주문이 성공적으로 처리되었습니다.");
}
private Order mapToEntity(OrderRequest request) {
// DTO를 엔티티로 변환하는 로직
// ...
}
}
이제 각 클래스는 단 하나의 액터(사용자 그룹)만을 위한 책임을 갖게 되었습니다. 비즈니스 팀에 의해 유효성 검사가 변경되면 OrderValidator만 수정하면 되고, 마케팅 팀에 의해 알림 발송 기능이 바뀌면 OrderNotifier만 수정하면 됩니다.
이렇게 패턴이 잘 적용된 구조는 참 아름답지 않나요?
SRP 적용 시 고려할 점
SRP를 적용하면서 흔히 발생하는 문제 중 하나가 순환 참조입니다.
- 예를 들어, OrderService가 PaymentProcessor를 참조하고, PaymentProcessor가 다시 OrderService를 참조하는 경우입니다. 이를 방지하기 위해 이벤트를 활용하는 방법이 있습니다.
스프링 이벤트 활용하기
- 특히 주문 처리 후 알림 발송 같은 경우, 동기적 의존성을 제거하고 이벤트 기반 통신으로 전환할 수 있습니다. 아래와 같이 이벤트를 발행할 publisher와 발행된 이벤트를 처리할 listener를 선언하는 방식입니다.
@RequiredArgsConstructor
@Component
public class OrderEventPublisher {
private final ApplicationEventPublisher eventPublisher;
public void publishOrderCompletedEvent(Order order) {
eventPublisher.publishEvent(new OrderCompletedEvent(order));
}
}
@RequiredArgsConstructor
@Component
public class OrderNotificationListener {
private final OrderNotifier orderNotifier;
@EventListener
public void handleOrderCompletedEvent(OrderCompletedEvent event) {
Order order = event.getOrder();
orderNotifier.sendOrderConfirmation(order.getCustomerEmail(), order);
}
}
그리고 서비스 로직에서 이벤트를 발행합니다.
@RequiredArgsConstructor
@Service
public class OrderService {
private final OrderValidator orderValidator;
private final OrderRepository orderRepository;
private final PaymentProcessor paymentProcessor;
private final InventoryManager inventoryManager;
private final OrderEventPublisher eventPublisher;
@Transactional
public OrderResult processOrder(OrderRequest request) {
// 이전 로직 동일...
// 주문 완료 이벤트 발행 (알림은 이벤트 리스너에서 처리)
eventPublisher.publishOrderCompletedEvent(savedOrder);
return new OrderResult(savedOrder.getId(), "주문이 성공적으로 처리되었습니다.");
}
}
SRP 적용 시 주의할 점
1. 과도한 분리는 오히려 독이다
- 예전에 저는 모든 메서드를 클래스로 분리해 봤는데 이렇게 했더니 오히려 코드베이스가 더 복잡해졌습니다. 중요한 것은 '액터(사용자)' 기반으로 분리하는 것입니다. 저는 이것을 놓치고 있던 것 것입니다. 그러니 항상 "이 코드가 변경되는 이유는 무엇인가?"를 고민하며 작성해야 합니다.
2. 트랜잭션 관리에 주의하라
- 각 컴포넌트에 @Transactional을 붙이면 트랜잭션이 분리되어 예상치 못한 전파(propagation) 문제가 발생할 수 있습니다. 예를 들어, 오케스트레이션 서비스가 readOnly=true로 트랜잭션을 시작하고, 내부에서 호출되는 메서드들이 @Transactional로 write 트랜잭션을 갖고 있다면, 실제로는 모든 트랜잭션이 read-only로 묶이게 되어 데이터가 저장되지 않는 현상이 발생할 수 있습니다. 따라서, 트랜잭션은 오케스트레이션 레이어에만 적용하고, 컴포넌트들은 트랜잭션 없이 작동하도록 설계하는 것이 바람직합니다. 이는 트랜잭션의 흐름을 명확히 하고, 예기치 못한 전파 이슈를 방지할 수 있는 좋은 방법입니다.
3. 테스트 전략을 수립하자
- SRP를 적용해서 코드의 책임이 분리되면 단위 테스트를 작성하기가 훨씬 쉬워집니다. 각 컴포넌트는 자신의 책임만 테스트하고, 통합 테스트는 오케스트레이션 계층에서만 수행하면 되기 때문이죠.
내 코드는 SRP를 지키고 있을까?
이것은 코드가 SRP를 잘 지키고 있는지 확인하는 간단한 질문들입니다.
- 이 클래스를 변경해야 할 이유가 몇 가지인가?
-> 이유가 하나라면 건강한 상태, 둘 이상이라면 분리 고려 - 이 클래스를 수정해 달라고 요청할 수 있는 사람(팀)은 누구인가?
-> 한 팀이라면 좋은 신호, 여러 팀이라면 위험 신호 - 클래스의 메서드들이 같은 데이터/필드를 사용하는가?
-> 공유 데이터가 적다면 분리 신호, 많다면 응집도가 높은 좋은 신호 - 클래스 이름이 너무 일반적이진 않은가?
-> 추상적인 이름(Manager, Processor, Helper 등)은 잠재적 SRP 위반의 레드 플래그
SRP 위반 사례 분석
- 자 그럼 실전 예시로 아래의 코드를 진단해 봅시다.
@RequiredArgsConstructor
@Service
public class NotificationService {
private final EmailSender emailSender;
private final SMSProvider smsProvider;
private final PushNotificationClient pushClient;
private final TemplateEngine templateEngine;
private final UserRepository userRepository;
public void sendWelcomeNotification(Long userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException(userId));
String emailContent = templateEngine.process("welcome-email",
Map.of("userName", user.getName()));
emailSender.send(user.getEmail(), "환영합니다!", emailContent);
}
public void sendOrderConfirmation(Order order) {
User user = userRepository.findById(order.getUserId())
.orElseThrow(() -> new UserNotFoundException(order.getUserId()));
String emailContent = templateEngine.process("order-confirmation",
createOrderTemplateModel(order));
emailSender.send(user.getEmail(), "주문 확인 #" + order.getId(), emailContent);
// SMS 발송
smsProvider.sendSMS(user.getPhone(),
"주문 #" + order.getId() + "이 확인되었습니다. 감사합니다.");
}
public void sendDeliveryUpdate(Long orderId, DeliveryStatus status) {
Order order = // 주문 조회 로직
User user = // 사용자 조회 로직
// 푸시 알림 발송
pushClient.sendPush(user.getDeviceToken(),
"배송 업데이트",
"주문 #" + orderId + "이 현재 " + status.getDescription() + " 상태입니다.");
}
private Map<String, Object> createOrderTemplateModel(Order order) {
// 템플릿 데이터 생성 로직
}
}
이 클래스의 진단 결과는 다음과 같습니다.
- 변경 이유: 최소 3가지 (이메일, SMS, 푸시 알림 로직)
- 요청 팀: 3팀 (마케팅팀: 템플릿, 운영팀: 배송 알림, 제품팀: 알림 기능)
- 데이터 공유: UserRepository는 공유되지만, 각 알림 채널은 독립적
- 이름 분석: "NotificationService"는 너무 일반적 (여러 책임을 암시)
결론: 이 클래스는 명확하게 SRP를 위반하고 있습니다.
이 서비스를 어떻게 분리할까요? 먼저 실제 조직에서 누가 어떤 변경을 요청할지 분석해 봅시다. (액터 기반)
- 마케팅팀: 알림 내용과 템플릿 관련 변경
- 기술팀: 알림 전송 메커니즘 관련 변경
- 제품팀: 알림 정책(언제, 어떤 알림을 보낼지) 관련 변경
이에 따라 책임을 분리해 봅시다.
- 마케팅팀 관심사: 알림 내용과 템플릿
@RequiredArgsConstructor
@Component
public class NotificationContentService {
private final TemplateEngine templateEngine;
public String createWelcomeEmailContent(User user) {
return templateEngine.process("welcome-email",
Map.of("userName", user.getName()));
}
public String createOrderConfirmationEmailContent(Order order) {
return templateEngine.process("order-confirmation",
createOrderTemplateModel(order));
}
public String createOrderConfirmationSMSContent(Order order) {
return String.format("주문 #%s이 확인되었습니다. 감사합니다.", order.getId());
}
public String createDeliveryUpdatePushContent(Order order, DeliveryStatus status) {
return String.format("주문 #%s이 현재 %s 상태입니다.",
order.getId(), status.getDescription());
}
private Map<String, Object> createOrderTemplateModel(Order order) {
// 템플릿 데이터 생성 로직
return Map.of(
"orderId", order.getId(),
"orderDate", order.getCreatedAt(),
"total", order.getTotal()
);
}
}
기술팀 관심사: 알림 전송 메커니즘
@RequiredArgsConstructor
@Component
public class NotificationDeliveryService {
private final EmailSender emailSender;
private final SMSProvider smsProvider;
private final PushNotificationClient pushClient;
public void sendEmail(String to, String subject, String content) {
emailSender.send(to, subject, content);
}
public void sendSMS(String phoneNumber, String message) {
smsProvider.sendSMS(phoneNumber, message);
}
public void sendPush(String deviceToken, String title, String body) {
pushClient.sendPush(deviceToken, title, body);
}
}
데이터 접근 관심사: 사용자 조회
@RequiredArgsConstructor
@Component
public class UserLookupService {
private final UserRepository userRepository;
public User findUserById(Long userId) {
return userRepository.findById(userId)
.orElseThrow(() -> new UserNotFoundException(userId));
}
public User findUserByOrderId(Long orderId) {
// 주문으로부터 사용자 조회 로직
Order order = // 주문 조회
return findUserById(order.getUserId());
}
}
제품팀 관심사: 알림 정책 (오케스트레이션 서비스)
- 참고로 오케스트레이션 서비스 클래스에서만 Service 클래스들을 주입받도록 설계해야 합니다. 그렇지 않으면 서비스끼리 참조해서 순환참조가 발생할 수 있기 때문입니다. 설계 시 이 부분을 항상 주의해 주세요!
@RequiredArgsConstructor
@Service
public class NotificationPolicyService {
private final NotificationContentService contentService;
private final NotificationDeliveryService deliveryService;
private final UserLookupService userLookupService;
public void handleWelcomeNotification(Long userId) {
User user = userLookupService.findUserById(userId);
String content = contentService.createWelcomeEmailContent(user);
deliveryService.sendEmail(user.getEmail(), "환영합니다!", content);
}
public void handleOrderConfirmation(Order order) {
User user = userLookupService.findUserById(order.getUserId());
// 이메일 발송
String emailContent = contentService.createOrderConfirmationEmailContent(order);
deliveryService.sendEmail(user.getEmail(), "주문 확인 #" + order.getId(), emailContent);
// SMS 발송
String smsContent = contentService.createOrderConfirmationSMSContent(order);
deliveryService.sendSMS(user.getPhone(), smsContent);
}
public void handleDeliveryUpdate(Long orderId, DeliveryStatus status) {
// 이 예시에서는 간소화를 위해 Order 객체를 직접 전달받는다고 가정
Order order = // 주문 조회 로직
User user = userLookupService.findUserById(order.getUserId());
// 푸시 알림 발송
String pushContent = contentService.createDeliveryUpdatePushContent(order, status);
deliveryService.sendPush(user.getDeviceToken(), "배송 업데이트", pushContent);
}
}
설계 원칙과 주의사항
- 액터 기반 분리: 각 클래스는 단일 액터(팀)만을 위한 변경 이유를 가집니다.
- 계층 구조: 오케스트레이션 서비스(NotificationPolicyService)만 다른 서비스들을 주입받음
- 순환 참조 방지: 서비스들 간에 상호 참조가 없도록 의존성 방향이 단방향으로 설계됨
- 테스트 용이성: 각 컴포넌트는 독립적으로 테스트 가능
이렇게 새롭게 설계한 클래스(서비스)는 명확한 단일 책임을 갖게 되었으며, 오케스트레이션 역할의 NotificationPolicyService를 통해 전체 알림 기능이 조율되도록 리팩토링되었습니다. 덕분에 각 액터(그룹)들이 본인들이 관리할 서비스만 수정하면 되는 구조가 되었습니다.
마무리하며
지금까지 SRP에 대해 정리해 봤습니다. 이번 포스팅을 통해 전달드리고 싶었던 것은 SRP를 단순히 "클래스는 한 가지 일만 해야 한다"로 이해하면 안 된다는 것입니다. 진정한 의미는 "클래스는 변경해야 할 이유가 하나만 있어야 한다."이며 여기서 말하는 하나의 이유는 액터(사용자 그룹)에 의한 것을 의미합니다. 즉, "한 클래스는 오직 하나의 액터(사용자 그룹)만을 위한 책임을 가져야 한다."는 것입니다.
바로 위의 예제에서 보여드린 것처럼, 각 액터 전용 클래스로 책임을 분리함으로써 각 클래스는 자신의 그룹에서 발생하는 변경에 대한 영향만을 받게 됩니다. 이런 접근법은 코드베이스가 커질수록 더 큰 가치를 발휘한다고 생각합니다. 저도 처음 리팩토링을 시작했을 때는 어떻게 SRP를 지키며 분리할지 많은 고민을 하고 잘못된 분리도 했는데 이제는 코드를 작성하면서 지속적으로 SRP를 지키고 있는지 고민하며 설계하고 있습니다.
오늘도 긴 글 읽어주셔서 감사합니다.
'Spring > Spring 기초 지식' 카테고리의 다른 글
[Spring] 왜 @Transactional 내부에서 호출한 @Transactional은 안 먹힐까? (8) | 2024.11.10 |
---|---|
[Spring] ApplicationRunner 활용하기 (1) | 2024.09.28 |
[Spring] 스프링 순환참조 (1) | 2024.07.28 |
[Spring] 코틀린 스프링에서 Validation 적용 방법과 주의점 (3) | 2024.06.06 |
[Spring] Spring Event 스레드의 동작원리 (동기/비동기) (5) | 2024.03.10 |