Post

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

[리팩토링] 복잡한 승인 로직, 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. 매번 객체를 새로 생성

refactor.png

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

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

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

This post is licensed under CC BY 4.0 by the author.