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()
);
}
}
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);
}
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);
}
@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;
}
}
핵심 비즈니스 로직을 처리하는 서비스 계층
CoopostServiceImpl 실제 로직
// 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);
}

@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));
}
}