diff --git a/build.gradle b/build.gradle index 4e2c9d6..cd02e26 100644 --- a/build.gradle +++ b/build.gradle @@ -4,6 +4,10 @@ plugins { id 'io.spring.dependency-management' version '1.1.7' apply false } +ext { + querydslVersion = '7.4.0' +} + subprojects { apply plugin: 'java-library' apply plugin: 'io.spring.dependency-management' @@ -36,6 +40,9 @@ subprojects { dependencies { compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' + annotationProcessor "io.github.openfeign.querydsl:querydsl-apt:${querydslVersion}:jpa" + annotationProcessor 'jakarta.persistence:jakarta.persistence-api' + annotationProcessor 'jakarta.annotation:jakarta.annotation-api' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/common/build.gradle b/common/build.gradle index e5362ff..dd91b7a 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -3,6 +3,7 @@ dependencies { api 'org.springframework.boot:spring-boot-starter-json' api 'org.springframework.boot:spring-boot-starter-validation' api 'org.springframework.boot:spring-boot-starter-data-jpa' + api "io.github.openfeign.querydsl:querydsl-jpa:${querydslVersion}" implementation 'io.hypersistence:hypersistence-tsid:2.1.4' } diff --git a/common/src/main/java/com/nalssilog/common/config/QuerydslConfig.java b/common/src/main/java/com/nalssilog/common/config/QuerydslConfig.java new file mode 100644 index 0000000..4240281 --- /dev/null +++ b/common/src/main/java/com/nalssilog/common/config/QuerydslConfig.java @@ -0,0 +1,15 @@ +package com.nalssilog.common.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration(proxyBeanMethods = false) +public class QuerydslConfig { + + @Bean + public JPAQueryFactory jpaQueryFactory(EntityManager entityManager) { + return new JPAQueryFactory(entityManager); + } +} diff --git a/location/src/main/java/com/nalssilog/location/repository/LocationFavoriteJpaRepository.java b/location/src/main/java/com/nalssilog/location/repository/LocationFavoriteJpaRepository.java index 202247d..d5658ce 100644 --- a/location/src/main/java/com/nalssilog/location/repository/LocationFavoriteJpaRepository.java +++ b/location/src/main/java/com/nalssilog/location/repository/LocationFavoriteJpaRepository.java @@ -2,12 +2,11 @@ import com.nalssilog.location.domain.LocationFavorite; import java.util.List; -import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; /** - * Spring Data JPA 인터페이스. 서비스가 직접 호출하지 않고 {@link LocationFavoriteRepository} 래퍼를 통해 사용한다. + * 단순 CRUD와 메서드 이름으로 표현 가능한 조회만 담당한다. + * 인기 지역 집계는 {@link LocationFavoriteRepository}가 QueryDSL로 처리한다. */ public interface LocationFavoriteJpaRepository extends JpaRepository { @@ -16,10 +15,4 @@ public interface LocationFavoriteJpaRepository extends JpaRepository findAllByMemberIdOrderByCreatedAtDesc(Long memberId); - - /** - * 즐겨찾기 많은 지역 id (임시 인기 기준). 나중에 제보 기반으로 교체 예정. - */ - @Query("select f.locationId from LocationFavorite f group by f.locationId order by count(f) desc") - List findPopularLocationIds(Pageable pageable); } diff --git a/location/src/main/java/com/nalssilog/location/repository/LocationFavoriteRepository.java b/location/src/main/java/com/nalssilog/location/repository/LocationFavoriteRepository.java index 21fd6df..9412267 100644 --- a/location/src/main/java/com/nalssilog/location/repository/LocationFavoriteRepository.java +++ b/location/src/main/java/com/nalssilog/location/repository/LocationFavoriteRepository.java @@ -1,19 +1,23 @@ package com.nalssilog.location.repository; +import static com.nalssilog.location.domain.QLocationFavorite.locationFavorite; + import com.nalssilog.location.domain.LocationFavorite; +import com.querydsl.jpa.impl.JPAQueryFactory; import java.util.List; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Repository; /** - * 서비스 호출용 LocationFavorite 저장소 래퍼. + * 서비스 호출용 LocationFavorite 저장소. + * 단순 조회는 Spring Data JPA에 위임하고, 인기 지역 집계는 QueryDSL로 처리한다. */ @Repository @RequiredArgsConstructor public class LocationFavoriteRepository { private final LocationFavoriteJpaRepository locationFavoriteJpaRepository; + private final JPAQueryFactory queryFactory; public boolean exists(Long memberId, Long locationId) { return locationFavoriteJpaRepository.existsByMemberIdAndLocationId(memberId, locationId); @@ -34,6 +38,12 @@ public List findFavoriteLocationIds(Long memberId) { } public List findPopularLocationIds(int size) { - return locationFavoriteJpaRepository.findPopularLocationIds(PageRequest.of(0, size)); + return queryFactory + .select(locationFavorite.locationId) + .from(locationFavorite) + .groupBy(locationFavorite.locationId) + .orderBy(locationFavorite.id.count().desc()) + .limit(size) + .fetch(); } } diff --git a/location/src/main/java/com/nalssilog/location/repository/LocationJpaRepository.java b/location/src/main/java/com/nalssilog/location/repository/LocationJpaRepository.java index 67d56df..66ed931 100644 --- a/location/src/main/java/com/nalssilog/location/repository/LocationJpaRepository.java +++ b/location/src/main/java/com/nalssilog/location/repository/LocationJpaRepository.java @@ -3,48 +3,15 @@ import com.nalssilog.location.domain.Location; import java.util.List; import java.util.Optional; -import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; /** - * Spring Data JPA 인터페이스. 서비스가 직접 호출하지 않고 {@link LocationRepository} 래퍼를 통해 사용한다. + * 단순 CRUD와 메서드 이름으로 표현 가능한 조회만 담당한다. + * 키워드 검색과 원자적 등록은 {@link LocationRepository}가 담당한다. */ public interface LocationJpaRepository extends JpaRepository { - /** - * 시도/시군구/동, 그리고 "시도 시군구 동" 조합 label 까지 부분검색(대소문자 무시). - * 예: "강남", "역삼", "서울 강남", "강남구 역삼동" 모두 매칭. - */ - @Query(""" - select l from Location l - where lower(l.sido) like lower(concat('%', :keyword, '%')) - or lower(l.sigungu) like lower(concat('%', :keyword, '%')) - or lower(l.dong) like lower(concat('%', :keyword, '%')) - or lower(concat(l.sido, ' ', l.sigungu, ' ', l.dong)) like lower(concat('%', :keyword, '%')) - order by l.sido asc, l.sigungu asc, l.dong asc - """) - List searchByKeyword(@Param("keyword") String keyword, Pageable pageable); - List findByAdminCodeIn(List adminCodes); Optional findByAdminCode(String adminCode); - - @Modifying(flushAutomatically = true, clearAutomatically = true) - @Query(value = """ - insert into location ( - created_at, updated_at, admin_code, sido, sigungu, dong, latitude, longitude - ) values ( - current_timestamp, current_timestamp, :adminCode, :sido, :sigungu, :dong, :latitude, :longitude - ) - on conflict (admin_code) do nothing - """, nativeQuery = true) - int insertIfAbsent(@Param("adminCode") String adminCode, - @Param("sido") String sido, - @Param("sigungu") String sigungu, - @Param("dong") String dong, - @Param("latitude") double latitude, - @Param("longitude") double longitude); } diff --git a/location/src/main/java/com/nalssilog/location/repository/LocationRepository.java b/location/src/main/java/com/nalssilog/location/repository/LocationRepository.java index a4a67fb..9e94a09 100644 --- a/location/src/main/java/com/nalssilog/location/repository/LocationRepository.java +++ b/location/src/main/java/com/nalssilog/location/repository/LocationRepository.java @@ -1,33 +1,61 @@ package com.nalssilog.location.repository; +import static com.nalssilog.location.domain.QLocation.location; + import com.nalssilog.common.exception.NalssiLogException; import com.nalssilog.location.application.dto.LocationInfo; import com.nalssilog.location.client.KakaoRegion; import com.nalssilog.location.domain.Location; import com.nalssilog.location.domain.LocationErrorCode; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import java.time.Instant; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.function.Function; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.PageRequest; +import org.hibernate.Session; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; /** - * 서비스 호출용 Location 저장소 래퍼. 조회는 DTO 로 반환한다. + * 서비스 호출용 Location 저장소. + * 단순 조회는 Spring Data JPA에 위임하고, 키워드 검색은 QueryDSL로 처리한다. */ @Repository @RequiredArgsConstructor public class LocationRepository { - private final LocationJpaRepository locationJpaRepository; - private static final int SEARCH_LIMIT = 20; + private static final String INSERT_IF_ABSENT = """ + insert Location (createdAt, updatedAt, adminCode, sido, sigungu, dong, latitude, longitude) + values (:now, :now, :adminCode, :sido, :sigungu, :dong, :latitude, :longitude) + on conflict (adminCode) do nothing + """; + + private final LocationJpaRepository locationJpaRepository; + private final JPAQueryFactory queryFactory; + private final EntityManager entityManager; public List searchByKeyword(String keyword) { - return locationJpaRepository.searchByKeyword(keyword, PageRequest.of(0, SEARCH_LIMIT)).stream() + BooleanExpression matchesKeyword = location.sido.containsIgnoreCase(keyword) + .or(location.sigungu.containsIgnoreCase(keyword)) + .or(location.dong.containsIgnoreCase(keyword)) + .or(location.sido.concat(" ") + .concat(location.sigungu).concat(" ") + .concat(location.dong) + .containsIgnoreCase(keyword)); + + return queryFactory + .selectFrom(location) + .where(matchesKeyword) + .orderBy(location.sido.asc(), location.sigungu.asc(), location.dong.asc()) + .limit(SEARCH_LIMIT) + .fetch() + .stream() .map(LocationInfo::of) .toList(); } @@ -38,9 +66,7 @@ public LocationInfo getById(Long id) { .orElseThrow(() -> new NalssiLogException(LocationErrorCode.LOCATION_NOT_FOUND)); } - /** - * 주어진 id 순서를 보존해 조회한다. (인기·즐겨찾기 목록의 정렬 유지용) - */ + /** 주어진 id 순서를 보존해 조회한다. (인기·즐겨찾기 목록의 정렬 유지용) */ public List findByIds(List ids) { Map byId = locationJpaRepository.findAllById(ids).stream() .collect(Collectors.toMap(Location::getId, Function.identity())); @@ -74,18 +100,21 @@ public void saveAll(List locations) { /** * 카카오 법정동 코드로 지역을 원자적으로 등록한 뒤 반환한다. - * 같은 법정동에 요청이 동시에 들어와도 DB unique + ON CONFLICT 로 한 행만 유지한다. + * QueryDSL JPA가 INSERT를 지원하지 않아 Hibernate HQL upsert를 사용한다. */ @Transactional public LocationInfo findOrCreate(KakaoRegion region) { - locationJpaRepository.insertIfAbsent( - region.adminCode(), - region.sido(), - region.sigungu(), - region.dong(), - region.latitude(), - region.longitude() - ); + Instant now = Instant.now(); + entityManager.unwrap(Session.class) + .createMutationQuery(INSERT_IF_ABSENT) + .setParameter("now", now) + .setParameter("adminCode", region.adminCode()) + .setParameter("sido", region.sido()) + .setParameter("sigungu", region.sigungu()) + .setParameter("dong", region.dong()) + .setParameter("latitude", region.latitude()) + .setParameter("longitude", region.longitude()) + .executeUpdate(); return locationJpaRepository.findByAdminCode(region.adminCode()) .map(LocationInfo::of) diff --git a/report/src/main/java/com/nalssilog/report/application/ReportService.java b/report/src/main/java/com/nalssilog/report/application/ReportService.java index fd9afb1..4a216ca 100644 --- a/report/src/main/java/com/nalssilog/report/application/ReportService.java +++ b/report/src/main/java/com/nalssilog/report/application/ReportService.java @@ -38,8 +38,7 @@ public class ReportService { private static final int PAGE_SIZE = 20; - // 홈 피드·통계 공통 최근 윈도우. "지금 이 동네 체감" 컨셉이라 24시간으로 통일. - private static final Duration RECENT_WINDOW = Duration.ofHours(24); + private static final Duration STATS_WINDOW = Duration.ofHours(24); private final WeatherReportRepository reportRepository; private final ThanksRepository thanksRepository; @@ -72,8 +71,7 @@ public CursorPage list(Long locationId, String cursor, ReportAct Instant cursorTime = decoded == null ? null : decoded.createdAt(); Long cursorId = decoded == null ? null : decoded.id(); - Instant since = Instant.now().minus(RECENT_WINDOW); - List fetched = reportRepository.findPage(locationId, since, cursorTime, cursorId, PAGE_SIZE + 1); + List fetched = reportRepository.findPage(locationId, cursorTime, cursorId, PAGE_SIZE + 1); boolean hasNext = fetched.size() > PAGE_SIZE; List page = hasNext ? fetched.subList(0, PAGE_SIZE) : fetched; @@ -140,12 +138,12 @@ public CursorPage listByMember(Long memberId, String cursor, Rep } /** - * 지역 날씨 통계. 최근 {@link #RECENT_WINDOW} 이내 제보들의 3축 분포 + 제보 수. + * 지역 날씨 통계. 최근 {@link #STATS_WINDOW} 이내 제보들의 3축 분포 + 제보 수. * (locationClient.getLocation 이 유효하지 않은 지역이면 LOCATION_NOT_FOUND 를 던져 검증도 겸함) */ public WeatherStatsResponse stats(Long locationId) { LocationSummary location = locationClient.getLocation(locationId); - WeatherStatsData stats = reportRepository.statsSince(locationId, Instant.now().minus(RECENT_WINDOW)); + WeatherStatsData stats = reportRepository.statsSince(locationId, Instant.now().minus(STATS_WINDOW)); return WeatherStatsResponse.of(location, stats); } diff --git a/report/src/main/java/com/nalssilog/report/application/dto/ThanksCountRow.java b/report/src/main/java/com/nalssilog/report/application/dto/ThanksCountRow.java deleted file mode 100644 index 12fc4a1..0000000 --- a/report/src/main/java/com/nalssilog/report/application/dto/ThanksCountRow.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.nalssilog.report.application.dto; - -/** - * GROUP BY 배치 집계 결과 한 행. (JPQL 생성자 표현식 대상) - */ -public record ThanksCountRow(Long reportId, Long count) { -} diff --git a/report/src/main/java/com/nalssilog/report/repository/ThanksJpaRepository.java b/report/src/main/java/com/nalssilog/report/repository/ThanksJpaRepository.java index 5a62a73..aae2ec9 100644 --- a/report/src/main/java/com/nalssilog/report/repository/ThanksJpaRepository.java +++ b/report/src/main/java/com/nalssilog/report/repository/ThanksJpaRepository.java @@ -1,16 +1,12 @@ package com.nalssilog.report.repository; -import com.nalssilog.report.application.dto.ThanksCountRow; import com.nalssilog.report.domain.ActorType; import com.nalssilog.report.domain.Thanks; -import java.util.Collection; -import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; /** - * Spring Data JPA 인터페이스. 서비스가 직접 호출하지 않고 {@link ThanksRepository} 래퍼를 통해 사용한다. + * 단순 CRUD와 메서드 이름으로 표현 가능한 조회만 담당한다. + * 배치 집계와 복합 조건 조회는 {@link ThanksRepository}가 QueryDSL로 처리한다. */ public interface ThanksJpaRepository extends JpaRepository { @@ -21,20 +17,4 @@ public interface ThanksJpaRepository extends JpaRepository { long deleteByReportId(Long reportId); long countByReportId(Long reportId); - - @Query(""" - select new com.nalssilog.report.application.dto.ThanksCountRow(t.reportId, count(t)) - from Thanks t - where t.reportId in :reportIds - group by t.reportId - """) - List countByReportIds(@Param("reportIds") Collection reportIds); - - @Query(""" - select distinct t.reportId from Thanks t - where t.reportId in :reportIds and t.actorType = :actorType and t.actorKey = :actorKey - """) - List findThankedReportIds(@Param("reportIds") Collection reportIds, - @Param("actorType") ActorType actorType, - @Param("actorKey") String actorKey); } diff --git a/report/src/main/java/com/nalssilog/report/repository/ThanksRepository.java b/report/src/main/java/com/nalssilog/report/repository/ThanksRepository.java index 2d84369..e44543c 100644 --- a/report/src/main/java/com/nalssilog/report/repository/ThanksRepository.java +++ b/report/src/main/java/com/nalssilog/report/repository/ThanksRepository.java @@ -1,9 +1,14 @@ package com.nalssilog.report.repository; +import static com.nalssilog.report.domain.QThanks.thanks; + import com.nalssilog.report.application.dto.ReportActor; -import com.nalssilog.report.application.dto.ThanksCountRow; import com.nalssilog.report.domain.Thanks; +import com.querydsl.core.Tuple; +import com.querydsl.core.types.dsl.NumberExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; import java.util.Collection; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @@ -11,13 +16,15 @@ import org.springframework.stereotype.Repository; /** - * 서비스 호출용 Thanks 저장소 래퍼. 카운트 컬럼 없이 행으로 관리하고, 목록은 GROUP BY 배치로 집계한다. + * 서비스 호출용 Thanks 저장소. + * 단순 조회는 Spring Data JPA에 위임하고, 배치 집계와 복합 조회는 QueryDSL로 처리한다. */ @Repository @RequiredArgsConstructor public class ThanksRepository { private final ThanksJpaRepository thanksJpaRepository; + private final JPAQueryFactory queryFactory; public void add(Long reportId, ReportActor actor) { if (!thanksJpaRepository.existsByReportIdAndActorTypeAndActorKey(reportId, actor.type(), actor.actorKey())) { @@ -46,8 +53,19 @@ public Map countByReportIds(Collection reportIds) { return Map.of(); } - return thanksJpaRepository.countByReportIds(reportIds).stream() - .collect(Collectors.toMap(ThanksCountRow::reportId, ThanksCountRow::count)); + NumberExpression count = thanks.id.count(); + List rows = queryFactory + .select(thanks.reportId, count) + .from(thanks) + .where(thanks.reportId.in(reportIds)) + .groupBy(thanks.reportId) + .fetch(); + + return rows.stream() + .collect(Collectors.toMap( + row -> row.get(thanks.reportId), + row -> row.get(count) + )); } public Set thankedReportIds(Collection reportIds, ReportActor actor) { @@ -55,6 +73,15 @@ public Set thankedReportIds(Collection reportIds, ReportActor actor) return Set.of(); } - return Set.copyOf(thanksJpaRepository.findThankedReportIds(reportIds, actor.type(), actor.actorKey())); + return Set.copyOf(queryFactory + .select(thanks.reportId) + .distinct() + .from(thanks) + .where( + thanks.reportId.in(reportIds), + thanks.actorType.eq(actor.type()), + thanks.actorKey.eq(actor.actorKey()) + ) + .fetch()); } } diff --git a/report/src/main/java/com/nalssilog/report/repository/WeatherReportJpaRepository.java b/report/src/main/java/com/nalssilog/report/repository/WeatherReportJpaRepository.java index 2a1b12d..7b3b995 100644 --- a/report/src/main/java/com/nalssilog/report/repository/WeatherReportJpaRepository.java +++ b/report/src/main/java/com/nalssilog/report/repository/WeatherReportJpaRepository.java @@ -1,100 +1,22 @@ package com.nalssilog.report.repository; +import com.nalssilog.report.domain.ActorType; import com.nalssilog.report.domain.WeatherReport; import java.time.Instant; import java.util.List; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; /** - * Spring Data JPA 인터페이스. 서비스가 직접 호출하지 않고 {@link WeatherReportRepository} 래퍼를 통해 사용한다. + * 단순 CRUD와 메서드 이름으로 표현 가능한 조회만 담당한다. + * 커서 조건, 집계, 벌크 변경은 {@link WeatherReportRepository}가 QueryDSL로 처리한다. */ public interface WeatherReportJpaRepository extends JpaRepository { - @Query(""" - select r from WeatherReport r - where r.locationId = :locationId and r.createdAt >= :since - order by r.createdAt desc, r.id desc - """) - List findFirstPage(@Param("locationId") Long locationId, - @Param("since") Instant since, - Pageable pageable); + List findAllByLocationIdOrderByCreatedAtDescIdDesc(Long locationId, Pageable pageable); - @Query(""" - select r from WeatherReport r - where r.locationId = :locationId and r.createdAt >= :since - and (r.createdAt < :cursorTime or (r.createdAt = :cursorTime and r.id < :cursorId)) - order by r.createdAt desc, r.id desc - """) - List findAfterCursor(@Param("locationId") Long locationId, - @Param("since") Instant since, - @Param("cursorTime") Instant cursorTime, - @Param("cursorId") Long cursorId, - Pageable pageable); - - @Query(""" - select r from WeatherReport r - where r.authorType = com.nalssilog.report.domain.ActorType.MEMBER and r.authorMemberId = :memberId - order by r.createdAt desc, r.id desc - """) - List findFirstMemberPage(@Param("memberId") Long memberId, Pageable pageable); - - @Query(""" - select r from WeatherReport r - where r.authorType = com.nalssilog.report.domain.ActorType.MEMBER and r.authorMemberId = :memberId - and (r.createdAt < :cursorTime or (r.createdAt = :cursorTime and r.id < :cursorId)) - order by r.createdAt desc, r.id desc - """) - List findMemberAfterCursor(@Param("memberId") Long memberId, - @Param("cursorTime") Instant cursorTime, - @Param("cursorId") Long cursorId, - Pageable pageable); - - /** - * 탈퇴 회원의 제보를 익명화한다(삭제하지 않음). 작성자를 ANONYMOUS 로 바꾸고 회원 참조를 끊는다. - * anonymousKey 는 탈퇴 회원마다 고정값(원래 익명 제보와 동일하게 "익명의 이웃"으로 렌더됨). - */ - @Modifying(clearAutomatically = true) - @Query(""" - update WeatherReport r - set r.authorType = com.nalssilog.report.domain.ActorType.ANONYMOUS, - r.authorMemberId = null, - r.authorAnonymousKey = :anonymousKey - where r.authorType = com.nalssilog.report.domain.ActorType.MEMBER and r.authorMemberId = :memberId - """) - int anonymizeByMemberId(@Param("memberId") Long memberId, @Param("anonymousKey") String anonymousKey); + List findAllByAuthorTypeAndAuthorMemberIdOrderByCreatedAtDescIdDesc( + ActorType authorType, Long authorMemberId, Pageable pageable); long countByLocationIdAndCreatedAtGreaterThanEqual(Long locationId, Instant since); - - @Query(""" - select r.locationId from WeatherReport r - where r.createdAt >= :since - group by r.locationId - order by count(r) desc - """) - List topLocationIdsSince(@Param("since") Instant since, Pageable pageable); - - @Query(""" - select r.temperature, count(r) from WeatherReport r - where r.locationId = :locationId and r.createdAt >= :since - group by r.temperature - """) - List temperatureCounts(@Param("locationId") Long locationId, @Param("since") Instant since); - - @Query(""" - select r.precipitation, count(r) from WeatherReport r - where r.locationId = :locationId and r.createdAt >= :since - group by r.precipitation - """) - List precipitationCounts(@Param("locationId") Long locationId, @Param("since") Instant since); - - @Query(""" - select r.sunlight, count(r) from WeatherReport r - where r.locationId = :locationId and r.createdAt >= :since - group by r.sunlight - """) - List sunlightCounts(@Param("locationId") Long locationId, @Param("since") Instant since); } diff --git a/report/src/main/java/com/nalssilog/report/repository/WeatherReportRepository.java b/report/src/main/java/com/nalssilog/report/repository/WeatherReportRepository.java index 21ec688..a6b16a1 100644 --- a/report/src/main/java/com/nalssilog/report/repository/WeatherReportRepository.java +++ b/report/src/main/java/com/nalssilog/report/repository/WeatherReportRepository.java @@ -1,13 +1,22 @@ package com.nalssilog.report.repository; +import static com.nalssilog.report.domain.QWeatherReport.weatherReport; + import com.nalssilog.common.exception.NalssiLogException; import com.nalssilog.report.application.dto.ReportData; import com.nalssilog.report.application.dto.WeatherStatsData; +import com.nalssilog.report.domain.ActorType; import com.nalssilog.report.domain.Precipitation; import com.nalssilog.report.domain.ReportErrorCode; import com.nalssilog.report.domain.Sunlight; import com.nalssilog.report.domain.Temperature; import com.nalssilog.report.domain.WeatherReport; +import com.querydsl.core.Tuple; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.EnumPath; +import com.querydsl.core.types.dsl.NumberExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; import java.time.Instant; import java.util.EnumMap; import java.util.List; @@ -18,13 +27,16 @@ import org.springframework.stereotype.Repository; /** - * 서비스 호출용 WeatherReport 저장소 래퍼. 조회는 ReportData(회원·지역 enrich 전) 로 반환한다. + * 서비스 호출용 WeatherReport 저장소. + * 단순 조회는 Spring Data JPA에 위임하고, 복합 조회와 집계는 QueryDSL로 처리한다. */ @Repository @RequiredArgsConstructor public class WeatherReportRepository { private final WeatherReportJpaRepository weatherReportJpaRepository; + private final JPAQueryFactory queryFactory; + private final EntityManager entityManager; public ReportData save(WeatherReport report) { return ReportData.of(weatherReportJpaRepository.save(report)); @@ -34,7 +46,20 @@ public ReportData save(WeatherReport report) { * 탈퇴 회원의 제보를 익명화한다(삭제 없이 작성자만 ANONYMOUS 로). 반환값은 익명화된 제보 수. */ public int anonymizeAuthor(Long memberId) { - return weatherReportJpaRepository.anonymizeByMemberId(memberId, "withdrawn-" + memberId); + entityManager.flush(); + long affectedRows = queryFactory + .update(weatherReport) + .set(weatherReport.authorType, ActorType.ANONYMOUS) + .setNull(weatherReport.authorMemberId) + .set(weatherReport.authorAnonymousKey, "withdrawn-" + memberId) + .where( + weatherReport.authorType.eq(ActorType.MEMBER), + weatherReport.authorMemberId.eq(memberId) + ) + .execute(); + entityManager.clear(); + + return Math.toIntExact(affectedRows); } public ReportData getReport(Long reportId) { @@ -51,12 +76,11 @@ public void delete(WeatherReport report) { weatherReportJpaRepository.delete(report); } - public List findPage(Long locationId, Instant since, Instant cursorTime, Long cursorId, int limit) { + public List findPage(Long locationId, Instant cursorTime, Long cursorId, int limit) { Pageable pageable = PageRequest.of(0, limit); - List reports = cursorTime == null - ? weatherReportJpaRepository.findFirstPage(locationId, since, pageable) - : weatherReportJpaRepository.findAfterCursor(locationId, since, cursorTime, cursorId, pageable); + ? weatherReportJpaRepository.findAllByLocationIdOrderByCreatedAtDescIdDesc(locationId, pageable) + : findAfterLocationCursor(locationId, cursorTime, cursorId, limit); return reports.stream() .map(ReportData::of) @@ -65,44 +89,88 @@ public List findPage(Long locationId, Instant since, Instant cursorT public List findMemberPage(Long memberId, Instant cursorTime, Long cursorId, int limit) { Pageable pageable = PageRequest.of(0, limit); - List reports = cursorTime == null - ? weatherReportJpaRepository.findFirstMemberPage(memberId, pageable) - : weatherReportJpaRepository.findMemberAfterCursor(memberId, cursorTime, cursorId, pageable); + ? weatherReportJpaRepository.findAllByAuthorTypeAndAuthorMemberIdOrderByCreatedAtDescIdDesc( + ActorType.MEMBER, memberId, pageable) + : findAfterMemberCursor(memberId, cursorTime, cursorId, limit); return reports.stream() .map(ReportData::of) .toList(); } - /** - * 최근({@code since} 이후) 제보 수가 많은 순으로 상위 locationId 목록(인기 지역 랭킹용). - */ + /** 최근({@code since} 이후) 제보 수가 많은 순으로 상위 locationId 목록(인기 지역 랭킹용). */ public List topLocationIds(Instant since, int size) { - return weatherReportJpaRepository.topLocationIdsSince(since, PageRequest.of(0, size)); + return queryFactory + .select(weatherReport.locationId) + .from(weatherReport) + .where(weatherReport.createdAt.goe(since)) + .groupBy(weatherReport.locationId) + .orderBy(weatherReport.id.count().desc()) + .limit(size) + .fetch(); } - /** - * 최근({@code since} 이후) 제보의 3축 분포 + 제보 수 집계. - */ + /** 최근({@code since} 이후) 제보의 3축 분포 + 제보 수 집계. */ public WeatherStatsData statsSince(Long locationId, Instant since) { long reportCount = weatherReportJpaRepository .countByLocationIdAndCreatedAtGreaterThanEqual(locationId, since); return new WeatherStatsData( reportCount, - toEnumMap(weatherReportJpaRepository.temperatureCounts(locationId, since), Temperature.class), - toEnumMap(weatherReportJpaRepository.precipitationCounts(locationId, since), Precipitation.class), - toEnumMap(weatherReportJpaRepository.sunlightCounts(locationId, since), Sunlight.class) + countByAxis(weatherReport.temperature, Temperature.class, locationId, since), + countByAxis(weatherReport.precipitation, Precipitation.class, locationId, since), + countByAxis(weatherReport.sunlight, Sunlight.class, locationId, since) ); } - private static > Map toEnumMap(List rows, Class type) { - Map counts = new EnumMap<>(type); + private List findAfterLocationCursor( + Long locationId, Instant cursorTime, Long cursorId, int limit) { + return queryFactory + .selectFrom(weatherReport) + .where( + weatherReport.locationId.eq(locationId), + beforeCursor(cursorTime, cursorId) + ) + .orderBy(weatherReport.createdAt.desc(), weatherReport.id.desc()) + .limit(limit) + .fetch(); + } + + private List findAfterMemberCursor( + Long memberId, Instant cursorTime, Long cursorId, int limit) { + return queryFactory + .selectFrom(weatherReport) + .where( + weatherReport.authorType.eq(ActorType.MEMBER), + weatherReport.authorMemberId.eq(memberId), + beforeCursor(cursorTime, cursorId) + ) + .orderBy(weatherReport.createdAt.desc(), weatherReport.id.desc()) + .limit(limit) + .fetch(); + } - for (Object[] row : rows) { - counts.put(type.cast(row[0]), (Long) row[1]); - } + private BooleanExpression beforeCursor(Instant cursorTime, Long cursorId) { + return weatherReport.createdAt.lt(cursorTime) + .or(weatherReport.createdAt.eq(cursorTime).and(weatherReport.id.lt(cursorId))); + } + + private > Map countByAxis( + EnumPath axis, Class type, Long locationId, Instant since) { + NumberExpression count = weatherReport.id.count(); + List rows = queryFactory + .select(axis, count) + .from(weatherReport) + .where( + weatherReport.locationId.eq(locationId), + weatherReport.createdAt.goe(since) + ) + .groupBy(axis) + .fetch(); + + Map counts = new EnumMap<>(type); + rows.forEach(row -> counts.put(row.get(axis), row.get(count))); return counts; } diff --git a/report/src/test/java/com/nalssilog/report/application/ReportServiceTest.java b/report/src/test/java/com/nalssilog/report/application/ReportServiceTest.java index d1cab22..e8ffc7b 100644 --- a/report/src/test/java/com/nalssilog/report/application/ReportServiceTest.java +++ b/report/src/test/java/com/nalssilog/report/application/ReportServiceTest.java @@ -2,7 +2,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.catchThrowableOfType; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; @@ -126,7 +125,7 @@ void detailMarksPreLoginAnonymousReportAsMineAfterLogin() { @Test void listIncludesOwnershipCalculatedFromAllAvailableActors() { ReportData data = anonymousData("anonymous-key"); - when(reportRepository.findPage(eq(1L), any(Instant.class), isNull(), isNull(), eq(21))) + when(reportRepository.findPage(eq(1L), isNull(), isNull(), eq(21))) .thenReturn(List.of(data)); when(locationClient.getLocation(1L)).thenReturn(location()); when(thanksRepository.countByReportIds(List.of(10L))).thenReturn(Map.of());