From 7aee8df2957d546e8d9eee8ee803052e700f6fa2 Mon Sep 17 00:00:00 2001 From: Hexeong <123macanic@naver.com> Date: Sat, 27 Jun 2026 15:00:41 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refactor:=20=EB=A7=8C=EB=A3=8C=20=EC=9E=84?= =?UTF-8?q?=EB=B0=95=20=EC=BA=90=EC=8B=9C=20=EA=B0=B1=EC=8B=A0=20=EC=8B=9C?= =?UTF-8?q?=20=EA=B0=92=EC=9D=84=20=EC=9E=AC=EA=B3=84=EC=82=B0=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95=20-?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cache/ThunderingHerdCachingAspect.java | 19 +- .../solidconnection/util/RedisUtils.java | 5 +- .../ThunderingHerdCachingAspectTest.java | 185 ++++++++++++++++++ 3 files changed, 203 insertions(+), 6 deletions(-) create mode 100644 src/test/java/com/example/solidconnection/cache/ThunderingHerdCachingAspectTest.java diff --git a/src/main/java/com/example/solidconnection/cache/ThunderingHerdCachingAspect.java b/src/main/java/com/example/solidconnection/cache/ThunderingHerdCachingAspect.java index 22652b1c1..1a9c1c5ad 100644 --- a/src/main/java/com/example/solidconnection/cache/ThunderingHerdCachingAspect.java +++ b/src/main/java/com/example/solidconnection/cache/ThunderingHerdCachingAspect.java @@ -63,7 +63,7 @@ public Object cache(ProceedingJoinPoint joinPoint, ThunderingHerdCaching thunder 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); + return refreshCache(joinPoint, cacheManager, cachedValue, ttl, key); } log.info("Cache hit. Key: {}, Thread: {}", key, Thread.currentThread().getName()); @@ -88,14 +88,23 @@ private Object createCache(ProceedingJoinPoint joinPoint, CacheManager cacheMana ); } - private Object refreshCache(Object cachedValue, Long ttl, String key) { + private Object refreshCache(ProceedingJoinPoint joinPoint, CacheManager cacheManager, 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; + try { + Object result = proceedJoinPoint(joinPoint); + cacheManager.put(key, result, ttl); + log.info("캐시 갱신을 마쳤습니다. Key: {}, Thread: {}", key, Thread.currentThread().getName()); + return result; + } catch (CustomException e) { + throw e; + } catch (RuntimeException e) { + log.warn("캐시 갱신 중 오류가 발생하여 기존 캐시값을 반환합니다. Key: {}, Thread: {}", + key, Thread.currentThread().getName(), e); + return cachedValue; + } }, () -> { log.info("갱신락 흭득에 실패하였습니다. 캐시의 값을 바로 반환합니다. Key: {}, Thread: {}", key, Thread.currentThread().getName()); diff --git a/src/main/java/com/example/solidconnection/util/RedisUtils.java b/src/main/java/com/example/solidconnection/util/RedisUtils.java index a18fe1791..c980a34cd 100644 --- a/src/main/java/com/example/solidconnection/util/RedisUtils.java +++ b/src/main/java/com/example/solidconnection/util/RedisUtils.java @@ -58,6 +58,9 @@ public String getRefreshLockKey(String key) { public boolean isCacheExpiringSoon(String key, Long defaultTtl, Double percent) { Long leftTtl = redisTemplate.getExpire(key); - return defaultTtl != null && ((double) leftTtl / defaultTtl) * 100 < percent; + if (defaultTtl == null || defaultTtl <= 0 || leftTtl == null || leftTtl < 0 || percent == null) { + return false; + } + return ((double) leftTtl / defaultTtl) * 100 < percent; } } diff --git a/src/test/java/com/example/solidconnection/cache/ThunderingHerdCachingAspectTest.java b/src/test/java/com/example/solidconnection/cache/ThunderingHerdCachingAspectTest.java new file mode 100644 index 000000000..ace0f1c44 --- /dev/null +++ b/src/test/java/com/example/solidconnection/cache/ThunderingHerdCachingAspectTest.java @@ -0,0 +1,185 @@ +package com.example.solidconnection.cache; + +import static com.example.solidconnection.redis.RedisConstants.REFRESH_LOCK_PREFIX; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertAll; + +import com.example.solidconnection.cache.annotation.ThunderingHerdCaching; +import com.example.solidconnection.common.exception.CustomException; +import com.example.solidconnection.common.exception.ErrorCode; +import com.example.solidconnection.support.TestContainerSpringBootTest; +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +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.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.data.redis.core.RedisTemplate; + +@TestContainerSpringBootTest +@DisplayName("ThunderingHerdCaching Aspect 테스트") +class ThunderingHerdCachingAspectTest { + + private static final String CACHE_KEY_PREFIX = "test:thundering:"; + private static final long TEST_CACHE_TTL_SEC = 20L; + + @Autowired + private TestCacheService testCacheService; + + @Autowired + private RedisTemplate redisTemplate; + + @BeforeEach + void setUp() { + testCacheService.reset(); + } + + @Test + void 캐시가_만료_임박하면_갱신락을_획득한_요청이_값을_다시_계산한다() { + // given + String firstValue = testCacheService.getValue("refresh"); + redisTemplate.expire(cacheKey("refresh"), Duration.ofSeconds(1)); + + // when + String secondValue = testCacheService.getValue("refresh"); + + // then + assertAll( + () -> assertThat(firstValue).isEqualTo("value-1"), + () -> assertThat(secondValue).isEqualTo("value-2"), + () -> assertThat(redisTemplate.opsForValue().get(cacheKey("refresh"))).isEqualTo("value-2"), + () -> assertThat(testCacheService.getCallCount()).isEqualTo(2) + ); + } + + @Test + void 캐시가_만료_임박했지만_갱신락_획득에_실패하면_기존_캐시값을_반환한다() { + // given + String firstValue = testCacheService.getValue("locked"); + redisTemplate.expire(cacheKey("locked"), Duration.ofSeconds(1)); + redisTemplate.opsForValue().set(refreshLockKey("locked"), "lock", Duration.ofSeconds(5)); + + // when + String secondValue = testCacheService.getValue("locked"); + + // then + assertAll( + () -> assertThat(secondValue).isEqualTo(firstValue), + () -> assertThat(redisTemplate.opsForValue().get(cacheKey("locked"))).isEqualTo(firstValue), + () -> assertThat(testCacheService.getCallCount()).isEqualTo(1) + ); + } + + @Test + void 캐시_갱신_중_오류가_발생하면_기존_캐시값을_반환하고_TTL을_연장하지_않는다() { + // given + String firstValue = testCacheService.getValue("failed"); + redisTemplate.expire(cacheKey("failed"), Duration.ofSeconds(1)); + testCacheService.failWithRuntimeException(); + + // when + String secondValue = testCacheService.getValue("failed"); + + // then + Long ttlMillis = redisTemplate.getExpire(cacheKey("failed"), TimeUnit.MILLISECONDS); + assertAll( + () -> assertThat(secondValue).isEqualTo(firstValue), + () -> assertThat(redisTemplate.opsForValue().get(cacheKey("failed"))).isEqualTo(firstValue), + () -> assertThat(testCacheService.getCallCount()).isEqualTo(1), + () -> assertThat(ttlMillis).isLessThan(TEST_CACHE_TTL_SEC * 1000) + ); + } + + @Test + void 캐시_갱신_중_CustomException이_발생하면_기존_캐시값으로_fallback하지_않고_예외를_전파한다() { + // given + testCacheService.getValue("custom-exception"); + redisTemplate.expire(cacheKey("custom-exception"), Duration.ofSeconds(1)); + testCacheService.failWithCustomException(); + + // when & then + assertAll( + () -> assertThatThrownBy(() -> testCacheService.getValue("custom-exception")) + .isInstanceOf(CustomException.class), + () -> assertThat(testCacheService.getCallCount()).isEqualTo(1) + ); + } + + @Test + void 만료_시간이_없는_캐시는_만료_임박으로_판단하지_않고_기존_캐시값을_반환한다() { + // given + String firstValue = testCacheService.getValue("no-expire"); + redisTemplate.persist(cacheKey("no-expire")); + + // when + String secondValue = testCacheService.getValue("no-expire"); + + // then + assertAll( + () -> assertThat(secondValue).isEqualTo(firstValue), + () -> assertThat(testCacheService.getCallCount()).isEqualTo(1), + () -> assertThat(redisTemplate.getExpire(cacheKey("no-expire"))).isEqualTo(-1) + ); + } + + private String cacheKey(String key) { + return CACHE_KEY_PREFIX + key; + } + + private String refreshLockKey(String key) { + return REFRESH_LOCK_PREFIX.getValue() + cacheKey(key); + } + + @TestConfiguration + static class TestCacheConfig { + + @Bean + TestCacheService testCacheService() { + return new TestCacheService(); + } + } + + static class TestCacheService { + + private final AtomicInteger callCount = new AtomicInteger(); + private boolean failWithRuntimeException; + private boolean failWithCustomException; + + @ThunderingHerdCaching( + key = CACHE_KEY_PREFIX + "{0}", + cacheManager = "customCacheManager", + ttlSec = TEST_CACHE_TTL_SEC + ) + public String getValue(String key) { + if (failWithCustomException) { + throw new CustomException(ErrorCode.INVALID_INPUT); + } + if (failWithRuntimeException) { + throw new IllegalStateException("refresh failed"); + } + return "value-" + callCount.incrementAndGet(); + } + + void reset() { + callCount.set(0); + failWithRuntimeException = false; + failWithCustomException = false; + } + + int getCallCount() { + return callCount.get(); + } + + void failWithRuntimeException() { + failWithRuntimeException = true; + } + + void failWithCustomException() { + failWithCustomException = true; + } + } +} From 82a3c5f04a948b02359b1d5a4b401b62daae57c6 Mon Sep 17 00:00:00 2001 From: Hexeong <123macanic@naver.com> Date: Sat, 27 Jun 2026 15:40:37 +0900 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20=EC=BA=90=EC=8B=9C=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=20=EC=A4=91=20=EC=83=9D=EC=84=B1=EB=9D=BD=EC=9D=84=20?= =?UTF-8?q?=EA=B3=B5=EC=9C=A0=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=B4?= =?UTF-8?q?=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cache/ThunderingHerdCachingAspect.java | 3 +- .../solidconnection/util/RedisUtils.java | 4 +- .../ThunderingHerdCachingAspectTest.java | 126 ++++++++++++++++-- 3 files changed, 123 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/example/solidconnection/cache/ThunderingHerdCachingAspect.java b/src/main/java/com/example/solidconnection/cache/ThunderingHerdCachingAspect.java index 1a9c1c5ad..1c88f1f22 100644 --- a/src/main/java/com/example/solidconnection/cache/ThunderingHerdCachingAspect.java +++ b/src/main/java/com/example/solidconnection/cache/ThunderingHerdCachingAspect.java @@ -90,12 +90,13 @@ private Object createCache(ProceedingJoinPoint joinPoint, CacheManager cacheMana private Object refreshCache(ProceedingJoinPoint joinPoint, CacheManager cacheManager, Object cachedValue, Long ttl, String key) { return executeWithLock( - redisUtils.getRefreshLockKey(key), + redisUtils.getCreateLockKey(key), () -> { log.info("갱신락 흭득하였습니다. Key: {}, Thread: {}", key, Thread.currentThread().getName()); try { Object result = proceedJoinPoint(joinPoint); cacheManager.put(key, result, ttl); + redisTemplate.convertAndSend(CREATE_CHANNEL.getValue(), key); log.info("캐시 갱신을 마쳤습니다. Key: {}, Thread: {}", key, Thread.currentThread().getName()); return result; } catch (CustomException e) { diff --git a/src/main/java/com/example/solidconnection/util/RedisUtils.java b/src/main/java/com/example/solidconnection/util/RedisUtils.java index c980a34cd..a336920ac 100644 --- a/src/main/java/com/example/solidconnection/util/RedisUtils.java +++ b/src/main/java/com/example/solidconnection/util/RedisUtils.java @@ -58,7 +58,9 @@ public String getRefreshLockKey(String key) { public boolean isCacheExpiringSoon(String key, Long defaultTtl, Double percent) { Long leftTtl = redisTemplate.getExpire(key); - if (defaultTtl == null || defaultTtl <= 0 || leftTtl == null || leftTtl < 0 || percent == null) { + if (defaultTtl == null || defaultTtl <= 0 + || leftTtl == null || leftTtl < 0 + || percent == null || percent <= 0 || percent > 100) { return false; } return ((double) leftTtl / defaultTtl) * 100 < percent; diff --git a/src/test/java/com/example/solidconnection/cache/ThunderingHerdCachingAspectTest.java b/src/test/java/com/example/solidconnection/cache/ThunderingHerdCachingAspectTest.java index ace0f1c44..98cb18257 100644 --- a/src/test/java/com/example/solidconnection/cache/ThunderingHerdCachingAspectTest.java +++ b/src/test/java/com/example/solidconnection/cache/ThunderingHerdCachingAspectTest.java @@ -1,15 +1,21 @@ package com.example.solidconnection.cache; -import static com.example.solidconnection.redis.RedisConstants.REFRESH_LOCK_PREFIX; +import static com.example.solidconnection.redis.RedisConstants.CREATE_LOCK_PREFIX; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.awaitility.Awaitility.await; import static org.junit.jupiter.api.Assertions.assertAll; import com.example.solidconnection.cache.annotation.ThunderingHerdCaching; import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.common.exception.ErrorCode; import com.example.solidconnection.support.TestContainerSpringBootTest; +import com.example.solidconnection.util.RedisUtils; import java.time.Duration; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import org.junit.jupiter.api.BeforeEach; @@ -33,6 +39,9 @@ class ThunderingHerdCachingAspectTest { @Autowired private RedisTemplate redisTemplate; + @Autowired + private RedisUtils redisUtils; + @BeforeEach void setUp() { testCacheService.reset(); @@ -61,7 +70,7 @@ void setUp() { // given String firstValue = testCacheService.getValue("locked"); redisTemplate.expire(cacheKey("locked"), Duration.ofSeconds(1)); - redisTemplate.opsForValue().set(refreshLockKey("locked"), "lock", Duration.ofSeconds(5)); + redisTemplate.opsForValue().set(createLockKey("locked"), "lock", Duration.ofSeconds(5)); // when String secondValue = testCacheService.getValue("locked"); @@ -70,7 +79,8 @@ void setUp() { assertAll( () -> assertThat(secondValue).isEqualTo(firstValue), () -> assertThat(redisTemplate.opsForValue().get(cacheKey("locked"))).isEqualTo(firstValue), - () -> assertThat(testCacheService.getCallCount()).isEqualTo(1) + () -> assertThat(testCacheService.getCallCount()).isEqualTo(1), + () -> assertThat(testCacheService.getInvocationCount()).isEqualTo(1) ); } @@ -90,6 +100,7 @@ void setUp() { () -> assertThat(secondValue).isEqualTo(firstValue), () -> assertThat(redisTemplate.opsForValue().get(cacheKey("failed"))).isEqualTo(firstValue), () -> assertThat(testCacheService.getCallCount()).isEqualTo(1), + () -> assertThat(testCacheService.getInvocationCount()).isEqualTo(2), () -> assertThat(ttlMillis).isLessThan(TEST_CACHE_TTL_SEC * 1000) ); } @@ -105,7 +116,8 @@ void setUp() { assertAll( () -> assertThatThrownBy(() -> testCacheService.getValue("custom-exception")) .isInstanceOf(CustomException.class), - () -> assertThat(testCacheService.getCallCount()).isEqualTo(1) + () -> assertThat(testCacheService.getCallCount()).isEqualTo(1), + () -> assertThat(testCacheService.getInvocationCount()).isEqualTo(2) ); } @@ -122,16 +134,70 @@ void setUp() { assertAll( () -> assertThat(secondValue).isEqualTo(firstValue), () -> assertThat(testCacheService.getCallCount()).isEqualTo(1), + () -> assertThat(testCacheService.getInvocationCount()).isEqualTo(1), () -> assertThat(redisTemplate.getExpire(cacheKey("no-expire"))).isEqualTo(-1) ); } + @Test + void 만료_임박_비율이_유효하지_않으면_만료_임박으로_판단하지_않는다() { + // given + String key = cacheKey("invalid-percent"); + redisTemplate.opsForValue().set(key, "value", Duration.ofSeconds(1)); + + // when & then + assertAll( + () -> assertThat(redisUtils.isCacheExpiringSoon(key, TEST_CACHE_TTL_SEC, 0.0)).isFalse(), + () -> assertThat(redisUtils.isCacheExpiringSoon(key, TEST_CACHE_TTL_SEC, -1.0)).isFalse(), + () -> assertThat(redisUtils.isCacheExpiringSoon(key, TEST_CACHE_TTL_SEC, 101.0)).isFalse() + ); + } + + @Test + void 캐시_갱신_중_기존_캐시가_만료되어도_생성락으로_중복_계산을_막는다() throws Exception { + // given + String firstValue = testCacheService.getValue("expired-during-refresh"); + redisTemplate.expire(cacheKey("expired-during-refresh"), Duration.ofSeconds(1)); + testCacheService.blockNextInvocation(); + + ExecutorService refreshExecutor = Executors.newSingleThreadExecutor(); + ExecutorService waitingExecutor = Executors.newSingleThreadExecutor(); + + try { + Future refreshResult = refreshExecutor.submit(() -> testCacheService.getValue("expired-during-refresh")); + testCacheService.awaitBlockedInvocation(); + await().atMost(Duration.ofSeconds(3)) + .untilAsserted(() -> assertThat(redisTemplate.hasKey(cacheKey("expired-during-refresh"))).isFalse()); + + // when + Future waitingResult = waitingExecutor.submit(() -> testCacheService.getValue("expired-during-refresh")); + await().during(Duration.ofMillis(200)) + .atMost(Duration.ofSeconds(1)) + .untilAsserted(() -> assertThat(waitingResult.isDone()).isFalse()); + testCacheService.releaseBlockedInvocation(); + + // then + assertAll( + () -> assertThat(firstValue).isEqualTo("value-1"), + () -> assertThat(refreshResult.get(3, TimeUnit.SECONDS)).isEqualTo("value-2"), + () -> assertThat(waitingResult.get(3, TimeUnit.SECONDS)).isEqualTo("value-2"), + () -> assertThat(redisTemplate.opsForValue().get(cacheKey("expired-during-refresh"))).isEqualTo("value-2"), + () -> assertThat(testCacheService.getCallCount()).isEqualTo(2), + () -> assertThat(testCacheService.getInvocationCount()).isEqualTo(2) + ); + } finally { + testCacheService.releaseBlockedInvocation(); + refreshExecutor.shutdownNow(); + waitingExecutor.shutdownNow(); + } + } + private String cacheKey(String key) { return CACHE_KEY_PREFIX + key; } - private String refreshLockKey(String key) { - return REFRESH_LOCK_PREFIX.getValue() + cacheKey(key); + private String createLockKey(String key) { + return CREATE_LOCK_PREFIX.getValue() + cacheKey(key); } @TestConfiguration @@ -146,8 +212,12 @@ TestCacheService testCacheService() { static class TestCacheService { private final AtomicInteger callCount = new AtomicInteger(); - private boolean failWithRuntimeException; - private boolean failWithCustomException; + private final AtomicInteger invocationCount = new AtomicInteger(); + private volatile boolean failWithRuntimeException; + private volatile boolean failWithCustomException; + private volatile boolean blockNextInvocation; + private volatile CountDownLatch blockedInvocationStarted; + private volatile CountDownLatch blockedInvocationRelease; @ThunderingHerdCaching( key = CACHE_KEY_PREFIX + "{0}", @@ -155,6 +225,8 @@ static class TestCacheService { ttlSec = TEST_CACHE_TTL_SEC ) public String getValue(String key) { + invocationCount.incrementAndGet(); + awaitIfBlocked(); if (failWithCustomException) { throw new CustomException(ErrorCode.INVALID_INPUT); } @@ -166,14 +238,22 @@ public String getValue(String key) { void reset() { callCount.set(0); + invocationCount.set(0); failWithRuntimeException = false; failWithCustomException = false; + blockNextInvocation = false; + blockedInvocationStarted = null; + blockedInvocationRelease = null; } int getCallCount() { return callCount.get(); } + int getInvocationCount() { + return invocationCount.get(); + } + void failWithRuntimeException() { failWithRuntimeException = true; } @@ -181,5 +261,35 @@ void failWithRuntimeException() { void failWithCustomException() { failWithCustomException = true; } + + void blockNextInvocation() { + blockedInvocationStarted = new CountDownLatch(1); + blockedInvocationRelease = new CountDownLatch(1); + blockNextInvocation = true; + } + + void awaitBlockedInvocation() throws InterruptedException { + assertThat(blockedInvocationStarted.await(3, TimeUnit.SECONDS)).isTrue(); + } + + void releaseBlockedInvocation() { + if (blockedInvocationRelease != null) { + blockedInvocationRelease.countDown(); + } + } + + private void awaitIfBlocked() { + if (!blockNextInvocation) { + return; + } + blockNextInvocation = false; + blockedInvocationStarted.countDown(); + try { + blockedInvocationRelease.await(3, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException(e); + } + } } }