9osari.log
← 목록으로

[리팩토링] 복잡한 승인 로직, SRP와 DRY 원칙으로 통합하기

공통 모듈 캡슐화로 승인 로직 리팩토링

들어가며

최근 신규 기능을 개발하던 중, 기존 모듈들에 흩어져 있는 승인/반려 로직을 전반적으로 훑어볼 기회가 있었습니다.

처음에는 별다른 위화감이 없었지만, 코드를 하나씩 따라가다 보니 소름 돋을 정도로 익숙한 패턴들이 눈에 들어오기 시작했습니다. 여러 화면에서 동일하게 동작하는 승인 기능이 각 Service 클래스마다 거의 그대로 ‘복사 붙여넣기’ 되어 있었던 것이죠.

문득 이런 생각이 들었습니다. “만약 승인 프로세스가 조금이라도 변경되면 어떻게 될까?” 10개의 화면이 있다면 10개의 파일을 일일이 찾아가 고쳐야 할 테고, 이는 곧 버그와 야근의 주범이 될 게 뻔했습니다. 그래서 더 늦기 전에 반복되는 로직을 공통 모듈로 추출하고 캡슐화하기로 마음먹었습니다.

제약 사항

물론 리팩토링 과정이 순탄치만은 않았습니다. 현재 프로젝트는 다음과 같은 몇 가지 기술적/문화적 제약 사항을 안고 있었기 때문입니다.

이러한 제약들은 코드 간의 결합도를 높였고, 결국 승인/반려/저장 같은 공통 결재 프로세스가 모든 서비스에 중복 코드(Boilerplate code)로 퍼지는 결과를 초래했습니다. 과연 이 야생 같은 코드들을 어떻게 점진적으로 개선했는지 그 과정을 기록해 봅니다.


코드 살펴보기

기존 코드

@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. 매번 객체를 새로 생성

refactor.png

ApprovalProcessor processor = new ApprovalProcessor(
    this.approvalService,
    this.approvalDao,
    this.commonDao
);

호출할 때마다 불필요한 객체가 만들어지고, 그만큼 GC 부담도 쌓입니다. 당장은 티 안 나지만, 이런 게 계속 쌓이면 결국 성능 이슈 발생 가능성이 있습니다.

2. 승인 로직과 도메인 로직의 혼재

// 승인 관련 설정
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. 예외 처리 누락

catch (Exception e) {
    e.printStackTrace(); // 단순 로그만 출력하고 정상 return
}

예외가 발생해도 그냥 로그만 찍고 정상 응답을 반환 클라이언트는 저장이 성공했다고 판단하지만 실제로는 실패한 상태가 될 수 있다.

5. 단일 책임 원칙(SRP) 위배

saveReport 메서드 하나에 너무 많은 책임..


해결 전략: 공통 모듈 설계

그래서 결국, 승인 로직을 공통 모듈로 묶기로 했습니다.

설계 정리

설계 구조

[기존]
SampleReportService
  ├─ ApprovalProcessor 생성 (매번)
  ├─ 승인 로직 설정 (직접)
  ├─ 도메인 로직 (보고서)
  └─ processor.process() 호출

[개선]
SampleEvaluationService
  ├─ ApprovalApplyCommon (주입받음)
  │   └─ ApprovalProcessor (1번만 생성)
  ├─ 도메인 로직만 집중 (평가)
  └─ approvalApplyCommon.apply() 호출

After: 리팩토링

1. 먼저 공통 인터페이스부터 만들었다

public interface ApprovalApplyCommon {
    void apply(DomainVO vo);
}

2. 승인 처리를 담당하는 공통 모듈 구현

@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);
        }
    }
}

핵심 개선 포인트

ApprovalProcessor의 한계

ApprovalProcessor 역시 엄밀히 말하면 SRP를 완전히 지키진 못한다.. 승인/반려/저장 등 여러 책임을 가지고 있기 때문

하지만..

3. 개선된 Service 구현

@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 활용)

public enum ActionType {
    SAVE,
    APPROVE,
    REJECT
}

5. DomainVO (상태 변경 책임 위임)

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;
    }
}

개선된 구조의 특징


비교: Before vs After

코드 구조 비교

항목BeforeAfter
저장 메서드모든 로직 혼재위임 중심
객체 생성메서드 호출마다애플리케이션 시작 시 1회
승인 로직메서드 내부에서 직접 처리공통 모듈에 위임
관심사 분리승인/도메인 로직 혼재도메인 로직만 집중
의존성 주입@Autowired + @Resource 혼용@RequiredArgsConstructor (일관성)

개선 효과

1. 성능 개선

2. 유지보수성 향상

3. 코드 품질 개선

4. 테스트 용이성

Before: 테스트하기 어려운 구조

@Test
public void 저장_테스트() {
    // 문제: ApprovalProcessor를 Mock으로 대체할 수 없음
    // saveReport 메서드 내부에서 new로 생성하기 때문
    ReportVO result = service.saveReport(vo);
    // 승인 로직까지 모두 실행됨
}

After: 테스트하기 쉬운 구조

@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 한 곳만 수정하면 끝!

잘 돌아가긴 해서 “이게 뭐가 문제지?” 라고 생각할 수 있지만. 유지보수성, 확장성, 테스트 용이성을 고려하면 분명히 개선이 필요한 코드였다.

코드는 회사 보안 정책을 준수하기 위해 실제 도메인 용어와 비즈니스 로직을 일반적인 예시로 수정하였습니다.

···
← PREV [JVM] 메모리 구조와 객체 흐름 NEXT → [리팩토링] 도메인 책임 위치를 잘못 잡았을 때 보이는 신호