Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/main/java/com/matzip/MatzipServerApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
43 changes: 43 additions & 0 deletions src/main/java/com/matzip/place/domain/entity/PlaceViewLog.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
7 changes: 7 additions & 0 deletions src/main/java/com/matzip/place/dto/PlaceViewTallyDto.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.matzip.place.dto;

/**
* 특정 날짜의 맛집별 조회수 합계. 원본 로그와 일별 집계를 같은 형태로 비교하려고 쓴다.
*/
public record PlaceViewTallyDto(Long placeId, Long count) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -28,4 +29,13 @@ List<DailyViewCount> 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<PlaceViewTallyDto> 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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ List<Place> 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);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +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<PlaceViewLog, Long> {

@Query("SELECT new com.matzip.place.dto.PlaceViewTallyDto(l.placeId, COUNT(l)) " +
"FROM PlaceViewLog l WHERE l.viewDate = :date GROUP BY l.placeId")
List<PlaceViewTallyDto> countByPlaceOn(@Param("date") LocalDate date);

@Modifying
@Query("DELETE FROM PlaceViewLog l WHERE l.viewDate < :date")
int deleteByViewDateBefore(@Param("date") LocalDate date);
}
Original file line number Diff line number Diff line change
@@ -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<ViewCountReconciliation, Long> {

Optional<ViewCountReconciliation> findTopByOrderByTargetDateDesc();
}
15 changes: 13 additions & 2 deletions src/main/java/com/matzip/place/service/PlaceReadService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -70,15 +73,15 @@ 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));

if (!place.isApproved()) {
throw new BusinessException(ErrorCode.PLACE_NOT_FOUND);
}

recordView(placeId, userId);

PlaceRelatedData relatedData = getPlaceRelatedData(place);
List<Menu> menus = menuRepository.findByPlaceOrderByIsRecommendedDescNameAsc(place);

Expand All @@ -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<MapSearchResponseDto> findPlacesInMapBounds(MapSearchRequestDto requestDto) {
List<Place> places;

Expand Down
Original file line number Diff line number Diff line change
@@ -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()));
}
}
Loading
Loading