개발 slecs

서비스 계층을 정적 유틸로 전환해 패키지 구조 개선

목차

리팩토링을 실제로 해보면, 기능은 아무것도 안 바꿨는데 PR 규모가 크다는 이유로 리뷰가 묻히는 경우가 생긴다. 이번 작업도 비슷했다. 핵심은 두 가지였는데, 패키지 구조를 기능 단위에서 레이어 단위로 재편하는 것과, 기존 Service 빈(bean)을 static Util 클래스로 전환하는 것이었다.

왜 Service를 static Util로 바꿨나

스프링 프로젝트에서 Service를 @Service로 관리하다 보면 의존성 그래프가 눈에 안 보이게 커진다. Service A가 B를 주입받고, B가 C를 주입받고, C는 또 D, E를 주입받는 구조가 반복된다. 처음엔 느슨한 결합이라고 생각했던 게 어느 순간 "이 서비스 하나 고치려면 뭘 건드려야 하지?" 싶은 순간이 온다.

static Util로 전환하면 진입점이 명확해진다. 메서드를 따라가면 어떤 외부 의존성도 숨어 있지 않다. 스프링 컨텍스트가 뜨지 않아도 메서드를 호출할 수 있으니 단위 테스트 작성도 훨씬 편해진다. 아래처럼 컨텍스트 없이 바로 테스트 가능한 구조가 된다.

// 기존 Service 방식 - 테스트에 @SpringBootTest 혹은 mock 설정 필요
@Service
public class FeeService {
    @Autowired
    private FeeRepository feeRepository;

    public BigDecimal calculate(Order order) { ... }
}

// Util 방식 - 순수 자바, 컨텍스트 불필요
public class FeeUtil {
    public static BigDecimal calculate(Order order, FeePolicy policy) { ... }
}

물론 트레이드오프도 있다. static 메서드는 오버라이딩이 안 되니 다형성이 필요한 지점에는 맞지 않는다. DB나 외부 API를 내부에서 직접 호출해야 하는 경우엔 의존성을 인자로 전부 넘겨야 해서 시그니처가 길어질 수 있다. 이번엔 대상 Service들이 대부분 순수 계산 로직이나 변환 로직 위주여서 Util로 뽑기 적합했다. 상태를 들고 있거나 트랜잭션 경계가 복잡한 Service는 건드리지 않았다.

전환 순서 - 단계별로 끊었다

한 번에 모두 바꾸는 건 위험하다. 중간에 빌드가 깨지면 어디서 문제가 생겼는지 추적이 어려워진다. 그래서 의존성 관계를 먼저 분석하고, 아래 순서대로 쪼갰다.

Phase 대상 내용
1-1 독립 Service 6개 다른 Service에 의존 없는 것부터
1-2 중간 의존성 4개 1-1 완료 후 진행
1-3 핵심 금융 Service 3개 마지막, 가장 신중하게
2 XML 정리 죽은 설정·매핑 XML 제거

1-1은 빠르게 끝났다. 의존성이 없으니 메서드 시그니처만 static으로 바꾸고 호출부 import 수정하면 됐다. 1-2부터 조금 손이 갔다. 주입받던 Service가 이미 Util로 바뀌었으니 호출 방식만 바꾸면 됐는데, 중간중간 놓친 참조가 있어서 컴파일 에러로 잡히는 경우가 있었다. 오히려 그게 낫다. 런타임에서 터지는 것보다 컴파일 타임에 잡히는 게 훨씬 안전하다.

1-3은 금융 관련 로직이라 손 하나 잘못 대면 계산 결과가 달라질 수 있었다. 기존 테스트가 있는 케이스는 테스트 통과 여부로 확인했고, 없는 케이스는 전환 전에 기댓값을 먼저 기록해 두고 비교했다.

패키지 구조도 함께 정리했다. 기존엔 기능 단위로 패키지를 나누고 그 안에 Controller, Service, Mapper가 섞여 있었는데, 이번에 Controller는 web 하위로, Util은 utl 하위로 통일했다. 패키지 이름만 봐도 레이어가 보이는 구조가 됐다.

// 기존
com.example.order
  ├── OrderController.java
  ├── OrderService.java
  └── OrderMapper.java

// 개선
com.example.web
  └── OrderController.java
com.example.utl
  └── OrderUtil.java

리팩토링 커밋을 따로 쪼개는 이유

리뷰어 입장에서 가장 힘든 PR은 "리팩토링 + 기능 추가"가 섞인 것이다. 어디까지가 구조 변경이고 어디서부터 동작이 바뀌는지 눈으로 추적해야 하니 집중력이 분산된다. 놓치는 것도 많아진다.

리팩토링 커밋을 분리하면 좋은 점이 하나 더 있다. 뭔가 이상해서 git bisectgit revert를 써야 하는 상황이 올 때, 기능 커밋과 구조 커밋이 섞여 있으면 롤백이 복잡해진다. 단계별로 쪼개놓으면 특정 Phase만 되돌리기 쉽다.

좋은 리팩토링 피해야 할 리팩토링
중복 코드 메서드 추출 리팩토링 도중 기능 변경
변수명·메서드명 의미있게 수정 테스트 없이 큰 범위 변경
복잡한 조건식 단순화 한 커밋에 전부 몰아넣기
단계별 커밋, 단계별 빌드 확인 빌드 깨진 채 다음 단계 진행

이번 작업에서 체감한 건, 리팩토링은 계획이 반이라는 거다. 어떤 순서로 건드릴지, 중간에 빌드가 깨지면 어떻게 복구할지, 각 단계의 완료 기준이 뭔지를 먼저 정해두지 않으면 중반부에 반드시 꼬인다. 이번엔 의존성 분석을 선행하고 Phase를 명확히 정의했기 때문에 중간에 당황하는 일 없이 끝낼 수 있었다.

XML 정리(Phase 2)는 의외로 시원했다. 오랫동안 죽어 있었던 설정들이 정리되면서 전체 파일 수가 줄었다. 실제 동작에 영향은 없는데 코드베이스가 가벼워진 느낌이 분명히 든다.

댓글 0

첫 댓글 달아줘.