SOLID 리팩토링 (feat. Processor)
작성일: 2025.09.16
개요
개발을 진행하면서 내가 가장 큰 문제라고 느꼈던 부분이 바로 Service 계층의 비대화였다. 처음에는 깔끔했던 Service 코드가 특정 비즈니스 로직, 외부 API 연동, 복잡한 데이터 검증 같은 것들이 덕지덕지 붙으면서 점점 무거워지는 현상을 겪었다.
이게 단순히 코드가 길어지는 문제를 넘어, SOLID 원칙을 정면으로 위반하고 있다는 생각이 들었다. 특히 **단일 책임 원칙(SRP)**이 심각하게 깨지고 있었다.
문제 진단: 서비스 계층의 SOLID
내가 기존에 작성했던 서비스 코드를 보면서 스스로 진단한 가장 큰 문제는 다음과 같았다.
- 책임의 과부하 (SRP 위반):
- 하나의 Service 클래스가 데이터 조회, 생성, 수정이라는 CRUD 흐름부터 시작해서, 패스워드 암호화, 이메일 중복 검사, 포인트 계산, 외부 PG사(결제대행사) 연동까지 모든 것을 다 처리하고 있었다. 이 클래스는 'DB 스키마 변경', '인증 로직 변경', '결제 방식 변경' 등 너무 많은 이유로 수정될 가능성이 높았다.
- 낮은 재사용성 및 OCP 위반:
- 특정 로직이
private메소드로 Service 구현체 안에 숨겨져 있어, 다른 Service에서 동일한 로직이 필요해도 재사용할 수 없었다. 또한, 결제 방식 같은 로직에 새로운 규칙이 추가되면 기존 Service 코드를 통째로 수정해야 했기에 **OCP(개방-폐쇄 원칙)**도 지켜지지 않았다.
- 흐름과 로직의 혼재:
@Transactional이 걸린 Service 메소드 안에서 '어떻게 처리할지'에 대한 상세한 로직과 '전체적인 비즈니스 흐름'이 섞여 있어 가독성이 매우 떨어졌다.
결론적으로, 이대로는 안 된다. Service의 SRP를 회복하고 OCP를 지킬 수 있는 구조가 필요하다고 판단했다.
해결책: Processor 패턴 도입으로 책임 분리
그래서 저는 복잡한 비즈니스 로직을 Service에서 분리해 Processor라는 독립적인 컴포넌트로 만들기로 했다.
이 구조를 통해 **Service는 '흐름 관리'**라는 단일 책임을 갖게 되었고, **Processor는 '로직 처리'**라는 단일 책임을 갖게 되었다.
| 컴포넌트 | 새로운 책임 | 역할 정의 |
|---|---|---|
| Service (Orchestrator) | 지휘자 (흐름 관리) | 트랜잭션을 설정하고, Processor들을 호출하여 전체 시나리오를 관리한다. |
| Processor (Expert) | 전문가 (로직 처리) | 단 하나의 비즈니스 로직 (예: 비밀번호 인코딩, 외부 API 호출) 처리에만 집중한다. |
리팩토링 과정: 인터페이스 및 구현체 재조립
1. 인터페이스 분리 (ISP 적용)
가장 먼저 UserService 인터페이스부터 쪼개서 **ISP(인터페이스 분리 원칙)**를 적용했다. 기존 인터페이스는 너무 많은 책임을 가지고 있었다.
| 구분 | 기존 UserService 인터페이스 | 리팩토링 후 인터페이스 |
|---|---|---|
| 목적 | 모든 사용자 관련 기능 포함 | 역할별로 기능 분리 |
| 변경 전 (하나의 인터페이스) | createUser(), login(), updateUserInfo(), getUserInfo(), adminDeleteUser() 등 모두 포함 | - |
| 변경 후 (분리) | UserCommandService: 상태 변경 (쓰기) 기능만 담당 (createUser, updateUserInfo, deleteUser) | UserQueryService: 데이터 조회 (읽기) 기능만 담당 (getUserInfo, getAllUsers, isEmailExists) |
2. 구현체 내부: 로직을 해체하고 Processor로 재조립
제 프로젝트의 UserServiceImpl을 예시로, 가장 복잡했던 회원 생성 로직을 걷어내고 Processor를 주입받아 호출하는 방식으로 변경했다.
[기존 문제 코드 스타일 (예시: 회원 생성)]
// UserServiceImpl.java (리팩토링 전)
@Transactional
@Override
public ResCreateUserDto createUser(ReqCreateUserDto dto) {
validateDuplicate(dto); // 로직 1: 유효성 검사 (재사용성 낮았음)
String encodedPw = passwordEncoder.encode(dto.getPassword()); // 로직 2: 인코딩
// 로직 3: 엔티티 생성 및 DB 저장
User saved = userRepository.save(user);
return ResCreateUserDto.fromEntity(saved);
}
[리팩토링 후 코드 스타일]
Processor생성:UserValidationProcessor,UserCreationProcessor등 로직 전문가를 생성했다.Service단순화: Service는 Processor를 호출하는 지휘자가 되었다.
// UserCommandServiceImpl.java (리팩토링 후)
@Service
@RequiredArgsConstructor
public class UserCommandServiceImpl implements UserCommandService {
private final UserCreationProcessor userCreationProcessor; // Processor만 의존
@Transactional // Service는 트랜잭션과 흐름 관리
@Override
public ResCreateUserDto createUser(ReqCreateUserDto dto) {
// 여기엔 딱 한 줄! 모든 복잡한 로직은 Processor에 위임했다.
User savedUser = userCreationProcessor.process(dto);
return ResCreateUserDto.fromEntity(savedUser);
}
}
리팩토링 후 얻은 결정적인 이점
Processor 패턴을 적용한 결과, 코드의 품질이 훨씬 좋아졌다고 느꼈다.
- SRP 회복 및 OCP 준수:
- Service는 이제 트랜잭션과 흐름만 책임지기 때문에, 오직 '흐름'이 바뀔 때만 수정하면 되었다. 로직이 바뀔 때는 해당
Processor만 수정하고 Service는 그대로 둬서 OCP를 지킬 수 있었다. - 재사용성 극대화:
UserValidationProcessor처럼 공통 로직을 담은 Processor는 다른AdminService나BoardService등에서도 주입받아 바로 사용할 수 있게 되어 코드 중복이 사라졌다.- 테스트 용이성:
- 복잡한 로직이
Processor단위로 분리되어, 인코딩 로직만 테스트하고 싶을 때UserCreationProcessor만 독립적으로 테스트하면 되었다. Service 테스트 시 복잡한 의존성들을 Mocking할 필요가 없어 테스트 코드가 압도적으로 단순해졌다.
💬 마무리
복잡한 비즈니스 로직을 다루는 개발자라면, Processor 패턴은 정말 강력한 무기가 되어줄 거라고 확신한다. 솔직히 개발 초반에는 크게 고민 안 하고 비즈니스 로직들을 다 때려박고 인터페이스와 구현체로만 분리하면 될 줄 알았는데 전혀 아니었다는걸 깨달았다... 그래도 이제 프로젝트 서비스단의 복잡성에서 조금은 벗어날 수 있을 것 같다!