Pagination (2)

하루·2025년 10월 15일

JAVA

목록 보기
8/8

프로젝트에 Paging 추가 순서

1. SliceResponse: 페이징 응답 DTO

  • 페이징 결과를 전달하기 위한 전용 응답 DTO이다.
  • Slice객체를 SliceResponse로 쉽게 변환해주는 정적 팩토리 메서드입니다. 엔티티를 DTO로 매핑하는 로직을 재사용 가능하도록 함.
package com.gonggoo.gonggoo.coopost.dto.response;

import lombok.Value;
import org.springframework.data.domain.Slice;
import java.util.List;
import java.util.function.Function;

@Value
public class SliceResponse<T> {
    List<T> content;
    int size;
    int number; // 현재 페이지 번호 (Slice에서도 제공)
    boolean hasNext;

    // Slice 객체와 매핑 함수를 받아 DTO를 생성하는 정적 메서드
    public static <E, R> SliceResponse<R> of(Slice<E> slice, Function<E, R> mapper) {
        return new SliceResponse<>(
                slice.map(mapper).getContent(),
                slice.getSize(),
                slice.getNumber(),
                slice.hasNext()
        );
    }
}

2. CoopostRepository: JPA 리포지토리

  • 데이터베이스와 직접 상호작용하는 JPA 리포지토리
  • 커서 기반 페이징을 위한 핵심 조회 메서드 정의
package com.gonggoo.gonggoo.coopost.repository;
//(import문생략)

public interface CoopostRepository extends JpaRepository<Coopost, UUID>, CoopostRepositoryCustom {

        // 첫 페이지 조회를 위한 메서드 (커서 없음)
        Slice<Coopost> findByOrderByCreatedAtDescCoopostIdDesc(Pageable pageable);

        // @Query를 사용하여 복합 커서 WHERE 절 구현
        @Query("SELECT c FROM Coopost c " +
                "WHERE (c.createdAt < :createdAtCursor) OR " +
                "(c.createdAt = :createdAtCursor AND c.coopostId < :idCursor) " +
                "ORDER BY c.createdAt DESC, c.coopostId DESC")
        Slice<Coopost> findNextPage(@Param("createdAtCursor") LocalDateTime createdAtCursor,
                                    @Param("idCursor") UUID idCursor,
                                    Pageable pageable);
}

3. CoopostRepositoryCustom: QueryDSL용 인터페이스

  • 동적인 쿼리를 처리하기 위해서 QueryDSL을 사용할 메서드를 선언하는 인터페이스
  • JPA가 기본으로 제공하는 메서드 외, 구현하기 복잡한 로직을 별도로 분리해서 관리
public interface CoopostRepositoryCustom {
    /**
     * 게시글 검색 (키워드, 카테고리, 지역)
     */
    Slice<Coopost> search(String keyword, CoopostCategory category, String location,
                          LocalDateTime createdAtCursor, UUID idCursor, Pageable pageable);

    /**
     * 내가 쓴 게시글 조회
     */
    Slice<Coopost> findMyPosts(UUID authorId,
                               LocalDateTime createdAtCursor, UUID idCursor, Pageable pageable);

    /**
     * 인기 게시글 조회 (조회수, ID 기준)
     */
    Slice<Coopost> findPopular(Long viewCountCursor, UUID idCursor, Pageable pageable);
}

4. CoopostRepositoryImpl: QueryDSL 구현체

  • CoopostRepositoryCustom 인터페이스를 구현한 클래스
  • QueryDSL 사용해서 동적이고 복잡한 쿼리를 type-safe하게 작성함

@RequiredArgsConstructor
public class CoopostRepositoryImpl implements CoopostRepositoryCustom {

    private final JPAQueryFactory queryFactory;

    @Override
    public Slice<Coopost> search(String keyword, CoopostCategory category, String location,
                                 LocalDateTime createdAtCursor, UUID idCursor, Pageable pageable) {
        List<Coopost> content = queryFactory
                .selectFrom(coopost)
                .where(
                        // 커서 조건
                        cursorCondition(createdAtCursor, idCursor),
                        // 검색 조건
                        keywordContains(keyword),
                        categoryEq(category),
                        locationEq(location)
                )
                .orderBy(coopost.createdAt.desc(), coopost.coopostId.desc())
                .limit(pageable.getPageSize() + 1) // Slice 처리를 위해 1개 더 조회
                .fetch();

        return toSlice(content, pageable);
    }

    @Override
    public Slice<Coopost> findMyPosts(UUID authorId, LocalDateTime createdAtCursor, UUID idCursor, Pageable pageable) {
        List<Coopost> content = queryFactory
                .selectFrom(coopost)
                .where(
                        // 커서 조건
                        cursorCondition(createdAtCursor, idCursor),
                        // 작성자 ID 조건
                        authorIdEq(authorId)
                )
                .orderBy(coopost.createdAt.desc(), coopost.coopostId.desc())
                .limit(pageable.getPageSize() + 1)
                .fetch();

        return toSlice(content, pageable);
    }

    @Override
    public Slice<Coopost> findPopular(Long viewCountCursor, UUID idCursor, Pageable pageable) {
        List<Coopost> content = queryFactory
                .selectFrom(coopost)
                .where(
                        // 인기글 전용 커서 조건
                        popularCursorCondition(viewCountCursor, idCursor)
                )
                .orderBy(coopost.viewCount.desc(), coopost.coopostId.desc())
                .limit(pageable.getPageSize() + 1)
                .fetch();

        return toSlice(content, pageable);
    }

    /**
     * createdAt, coopostId 복합 커서 조건을 생성하는 메서드
     */
    private BooleanExpression cursorCondition(LocalDateTime createdAtCursor, UUID idCursor) {
        if (createdAtCursor == null || idCursor == null) {
            return null; // 첫 페이지 조회 시
        }
        return coopost.createdAt.lt(createdAtCursor)
                .or(coopost.createdAt.eq(createdAtCursor)
                        .and(coopost.coopostId.lt(idCursor)));
    }

    /**
     * viewCount, coopostId 복합 커서 조건을 생성하는 메서드 (인기글용)
     */
    private BooleanExpression popularCursorCondition(Long viewCountCursor, UUID idCursor) {
        if (viewCountCursor == null || idCursor == null) {
            return null; // 첫 페이지 조회 시
        }
        return coopost.viewCount.lt(viewCountCursor)
                .or(coopost.viewCount.eq(viewCountCursor)
                        .and(coopost.coopostId.lt(idCursor)));
    }

    /**
     * 조회 결과를 Slice 객체로 변환하는 헬퍼 메서드
     */
    private <T> Slice<T> toSlice(List<T> content, Pageable pageable) {
        boolean hasNext = false;
        if (content.size() > pageable.getPageSize()) {
            content.remove(pageable.getPageSize());
            hasNext = true;
        }
        return new SliceImpl<>(content, pageable, hasNext);
    }

    // --- 기타 검색 조건 메서드들 ---
    private BooleanExpression authorIdEq(UUID authorId) {
        return authorId != null ? coopost.authorId.eq(authorId) : null;
    }

    private BooleanExpression keywordContains(String keyword) {
        return keyword != null ? coopost.title.containsIgnoreCase(keyword).or(coopost.content.containsIgnoreCase(keyword)) : null;
    }

    private BooleanExpression categoryEq(CoopostCategory category) {
        return category != null ? coopost.category.eq(category) : null;
    }

    private BooleanExpression locationEq(String location) {
        return location != null ? coopost.location.eq(location) : null;
    }
}

5. CoopostService & CoopostServiceImpl: 비즈니스 로직

  • 핵심 비즈니스 로직을 처리하는 서비스 계층

  • CoopostServiceImpl 실제 로직

    • Controller로부터 Cursor+Pageable 객체 전달 받음
    • cursor 없으면 첫 페이지 조회 메서드 호출, 있으면 다음 페이지 조회 메서드 호출
    • Repository로부터 Slice를 SliceResponse(DTO)로 변환해서 Controller로 반환
// CoopostService (인터페이스)
```java
package com.gonggoo.gonggoo.coopost.service;


import com.gonggoo.gonggoo.coopost.domain.CoopostCategory;
import com.gonggoo.gonggoo.coopost.domain.CoopostStatus;
import com.gonggoo.gonggoo.coopost.dto.request.CoopostCreateRequest;
import com.gonggoo.gonggoo.coopost.dto.request.CoopostUpdateRequest;
import com.gonggoo.gonggoo.coopost.dto.response.CoopostResponse;
import com.gonggoo.gonggoo.coopost.dto.response.PageResponse;
import com.gonggoo.gonggoo.coopost.dto.response.SliceResponse;
import org.springframework.data.domain.Pageable;

import java.time.LocalDateTime;
import java.util.UUID;

public interface CoopostService {

    CoopostResponse create(CoopostCreateRequest req);


    CoopostResponse getById(UUID coopostId, boolean increaseView);

    CoopostResponse update(UUID coopostId, CoopostUpdateRequest req);

    void delete(UUID coopostId);
    CoopostResponse changeStatus(UUID coopostId, CoopostStatus status);


    // --- Slice 기반 커서 페이지네이션 API ---

    /**
     * 전체 게시글 조회
     */
    SliceResponse<CoopostResponse> getAll(LocalDateTime createdAtCursor, UUID idCursor, Pageable pageable);

    /**
     * 내가 쓴 게시글 조회
     */
    SliceResponse<CoopostResponse> getMyPosts(UUID authorId, LocalDateTime createdAtCursor, UUID idCursor, Pageable pageable);

    /**
     * 인기 게시글 조회
     */
    SliceResponse<CoopostResponse> getPopular(Long viewCountCursor, UUID idCursor, Pageable pageable);

    /**
     * 게시글 검색
     */
    SliceResponse<CoopostResponse> search(String keyword, CoopostCategory category, String location,
                                          LocalDateTime createdAtCursor, UUID idCursor, Pageable pageable);
}

//CoopostServiceImpl(구현체)
@Service
@RequiredArgsConstructor
@Transactional
public class CoopostServiceImpl implements CoopostService {

    private final CoopostRepository repo;

    // 공구글 생성
    @Override
    public CoopostResponse create(CoopostCreateRequest req) {
    Coopost entity = Coopost.builder()
            .authorId(Objects.requireNonNull(req.getAuthorId(), "authorId required"))
            .title(req.getTitle())
            .content(req.getContent())
            .status(CoopostStatus.OPEN)
            .pricePerUnit(req.getPricePerUnit())
            .minParticipants(req.getMinParticipants())
            .maxParticipants(req.getMaxParticipants())
            .currentParticipants(0)
            .category(Optional.ofNullable(req.getCategory()).orElse(CoopostCategory.ELSE))
            .location(req.getLocation())
            .deadlineAt(req.getDeadlineAt())
            .viewCount(0)
            .build();
        return CoopostResponse.from(repo.save(entity));
    }

    // 공구글 상세 조회 (조회수 +1) ID는 CoopostID를 말함
    @Override
    @Transactional(readOnly = true)
    public CoopostResponse getById(UUID coopostId, boolean increaseView) {
        Coopost e = repo.findById(coopostId)
                .orElseThrow(() -> new EntityNotFoundException("Coopost not found"));
        if (increaseView) {
            e.setViewCount(e.getViewCount() + 1);
        }
        return CoopostResponse.from(e);
    }

    //공구글 업데이트
    @Override
    public CoopostResponse update(UUID coopostId, CoopostUpdateRequest req) {
        Coopost e = repo.findById(coopostId)
                .orElseThrow(() -> new EntityNotFoundException("Coopost not found"));

        if (req.getTitle() != null) e.setTitle(req.getTitle());
        if (req.getContent() != null) e.setContent(req.getContent());
        if (req.getPricePerUnit() != null) e.setPricePerUnit(req.getPricePerUnit());
        if (req.getMinParticipants() != null) e.setMinParticipants(req.getMinParticipants());
        if (req.getMaxParticipants() != null) e.setMaxParticipants(req.getMaxParticipants());
        if (req.getCategory() != null) e.setCategory(req.getCategory());
        if (req.getLocation() != null) e.setLocation(req.getLocation());
        if (req.getDeadlineAt() != null) e.setDeadlineAt(req.getDeadlineAt());

        return CoopostResponse.from(e);
    }

    // 공구글 삭제 (Soft Delete)
    @Override
    public void delete(UUID coopostId) {
        Coopost coopost = repo.findById(coopostId)
                .orElseThrow(() -> new EntityNotFoundException("Coopost not found"));
        coopost.setStatus(CoopostStatus.DELETED);
        repo.save(coopost);
    }

    //공구글 상태 변경
    @Override
    public CoopostResponse changeStatus(UUID coopostId, CoopostStatus status) {
        Coopost e = repo.findById(coopostId)
                .orElseThrow(() -> new EntityNotFoundException("Coopost not found"));
        e.setStatus(status);
        return CoopostResponse.from(e);
    }

    // --- Slice 기반 커서 페이지네이션 API ---

    //전체 공구글 조회
    @Override
    @Transactional(readOnly = true)
    public SliceResponse<CoopostResponse> getAll(LocalDateTime createdAtCursor, UUID idCursor, Pageable pageable) {
        Slice<Coopost> slice;
        if (createdAtCursor == null || idCursor == null) {
            slice = repo.findByOrderByCreatedAtDescCoopostIdDesc(pageable);
        } else {
            slice = repo.findNextPage(createdAtCursor, idCursor, pageable);
        }
        return SliceResponse.of(slice, CoopostResponse::from);
    }

    // 내가 쓴 공구글 조회
    @Override
    @Transactional(readOnly = true)
    public SliceResponse<CoopostResponse> getMyPosts(UUID authorId, LocalDateTime createdAtCursor, UUID idCursor, Pageable pageable) {
        Slice<Coopost> slice = repo.findMyPosts(authorId, createdAtCursor, idCursor, pageable);
        return SliceResponse.of(slice, CoopostResponse::from);
    }

    // 인기 공구글 조회
    @Override
    @Transactional(readOnly = true)
    public SliceResponse<CoopostResponse> getPopular(Long viewCountCursor, UUID idCursor, Pageable pageable) {
        Slice<Coopost> slice = repo.findPopular(viewCountCursor, idCursor, pageable);
        return SliceResponse.of(slice, CoopostResponse::from);
    }

    // 공구글 검색
    @Override
    @Transactional(readOnly = true)
    public SliceResponse<CoopostResponse> search(String keyword, CoopostCategory category, String location,
                                                 LocalDateTime createdAtCursor, UUID idCursor, Pageable pageable) {
        Slice<Coopost> slice = repo.search(keyword, category, location, createdAtCursor, idCursor, pageable);
        return SliceResponse.of(slice, CoopostResponse::from);
    }

6. CoopostController

  • HTTP 요청을 받아 해당 요청을 처리할 서비스 메서드 호출하고, 결과를 HTTP응답을 반환하는 API계층
@RestController
@RequestMapping("/api/coopost/v1")
@RequiredArgsConstructor
public class CoopostController {

    private final CoopostService service;

    /**
     * 공구글 생성 (201 Created)
     */
    @PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    public ApiResponse<CoopostResponse> create(@Valid @RequestBody CoopostCreateRequest req) {
        // ApiResponse의 success(status, message, data) 오버로딩 메서드 사용
        return ApiResponse.success(HttpStatus.CREATED, "공구글이 성공적으로 생성되었습니다.", service.create(req));
    }

    /**
     * 공구글 상세 조회 (200 OK)
     */
    @GetMapping("/{coopostId}")
    public ApiResponse<CoopostResponse> getById(@PathVariable UUID coopostId) {
        // ApiResponse.success(data) -> "OK" 메시지와 함께 200 상태 코드로 응답
        return ApiResponse.success(service.getById(coopostId, true));
    }

    /**
     * 공구글 수정 (200 OK)
     */
    @PatchMapping("/{coopostId}")
    public ApiResponse<CoopostResponse> update(@PathVariable UUID coopostId,
                                               @Valid @RequestBody CoopostUpdateRequest req) {
        return ApiResponse.success(service.update(coopostId, req));
    }

    /**
     * 공구글 삭제 (204 No Content)
     * 204 응답은 본문(body)이 없으므로 ApiResponse를 사용하지 않는 것이 표준적입니다.
     */
    @DeleteMapping("/{coopostId}")
    @ResponseStatus(HttpStatus.NO_CONTENT)
    public void delete(@PathVariable UUID coopostId) {
        service.delete(coopostId);
    }

    /**
     * 공구글 상태 변경 (200 OK)
     */
    @PatchMapping("/{coopostId}/status")
    public ApiResponse<CoopostResponse> changeStatus(@PathVariable UUID coopostId,
                                                     @RequestBody CoopostStatusUpdateRequest req) {
        return ApiResponse.success(service.changeStatus(coopostId, req.getStatus()));
    }
// --- Slice 기반 커서 페이지네이션 엔드포인트 ---

    /**
     * 공구글 전체 조회 (200 OK)
     */
    @GetMapping
    public ApiResponse<SliceResponse<CoopostResponse>> getAll(
            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime createdAtCursor,
            @RequestParam(required = false) UUID idCursor,
            @RequestParam(defaultValue = "20") int size
    ) {
        Pageable pageable = PageRequest.of(0, size, Sort.by(
                Sort.Order.desc("createdAt"),
                Sort.Order.desc("coopostId")
        ));
        return ApiResponse.success(service.getAll(createdAtCursor, idCursor, pageable));
    }

    /**
     * 내가 쓴 공구글 조회 (200 OK)
     */
    @GetMapping("/myposts")
    public ApiResponse<SliceResponse<CoopostResponse>> myPosts(
            @RequestParam UUID authorId, // JWT 적용 후 SecurityContext에서 추출
            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime createdAtCursor,
            @RequestParam(required = false) UUID idCursor,
            @RequestParam(defaultValue = "20") int size
    ) {
        Pageable pageable = PageRequest.of(0, size, Sort.by(
                Sort.Order.desc("createdAt"),
                Sort.Order.desc("coopostId")
        ));
        return ApiResponse.success(service.getMyPosts(authorId, createdAtCursor, idCursor, pageable));
    }

    /**
     * 인기 공구글 조회 (200 OK)
     */
    @GetMapping("/popular")
    public ApiResponse<SliceResponse<CoopostResponse>> popular(
            @RequestParam(required = false) Long viewCountCursor,
            @RequestParam(required = false) UUID idCursor,
            @RequestParam(defaultValue = "10") int size
    ) {
        Pageable pageable = PageRequest.of(0, size, Sort.by(
                Sort.Order.desc("viewCount"),
                Sort.Order.desc("coopostId")
        ));
        return ApiResponse.success(service.getPopular(viewCountCursor, idCursor, pageable));
    }

    /**
     * 공구글 검색 (200 OK)
     */
    @GetMapping("/search")
    public ApiResponse<SliceResponse<CoopostResponse>> search(
            @RequestParam(required = false) String keyword,
            @RequestParam(required = false) CoopostCategory category,
            @RequestParam(required = false) String location,
            @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime createdAtCursor,
            @RequestParam(required = false) UUID idCursor,
            @RequestParam(defaultValue = "20") int size
    ) {
        Pageable pageable = PageRequest.of(0, size, Sort.by(
                Sort.Order.desc("createdAt"),
                Sort.Order.desc("coopostId")
        ));
        return ApiResponse.success(service.search(keyword, category, location, createdAtCursor, idCursor, pageable));
    }




}

0개의 댓글