DDD 구조의 MSA 프로젝트에서는 JPA의 Pageable 객체를 사용하면 안 좋다고 생각되어 (DDD 규칙에 어긋나기 때문), 커스터마이징 한 Page 객체를 만들었다.
결론부터 말하면:
“MSA + DDD 구조라서
Page/Pageable을 절대 쓰면 안 된다” 같은 건 없다.
다만, “어디까지 써도 되는지(레이어 경계)” 가 중요하다.
Page/Pageable 마음껏 써도 됨Page/Pageable이 새어 나오면 좀 곤란해짐DDD + 헥사고날(ports & adapters)에서 보통 구조를 이렇게 잡는다:
HubRepository 같은 포트 인터페이스SpringDataHubRepository extends JpaRepository<Hub, UUID> 같은 어댑터 구현체이때 도메인 레이어의 포트 인터페이스에 Page/Pageable이 등장하면:
// (안 좋은 예) domain 레이어에 있는 인터페이스
public interface HubRepository {
Page<Hub> searchByHubName(String hubName, Pageable pageable);
}
그래서 보통은 이렇게 분리한다:
// 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()
);
}
}
Page/Pageable은 infra 안에서만 사용 → 기술 교체/테스트 분리가 쉬워짐MSA에서는 보통:
lib-web, lib-pagination)이런 데서 Spring Data 타입이 바로 노출되는 것을 피하는 편이 좋다.
예를 들어, 이런 응답은 바람직하지 않음:
// (안 좋은 예) Controller에서 Page를 그대로 응답으로 내려버리는 경우
@GetMapping("/hubs")
public Page<HubResponse> searchHubs(...) { ... }
이렇게 되면:
그래서 보통은:
HubPageResponseV1)로 감싸고content, page, size, totalElements, totalPages 등을 명시적으로 설계public record HubPageResponseV1(
List<HubSummaryResponseV1> content,
int page,
int size,
long totalElements,
int totalPages,
boolean first,
boolean last
) {}
어디서 쓰면 좋은지 정리하면:
infra 레이어(Spring Data JPA repository) 내부
JpaRepository 상속, Page<엔티티> find... 메서드PageRequest.of(page, size, Sort...) 로 정렬 포함 페이징테스트 코드, 샘플 코드에서 빠르게 페이징 붙일 때
domain/application 레이어 인터페이스 (포트)
Page/Pageable이 올라오면 도메인이 Spring Data에 물림서비스 간 API 계약(Controller 응답, 공용 DTO)
공용 모듈 (lib-*)
도메인/애플리케이션/공용에서는
CustomPage, CustomPageRequest 같은 기술 중립 페이징 모델 사용infra에서는
Page/Pageable 마음껏 사용CustomPage로 변환해서 위로 올려줌Controller/REST 응답에서는
CustomPage 기반 DTO (PageResponseV1)만 노출이렇게 하면:
분리된 공통 모듈에 커스텀 페이지 구현
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);
}
}
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);
}
}
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);
}
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()
);
}
}
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);
}
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);
}
}
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);
}
}