[리팩토링] 복잡한 승인 로직, SRP와 DRY 원칙으로 통합하기
공통 모듈 캡슐화로 승인 로직 리팩토링
들어가며
최근 신규 기능을 개발하던 중, 기존 모듈들에 흩어져 있는 승인/반려 로직을 전반적으로 훑어볼 기회가 있었습니다.
처음에는 별다른 위화감이 없었지만, 코드를 하나씩 따라가다 보니 소름 돋을 정도로 익숙한 패턴들이 눈에 들어오기 시작했습니다. 여러 화면에서 동일하게 동작하는 승인 기능이 각 Service 클래스마다 거의 그대로 ‘복사 붙여넣기’ 되어 있었던 것이죠.
문득 이런 생각이 들었습니다. “만약 승인 프로세스가 조금이라도 변경되면 어떻게 될까?” 10개의 화면이 있다면 10개의 파일을 일일이 찾아가 고쳐야 할 테고, 이는 곧 버그와 야근의 주범이 될 게 뻔했습니다. 그래서 더 늦기 전에 반복되는 로직을 공통 모듈로 추출하고 캡슐화하기로 마음먹었습니다.
제약 사항
물론 리팩토링 과정이 순탄치만은 않았습니다. 현재 프로젝트는 다음과 같은 몇 가지 기술적/문화적 제약 사항을 안고 있었기 때문입니다.
- 거대한 단일 VO 사용: 레이어 간 데이터 전달 시 DTO를 분리하지 않고, 수백 개의 필드가 담긴 하나의 VO를 모든 화면과 서비스에서 공유하는 구조였습니다.
- 문자열 기반의 상태 관리: Enum 대신 String 구분값(예: saveGbn)으로 로직을 분기하다 보니 타입 안정성이 떨어져 있었습니다.
- 클래스 생성 지양 분위기: “클래스가 많아지면 관리가 힘들다”는 팀 내 정서 때문에 새로운 클래스를 하나 만드는 것조차 꽤나 조심스러운 환경이었습니다.
이러한 제약들은 코드 간의 결합도를 높였고, 결국 승인/반려/저장 같은 공통 결재 프로세스가 모든 서비스에 중복 코드(Boilerplate code)로 퍼지는 결과를 초래했습니다. 과연 이 야생 같은 코드들을 어떻게 점진적으로 개선했는지 그 과정을 기록해 봅니다.
코드 살펴보기
기존 코드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
@Service
public class SampleReportService {
private final ApprovalService approvalService;
private final SampleReportDao reportDao;
public SampleReportService(ApprovalService approvalService,
SampleReportDao reportDao) {
this.approvalService = approvalService;
this.reportDao = reportDao;
}
public ReportVO saveReport(ReportVO vo) {
try {
// 매번 공통 모듈 생성
ApprovalProcessor processor =
new ApprovalProcessor(approvalService);
// 승인 / 저장 분기
if ("APPROVE".equals(vo.getAction())) {
vo.setUpdateStatus(true);
} else {
vo.setUpdateStatus(false);
}
// 신규 / 수정 분기
if (vo.getId() == null) {
reportDao.insert(vo);
} else {
reportDao.update(vo);
}
// 승인 공통 처리
processor.process(vo);
} catch (Exception e) {
e.printStackTrace();
}
return vo;
}
}
문제점 분석
코드를 보면 몇 가지 문제점이 바로 보였습니다.
1. 매번 객체를 새로 생성
1
2
3
4
5
ApprovalProcessor processor = new ApprovalProcessor(
this.approvalService,
this.approvalDao,
this.commonDao
);
호출할 때마다 불필요한 객체가 만들어지고, 그만큼 GC 부담도 쌓입니다. 당장은 티 안 나지만, 이런 게 계속 쌓이면 결국 성능 이슈 발생 가능성이 있습니다.
2. 승인 로직과 도메인 로직의 혼재
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 승인 관련 설정
vo.setRejected(false);
if ("save".equals(vo.getAction())) {
vo.setUpdateProgress(false);
} else {
vo.setUpdateProgress(true);
}
// 도메인 로직
vo.setProgressStatus(vo.getStatus());
if (isEmpty(vo.getId())) {
// 비즈니스 로직 저장
processBusinessData(...);
}
// 다시 승인 로직
processor.processCommon(vo);
dao.updateApprovalKey(vo);
승인 로직이랑 도메인 로직이 섞여 있어서, 가독성이 좋지 않습니다
3. 확장성 문제
프로젝트에는 이런 Service가 각 모듈마다 평균 10개정도 존재.. 만약 승인 프로세스에 새로운 필드 추가가 필요하면?
- 여기저기 흩어진 코드 찾아다니기
- 하나라도 빠뜨리면 바로 장애
- 수정 후 테스트 범위 증가
4. 예외 처리 누락
1
2
3
catch (Exception e) {
e.printStackTrace(); // 단순 로그만 출력하고 정상 return
}
예외가 발생해도 그냥 로그만 찍고 정상 응답을 반환 클라이언트는 저장이 성공했다고 판단하지만 실제로는 실패한 상태가 될 수 있다.
5. 단일 책임 원칙(SRP) 위배
saveReport 메서드 하나에 너무 많은 책임..
- 승인 로직 설정
- 보고서 저장/수정
- 관련 데이터 처리
- 품목 처리
- 모듈 설정
- 승인 공통 비즈니스 실행
해결 전략: 공통 모듈 설계
그래서 결국, 승인 로직을 공통 모듈로 묶기로 했습니다.
설계 정리
- 승인/반려/저장 로직은 전부 공통 모듈로 이동
- action 값만 넘겨서 분기하도록 단순화
- ApprovalProcessor는 생성자에서 한 번만 만들고 재사용
- 인터페이스로 감싸서 확장 가능하게
- Service에서는 도메인 로직만 남기기 (승인은 전부 위임)
설계 구조
1
2
3
4
5
6
7
8
9
10
11
12
13
[기존]
SampleReportService
├─ ApprovalProcessor 생성 (매번)
├─ 승인 로직 설정 (직접)
├─ 도메인 로직 (보고서)
└─ processor.process() 호출
[개선]
SampleEvaluationService
├─ ApprovalApplyCommon (주입받음)
│ └─ ApprovalProcessor (1번만 생성)
├─ 도메인 로직만 집중 (평가)
└─ approvalApplyCommon.apply() 호출
After: 리팩토링
1. 먼저 공통 인터페이스부터 만들었다
1
2
3
public interface ApprovalApplyCommon {
void apply(DomainVO vo);
}
2. 승인 처리를 담당하는 공통 모듈 구현
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Service
public class ApprovalApplyCommonImpl implements ApprovalApplyCommon {
private final ApprovalProcessor processor;
public ApprovalApplyCommonImpl(ApprovalService approvalService) {
this.processor = new ApprovalProcessor(approvalService);
}
@Override
public void apply(DomainVO vo) {
try {
switch (vo.getAction()) {
case APPROVE -> {
vo.markApproveRequested();
}
case REJECT -> {
vo.markRejected();
}
default -> {
vo.markSaved();
}
}
processor.process(vo);
} catch (Exception e) {
throw new IllegalStateException("Approval processing failed", e);
}
}
}
핵심 개선 포인트
- 매번 new 하던 ApprovalProcessor를 필드로 올려서 재사용 (이제 객체 생성 한 줄로 끝)
- 승인/반려/저장을 한 메서드에서 처리
- 예외는 삼키지 않고 위로 던져서 트랜잭션 깨지게 처리
ApprovalProcessor의 한계
ApprovalProcessor 역시 엄밀히 말하면 SRP를 완전히 지키진 못한다.. 승인/반려/저장 등 여러 책임을 가지고 있기 때문
하지만..
- 12개 Service에 흩어진 중복을 우선 한 곳으로 모으는 게 목표
- 완벽한 분리보다 점진적 개선을 먼저 선택
- 레거시 환경에서 클래스를 한 번에 많이 늘리는 건 팀 합의가 어려움
3. 개선된 Service 구현
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Service
@RequiredArgsConstructor
public class SampleEvaluationService {
private final SampleEvaluationDao dao;
private final ApprovalApplyCommon approvalApplyCommon;
public DomainVO save(DomainVO vo) {
// 1. 승인 공통 처리 위임
approvalApplyCommon.apply(vo);
// 2. 도메인 로직만 담당
dao.updateEvaluation(vo);
syncCheckList(vo);
saveItems(vo);
return vo;
}
}
4. 문자열 제거 (Enum 활용)
1
2
3
4
5
public enum ActionType {
SAVE,
APPROVE,
REJECT
}
5. DomainVO (상태 변경 책임 위임)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class DomainVO {
private ActionType action;
private boolean approveRequested;
private boolean rejected;
public void markApproveRequested() {
this.approveRequested = true;
this.rejected = false;
}
public void markRejected() {
this.rejected = true;
}
public void markSaved() {
this.approveRequested = false;
this.rejected = false;
}
}
- VO가 스스로의 상태를 결정하도록 수정
개선된 구조의 특징
- 메서드 하나가 하나의 책임만 담당 (SRP)
- 승인 로직은
approvalApplyCommon에 완전히 위임 - 도메인 로직만 명확하게 분리 (체크리스트, 그리드 처리 등)
- 메서드명이 명확하여 가독성 향상
- 테스트할 때 공통 모듈은 Mock으로 날릴 수 있어서 훨씬 편해짐
비교: Before vs After
코드 구조 비교
| 항목 | Before | After |
|---|---|---|
| 저장 메서드 | 모든 로직 혼재 | 위임 중심 |
| 객체 생성 | 메서드 호출마다 | 애플리케이션 시작 시 1회 |
| 승인 로직 | 메서드 내부에서 직접 처리 | 공통 모듈에 위임 |
| 관심사 분리 | 승인/도메인 로직 혼재 | 도메인 로직만 집중 |
| 의존성 주입 | @Autowired + @Resource 혼용 | @RequiredArgsConstructor (일관성) |
개선 효과
1. 성능 개선
- 객체 생성 오버헤드 감소: 메서드 호출마다 생성 → 싱글톤처럼 재사용
- 메모리 효율성: 불필요한 객체 생성 제거로 GC 부담 감소
- 실측 효과: 실제로 동일 화면에서 10번 저장해보면, ApprovalProcessor 생성 횟수가 10회 → 0회로 줄어듭니다.
2. 유지보수성 향상
- 단일 수정 지점: 승인 로직 변경 시 1개 파일만 수정
- 버그 감소: 중복 코드 제거로 누락/실수 가능성 제거
- 일관성 보장: 모든 화면에서 동일한 승인 로직 적용
- 코드 리뷰 효율: 승인 로직은 이미 검증된 공통 모듈 사용
3. 코드 품질 개선
- 단일 책임 원칙(SRP):
- 공통 모듈: 승인 로직만
- 각 Service: 자신의 도메인 로직만
- DRY 원칙: Don’t Repeat Yourself - 중복 완전 제거
- 의존성 역전 원칙(DIP): 인터페이스(ApprovalApplyCommon)에 의존
- 가독성: 메서드가 짧고 명확하여 이해하기 쉬움
4. 테스트 용이성
Before: 테스트하기 어려운 구조
1
2
3
4
5
6
7
@Test
public void 저장_테스트() {
// 문제: ApprovalProcessor를 Mock으로 대체할 수 없음
// saveReport 메서드 내부에서 new로 생성하기 때문
ReportVO result = service.saveReport(vo);
// 승인 로직까지 모두 실행됨
}
After: 테스트하기 쉬운 구조
1
2
3
4
5
6
7
8
9
10
11
12
13
@Test
public void 저장_테스트() {
// 공통 모듈을 Mock으로 주입 가능
ApprovalApplyCommon mockCommon = mock(ApprovalApplyCommon.class);
SampleEvaluationService service =
new SampleEvaluationService(dao, mockCommon);
// 도메인 로직만 테스트
DomainVO result = service.save(request);
// 공통 모듈이 호출되었는지 검증
verify(mockCommon).apply(any());
}
결론
신규 기능 개발을 위해 다른 모듈의 코드를 리뷰하면서 “이건 개선이 필요하다”고 느꼈던 부분을 공통 모듈로 추출해서 캡슐화했습니다.
정리해보면
- 중복은 무조건 관리 포인트가 된다
- 작은 리팩토링이 나중에 큰 차이를 만든다
- 관심사만 분리해도 코드가 확 깨끗해진다
마치며
이번 리팩토링으로 12개 Service 에서 중복되던 승인 로직을 단 1개의 공통 모듈로 통합했습니다. 이제 승인 프로세스에 변경이 생기면 ApprovalApplyCommonImpl 한 곳만 수정하면 끝!
잘 돌아가긴 해서 “이게 뭐가 문제지?” 라고 생각할 수 있지만. 유지보수성, 확장성, 테스트 용이성을 고려하면 분명히 개선이 필요한 코드였다.
코드는 회사 보안 정책을 준수하기 위해 실제 도메인 용어와 비즈니스 로직을 일반적인 예시로 수정하였습니다.
