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 @@ -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 {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ public ResponseEntity<ApplicationsResponse> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<University> universities
Expand All @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
56 changes: 56 additions & 0 deletions src/main/java/com/example/solidconnection/cache/CachingAspect.java
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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<String, CompletableFuture<Void>> waitingRequests = new ConcurrentHashMap<>();

public CompletableFuture<Void> getOrCreateFuture(String key) {
return waitingRequests.computeIfAbsent(key, k -> new CompletableFuture<>());
}

public void completeFuture(String key) {
CompletableFuture<Void> future = waitingRequests.remove(key);
if (future != null) {
future.complete(null);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<String, Object> redisTemplate;
private final CompletableFutureManager futureManager;
private final RedisUtils redisUtils;

@Autowired
public ThunderingHerdCachingAspect(ApplicationContext applicationContext, RedisTemplate<String, Object> 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<Object> onLockAcquired, Callable<Object> 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<Void> 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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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();
}
Original file line number Diff line number Diff line change
@@ -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();
}
Original file line number Diff line number Diff line change
@@ -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);
}
Loading