오늘은 한 서비스단의 파일을 여러 파일로 분리했을 때 생기는 문제에 대해 다루려 한다. 배달 서비스의 주문 도메인을 개발하던 도중, 다른 도메인과 많이 연계됨으로 인해 주문 서비스의 파일이 비대해지는 상황을 겪었다. 비대해지게 되면 나중에 내가 유지보수하거나 다른 사람이 내 코드를 읽을 때 의도와 로직의 흐름을 파악하기 쉽지 않아질 것이라 생각했다. 그래서 비대해진 주문 서비스를 유관한 도메인끼리 묶어서 파일을 나누었다. 이렇게 하니 서비스 단에서 레포를 참조하는 로직이 서비스 파일마다 생겨 중복 코드가 발생하는 일이 생겼다.
간략하게 상황을 인지할 수 있을 정도의 코드들만 넣겠다. Order 도메인은 Payment(결제) 도메인, Store(상점) 도메인 등 여러 도메인과 얽혀 있다. 그렇기에 Order도메인의 PaymentOrderService 와 StoreOrderService 클래스로 분리했다. 그런데 다음과 같은 중복되는 메서드를 가지고 있었다.
public class StoreOrderService & PaymentOrderService {
private final OrderRepository orderRepository;
private Order getOrder(Long orderId) {
return orderRepository.findById(orderId)
.orElseThrow(() -> new CustomException(ErrorCode.ORDER_NOT_FOUND));
}
}
하나의 서비스 안에 레포를 통해 DB에서 엔티티 객체를 가지고오는게 무슨 문제냐 할 수 있겠지만, 만약 CustomException의 이름을 구체화해야한다던가, Enum 이름을 바꿔야한다던가 등의 요구사항이 생긴다면 모든 서비스 파일의 해당 메서드에 대해 똑같이 수정해줘야할 것이다. 굉장히 번겁롭고 시간이 아까운 작업이 연속되게 된다.
지금 문제는 같은 일을 하는 코드가 여러 군데에 돌아다닌다는 것이다. 즉, 이걸 한 곳에서 관리하게 하는 것이 핵심이다.
내가 생각한 방법은 주문 도메인 내의 모든 서비스가 공통적으로 사용하는 메서드를 위한 클래스를 하나 만드는 것이었다. 조금 더 나아가 이미 레포지터리의 메서드를 가지고 엔티티를 DB로부터 받아오고 있으니, 데이터 fetch에 대한 책임을 레포지터리가 전담하여 중복 문제를 해결하는 것이 좋다고 생각했다. 그래서 아래와 같은 방식의 코드로 문제를 종결지었다.
public interface OrderRepository extends JpaRepository<Order, Long> {
Optional<Order> findByMerchantId(String merchantId);
default Order findByMerchantIdOrThrow(String merchantId) {
return findByMerchantId(merchantId)
.orElseThrow(() -> new CustomException(ErrorCode.ORDER_NOT_FOUND));
}
}
현재 사용하는 Java 21 기준으로, 인터페이스의 접근제어자에는 private, public, default가 있다.
Java가 처음 태어났을 때는 public 만 있었다. 그러나 개발자들이 코드를 작성하며 내가 겪은 것과 비슷한 문제들을 해결하기 위해 default 가 생겼다. default 를 통해 기본적인 구현을 하게되는데, 여기서도 중복이 생길 수 있다. 이러한 문제를 해결하기 위해 Java 9로 업데이트 될 때, private을 추가하여 default 메서드 내에서의 중복도 해결했음을 알았다.
Java 초기 → Java 8 → Java 9 로 넘어가며 생긴 변화에 대해 기억해두면 좋을 것이다.
그렇다면
public interface OrderRepository extends JpaRepository<Order, Long> {
Optional<Order> findByMerchantId(String merchantId);
default Order findByMerchantIdOrThrow(String merchantId) {
return findByMerchantId(merchantId)
.orElseThrow(() -> new CustomException(ErrorCode.ORDER_NOT_FOUND));
}
}
이 코드의 단점으로 기재한 부분을 private으로 해결할 수 있는 것이 아닌가? 할 수 있다. 결론부터 말하자면 private을 findByMerchantId 메서드 앞에 붙일 수 없다. 이유는 JPA 쿼리 메서드는 추상 메서드로 간주되는데, Java 문법상 인터페이스의 추상 메서드는 반드시 public 접근 제어자를 가져야하기 때문이다. 따라서 public 외의 접근 제어자를 명시할 수 없다.
OrderRepository 인터페이스를 구현하는 OrderRepositoryImpl 클래스여러 서비스에 있던 예외처리 로직이 한 곳으로 일원화 되어 유지보수성이 높아진 것은 좋다. 그러나 Repository가 예외처리라는 비즈니스 로직과 데이터 fetch 라는 2개의 역할을 맡게 되었다. 또, OrderRepository 참조 시에 JPA 쿼리 메서드를 사용할 수 있다는 가능성이 여전히 남아있다.
2가지 문제점을 해결하기 위해 서비스와 레포지터리 사이에 계층을 하나 더 두는 것이 합리적으로 보인다.