Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -88,14 +88,24 @@ 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),
redisUtils.getCreateLockKey(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);
Comment on lines +97 to +98

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep the key alive while refresh recomputes

When a key is already in the refresh window and the underlying method takes longer than the small TTL that remains, this path does not extend or otherwise reserve the cached key until after proceedJoinPoint returns. The key can expire while the refresh lock is still held, so later requests enter the cache-miss/create-lock path and execute the same expensive method concurrently; whichever cacheManager.put finishes last then overwrites the cache, which reintroduces the thundering-herd/racing-write behavior this aspect is meant to prevent. The previous getAndExpire extended the TTL before returning, so consider extending/marking the key before recomputation or coordinating refresh and create locks.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

refresh 중 기존 cache key가 만료되어도 create 경로와 중복 계산하지 않도록 refresh도 createLock을 공유하게 변경했습니다 :)

redisTemplate.convertAndSend(CREATE_CHANNEL.getValue(), key);
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());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,11 @@ 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 || percent <= 0 || percent > 100) {
return false;
}
return ((double) leftTtl / defaultTtl) * 100 < percent;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
package com.example.solidconnection.cache;

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;
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<String, Object> redisTemplate;

@Autowired
private RedisUtils redisUtils;

@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(createLockKey("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),
() -> assertThat(testCacheService.getInvocationCount()).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(testCacheService.getInvocationCount()).isEqualTo(2),
() -> 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),
() -> assertThat(testCacheService.getInvocationCount()).isEqualTo(2)
);
}

@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(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<String> 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<String> 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 createLockKey(String key) {
return CREATE_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 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}",
cacheManager = "customCacheManager",
ttlSec = TEST_CACHE_TTL_SEC
)
public String getValue(String key) {
invocationCount.incrementAndGet();
awaitIfBlocked();
if (failWithCustomException) {
throw new CustomException(ErrorCode.INVALID_INPUT);
}
if (failWithRuntimeException) {
throw new IllegalStateException("refresh failed");
}
return "value-" + callCount.incrementAndGet();
}

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;
}

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);
}
}
}
}
Loading