posts

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

May 11, 2025 updated May 11, 2025 auth

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

이 문서는 파일 목록 화면에서 자주 붙는 요구사항을 한 번에 메모해둔 버전입니다. 검색 조건, 정렬 기준, 압축 다운로드, 페이지네이션이 같이 들어가면 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);
}

이 설계는 다음과 같은 특징을 가집니다:

  1. 검색 기준(파일명, 내용)을 searchType 파라미터로 별도로 받습니다.
  2. 정렬 기준은 OnePaging 객체의 sortBysortDirection으로 처리합니다.
  3. 검색 초기화는 searchWordsearchType이 모두 null일 때 자동으로 이루어집니다.
  4. 페이징 처리는 기존의 OnePaging 객체를 사용합니다.
  5. 보안은 @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;
    }
}

이 코드의 주요 특징은 다음과 같습니다:

  1. 소유자 필터링: where.and(fileEntity.ownerId.eq(ownerId))로 소유자의 파일만 검색합니다.

  2. 삭제된 항목 제외: where.and(fileEntity.removed.isFalse())로 삭제되지 않은 파일만 검색합니다.

  3. 검색 기능: 파일명과 내용에서 검색어를 찾습니다. 필요에 따라 다른 필드도 추가할 수 있습니다.

  4. 정렬 유지: getOrderSpecifiers 메서드를 통해 Pageable 객체의 정렬 정보를 QueryDSL의 OrderSpecifier로 변환하여 적용합니다.

  5. 페이징: limitoffset을 사용하여 페이징을 구현합니다.

이 구현을 사용하려면 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);
}

이렇게 하면 사용자는 자신의 파일만 검색할 수 있고, 삭제된 파일은 보이지 않으며, 원하는 방식으로 정렬할 수 있습니다.