-
Notifications
You must be signed in to change notification settings - Fork 0
Feat/34 filter #37
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Feat/34 filter #37
Conversation
[롬복 (Lombok) Java 21 + query dsl 조합시 롬복 오류로 인해 수정하였습니다.]
[- 향후 정렬(sort) 옵션 확장을 위한 코드 추가 - popular: 조회수순 - mostReviewed: 리뷰순 - latest: 최신순 - 향후 검색(q) 기능 확장을 위한 스텁 코드 추가 - name / summary / longDescription containsIgnoreCase 조건]
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
이 PR은 API 목록 조회 기능의 통합 엔드포인트(/apis)를 구현하여 Explore(전체 API 조회), 카테고리 필터링, 다중 필터 조건 조회를 단일 API로 제공합니다. QueryDSL을 활용한 동적 쿼리 구성을 통해 확장 가능한 필터링 구조를 구축했습니다.
Changes:
- QueryDSL 의존성 추가 및 관련 설정 구성
- API 목록 조회를 위한 DTO, Repository, Service, Controller 구현
- 제공사, 인증 방식, 가격 유형, 최소 평점, 카테고리를 포함한 동적 필터링 로직 구현
Reviewed changes
Copilot reviewed 4 out of 5 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| build.gradle | QueryDSL 의존성 추가 및 Lombok 버전 명시, QueryDSL Q클래스 생성 설정 추가 |
| ApiDTO.java | API 목록 미리보기를 위한 ApiPreview record DTO 정의 |
| ApiRepository.java | Api 엔티티를 위한 기본 JpaRepository 인터페이스 생성 |
| ApiQueryService.java | QueryDSL을 활용한 동적 필터링 및 페이징 조회 로직 구현 |
| ApiController.java | GET /apis 엔드포인트를 통한 통합 API 조회 컨트롤러 구현 |
| public Page<ApiDTO.ApiPreview> searchApis( | ||
| int page, | ||
| Integer size, | ||
| Long categoryId, | ||
| // String q, | ||
| // String sort, | ||
| ProviderCompany providerCompany, | ||
| AuthType authType, | ||
| PricingType pricingType, | ||
| BigDecimal minRating | ||
| ) { | ||
|
|
||
| QApi api = QApi.api; | ||
| QApiReview review = QApiReview.apiReview; | ||
| QApiCategoriesMap map = QApiCategoriesMap.apiCategoriesMap; | ||
|
|
||
| JPAQueryFactory queryFactory = new JPAQueryFactory(entityManager); | ||
| BooleanBuilder builder = new BooleanBuilder(); | ||
|
|
||
| // 필터 조건 | ||
| if (providerCompany != null) builder.and(api.providerCompany.eq(providerCompany)); | ||
| if (authType != null) builder.and(api.authType.eq(authType)); | ||
| if (pricingType != null) builder.and(api.pricingType.eq(pricingType)); | ||
| if (minRating != null) builder.and(api.avgRating.goe(minRating)); | ||
|
|
||
| // 카테고리 필터 (exists 서브쿼리) | ||
| if (categoryId != null) { | ||
| builder.and( | ||
| JPAExpressions.selectOne() | ||
| .from(map) | ||
| .where( | ||
| map.api.id.eq(api.id) | ||
| .and(map.category.id.eq(categoryId)) | ||
| ) | ||
| .exists() | ||
| ); | ||
| } | ||
|
|
||
| // 검색 조건 | ||
| // if (q != null && !q.isBlank()) { | ||
| // builder.and( | ||
| // api.name.containsIgnoreCase(q) | ||
| // .or(api.summary.containsIgnoreCase(q)) | ||
| // .or(api.longDescription.containsIgnoreCase(q)) | ||
| // ); | ||
| // } | ||
|
|
||
| int pageSize = (size != null) ? size : 16; | ||
| Pageable pageable = PageRequest.of(page, pageSize); | ||
|
|
||
| // reviewCount 서브쿼리 | ||
| var reviewCountSubQuery = | ||
| JPAExpressions.select(review.count()) | ||
| .from(review) | ||
| .where(review.api.id.eq(api.id)); | ||
|
|
||
| // 정렬 옵션 | ||
| // if (sort == null || sort.isBlank()) { | ||
| // sort = "latest"; | ||
| // } | ||
|
|
||
| // var orderSpecifier = switch (sort) { | ||
| // case "popular" -> api.viewCounts.desc(); | ||
| // case "mostReviewed" -> reviewCountSubQuery.desc(); | ||
| // case "latest" -> api.createdAt.desc(); | ||
| // default -> api.createdAt.desc(); // 잘못된 값 방어 | ||
| // }; | ||
|
|
||
| // 목록 조회 | ||
| List<ApiDTO.ApiPreview> content = queryFactory | ||
| .select(Projections.constructor( | ||
| ApiDTO.ApiPreview.class, | ||
| api.id, | ||
| api.name, | ||
| api.summary, | ||
| api.avgRating, | ||
| reviewCountSubQuery, | ||
| api.viewCounts, | ||
| api.pricingType, | ||
| api.authType, | ||
| api.providerCompany | ||
| )) | ||
| .from(api) | ||
| .where(builder) | ||
| .orderBy(api.createdAt.desc()) // 정렬 적용 | ||
| // .orderBy(orderSpecifier) // 기존 createdAt.desc() → 동적 정렬 | ||
| .offset(pageable.getOffset()) | ||
| .limit(pageable.getPageSize()) | ||
| .fetch(); | ||
|
|
||
| // total count | ||
| Long total = queryFactory | ||
| .select(api.count()) | ||
| .from(api) | ||
| .where(builder) | ||
| .fetchOne(); | ||
|
|
||
| long totalCount = (total != null) ? total : 0L; | ||
|
|
||
| // null 안전처리 | ||
| content = content.stream() | ||
| .map(p -> new ApiDTO.ApiPreview( | ||
| p.apiId(), | ||
| p.name(), | ||
| p.summary(), | ||
| p.avgRating() != null ? p.avgRating() : BigDecimal.ZERO, | ||
| p.reviewCount() != null ? p.reviewCount() : 0L, | ||
| p.viewCounts() != null ? p.viewCounts() : 0L, | ||
| p.pricingType(), | ||
| p.authType(), | ||
| p.providerCompany() | ||
| )) | ||
| .toList(); | ||
|
|
||
| return new PageImpl<>(content, pageable, totalCount); | ||
| } | ||
| } No newline at end of file |
Copilot
AI
Jan 23, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
새로 추가된 ApiQueryService와 ApiController에 대한 테스트 코드가 없습니다. 현재 테스트 디렉토리에는 ApiwikiBackendApplicationTests만 존재합니다.
주요 기능에 대한 테스트가 필요합니다:
- 필터 조건(providerCompany, authType, pricingType, minRating) 각각의 동작
- 카테고리 필터링 동작
- 페이징 처리
- null 값 처리
- 빈 결과 처리
최소한 단위 테스트(Service layer)와 통합 테스트(Controller layer)를 추가하는 것을 권장합니다.
|
|
||
| import java.math.BigDecimal; | ||
|
|
||
| @RestController | ||
| @RequiredArgsConstructor | ||
| @RequestMapping("/apis") | ||
| public class ApiController { | ||
|
|
||
| private final ApiQueryService apiQueryService; | ||
|
|
||
| @GetMapping | ||
| public ApiResponse<PageResponseDTO<ApiDTO.ApiPreview>> searchApis( | ||
| @RequestParam int page, | ||
| @RequestParam(required = false) Integer size, | ||
| @RequestParam(required = false) Long categoryId, | ||
| // @RequestParam(required = false) String q, // 검색어 | ||
| // @RequestParam(required = false, defaultValue = "latest") String sort, // 정렬 옵션 | ||
| @RequestParam(required = false, name = "providers") ProviderCompany providerCompany, | ||
| @RequestParam(required = false, name = "authTypes") AuthType authType, | ||
| @RequestParam(required = false, name = "pricingTypes") PricingType pricingType, | ||
| @RequestParam(required = false) BigDecimal minRating |
Copilot
AI
Jan 23, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
page 파라미터에 대한 입력 검증(validation)이 없습니다. 음수값이나 비정상적인 값이 전달될 경우 예기치 않은 동작이나 에러가 발생할 수 있습니다.
@RequestParam 파라미터에 대한 검증 추가를 권장합니다:
- @min(0) 또는 @PositiveOrZero를 page에 추가
- @positive를 size에 추가 (null이 아닌 경우)
- @positive를 minRating에 추가하고 @max(5.0) 같은 최대값 제한도 고려
컨트롤러 클래스에 @validated 어노테이션도 추가해야 합니다.
| import java.math.BigDecimal; | |
| @RestController | |
| @RequiredArgsConstructor | |
| @RequestMapping("/apis") | |
| public class ApiController { | |
| private final ApiQueryService apiQueryService; | |
| @GetMapping | |
| public ApiResponse<PageResponseDTO<ApiDTO.ApiPreview>> searchApis( | |
| @RequestParam int page, | |
| @RequestParam(required = false) Integer size, | |
| @RequestParam(required = false) Long categoryId, | |
| // @RequestParam(required = false) String q, // 검색어 | |
| // @RequestParam(required = false, defaultValue = "latest") String sort, // 정렬 옵션 | |
| @RequestParam(required = false, name = "providers") ProviderCompany providerCompany, | |
| @RequestParam(required = false, name = "authTypes") AuthType authType, | |
| @RequestParam(required = false, name = "pricingTypes") PricingType pricingType, | |
| @RequestParam(required = false) BigDecimal minRating | |
| import org.springframework.validation.annotation.Validated; | |
| import jakarta.validation.constraints.DecimalMax; | |
| import jakarta.validation.constraints.Positive; | |
| import jakarta.validation.constraints.PositiveOrZero; | |
| import java.math.BigDecimal; | |
| @RestController | |
| @RequiredArgsConstructor | |
| @RequestMapping("/apis") | |
| @Validated | |
| public class ApiController { | |
| private final ApiQueryService apiQueryService; | |
| @GetMapping | |
| public ApiResponse<PageResponseDTO<ApiDTO.ApiPreview>> searchApis( | |
| @RequestParam @PositiveOrZero int page, | |
| @RequestParam(required = false) @Positive Integer size, | |
| @RequestParam(required = false) Long categoryId, | |
| // @RequestParam(required = false) String q, // 검색어 | |
| // @RequestParam(required = false, defaultValue = "latest") String sort, // 정렬 옵션 | |
| @RequestParam(required = false, name = "providers") ProviderCompany providerCompany, | |
| @RequestParam(required = false, name = "authTypes") AuthType authType, | |
| @RequestParam(required = false, name = "pricingTypes") PricingType pricingType, | |
| @RequestParam(required = false) @Positive @DecimalMax("5.0") BigDecimal minRating |
| @RestController | ||
| @RequiredArgsConstructor | ||
| @RequestMapping("/apis") | ||
| public class ApiController { | ||
|
|
||
| private final ApiQueryService apiQueryService; | ||
|
|
||
| @GetMapping | ||
| public ApiResponse<PageResponseDTO<ApiDTO.ApiPreview>> searchApis( | ||
| @RequestParam int page, | ||
| @RequestParam(required = false) Integer size, | ||
| @RequestParam(required = false) Long categoryId, | ||
| // @RequestParam(required = false) String q, // 검색어 | ||
| // @RequestParam(required = false, defaultValue = "latest") String sort, // 정렬 옵션 | ||
| @RequestParam(required = false, name = "providers") ProviderCompany providerCompany, | ||
| @RequestParam(required = false, name = "authTypes") AuthType authType, | ||
| @RequestParam(required = false, name = "pricingTypes") PricingType pricingType, | ||
| @RequestParam(required = false) BigDecimal minRating | ||
| ) { | ||
|
|
||
| Page<ApiDTO.ApiPreview> resultPage = apiQueryService.searchApis( | ||
| page, | ||
| size, | ||
| categoryId, | ||
| // q, | ||
| // sort, | ||
| providerCompany, | ||
| authType, | ||
| pricingType, | ||
| minRating | ||
| ); | ||
|
|
||
| return ApiResponse.onPageSuccess(GeneralSuccessCode.OK, resultPage); | ||
| } | ||
| } No newline at end of file |
Copilot
AI
Jan 23, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
API 문서화가 누락되었습니다. 코드베이스의 UserController는 UserControllerDocs 인터페이스를 구현하여 Swagger @operation 어노테이션으로 API를 문서화하고 있습니다 (src/main/java/com/umc/apiwiki/domain/user/controller/UserControllerDocs.java 참조).
동일한 패턴으로 ApiControllerDocs 인터페이스를 생성하여 다음 내용을 문서화하는 것을 권장합니다:
- API 엔드포인트의 목적과 동작
- 쿼리 파라미터 설명 (page, size, categoryId, providers, authTypes, pricingTypes, minRating)
- 응답 예시
- 필터 조합 방식 설명
이는 코드베이스의 일관된 문서화 패턴을 따르는 것입니다.
| // total count | ||
| Long total = queryFactory | ||
| .select(api.count()) | ||
| .from(api) | ||
| .where(builder) | ||
| .fetchOne(); |
Copilot
AI
Jan 23, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
두 번의 쿼리(content 조회 + total count 조회)가 같은 where 조건을 사용하고 있어 비효율적일 수 있습니다. 카테고리 필터의 경우 EXISTS 서브쿼리를 사용하므로 성능 영향이 있을 수 있습니다.
대안으로, total count가 필요 없는 경우(무한 스크롤 등)를 고려하여 선택적으로 count 쿼리를 실행하거나, Spring Data JPA의 Pageable과 함께 사용할 수 있는 최적화 방법을 고려해보세요. 또한 카테고리 필터는 JOIN으로 변경하는 것이 더 효율적일 수 있습니다.
| // null 안전처리 | ||
| content = content.stream() | ||
| .map(p -> new ApiDTO.ApiPreview( | ||
| p.apiId(), | ||
| p.name(), | ||
| p.summary(), | ||
| p.avgRating() != null ? p.avgRating() : BigDecimal.ZERO, | ||
| p.reviewCount() != null ? p.reviewCount() : 0L, | ||
| p.viewCounts() != null ? p.viewCounts() : 0L, | ||
| p.pricingType(), | ||
| p.authType(), | ||
| p.providerCompany() | ||
| )) | ||
| .toList(); |
Copilot
AI
Jan 23, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
null 안전 처리를 위해 모든 content를 스트림으로 재생성하는 로직은 비효율적입니다. QueryDSL의 Projections.constructor는 이미 값을 매핑하므로, null 값이 있다면 쿼리 레벨에서 COALESCE 함수를 사용하여 기본값을 설정하는 것이 더 효율적입니다.
예시:
api.avgRating.coalesce(BigDecimal.ZERO),
reviewCountSubQuery.coalesce(0L),
api.viewCounts.coalesce(0L)이렇게 하면 131-143라인의 스트림 변환 로직을 제거할 수 있습니다.
| import org.springframework.stereotype.Service; | ||
|
|
||
| import java.math.BigDecimal; | ||
| import java.util.List; | ||
|
|
||
| @Service | ||
| @RequiredArgsConstructor |
Copilot
AI
Jan 23, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
읽기 전용 쿼리 서비스에 @transactional(readOnly = true)를 추가하는 것을 권장합니다. ApiQueryService는 조회만 수행하므로, readOnly 트랜잭션을 사용하면 성능 최적화와 데이터 일관성 보장에 도움이 됩니다.
코드베이스의 UserCommandService는 명령(Command) 작업이므로 @transactional을 사용하지만, 조회(Query) 서비스는 readOnly를 명시하는 것이 best practice입니다.
추가 권장:
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ApiQueryService {| import org.springframework.stereotype.Service; | |
| import java.math.BigDecimal; | |
| import java.util.List; | |
| @Service | |
| @RequiredArgsConstructor | |
| import org.springframework.stereotype.Service; | |
| import org.springframework.transaction.annotation.Transactional; | |
| import java.math.BigDecimal; | |
| import java.util.List; | |
| @Service | |
| @RequiredArgsConstructor | |
| @Transactional(readOnly = true) |
| QApiReview review = QApiReview.apiReview; | ||
| QApiCategoriesMap map = QApiCategoriesMap.apiCategoriesMap; | ||
|
|
||
| JPAQueryFactory queryFactory = new JPAQueryFactory(entityManager); |
Copilot
AI
Jan 23, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
JPAQueryFactory를 매 요청마다 새로 생성하는 것은 비효율적입니다. JPAQueryFactory는 스레드 안전하며 재사용이 가능하므로, 빈으로 등록하거나 필드 레벨에서 한 번만 초기화하는 것이 권장됩니다. 현재 방식은 searchApis 메서드가 호출될 때마다 새로운 인스턴스를 생성하므로 불필요한 오버헤드가 발생합니다.
해결 방법:
- 생성자에서 초기화:
private final JPAQueryFactory queryFactory;
public ApiQueryService(EntityManager entityManager) {
this.queryFactory = new JPAQueryFactory(entityManager);
}- 또는 Configuration 클래스에서 빈으로 등록
|
|
||
| @GetMapping | ||
| public ApiResponse<PageResponseDTO<ApiDTO.ApiPreview>> searchApis( | ||
| @RequestParam int page, |
Copilot
AI
Jan 23, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
페이지 번호 처리에 일관성 문제가 있습니다. 컨트롤러에서 받는 page 파라미터가 0-based인지 1-based인지 명확하지 않습니다. PageResponseDTO에서는 currentPage = page.getNumber() + 1로 1-based로 변환하는데, 컨트롤러에서는 page를 그대로 사용합니다.
PR 설명의 테스트 예시에서 page=0을 사용하고 있어 0-based로 보이지만, 응답에서 currentPage: 1로 반환되는 것을 보면 혼란을 줄 수 있습니다. API 문서나 주석으로 명확히 명시하거나, @min(0) 또는 @min(1) 같은 validation 어노테이션을 추가하는 것이 좋습니다.
| // String q, | ||
| // String sort, | ||
| ProviderCompany providerCompany, | ||
| AuthType authType, | ||
| PricingType pricingType, | ||
| BigDecimal minRating | ||
| ) { | ||
|
|
||
| QApi api = QApi.api; | ||
| QApiReview review = QApiReview.apiReview; | ||
| QApiCategoriesMap map = QApiCategoriesMap.apiCategoriesMap; | ||
|
|
||
| JPAQueryFactory queryFactory = new JPAQueryFactory(entityManager); | ||
| BooleanBuilder builder = new BooleanBuilder(); | ||
|
|
||
| // 필터 조건 | ||
| if (providerCompany != null) builder.and(api.providerCompany.eq(providerCompany)); | ||
| if (authType != null) builder.and(api.authType.eq(authType)); | ||
| if (pricingType != null) builder.and(api.pricingType.eq(pricingType)); | ||
| if (minRating != null) builder.and(api.avgRating.goe(minRating)); | ||
|
|
||
| // 카테고리 필터 (exists 서브쿼리) | ||
| if (categoryId != null) { | ||
| builder.and( | ||
| JPAExpressions.selectOne() | ||
| .from(map) | ||
| .where( | ||
| map.api.id.eq(api.id) | ||
| .and(map.category.id.eq(categoryId)) | ||
| ) | ||
| .exists() | ||
| ); | ||
| } | ||
|
|
||
| // 검색 조건 | ||
| // if (q != null && !q.isBlank()) { | ||
| // builder.and( | ||
| // api.name.containsIgnoreCase(q) | ||
| // .or(api.summary.containsIgnoreCase(q)) | ||
| // .or(api.longDescription.containsIgnoreCase(q)) | ||
| // ); | ||
| // } | ||
|
|
||
| int pageSize = (size != null) ? size : 16; | ||
| Pageable pageable = PageRequest.of(page, pageSize); | ||
|
|
||
| // reviewCount 서브쿼리 | ||
| var reviewCountSubQuery = | ||
| JPAExpressions.select(review.count()) | ||
| .from(review) | ||
| .where(review.api.id.eq(api.id)); | ||
|
|
||
| // 정렬 옵션 | ||
| // if (sort == null || sort.isBlank()) { | ||
| // sort = "latest"; | ||
| // } | ||
|
|
||
| // var orderSpecifier = switch (sort) { | ||
| // case "popular" -> api.viewCounts.desc(); | ||
| // case "mostReviewed" -> reviewCountSubQuery.desc(); | ||
| // case "latest" -> api.createdAt.desc(); | ||
| // default -> api.createdAt.desc(); // 잘못된 값 방어 | ||
| // }; | ||
|
|
||
| // 목록 조회 | ||
| List<ApiDTO.ApiPreview> content = queryFactory | ||
| .select(Projections.constructor( | ||
| ApiDTO.ApiPreview.class, | ||
| api.id, | ||
| api.name, | ||
| api.summary, | ||
| api.avgRating, | ||
| reviewCountSubQuery, | ||
| api.viewCounts, | ||
| api.pricingType, | ||
| api.authType, | ||
| api.providerCompany | ||
| )) | ||
| .from(api) | ||
| .where(builder) | ||
| .orderBy(api.createdAt.desc()) // 정렬 적용 | ||
| // .orderBy(orderSpecifier) // 기존 createdAt.desc() → 동적 정렬 |
Copilot
AI
Jan 23, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
주석 처리된 코드가 많이 포함되어 있습니다. 향후 확장을 위한 예비 코드라고 하셨지만, 프로덕션 코드에 주석 처리된 코드를 포함하는 것은 가독성을 떨어뜨리고 유지보수를 어렵게 만듭니다.
대안:
- 별도의 브랜치나 feature flag로 관리
- TODO 주석과 함께 깔끔하게 정리
- 또는 완전히 제거하고 필요시 Git 히스토리에서 참조
특히 35-36, 44-45, 70-76, 88-97, 116번 라인의 주석 처리된 코드는 제거하는 것을 권장합니다.
[GeneralErrorCode - 불필요한 세미콜론 삭제 ApiControllerDocs - 경로추가 ApiController - 어노테이션 병합]
dusvlf111
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
확인했습니다!
seohyun27
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
확인했습니다! merge 해주시면 해당 기능들 바탕으로 좋아요 기능 추가하도록 하겠습니다!
#️⃣ 연관된 이슈
#️⃣ 작업 내용
Explore API 목록 조회 기능에 대해 필터, 검색, 정렬, 페이징 로직을 QueryDSL 기반으로 확장 구현했습니다.
기존 Explore / 카테고리 / 필터 흐름을 하나의 API(
/api/v1/apis)로 통합 처리하도록 구성했습니다.또한 정렬 옵션(enum 기반)과 방향(enum 기반)을 추가하여 다양한 정렬 기준을 지원하도록 확장했습니다.
기존 코드와 main 브랜치 변경 사항을 병합하면서 충돌 해결 및 구조 분리(ApiDetailQueryService / ApiSearchQueryService)도 함께 진행했습니다.
Explore / 카테고리 / 필터 / 검색 / 정렬을 하나의 엔드포인트로 통합했습니다.
📌 동작 방식
Explore
GET /api/v1/apis?page=0&size=16&sort=LATEST&direction=ASC-> 전체 API 반환
naver 검색시
GET /api/v1/apis?page=0&size=16&q=naver&sort=LATEST&direction=ASC{ "isSuccess": true, "code": "COMMON200", "message": "성공입니다.", "result": { "content": [ { "apiId": 41, "name": "Naver Papago", "summary": "네이버 파파고 번역 API로 다양한 언어 번역", "avgRating": 0, "reviewCount": 0, "viewCounts": 0, "pricingType": "FREE", "authType": null, "providerCompany": "NAVER" } ], "totalPage": 1, "totalElements": 1, "listSize": 16, "currentPage": 1, "first": true, "last": true } }GET /api/v1/apis?page=0&size=16&categoryId=5&sort=LATEST&direction=ASC&pricingTypes=FREE{ "isSuccess": true, "code": "COMMON200", "message": "성공입니다.", "result": { "content": [ { "apiId": 24, "name": "DeepL API", "summary": "고품질 AI 기반 번역", "avgRating": 0, "reviewCount": 0, "viewCounts": 0, "pricingType": "FREE", "authType": null, "providerCompany": "DEEPL" }, { "apiId": 41, "name": "Naver Papago", "summary": "네이버 파파고 번역 API로 다양한 언어 번역", "avgRating": 0, "reviewCount": 0, "viewCounts": 0, "pricingType": "FREE", "authType": null, "providerCompany": "NAVER" } ], "totalPage": 3, "totalElements": 35, "listSize": 16, "currentPage": 1, "first": true, "last": false } }필터 조건이 존재하는 경우에만 WHERE 절에 반영되도록 BooleanBuilder 기반으로 구현했습니다.
지원 필터
CLOB 컬럼 제외 후 lower() 기반 LIKE 검색 적용
정렬 기준과 방향을 enum으로 분리하였습니다.
지원 정렬
기능별 QueryService 분리
책임 분리 및 충돌 최소화를 위한 구조 개선
#️⃣ 테스트 결과
#️⃣ 변경 사항 체크리스트
#️⃣ 리뷰 요구사항 (선택)