From b9d6df541a4076e303ad2c20f5ae8135f3777ac2 Mon Sep 17 00:00:00 2001 From: junho <2171168@hansung.ac.kr> Date: Sun, 26 Oct 2025 15:39:38 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:QueryDSL=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/build.gradle b/build.gradle index 118b98c..4d494bb 100644 --- a/build.gradle +++ b/build.gradle @@ -32,8 +32,34 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // QueryDSL : OpenFeign + implementation "io.github.openfeign.querydsl:querydsl-jpa:7.0" + implementation "io.github.openfeign.querydsl:querydsl-core:7.0" + annotationProcessor "io.github.openfeign.querydsl:querydsl-apt:7.0:jpa" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" } tasks.named('test') { useJUnitPlatform() } + +// QueryDSL 관련 설정 +// generated/querydsl 폴더 생성 & 삽입 +def querydslDir = layout.buildDirectory.dir("generated/querydsl").get().asFile + +// 소스 세트에 생성 경로 추가 (구체적인 경로 지정) +sourceSets { + main.java.srcDirs += [ querydslDir ] +} + +// 컴파일 시 생성 경로 지정 +tasks.withType(JavaCompile).configureEach { + options.generatedSourceOutputDirectory.set(querydslDir) +} + +// clean 태스크에 생성 폴더 삭제 로직 추가 +clean.doLast { + file(querydslDir).deleteDir() +} From 2e0c3398b7e0677f0ac4ccd26d727856bf795ca7 Mon Sep 17 00:00:00 2001 From: junho <2171168@hansung.ac.kr> Date: Mon, 27 Oct 2025 00:24:07 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=EB=82=B4=EA=B0=80=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=ED=95=9C=20=EB=A6=AC=EB=B7=B0API(=ED=95=84=ED=84=B0:?= =?UTF-8?q?=20=EA=B0=80=EA=B2=8C=20=EC=9D=B4=EB=A6=84,=20=EB=B3=84?= =?UTF-8?q?=EC=A0=90)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + .../com/example/umc9th/Umc9thApplication.java | 3 + .../example/umc9th/config/QueryDslConfig.java | 19 +++++ .../example/umc9th/config/SwaggerConfig.java | 24 ++++++ .../review/controller/ReviewController.java | 41 ++++++++++ .../domain/review/dto/ReviewResponseDto.java | 29 +++++++ .../umc9th/domain/review/entity/Review.java | 7 ++ .../review/repository/ReviewPredicate.java | 32 ++++++++ .../review/repository/ReviewQueryDsl.java | 13 ++++ .../review/repository/ReviewRepository.java | 3 +- .../repository/ReviewRepositoryImpl.java | 78 +++++++++++++++++++ .../domain/review/service/ReviewService.java | 9 +++ .../review/service/ReviewServiceImpl.java | 48 ++++++++++++ .../umc9th/global/dto/CursorResponseDto.java | 30 +++++++ .../umc9th/global/dto/PageResponseDto.java | 38 +++++++++ 15 files changed, 373 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/example/umc9th/config/QueryDslConfig.java create mode 100644 src/main/java/com/example/umc9th/config/SwaggerConfig.java create mode 100644 src/main/java/com/example/umc9th/domain/review/controller/ReviewController.java create mode 100644 src/main/java/com/example/umc9th/domain/review/dto/ReviewResponseDto.java create mode 100644 src/main/java/com/example/umc9th/domain/review/repository/ReviewPredicate.java create mode 100644 src/main/java/com/example/umc9th/domain/review/repository/ReviewQueryDsl.java create mode 100644 src/main/java/com/example/umc9th/domain/review/repository/ReviewRepositoryImpl.java create mode 100644 src/main/java/com/example/umc9th/global/dto/CursorResponseDto.java create mode 100644 src/main/java/com/example/umc9th/global/dto/PageResponseDto.java diff --git a/build.gradle b/build.gradle index 4d494bb..bba915e 100644 --- a/build.gradle +++ b/build.gradle @@ -39,6 +39,7 @@ dependencies { annotationProcessor "io.github.openfeign.querydsl:querydsl-apt:7.0:jpa" annotationProcessor "jakarta.persistence:jakarta.persistence-api" annotationProcessor "jakarta.annotation:jakarta.annotation-api" + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.0.2' } tasks.named('test') { diff --git a/src/main/java/com/example/umc9th/Umc9thApplication.java b/src/main/java/com/example/umc9th/Umc9thApplication.java index bc54005..973809b 100644 --- a/src/main/java/com/example/umc9th/Umc9thApplication.java +++ b/src/main/java/com/example/umc9th/Umc9thApplication.java @@ -4,8 +4,11 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.web.servlet.config.annotation.EnableWebMvc; + @SpringBootApplication @EnableJpaAuditing +@EnableWebMvc public class Umc9thApplication { public static void main(String[] args) { diff --git a/src/main/java/com/example/umc9th/config/QueryDslConfig.java b/src/main/java/com/example/umc9th/config/QueryDslConfig.java new file mode 100644 index 0000000..354945f --- /dev/null +++ b/src/main/java/com/example/umc9th/config/QueryDslConfig.java @@ -0,0 +1,19 @@ +package com.example.umc9th.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QueryDslConfig { + + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} diff --git a/src/main/java/com/example/umc9th/config/SwaggerConfig.java b/src/main/java/com/example/umc9th/config/SwaggerConfig.java new file mode 100644 index 0000000..d095a60 --- /dev/null +++ b/src/main/java/com/example/umc9th/config/SwaggerConfig.java @@ -0,0 +1,24 @@ +package com.example.umc9th.config; + +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.info.Info; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class SwaggerConfig { + @Bean + public OpenAPI openAPI() { + return new OpenAPI() + .components(new Components()) + .info(apiInfo()); + } + + private Info apiInfo() { + return new Info() + .title("Umc 9th API") + .description("Umc 9th API 명세서") + .version("1.0.0"); + } +} diff --git a/src/main/java/com/example/umc9th/domain/review/controller/ReviewController.java b/src/main/java/com/example/umc9th/domain/review/controller/ReviewController.java new file mode 100644 index 0000000..19186f7 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/review/controller/ReviewController.java @@ -0,0 +1,41 @@ +package com.example.umc9th.domain.review.controller; + +import com.example.umc9th.domain.review.dto.ReviewResponseDto; +import com.example.umc9th.domain.review.service.ReviewService; +import com.example.umc9th.global.dto.CursorResponseDto; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Slice; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/reviews") +@Tag(name = "리뷰") +public class ReviewController { + + private final ReviewService reviewService; + + @Operation( + summary = "리뷰 조회", + description = "가게별, 별점별로 리뷰를 조회합니다.") + @GetMapping + public CursorResponseDto getReviews( + @RequestParam(required = false) String storeName, + @RequestParam(required = false, defaultValue = "0.0") Float minStar, + @RequestParam(required = false, defaultValue = "5.0") Float maxStar, + @RequestParam(required = false) Long cursorId, + @RequestParam(required = false, defaultValue = "10") Integer size) { + + return reviewService.getReviews(storeName, + minStar, + maxStar, + cursorId, + size); + } + +} diff --git a/src/main/java/com/example/umc9th/domain/review/dto/ReviewResponseDto.java b/src/main/java/com/example/umc9th/domain/review/dto/ReviewResponseDto.java new file mode 100644 index 0000000..0e45316 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/review/dto/ReviewResponseDto.java @@ -0,0 +1,29 @@ +package com.example.umc9th.domain.review.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +public class ReviewResponseDto { + // 객체 생성 방지 + private ReviewResponseDto() {} + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class Review{ + private Long id; + private String nickname; + private Float star; + private String content; + private String createdAt; + private List reviewImages; + + private String reviewReplyContent; + private String reviewReplyCreatedAt; + } +} diff --git a/src/main/java/com/example/umc9th/domain/review/entity/Review.java b/src/main/java/com/example/umc9th/domain/review/entity/Review.java index ccdab19..0d50e51 100644 --- a/src/main/java/com/example/umc9th/domain/review/entity/Review.java +++ b/src/main/java/com/example/umc9th/domain/review/entity/Review.java @@ -5,6 +5,8 @@ import com.example.umc9th.domain.user.entity.User; import com.example.umc9th.global.entity.BaseEntity; import jakarta.persistence.*; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; import lombok.*; import org.springframework.data.jpa.domain.support.AuditingEntityListener; @@ -36,6 +38,11 @@ public class Review extends BaseEntity { @Column(nullable = false) private String content; + @Column(nullable = false) + @Min(value = 0, message = "별점은 0점 이상이어야 합니다.") + @Max(value = 5, message = "별점은 5점을 초과할 수 없습니다.") + private Float star; + @OneToMany(mappedBy = "review", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) @Builder.Default private List reviewImages = new ArrayList<>(); diff --git a/src/main/java/com/example/umc9th/domain/review/repository/ReviewPredicate.java b/src/main/java/com/example/umc9th/domain/review/repository/ReviewPredicate.java new file mode 100644 index 0000000..6c40bf5 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/review/repository/ReviewPredicate.java @@ -0,0 +1,32 @@ +package com.example.umc9th.domain.review.repository; + +import com.querydsl.core.types.dsl.BooleanExpression; +import org.springframework.util.StringUtils; + +import static com.example.umc9th.domain.review.entity.QReview.review; +import static com.example.umc9th.domain.store.entity.QStore.store; + +public class ReviewPredicate { + + private ReviewPredicate() {} + + public static BooleanExpression storeNameContains(String storeName) { + return StringUtils.hasText(storeName) ? store.name.contains(storeName) : null; + } + + public static BooleanExpression starRange(Float minStar, Float maxStar) { + if (minStar != null && maxStar != null) { + return review.star.between(minStar, maxStar); + } else if (minStar != null) { + return review.star.goe(minStar); // goe: great or equal + } else if (maxStar != null) { + return review.star.loe(maxStar); // loe: low or equal + } + + return null; + } + + public static BooleanExpression userIdEquals(Long userId) { + return userId != null ? review.user.id.eq(userId) : null; + } +} diff --git a/src/main/java/com/example/umc9th/domain/review/repository/ReviewQueryDsl.java b/src/main/java/com/example/umc9th/domain/review/repository/ReviewQueryDsl.java new file mode 100644 index 0000000..909da19 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/review/repository/ReviewQueryDsl.java @@ -0,0 +1,13 @@ +package com.example.umc9th.domain.review.repository; + +import com.example.umc9th.domain.review.dto.ReviewResponseDto; +import com.querydsl.core.types.Predicate; +import org.springframework.data.domain.Slice; + +import org.springframework.data.domain.Pageable; + +public interface ReviewQueryDsl { + Slice getReviews(Predicate predicate, + Long cursorId, + Pageable pageable); +} diff --git a/src/main/java/com/example/umc9th/domain/review/repository/ReviewRepository.java b/src/main/java/com/example/umc9th/domain/review/repository/ReviewRepository.java index 628838a..f684995 100644 --- a/src/main/java/com/example/umc9th/domain/review/repository/ReviewRepository.java +++ b/src/main/java/com/example/umc9th/domain/review/repository/ReviewRepository.java @@ -5,9 +5,8 @@ import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import org.springframework.transaction.annotation.Transactional; -public interface ReviewRepository extends JpaRepository { +public interface ReviewRepository extends JpaRepository, ReviewQueryDsl { @Modifying @Query("DELETE FROM Review r WHERE r.user.id = :userId") void deleteByUserId(@Param("userId") Long userId); diff --git a/src/main/java/com/example/umc9th/domain/review/repository/ReviewRepositoryImpl.java b/src/main/java/com/example/umc9th/domain/review/repository/ReviewRepositoryImpl.java new file mode 100644 index 0000000..804582d --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/review/repository/ReviewRepositoryImpl.java @@ -0,0 +1,78 @@ +package com.example.umc9th.domain.review.repository; + +import com.example.umc9th.domain.review.dto.ReviewResponseDto; +import com.example.umc9th.domain.review.entity.QReviewImage; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.group.GroupBy; +import com.querydsl.core.types.Predicate; +import com.querydsl.core.types.Projections; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.domain.SliceImpl; +import org.springframework.stereotype.Repository; + +import java.util.List; + +import static com.example.umc9th.domain.review.entity.QReview.review; +import static com.example.umc9th.domain.store.entity.QStore.store; +import static com.example.umc9th.domain.user.entity.QUser.user; +import static com.example.umc9th.domain.review.entity.QReviewImage.reviewImage; +import static com.example.umc9th.domain.review.entity.QReviewReply.reviewReply; + +@Slf4j +@RequiredArgsConstructor +public class ReviewRepositoryImpl implements ReviewQueryDsl { + + private final JPAQueryFactory queryFactory; + + @Override + public Slice getReviews(Predicate predicate, + Long cursorId, + Pageable pageable) { + + BooleanBuilder builder = new BooleanBuilder(predicate); + + // 커서가 있으면 + if (cursorId != null) { + builder.and(review.id.lt(cursorId)); // lt: less than + } + + // 'size + 1' 만큼 조회 (다음 페이지 유무 확인용) + int pageSize = pageable.getPageSize(); + + List content = queryFactory + .from(review) + .leftJoin(review.user, user) + .leftJoin(review.reviewImages, reviewImage) + .leftJoin(review.reviewReply, reviewReply) + .where(builder) + .orderBy(review.id.desc()) + .limit(pageSize + 1) + .transform( // transform은 자바 애플리케이션 메모리에서 sql 결과 리스트를 가공 + GroupBy.groupBy(review.id).list( + Projections.constructor(ReviewResponseDto.Review.class, + review.id, + user.nickname, + review.star, + review.content, + review.createdAt.stringValue(), + GroupBy.list(reviewImage.imageUrl), + reviewReply.content, + reviewReply.createdAt.stringValue() + ) + ) + ); + + // hasNext (다음 페이지 존재 여부) 확인 + boolean hasNext = false; + if (content.size() > pageSize) { + content.remove(pageSize); + hasNext = true; + } + + return new SliceImpl<>(content, pageable, hasNext); + } +} diff --git a/src/main/java/com/example/umc9th/domain/review/service/ReviewService.java b/src/main/java/com/example/umc9th/domain/review/service/ReviewService.java index 0115189..c126e67 100644 --- a/src/main/java/com/example/umc9th/domain/review/service/ReviewService.java +++ b/src/main/java/com/example/umc9th/domain/review/service/ReviewService.java @@ -1,5 +1,14 @@ package com.example.umc9th.domain.review.service; +import com.example.umc9th.domain.review.dto.ReviewResponseDto; +import com.example.umc9th.global.dto.CursorResponseDto; + public interface ReviewService { void createReview(String content, Long userId, Long storeId); + + CursorResponseDto getReviews(String storeName, + Float minStar, + Float maxStar, + Long cursorId, + int size); } diff --git a/src/main/java/com/example/umc9th/domain/review/service/ReviewServiceImpl.java b/src/main/java/com/example/umc9th/domain/review/service/ReviewServiceImpl.java index 04fee8c..18473c1 100644 --- a/src/main/java/com/example/umc9th/domain/review/service/ReviewServiceImpl.java +++ b/src/main/java/com/example/umc9th/domain/review/service/ReviewServiceImpl.java @@ -1,13 +1,25 @@ package com.example.umc9th.domain.review.service; +import com.example.umc9th.domain.review.dto.ReviewResponseDto; import com.example.umc9th.domain.review.entity.Review; +import com.example.umc9th.domain.review.repository.ReviewPredicate; +import com.example.umc9th.domain.review.repository.ReviewQueryDsl; import com.example.umc9th.domain.review.repository.ReviewRepository; import com.example.umc9th.domain.store.repository.StoreRepository; import com.example.umc9th.domain.user.repository.UserRepository; +import com.example.umc9th.global.dto.CursorResponseDto; +import com.querydsl.core.BooleanBuilder; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; + +@Slf4j @Service @RequiredArgsConstructor @Transactional @@ -27,4 +39,40 @@ public void createReview(String content, Long userId, Long storeId) { reviewRepository.save(review); } + + @Override + public CursorResponseDto getReviews(String storeName, + Float minStar, + Float maxStar, + Long cursorId, + int size) { + // 1. 서비스에서 Predicate 조합 + BooleanBuilder predicate = new BooleanBuilder(); + predicate.and(ReviewPredicate.userIdEquals(1L));// todo: 로그인한 사용자 + predicate.and(ReviewPredicate.storeNameContains(storeName)); + predicate.and(ReviewPredicate.starRange(minStar, maxStar)); + + // 2. Pageable 객체 생성 + Pageable pageable = PageRequest.of(0, size); // 커서 기반이므로 page는 0 + + Slice slice = reviewRepository.getReviews( + predicate, + cursorId, + pageable + ); + + // nextCursor 계산 + String nextCursor = null; + if (slice.hasNext()) { + List content = slice.getContent(); + if (!content.isEmpty()) { + nextCursor = content.get(content.size() - 1).getId().toString(); + } + } + + // 5. CursorResponseDto 반환 + return new CursorResponseDto<>(slice, nextCursor); + } + + } diff --git a/src/main/java/com/example/umc9th/global/dto/CursorResponseDto.java b/src/main/java/com/example/umc9th/global/dto/CursorResponseDto.java new file mode 100644 index 0000000..fe1f741 --- /dev/null +++ b/src/main/java/com/example/umc9th/global/dto/CursorResponseDto.java @@ -0,0 +1,30 @@ +package com.example.umc9th.global.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import org.springframework.data.domain.Slice; // import 확인 + +import java.util.List; + +@Getter +public class CursorResponseDto { + + @Schema(description = "데이터 리스트") + private List content; + + @Schema(description = "현재 페이지의 데이터 개수", example = "10") + private int pageSize; + + @Schema(description = "다음 페이지를 위한 커서 값 (응답의 마지막 아이템의 식별자)", example = "123") + private String nextCursor; + + @Schema(description = "다음 페이지 존재 여부", example = "true") + private boolean hasNext; + + public CursorResponseDto(Slice slice, String nextCursor) { + this.content = slice.getContent(); + this.pageSize = slice.getNumberOfElements(); + this.nextCursor = nextCursor; + this.hasNext = slice.hasNext(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/umc9th/global/dto/PageResponseDto.java b/src/main/java/com/example/umc9th/global/dto/PageResponseDto.java new file mode 100644 index 0000000..043973f --- /dev/null +++ b/src/main/java/com/example/umc9th/global/dto/PageResponseDto.java @@ -0,0 +1,38 @@ +package com.example.umc9th.global.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import org.springframework.data.domain.Page; + +import java.util.List; + +@Getter +public class PageResponseDto { // 제네릭 타입으로 다른 리스트에도 재사용 가능 + + @Schema(description = "데이터 리스트") + private List content; + + @Schema(description = "현재 페이지 번호", example = "0") + private int pageNumber; + + @Schema(description = "페이지 크기", example = "10") + private int pageSize; + + @Schema(description = "전체 페이지 수", example = "15") + private int totalPages; + + @Schema(description = "전체 요소 개수", example = "145") + private long totalElements; + + @Schema(description = "마지막 페이지 여부", example = "false") + private boolean last; + + public PageResponseDto(Page page) { + this.content = page.getContent(); + this.pageNumber = page.getNumber()+1; // 0-based + this.pageSize = page.getSize(); + this.totalPages = page.getTotalPages(); + this.totalElements = page.getTotalElements(); + this.last = page.isLast(); + } +}