Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.hyetaekon.hyetaekon.publicservice.entity.mongodb.CacheType;
import com.hyetaekon.hyetaekon.publicservice.entity.CacheType;

import java.time.Duration;
import java.util.Arrays;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.hyetaekon.hyetaekon.common.publicdata.service.PublicServiceDataService;
import com.hyetaekon.hyetaekon.common.publicdata.util.PublicDataPath;
import com.hyetaekon.hyetaekon.publicservice.util.ServiceCacheManager;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
Expand All @@ -13,15 +14,16 @@
public class PublicServiceDataScheduler {

private final PublicServiceDataService publicServiceDataService;
private final ServiceCacheManager serviceCacheManager;

/**
* ๋งค์ผ ์ƒˆ๋ฒฝ 2์‹œ์— ๊ณต๊ณต์„œ๋น„์Šค ๋ฐ์ดํ„ฐ ์ „์ฒด ๋™๊ธฐํ™” ์‹คํ–‰
* ๋งค์ฃผ ์›”์š”์ผ ์ƒˆ๋ฒฝ 2์‹œ์— ๊ณต๊ณต์„œ๋น„์Šค ๋ฐ์ดํ„ฐ ์ „์ฒด ๋™๊ธฐํ™” ์‹คํ–‰
* 1. ์„œ๋น„์Šค ๋ชฉ๋ก ๋™๊ธฐํ™”
* 2. ์„œ๋น„์Šค ์ƒ์„ธ์ •๋ณด ๋™๊ธฐํ™”
* 3. ์„œ๋น„์Šค ์ง€์›์กฐ๊ฑด ๋™๊ธฐํ™”
* 4. ๋” ์ด์ƒ ์‚ฌ์šฉ๋˜์ง€ ์•Š๋Š” ๋ฐ์ดํ„ฐ ์ •๋ฆฌ
*/
@Scheduled(cron = "0 0 2 * * ?")
@Scheduled(cron = "0 0 2 ? * MON")
public void syncAllPublicServiceData() {
log.info("๊ณต๊ณต์„œ๋น„์Šค ๋ฐ์ดํ„ฐ ์ „์ฒด ๋™๊ธฐํ™” ์Šค์ผ€์ค„๋Ÿฌ ์‹œ์ž‘");

Expand All @@ -43,6 +45,9 @@ public void syncAllPublicServiceData() {
int deletedCount = publicServiceDataService.cleanupObsoleteServices();
log.info("๋ฏธ์‚ฌ์šฉ ๊ณต๊ณต์„œ๋น„์Šค ๋ฐ์ดํ„ฐ {}๊ฑด ์‚ญ์ œ ์™„๋ฃŒ", deletedCount);

// 5. ๋™๊ธฐํ™” ์™„๋ฃŒ ํ›„ ์บ์‹œ ์ดˆ๊ธฐํ™”
serviceCacheManager.clearAllServiceCaches();

log.info("๊ณต๊ณต์„œ๋น„์Šค ๋ฐ์ดํ„ฐ ์ „์ฒด ๋™๊ธฐํ™” ์™„๋ฃŒ");
} catch (Exception e) {
log.error("๊ณต๊ณต์„œ๋น„์Šค ๋ฐ์ดํ„ฐ ๋™๊ธฐํ™” ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ", e);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.hyetaekon.hyetaekon.publicservice.controller;

import com.hyetaekon.hyetaekon.common.jwt.CustomUserDetails;
import com.hyetaekon.hyetaekon.publicservice.dto.PublicServiceListResponseDto;
import com.hyetaekon.hyetaekon.publicservice.service.RecentVisitService;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Positive;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;

@Validated
@RestController
@RequestMapping("/api/services/recent")
@RequiredArgsConstructor
public class RecentVisitController {

private final RecentVisitService recentVisitService;

/**
* ์ตœ๊ทผ ๋ฐฉ๋ฌธํ•œ ์„œ๋น„์Šค ๋ชฉ๋ก ์กฐํšŒ
*/
@GetMapping
public ResponseEntity<Page<PublicServiceListResponseDto>> getRecentVisits(
@RequestParam(name = "page", defaultValue = "0") @Min(0) int page,
@RequestParam(name = "size", defaultValue = "3") @Positive @Max(10) int size,
@AuthenticationPrincipal CustomUserDetails userDetails
) {
Long userId = userDetails != null ? userDetails.getId() : 0L;

// ๋น„๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž๋Š” ๋นˆ ๋ชฉ๋ก ๋ฐ˜ํ™˜
if (userId == 0L) {
return ResponseEntity.ok(Page.empty(PageRequest.of(page, size)));
}

Page<PublicServiceListResponseDto> recentServices = recentVisitService.getRecentVisits(userId, page, size);
return ResponseEntity.ok(recentServices);
}

/**
* ๋ฐฉ๋ฌธ ๊ธฐ๋ก ์ „์ฒด ์‚ญ์ œ
*/
@DeleteMapping
public ResponseEntity<Void> clearRecentVisits(
@AuthenticationPrincipal CustomUserDetails userDetails
) {
if (userDetails != null) {
recentVisitService.clearUserVisits(userDetails.getId());
}
return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package com.hyetaekon.hyetaekon.publicservice.entity.mongodb;
package com.hyetaekon.hyetaekon.publicservice.entity;

import lombok.Getter;
import lombok.RequiredArgsConstructor;
Expand All @@ -8,7 +8,8 @@
public enum CacheType {
SERVICE_AUTOCOMPLETE("serviceAutocomplete", 2, 1200), // ์ž๋™์™„์„ฑ ์บ์‹œ
FILTER_OPTIONS("filterOptions", 24, 50), // ํ•„ํ„ฐ ์˜ต์…˜ ์บ์‹œ (ํ•˜๋ฃจ ์œ ์ง€)
MATCHED_SERVICES("matchedServices", 2, 100); // ๋งž์ถค ์„œ๋น„์Šค ์บ์‹œ
MATCHED_SERVICES("matchedServices", 2, 100), // ๋งž์ถค ์„œ๋น„์Šค ์บ์‹œ
SERVICE_BASIC_INFO("serviceBasicInfo", 12, 1000); // ์„œ๋น„์Šค ๊ธฐ๋ณธ ์ •๋ณด ์บ์‹œ

private final String cacheName;
private final int expiredAfterWrite; // ์‹œ๊ฐ„(hour) ๋‹จ์œ„
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ public class PublicServiceHandler {
private final PublicServiceMapper publicServiceMapper;
private final PublicServiceValidate publicServiceValidate;
private final BookmarkRepository bookmarkRepository;
private final RecentVisitService recentVisitService;

// ์„œ๋น„์Šค๋ถ„์•ผ๋ณ„ ์„œ๋น„์Šค๋ชฉ๋ก ์กฐํšŒ(ํŽ˜์ด์ง€)
/*public Page<PublicServiceListResponseDto> getServicesByCategory(ServiceCategory category, Pageable pageable, Long userId) {
Expand Down Expand Up @@ -67,6 +68,8 @@ public PublicServiceDetailResponseDto getServiceDetail(String serviceId, Long us
service.updateViewsUp();
publicServiceRepository.save(service);

recentVisitService.addVisit(userId, serviceId);

PublicServiceDetailResponseDto dto = publicServiceMapper.toDetailDto(service);

if (userId != 0L) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
package com.hyetaekon.hyetaekon.publicservice.service;

import com.hyetaekon.hyetaekon.bookmark.repository.BookmarkRepository;
import com.hyetaekon.hyetaekon.publicservice.dto.PublicServiceListResponseDto;
import com.hyetaekon.hyetaekon.publicservice.entity.PublicService;
import com.hyetaekon.hyetaekon.publicservice.mapper.PublicServiceMapper;
import com.hyetaekon.hyetaekon.publicservice.repository.PublicServiceRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

@Slf4j
@Service
@RequiredArgsConstructor
public class RecentVisitService {

private final RedisTemplate<String, String> redisTemplate;
private final PublicServiceRepository publicServiceRepository;
private final PublicServiceMapper publicServiceMapper;
private final BookmarkRepository bookmarkRepository;

// Redis ํ‚ค ํ˜•์‹: "user:{userId}:recentServices"
private static final String KEY_PREFIX = "user:";
private static final String KEY_SUFFIX = ":recentServices";

// ์„ค์ •๊ฐ’
private static final int MAX_ITEMS = 10; // ์ตœ๋Œ€ ์ €์žฅ ๊ฐœ์ˆ˜
private static final int TTL_DAYS = 30; // ๋ฐ์ดํ„ฐ ์œ ์ง€ ๊ธฐ๊ฐ„(์ผ)

/**
* ์‚ฌ์šฉ์ž์˜ ์„œ๋น„์Šค ๋ฐฉ๋ฌธ ๊ธฐ๋ก ์ถ”๊ฐ€
*/
public void addVisit(Long userId, String serviceId) {
if (userId == null || userId == 0 || serviceId == null) {
return; // ๋น„๋กœ๊ทธ์ธ ์‚ฌ์šฉ์ž๋‚˜ ์œ ํšจํ•˜์ง€ ์•Š์€ ID๋Š” ์ฒ˜๋ฆฌํ•˜์ง€ ์•Š์Œ
}

String key = generateKey(userId);

// ์ด๋ฏธ ์กด์žฌํ•˜๋Š” ๊ฒฝ์šฐ ์‚ญ์ œ (์ค‘๋ณต ์ œ๊ฑฐ)
redisTemplate.opsForList().remove(key, 0, serviceId);

// ๋งจ ์•ž์— ์ƒˆ๋กœ ์ถ”๊ฐ€ (์ตœ์‹  ๋ฐฉ๋ฌธ)
redisTemplate.opsForList().leftPush(key, serviceId);

// ์ตœ๋Œ€ 10๊ฐœ๋กœ ์ œํ•œ
redisTemplate.opsForList().trim(key, 0, MAX_ITEMS - 1);

// TTL ์„ค์ • (30์ผ)
redisTemplate.expire(key, TTL_DAYS, TimeUnit.DAYS);

log.debug("์‚ฌ์šฉ์ž {} - ์ตœ๊ทผ ๋ฐฉ๋ฌธ ์„œ๋น„์Šค ์ถ”๊ฐ€: {}", userId, serviceId);
}

/**
* ์ตœ๊ทผ ๋ฐฉ๋ฌธ ์„œ๋น„์Šค ๋ชฉ๋ก ์กฐํšŒ (ํŽ˜์ด์ง•)
* - ์„œ๋น„์Šค ID ๋ชฉ๋ก: Redis์—์„œ ์กฐํšŒ
* - ์„œ๋น„์Šค ๊ธฐ๋ณธ ์ •๋ณด: Caffeine ์บ์‹œ ํ™œ์šฉ (๋ณ€๊ฒฝ์ด ์ ์€ ์ •๋ณด)
* - ๋ถ๋งˆํฌ ์ƒํƒœ: ์ง์ ‘ DB ์กฐํšŒ (์ž์ฃผ ๋ณ€๊ฒฝ๋˜๋Š” ์ •๋ณด)
*/
public Page<PublicServiceListResponseDto> getRecentVisits(Long userId, int page, int size) {
String key = generateKey(userId);

// ์ „์ฒด ๊ฐœ์ˆ˜ ํ™•์ธ
Long total = redisTemplate.opsForList().size(key);
if (total == null || total == 0) {
return Page.empty(PageRequest.of(page, size));
}

// ํ˜„์žฌ ํŽ˜์ด์ง€์— ํ•ด๋‹นํ•˜๋Š” ๋ฒ”์œ„ ๊ณ„์‚ฐ
int start = page * size;
int end = start + size - 1;

// Redis์—์„œ ID ๋ชฉ๋ก ์กฐํšŒ
List<String> serviceIds = redisTemplate.opsForList().range(key, start, end);
if (serviceIds == null || serviceIds.isEmpty()) {
return Page.empty(PageRequest.of(page, size));
}

// ์„œ๋น„์Šค ์ •๋ณด ์กฐํšŒ (ID๋ณ„๋กœ ๋ถ„๋ฆฌํ•˜์—ฌ ์ฒ˜๋ฆฌ)
List<PublicService> services = new ArrayList<>();
for (String id : serviceIds) {
// ๊ธฐ๋ณธ ์ •๋ณด๋Š” ์บ์‹œ ํ™œ์šฉ (์บ์‹œ ์‹คํŒจ ์‹œ ์ง์ ‘ ์กฐํšŒ)
PublicService service = getServiceBasicInfo(id);
if (service != null) {
services.add(service);
}
}

// DTO ๋ณ€ํ™˜ ๋ฐ ๋ถ๋งˆํฌ ์ •๋ณด ์„ค์ •
List<PublicServiceListResponseDto> dtoList = services.stream()
.map(service -> {
// ๊ธฐ๋ณธ ์ •๋ณด ๋ณ€ํ™˜
PublicServiceListResponseDto dto = publicServiceMapper.toListDto(service);

// ๋ถ๋งˆํฌ ์ƒํƒœ๋Š” ํ•ญ์ƒ ์ง์ ‘ ์กฐํšŒ (์ž์ฃผ ๋ณ€๊ฒฝ๋˜๋Š” ์ •๋ณด)
if (userId != 0L) {
dto.setBookmarked(isBookmarked(userId, service.getId()));
}

return dto;
})
.collect(Collectors.toList());

// ํŽ˜์ด์ง€ ๊ฐ์ฒด ์ƒ์„ฑ ๋ฐ ๋ฐ˜ํ™˜
return new PageImpl<>(dtoList, PageRequest.of(page, size), total);
}

/**
* ์„œ๋น„์Šค ๊ธฐ๋ณธ ์ •๋ณด ์กฐํšŒ (์บ์‹œ ์ ์šฉ)
* - ์„œ๋น„์Šค ์ด๋ฆ„, ์„ค๋ช… ๋“ฑ ์ž์ฃผ ๋ณ€๊ฒฝ๋˜์ง€ ์•Š๋Š” ์ •๋ณด์— ์บ์‹œ ์ ์šฉ
*/
@Cacheable(value = "serviceBasicInfo", key = "#serviceId", unless = "#result == null")
public PublicService getServiceBasicInfo(String serviceId) {
log.debug("Cache miss: ์„œ๋น„์Šค ๊ธฐ๋ณธ ์ •๋ณด DB ์กฐํšŒ - ID: {}", serviceId);
return publicServiceRepository.findById(serviceId).orElse(null);
}

/**
* ๋ถ๋งˆํฌ ์ƒํƒœ ์กฐํšŒ (ํ•ญ์ƒ ์ง์ ‘ DB ์กฐํšŒ)
* - ์ž์ฃผ ๋ณ€๊ฒฝ๋  ์ˆ˜ ์žˆ์–ด ์บ์‹œ ๋ฏธ์ ์šฉ
*/
public boolean isBookmarked(Long userId, String serviceId) {
return bookmarkRepository.existsByUserIdAndPublicServiceId(userId, serviceId);
}

/**
* ํŠน์ • ์‚ฌ์šฉ์ž์˜ ์ „์ฒด ๋ฐฉ๋ฌธ ๊ธฐ๋ก ์‚ญ์ œ
*/
public void clearUserVisits(Long userId) {
String key = generateKey(userId);
redisTemplate.delete(key);
log.debug("์‚ฌ์šฉ์ž {} - ๋ฐฉ๋ฌธ ๊ธฐ๋ก ์ „์ฒด ์‚ญ์ œ", userId);
}

/**
* Redis ํ‚ค ์ƒ์„ฑ
*/
private String generateKey(Long userId) {
return KEY_PREFIX + userId + KEY_SUFFIX;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.hyetaekon.hyetaekon.publicservice.util;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.stereotype.Service;

@Slf4j
@Service
@RequiredArgsConstructor
public class ServiceCacheManager {

private final CacheManager cacheManager;

/**
* ํŠน์ • ์„œ๋น„์Šค์˜ ๊ธฐ๋ณธ ์ •๋ณด ์บ์‹œ ์‚ญ์ œ
*/
@CacheEvict(value = "serviceBasicInfo", key = "#serviceId")
public void evictServiceBasicInfoCache(String serviceId) {
log.debug("์„œ๋น„์Šค ๊ธฐ๋ณธ ์ •๋ณด ์บ์‹œ ์‚ญ์ œ: {}", serviceId);
}

/**
* ์„œ๋น„์Šค ๋ฐ์ดํ„ฐ ๋™๊ธฐํ™” ์‹œ ์บ์‹œ ์ผ๊ด„ ์‚ญ์ œ
*/
public void clearAllServiceCaches() {
cacheManager.getCache("serviceBasicInfo").clear();
log.info("๋ชจ๋“  ์„œ๋น„์Šค ๊ธฐ๋ณธ ์ •๋ณด ์บ์‹œ ์‚ญ์ œ ์™„๋ฃŒ");
}
}
Loading