From 47ca969dfeaf6c59a40738d011c02edcfbdaac90 Mon Sep 17 00:00:00 2001 From: sewon Date: Mon, 2 Sep 2024 14:42:18 +0900 Subject: [PATCH 01/10] =?UTF-8?q?feat:=20Redis=20=EB=B0=8F=20AOP=20?= =?UTF-8?q?=ED=99=9C=EC=9A=A9=ED=95=9C=20=EC=BB=A4=EC=8A=A4=ED=85=80=20?= =?UTF-8?q?=EC=BA=90=EC=8B=B1=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../solidconnection/cache/CachingAspect.java | 56 +++++++++++++++++++ .../cache/annotation/DefaultCacheOut.java | 14 +++++ .../cache/annotation/DefaultCaching.java | 14 +++++ .../cache/manager/CacheManager.java | 8 +++ .../cache/manager/CustomCacheManager.java | 41 ++++++++++++++ .../config/redis/RedisConfig.java | 24 ++++++++ .../solidconnection/type/RedisConstants.java | 9 ++- .../solidconnection/util/RedisUtils.java | 27 ++++++++- 8 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/example/solidconnection/cache/CachingAspect.java create mode 100644 src/main/java/com/example/solidconnection/cache/annotation/DefaultCacheOut.java create mode 100644 src/main/java/com/example/solidconnection/cache/annotation/DefaultCaching.java create mode 100644 src/main/java/com/example/solidconnection/cache/manager/CacheManager.java create mode 100644 src/main/java/com/example/solidconnection/cache/manager/CustomCacheManager.java 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/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/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/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/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; + } } From 844268fda34c5728d908c154faec267b41a866fe Mon Sep 17 00:00:00 2001 From: sewon Date: Mon, 2 Sep 2024 14:45:24 +0900 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20Thundering=20Herd=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=EB=A5=BC=20=ED=95=B4=EA=B2=B0=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EC=BA=90=EC=8B=B1=20=EA=B8=B0=EB=8A=A5=20=EA=B0=9C=EB=B0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cache/ThunderingHerdCachingAspect.java | 150 ++++++++++++++++++ .../annotation/ThunderingHerdCaching.java | 14 ++ 2 files changed, 164 insertions(+) create mode 100644 src/main/java/com/example/solidconnection/cache/ThunderingHerdCachingAspect.java create mode 100644 src/main/java/com/example/solidconnection/cache/annotation/ThunderingHerdCaching.java 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/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(); +} From d2f7a3933ae112d34fa515d4cac5b18c3e9f94d9 Mon Sep 17 00:00:00 2001 From: sewon Date: Mon, 2 Sep 2024 14:46:06 +0900 Subject: [PATCH 03/10] =?UTF-8?q?feat:=20Redis=20pub/sub=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EB=8F=84=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cache/CacheUpdateListener.java | 22 ++++++++++++++++++ .../cache/CompletableFutureManager.java | 23 +++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 src/main/java/com/example/solidconnection/cache/CacheUpdateListener.java create mode 100644 src/main/java/com/example/solidconnection/cache/CompletableFutureManager.java 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/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); + } + } +} From e55a843fb438c6dda2c4ecff35f46367767ef994 Mon Sep 17 00:00:00 2001 From: sewon Date: Mon, 2 Sep 2024 14:47:55 +0900 Subject: [PATCH 04/10] =?UTF-8?q?refactor:=20=EC=BA=90=EC=8B=B1=20?= =?UTF-8?q?=EB=8F=84=EC=9E=85=EC=97=90=20=EB=94=B0=EB=9D=BC=EC=84=9C=20?= =?UTF-8?q?=EC=9C=A0=ED=9A=A8=EC=84=B1=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/controller/ApplicationController.java | 1 + 1 file changed, 1 insertion(+) 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); From 0b84b0f65f33050c153adb6a280dc46efaddf77e Mon Sep 17 00:00:00 2001 From: sewon Date: Mon, 2 Sep 2024 14:49:49 +0900 Subject: [PATCH 05/10] =?UTF-8?q?refactor:=20=EC=BA=90=EC=8B=B1=EB=90=9C?= =?UTF-8?q?=20list=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=97=AD=EC=A7=81?= =?UTF-8?q?=EB=A0=AC=ED=99=94=20=EB=AC=B8=EC=A0=9C=EB=A5=BC=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0=ED=95=98=EA=B8=B0=20=EC=9C=84=ED=95=B4=20Wrapping=20D?= =?UTF-8?q?TO=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../university/controller/UniversityController.java | 3 ++- .../dto/UniversityInfoForApplyPreviewResponses.java | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/example/solidconnection/university/dto/UniversityInfoForApplyPreviewResponses.java 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 +) { +} From b33ad23a7625b1e96d15c71da185d5c810270e4d Mon Sep 17 00:00:00 2001 From: sewon Date: Mon, 2 Sep 2024 14:53:18 +0900 Subject: [PATCH 06/10] =?UTF-8?q?feat:=20=EC=A7=80=EC=9B=90=EC=9E=90=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=EC=97=90=20=EC=BA=90?= =?UTF-8?q?=EC=8B=B1=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../solidconnection/SolidConnectionApplication.java | 2 ++ .../application/service/ApplicationQueryService.java | 9 ++++++--- 2 files changed, 8 insertions(+), 3 deletions(-) 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/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); } From 6082af9659211342d0aecd850d814e1200408b15 Mon Sep 17 00:00:00 2001 From: sewon Date: Mon, 2 Sep 2024 14:54:31 +0900 Subject: [PATCH 07/10] =?UTF-8?q?feat:=20=EA=B8=B0=EC=A1=B4=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=EC=9E=90=20=EC=84=B1=EC=A0=81=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=98=90=EB=8A=94=20=EC=83=88=EB=A1=9C=EC=9A=B4=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=EC=9E=90=20=EB=93=B1=EB=A1=9D=20=EC=8B=9C=20=EC=BA=90?= =?UTF-8?q?=EC=8B=9C=EC=95=84=EC=9B=83=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/service/ApplicationSubmissionService.java | 3 +++ 1 file changed, 3 insertions(+) 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 0f22be85f..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,6 +69,7 @@ public boolean submitScore(String email, ScoreRequest scoreRequest) { * - 성적 승인 상태(verifyStatus) 는 변경하지 않는다. * */ @Transactional + @DefaultCacheOut(key = "application:query", cacheManager = "customCacheManager", prefix = true) public boolean submitUniversityChoice(String email, UniversityChoiceRequest universityChoiceRequest) { validateUniversityChoices(universityChoiceRequest); From 6f9833d58a017ecfb04dd21c44cacbbf51e907a7 Mon Sep 17 00:00:00 2001 From: sewon Date: Mon, 2 Sep 2024 14:55:12 +0900 Subject: [PATCH 08/10] =?UTF-8?q?feat:=20=EA=B3=B5=ED=86=B5=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=ED=95=99=EA=B5=90=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=EC=97=90=20=EC=BA=90=EC=8B=B1=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../university/service/UniversityRecommendService.java | 2 ++ 1 file changed, 2 insertions(+) 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); From d8e4848603967d63b92a4b4efd43538909ba6849 Mon Sep 17 00:00:00 2001 From: sewon Date: Mon, 2 Sep 2024 14:55:45 +0900 Subject: [PATCH 09/10] =?UTF-8?q?feat:=20=EB=8C=80=ED=95=99=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=EC=97=90=20?= =?UTF-8?q?=EC=BA=90=EC=8B=B1=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../university/service/UniversityService.java | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) 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()); } /* From 2eee17a0299957dec9a724c157191dda27923cda Mon Sep 17 00:00:00 2001 From: sewon Date: Mon, 2 Sep 2024 14:56:13 +0900 Subject: [PATCH 10/10] =?UTF-8?q?test:=20Thundering=20Herd=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../concurrency/ThunderingHerdTest.java | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) create mode 100644 src/test/java/com/example/solidconnection/concurrency/ThunderingHerdTest.java 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."); + } + } +}