문제 발생
- 헥사고날 아키텍처와 DDD 기반으로 허브 서버를 개발하면서, 페이징 로직을 어떻게 가져가야 할지에 대한 고민이 생겼다.
- Spring Data JPA가 제공하는 Pageable, PageRequest, Page 같은 페이징 타입들은 매우 강력하고 편리하지만, 도메인 계층에 노출시키기에는 기술 의존성이 크다는 문제가 있다.
- 도메인 계층에 그대로 사용하기에는 기술 의존성이 크다.
문제 예시
- 헥사고날 아키텍처에서 도메인은 비즈니스 규칙만 알고 있어야 하고, 프레임워크나 기술에 종속되면 안되지만 페이징을 구현하려다 보니 자연스럽게 아래와 같은 코드가 만들어졌다.
- Hub Repository 인터페이스에서 Page를 사용하게 된다면 도메인 부분이지만 Jpa에 의존하는 문제가 됨
- 도메인은 특정 프레임워크에 종속되지 않아야 한다는 점에서 구조가 무너지기 때문
최초로 작성했던 Hub Repository 인터페이스 ( 도메인이 JPA에 잠식)
public interface HubRepository {
void save(Hub hub);
Page<Hub> findAll(Pageable pageable);
Page<Hub> findByNameOrFullAddressContaining(String nameOrFullAddress, Pageable pageable);
}
| 구분 | 문제점 |
| DDD 관점 | 도메인 계층이 프레임워크에 의존하게 됨 |
| 확장성 | JPA 기반 페이징 전략에 고정되어 버림 |
DDD 구조를 깨트리지 않기 위해 리팩토링
- Pageable / Page 같은 JPA 타입을 제거
- 순수 타입(int page, int size)으로 대체
도메인은 페이징이 어떻게 구현되는지 모르며 페이징 전략이 무엇이든(Offset / Slice / Cursor 등) 도메인은 **그저 “몇 번째 페이지를 가져와라”**만 표현
void save(Hub hub);
List<Hub> findAll(int page, int size);
List<Hub> findByNameOrFullAddressContaining(String nameOrFullAddress, int page, int size);
JpaRepsitory
public interface JpaHubRepository extends JpaRepository<Hub, UUID> {
List<Hub> findByNameAndFullAddressContaining(String name, String address, Pageable pageable);
List<Hub> findAllByDeletedAtIsNull(Pageable pageable);
}
Adapter (인프라스트럭처)
@RequiredArgsConstructor
public class HubRepositoryAdapter implements HubRepository {
private final JpaHubRepository jpaHubRepository;
@Override
public void save(Hub hub) {
jpaHubRepository.save(hub);
}
@Override
public List<Hub> findAll(int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by("name").ascending());
return jpaHubRepository.findAllByDeletedAtIsNull(pageable);
}
@Override
public List<Hub> findByNameOrFullAddressContaining(String nameOrFullAddress, int page, int size) {
Pageable pageable = PageRequest.of(page, size);
return jpaHubRepository.findByNameAndFullAddressContainingAndDeletedAtIsNotNull(nameOrFullAddress, nameOrFullAddress, pageable);
}
}
여기서 한 가지 의문이 들었다.
“실제로 Repository에 넘어가는 객체는 PageRequest인데, 그냥 Repository에서 PageRequest를 받으면 더 낫지 않나?”
List<Hub> findAllByDeletedAtIsNull(PageRequest pageRequest);
하지만 이 구조는 더 큰 문제를 만들어냈다.
- Repository가 PageRequest라는 특정 구현체에 고정됨
- Slice, Keyset, Cursor 기반 페이징으로 변경될 경우 메서드 시그니처부터 수정해야 함
- Controller에서 Pageable 자동 매핑을 사용할 경우 타입 불일치로 호출 불가
즉, PageRequest를 파라미터로 쓰는 순간 확장성이 완전히 사라지므로 Repository가 PageRequest에 “고정”되므로 구현체는 내부에서 생성해서 넘긴다.
1. find(PageRequest request)
2. find(Pageable pageable)
둘 다 지금은 똑같이 동작하지만 2번째 구조가 변화에 강한 구조다.
Pageable vs PageRequest 차이
| 개념 | 역할 |
| Pageable | 페이징 정보를 표현하는 인터페이스 |
| PageRequest | 가장 널리 쓰이는 Pageable의 구현체 |
Pageable은 추상 타입이고 PageRequest는 그 구현체이다.
List<Hub> findAllByDeletedAtIsNull(PageRequest pageRequest);
- 코드상에서는 문제가 없었고, 실제로 동작도 문제 없지만 PageRequest라는 특정 구현체에 종속됨으로써 확장성을 잃게된다.
- 이를 해결하기 위해 Repository는 Pageable, Adapter는 PageRequest.of(...)를 적용하는 방식 사용
결론
- Repository가 PageRequest를 받는다고 해서 성능이 좋아지거나 기능이 늘어나지 않는다.
- 하지만 Repository가 Pageable을 받는 순간, PageRequest · unpaged · Slice · keyset · cursor · custom Pageable 등 어떤 페이징 전략을 적용하더라도 Repository는 변경되지 않는다.
- 즉 지금은 PageRequest가 넘어가더라도, 파라미터 타입을 Pageable로 두는 것이 미래의 변경 비용을 극적으로 줄이는 선택이 된다.
어? 만들어보니 프론트가 남은 갯수를 List로 주면 못 보는데??? 커스텀을 해야 한다...
도메인 계층에서는 JPA의 Page를 쓰지 않기 위해 List<Hub>만 반환하도록 깔끔하게 정리했는데, 막상 화면에서 필요한 정보는 단순히 “현재 페이지의 목록”뿐만 아니라 전체 허브 개수가 몇 개인지, 마지막 페이지가 어디까지인지 또는 다음 페이지가 있는지 정보를 제공해줘야 한다.
- JPA의 Page를 그대로 노출하는 대신
- 도메인에 독립적인 커스텀 페이징 타입(예: PageResult<T> 같은)을 만들고
- 인프라 계층에서 Page → PageResult로 변환해 올려주는 구조
PageResult
import lombok.Getter;
import java.util.List;
@Getter
public class PageResult<T> {
private final List<T> items; // 현재 페이지의 데이터
private final int page; // 현재 페이지 번호 (0 기반)
private final int size; // 페이지 크기
private final long totalCount; // 전체 데이터 개수 (Slice 기반일 경우 -1 허용)
private final boolean hasNext; // 다음 페이지 존재 여부
private PageResult(List<T> items, int page, int size, long totalCount, boolean hasNext) {
this.items = items;
this.page = page;
this.size = size;
this.totalCount = totalCount;
this.hasNext = hasNext;
}
public static <T> PageResult<T> of(List<T> items, int page, int size, long totalCount) {
boolean hasNext = (long) (page + 1) * size < totalCount;
return new PageResult<>(items, page, size, totalCount, hasNext);
}
// slice 기반 페이징을 위한 팩토리 (totalCount 미알 수 있을 때)
public static <T> PageResult<T> sliceOf(List<T> items, int page, int size, boolean hasNext) {
return new PageResult<>(items, page, size, -1, hasNext);
}
}
사용 예시
public PageResult<Hub> findAll(int page, int size) {
Pageable pageable = PageRequest.of(page, size, Sort.by("name").ascending());
Page<Hub> result = jpaHubRepository.findAllByDeletedAtIsNull(pageable);
return PageResult.of(
result.getContent(),
result.getNumber(),
result.getSize(),
result.getTotalElements()
);
}'Spring Boot' 카테고리의 다른 글
| 🚚 허브 간 최단 경로 탐색 캐시 스탬피드 (Cache Stampede) 발생+해결 (1) | 2026.01.19 |
|---|---|
| 🚚 허브 간 최단 경로 탐색 알고리즘 구현 + Kakao Map Api 연동 (Spring Boot) (0) | 2025.12.06 |
| 특정 이벤트 발생 시 Discord 실시간 알림 시스템 (Spring Boot) (0) | 2025.11.17 |
| Redis로 Refresh Token 검증 (Spring Boot) (0) | 2025.10.11 |
| Pre-Signed URL 도입 (Spring Boot) (0) | 2025.08.31 |