서비스 계층을 정적 유틸로 전환하고 패키지 구조 재편
목차
리팩토링이 필요하다는 걸 느끼는 순간은 보통 코드를 고치다가 "이거 어디 있었지?"를 중얼거릴 때다. 기능별로 묶인 패키지 구조는 처음엔 직관적으로 보이지만, 도메인이 늘어나면서 Controller/Service/Mapper가 한 패키지에 뒤엉기면 파일 탐색 자체가 일이 돼버린다.
이번 작업의 목표는 두 가지였다. 패키지 구조를 역할 기반으로 재편하고, Service 계층을 static Util로 전환하는 것. 기능 변경은 없다. 코드가 하는 일은 그대로인데, 읽기 쉽고 고치기 쉬운 모양으로 바꾸는 것.
패키지 구조를 왜 바꿨나
기존 구조는 기능별로 패키지가 나뉘어 있고, 그 안에 Controller/Service/Mapper가 같이 들어 있었다. 기능이 적을 땐 패키지 안에 파일이 세 개뿐이라 문제없다. 근데 도메인이 쌓이면 이런 구조는 점점 피곤해진다. "Mapper 파일 전체 보고 싶다"는 요구가 생겼을 때, 기능별 패키지 구조에서는 모든 패키지를 순회해야 한다.
// 기존 - 기능별 패키지, 역할이 분산됨
com.example.deposit
├── DepositController.java
├── DepositService.java
└── DepositMapper.java
com.example.user
├── UserController.java
├── UserService.java
└── UserMapper.java
// 변경 후 - 역할별 패키지
com.example.web
├── DepositController.java
└── UserController.java
com.example.utl
├── DepositUtl.java
└── UserUtl.java
바꾸고 나면 "Controller 전부 어디 있냐"는 질문에 web 패키지 하나로 답이 난다. MyBatis Mapper 인터페이스는 기존 위치에 두고, Service 계층이 사라지는 대신 utl 패키지에 static 메서드로 들어간다. 구조가 지도 역할을 하는 셈이다. 파일 탐색에 쓰던 인지 자원을 실제 로직에 쓸 수 있게 된다.
Service를 static Util로 전환하는 결정
이건 팀에 따라 호불호가 갈리는 선택이다. Spring 진영의 일반적인 컨벤션은 Service를 Bean으로 등록하고 DI로 주입받는 것이니까. 그럼에도 이 방향으로 간 데는 이유가 있다.
Spring Bean으로 관리되는 Service는 컨텍스트가 올라와야 쓸 수 있다. 단위 테스트 작성할 때 @SpringBootTest나 Mockito 세팅이 필요하고, 의존성 체인이 깊어지면 테스트 하나 돌리는 준비 코드가 본 테스트보다 길어진다. static Util 메서드는 그냥 호출하면 된다.
// Bean 방식 - 테스트 시 DI 컨텍스트 또는 Mocking 필요
@Service
public class DepositService {
@Autowired
private DepositMapper depositMapper;
public DepositDto getDeposit(Long id) {
return depositMapper.selectDeposit(id);
}
}
// Util 방식 - 의존성이 시그니처에 노출됨
public class DepositUtl {
public static DepositDto getDeposit(DepositMapper depositMapper, Long id) {
return depositMapper.selectDeposit(id);
}
}
Mapper는 여전히 주입받아야 하는데, Util 방식에서는 그걸 호출부인 Controller에서 넘겨주는 형태가 된다. 의존성이 필드 뒤에 숨겨지지 않고 메서드 시그니처에 노출된다. 이게 장점이자 단점이다. "이 메서드가 뭘 필요로 하는지"가 선언부에서 바로 보이는 반면, 파라미터가 많아지면 시그니처가 지저분해질 수 있다.
| Bean Service | Static Util | |
|---|---|---|
| 테스트 용이성 | Mocking/컨텍스트 필요 | 직접 호출 가능 |
| 의존성 가시성 | 필드 주입으로 숨겨짐 | 파라미터로 명시 |
| 상태 관리 | Bean 생명주기 따름 | 무상태 강제 |
| Spring 관례 | 일반적인 방식 | 비전통적 |
| 재사용 범위 | Spring 컨텍스트 안에서 | 어디서든 호출 가능 |
무상태를 구조적으로 강제한다는 점도 결정에 영향을 줬다. static 메서드는 인스턴스 필드 자체가 없으니 상태를 들고 있을 수가 없다. Service Bean이 실수로 상태를 갖게 되면 멀티스레드 환경에서 사이드이펙트가 생길 수 있는데, Util 방식은 그걸 원천 차단한다.
비슷한 고민을 한다면 팀의 테스트 전략과 코드베이스 크기를 같이 봐야 한다. 팀 전체가 Spring 컨벤션에 익숙하고 통합 테스트 중심으로 돌아간다면 굳이 이 방향일 필요는 없다. 반대로 단위 테스트를 자주 작성하고, DI 설정 복잡도를 줄이고 싶다면 유효한 선택지다.
단계별로 쪼갠 이유
한 번에 13개 Service를 다 바꾸는 건 리팩토링이 아니라 모험이다. 어느 시점에 빌드가 깨지는지 파악하기 어렵고, 롤백도 복잡해진다.
| Phase | 대상 | 내용 |
|---|---|---|
| 1-1 | 독립 Service 6개 | 다른 Service를 의존하지 않는 것 먼저 |
| 1-2 | 중간 의존성 4개 | 1-1 완료 후 전환 |
| 1-3 | 핵심 금융 Service 3개 | 마지막으로, 신중하게 |
| 2 | XML 정리 | 참조 없어진 쿼리 제거 |
1-1에서 의존성 없는 것부터 시작한 건 위험이 낮은 데서 손에 익히겠다는 의도였다. 실수가 생겨도 영향 범위가 좁고, 전환 패턴 자체를 검증할 수 있다. 1-3의 핵심 금융 Service는 예치금 처리와 연결된 부분이라 가장 마지막에 뒀다. 금액 계산이 엮인 코드는 리팩토링이라도 건드릴 때 조심스럽다. 숫자가 달라지면 바로 티가 나고, 책임도 크다. 1-2까지 패턴이 완전히 검증된 다음에 손댔다.
Phase 2의 XML 정리는 Mapper XML에서 더 이상 호출되지 않는 쿼리를 제거하는 작업이다. Service가 사라지면서 해당 Service만 호출하던 쿼리가 미아가 된 경우가 있었다. 죽은 쿼리를 남겨두면 나중에 "이거 쓰는 데가 있나?" 하고 grep 돌리는 사람이 생긴다. 버전 관리에 기록이 남으니 확인되면 바로 제거했다.
커밋도 Phase마다 분리했다. 리팩토링 커밋과 기능 변경 커밋이 섞이면 리뷰어가 "이건 동작도 바뀌었나?" 를 같이 확인해야 해서 부담이 올라간다. 분리하면 리팩토링 커밋은 diff가 크더라도 "동작은 그대로, 구조만 바뀜"이라는 컨텍스트를 전달할 수 있고, 리뷰어도 기능 변경 커밋에 집중력을 쓸 수 있다.
리팩토링 중에 지킨 원칙
리팩토링의 핵심은 기능을 바꾸지 않는 것이다. 같은 동작을 더 읽기 좋은 코드로 표현하는 것. 이게 무너지면 버그가 어디서 들어왔는지 추적하기가 매우 어려워진다.
- 각 Phase 완료마다 빌드 확인. 다음 단계는 빌드가 통과된 이후에만 진행
- 리팩토링 커밋 안에서 로직 수정 금지. 발견된 버그는 별도 커밋
- 정말 이상한 코드를 발견했을 때는 리팩토링 범위 밖으로 빼서 티켓으로 남김
세 번째가 실전에서 제일 지키기 어렵다. 고치다 보면 "여기 이상한데 그냥 고칠까" 싶은 순간이 계속 나온다. 그 충동을 참는 게 단계별 리팩토링의 훈련이기도 하다.
마무리하고 나서 느낀 것
구조가 정리되고 나니 새 기능을 어디에 추가할지가 명확해졌다. 기존엔 비슷한 기능이 어느 Service에 붙어야 하는지 판단이 애매했는데, 역할별 패키지가 생기면서 그 판단 기준이 명확해졌다.
static Util 전환에 대해선 팀 내에서 먼저 공유가 필요하다. Spring 컨벤션에서 벗어나는 결정이라, 나중에 합류하는 사람이 코드를 보면 당황할 수 있다. 구조 변경 자체보다 그 결정의 이유를 문서화하는 게 장기적으로 더 중요할 수 있다. "왜 이렇게 했는지"가 없으면, 시간이 지나서 다시 Service Bean으로 되돌리는 리팩토링이 또 발생한다.
댓글 0
첫 댓글 달아줘.