From a0b35d55277352a6b27f6537bb8eaa28efbbb93a Mon Sep 17 00:00:00 2001 From: gihhyeon Date: Wed, 14 Oct 2026 21:52:07 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EB=A7=9B=EC=A7=91=20=EC=83=81?= =?UTF-8?q?=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20=EC=9B=90=EB=B3=B8=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=20=EC=A0=81=EC=9E=AC=20=EB=B0=8F=20=EA=B8=B0=EB=A1=9D?= =?UTF-8?q?=20=EC=9C=84=EC=B9=98=EB=A5=BC=20=EC=8A=B9=EC=9D=B8=20=EA=B2=80?= =?UTF-8?q?=EC=82=AC=20=EB=92=A4=EB=A1=9C=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../place/domain/entity/PlaceViewLog.java | 43 ++++++++++++++ .../repository/PlaceViewLogRepository.java | 7 +++ .../place/service/PlaceReadService.java | 15 ++++- .../place/service/ViewCountService.java | 11 ++-- .../application/PlaceReadServiceTest.java | 57 +++++++++++++++++++ 5 files changed, 125 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/matzip/place/domain/entity/PlaceViewLog.java create mode 100644 src/main/java/com/matzip/place/repository/PlaceViewLogRepository.java diff --git a/src/main/java/com/matzip/place/domain/entity/PlaceViewLog.java b/src/main/java/com/matzip/place/domain/entity/PlaceViewLog.java new file mode 100644 index 0000000..bc93828 --- /dev/null +++ b/src/main/java/com/matzip/place/domain/entity/PlaceViewLog.java @@ -0,0 +1,43 @@ +package com.matzip.place.domain.entity; + +import com.matzip.common.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +/** + * 맛집 상세 조회 1건의 원본 로그. 조회수 일별 검증 보정의 기준 데이터다. + * 조회 시각은 createdAt을 사용하고, 쓰기를 가볍게 하려고 place는 연관관계 없이 id만 둔다. + */ +@Entity +@Table(name = "place_view_log", + indexes = @Index(name = "idx_place_view_log_date_place", columnList = "view_date, place_id")) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PlaceViewLog extends BaseEntity { + + @Column(name = "place_id", nullable = false) + private Long placeId; + + @Column(name = "user_id") + private Long userId; + + @Column(name = "view_date", nullable = false) + private LocalDate viewDate; + + private PlaceViewLog(Long placeId, Long userId, LocalDate viewDate) { + this.placeId = placeId; + this.userId = userId; + this.viewDate = viewDate; + } + + public static PlaceViewLog of(Long placeId, Long userId, LocalDate viewDate) { + return new PlaceViewLog(placeId, userId, viewDate); + } +} diff --git a/src/main/java/com/matzip/place/repository/PlaceViewLogRepository.java b/src/main/java/com/matzip/place/repository/PlaceViewLogRepository.java new file mode 100644 index 0000000..40ee021 --- /dev/null +++ b/src/main/java/com/matzip/place/repository/PlaceViewLogRepository.java @@ -0,0 +1,7 @@ +package com.matzip.place.repository; + +import com.matzip.place.domain.entity.PlaceViewLog; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PlaceViewLogRepository extends JpaRepository { +} diff --git a/src/main/java/com/matzip/place/service/PlaceReadService.java b/src/main/java/com/matzip/place/service/PlaceReadService.java index 78dd7cb..873a2b9 100644 --- a/src/main/java/com/matzip/place/service/PlaceReadService.java +++ b/src/main/java/com/matzip/place/service/PlaceReadService.java @@ -23,6 +23,7 @@ import com.matzip.place.domain.entity.Place; import com.matzip.place.domain.entity.PlaceCategory; import com.matzip.place.domain.entity.PlaceTag; +import com.matzip.place.domain.entity.PlaceViewLog; import com.matzip.place.domain.entity.Tag; import com.matzip.place.dto.CategoryDto; import com.matzip.place.dto.TagDto; @@ -34,6 +35,7 @@ import com.matzip.place.repository.PlaceLikeRepository; import com.matzip.place.repository.PlaceRepository; import com.matzip.place.repository.PlaceTagRepository; +import com.matzip.place.repository.PlaceViewLogRepository; import com.matzip.user.domain.User; import com.matzip.user.repository.UserRepository; import lombok.RequiredArgsConstructor; @@ -58,6 +60,7 @@ public class PlaceReadService { private final PlaceCategoryRepository placeCategoryRepository; private final PlaceTagRepository placeTagRepository; private final DailyViewCountRepository dailyViewCountRepository; + private final PlaceViewLogRepository placeViewLogRepository; private final CategoryRepository categoryRepository; private final PlaceLikeRepository placeLikeRepository; private final UserRepository userRepository; @@ -70,8 +73,6 @@ public class PlaceReadService { @Transactional public PlaceDetailResponseDto getPlaceDetail(Long placeId, Long userId) { - viewCountService.incrementAllCounts(placeId); - Place place = placeRepository.findById(placeId) .orElseThrow(() -> new BusinessException(ErrorCode.PLACE_NOT_FOUND)); @@ -79,6 +80,8 @@ public PlaceDetailResponseDto getPlaceDetail(Long placeId, Long userId) { throw new BusinessException(ErrorCode.PLACE_NOT_FOUND); } + recordView(placeId, userId); + PlaceRelatedData relatedData = getPlaceRelatedData(place); List menus = menuRepository.findByPlaceOrderByIsRecommendedDescNameAsc(place); @@ -87,6 +90,14 @@ public PlaceDetailResponseDto getPlaceDetail(Long placeId, Long userId) { return PlaceDetailResponseDto.from(place, relatedData.photos(), menus, relatedData.categories(), relatedData.tags(), isLiked); } + // 원본 로그는 요청 트랜잭션에서 동기로 남기고, 집계는 비동기로 올린다 + // 조회 날짜를 여기서 고정해 넘겨야 자정 직전 요청이 지연돼도 로그와 집계 날짜가 갈라지지 않는다 + private void recordView(Long placeId, Long userId) { + LocalDate viewDate = LocalDate.now(); + placeViewLogRepository.save(PlaceViewLog.of(placeId, userId, viewDate)); + viewCountService.incrementAllCounts(placeId, viewDate); + } + public List findPlacesInMapBounds(MapSearchRequestDto requestDto) { List places; diff --git a/src/main/java/com/matzip/place/service/ViewCountService.java b/src/main/java/com/matzip/place/service/ViewCountService.java index a3b1d78..d36d499 100644 --- a/src/main/java/com/matzip/place/service/ViewCountService.java +++ b/src/main/java/com/matzip/place/service/ViewCountService.java @@ -21,18 +21,17 @@ public class ViewCountService { @Async(AsyncConfiguration.GENERAL_EXECUTOR) @Transactional(propagation = Propagation.REQUIRES_NEW) - public void incrementAllCounts(Long placeId) { + public void incrementAllCounts(Long placeId, LocalDate viewDate) { placeRepository.incrementViewCount(placeId); - incrementDailyViewCount(placeId); + incrementDailyViewCount(placeId, viewDate); } - private void incrementDailyViewCount(Long placeId) { - LocalDate today = LocalDate.now(); - int updatedRows = dailyViewCountRepository.incrementDailyViewCount(placeId, today); + private void incrementDailyViewCount(Long placeId, LocalDate viewDate) { + int updatedRows = dailyViewCountRepository.incrementDailyViewCount(placeId, viewDate); if (updatedRows == 0) { placeRepository.findById(placeId).ifPresent(place -> { - DailyViewCount newDailyViewCount = new DailyViewCount(place, today, 1); + DailyViewCount newDailyViewCount = new DailyViewCount(place, viewDate, 1); dailyViewCountRepository.save(newDailyViewCount); }); } diff --git a/src/test/java/com/matzip/place/application/PlaceReadServiceTest.java b/src/test/java/com/matzip/place/application/PlaceReadServiceTest.java index 424abd3..ccc1924 100644 --- a/src/test/java/com/matzip/place/application/PlaceReadServiceTest.java +++ b/src/test/java/com/matzip/place/application/PlaceReadServiceTest.java @@ -9,6 +9,9 @@ import com.matzip.place.dto.response.PlaceCommonResponseDto; import com.matzip.place.repository.DailyViewCountRepository; import com.matzip.place.repository.PlaceRepository; +import com.matzip.place.repository.PlaceViewLogRepository; +import com.matzip.place.domain.entity.PlaceViewLog; +import com.matzip.common.exception.BusinessException; import com.matzip.user.domain.User; import com.matzip.user.repository.UserRepository; @@ -30,6 +33,7 @@ import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.awaitility.Awaitility.await; @Slf4j @@ -48,6 +52,9 @@ public class PlaceReadServiceTest { @Autowired private DailyViewCountRepository dailyViewCountRepository; + @Autowired + private PlaceViewLogRepository placeViewLogRepository; + @Autowired private PlatformTransactionManager transactionManager; @@ -58,6 +65,7 @@ public class PlaceReadServiceTest { void setUp() { // 모든 테스트 전에 실행될 초기 데이터 설정 dailyViewCountRepository.deleteAll(); + placeViewLogRepository.deleteAll(); placeRepository.deleteAll(); userRepository.deleteAll(); @@ -92,6 +100,55 @@ void getPlaceDetail_incrementsViewCount() { }); } + @Test + @DisplayName("상세 페이지를 조회하면 원본 로그가 남고, 같은 날짜의 일별 조회수가 증가한다") + void getPlaceDetail_recordsViewLogAndDailyCountOnSameDate() { + // when + placeReadService.getPlaceDetail(testPlace.getId(), testUser.getId()); + + // then + List logs = placeViewLogRepository.findAll(); + assertThat(logs).hasSize(1); + PlaceViewLog viewLog = logs.get(0); + assertThat(viewLog.getPlaceId()).isEqualTo(testPlace.getId()); + assertThat(viewLog.getUserId()).isEqualTo(testUser.getId()); + + await().atMost(5, TimeUnit.SECONDS) + .untilAsserted(() -> assertThat(dailyViewCountRepository.findAll()) + .singleElement() + .satisfies(daily -> { + assertThat(daily.getViewDate()).isEqualTo(viewLog.getViewDate()); + assertThat(daily.getCount()).isEqualTo(1); + })); + } + + @Test + @DisplayName("미승인 맛집 상세 조회는 원본 로그와 조회수를 남기지 않는다") + void getPlaceDetail_pendingPlace_doesNotRecordView() { + // given + Place pendingPlace = placeRepository.save(Place.builder() + .name("미승인 맛집") + .address("주소") + .kakaoPlaceId("pending") + .campus(Campus.CHEONAN) + .latitude(36.1) + .longitude(127.1) + .status(PlaceStatus.PENDING) + .build()); + + // when + assertThatThrownBy(() -> placeReadService.getPlaceDetail(pendingPlace.getId(), testUser.getId())) + .isInstanceOf(BusinessException.class); + + // then: 비동기 증가가 호출되지 않았음을 확인하려고 잠시 기다린 뒤 검증한다 + await().during(1, TimeUnit.SECONDS).atMost(2, TimeUnit.SECONDS) + .untilAsserted(() -> { + assertThat(placeViewLogRepository.count()).isZero(); + assertThat(dailyViewCountRepository.count()).isZero(); + assertThat(placeRepository.findById(pendingPlace.getId()).get().getViewCount()).isZero(); + }); + } + @Test @DisplayName("여러 요청이 동시에 들어와도 조회수가 정확하게 증가한다") void getPlaceDetail_concurrentAccess() throws InterruptedException { From 658624c413d2edb88eed995227a3a4c0edcc5e6a Mon Sep 17 00:00:00 2001 From: gihhyeon Date: Wed, 14 Oct 2026 21:54:13 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EC=A1=B0=ED=9A=8C=EC=88=98=20?= =?UTF-8?q?=EC=9D=BC=EB=B3=84=20=EA=B2=80=EC=A6=9D=20=EB=B3=B4=EC=A0=95=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=EC=99=80=20=EC=8A=A4=EC=BC=80?= =?UTF-8?q?=EC=A4=84=EB=9F=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/matzip/MatzipServerApplication.java | 4 +- .../ViewCountReconciliationProperties.java | 17 ++ .../entity/ViewCountReconciliation.java | 54 +++++ .../matzip/place/dto/PlaceViewTallyDto.java | 7 + .../repository/DailyViewCountRepository.java | 10 + .../place/repository/PlaceRepository.java | 4 + .../repository/PlaceViewLogRepository.java | 15 ++ .../ViewCountReconciliationRepository.java | 11 + .../ViewCountReconciliationScheduler.java | 90 ++++++++ .../ViewCountReconciliationService.java | 101 +++++++++ src/main/resources/application.yml | 5 + .../ViewCountReconciliationSchedulerTest.java | 139 +++++++++++++ .../ViewCountReconciliationServiceTest.java | 193 ++++++++++++++++++ 13 files changed, 649 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/matzip/common/config/ViewCountReconciliationProperties.java create mode 100644 src/main/java/com/matzip/place/domain/entity/ViewCountReconciliation.java create mode 100644 src/main/java/com/matzip/place/dto/PlaceViewTallyDto.java create mode 100644 src/main/java/com/matzip/place/repository/ViewCountReconciliationRepository.java create mode 100644 src/main/java/com/matzip/place/service/ViewCountReconciliationScheduler.java create mode 100644 src/main/java/com/matzip/place/service/ViewCountReconciliationService.java create mode 100644 src/test/java/com/matzip/place/service/ViewCountReconciliationSchedulerTest.java create mode 100644 src/test/java/com/matzip/place/service/ViewCountReconciliationServiceTest.java diff --git a/src/main/java/com/matzip/MatzipServerApplication.java b/src/main/java/com/matzip/MatzipServerApplication.java index ba2c8d2..83a10ac 100644 --- a/src/main/java/com/matzip/MatzipServerApplication.java +++ b/src/main/java/com/matzip/MatzipServerApplication.java @@ -4,6 +4,7 @@ import com.matzip.common.config.DiscordWebhookProperties; import com.matzip.common.config.JwtProperties; import com.matzip.common.config.KakaoProperties; +import com.matzip.common.config.ViewCountReconciliationProperties; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.EnableConfigurationProperties; @@ -14,7 +15,8 @@ @EnableConfigurationProperties({JwtProperties.class, AuthRedirectProperties.class, KakaoProperties.class, - DiscordWebhookProperties.class}) + DiscordWebhookProperties.class, + ViewCountReconciliationProperties.class}) public class MatzipServerApplication { public static void main(String[] args) { diff --git a/src/main/java/com/matzip/common/config/ViewCountReconciliationProperties.java b/src/main/java/com/matzip/common/config/ViewCountReconciliationProperties.java new file mode 100644 index 0000000..4caf1d4 --- /dev/null +++ b/src/main/java/com/matzip/common/config/ViewCountReconciliationProperties.java @@ -0,0 +1,17 @@ +package com.matzip.common.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@Setter +@ConfigurationProperties(prefix = "app.view-count.reconciliation") +public class ViewCountReconciliationProperties { + + // 원본 로그 보존 기간이자 밀린 날짜를 거슬러 처리할 수 있는 최대 범위 + private int retentionDays = 60; + + // 누락률(누락 건수 / 로그 건수)이 이 값을 넘으면 Discord로 알린다 + private double alertLossRateThreshold = 0.01; +} diff --git a/src/main/java/com/matzip/place/domain/entity/ViewCountReconciliation.java b/src/main/java/com/matzip/place/domain/entity/ViewCountReconciliation.java new file mode 100644 index 0000000..d052428 --- /dev/null +++ b/src/main/java/com/matzip/place/domain/entity/ViewCountReconciliation.java @@ -0,0 +1,54 @@ +package com.matzip.place.domain.entity; + +import com.matzip.common.entity.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; + +/** + * 하루치 조회수 검증 보정 결과. 보정 전 집계와 원본 로그를 비교한 값을 남긴다. + * - missingCount: 로그보다 집계가 적었던 만큼의 합 (비동기 증가 유실분) + * - overCount: 로그보다 집계가 많았던 만큼의 합 (집계 후 롤백된 요청 등) + */ +@Entity +@Table(name = "view_count_reconciliation") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class ViewCountReconciliation extends BaseEntity { + + @Column(name = "target_date", nullable = false, unique = true) + private LocalDate targetDate; + + @Column(name = "logged_count", nullable = false) + private long loggedCount; + + @Column(name = "aggregated_count", nullable = false) + private long aggregatedCount; + + @Column(name = "missing_count", nullable = false) + private long missingCount; + + @Column(name = "over_count", nullable = false) + private long overCount; + + public ViewCountReconciliation(LocalDate targetDate, long loggedCount, long aggregatedCount, + long missingCount, long overCount) { + this.targetDate = targetDate; + this.loggedCount = loggedCount; + this.aggregatedCount = aggregatedCount; + this.missingCount = missingCount; + this.overCount = overCount; + } + + public double lossRate() { + if (loggedCount == 0) { + return 0.0; + } + return (double) missingCount / loggedCount; + } +} diff --git a/src/main/java/com/matzip/place/dto/PlaceViewTallyDto.java b/src/main/java/com/matzip/place/dto/PlaceViewTallyDto.java new file mode 100644 index 0000000..9529e50 --- /dev/null +++ b/src/main/java/com/matzip/place/dto/PlaceViewTallyDto.java @@ -0,0 +1,7 @@ +package com.matzip.place.dto; + +/** + * 특정 날짜의 맛집별 조회수 합계. 원본 로그와 일별 집계를 같은 형태로 비교하려고 쓴다. + */ +public record PlaceViewTallyDto(Long placeId, Long count) { +} diff --git a/src/main/java/com/matzip/place/repository/DailyViewCountRepository.java b/src/main/java/com/matzip/place/repository/DailyViewCountRepository.java index 5fee129..f5400cc 100644 --- a/src/main/java/com/matzip/place/repository/DailyViewCountRepository.java +++ b/src/main/java/com/matzip/place/repository/DailyViewCountRepository.java @@ -3,6 +3,7 @@ import com.matzip.place.domain.Campus; import com.matzip.place.domain.DailyViewCount; import com.matzip.place.domain.entity.Place; +import com.matzip.place.dto.PlaceViewTallyDto; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; @@ -28,4 +29,13 @@ List findDailyRankingByCampus( @Modifying @Query("UPDATE DailyViewCount d SET d.count = d.count + 1 WHERE d.place.id = :placeId AND d.viewDate = :date") int incrementDailyViewCount(@Param("placeId") Long placeId, @Param("date") LocalDate date); + + // 동시 첫 조회로 같은 날짜 행이 중복될 수 있어 place별로 합산한다 + @Query("SELECT new com.matzip.place.dto.PlaceViewTallyDto(d.place.id, SUM(d.count)) " + + "FROM DailyViewCount d WHERE d.viewDate = :date GROUP BY d.place.id") + List sumByPlaceOn(@Param("date") LocalDate date); + + @Modifying(flushAutomatically = true, clearAutomatically = true) + @Query("DELETE FROM DailyViewCount d WHERE d.viewDate = :date") + int deleteByViewDate(@Param("date") LocalDate date); } diff --git a/src/main/java/com/matzip/place/repository/PlaceRepository.java b/src/main/java/com/matzip/place/repository/PlaceRepository.java index 49e882e..1bee4b6 100644 --- a/src/main/java/com/matzip/place/repository/PlaceRepository.java +++ b/src/main/java/com/matzip/place/repository/PlaceRepository.java @@ -52,6 +52,10 @@ List findWithinBoundsAndSortByDistance( @Query("UPDATE Place p SET p.viewCount = p.viewCount + 1 WHERE p.id = :placeId") void incrementViewCount(@Param("placeId") Long placeId); + @Modifying + @Query("UPDATE Place p SET p.viewCount = p.viewCount + :delta WHERE p.id = :placeId") + void addViewCount(@Param("placeId") Long placeId, @Param("delta") long delta); + @Modifying @Query("UPDATE Place p SET p.likeCount = p.likeCount + 1 WHERE p.id = :placeId") void incrementLikeCount(@Param("placeId") Long placeId); diff --git a/src/main/java/com/matzip/place/repository/PlaceViewLogRepository.java b/src/main/java/com/matzip/place/repository/PlaceViewLogRepository.java index 40ee021..c02a003 100644 --- a/src/main/java/com/matzip/place/repository/PlaceViewLogRepository.java +++ b/src/main/java/com/matzip/place/repository/PlaceViewLogRepository.java @@ -1,7 +1,22 @@ package com.matzip.place.repository; import com.matzip.place.domain.entity.PlaceViewLog; +import com.matzip.place.dto.PlaceViewTallyDto; 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; + +import java.time.LocalDate; +import java.util.List; public interface PlaceViewLogRepository extends JpaRepository { + + @Query("SELECT new com.matzip.place.dto.PlaceViewTallyDto(l.placeId, COUNT(l)) " + + "FROM PlaceViewLog l WHERE l.viewDate = :date GROUP BY l.placeId") + List countByPlaceOn(@Param("date") LocalDate date); + + @Modifying + @Query("DELETE FROM PlaceViewLog l WHERE l.viewDate < :date") + int deleteByViewDateBefore(@Param("date") LocalDate date); } diff --git a/src/main/java/com/matzip/place/repository/ViewCountReconciliationRepository.java b/src/main/java/com/matzip/place/repository/ViewCountReconciliationRepository.java new file mode 100644 index 0000000..62b5f80 --- /dev/null +++ b/src/main/java/com/matzip/place/repository/ViewCountReconciliationRepository.java @@ -0,0 +1,11 @@ +package com.matzip.place.repository; + +import com.matzip.place.domain.entity.ViewCountReconciliation; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface ViewCountReconciliationRepository extends JpaRepository { + + Optional findTopByOrderByTargetDateDesc(); +} diff --git a/src/main/java/com/matzip/place/service/ViewCountReconciliationScheduler.java b/src/main/java/com/matzip/place/service/ViewCountReconciliationScheduler.java new file mode 100644 index 0000000..925689f --- /dev/null +++ b/src/main/java/com/matzip/place/service/ViewCountReconciliationScheduler.java @@ -0,0 +1,90 @@ +package com.matzip.place.service; + +import com.matzip.common.config.ViewCountReconciliationProperties; +import com.matzip.common.infra.discord.DiscordWebhookSender; +import com.matzip.place.domain.entity.ViewCountReconciliation; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; + +/** + * 매일 새벽 전날까지의 조회수를 검증 보정하고 보존 기간이 지난 원본 로그를 정리한다. + * 보정 자체는 ViewCountReconciliationService가 날짜별 트랜잭션으로 수행한다(self-invocation 회피용 분리). + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class ViewCountReconciliationScheduler { + + private final ViewCountReconciliationService viewCountReconciliationService; + private final ViewCountReconciliationProperties properties; + private final DiscordWebhookSender discordWebhookSender; + + // zone은 실행 시각만 정한다. 날짜 계산은 조회 로그의 view_date와 맞추려고 LocalDate.now()를 그대로 쓴다 + @Scheduled(cron = "${app.view-count.reconciliation.cron:0 0 4 * * *}", zone = "Asia/Seoul") + public void run() { + reconcileUntil(LocalDate.now()); + } + + void reconcileUntil(LocalDate today) { + LocalDate yesterday = today.minusDays(1); + LocalDate oldestRetained = today.minusDays(properties.getRetentionDays()); + + // 이력이 없으면 어제만 처리한다. 로그 도입 이전 날짜를 보정하면 집계가 0으로 덮이기 때문이다 + LocalDate from = viewCountReconciliationService.findLastReconciledDate() + .map(last -> last.plusDays(1)) + .orElse(yesterday); + if (from.isBefore(oldestRetained)) { + from = oldestRetained; + } + + for (LocalDate date = from; !date.isAfter(yesterday); date = date.plusDays(1)) { + reconcileSafely(date); + } + + try { + int purged = viewCountReconciliationService.purgeLogsBefore(oldestRetained); + log.info("[조회수 검증 보정] {} 이전 원본 로그 {}건 삭제", oldestRetained, purged); + } catch (Exception e) { + log.error("[조회수 검증 보정] 원본 로그 정리 실패. before={}", oldestRetained, e); + } + } + + private void reconcileSafely(LocalDate date) { + try { + ViewCountReconciliation result = viewCountReconciliationService.reconcile(date); + log.info("[조회수 검증 보정] date={}, logged={}, aggregated={}, missing={}, over={}", + date, result.getLoggedCount(), result.getAggregatedCount(), + result.getMissingCount(), result.getOverCount()); + if (needsAlert(result)) { + notifyMismatch(result); + } + } catch (Exception e) { + log.error("[조회수 검증 보정] 실패. date={}", date, e); + discordWebhookSender.sendAsync(""" + **조회수 검증 보정 실패** + - 날짜: %s + - 오류: %s + """.formatted(date, e.getMessage())); + } + } + + private boolean needsAlert(ViewCountReconciliation result) { + return result.lossRate() > properties.getAlertLossRateThreshold() || result.getOverCount() > 0; + } + + private void notifyMismatch(ViewCountReconciliation result) { + discordWebhookSender.sendAsync(""" + **조회수 검증 보정 결과** + - 날짜: %s + - 원본 로그: %d건 + - 보정 전 집계: %d건 + - 누락: %d건 (%.2f%%) + - 과집계: %d건 + """.formatted(result.getTargetDate(), result.getLoggedCount(), result.getAggregatedCount(), + result.getMissingCount(), result.lossRate() * 100, result.getOverCount())); + } +} diff --git a/src/main/java/com/matzip/place/service/ViewCountReconciliationService.java b/src/main/java/com/matzip/place/service/ViewCountReconciliationService.java new file mode 100644 index 0000000..40e727d --- /dev/null +++ b/src/main/java/com/matzip/place/service/ViewCountReconciliationService.java @@ -0,0 +1,101 @@ +package com.matzip.place.service; + +import com.matzip.place.domain.DailyViewCount; +import com.matzip.place.domain.entity.Place; +import com.matzip.place.domain.entity.ViewCountReconciliation; +import com.matzip.place.dto.PlaceViewTallyDto; +import com.matzip.place.repository.DailyViewCountRepository; +import com.matzip.place.repository.PlaceRepository; +import com.matzip.place.repository.PlaceViewLogRepository; +import com.matzip.place.repository.ViewCountReconciliationRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 원본 조회 로그(place_view_log)를 기준으로 하루치 조회수를 검증하고 보정한다. + * 보정 후에는 집계와 로그가 같아지므로 같은 날짜를 다시 보정해도 delta는 0이다. + */ +@Service +@RequiredArgsConstructor +public class ViewCountReconciliationService { + + private final PlaceViewLogRepository placeViewLogRepository; + private final DailyViewCountRepository dailyViewCountRepository; + private final PlaceRepository placeRepository; + private final ViewCountReconciliationRepository viewCountReconciliationRepository; + + @Transactional + public ViewCountReconciliation reconcile(LocalDate date) { + Map logged = toCountMap(placeViewLogRepository.countByPlaceOn(date)); + Map aggregated = toCountMap(dailyViewCountRepository.sumByPlaceOn(date)); + + Set placeIds = new HashSet<>(logged.keySet()); + placeIds.addAll(aggregated.keySet()); + // 삭제된 맛집은 다시 넣을 대상이 없으므로 비교에서 제외한다 + Map places = placeRepository.findAllById(placeIds).stream() + .collect(Collectors.toMap(Place::getId, Function.identity())); + + long loggedTotal = 0; + long aggregatedTotal = 0; + long missing = 0; + long over = 0; + List corrected = new ArrayList<>(); + Map deltas = new HashMap<>(); + + for (Place place : places.values()) { + long logCount = logged.getOrDefault(place.getId(), 0L); + long aggCount = aggregated.getOrDefault(place.getId(), 0L); + loggedTotal += logCount; + aggregatedTotal += aggCount; + + long delta = logCount - aggCount; + if (delta > 0) { + missing += delta; + } else if (delta < 0) { + over += -delta; + } + if (delta != 0) { + deltas.put(place.getId(), delta); + } + if (logCount > 0) { + corrected.add(new DailyViewCount(place, date, Math.toIntExact(logCount))); + } + } + + // 중복 행까지 한 번에 정리하려고 해당 날짜를 지우고 로그 기준으로 place당 1행씩 다시 넣는다 + dailyViewCountRepository.deleteByViewDate(date); + dailyViewCountRepository.saveAll(corrected); + deltas.forEach(placeRepository::addViewCount); + + return viewCountReconciliationRepository.save( + new ViewCountReconciliation(date, loggedTotal, aggregatedTotal, missing, over)); + } + + @Transactional(readOnly = true) + public Optional findLastReconciledDate() { + return viewCountReconciliationRepository.findTopByOrderByTargetDateDesc() + .map(ViewCountReconciliation::getTargetDate); + } + + @Transactional + public int purgeLogsBefore(LocalDate date) { + return placeViewLogRepository.deleteByViewDateBefore(date); + } + + private Map toCountMap(List tallies) { + return tallies.stream() + .collect(Collectors.toMap(PlaceViewTallyDto::placeId, PlaceViewTallyDto::count)); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2e0136f..38a30df 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -22,6 +22,11 @@ server: app: discord: webhook-url: ${DISCORD_WEBHOOK_URL:} + view-count: + reconciliation: + cron: "0 0 4 * * *" + retention-days: 60 + alert-loss-rate-threshold: 0.01 springdoc: swagger-ui: diff --git a/src/test/java/com/matzip/place/service/ViewCountReconciliationSchedulerTest.java b/src/test/java/com/matzip/place/service/ViewCountReconciliationSchedulerTest.java new file mode 100644 index 0000000..ff194c7 --- /dev/null +++ b/src/test/java/com/matzip/place/service/ViewCountReconciliationSchedulerTest.java @@ -0,0 +1,139 @@ +package com.matzip.place.service; + +import com.matzip.common.config.ViewCountReconciliationProperties; +import com.matzip.common.infra.discord.DiscordWebhookSender; +import com.matzip.place.domain.entity.ViewCountReconciliation; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.contains; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class ViewCountReconciliationSchedulerTest { + + private static final LocalDate TODAY = LocalDate.of(2026, 3, 10); + + @Mock + private ViewCountReconciliationService viewCountReconciliationService; + + @Mock + private DiscordWebhookSender discordWebhookSender; + + private ViewCountReconciliationScheduler scheduler; + + @BeforeEach + void setUp() { + ViewCountReconciliationProperties properties = new ViewCountReconciliationProperties(); + properties.setRetentionDays(60); + properties.setAlertLossRateThreshold(0.01); + scheduler = new ViewCountReconciliationScheduler(viewCountReconciliationService, properties, discordWebhookSender); + } + + @Test + void 검증_이력이_없으면_어제만_처리한다() { + given(viewCountReconciliationService.findLastReconciledDate()).willReturn(Optional.empty()); + given(viewCountReconciliationService.reconcile(any())).willAnswer(inv -> result(inv.getArgument(0), 100, 0, 0)); + + scheduler.reconcileUntil(TODAY); + + verify(viewCountReconciliationService, times(1)).reconcile(any()); + verify(viewCountReconciliationService).reconcile(TODAY.minusDays(1)); + verify(viewCountReconciliationService).purgeLogsBefore(TODAY.minusDays(60)); + } + + @Test + void 밀린_날짜를_순서대로_처리한다() { + given(viewCountReconciliationService.findLastReconciledDate()).willReturn(Optional.of(TODAY.minusDays(4))); + given(viewCountReconciliationService.reconcile(any())).willAnswer(inv -> result(inv.getArgument(0), 100, 0, 0)); + + scheduler.reconcileUntil(TODAY); + + InOrder inOrder = inOrder(viewCountReconciliationService); + inOrder.verify(viewCountReconciliationService).reconcile(TODAY.minusDays(3)); + inOrder.verify(viewCountReconciliationService).reconcile(TODAY.minusDays(2)); + inOrder.verify(viewCountReconciliationService).reconcile(TODAY.minusDays(1)); + verify(viewCountReconciliationService, times(3)).reconcile(any()); + } + + @Test + void 어제까지_처리했으면_보정하지_않는다() { + given(viewCountReconciliationService.findLastReconciledDate()).willReturn(Optional.of(TODAY.minusDays(1))); + + scheduler.reconcileUntil(TODAY); + + verify(viewCountReconciliationService, never()).reconcile(any()); + verify(viewCountReconciliationService).purgeLogsBefore(TODAY.minusDays(60)); + } + + @Test + void 밀린_날짜는_보존_기간_이내로만_처리한다() { + given(viewCountReconciliationService.findLastReconciledDate()).willReturn(Optional.of(TODAY.minusDays(100))); + given(viewCountReconciliationService.reconcile(any())).willAnswer(inv -> result(inv.getArgument(0), 100, 0, 0)); + + scheduler.reconcileUntil(TODAY); + + verify(viewCountReconciliationService).reconcile(TODAY.minusDays(60)); + verify(viewCountReconciliationService, never()).reconcile(TODAY.minusDays(61)); + verify(viewCountReconciliationService, times(60)).reconcile(any()); + } + + @Test + void 한_날짜가_실패해도_다음_날짜를_처리하고_실패를_알린다() { + given(viewCountReconciliationService.findLastReconciledDate()).willReturn(Optional.of(TODAY.minusDays(3))); + given(viewCountReconciliationService.reconcile(TODAY.minusDays(2))).willThrow(new IllegalStateException("db down")); + given(viewCountReconciliationService.reconcile(TODAY.minusDays(1))).willReturn(result(TODAY.minusDays(1), 100, 0, 0)); + + scheduler.reconcileUntil(TODAY); + + verify(viewCountReconciliationService).reconcile(TODAY.minusDays(1)); + verify(discordWebhookSender).sendAsync(contains("실패")); + } + + @Test + void 누락률이_임계치를_넘으면_알린다() { + given(viewCountReconciliationService.findLastReconciledDate()).willReturn(Optional.empty()); + given(viewCountReconciliationService.reconcile(any())).willReturn(result(TODAY.minusDays(1), 100, 2, 0)); + + scheduler.reconcileUntil(TODAY); + + verify(discordWebhookSender).sendAsync(contains("누락: 2건")); + } + + @Test + void 누락률이_임계치_이하이고_과집계가_없으면_알리지_않는다() { + given(viewCountReconciliationService.findLastReconciledDate()).willReturn(Optional.empty()); + given(viewCountReconciliationService.reconcile(any())).willReturn(result(TODAY.minusDays(1), 100, 1, 0)); + + scheduler.reconcileUntil(TODAY); + + verify(discordWebhookSender, never()).sendAsync(anyString()); + } + + @Test + void 과집계가_있으면_알린다() { + given(viewCountReconciliationService.findLastReconciledDate()).willReturn(Optional.empty()); + given(viewCountReconciliationService.reconcile(any())).willReturn(result(TODAY.minusDays(1), 100, 0, 1)); + + scheduler.reconcileUntil(TODAY); + + verify(discordWebhookSender).sendAsync(contains("과집계: 1건")); + } + + private ViewCountReconciliation result(LocalDate date, long logged, long missing, long over) { + return new ViewCountReconciliation(date, logged, logged - missing + over, missing, over); + } +} diff --git a/src/test/java/com/matzip/place/service/ViewCountReconciliationServiceTest.java b/src/test/java/com/matzip/place/service/ViewCountReconciliationServiceTest.java new file mode 100644 index 0000000..9c949dd --- /dev/null +++ b/src/test/java/com/matzip/place/service/ViewCountReconciliationServiceTest.java @@ -0,0 +1,193 @@ +package com.matzip.place.service; + +import com.matzip.fixture.PlaceFixtures; +import com.matzip.place.domain.DailyViewCount; +import com.matzip.place.domain.entity.Place; +import com.matzip.place.domain.entity.PlaceViewLog; +import com.matzip.place.domain.entity.ViewCountReconciliation; +import com.matzip.place.repository.DailyViewCountRepository; +import com.matzip.place.repository.PlaceRepository; +import com.matzip.place.repository.PlaceViewLogRepository; +import com.matzip.place.repository.ViewCountReconciliationRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest +class ViewCountReconciliationServiceTest { + + // 다른 테스트가 만드는 오늘/어제 데이터와 섞이지 않도록 고정 날짜를 쓴다 + private static final LocalDate DATE = LocalDate.of(2026, 1, 15); + + @Autowired + private ViewCountReconciliationService viewCountReconciliationService; + + @Autowired + private PlaceRepository placeRepository; + + @Autowired + private PlaceViewLogRepository placeViewLogRepository; + + @Autowired + private DailyViewCountRepository dailyViewCountRepository; + + @Autowired + private ViewCountReconciliationRepository viewCountReconciliationRepository; + + @Autowired + private PlatformTransactionManager transactionManager; + + @BeforeEach + void setUp() { + viewCountReconciliationRepository.deleteAll(); + placeViewLogRepository.deleteAll(); + dailyViewCountRepository.deleteAll(); + } + + @Test + void 집계가_로그보다_적으면_로그_기준으로_보정하고_누락으로_기록한다() { + Place place = createPlaceWithViewCount(2); + saveLogs(place, DATE, 5); + saveDaily(place, DATE, 2); + + ViewCountReconciliation result = viewCountReconciliationService.reconcile(DATE); + + assertThat(result.getLoggedCount()).isEqualTo(5); + assertThat(result.getAggregatedCount()).isEqualTo(2); + assertThat(result.getMissingCount()).isEqualTo(3); + assertThat(result.getOverCount()).isZero(); + assertThat(result.lossRate()).isEqualTo(0.6); + assertThat(dailyCountsOf(place)).containsExactly(5); + assertThat(viewCountOf(place)).isEqualTo(5); + } + + @Test + void 집계가_로그보다_많으면_로그_기준으로_줄이고_과집계로_기록한다() { + Place place = createPlaceWithViewCount(10); + saveLogs(place, DATE, 3); + saveDaily(place, DATE, 4); + + ViewCountReconciliation result = viewCountReconciliationService.reconcile(DATE); + + assertThat(result.getMissingCount()).isZero(); + assertThat(result.getOverCount()).isEqualTo(1); + assertThat(dailyCountsOf(place)).containsExactly(3); + assertThat(viewCountOf(place)).isEqualTo(9); + } + + @Test + void 같은_날짜의_중복_집계_행은_합산해_비교하고_한_행으로_정리한다() { + Place place = createPlaceWithViewCount(4); + saveLogs(place, DATE, 4); + saveDaily(place, DATE, 1); + saveDaily(place, DATE, 3); + + ViewCountReconciliation result = viewCountReconciliationService.reconcile(DATE); + + assertThat(result.getMissingCount()).isZero(); + assertThat(result.getOverCount()).isZero(); + assertThat(dailyCountsOf(place)).containsExactly(4); + assertThat(viewCountOf(place)).isEqualTo(4); + } + + @Test + void 로그가_없는_맛집의_집계는_삭제되고_누적에서_빠진다() { + Place place = createPlaceWithViewCount(7); + saveDaily(place, DATE, 2); + + ViewCountReconciliation result = viewCountReconciliationService.reconcile(DATE); + + assertThat(result.getOverCount()).isEqualTo(2); + assertThat(dailyCountsOf(place)).isEmpty(); + assertThat(viewCountOf(place)).isEqualTo(5); + } + + @Test + void 다른_날짜의_집계는_건드리지_않는다() { + Place place = createPlaceWithViewCount(0); + saveDaily(place, DATE.plusDays(1), 9); + + viewCountReconciliationService.reconcile(DATE); + + assertThat(dailyCountsOf(place)).containsExactly(9); + } + + @Test + void 보정한_날짜를_다시_보정해도_값이_바뀌지_않는다() { + Place place = createPlaceWithViewCount(1); + saveLogs(place, DATE, 3); + saveDaily(place, DATE, 1); + viewCountReconciliationService.reconcile(DATE); + viewCountReconciliationRepository.deleteAll(); + + ViewCountReconciliation second = viewCountReconciliationService.reconcile(DATE); + + assertThat(second.getMissingCount()).isZero(); + assertThat(second.getOverCount()).isZero(); + assertThat(dailyCountsOf(place)).containsExactly(3); + assertThat(viewCountOf(place)).isEqualTo(3); + } + + @Test + void 마지막_검증_날짜를_조회한다() { + assertThat(viewCountReconciliationService.findLastReconciledDate()).isEmpty(); + + viewCountReconciliationService.reconcile(DATE); + viewCountReconciliationService.reconcile(DATE.plusDays(1)); + + assertThat(viewCountReconciliationService.findLastReconciledDate()).contains(DATE.plusDays(1)); + } + + @Test + void 기준일_이전의_원본_로그만_삭제한다() { + Place place = createPlaceWithViewCount(0); + saveLogs(place, DATE.minusDays(1), 2); + saveLogs(place, DATE, 1); + + int purged = viewCountReconciliationService.purgeLogsBefore(DATE); + + assertThat(purged).isEqualTo(2); + assertThat(placeViewLogRepository.findAll()) + .extracting(PlaceViewLog::getViewDate) + .containsExactly(DATE); + } + + private Place createPlaceWithViewCount(int viewCount) { + Place place = placeRepository.save(PlaceFixtures.createPlaceWith(UUID.randomUUID().toString(), null)); + if (viewCount > 0) { + new TransactionTemplate(transactionManager).executeWithoutResult(status -> + placeRepository.addViewCount(place.getId(), viewCount)); + } + return place; + } + + private void saveLogs(Place place, LocalDate date, int times) { + for (int i = 0; i < times; i++) { + placeViewLogRepository.save(PlaceViewLog.of(place.getId(), null, date)); + } + } + + private void saveDaily(Place place, LocalDate date, int count) { + dailyViewCountRepository.save(new DailyViewCount(place, date, count)); + } + + private List dailyCountsOf(Place place) { + return dailyViewCountRepository.findAll().stream() + .filter(daily -> daily.getPlace().getId().equals(place.getId())) + .map(DailyViewCount::getCount) + .toList(); + } + + private int viewCountOf(Place place) { + return placeRepository.findById(place.getId()).orElseThrow().getViewCount(); + } +} From d507bc2b6444a4df3716b7c9b600986db123b9c8 Mon Sep 17 00:00:00 2001 From: gihhyeon Date: Wed, 14 Oct 2026 21:54:28 +0900 Subject: [PATCH 3/3] =?UTF-8?q?chore:=20=EC=A1=B0=ED=9A=8C=EC=88=98=20?= =?UTF-8?q?=EC=9B=90=EB=B3=B8=20=EB=A1=9C=EA=B7=B8=20=EB=B0=8F=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EB=B3=B4=EC=A0=95=20=EA=B2=B0=EA=B3=BC=20=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=B8=94=20prod=20DDL=20=EC=8A=A4=ED=81=AC=EB=A6=BD?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sql/view_count_reconciliation.sql | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) create mode 100644 src/main/resources/sql/view_count_reconciliation.sql diff --git a/src/main/resources/sql/view_count_reconciliation.sql b/src/main/resources/sql/view_count_reconciliation.sql new file mode 100644 index 0000000..837cde3 --- /dev/null +++ b/src/main/resources/sql/view_count_reconciliation.sql @@ -0,0 +1,65 @@ +-- 조회수 원본 로그(place_view_log) 및 일별 검증 보정 결과(view_count_reconciliation) 참고 스크립트 +-- 적재 경로: PlaceReadService.getPlaceDetail 이 요청 트랜잭션에서 place_view_log 를 동기 적재 +-- 검증 보정: ViewCountReconciliationScheduler 가 매일 04:00(KST) 전날까지 로그와 daily_view_count 를 비교해 보정 + +-- ===================================================================== +-- [필수] prod 배포 전 수동 DDL +-- prod 는 ddl-auto: validate 이므로 이 테이블들을 먼저 생성하지 않으면 기동에 실패한다. +-- (local/test 는 자동 생성) 컬럼 정의는 엔티티 매핑과 일치시켜 validate 를 통과시킨다. +-- 배포 후 기동 로그에 스키마 validate 에러가 없는지 확인할 것. +-- ===================================================================== +CREATE TABLE place_view_log ( + id BIGINT NOT NULL AUTO_INCREMENT, + place_id BIGINT NOT NULL, + user_id BIGINT NULL, + view_date DATE NOT NULL, + created_at DATETIME(6) NULL, + updated_at DATETIME(6) NULL, + PRIMARY KEY (id), + KEY idx_place_view_log_date_place (view_date, place_id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4; + +CREATE TABLE view_count_reconciliation ( + id BIGINT NOT NULL AUTO_INCREMENT, + target_date DATE NOT NULL, + logged_count BIGINT NOT NULL, + aggregated_count BIGINT NOT NULL, + missing_count BIGINT NOT NULL, + over_count BIGINT NOT NULL, + created_at DATETIME(6) NULL, + updated_at DATETIME(6) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_view_count_reconciliation_target_date (target_date) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4; + +-- ===================================================================== +-- 조회 쿼리 +-- ===================================================================== + +-- 1) 최근 30일 일별 누락률 +SELECT target_date, + logged_count, + aggregated_count, + missing_count, + over_count, + ROUND(missing_count / NULLIF(logged_count, 0) * 100, 2) AS loss_rate_pct +FROM view_count_reconciliation +ORDER BY target_date DESC +LIMIT 30; + +-- 2) 아직 보정되지 않은 오늘 기준, 맛집별 원본 로그와 집계 비교 +SELECT l.place_id, + l.logged, + COALESCE(d.aggregated, 0) AS aggregated, + l.logged - COALESCE(d.aggregated, 0) AS delta +FROM (SELECT place_id, COUNT(*) AS logged + FROM place_view_log + WHERE view_date = CURDATE() + GROUP BY place_id) l +LEFT JOIN (SELECT place_id, SUM(count) AS aggregated + FROM daily_view_count + WHERE view_date = CURDATE() + GROUP BY place_id) d ON d.place_id = l.place_id +ORDER BY delta DESC;