12/3

졸용·2025년 12월 3일

TIL

목록 보기
127/144

DDD구조의 MSA 프로젝트에 JPA Pageable 사용?

DDD 구조의 MSA 프로젝트에서는 JPA의 Pageable 객체를 사용하면 안 좋다고 생각되어 (DDD 규칙에 어긋나기 때문), 커스터마이징 한 Page 객체를 만들었다.


🔹 왜 사용하면 안 되는가?

결론부터 말하면:

“MSA + DDD 구조라서 Page/Pageable을 절대 쓰면 안 된다” 같은 건 없다.
다만, “어디까지 써도 되는지(레이어 경계)” 가 중요하다.

  • ✅ 인프라 레이어(Repository 구현체) 에서는 Page/Pageable 마음껏 써도 됨
  • ⚠️ 도메인/애플리케이션/공용 인터페이스까지 Page/Pageable이 새어 나오면 좀 곤란해짐
  • 그래서 “내 페이징 모델(CustomPage, CustomPageRequest)”을 바깥 계약으로 쓰고, 안쪽에서만 Page/Pageable로 매핑하는 패턴을 많이 씀

🔸 DDD/Hexagonal 관점에서의 문제점

1) 도메인 계층이 Spring Data에 직접 의존하게 됨

DDD + 헥사고날(ports & adapters)에서 보통 구조를 이렇게 잡는다:

  • domain/application 레이어: HubRepository 같은 포트 인터페이스
  • infra 레이어: SpringDataHubRepository extends JpaRepository<Hub, UUID> 같은 어댑터 구현체

이때 도메인 레이어의 포트 인터페이스에 Page/Pageable이 등장하면:

// (안 좋은 예) domain 레이어에 있는 인터페이스
public interface HubRepository {
    Page<Hub> searchByHubName(String hubName, Pageable pageable);
}
  • 도메인 코드가 Spring Data JPA에 직접 의존하게 됨
  • 나중에 저장소 기술을 바꾸고 싶을 때(몽고, 엘라스틱, R2DBC 등)
    → 인터페이스 자체를 갈아엎어야 함
  • “도메인은 기술에 독립적이어야 한다”는 DDD/헥사고날 철학이 깨짐

그래서 보통은 이렇게 분리한다:

// domain/application 레이어: 기술 중립 포트
public interface HubRepository {

    MyPage<Hub> searchByHubName(String hubName, MyPageRequest pageRequest);
}
// infra 레이어: Spring Data JPA 어댑터
public interface HubJpaRepository extends JpaRepository<Hub, UUID> {

    Page<Hub> findByHubNameAndDeletedAtIsNull(String hubName, Pageable pageable);
}
// infra 레이어: 포트 구현체
@Repository
@RequiredArgsConstructor
public class HubRepositoryImpl implements HubRepository {

    private final HubJpaRepository hubJpaRepository;

    @Override
    public MyPage<Hub> searchByHubName(String hubName, MyPageRequest pageRequest) {
        Pageable pageable = PageRequest.of(pageRequest.page(), pageRequest.size());
        Page<Hub> page = hubJpaRepository.findByHubNameAndDeletedAtIsNull(hubName, pageable);

        return new MyPage<>(
            page.getContent(),
            page.getNumber(),
            page.getSize(),
            page.getTotalElements()
        );
    }
}
  • 도메인/애플리케이션 레이어는 MyPage/MyPageRequest만 알면 됨
  • Page/Pageableinfra 안에서만 사용 → 기술 교체/테스트 분리가 쉬워짐

🔸 MSA 관점에서의 문제점

1) 서비스 간 계약(API, 공용 모듈)에 Spring Data 타입이 새어 나옴

MSA에서는 보통:

  • REST API 응답 DTO
  • 공용 라이브러리(예: lib-web, lib-pagination)
  • 다른 서비스와의 계약(Swagger/OpenAPI)

이런 데서 Spring Data 타입이 바로 노출되는 것을 피하는 편이 좋다.

예를 들어, 이런 응답은 바람직하지 않음:

// (안 좋은 예) Controller에서 Page를 그대로 응답으로 내려버리는 경우
@GetMapping("/hubs")
public Page<HubResponse> searchHubs(...) { ... }

이렇게 되면:

  • 외부(클라이언트, 다른 서비스)가 Spring Data의 Page 구조에 종속
  • 페이지 인덱스가 0-based인지, 필드명이 뭐인지까지 바깥 계약이 되어버림
  • 나중에 페이지 규칙을 바꾸거나 라이브러리 교체하기 어려움

그래서 보통은:

  • 내가 정의한 페이징 응답 DTO (HubPageResponseV1)로 감싸고
  • 그 안의 content, page, size, totalElements, totalPages 등을 명시적으로 설계
public record HubPageResponseV1(
    List<HubSummaryResponseV1> content,
    int page,
    int size,
    long totalElements,
    int totalPages,
    boolean first,
    boolean last
) {}
  • 이렇게 해두면 나중에 JPA → Mongo, JPA → MyBatis로 바꿔도
    Controller 바깥 계약은 그대로 유지 가능


🔹 그렇다고 Page/Pageable이 나쁜 건 아니다

어디서 쓰면 좋은지 정리하면:

🔸 이런 곳에서는 적극 사용해도 됨

  • infra 레이어(Spring Data JPA repository) 내부

    • JpaRepository 상속, Page<엔티티> find... 메서드
    • PageRequest.of(page, size, Sort...) 로 정렬 포함 페이징
  • 테스트 코드, 샘플 코드에서 빠르게 페이징 붙일 때


🔸 이런 곳에선 피하는 게 좋음

  • domain/application 레이어 인터페이스 (포트)

    • Page/Pageable이 올라오면 도메인이 Spring Data에 물림
  • 서비스 간 API 계약(Controller 응답, 공용 DTO)

    • 프론트/다른 서비스가 Spring Data 타입 구조에 종속됨
  • 공용 모듈 (lib-*)

    • 여러 서비스가 함께 쓰는 모듈에 Spring Data 타입을 넣어버리면
      그 모듈을 쓰는 모든 서비스가 Spring Data를 강제 당함


🔹 그래서 “MSA + DDD”에서 권장하는 타협안

  1. 도메인/애플리케이션/공용에서는

    • CustomPage, CustomPageRequest 같은 기술 중립 페이징 모델 사용
  2. infra에서는

    • Spring Data JPA의 Page/Pageable 마음껏 사용
    • 그리고 거기서 CustomPage로 변환해서 위로 올려줌
  3. Controller/REST 응답에서는

    • CustomPage 기반 DTO (PageResponseV1)만 노출

이렇게 하면:

  • DDD/헥사고날 철학 유지 (도메인은 기술에 독립)
  • MSA 간 API 계약도 깔끔하게 유지
  • 하지만 내부 구현에서는 Page/Pageable의 생산성을 그대로 가져감


🔹 요약

  • “MSA + DDD라서 Page/Pageable 자체가 나쁘다” → ❌ (틀린 말)
  • “도메인/애플리케이션/API 계약에 Page/Pageable을 직접 노출하는 건 피하는 게 좋다” → ✅
  • 그래서 infra에서만 JPA Page/Pageable 쓰고, 바깥으로는 내가 정의한 MyPage/DTO로 감싸는 패턴이 제일 깔끔하다.



Custom 페이지네이션 구현

🔹 lib (공통 모듈)

분리된 공통 모듈에 커스텀 페이지 구현

🔸 CustomPageRequest

package lib.pagination;

public record CustomPageRequest(
    int page,
    int size) {

    public CustomPageRequest {

        if (page < 0) {
            throw new IllegalArgumentException("페이지는 반드시 0 이상이어야 합니다. page=" + page);
        }

        if (size <= 0) {
            throw new IllegalArgumentException("사이즈는 반드시 0보다 커야 합니다. size=" + size);
        }
    }

    public int offset() {
        return page * size;
    }

    // null 및 이상값 방로용 헬퍼 메서드
    public static CustomPageRequest of(Integer page, Integer size, int defaultPage, int defaultSize) {

        int p = (page == null || page < 0) ? defaultPage : page;
        int s = (size == null || size <= 0) ? defaultSize : size;

        return new CustomPageRequest(p, s);
    }
}

🔸 CustomPageResult

package lib.pagination;

import java.util.List;
import lombok.Getter;

@Getter
public class CustomPageResult<T> {

    private final List<T> itemList;     // 현재 페이지 데이터
    private final int page;             // 현재 페이지 번호
    private final int size;             // 페이지 크기
    private final long totalCount;      // 전체 데이터 개수 (Slice면 -1 허용)
    private final boolean hasNext;      // 다음 페이지 존재 여부

    private CustomPageResult(List<T> itemList, int page, int size, long totalCount, boolean hasNext) {

        this.itemList = itemList;
        this.page = page;
        this.size = size;
        this.totalCount = totalCount;
        this.hasNext = hasNext;
    }

    // totalCount를 아는 일반적인 페이징용
    public static <T> CustomPageResult<T> of(List<T> itemList, int page, int size, long totalCount) {

        boolean hasNext = ((long) page + 1) * size < totalCount;

        return new CustomPageResult<>(itemList, page, size, totalCount, hasNext);
    }

    // totalCount를 모르는 Slice 기반 페이징용
    public static <T> CustomPageResult<T> sliceOf(List<T> itemList, int page, int size, boolean hasNext) {
        return new CustomPageResult<>(itemList, page, size, -1, hasNext);
    }
}

🔹 다른 모듈에 사용 예시

🔸 HubDeliveryRepository

package chill_logistics.delivery_server.domain.repository;

import chill_logistics.delivery_server.domain.entity.HubDelivery;
import lib.pagination.CustomPageRequest;
import lib.pagination.CustomPageResult;

public interface HubDeliveryRepository {

    CustomPageResult<HubDelivery> searchByStartHubName(String startHubName, CustomPageRequest customPageRequest);
}

🔸HubDeliveryRepositoryAdapter

package chill_logistics.delivery_server.infrastructure.repository;

import chill_logistics.delivery_server.domain.entity.HubDelivery;
import chill_logistics.delivery_server.domain.repository.HubDeliveryRepository;
import lib.pagination.CustomPageRequest;
import lib.pagination.CustomPageResult;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;

@RequiredArgsConstructor
public class HubDeliveryRepositoryAdapter implements HubDeliveryRepository {

    private final JpaHubDeliveryRepository jpaHubDeliveryRepository;

    @Override
    public CustomPageResult<HubDelivery> searchByStartHubName(
        String startHubName,
        CustomPageRequest customPageRequest) {

        PageRequest pageable = PageRequest.of(customPageRequest.page(), customPageRequest.size());

        Page<HubDelivery> page;

        // 검색어 없으면 전체 조회, 있으면 조건 검색
        if (startHubName == null || startHubName.isBlank()) {
            page = jpaHubDeliveryRepository.findByDeletedAtIsNull(pageable);
        } else {
            page = jpaHubDeliveryRepository.findByStartHubNameAndDeletedAtIsNull(startHubName, pageable);
        }

        return CustomPageResult.of(
            page.getContent(),
            page.getNumber(),
            page.getSize(),
            page.getTotalElements()
        );
    }
}

🔸JpaHubDeliveryRepository

package chill_logistics.delivery_server.infrastructure.repository;

import chill_logistics.delivery_server.domain.entity.HubDelivery;
import java.util.UUID;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;

public interface JpaHubDeliveryRepository extends JpaRepository<HubDelivery, UUID> {

    Page<HubDelivery> findByDeletedAtIsNull(Pageable pageable);

    Page<HubDelivery> findByStartHubNameAndDeletedAtIsNull(String hubName, Pageable pageable);
}

🔸DeliveryQueryService

package chill_logistics.delivery_server.application;

import chill_logistics.delivery_server.application.dto.query.HubDeliveryInfoResponseV1;
import chill_logistics.delivery_server.application.dto.query.HubDeliverySummaryResponseV1;
import chill_logistics.delivery_server.domain.entity.HubDelivery;
import chill_logistics.delivery_server.domain.repository.FirmDeliveryRepository;
import chill_logistics.delivery_server.domain.repository.HubDeliveryRepository;
import chill_logistics.delivery_server.presentation.ErrorCode;
import chill_logistics.delivery_server.presentation.dto.response.HubDeliveryPageResponseV1;
import java.util.List;
import java.util.UUID;
import lib.pagination.CustomPageRequest;
import lib.pagination.CustomPageResult;
import lib.web.error.BusinessException;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class DeliveryQueryService {

    private final HubDeliveryRepository hubDeliveryRepository;
    private final FirmDeliveryRepository firmDeliveryRepository;

    /* [허브배송 단건 조회]
     */
    public HubDeliveryInfoResponseV1 getHubDelivery(UUID hubDeliveryId) {

        HubDelivery hubDelivery = hubDeliveryRepository.findById(hubDeliveryId)
            .orElseThrow(() -> new BusinessException(ErrorCode.HUB_DELIVERY_NOT_FOUND));

        if (!(hubDelivery.getDeletedAt() == null)) {
            throw new BusinessException(ErrorCode.DELIVERY_HAS_BEEN_DELETED);
        }

        return HubDeliveryInfoResponseV1.from(hubDelivery);
    }

    /* [허브배송 검색 조회]
     * 검색 기준: startHubName
     * 검색어 없으면 전체 목록 조회, 있으면 조건 검색 결과 반환
     */
    public HubDeliveryPageResponseV1 searchHubDeliveryByHubName(String hubName, int page, int size) {

        CustomPageRequest pageRequest = new CustomPageRequest(page, size);

        // 페이징 된 엔티티 조회
        CustomPageResult<HubDelivery> entityPage = hubDeliveryRepository.searchByStartHubName(hubName, pageRequest);

        // 엔티티 → DTO로 변환
        List<HubDeliverySummaryResponseV1> dataSummaryList = entityPage.getItemList().stream()
            .map(HubDeliverySummaryResponseV1::from)
            .toList();

        return HubDeliveryPageResponseV1.of(entityPage, dataSummaryList);
    }
}

🔸DeliveryController

package chill_logistics.delivery_server.presentation;

import chill_logistics.delivery_server.application.DeliveryQueryService;
import chill_logistics.delivery_server.application.dto.query.HubDeliveryInfoResponseV1;
import chill_logistics.delivery_server.presentation.dto.response.HubDeliveryPageResponseV1;
import java.util.UUID;
import lib.entity.BaseStatus;
import lib.web.response.BaseResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/v1")
public class DeliveryController {

    private final DeliveryQueryService deliveryQueryService;

    /**
     * [허브배송 단건 조회]
     *
     * @param hubDeliveryId 조회하고자 하는 허브배송의 UUID
     * @return 허브배송 상세 정보
     */
    @GetMapping("/hub-deliveries/{hubDeliveryId}")
    @ResponseStatus(HttpStatus.OK)
    public BaseResponse<HubDeliveryInfoResponseV1> getHubDelivery(
        @PathVariable("hubDeliveryId") UUID hubDeliveryId) {

        HubDeliveryInfoResponseV1 response = deliveryQueryService.getHubDelivery(hubDeliveryId);

        return BaseResponse.ok(response, BaseStatus.OK);
    }

    /**
     * [허브배송 검색 조회]
     *
     * @param startHubName 허브배송에서 검색하고자 하는 허브명
     * @param page         조회할 페이지 번호 (0부터 시작)
     * @param size         페이지 당 조회할 데이터 개수
     * @return 허브배송 요약 정보 목록 + 페이징 정보
     */
    @GetMapping("/hub-deliveries")
    @ResponseStatus(HttpStatus.OK)
    public BaseResponse<HubDeliveryPageResponseV1> searchHubDeliveries(
        @RequestParam(required = false) String startHubName,
        @RequestParam(defaultValue = "0") int page,
        @RequestParam(defaultValue = "20") int size) {

        HubDeliveryPageResponseV1 response = deliveryQueryService.searchHubDeliveryByHubName(
            startHubName, page, size);

        return BaseResponse.ok(response, BaseStatus.OK);
    }
}
profile
꾸준한 공부만이 답이다

0개의 댓글