Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Redis 장애 대응 - 분산 락 및 캐시 리포지토리 Fallback 구현 #321

Open
wants to merge 8 commits into
base: develop
Choose a base branch
from
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import com.api.email.presentation.EmailVerifyRequest;
import com.common.exception.ApiException;
import com.common.exception.ErrorType;
import com.infrastructure.util.RedisUtil;
import com.infrastructure.cache.repository.CacheRepository;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.InternetAddress;
import jakarta.mail.internet.MimeMessage;
Expand All @@ -24,8 +24,8 @@
public class EmailFacade {

private final Sender sender;
private final RedisUtil redisUtil;
private final SpringTemplateEngine templateEngine;
private final CacheRepository cacheRepository;

@Value("${spring.mail.sender-email}")
private String senderEmail;
Expand Down Expand Up @@ -72,7 +72,7 @@ private MimeMessage createEmailForm(String email) {

private void setRedisData(final String email, final String authCode) {
try {
redisUtil.setDataExpire(email, authCode, 60 * 30L);
cacheRepository.set(email, authCode, 60 * 30L);
} catch (Exception e) {
log.error("Failed to set Redis data: {}", e.getMessage(), e);
throw new ApiException(ErrorType.REDIS_SAVE_ERROR);
Expand All @@ -81,28 +81,27 @@ private void setRedisData(final String email, final String authCode) {

@Async
// 인증코드 이메일 발송
public void sendEmail(String toEmail) {
public void send(String toEmail) {
try {
validEmailHasText(toEmail);
validRedisHasEmail(toEmail);

createEmail(toEmail);
MimeMessage email = createEmail(toEmail);
sendEmail(email);
} catch (Exception e) {
log.error("Exception in sendEmail: {}", e.getMessage(), e);
throw e;
}
}

private void validRedisHasEmail(final String toEmail) {
if (redisUtil.existData(toEmail)) {
redisUtil.deleteData(toEmail);
if (cacheRepository.exists(toEmail)) {
cacheRepository.delete(toEmail);
}
}

private void createEmail(final String toEmail) {
private MimeMessage createEmail(final String toEmail) {
try {
MimeMessage emailForm = createEmailForm(toEmail);
sendEmail(emailForm);
return createEmailForm(toEmail);
} catch (ApiException e) {
log.error("ApiException during email creation: {}", e.getMessage(), e);
throw e;
Expand Down Expand Up @@ -139,8 +138,8 @@ private void validCode(String email, String code) {
}

try {
String codeFoundByEmail = redisUtil.getData(email);
log.info("code found by email: " + codeFoundByEmail);
String codeFoundByEmail = cacheRepository.get(email);
log.info("code found by email: {}", codeFoundByEmail);
validAuthenticationCode(code, codeFoundByEmail);
} catch (Exception e) {
log.error("Failed to retrieve data from Redis: {}", e.getMessage(), e);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public class EmailController implements EmailControllerDocs {
public TtoklipResponse<Message> mailSend(
final @RequestBody EmailSendRequest request
) {
emailFacade.sendEmail(request.email());
emailFacade.send(request.email());
return TtoklipResponse.accepted(
Message.sendEmail()
);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.infrastructure.cache;

public class CacheException extends RuntimeException {
public CacheException(String message, Throwable cause) {
super(message, cause);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package com.infrastructure.cache.infrastructure;

import com.infrastructure.cache.CacheException;
import com.infrastructure.cache.repository.CacheRepository;
import com.infrastructure.cache.repository.LocalCacheRepository;
import com.infrastructure.cache.repository.RedisCacheRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Repository;

/**
* CacheRepositoryImpl
*
* Redis와 Local Cache를 통합 관리하는 클래스입니다.
* Redis를 우선 사용하며, 실패 시 Local Cache를 사용합니다.
*
* 주요 기능:
* 1. Redis를 우선 사용하고, 실패 시 Local Cache Fallback.
* 2. Write-Through 방식으로 동시 저장.
*/

@Slf4j
@Repository
@RequiredArgsConstructor
public class CacheRepositoryImpl implements CacheRepository {

private final RedisCacheRepository redisCacheRepository;
private final LocalCacheRepository localCacheRepository;

@Override
public String get(final String key) {
try {
String value = redisCacheRepository.get(key);
if (value != null) {
return value;
}
// Redis에 데이터가 없으면 LocalCache 확인
return localCacheRepository.get(key);
} catch (CacheException e) {
log.warn("Redis failed, falling back to LocalCache");
return localCacheRepository.get(key);
}
}

@Override
public void set(final String key, final String value, final long duration) {
try {
// Write-Through 전략. Redis, LocalCache 둘 다 저장
redisCacheRepository.set(key, value, duration);
localCacheRepository.set(key, value, duration);
} catch (CacheException e) {
log.warn("Redis failed, writing only to LocalCache");
localCacheRepository.set(key, value, duration);
}
}

@Override
public boolean exists(final String key) {
try {
return redisCacheRepository.exists(key) || localCacheRepository.exists(key);
} catch (CacheException e) {
return localCacheRepository.exists(key);
}
}

@Override
public void delete(final String key) {
try {
redisCacheRepository.delete(key);
} catch (CacheException e) {
log.warn("Redis failed, deleting from LocalCache");
}
localCacheRepository.delete(key);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.infrastructure.cache.repository;

public interface CacheRepository {
String get(String key);
void set(String key, String value, long duration);
boolean exists(String key);
void delete(String key);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.infrastructure.cache.repository;

import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import org.springframework.stereotype.Repository;

/**
* LocalCacheRepository
*
* JVM 메모리 내에서 작동하는 로컬 캐시 저장소입니다.
* Redis 장애 시 Fallback으로 사용됩니다.
*
* 주요 기능:
* 1. 메모리에 데이터를 저장 및 조회.
* 2. 만료 시간이 지난 데이터를 필터링.
* 3. 모든 활성 키 반환.
*/

@Repository
public class LocalCacheRepository {

private final Map<String, CacheEntry> localCache = new ConcurrentHashMap<>();

public String get(final String key) {
CacheEntry entry = localCache.get(key);
if (entry != null && !entry.isExpired()) {
return entry.value();
}
return null;
}

public void set(final String key, final String value, final long duration) {
localCache.put(
key, new CacheEntry(
value, System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(duration)
)
);
}

public boolean exists(final String key) {
CacheEntry entry = localCache.get(key);
return entry != null && !entry.isExpired();
}

public void delete(final String key) {
localCache.remove(key);
}

/**
* 모든 키 반환
*/
public Set<String> getAllKeys() {
return localCache.entrySet().stream()
.filter(entry -> !entry.getValue().isExpired())
.map(Map.Entry::getKey)
.collect(Collectors.toSet());
}

private record CacheEntry(String value, long expireTime) {
public boolean isExpired() {
return System.currentTimeMillis() > expireTime;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.infrastructure.cache.repository;

import com.infrastructure.cache.CacheException;
import java.time.Duration;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.dao.DataAccessException;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Repository;

/**
* RedisCacheRepository
*
* Redis 기반의 캐시 저장소입니다.
* Redis를 사용하여 데이터를 저장, 조회, 삭제합니다.
*
* 주요 기능:
* 1. Redis를 통해 데이터를 저장 및 조회.
* 2. Redis에 데이터가 없는 경우 예외 처리.
*/

@Repository
@RequiredArgsConstructor
public class RedisCacheRepository {

private final StringRedisTemplate redisTemplate;

@Value("${spring.cache.redis.timeout}")
private long redisTimeout;

public String get(final String key) {
try {
ValueOperations<String, String> ops = redisTemplate.opsForValue();
return ops.get(key);
} catch (DataAccessException e) {
throw new CacheException("Redis connection failed", e);
}
}

public void set(
final String key,
final String value,
final long duration
) {
try {
ValueOperations<String, String> ops = redisTemplate.opsForValue();
ops.set(key, value, Duration.ofSeconds(duration));
} catch (DataAccessException e) {
throw new CacheException("Redis connection failed", e);
}
}

public boolean exists(final String key) {
try {
return Boolean.TRUE.equals(redisTemplate.hasKey(key));
} catch (DataAccessException e) {
throw new CacheException("Redis connection failed", e);
}
}

public void delete(final String key) {
try {
redisTemplate.delete(key);
} catch (DataAccessException e) {
throw new CacheException("Redis connection failed", e);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
package com.infrastructure.cache.service;

import com.infrastructure.cache.repository.LocalCacheRepository;
import com.infrastructure.cache.repository.RedisCacheRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.List;
import java.util.Set;

/**
* CacheSynchronizationService
*
* 이 서비스는 Redis Image가 일시적으로 다운되어 Local Cache가 사용된 경우,
* LocalCache 데이터를 주기적으로 Redis로 동기화하는 역할을 합니다.
*
* 주요 기능:
* 1. 5분마다 LocalCache 데이터를 Redis로 동기화
* 2. 각 키를 비동기적으로 처리하여 성능 병목을 방지
*/

@Slf4j
@Service
@RequiredArgsConstructor
public class CacheSynchronizationService {

private final RedisCacheRepository redisCacheRepository;
private final LocalCacheRepository localCacheRepository;

/**
* 5분마다 LocalCache 데이터를 Redis로 동기화
*/
@Scheduled(fixedRate = 300000) // 5분마다 실행
public void syncLocalCacheToRedis() {
log.info("Starting batch synchronization of LocalCache to Redis...");
Set<String> allKeys = localCacheRepository.getAllKeys();
List<String> keyList = new ArrayList<>(allKeys);
int batchSize = 10; // 한 번에 처리할 배치 크기

for (int i = 0; i < keyList.size(); i += batchSize) {
List<String> batch = keyList.subList(i, Math.min(i + batchSize, keyList.size()));
batch.forEach(this::syncKeyAsync);
}
}

/**
* 개별 키를 Redis로 비동기 동기화
*/
@Async
public void syncKeyAsync(String key) {
try {
String value = localCacheRepository.get(key);
if (value != null) {
redisCacheRepository.set(key, value, 60); // TTL 60초
localCacheRepository.delete(key); // 동기화 후 LocalCache에서 제거
log.info("Key '{}' synchronized to Redis.", key);
}
} catch (Exception e) {
log.warn("Failed to synchronize key '{}' to Redis: {}", key, e.getMessage());
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;

@Configuration
public class RedisConfig {
Expand All @@ -29,4 +30,8 @@ public RedisConnectionFactory redisConnectionFactory() {
return redisTemplate;
}

@Bean
public StringRedisTemplate stringRedisTemplate() {
return new StringRedisTemplate(redisConnectionFactory());
}
}
Loading