레디스는 빠른 오픈 소스인 메모리 키 값 데이터 구조 데이터베이스이다. 레디스는 다양한 인 메모리 구조 집합을 제공하므로 다양한 사용자 정의 애플리케이션을 손쉽게 정의할 수 있다. 주요 레디스 사용 사례는 캐싱, 세션 관리, pub/sub 및 순위표를 들 수 있다.
처리 속도가 빠르며, 디스크와 메모리에 저장되더라도 메모리 캐시와 성능 차이가 거의 없다.
데이터는 메모리와 디스크에 모두 저장되어, 장애나 불의의 상황에서도 데이터 복구가 가능하다.
저장소의 메모리를 재사용하지 않으며, 명시적으로만 데이터를 삭제할 수 있다.
문자열, Set, Sorted Set, Hash, List와 같은 다양한 데이터 타입을 지원한다.
싱글 스레드 구조라 스냅샷 시 자식 프로세스를 만들어 메모리를 2배까지 사용할 수 있다.
Copy-on-write 방식으로 데이터 변경이 많을수록 메모리 소모가 커진다.
대규모 트래픽 상황에서는 Memcached보다 속도가 불안정할 수 있다.
메모리 파편화가 발생할 수 있으며, 이로 인해 극단적으로는 응답 지연이 생길 수 있다.
앞선 코드를 보고 싶으면 밑의 글을 참고 하면 된다
SSE로 알림 발송
알림은 실시간성이 요구되며 순간적으로 대량 발생하고 빠르게 전달되어야 하는 휘발성 데이터이다.
이러한 특성상 디스크 기반의 RDB는 처리 속도와 효율 면에서 적합하지 않다고 판단하여 인메모리 기반 저장소인 Redis를 도입하였고 TTL(Time-To-Live) 기능을 활용해 알림 데이터를 빠르게 처리하고 자동으로 만료시키는 구조로 구현함으로써
전반적인 처리 속도 및 성능과 사용자 응답 속도를 향상시킬 수 있었다.Redis를 사용해서 알림 발송의 중복을 막고 db에 저장을 안함으로써 메모리 절약
@Configuration
public class RedisConfig {
@Bean
public LettuceConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration("localhost", 6379);
return new LettuceConnectionFactory(config);
}
@Bean
public RedisTemplate<String, String> redisTemplate() {
RedisTemplate<String, String> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory());
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new StringRedisSerializer());
return template;
}
}
저장한 알림이 언제 만료될지 정하는 TTL을 설정합니다.
Redis에 저장되어있는지 확인하는 메서드와
알림이 발송된 건에 대해서 Redis에 저장하는 메서드 구현합니다.
<예시>
@Slf4j
@Service
@RequiredArgsConstructor
public class ReservationAlarmCacheServiceImpl implements ReservationAlarmCacheService{
private final RedisTemplate<String, String> redisTemplate;
private static final long EXPIRE_SECONDS = 24 * 60 * 60;
//알림이 발송되었는지 획인하기 위함 (레디스에 저장되었는지 확인)
@Override
public boolean isSent(Long reservationId, Long receiverId, NotificationType type) {
String key = buildKey(reservationId, receiverId, type);
Boolean result = Boolean.TRUE.equals(redisTemplate.hasKey(key));
log.info("Redis 캐시에 중복 여부 확인: {}, 결과={}", key, result);
return result;
}
//알림 발송된 것 레디스에 저장
@Override
public void markSent(Long reservationId, Long receiverId, NotificationType type) {
String key = buildKey(reservationId, receiverId, type);
redisTemplate.opsForValue().set(key, "true", EXPIRE_SECONDS, TimeUnit.SECONDS);
log.info("Redis 캐시에 알림 발송 기록 저장: {}", key);
}
//Redis 키를 일관되게 만들기 위한 헬퍼 메서드
private String buildKey(Long reservationId, Long receiverId, NotificationType type) {
return String.format("alarm:%d:%d:%s", reservationId, receiverId, type.name());
}
}
(+ Service 인터페이스)
public interface ReservationAlarmCacheService {
boolean isSent(Long reservationId, Long receiverId, NotificationType type);
void markSent(Long reservationId, Long receiverId, NotificationType type);
}
레디스에 저장되어있는지 확인하는 코드를 추가하였다.
레디스에 저장되어있으면 알림을 발송하지 않고 저장이 안되어있으면 알림을 발송한다.
<예시>
@Slf4j
@Async
@Component
@RequiredArgsConstructor
public class AlertScheduledTask {
private final ReservationMapper reservationMapper;
private final ReservationAlarmSseService sseService;
private final ReservationAlarmCacheService reservationAlarmCacheService;
@Scheduled(initialDelay = 10000, fixedRate = 1000 * 60) //대기시간 10초, 1분 주기로 스케줄러 실행
public void processReservationAlarms() {
log.info("스케줄러 실행 - 현재 시간: {}", LocalDateTime.now());
LocalDateTime now = LocalDateTime.now();
// 1. EXPIRED 알림 (24시간 지난 CONFIRMED)
List<ReservationResponse> expired = reservationMapper.findExpiredConfirmedReservations(now.minusHours(24));
for (ReservationResponse r : expired) {
sendToBoth(r, NotificationType.EXPIRED, "짐 보관이 아직 완료되지 않았어요.", "고객 짐 보관 상태가 아직 완료되지 않았습니다.");
}
// 2. REMINDER 알림 (30분 전)
List<ReservationResponse> remind = reservationMapper.findConfirmedNearEndTime(now.plusMinutes(30));
for (ReservationResponse r : remind) {
sendToOne(r.getDropper().getMemberId(), r.getReservationId(), r.getDropper().getNickname(), "DROPPER", NotificationType.REMINDER, "곧 짐을 찾아가셔야 해요.");
}
// 3. STATE_CHANGE 알림
List<ReservationResponse> confirmed = reservationMapper.findStateChangedToConfirmed();
for (ReservationResponse r : confirmed) {
sendToOne(r.getDropper().getMemberId(), r.getReservationId(), r.getDropper().getNickname(),"DROPPER", NotificationType.STATE_CHANGE, "예약이 확정되었습니다.");
}
// 4. CANCEL_NOTICE 알림
List<ReservationResponse> cancelled = reservationMapper.findStateChangedToCancelled();
for (ReservationResponse r : cancelled) {
sendToOne(r.getDropper().getMemberId(), r.getReservationId(), r.getDropper().getNickname(),"DROPPER", NotificationType.CANCEL_NOTICE, "예약이 취소되었습니다.");
}
}
private void sendToBoth(ReservationResponse r, NotificationType type, String dropperMsg, String keeperMsg) {
LocalDateTime now = LocalDateTime.now();
// DROPPER
//레디스 캐시에 해당 내용의 알림 없으면 알림 발송
if (!reservationAlarmCacheService.isSent(r.getReservationId(), r.getDropper().getMemberId(), type)) {
if (sseService.hasConnected(r.getDropper().getMemberId())) {
AlarmRespose d = AlarmRespose.builder()
.reservationId(r.getReservationId())
.receiverId(r.getDropper().getMemberId())
.nickName(r.getDropper().getNickname())
.role("DROPPER")
.type(type)
.message(dropperMsg)
.sendTime(now.toString()).build();
sseService.sendMessage(r.getDropper().getMemberId(), d);
//알림 보내고 레디스 캐시에 해당 내용 저장
reservationAlarmCacheService.markSent(r.getReservationId(), r.getDropper().getMemberId(), type);
log.info("발송 후 Redis markSent 완료 (dropper)");
}
} else {
log.debug("DROPPER Redis에 발송됨 표시가 있어 재발송 안함");
}
// KEEPER
//레디스 캐시에 해당 내용의 알림 없으면 알림 발송
if (!reservationAlarmCacheService.isSent(r.getReservationId(), r.getKeeper().getMemberId(), type)) {
if (sseService.hasConnected(r.getKeeper().getMemberId())) {
AlarmRespose k = AlarmRespose.builder()
.reservationId(r.getReservationId())
.receiverId(r.getKeeper().getMemberId())
.nickName(r.getKeeper().getNickname())
.role("KEEPER")
.type(type)
.message(keeperMsg)
.sendTime(now.toString()).build();
sseService.sendMessage(r.getKeeper().getMemberId(), k);
//알림 보내고 레디스 캐시에 해당 내용 저장
reservationAlarmCacheService.markSent(r.getReservationId(), r.getKeeper().getMemberId(), type);
log.info("발송 후 Redis markSent 완료 (keeper)");
}
} else {
log.debug("KEEPER Redis에 발송됨 표시가 있어 재발송 안함");
}
}
private void sendToOne(Long id, Long resId, String name, String role, NotificationType type, String message) {
if (!sseService.hasConnected(id)) return;
if (reservationAlarmCacheService.isSent(resId, id, type)) {
log.debug("이미 Redis에 발송됨 표시가 있어 재발송 안함 (reservationId={}, memberId={}, type={})", resId, id, type);
return;
}
AlarmRespose dto = AlarmRespose.builder()
.reservationId(resId)
.receiverId(id)
.nickName(name)
.role(role)
.type(type)
.message(message)
.sendTime(String.valueOf(LocalDateTime.now()))
.build();
sseService.sendMessage(id, dto);
reservationAlarmCacheService.markSent(id, resId, type);
}
}