Repository의 책임 분리 및 조회 로직의 캡슐화

StudyBug·2025년 11월 28일

서론

저번에 서비스의 중복 코드를 개선하고자 레포지터리에 데이터 조회 및 처리 로직을 담았었다. 이로 인해 Repository가 SRP 원칙을 위배하게 됐다. 또, 인터페이스의 기본 메서드를 통해 단일 조회를 해야하는데, Repository 참조 시에 기본 메서드와 JPA 쿼리 메서드라는 2가지 선택지로 인해 무분별한 사용의 여지가 있었다. 오늘은 이 2가지 문제를 해결한 방안에 대해 작성하고자 한다.

문제 상황

아래 코드를 살펴보자. 크게 분류하면 단일 조회 / 기본 메서드 / 목록 조회로 되어있다.

public interface OrderRepository extends JpaRepository<Order, Long>, OrderRepositoryCustom {

  Optional<Order> findByMerchantId(String merchantId);

  Optional<Order> findByIdAndCustomerProfileId(Long orderId, Long customerProfileId);

  Optional<Order> findByDeliveryId(Long deliveryId);

  default Order findByIdOrThrow(Long orderId) {
    return findById(orderId)
        .orElseThrow(() -> new CustomException(ErrorCode.ORDER_NOT_FOUND));
  }

  default Order findByMerchantIdOrThrow(String merchantId) {
    return findByMerchantId(merchantId)
        .orElseThrow(() -> new CustomException(ErrorCode.ORDER_NOT_FOUND));
  }

  default Order findByIdAndCustomerProfileIdOrThrow(Long orderId, Long customerProfileId) {
    return findByIdAndCustomerProfileId(orderId, customerProfileId)
        .orElseThrow(() -> new CustomException(ErrorCode.CUSTOMER_ORDER_NOT_FOUND));
  }

  default Order findByDeliveryIdOrThrow(Long deliveryId) {
    return findByDeliveryId(deliveryId)
        .orElseThrow(() -> new CustomException(ErrorCode.ORDER_NOT_FOUND));
  }

  List<Order> findByStoreIdAndStatus(Long storeId, OrderStatus status);
}

Repository는 DB로부터 데이터를 가져오는 역할을 한다. 그런데 지금 default 메서드를 보면 비즈니스 로직에 속하는 예외처리가 포함되어 있다. 이는 객체 지향 중심적 설계에서 중요한 원칙 중 하나인 SRP 원칙 위반이다. 따라서 비즈니스 로직을 처리하는 서비스 단에 default 메서드 관련 코드들을 옮겨야 할 것이다.

또, 누군가 Service 단에서 Repository를 참조할 때, findByMerchantIdfindByMerchantIdOrThrow 메서드를 둘 다 사용할 수 있기 때문에 예외처리한 findByMerchantIdOrThrow 메서드에 대한 강제성이 떨어진다.

해결 방안

읽어온 데이터를 처리하는 클래스 A에 기본 메서드를 옮겨 놓고 다른 서비스가 A를 참조하게 하는 방법을 생각해볼 수 있다. 이러면 Reposiotry가 맡고 있는 비즈니스 책임도 없앨 수 있고 조회한 데이터에 대한 추가적인 작업이 필요한 메서드는 A 클래스에 책임을 맡길 수 있다.

public class OrderQueryService {

  private final OrderRepository orderRepository;

  public Order findByIdOrThrow(Long orderId) {
    return orderRepository.findById(orderId)
        .orElseThrow(() -> new CustomException(ErrorCode.ORDER_NOT_FOUND));
  }

  public Order findByMerchantIdOrThrow(String merchantId) {
    return orderRepository.findByMerchantId(merchantId)
        .orElseThrow(() -> new CustomException(ErrorCode.ORDER_NOT_FOUND));
  }

  public Order findByIdAndCustomerProfileIdOrThrow(Long orderId, Long customerProfileId) {
    return orderRepository.findByIdAndCustomerProfileId(orderId, customerProfileId)
        .orElseThrow(() -> new CustomException(ErrorCode.CUSTOMER_ORDER_NOT_FOUND));
  }

  public Order findByDeliveryIdOrThrow(Long deliveryId) {
    return orderRepository.findByDeliveryId(deliveryId)
        .orElseThrow(() -> new CustomException(ErrorCode.ORDER_NOT_FOUND));
  }
}

위에서 말한 A 클래스를 OrderQueryService 라고 정의했다. 이렇게 StoreOrderService, PaymentOrderService와 같은 기존의 서비스 계층과 Repository 계층 사이에 OrderQureyService를 추가했다. 이로써 OrderRepository는 순수한 데이터 접근의 역할만 맡게 되었고 비즈니스 로직에 종속적인 조회는 캡슐화됐다.

장점

  • 책임 분리 및 SRP 준수
  • 클래스의 목적 명확화

단점

  • 새로운 클래스로 인한 복잡도 증가

새롭게 알게된 점

조회한 결과에 추가적인 작업을 처리하는 클래스의 네이밍을 고민하던 중, CQRS 패턴에 대해서 살짝 알아보게 되었다. Query 는 조회에 대한 처리, Command는 생성, 수정, 삭제의 기능을 맡아서 책임을 분리하는 패턴이다. 조회의 빈도수가 나머지 3개 보다 많기 때문에 조회 결과를 처리하는 클래스가 따로 분리된다.

이 점에 착안하여 현재 나도 비슷한 상황에 놓여 있기 때문에 클래스의 이름을 OrderQueryService라고 지었다. CQRS 패턴의 도입이라곤 할 수 없지만, 나중에 조회 : 쓰기 작업의 비율이 100 : 1 처럼 조회가 훨씬 많은 경우가 있을 수도 있다. 이때 Query 모델(서버)과 Command 모델(서버)로 나누어서 아키텍처가 구성될 수 있겠다고도 생각하는 계기가 됐다.

결론

새로운 클래스 도입으로 인한 복잡도 증가라는 단점은 있으나, 시스템의 유지보수성, 책임의 명확성 측면에서 얻는 장점이 훨씬 크다. 따라서 복잡성 증가를 감수할 만한 가치가 있는 구조 개선이라고 생각한다.

profile
갈 길이 먼 개발자 꿈나무

0개의 댓글