diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..2b503786a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,21 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: '' + +--- + +## 어떤 버그인가요 + +> 문제가 되는 부분에 대해 설명해주세요 + +## 재현 방법(선택) +버그를 재현할 수 있는 과정을 설명해주세요(필요하다면 사진을 첨부해주세요) +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +## 참고할만한 자료(선택) diff --git a/docker-compose.yml b/docker-compose.yml index 86e7e3a1e..e7358d2b2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,6 +7,16 @@ services: ports: - "6379:6379" + redis-exporter: + image: oliver006/redis_exporter + container_name: redis-exporter + ports: + - "9121:9121" + environment: + REDIS_ADDR: "redis:6379" + depends_on: + - redis + solid-connect-server: build: context: . diff --git a/nginx.conf b/nginx.conf index 0be40fd00..e94acb4e3 100644 --- a/nginx.conf +++ b/nginx.conf @@ -20,6 +20,7 @@ server { ssl_certificate /etc/letsencrypt/live/api.solid-connect.net/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/api.solid-connect.net/privkey.pem; + client_max_body_size 10M; ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers on; # 클라이언트 보다 서버의 암호화 알고리즘을 우선하도록 설정 diff --git a/src/main/java/com/example/solidconnection/SolidConnectionApplication.java b/src/main/java/com/example/solidconnection/SolidConnectionApplication.java index 6727bdece..670a3f0f7 100644 --- a/src/main/java/com/example/solidconnection/SolidConnectionApplication.java +++ b/src/main/java/com/example/solidconnection/SolidConnectionApplication.java @@ -2,11 +2,13 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cache.annotation.EnableCaching; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; import org.springframework.scheduling.annotation.EnableScheduling; @EnableScheduling @EnableJpaAuditing +@EnableCaching @SpringBootApplication public class SolidConnectionApplication { diff --git a/src/main/java/com/example/solidconnection/application/controller/ApplicationController.java b/src/main/java/com/example/solidconnection/application/controller/ApplicationController.java index 3234e45b4..baf5159a1 100644 --- a/src/main/java/com/example/solidconnection/application/controller/ApplicationController.java +++ b/src/main/java/com/example/solidconnection/application/controller/ApplicationController.java @@ -55,6 +55,7 @@ public ResponseEntity getApplicants( Principal principal, @RequestParam(required = false, defaultValue = "") String region, @RequestParam(required = false, defaultValue = "") String keyword) { + applicationQueryService.validateSiteUserCanViewApplicants(principal.getName()); ApplicationsResponse result = applicationQueryService.getApplicants(principal.getName(), region, keyword); return ResponseEntity .ok(result); diff --git a/src/main/java/com/example/solidconnection/application/service/ApplicationQueryService.java b/src/main/java/com/example/solidconnection/application/service/ApplicationQueryService.java index 2e481023d..aee3ad25e 100644 --- a/src/main/java/com/example/solidconnection/application/service/ApplicationQueryService.java +++ b/src/main/java/com/example/solidconnection/application/service/ApplicationQueryService.java @@ -5,6 +5,7 @@ import com.example.solidconnection.application.dto.ApplicationsResponse; import com.example.solidconnection.application.dto.UniversityApplicantsResponse; import com.example.solidconnection.application.repository.ApplicationRepository; +import com.example.solidconnection.cache.annotation.ThunderingHerdCaching; import com.example.solidconnection.custom.exception.CustomException; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.repository.SiteUserRepository; @@ -43,10 +44,10 @@ public class ApplicationQueryService { * - 1지망, 2지망 지원자들을 조회한다. * */ @Transactional(readOnly = true) + @ThunderingHerdCaching(key = "application:query:{1}:{2}", cacheManager = "customCacheManager", ttlSec = 86400) public ApplicationsResponse getApplicants(String email, String regionCode, String keyword) { // 유저가 다른 지원자들을 볼 수 있는지 검증 SiteUser siteUser = siteUserRepository.getByEmail(email); - validateSiteUserCanViewApplicants(siteUser); // 국가와 키워드와 지역을 통해 대학을 필터링한다. List universities @@ -61,8 +62,10 @@ public ApplicationsResponse getApplicants(String email, String regionCode, Strin // 학기별로 상태가 관리된다. // 금학기에 지원이력이 있는 사용자만 지원정보를 확인할 수 있도록 한다. - private void validateSiteUserCanViewApplicants(SiteUser siteUser) { - VerifyStatus verifyStatus = applicationRepository.getApplicationBySiteUserAndTerm(siteUser,term).getVerifyStatus(); + @Transactional(readOnly = true) + public void validateSiteUserCanViewApplicants(String email) { + SiteUser siteUser = siteUserRepository.getByEmail(email); + VerifyStatus verifyStatus = applicationRepository.getApplicationBySiteUserAndTerm(siteUser, term).getVerifyStatus(); if (verifyStatus != VerifyStatus.APPROVED) { throw new CustomException(APPLICATION_NOT_APPROVED); } diff --git a/src/main/java/com/example/solidconnection/application/service/ApplicationSubmissionService.java b/src/main/java/com/example/solidconnection/application/service/ApplicationSubmissionService.java index 753bc89cc..b23b876c7 100644 --- a/src/main/java/com/example/solidconnection/application/service/ApplicationSubmissionService.java +++ b/src/main/java/com/example/solidconnection/application/service/ApplicationSubmissionService.java @@ -6,6 +6,7 @@ import com.example.solidconnection.application.dto.ScoreRequest; import com.example.solidconnection.application.dto.UniversityChoiceRequest; import com.example.solidconnection.application.repository.ApplicationRepository; +import com.example.solidconnection.cache.annotation.DefaultCacheOut; import com.example.solidconnection.custom.exception.CustomException; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.repository.SiteUserRepository; @@ -40,6 +41,7 @@ public class ApplicationSubmissionService { * - 수정을 하고 나면, 성적 승인 상태(verifyStatus)를 PENDING 상태로 변경한다. * */ @Transactional + @DefaultCacheOut(key = "application:query", cacheManager = "customCacheManager", prefix = true) public boolean submitScore(String email, ScoreRequest scoreRequest) { SiteUser siteUser = siteUserRepository.getByEmail(email); Gpa gpa = scoreRequest.toGpa(); @@ -67,8 +69,9 @@ public boolean submitScore(String email, ScoreRequest scoreRequest) { * - 성적 승인 상태(verifyStatus) 는 변경하지 않는다. * */ @Transactional + @DefaultCacheOut(key = "application:query", cacheManager = "customCacheManager", prefix = true) public boolean submitUniversityChoice(String email, UniversityChoiceRequest universityChoiceRequest) { - validateNoDuplicateUniversityChoices(universityChoiceRequest); + validateUniversityChoices(universityChoiceRequest); // 성적 제출한 적이 한번도 없는 경우 Application existingApplication = applicationRepository.findTop1BySiteUser_EmailOrderByTermDesc(email) @@ -83,14 +86,16 @@ public boolean submitUniversityChoice(String email, UniversityChoiceRequest univ }) .orElse(existingApplication); // 금학기에 이미 성적 제출한 경우 기존 객체 사용 + validateUpdateLimitNotExceed(application); + UniversityInfoForApply firstChoiceUniversity = universityInfoForApplyRepository .getUniversityInfoForApplyByIdAndTerm(universityChoiceRequest.firstChoiceUniversityId(), term); - UniversityInfoForApply secondChoiceUniversity = universityInfoForApplyRepository - .getUniversityInfoForApplyByIdAndTerm(universityChoiceRequest.secondChoiceUniversityId(), term); - UniversityInfoForApply thirdChoiceUniversity = universityInfoForApplyRepository - .getUniversityInfoForApplyByIdAndTerm(universityChoiceRequest.thirdChoiceUniversityId(), term); - - validateUpdateLimitNotExceed(application); + UniversityInfoForApply secondChoiceUniversity = Optional.ofNullable(universityChoiceRequest.secondChoiceUniversityId()) + .map(id -> universityInfoForApplyRepository.getUniversityInfoForApplyByIdAndTerm(id, term)) + .orElse(null); + UniversityInfoForApply thirdChoiceUniversity = Optional.ofNullable(universityChoiceRequest.thirdChoiceUniversityId()) + .map(id -> universityInfoForApplyRepository.getUniversityInfoForApplyByIdAndTerm(id, term)) + .orElse(null); application.updateUniversityChoice(firstChoiceUniversity, secondChoiceUniversity, thirdChoiceUniversity, getRandomNickname()); return true; } @@ -109,14 +114,21 @@ private void validateUpdateLimitNotExceed(Application application) { } } - private void validateNoDuplicateUniversityChoices(UniversityChoiceRequest universityChoiceRequest) { + // 입력값 유효성 검증 + private void validateUniversityChoices(UniversityChoiceRequest universityChoiceRequest) { Set uniqueUniversityIds = new HashSet<>(); - uniqueUniversityIds.add(universityChoiceRequest.firstChoiceUniversityId()); - uniqueUniversityIds.add(universityChoiceRequest.secondChoiceUniversityId()); - uniqueUniversityIds.add(universityChoiceRequest.thirdChoiceUniversityId()); + if (universityChoiceRequest.secondChoiceUniversityId() != null) { + addUniversityChoice(uniqueUniversityIds, universityChoiceRequest.secondChoiceUniversityId()); + } + if (universityChoiceRequest.thirdChoiceUniversityId() != null) { + addUniversityChoice(uniqueUniversityIds, universityChoiceRequest.thirdChoiceUniversityId()); + } + } - if (uniqueUniversityIds.size() < 3) { + private void addUniversityChoice(Set uniqueUniversityIds, Long universityId) { + boolean notAdded = !uniqueUniversityIds.add(universityId); + if (notAdded) { throw new CustomException(CANT_APPLY_FOR_SAME_UNIVERSITY); } } diff --git a/src/main/java/com/example/solidconnection/cache/CacheUpdateListener.java b/src/main/java/com/example/solidconnection/cache/CacheUpdateListener.java new file mode 100644 index 000000000..34e2752b3 --- /dev/null +++ b/src/main/java/com/example/solidconnection/cache/CacheUpdateListener.java @@ -0,0 +1,22 @@ +package com.example.solidconnection.cache; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.connection.Message; +import org.springframework.data.redis.connection.MessageListener; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; + +@Component +@RequiredArgsConstructor +@Slf4j +public class CacheUpdateListener implements MessageListener { + + private final CompletableFutureManager futureManager; + @Override + public void onMessage(Message message, byte[] pattern) { + String messageBody = new String(message.getBody(), StandardCharsets.UTF_8).replaceAll("^\"|\"$", ""); + futureManager.completeFuture(messageBody); + } +} diff --git a/src/main/java/com/example/solidconnection/cache/CachingAspect.java b/src/main/java/com/example/solidconnection/cache/CachingAspect.java new file mode 100644 index 000000000..29c355372 --- /dev/null +++ b/src/main/java/com/example/solidconnection/cache/CachingAspect.java @@ -0,0 +1,56 @@ +package com.example.solidconnection.cache; + +import com.example.solidconnection.cache.annotation.DefaultCacheOut; +import com.example.solidconnection.cache.annotation.DefaultCaching; +import com.example.solidconnection.cache.manager.CacheManager; +import com.example.solidconnection.util.RedisUtils; +import lombok.RequiredArgsConstructor; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.context.ApplicationContext; +import org.springframework.stereotype.Component; + +@Aspect +@Component +@RequiredArgsConstructor +public class CachingAspect { + private final ApplicationContext applicationContext; + private final RedisUtils redisUtils; + + @Around("@annotation(defaultCaching)") + public Object cache(ProceedingJoinPoint joinPoint, DefaultCaching defaultCaching) throws Throwable { + + CacheManager cacheManager = (CacheManager) applicationContext.getBean(defaultCaching.cacheManager()); + String key = redisUtils.generateCacheKey(defaultCaching.key(), joinPoint.getArgs()); + Long ttl = defaultCaching.ttlSec(); + + // 1. 캐시에 있으면 반환 + Object cachedValue = cacheManager.get(key); + if (cachedValue != null) { + return cachedValue; + } + // 2. 캐시에 없으면 캐싱 후 반환 + Object result = joinPoint.proceed(); + cacheManager.put(key, result, ttl); + return result; + } + + @Around("@annotation(defaultCacheOut)") + public Object cacheEvict(ProceedingJoinPoint joinPoint, DefaultCacheOut defaultCacheOut) throws Throwable { + + CacheManager cacheManager = (CacheManager) applicationContext.getBean(defaultCacheOut.cacheManager()); + + for (String key : defaultCacheOut.key()) { + String cacheKey = redisUtils.generateCacheKey(key, joinPoint.getArgs()); + boolean usingPrefix = defaultCacheOut.prefix(); + + if (usingPrefix) { + cacheManager.evictUsingPrefix(cacheKey); + }else{ + cacheManager.evict(cacheKey); + } + } + return joinPoint.proceed(); + } +} diff --git a/src/main/java/com/example/solidconnection/cache/CompletableFutureManager.java b/src/main/java/com/example/solidconnection/cache/CompletableFutureManager.java new file mode 100644 index 000000000..6bcf01e03 --- /dev/null +++ b/src/main/java/com/example/solidconnection/cache/CompletableFutureManager.java @@ -0,0 +1,23 @@ +package com.example.solidconnection.cache; + +import org.springframework.stereotype.Component; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.Map; + +@Component +public class CompletableFutureManager { + private final Map> waitingRequests = new ConcurrentHashMap<>(); + + public CompletableFuture getOrCreateFuture(String key) { + return waitingRequests.computeIfAbsent(key, k -> new CompletableFuture<>()); + } + + public void completeFuture(String key) { + CompletableFuture future = waitingRequests.remove(key); + if (future != null) { + future.complete(null); + } + } +} diff --git a/src/main/java/com/example/solidconnection/cache/ThunderingHerdCachingAspect.java b/src/main/java/com/example/solidconnection/cache/ThunderingHerdCachingAspect.java new file mode 100644 index 000000000..8dc1694db --- /dev/null +++ b/src/main/java/com/example/solidconnection/cache/ThunderingHerdCachingAspect.java @@ -0,0 +1,150 @@ +package com.example.solidconnection.cache; + +import com.example.solidconnection.cache.annotation.ThunderingHerdCaching; +import com.example.solidconnection.cache.manager.CacheManager; +import com.example.solidconnection.util.RedisUtils; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationContext; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.UUID; +import java.util.concurrent.*; + +import static com.example.solidconnection.type.RedisConstants.*; + +@Aspect +@Component +@Slf4j +public class ThunderingHerdCachingAspect { + private final ApplicationContext applicationContext; + private final RedisTemplate redisTemplate; + private final CompletableFutureManager futureManager; + private final RedisUtils redisUtils; + + @Autowired + public ThunderingHerdCachingAspect(ApplicationContext applicationContext, RedisTemplate redisTemplate, + CompletableFutureManager futureManager, RedisUtils redisUtils) { + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + this.redisTemplate = redisTemplate; + this.applicationContext = applicationContext; + this.futureManager = futureManager; + this.redisUtils = redisUtils; + } + + @Around("@annotation(thunderingHerdCaching)") + public Object cache(ProceedingJoinPoint joinPoint, ThunderingHerdCaching thunderingHerdCaching) { + + CacheManager cacheManager = (CacheManager) applicationContext.getBean(thunderingHerdCaching.cacheManager()); + String key = redisUtils.generateCacheKey(thunderingHerdCaching.key(), joinPoint.getArgs()); + Long ttl = thunderingHerdCaching.ttlSec(); + + Object cachedValue = cacheManager.get(key); + if (cachedValue == null) { + log.info("Cache miss. Key: {}, Thread: {}", key, Thread.currentThread().getName()); + return createCache(joinPoint, cacheManager, ttl, key); + } + + if (redisUtils.isCacheExpiringSoon(key, ttl, Double.valueOf(REFRESH_LIMIT_PERCENT.getValue()))) { + log.info("Cache hit, but TTL is expiring soon. Key: {}, Thread: {}", key, Thread.currentThread().getName()); + return refreshCache(cachedValue, ttl, key); + } + + log.info("Cache hit. Key: {}, Thread: {}", key, Thread.currentThread().getName()); + return cachedValue; + } + + private Object createCache(ProceedingJoinPoint joinPoint, CacheManager cacheManager, Long ttl, String key) { + return executeWithLock( + redisUtils.getCreateLockKey(key), + () -> { + log.info("생성락 흭득하였습니다. Key: {}, Thread: {}", key, Thread.currentThread().getName()); + Object result = proceedJoinPoint(joinPoint); + cacheManager.put(key, result, ttl); + redisTemplate.convertAndSend(CREATE_CHANNEL.getValue(), key); + log.info("캐시 생성 후 채널에 pub 진행합니다. Key: {}, Thread: {}", key, Thread.currentThread().getName()); + return result; + }, + () -> { + log.info("생성락 흭득에 실패하여 대기하러 갑니다. Key: {}, Thread: {}", key, Thread.currentThread().getName()); + return waitForCacheToUpdate(joinPoint, key); + } + ); + } + + private Object refreshCache(Object cachedValue, Long ttl, String key) { + return executeWithLock( + redisUtils.getRefreshLockKey(key), + () -> { + log.info("갱신락 흭득하였습니다. Key: {}, Thread: {}", key, Thread.currentThread().getName()); + redisTemplate.opsForValue().getAndExpire(key, Duration.ofSeconds(ttl)); + log.info("TTL 갱신을 마쳤습니다. Key: {}, Thread: {}", key, Thread.currentThread().getName()); + return cachedValue; + }, + () -> { + log.info("갱신락 흭득에 실패하였습니다. 캐시의 값을 바로 반환합니다. Key: {}, Thread: {}", key, Thread.currentThread().getName()); + return cachedValue; + } + ); + } + + private Object executeWithLock(String lockKey, Callable onLockAcquired, Callable onLockFailed) { + String lockValue = UUID.randomUUID().toString(); + boolean lockAcquired = false; + + try { + lockAcquired = tryAcquireLock(lockKey, lockValue); + if (lockAcquired) { + return onLockAcquired.call(); + } else { + return onLockFailed.call(); + } + } catch (Exception e) { + throw new RuntimeException("Error during executeWithLock", e); + } finally { + releaseLock(lockKey, lockValue, lockAcquired); + } + } + + private boolean tryAcquireLock(String lockKey, String lockValue) { + return redisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, Duration.ofMillis(Long.parseLong(LOCK_TIMEOUT_MS.getValue()))); + } + + private void releaseLock(String lockKey, String lockValue, boolean lockAcquired) { + if (lockAcquired && lockValue.equals(redisTemplate.opsForValue().get(lockKey))) { + redisTemplate.delete(lockKey); + log.info("락 반환합니다. Key: {}", lockKey); + } + } + + private Object waitForCacheToUpdate(ProceedingJoinPoint joinPoint, String key) { + CompletableFuture future = futureManager.getOrCreateFuture(key); + try { + future.get(Long.parseLong(MAX_WAIT_TIME_MS.getValue()), TimeUnit.MILLISECONDS); + log.info("대기에서 빠져나와 생성된 캐시값을 가져옵니다. Key: {}, Thread: {}", key, Thread.currentThread().getName()); + return redisTemplate.opsForValue().get(key); + } catch (TimeoutException e) { + log.warn("대기중 타임아웃 발생하여 DB 접근하여 반환합니다. Key: {}, Thread: {}", key, Thread.currentThread().getName()); + return proceedJoinPoint(joinPoint); + } catch (InterruptedException | ExecutionException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Error during waitForCacheToUpdate", e); + } + } + + private Object proceedJoinPoint(ProceedingJoinPoint joinPoint) { + try { + return joinPoint.proceed(); + } catch (Throwable e) { + throw new RuntimeException("Error during proceedJoinPoint", e); + } + } +} diff --git a/src/main/java/com/example/solidconnection/cache/annotation/DefaultCacheOut.java b/src/main/java/com/example/solidconnection/cache/annotation/DefaultCacheOut.java new file mode 100644 index 000000000..bb1d5b518 --- /dev/null +++ b/src/main/java/com/example/solidconnection/cache/annotation/DefaultCacheOut.java @@ -0,0 +1,14 @@ +package com.example.solidconnection.cache.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface DefaultCacheOut { + String[] key(); + String cacheManager(); + boolean prefix() default false; +} diff --git a/src/main/java/com/example/solidconnection/cache/annotation/DefaultCaching.java b/src/main/java/com/example/solidconnection/cache/annotation/DefaultCaching.java new file mode 100644 index 000000000..36c45a616 --- /dev/null +++ b/src/main/java/com/example/solidconnection/cache/annotation/DefaultCaching.java @@ -0,0 +1,14 @@ +package com.example.solidconnection.cache.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface DefaultCaching { + String key(); + String cacheManager(); + long ttlSec(); +} diff --git a/src/main/java/com/example/solidconnection/cache/annotation/ThunderingHerdCaching.java b/src/main/java/com/example/solidconnection/cache/annotation/ThunderingHerdCaching.java new file mode 100644 index 000000000..6772a52e7 --- /dev/null +++ b/src/main/java/com/example/solidconnection/cache/annotation/ThunderingHerdCaching.java @@ -0,0 +1,14 @@ +package com.example.solidconnection.cache.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface ThunderingHerdCaching { + String key(); + String cacheManager(); + long ttlSec(); +} diff --git a/src/main/java/com/example/solidconnection/cache/manager/CacheManager.java b/src/main/java/com/example/solidconnection/cache/manager/CacheManager.java new file mode 100644 index 000000000..8c46324e1 --- /dev/null +++ b/src/main/java/com/example/solidconnection/cache/manager/CacheManager.java @@ -0,0 +1,8 @@ +package com.example.solidconnection.cache.manager; + +public interface CacheManager { + void put(String key, Object value, Long ttl); + Object get(String key); + void evict(String key); + void evictUsingPrefix(String key); +} diff --git a/src/main/java/com/example/solidconnection/cache/manager/CustomCacheManager.java b/src/main/java/com/example/solidconnection/cache/manager/CustomCacheManager.java new file mode 100644 index 000000000..833ed00f7 --- /dev/null +++ b/src/main/java/com/example/solidconnection/cache/manager/CustomCacheManager.java @@ -0,0 +1,41 @@ +package com.example.solidconnection.cache.manager; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.util.Set; + +@Component("customCacheManager") +public class CustomCacheManager implements CacheManager { + private final RedisTemplate redisTemplate; + + @Autowired + public CustomCacheManager(RedisTemplate redisTemplate) { + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + this.redisTemplate = redisTemplate; + } + + public void put(String key, Object object, Long ttl) { + redisTemplate.opsForValue().set(key, object, Duration.ofSeconds(ttl)); + } + + public Object get(String key) { + return redisTemplate.opsForValue().get(key); + } + + public void evict(String key) { + redisTemplate.delete(key); + } + + public void evictUsingPrefix(String key) { + Set keys = redisTemplate.keys(key+"*"); + if (keys != null && !keys.isEmpty()) { + redisTemplate.delete(keys); + } + } +} diff --git a/src/main/java/com/example/solidconnection/config/redis/RedisConfig.java b/src/main/java/com/example/solidconnection/config/redis/RedisConfig.java index 1aa671dcf..282c36e8c 100644 --- a/src/main/java/com/example/solidconnection/config/redis/RedisConfig.java +++ b/src/main/java/com/example/solidconnection/config/redis/RedisConfig.java @@ -1,5 +1,6 @@ package com.example.solidconnection.config.redis; +import com.example.solidconnection.cache.CacheUpdateListener; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -9,9 +10,14 @@ import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.script.RedisScript; +import org.springframework.data.redis.listener.PatternTopic; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; +import static com.example.solidconnection.type.RedisConstants.CREATE_CHANNEL; + @Configuration @EnableRedisRepositories public class RedisConfig { @@ -40,6 +46,24 @@ public RedisTemplate redisTemplate() { return redisTemplate; } + @Bean + public RedisTemplate objectRedisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<>(Object.class)); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + return redisTemplate; + } + + @Bean + RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory connectionFactory, + CacheUpdateListener listener) { + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + container.setConnectionFactory(connectionFactory); + container.addMessageListener(listener, new PatternTopic(CREATE_CHANNEL.getValue())); + return container; + } + @Bean(name = "incrViewCountScript") public RedisScript incrViewCountLuaScript() { Resource scriptSource = new ClassPathResource("scripts/incrViewCount.lua"); diff --git a/src/main/java/com/example/solidconnection/post/domain/Post.java b/src/main/java/com/example/solidconnection/post/domain/Post.java index 287b255a8..203feb5a9 100644 --- a/src/main/java/com/example/solidconnection/post/domain/Post.java +++ b/src/main/java/com/example/solidconnection/post/domain/Post.java @@ -97,9 +97,4 @@ public void update(PostUpdateRequest postUpdateRequest) { this.content = postUpdateRequest.content(); this.category = PostCategory.valueOf(postUpdateRequest.postCategory()); } - - public void increaseViewCount(Long updateViewCount) { - this.viewCount += updateViewCount; - } - } diff --git a/src/main/java/com/example/solidconnection/post/repository/PostRepository.java b/src/main/java/com/example/solidconnection/post/repository/PostRepository.java index f5c10875c..b819cc45a 100644 --- a/src/main/java/com/example/solidconnection/post/repository/PostRepository.java +++ b/src/main/java/com/example/solidconnection/post/repository/PostRepository.java @@ -38,4 +38,9 @@ default Post getById(Long id) { @Query("UPDATE Post p SET p.likeCount = p.likeCount + 1 " + "WHERE p.id = :postId") void increaseLikeCount(@Param("postId") Long postId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("UPDATE Post p SET p.viewCount = p.viewCount + :count " + + "WHERE p.id = :postId") + void increaseViewCount(@Param("postId") Long postId, @Param("count") Long count); } diff --git a/src/main/java/com/example/solidconnection/service/UpdateViewCountService.java b/src/main/java/com/example/solidconnection/service/UpdateViewCountService.java index 46954fff6..55d4d9eba 100644 --- a/src/main/java/com/example/solidconnection/service/UpdateViewCountService.java +++ b/src/main/java/com/example/solidconnection/service/UpdateViewCountService.java @@ -26,7 +26,7 @@ public void updateViewCount(String key) { log.info("updateViewCount Processing key: {} in thread: {}", key, Thread.currentThread().getName()); Long postId = redisUtils.getPostIdFromPostViewCountRedisKey(key); Post post = postRepository.getById(postId); - post.increaseViewCount(redisService.getAndDelete(key)); + postRepository.increaseViewCount(postId, redisService.getAndDelete(key)); log.info("updateViewCount Updated post id: {} with view count from key: {}", postId, key); } } diff --git a/src/main/java/com/example/solidconnection/type/RedisConstants.java b/src/main/java/com/example/solidconnection/type/RedisConstants.java index 22d7762b1..7d4c7f2c9 100644 --- a/src/main/java/com/example/solidconnection/type/RedisConstants.java +++ b/src/main/java/com/example/solidconnection/type/RedisConstants.java @@ -8,7 +8,14 @@ public enum RedisConstants { VALIDATE_VIEW_COUNT_TTL("1"), VALIDATE_VIEW_COUNT_KEY_PREFIX("validate:post:view:"), VIEW_COUNT_KEY_PREFIX("post:view:"), - VIEW_COUNT_KEY_PATTERN("post:view:*"); + VIEW_COUNT_KEY_PATTERN("post:view:*"), + + REFRESH_LIMIT_PERCENT("10.0"), + CREATE_LOCK_PREFIX("create_lock:"), + REFRESH_LOCK_PREFIX("refresh_lock:"), + LOCK_TIMEOUT_MS("10000"), + MAX_WAIT_TIME_MS("3000"), + CREATE_CHANNEL("create_channel"); private final String value; diff --git a/src/main/java/com/example/solidconnection/university/controller/UniversityController.java b/src/main/java/com/example/solidconnection/university/controller/UniversityController.java index 3b19c683e..4d0609549 100644 --- a/src/main/java/com/example/solidconnection/university/controller/UniversityController.java +++ b/src/main/java/com/example/solidconnection/university/controller/UniversityController.java @@ -72,6 +72,7 @@ public ResponseEntity getUniversityDetails( return ResponseEntity.ok(universityDetailResponse); } + // todo return타입 UniversityInfoForApplyPreviewResponses로 추후 수정 필요 @GetMapping("/search") public ResponseEntity> searchUniversity( @RequestParam(required = false, defaultValue = "") String region, @@ -79,7 +80,7 @@ public ResponseEntity> searchUnivers @RequestParam(required = false, defaultValue = "") LanguageTestType testType, @RequestParam(required = false, defaultValue = "") String testScore) { List universityInfoForApplyPreviewResponse - = universityService.searchUniversity(region, keyword, testType, testScore); + = universityService.searchUniversity(region, keyword, testType, testScore).universityInfoForApplyPreviewResponses(); return ResponseEntity.ok(universityInfoForApplyPreviewResponse); } } diff --git a/src/main/java/com/example/solidconnection/university/dto/UniversityInfoForApplyPreviewResponses.java b/src/main/java/com/example/solidconnection/university/dto/UniversityInfoForApplyPreviewResponses.java new file mode 100644 index 000000000..3c8a00df4 --- /dev/null +++ b/src/main/java/com/example/solidconnection/university/dto/UniversityInfoForApplyPreviewResponses.java @@ -0,0 +1,8 @@ +package com.example.solidconnection.university.dto; + +import java.util.List; + +public record UniversityInfoForApplyPreviewResponses( + List universityInfoForApplyPreviewResponses +) { +} diff --git a/src/main/java/com/example/solidconnection/university/service/UniversityRecommendService.java b/src/main/java/com/example/solidconnection/university/service/UniversityRecommendService.java index 88a7a222f..cf9c112f8 100644 --- a/src/main/java/com/example/solidconnection/university/service/UniversityRecommendService.java +++ b/src/main/java/com/example/solidconnection/university/service/UniversityRecommendService.java @@ -1,5 +1,6 @@ package com.example.solidconnection.university.service; +import com.example.solidconnection.cache.annotation.ThunderingHerdCaching; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.repository.SiteUserRepository; import com.example.solidconnection.university.domain.UniversityInfoForApply; @@ -65,6 +66,7 @@ private List getGeneralRecommendsExcludingSelected(List< * 공통 추천 대학교를 불러온다. * */ @Transactional(readOnly = true) + @ThunderingHerdCaching(key = "university:recommend:general", cacheManager = "customCacheManager", ttlSec = 86400) public UniversityRecommendsResponse getGeneralRecommends() { List generalRecommends = new ArrayList<>(generalRecommendUniversities.getRecommendUniversities()); Collections.shuffle(generalRecommends); diff --git a/src/main/java/com/example/solidconnection/university/service/UniversityService.java b/src/main/java/com/example/solidconnection/university/service/UniversityService.java index 94e8bba83..c0cbe2c05 100644 --- a/src/main/java/com/example/solidconnection/university/service/UniversityService.java +++ b/src/main/java/com/example/solidconnection/university/service/UniversityService.java @@ -1,5 +1,6 @@ package com.example.solidconnection.university.service; +import com.example.solidconnection.cache.annotation.ThunderingHerdCaching; import com.example.solidconnection.siteuser.domain.SiteUser; import com.example.solidconnection.siteuser.repository.LikedUniversityRepository; import com.example.solidconnection.siteuser.repository.SiteUserRepository; @@ -7,10 +8,7 @@ import com.example.solidconnection.university.domain.LikedUniversity; import com.example.solidconnection.university.domain.University; import com.example.solidconnection.university.domain.UniversityInfoForApply; -import com.example.solidconnection.university.dto.IsLikeResponse; -import com.example.solidconnection.university.dto.LikeResultResponse; -import com.example.solidconnection.university.dto.UniversityDetailResponse; -import com.example.solidconnection.university.dto.UniversityInfoForApplyPreviewResponse; +import com.example.solidconnection.university.dto.*; import com.example.solidconnection.university.repository.UniversityInfoForApplyRepository; import com.example.solidconnection.university.repository.custom.UniversityFilterRepositoryImpl; import lombok.RequiredArgsConstructor; @@ -41,6 +39,7 @@ public class UniversityService { * - 대학교(University) 정보와 대학 지원 정보(UniversityInfoForApply) 정보를 조합하여 반환한다. * */ @Transactional(readOnly = true) + @ThunderingHerdCaching(key = "university:{0}", cacheManager = "customCacheManager", ttlSec = 86400) public UniversityDetailResponse getUniversityDetail(Long universityInfoForApplyId) { UniversityInfoForApply universityInfoForApply = universityInfoForApplyRepository.getUniversityInfoForApplyById(universityInfoForApplyId); @@ -57,13 +56,15 @@ public UniversityDetailResponse getUniversityDetail(Long universityInfoForApplyI * - 언어 시험 점수는 합격 최소 점수보다 높은 것이 조건이다. * */ @Transactional(readOnly = true) - public List searchUniversity( + @ThunderingHerdCaching(key = "university:{0}:{1}:{2}:{3}", cacheManager = "customCacheManager", ttlSec = 86400) + public UniversityInfoForApplyPreviewResponses searchUniversity( String regionCode, List keywords, LanguageTestType testType, String testScore) { - return universityFilterRepository + + return new UniversityInfoForApplyPreviewResponses(universityFilterRepository .findByRegionCodeAndKeywordsAndLanguageTestTypeAndTestScoreAndTerm(regionCode, keywords, testType, testScore, term) .stream() .map(UniversityInfoForApplyPreviewResponse::from) - .toList(); + .toList()); } /* diff --git a/src/main/java/com/example/solidconnection/util/RedisUtils.java b/src/main/java/com/example/solidconnection/util/RedisUtils.java index 7df79418e..ef91dfc3d 100644 --- a/src/main/java/com/example/solidconnection/util/RedisUtils.java +++ b/src/main/java/com/example/solidconnection/util/RedisUtils.java @@ -11,8 +11,7 @@ import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; -import static com.example.solidconnection.type.RedisConstants.VALIDATE_VIEW_COUNT_KEY_PREFIX; -import static com.example.solidconnection.type.RedisConstants.VIEW_COUNT_KEY_PREFIX; +import static com.example.solidconnection.type.RedisConstants.*; @Component public class RedisUtils { @@ -49,4 +48,28 @@ public String getValidatePostViewCountRedisKey(String email, Long postId) { public Long getPostIdFromPostViewCountRedisKey(String key) { return Long.parseLong(key.substring(VIEW_COUNT_KEY_PREFIX.getValue().length())); } + + public String generateCacheKey(String keyPattern, Object[] args) { + for (int i = 0; i < args.length; i++) { + // 키 패턴에 {i}가 포함된 경우에만 해당 인덱스의 파라미터를 삽입 + if (keyPattern.contains("{" + i + "}")) { + String replacement = (args[i] != null) ? args[i].toString() : "null"; + keyPattern = keyPattern.replace("{" + i + "}", replacement); + } + } + return keyPattern; + } + + public String getCreateLockKey(String key) { + return CREATE_LOCK_PREFIX.getValue() + key; + } + + public String getRefreshLockKey(String key) { + return REFRESH_LOCK_PREFIX.getValue() + key; + } + + public boolean isCacheExpiringSoon(String key, Long defaultTtl, Double percent) { + Long leftTtl = redisTemplate.getExpire(key); + return defaultTtl != null && ((double) leftTtl /defaultTtl)*100 < percent; + } } diff --git a/src/test/java/com/example/solidconnection/concurrency/ThunderingHerdTest.java b/src/test/java/com/example/solidconnection/concurrency/ThunderingHerdTest.java new file mode 100644 index 000000000..7ec6a511e --- /dev/null +++ b/src/test/java/com/example/solidconnection/concurrency/ThunderingHerdTest.java @@ -0,0 +1,90 @@ +package com.example.solidconnection.concurrency; + +import com.example.solidconnection.application.service.ApplicationQueryService; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; +import com.example.solidconnection.type.Gender; +import com.example.solidconnection.type.PreparationStatus; +import com.example.solidconnection.type.Role; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.test.context.ActiveProfiles; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +@SpringBootTest +@ActiveProfiles("test") +@DisplayName("ThunderingHerd 테스트") +public class ThunderingHerdTest { + @Autowired + private ApplicationQueryService applicationQueryService; + @Autowired + private RedisTemplate redisTemplate; + @Autowired + private SiteUserRepository siteUserRepository; + private int THREAD_NUMS = 1000; + private int THREAD_POOL_SIZE = 200; + private int TIMEOUT_SECONDS = 10; + private SiteUser siteUser; + + @BeforeEach + public void setUp() { + siteUser = createSiteUser(); + siteUserRepository.save(siteUser); + } + + private SiteUser createSiteUser() { + return new SiteUser( + "test@example.com", + "nickname", + "profileImageUrl", + "1999-01-01", + PreparationStatus.CONSIDERING, + Role.MENTEE, + Gender.MALE + ); + } + + @Test + public void ThunderingHerd_문제를_해결한다() throws InterruptedException { + redisTemplate.opsForValue().getAndDelete("application::"); + redisTemplate.opsForValue().getAndDelete("application:ASIA:"); + redisTemplate.opsForValue().getAndDelete("application::추오"); + + ExecutorService executorService = Executors.newFixedThreadPool(THREAD_POOL_SIZE); + CountDownLatch doneSignal = new CountDownLatch(THREAD_NUMS); + + for (int i = 0; i < THREAD_NUMS; i++) { + executorService.submit(() -> { + try { + List tasks = Arrays.asList( + () -> applicationQueryService.getApplicants(siteUser.getEmail(), "", ""), + () -> applicationQueryService.getApplicants(siteUser.getEmail(), "ASIA", ""), + () -> applicationQueryService.getApplicants(siteUser.getEmail(), "", "추오") + ); + Collections.shuffle(tasks); + tasks.forEach(Runnable::run); + } finally { + doneSignal.countDown(); + } + }); + } + + doneSignal.await(TIMEOUT_SECONDS, TimeUnit.SECONDS); + executorService.shutdown(); + boolean terminated = executorService.awaitTermination(TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (!terminated) { + System.err.println("ExecutorService did not terminate in the expected time."); + } + } +} diff --git a/src/test/java/com/example/solidconnection/unit/service/ApplicationServiceTest.java b/src/test/java/com/example/solidconnection/unit/service/ApplicationServiceTest.java index d967c90bf..7688478fc 100644 --- a/src/test/java/com/example/solidconnection/unit/service/ApplicationServiceTest.java +++ b/src/test/java/com/example/solidconnection/unit/service/ApplicationServiceTest.java @@ -16,6 +16,8 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -23,6 +25,7 @@ import java.util.Optional; +import static com.example.solidconnection.custom.exception.ErrorCode.CANT_APPLY_FOR_SAME_UNIVERSITY; import static com.example.solidconnection.custom.exception.ErrorCode.SCORE_SHOULD_SUBMITTED_FIRST; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -116,7 +119,9 @@ private Application createApplication(String term) { verify(applicationRepository, times(0)).save(any(Application.class)); } - // 예외테스트 + /** + * 지망대학 제출 + */ @Test void 지망대학_제출할_때_성적_제출이력이_없다면_예외_응답을_반환한다() { // given @@ -171,4 +176,48 @@ private Application createApplication(String term) { verify(siteUserRepository, times(0)).getByEmail(siteUser.getEmail()); verify(applicationRepository, times(0)).save(any(Application.class)); } + + @ParameterizedTest + @CsvSource({ + "1, 2, 3", + "1, , 3", + "1, 2, ", + "1, , " + }) + void 지망대학_제출할_때_2지망과_3지망은_NULL_허용한다(Long firstChoice, Long secondChoice, Long thirdChoice) { + // Given + UniversityChoiceRequest universityChoiceRequest = new UniversityChoiceRequest(firstChoice, secondChoice, thirdChoice); + when(applicationRepository.findTop1BySiteUser_EmailOrderByTermDesc(siteUser.getEmail())) + .thenReturn(Optional.of(application)); + + // When + applicationSubmissionService.submitUniversityChoice(siteUser.getEmail(), universityChoiceRequest); + + // Then + verify(applicationRepository, times(1)).findTop1BySiteUser_EmailOrderByTermDesc(siteUser.getEmail()); + verify(siteUserRepository, times(0)).getByEmail(siteUser.getEmail()); + verify(applicationRepository, times(0)).save(any(Application.class)); + } + + @ParameterizedTest + @CsvSource({ + "1, 1, 1", + "1, 2, 1", + "1, 1, 2", + "1, , 1", + "1, 1, " + }) + void 지망대학_제출할_때_선택지가_중복된다면_예외_응답을_반환한다(Long firstChoice, Long secondChoice, Long thirdChoice) { + // given + UniversityChoiceRequest universityChoiceRequest = new UniversityChoiceRequest(firstChoice, secondChoice, thirdChoice); + + // when, then + CustomException exception = assertThrows(CustomException.class, () -> { + applicationSubmissionService.submitUniversityChoice(siteUser.getEmail(), universityChoiceRequest); + }); + assertThat(exception.getMessage()) + .isEqualTo(CANT_APPLY_FOR_SAME_UNIVERSITY.getMessage()); + assertThat(exception.getCode()) + .isEqualTo(CANT_APPLY_FOR_SAME_UNIVERSITY.getCode()); + } }