파일 검색과 페이지네이션 설계 메모

이 문서는 파일 목록 화면에서 자주 붙는 요구사항을 한 번에 메모해둔 버전입니다. 검색 조건, 정렬 기준, 압축 다운로드, 페이지네이션이 같이 들어가면 API shape가 금방 지저분해지거든요.
검색 기준
- 파일명
- 내용
검색 초기화
정렬 기준
- 파일명
- 등록일
- 유형
압축 파일 다운로드
페이징 15개
파일 업로드
파일 삭제
package kr.co.visibleray.prop.infrastructure.adapter.inbound.rest.controller.dto.request;
import io.swagger.v3.oas.annotations.Parameter;
import lombok.*;
import lombok.experimental.FieldDefaults;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import java.util.List;
import java.util.Optional;
@Getter
@Setter
@NoArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE)
public class OnePaging {
@Parameter(description = "페이지 번호", example = "1")
int page = 1;
@Parameter(description = "페이지 사이즈", example = "10")
int size = 10;
@Parameter(description = "정렬 기준", example = "fileName,asc")
String sortBy = "createdDateTime";
@Parameter(description = "정렬 방향", example = "desc")
String sortDirection = "desc";
public Pageable toPageable() {
Sort sort = Sort.by(Sort.Direction.fromString(sortDirection), sortBy);
return PageRequest.of(page - 1, size, sort);
}
public static Pageable toPageable(OnePaging paging) {
OnePaging onePaging = Optional.ofNullable(paging).orElse(new OnePaging());
return onePaging.toPageable();
}
}
파일 목록 조회 API를 다음과 같이 수정
@Operation(summary = "파일 목록 조회", description = "파일 목록 조회 API")
@GetMapping
public BaseResponse<PageResult<FileSummary>> searchFiles(
@Parameter(hidden = true) @AuthenticationPrincipal Member member,
@RequestParam(required = false) @Parameter(description = "검색어") String searchWord,
@RequestParam(required = false) @Parameter(description = "검색 기준 (fileName 또는 content)") String searchType,
@ParameterObject OnePaging paging
) {
FileSearchCriteria criteria = new FileSearchCriteria(searchWord, searchType, paging);
PageResult<FileSummary> result = fileService.searchFiles(member, criteria);
return BaseResponse.of(result);
}
`FileSearchCriteria
@Getter
@AllArgsConstructor
public class FileSearchCriteria {
private String searchWord;
private String searchType;
private OnePaging paging;
public boolean isSearchInitialized() {
return searchWord == null && searchType == null;
}
}
FileService
public PageResult<FileSummary> searchFiles(Member member, FileSearchCriteria criteria) {
Pageable pageable = OnePaging.toPageable(criteria.getPaging());
if (criteria.isSearchInitialized()) {
return fileRepository.findAll(pageable);
}
Specification<File> spec = Specification.where(null);
if (criteria.getSearchWord() != null) {
if ("fileName".equals(criteria.getSearchType())) {
spec = spec.and((root, query, cb) ->
cb.like(root.get("fileName"), "%" + criteria.getSearchWord() + "%"));
} else if ("content".equals(criteria.getSearchType())) {
spec = spec.and((root, query, cb) ->
cb.like(root.get("content"), "%" + criteria.getSearchWord() + "%"));
}
}
return fileRepository.findAll(spec, pageable);
}
이 설계는 다음과 같은 특징을 가집니다:
- 검색 기준(파일명, 내용)을
searchType파라미터로 별도로 받습니다. - 정렬 기준은
OnePaging객체의sortBy와sortDirection으로 처리합니다. - 검색 초기화는
searchWord와searchType이 모두 null일 때 자동으로 이루어집니다. - 페이징 처리는 기존의
OnePaging객체를 사용합니다. - 보안은
@AuthenticationPrincipal을 통해 처리되며, 추가적인 접근 권한 체크는 하지 않습니다.
이 설계를 시각화하면 다음과 같습니다:
sequenceDiagram
participant Client
participant Controller
participant FileService
participant FileRepository
participant Database
Client ->> Controller: GET /files?searchWord=&searchType=&page=&size=&sortBy=&sortDirection=
Controller ->> FileService: searchFiles(member, criteria)
FileService ->> FileRepository: findAll(spec, pageable)
FileRepository ->> Database: SQL Query
Database -->> FileRepository: Results
FileRepository -->> FileService: Page<File>
FileService -->> Controller: PageResult<FileSummary>
Controller -->> Client: BaseResponse<PageResult<FileSummary>>
이 설계는 요구사항을 모두 충족하면서도 확장성과 유지보수성을 고려한 방식입니다. 필요에 따라 추가적인 수정이나 개선이 가능합니다.
package kr.co.visibleray.prop.infrastructure.adapter.outbound.persistence.file;
import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.types.Order;
import com.querydsl.core.types.OrderSpecifier;
import com.querydsl.core.types.dsl.PathBuilder;
import com.querydsl.jpa.impl.JPAQueryFactory;
import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import lombok.experimental.FieldDefaults;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.List;
import static kr.co.visibleray.prop.infrastructure.adapter.outbound.persistence.file.QFileEntity.fileEntity;
@Repository
@RequiredArgsConstructor
@FieldDefaults(level = AccessLevel.PRIVATE, makeFinal = true)
public class FileRepositoryImpl implements FileRepositoryCustom {
JPAQueryFactory jpaQueryFactory;
@Override
public Page<FileEntity> search(Long ownerId, String searchWord, Pageable pageable) {
BooleanBuilder where = new BooleanBuilder();
where.and(fileEntity.ownerId.eq(ownerId));
where.and(fileEntity.removed.isFalse());
if (StringUtils.hasText(searchWord)) {
where.and(fileEntity.fileName.containsIgnoreCase(searchWord)
.or(fileEntity.content.containsIgnoreCase(searchWord)));
}
List<OrderSpecifier> orders = getOrderSpecifiers(pageable.getSort());
List<FileEntity> files = jpaQueryFactory.selectFrom(fileEntity)
.where(where)
.orderBy(orders.toArray(new OrderSpecifier[0]))
.limit(pageable.getPageSize())
.offset(pageable.getOffset())
.fetch();
Long totalElements = jpaQueryFactory.select(fileEntity.count())
.from(fileEntity)
.where(where)
.fetchOne();
return new PageImpl<>(files, pageable, totalElements != null ? totalElements : 0L);
}
private List<OrderSpecifier> getOrderSpecifiers(Sort sort) {
List<OrderSpecifier> orders = new ArrayList<>();
sort.stream().forEach(order -> {
Order direction = order.isAscending() ? Order.ASC : Order.DESC;
String property = order.getProperty();
PathBuilder orderByExpression = new PathBuilder(FileEntity.class, "fileEntity");
orders.add(new OrderSpecifier(direction, orderByExpression.get(property)));
});
return orders;
}
}
이 코드의 주요 특징은 다음과 같습니다:
소유자 필터링:
where.and(fileEntity.ownerId.eq(ownerId))로 소유자의 파일만 검색합니다.삭제된 항목 제외:
where.and(fileEntity.removed.isFalse())로 삭제되지 않은 파일만 검색합니다.검색 기능: 파일명과 내용에서 검색어를 찾습니다. 필요에 따라 다른 필드도 추가할 수 있습니다.
정렬 유지:
getOrderSpecifiers메서드를 통해Pageable객체의 정렬 정보를 QueryDSL의OrderSpecifier로 변환하여 적용합니다.페이징:
limit와offset을 사용하여 페이징을 구현합니다.
이 구현을 사용하려면 FileRepositoryCustom 인터페이스도 다음과 같이 수정해야 합니다:
public interface FileRepositoryCustom {
Page<FileEntity> search(Long ownerId, String searchWord, Pageable pageable);
}
그리고 FileUseCaseImpl에서 이 메서드를 호출할 때 현재 로그인한 사용자의 ID를 ownerId로 전달해야 합니다:
@Override
public Page<File> searchFiles(Member member, String searchWord, Pageable paging) {
Page<FileEntity> fileEntities = fileRepository.search(member.getId(), searchWord, paging);
return fileEntities.map(FileFactory::toDomain);
}
이렇게 하면 사용자는 자신의 파일만 검색할 수 있고, 삭제된 파일은 보이지 않으며, 원하는 방식으로 정렬할 수 있습니다.