패키지 구조 재편과 Service를 Util로 전환한 리팩토링 가이드
목차
리팩토링이라는 단어가 팀 안에서 가장 오해받는 작업 중 하나다. "기능 안 건드리는 거잖아요, 굳이 일정을 잡아야 해요?" 이런 질문을 꽤 여러 번 들었음. 그래서 이번에 패키지 구조를 뒤집고 Service 계층을 Util 방식으로 전환하면서, 작업 전에 먼저 문서부터 썼다. 왜 이렇게 하는지, 어떤 순서로 하는지, 무엇을 건드리지 않는지 - 이 세 가지를 정리해 두지 않으면 중간에 반드시 "이거 기능도 같이 고쳐도 되지 않나요?" 하는 PR이 올라옴.
핵심은 두 가지였다. 패키지 구조를 역할 중심으로 재편하는 것, 그리고 Spring Bean으로 관리되던 Service를 static Util 클래스로 전환하는 것.
왜 Service를 Util로 바꿨나
기존 구조는 기능별 패키지 안에 Controller, Service, Mapper가 다 섞여 있었음. 도메인 단위로 묶는 방식인데, 작은 프로젝트에선 직관적이지만 서비스가 서비스를 주입받는 관계가 쌓이면서 의존성 그래프가 복잡해지기 시작했다. 어떤 Service가 어떤 Service에 의존하는지 파악하려면 IDE에서 call hierarchy를 펼쳐야 했음.
전환 후 구조는 단순하다.
기존: com.example.domain.user
├── UserController.java
├── UserService.java
└── UserMapper.java
개선: com.example.web
└── UserController.java
com.example.utl
└── UserUtil.java
(Mapper XML은 resources/mapper/ 하위 고정)
Controller(web)와 Util(utl) 두 레이어만 남긴다. Util은 static 메서드로만 구성하고, Spring context에 올라가지 않는다. 덕분에:
- 어디서 호출해도
UserUtil.findById(id)하나면 됨. Bean 주입 설정 없이. - 단위 테스트 작성이 편해짐. Mock 없이 메서드 직접 호출.
- 순환 의존성이 구조적으로 불가능해짐. static 클래스끼리는 서로 참조야 할 수 있지만, 그게 눈에 바로 보임.
트레이드오프도 있음. Spring의 트랜잭션 관리나 AOP 기반 로깅은 Bean이어야 동작한다. Util로 전환하면 @Transactional을 Controller 쪽에서 처리하거나, 트랜잭션 경계를 명시적으로 다시 설계해야 함. 이번 코드베이스엔 트랜잭션 처리가 Controller 레벨에서 관리돼도 무방한 구조였기 때문에 전환 결정을 내렸음. 무조건 옳은 패턴은 아니고, 이 선택이 맞는 상황과 맞지 않는 상황이 있다.
전환 순서가 중요한 이유
한 번에 다 바꾸면 롤백이 불가능해진다. 이번엔 의존성 복잡도 기준으로 3단계로 나눴음.
| Phase | 대상 | 기준 |
|---|---|---|
| 1-1 | 독립 Service 6개 | 다른 Service를 주입받지 않는 것 먼저 |
| 1-2 | 중간 의존성 Service 4개 | 1-1 결과물에만 의존하는 것 |
| 1-3 | 핵심 금융 Service 3개 | 가장 많이 호출되는 것, 마지막에 신중하게 |
| 2 | XML 정리 | 죽은 Mapper XML 제거 |
각 Phase를 커밋 단위로 분리했다. 이렇게 하면 1-2 작업 중에 문제가 생겼을 때 1-1 커밋으로 되돌아갈 수 있음. git revert 한 줄이면 됨. Phase를 합쳐서 커밋하면 롤백 시 원하지 않는 변경도 같이 되돌아가는 상황이 생긴다.
빌드는 각 단계마다 반드시 돌렸다. 컴파일 에러 없이 통과한 뒤에야 다음 Phase로 넘어감. 리팩토링 중에 기능이 깨지는 건 말 그대로 최악의 상황이다. 코드 리뷰어는 "기능 변경 없음"이라는 전제로 PR을 보는데, 런타임에서 다른 동작이 나오면 신뢰가 무너짐.
Phase 2의 XML 정리는 생각보다 손이 많이 갔다. Mapper XML 중에 실제로 호출되는 곳이 없는 것들이 있었음. 죽은 SQL이 남아 있으면 나중에 누군가 "이거 쓰이나?" 하고 추적하는 데 시간을 쓰게 된다. 이참에 다 정리했고, XML 위치 규칙도 문서화해 뒀음. resources/mapper/ 하위에 도메인명 폴더로 고정. 앞으로 새로 추가할 때 위치 고민 없이 그냥 거기에 넣으면 됨.
리팩토링 커밋을 분리해야 하는 이유
코드 리뷰 부담 얘기를 하지 않을 수 없음.
리팩토링과 기능 변경이 한 커밋에 섞이면 리뷰어가 diff에서 "이건 그냥 이름 바꾼 거고, 이건 로직이 달라진 건데..." 하면서 판단해야 함. 그 판단 비용이 꽤 크다. 리팩토링 커밋만 모아 놓으면 리뷰어 입장에서 "이 PR은 동작 변경 없음"이라는 전제를 가져갈 수 있고, diff 읽는 방식이 달라진다.
좋은 리팩토링과 나쁜 리팩토링을 구분하는 기준도 결국 이 맥락에서 나옴.
| 좋은 리팩토링 | 피해야 할 패턴 |
|---|---|
| 중복 코드를 메서드로 추출 | 리팩토링 중에 기능도 같이 변경 |
| 변수명을 의도가 드러나게 변경 | 테스트 없이 대규모 전환 |
| 복잡한 조건식을 단계별로 단순화 | 한 번에 전체를 뒤집는 커밋 |
| 의존성 방향을 단순하게 정리 | Phase 경계 없이 순서 뒤섞기 |
이번 작업을 마치고 나서 가장 크게 체감한 건, 구조 문서를 먼저 썼다는 점이 실질적으로 도움이 됐다는 거다. 작업 중간에 "이렇게 하는 게 맞나요?" 질문이 거의 없었음. 문서에 이미 답이 있었기 때문에. 다음 리팩토링 때도 이 순서대로 할 생각이다 - 문서 먼저, 단계 분리, 단계마다 빌드, 커밋은 기능과 무조건 분리.
댓글 0
첫 댓글 달아줘.