🎯 SOLID 원칙 - 단일 책임 원칙(SRP) 트러블슈팅 가이드
Spring Boot 개발에서 자주 발생하는 SRP(Single Responsibility Principle) 위반 사례와 해결 방법을 정리했습니다.
🔍 문제 1: 하나의 서비스가 너무 많은 일을 담당
📋 에러 상황
하나의 서비스 클래스에서 여러 가지 책임을 동시에 처리하는 코드를 발견했습니다.
@Service
public class BadOrderService {
private final OrderRepository orderRepository;
private final JavaMailSender mailSender;
public Order createOrder(OrderRequest request) {
// 주문 처리 + 이메일 발송 + 로깅 + 검증... 😵
Order order = new Order(request.getProductId(), request.getQuantity());
Order savedOrder = orderRepository.save(order);
SimpleMailMessage message = new SimpleMailMessage();
message.setTo(request.getUserEmail());
mailSender.send(message);
return savedOrder;
}
}
🎯 원인 분석
하나의 클래스가 여러 책임을 가지면서 SRP(Single Responsibility Principle)를 위반했습니다.
- 변경의 이유가 여러 개: 주문 로직 변경, 이메일 템플릿 변경 등
- 높은 결합도: 이메일 시스템 장애가 주문 시스템에 영향
- 테스트 복잡성: 여러 의존성을 모두 모킹해야 함
🔧 해결 방법
1단계: 책임별로 클래스 분리
// ✅ 주문 처리 책임만 담당
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final NotificationService notificationService; // 🎯 알림은 다른 서비스에 위임
public Order createOrder(OrderRequest request) {
// 1️⃣ 주문 생성과 저장에만 집중
Order order = new Order(request.getProductId(), request.getQuantity());
Order savedOrder = orderRepository.save(order);
// 2️⃣ 알림 발송은 전문 서비스에 위임
notificationService.sendOrderCompletionNotification(savedOrder, request.getUserEmail());
return savedOrder;
}
}
// ✅ 알림 발송 책임만 담당
@Service
public class NotificationService {
private final JavaMailSender mailSender;
public void sendOrderCompletionNotification(Order order, String userEmail) {
// 🎯 알림 발송에만 집중
try {
SimpleMailMessage message = new SimpleMailMessage();
message.setTo(userEmail);
message.setSubject("주문이 완료되었습니다.");
message.setText("주문 번호: " + order.getId());
mailSender.send(message);
} catch (MailException e) {
// 🚨 실패 처리 로직
System.err.println("이메일 발송 실패: " + e.getMessage());
}
}
}
2단계: 각 클래스의 단일 책임 확인
변경의 이유 분석
| 클래스 | 변경 이유 | 책임 |
|——–|———–|——|
| OrderService
| 주문 정책 변경 | 주문 생성, 저장, 관리 |
| NotificationService
| 알림 방식 변경 | 이메일, SMS 등 알림 발송 |
📚 SRP 핵심 개념
원칙 | 설명 | 혜택 |
---|---|---|
Single Responsibility | 하나의 클래스는 하나의 책임만 | 높은 응집도, 낮은 결합도 |
One Reason to Change | 변경의 이유가 오직 하나 | 유지보수성 향상 |
Cohesion | 관련 기능들의 응집 | 코드 가독성 증대 |
🔍 문제 2: 데이터와 비즈니스 로직의 혼재
📋 에러 상황
Entity나 DTO에 비즈니스 로직이 섞여있어 책임이 모호한 경우입니다.
// 😵 Entity에 비즈니스 로직이 혼재
@Entity
public class BadUser {
private String email;
private String password;
// ❌ 데이터 저장 + 비즈니스 로직이 함께
public boolean validatePassword(String inputPassword) {
return BCrypt.checkpw(inputPassword, this.password);
}
public void sendWelcomeEmail() {
// ❌ Entity가 이메일 발송까지 담당
JavaMailSender mailSender = ApplicationContextProvider.getBean(JavaMailSender.class);
// ... 이메일 발송 로직
}
}
🎯 원인 분석
데이터 표현과 비즈니스 로직이 한 곳에 섞여있어 책임 분리가 안되었습니다.
- Entity의 역할 오버로드: 데이터 + 검증 + 외부 서비스 호출
- 테스트 어려움: Entity 테스트에 외부 의존성 필요
- 재사용성 저하: 다른 컨텍스트에서 로직 재사용 불가
🔧 해결 방법
1단계: Entity는 데이터만 담당
// ✅ 순수한 데이터 표현에만 집중
@Entity
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String email;
private String password;
// 🎯 단순한 데이터 접근자만 제공
public String getEmail() { return email; }
public String getPassword() { return password; }
// 생성자, equals, hashCode 등...
}
2단계: 비즈니스 로직은 전용 서비스로 분리
// ✅ 사용자 인증 책임만 담당
@Service
public class AuthenticationService {
public boolean validatePassword(User user, String inputPassword) {
// 🔐 패스워드 검증 로직에만 집중
return BCrypt.checkpw(inputPassword, user.getPassword());
}
public boolean isValidEmail(String email) {
// ✉️ 이메일 형식 검증
return email.matches("^[A-Za-z0-9+_.-]+@(.+)$");
}
}
// ✅ 사용자 관련 알림 책임만 담당
@Service
public class UserNotificationService {
private final JavaMailSender mailSender;
public void sendWelcomeEmail(User user) {
// 🎉 환영 이메일 발송에만 집중
SimpleMailMessage message = new SimpleMailMessage();
message.setTo(user.getEmail());
message.setSubject("회원가입을 환영합니다!");
message.setText("안녕하세요, " + user.getEmail() + "님!");
mailSender.send(message);
}
}
🎨 계층별 책임 분리 패턴
// 🏗️ 사용자 생성 전체 과정을 조율하는 서비스
@Service
@RequiredArgsConstructor
public class UserRegistrationService {
private final UserRepository userRepository;
private final AuthenticationService authenticationService;
private final UserNotificationService notificationService;
@Transactional
public User registerUser(UserRegistrationRequest request) {
// 1️⃣ 입력 검증은 AuthenticationService에 위임
if (!authenticationService.isValidEmail(request.getEmail())) {
throw new InvalidEmailException("유효하지 않은 이메일입니다.");
}
// 2️⃣ 사용자 생성 및 저장
User user = new User(request.getEmail(), encryptPassword(request.getPassword()));
User savedUser = userRepository.save(user);
// 3️⃣ 환영 이메일 발송은 NotificationService에 위임
notificationService.sendWelcomeEmail(savedUser);
return savedUser;
}
private String encryptPassword(String rawPassword) {
return BCrypt.hashpw(rawPassword, BCrypt.gensalt());
}
}
🔍 문제 3: 컨트롤러에 비즈니스 로직 포함
📋 에러 상황
Controller에서 HTTP 요청 처리 외에 비즈니스 로직까지 담당하는 경우입니다.
// 😵 Controller가 너무 많은 책임을 가짐
@RestController
@RequestMapping("/api/orders")
public class BadOrderController {
private final OrderRepository orderRepository;
private final JavaMailSender mailSender;
@PostMapping
public ResponseEntity<Order> createOrder(@RequestBody OrderRequest request) {
// ❌ HTTP 처리 + 비즈니스 로직 + 외부 서비스 호출
// 1. 입력 검증
if (request.getQuantity() <= 0) {
return ResponseEntity.badRequest().build();
}
// 2. 비즈니스 로직
Order order = new Order(request.getProductId(), request.getQuantity());
Order savedOrder = orderRepository.save(order);
// 3. 이메일 발송
SimpleMailMessage message = new SimpleMailMessage();
message.setTo(request.getUserEmail());
mailSender.send(message);
return ResponseEntity.ok(savedOrder);
}
}
🎯 원인 분석
Controller가 웹 계층 책임을 넘어서 비즈니스 로직까지 처리하고 있습니다.
- 계층간 책임 혼재: 프레젠테이션 + 비즈니스 + 데이터 액세스
- 테스트 복잡성: 웹 테스트에 비즈니스 로직 검증까지 포함
- 재사용 불가: 웹이 아닌 다른 방식으로 호출 불가
🔧 해결 방법
Controller는 HTTP 처리에만 집중
// ✅ HTTP 요청/응답 처리에만 집중
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {
private final OrderService orderService; // 🎯 비즈니스 로직은 서비스에 위임
@PostMapping
public ResponseEntity<OrderResponse> createOrder(@RequestBody @Valid OrderRequest request) {
// 1️⃣ HTTP 요청을 받아서 서비스에 전달
try {
Order order = orderService.createOrder(request);
// 2️⃣ 비즈니스 결과를 HTTP 응답으로 변환
OrderResponse response = OrderResponse.from(order);
return ResponseEntity.ok(response);
} catch (InvalidOrderException e) {
// 3️⃣ 예외를 적절한 HTTP 상태로 변환
return ResponseEntity.badRequest().build();
}
}
}
📊 계층별 책임 분리 가이드라인
계층 | 주요 책임 | 포함하면 안되는 것 |
---|---|---|
Controller | HTTP 요청/응답 처리 | 비즈니스 로직, DB 접근 |
Service | 비즈니스 로직 수행 | HTTP 관련 코드, SQL |
Repository | 데이터 액세스 | 비즈니스 검증, 외부 API |
Entity | 데이터 표현 | 외부 서비스 호출 |
📊 SRP 체크리스트
✅ 클래스 단일 책임 확인
- 이 클래스를 수정해야 할 이유가 2개 이상인가?
- 클래스명에 ‘And’, ‘Or’, ‘Manager’ 등이 들어가는가?
- 하나의 메서드가 50줄을 넘어가는가?
✅ 의존성 체크
- 한 클래스가 5개 이상의 의존성을 가지는가?
- 서로 다른 계층의 책임이 한 곳에 있는가?
- 테스트 시 많은 Mock 객체가 필요한가?
✅ 응집도 확인
- 클래스 내 메서드들이 같은 데이터를 사용하는가?
- 메서드들이 하나의 목적으로 그룹화되는가?
- 클래스명만 보고 역할을 예측할 수 있는가?
🎯 실전 SRP 적용 전략
1단계: 기존 코드 분석
// 🔍 SRP 위반 징후 찾기
@Service
public class UserService {
// ⚠️ 너무 많은 의존성 = 책임 과다 의심
private final UserRepository userRepository;
private final JavaMailSender mailSender;
private final PasswordEncoder passwordEncoder;
private final FileStorageService fileService;
private final PaymentService paymentService; // 🚨 결제? 사용자 서비스에서?
// 🔍 메서드명으로 책임 분석
public void createUser() { } // ✅ 사용자 관리
public void sendEmail() { } // ⚠️ 알림 책임?
public void processPayment() { } // ⚠️ 결제 책임?
public void generateReport() { } // ⚠️ 보고서 생성 책임?
}
2단계: 책임별 서비스 분리
// ✅ 사용자 관리 책임만 담당
@Service
public class UserService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
public User createUser(CreateUserRequest request) {
// 사용자 생성 로직만 집중
}
public User updateUser(Long userId, UpdateUserRequest request) {
// 사용자 수정 로직만 집중
}
}
// ✅ 사용자 알림 책임만 담당
@Service
public class UserNotificationService {
private final JavaMailSender mailSender;
public void sendWelcomeEmail(User user) { }
public void sendPasswordResetEmail(User user, String token) { }
}
// ✅ 사용자 결제 책임만 담당
@Service
public class UserPaymentService {
private final PaymentService paymentService;
public void processUserPayment(User user, PaymentRequest request) { }
}
3단계: 조합 서비스로 전체 플로우 관리
// 🎭 여러 서비스를 조합하여 복잡한 비즈니스 플로우 처리
@Service
@RequiredArgsConstructor
public class UserRegistrationFacade {
private final UserService userService;
private final UserNotificationService notificationService;
private final UserPaymentService paymentService;
@Transactional
public User registerUser(UserRegistrationRequest request) {
// 1️⃣ 사용자 생성
User user = userService.createUser(request);
// 2️⃣ 환영 이메일 발송
notificationService.sendWelcomeEmail(user);
// 3️⃣ 초기 결제 처리 (프리미엄 회원인 경우)
if (request.isPremium()) {
paymentService.processUserPayment(user, request.getPaymentInfo());
}
return user;
}
}
🎉 마무리
이제 SRP를 적용하여 유지보수하기 쉽고 테스트하기 용이한 코드를 작성할 수 있습니다!
🚀 다음 단계 권장사항
- 개방-폐쇄 원칙(OCP): 확장에는 열려있고 수정에는 닫힌 구조 설계
- 의존성 역전 원칙(DIP): 인터페이스 기반의 느슨한 결합 구조
- 리팩토링 도구 활용: IntelliJ IDEA의 Extract Service 기능 활용
📞 추가 학습 리소스
- Clean Code by Robert Martin: 클린 코드의 바이블
- Effective Java 3판: 자바 개발자 필수 서적
- Spring Boot Reference Documentation: 스프링 부트 공식 가이드
💡 핵심 기억할 점
“하나의 클래스는 하나의 변경 이유만 가져야 한다” - 이 원칙만 지켜도 80%의 코드 품질 문제가 해결됩니다!