diff --git a/build.gradle b/build.gradle index 3d43dcea..bd22506d 100644 --- a/build.gradle +++ b/build.gradle @@ -125,6 +125,10 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-cache' implementation 'com.github.ben-manes.caffeine:caffeine:3.1.8' + // GEO-HASH 의존성 추가 + implementation 'ch.hsr:geohash:1.4.0' + + //kafka implementation "org.springframework.kafka:spring-kafka" diff --git a/src/main/java/spot/spot/domain/job/query/dto/response/NearByJobResponse.java b/src/main/java/spot/spot/domain/job/query/dto/response/NearByJobResponse.java index 37658746..62dd4dc6 100644 --- a/src/main/java/spot/spot/domain/job/query/dto/response/NearByJobResponse.java +++ b/src/main/java/spot/spot/domain/job/query/dto/response/NearByJobResponse.java @@ -1,6 +1,7 @@ package spot.spot.domain.job.query.dto.response; import io.swagger.v3.oas.annotations.media.Schema; +import java.util.Objects; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -27,4 +28,25 @@ public class NearByJobResponse { private double dist; @Schema(description = "카카오페이 결제 번호") private String tid; + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + NearByJobResponse that = (NearByJobResponse) o; + return id == that.id && + Double.compare(that.lat, lat) == 0 && + Double.compare(that.lng, lng) == 0 && + money == that.money && + Double.compare(that.dist, dist) == 0 && + Objects.equals(title, that.title) && + Objects.equals(content, that.content) && + Objects.equals(picture, that.picture); + } + + @Override + public int hashCode() { + return Objects.hash(id, title, content, picture, lat, lng, money, dist); + } + } diff --git a/src/main/java/spot/spot/domain/job/query/repository/dsl/SearchingListQueryDsl.java b/src/main/java/spot/spot/domain/job/query/repository/dsl/SearchingListQueryDsl.java index 9a87f079..61b4cdec 100644 --- a/src/main/java/spot/spot/domain/job/query/repository/dsl/SearchingListQueryDsl.java +++ b/src/main/java/spot/spot/domain/job/query/repository/dsl/SearchingListQueryDsl.java @@ -42,6 +42,7 @@ public class SearchingListQueryDsl implements SearchingListQueryDocs { // java private final QWorkerAbility workerAbility = QWorkerAbility.workerAbility; private final QMatching matching = QMatching.matching; private final QMember member = QMember.member; + private final JPAQueryFactory jpaQueryFactory; @Transactional(readOnly = true) public Slice findNearByJobsWithQueryDSL(double lat, double lng, double dist, Pageable pageable) { @@ -188,4 +189,28 @@ public List findWorkersCertificationImgList(long jobId .where(matching.job.id.eq(jobId).and(matching.status.notIn(MatchingStatus.OWNER, MatchingStatus.ATTENDER, MatchingStatus.REQUEST))) .fetch(); } + + + @Transactional(readOnly = true) + public List findJobsforGeoHashSync() { + // MySQL 인식 용 + // QueryDSL 실행 (SPATIAL INDEX 사용) + return jpaQueryFactory + .select(Projections.constructor( + NearByJobResponse.class, + job.id, + job.title, + job.content, + job.img.as("picture"), + job.lat, + job.lng, + job.money, + Expressions.constant(0.0), + job.tid + )) + .from(job) + .where(job.startedAt.isNull(), job.location.isNotNull()) + .fetch(); + // 다음 페이지가 있는지 계산 + } } diff --git a/src/main/java/spot/spot/domain/job/query/util/caching/AsyncGeoCacheScheduler.java b/src/main/java/spot/spot/domain/job/query/util/caching/AsyncGeoCacheScheduler.java new file mode 100644 index 00000000..a53f50ca --- /dev/null +++ b/src/main/java/spot/spot/domain/job/query/util/caching/AsyncGeoCacheScheduler.java @@ -0,0 +1,32 @@ +package spot.spot.domain.job.query.util.caching; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; +import org.springframework.stereotype.Component; +import org.springframework.transaction.support.TransactionTemplate; +import spot.spot.global.scheduler.SchedulingTask; +import static spot.spot.global.util.ConstantUtil.SYNC_INTERVAL; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AsyncGeoCacheScheduler { + + private final ThreadPoolTaskScheduler taskScheduler; + private final JobGeoCacheSyncUtil jobGeoCacheSyncUtil; + private final TransactionTemplate transactionTemplate; + + @PostConstruct + public void start() { + SchedulingTask task = new SchedulingTask<>( + jobGeoCacheSyncUtil, + JobGeoCacheSyncUtil::syncGeoHashCache, + transactionTemplate + ); + + taskScheduler.scheduleWithFixedDelay(task, SYNC_INTERVAL); + log.info("[GeoHashCache] 비동기 스케줄링 시작 ({} 간격)", SYNC_INTERVAL); + } +} diff --git a/src/main/java/spot/spot/domain/job/query/util/caching/GeoCacheWarmUp.java b/src/main/java/spot/spot/domain/job/query/util/caching/GeoCacheWarmUp.java new file mode 100644 index 00000000..9a65ea3b --- /dev/null +++ b/src/main/java/spot/spot/domain/job/query/util/caching/GeoCacheWarmUp.java @@ -0,0 +1,20 @@ +package spot.spot.domain.job.query.util.caching; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class GeoCacheWarmUp { + + private final JobGeoCacheSyncUtil jobGeoCacheSyncUtil; + + @PostConstruct + public void warmUpGeoCacheOnStart() { + log.info("[GeoHashCache] 서버 부팅에 따른 동기화 시작"); + jobGeoCacheSyncUtil.syncGeoHashCache(); + } +} diff --git a/src/main/java/spot/spot/domain/job/query/util/caching/GeoHashUtil.java b/src/main/java/spot/spot/domain/job/query/util/caching/GeoHashUtil.java new file mode 100644 index 00000000..fee210fc --- /dev/null +++ b/src/main/java/spot/spot/domain/job/query/util/caching/GeoHashUtil.java @@ -0,0 +1,29 @@ +package spot.spot.domain.job.query.util.caching; + +import ch.hsr.geohash.GeoHash; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import org.springframework.stereotype.Component; + +@Component +public class GeoHashUtil { + // 위도 경도를 Geo-hash 문자열로 인코딩 -> Redis caching 시 Key로 쓰인다. + // precision: 정밀도, Geo-hash는 격자 기반, 여기서 precision 값이 커지면 검색 범위 격자가 좁아지고, 작아지면 검색 범위 격자가 넓어진다. + public String encode (double lat, double lng, int precision) { + return GeoHash.geoHashStringWithCharacterPrecision(lat, lng, precision); + } + + public List geoNeighborHashes(double lat, double lng, int precision) { + GeoHash center = GeoHash.withCharacterPrecision(lat, lng, precision); + Set geoHashes = new HashSet<>(); + geoHashes.add(center.toBase32()); + + for(GeoHash neighbor : center.getAdjacent()) { + geoHashes.add(neighbor.toBase32()); + } + + return new ArrayList<>(geoHashes); + } +} diff --git a/src/main/java/spot/spot/domain/job/query/util/caching/JobGeoCacheSyncUtil.java b/src/main/java/spot/spot/domain/job/query/util/caching/JobGeoCacheSyncUtil.java new file mode 100644 index 00000000..ed2d23ab --- /dev/null +++ b/src/main/java/spot/spot/domain/job/query/util/caching/JobGeoCacheSyncUtil.java @@ -0,0 +1,68 @@ +package spot.spot.domain.job.query.util.caching; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import spot.spot.domain.job.query.dto.response.NearByJobResponse; +import spot.spot.domain.job.query.repository.dsl.SearchingListQueryDsl; +import static spot.spot.global.util.ConstantUtil.GEOHASH_PRECISION; + +@Slf4j +@Service +@RequiredArgsConstructor +public class JobGeoCacheSyncUtil { + + private final SearchingListQueryDsl searchingListQueryDsl; + private final CacheManager cacheManager; + private final GeoHashUtil geoHashUtil; + + + @Transactional(readOnly = true) + public void syncGeoHashCache () { + int updateCnt = 0; + Cache cache = cacheManager.getCache("job-geohash"); + + List allResponses = searchingListQueryDsl.findJobsforGeoHashSync(); + Map> geoGrouped = new HashMap<>(); + + // KEY: GEOHASH 문자열 값, VALUE: response dto 값 + for(NearByJobResponse response : allResponses) { + String geoHash = geoHashUtil.encode(response.getLat(), response.getLng(), GEOHASH_PRECISION); + geoGrouped.computeIfAbsent(geoHash, key -> new ArrayList<>()).add(response); + } + + for(Map.Entry> entry : geoGrouped.entrySet()){ + String nowKey = entry.getKey(); + List dbList = entry.getValue(); + List cacheList = cache.get(nowKey, List.class); + + if(isChanged(cacheList, dbList)) { + cache.put(nowKey, dbList); + log.debug("[근처 일거리 찾기] {} 캐싱 갱신 {} 건", nowKey, dbList.size()); + updateCnt += dbList.size(); + } + log.info("[GeoHashCache] 캐시 동기화 완료 - 총 {}개 갱신 / 전체 {}", updateCnt, geoGrouped.size()); + } + } + + private boolean isChanged(List cacheList, List dbList) { + if(cacheList == null || cacheList.size() != dbList.size()) return true; + + Map dbListwithPK = dbList.stream() + .collect(Collectors.toMap(NearByJobResponse::getId, j -> j)); + + for(NearByJobResponse old : cacheList) { + NearByJobResponse updated = dbListwithPK.get(old.getId()); + if(updated == null || !updated.equals(old)) return true; + } + return false; + } +} diff --git a/src/main/java/spot/spot/global/config/CacheConfig.java b/src/main/java/spot/spot/global/config/CacheConfig.java index f5cd5d18..d49b0aae 100644 --- a/src/main/java/spot/spot/global/config/CacheConfig.java +++ b/src/main/java/spot/spot/global/config/CacheConfig.java @@ -15,7 +15,7 @@ public class CacheConfig { public CacheManager cacheManager() { CaffeineCacheManager manager = new CaffeineCacheManager("job-geohash"); manager.setCaffeine(Caffeine.newBuilder() - .maximumSize(10_000) + .maximumSize(200_000) .expireAfterWrite(Duration.ofDays(1)) .recordStats()); return manager; diff --git a/src/main/java/spot/spot/global/response/format/ErrorCode.java b/src/main/java/spot/spot/global/response/format/ErrorCode.java index 5bd77178..e0ad25c5 100644 --- a/src/main/java/spot/spot/global/response/format/ErrorCode.java +++ b/src/main/java/spot/spot/global/response/format/ErrorCode.java @@ -49,6 +49,7 @@ public enum ErrorCode { // ALRAM INVALID_FCM_TOKEN(HttpStatus.NOT_ACCEPTABLE, "❌ 이 회원은 FCM 토큰이 전무하네요! 오래 접속하지 않았거나, 탈퇴회원 입니다. ❌ "), INVALID_TITLE(HttpStatus.NOT_ACCEPTABLE, "❌ 조회하신 일이 존재하지않습니다.! 결제준비된 상품인지 확인해주세요 ❌ "), + // POINT ALREADY_PAY_FAIL(HttpStatus.BAD_REQUEST, "이미 결제 취소된 내역입니다."), EMPTY_MEMBER(HttpStatus.BAD_REQUEST, "멤버 값이 비어있습니다."), EMPTY_TITLE(HttpStatus.BAD_REQUEST, "일 타이틀 값이 비어있습니다."), diff --git a/src/main/java/spot/spot/global/util/ConstantUtil.java b/src/main/java/spot/spot/global/util/ConstantUtil.java index 8d760d60..c12a2bcb 100644 --- a/src/main/java/spot/spot/global/util/ConstantUtil.java +++ b/src/main/java/spot/spot/global/util/ConstantUtil.java @@ -1,5 +1,7 @@ package spot.spot.global.util; +import java.time.Duration; + public class ConstantUtil { // FOR SECURITY public static final String AUTHORIZATION = "Authorization"; @@ -7,11 +9,14 @@ public class ConstantUtil { public static final String TOKEN_PREFIX = "token:"; // 거리 계산 public static final double EARTH_RADIUS_KM = 6371; - // WORKER STAUTS + // WORKER STATUS public static final Integer STILL_WORKING = 0; public static final Integer LITTLE_BREAK = 1; // MESSAGE TYPE public static final String TYPE = "type"; public static final String PERMIT_ALL = "permitAll"; public static final String AUTH_ERROR = "auth_error"; + // GEO-HASH + public static final int GEOHASH_PRECISION = 6; + public static final Duration SYNC_INTERVAL = Duration.ofDays(1); }