🚀 Redis Pub/Sub 적용기 — EventListener 한계부터 직렬화 이슈 해결까지
서비스가 커지면서 단순한 이벤트 처리 방식으로는 한계를 느끼기 시작했습니다.
이번 포스팅에서는 기존 @EventListener 기반 구조에서 Redis Pub/Sub으로 리팩토링한 과정과,
그 속에서 겪은 직렬화/역직렬화 이슈 해결기를 공유해보겠습니다.
1️⃣ 왜 Redis Pub/Sub을 도입했을까?
초기에는 스프링의 @EventListener 를 사용해 결제 취소/실패 알림을 처리했습니다.
스프링 이벤트는 단일 서버에서 간단한 후처리를 할 때 정말 편리하죠.
하지만 서비스가 성장하면서 확장성과 비동기 처리의 한계가 드러났습니다.
🔹 EventListener vs Redis Pub/Sub 비교
항목 | @EventListener | Redis Pub/Sub |
동작 범위 | 애플리케이션 내부 프로세스 | 분산 환경에서도 사용 가능 |
비동기 처리 | 가능 (추가 설정 필요) | 기본적으로 비동기 메시징 구조 |
확장성 | 단일 서버에 적합 | 멀티 서버, 마이크로서비스에 적합 |
내결함성 | 서버 다운 시 이벤트 소멸 | 구독자가 있으면 메시지 수신 가능 |
운영 환경 | JVM 내에서만 동작 | 네트워크 기반, 다양한 시스템과 연동 가능 |
설정 난이도 | 매우 쉬움 | 초기 설정 필요 (Redis 설정 포함) |
대표 사용처 | 내부 로직 후처리 | 알림 시스템, 서비스 간 이벤트 처리 |
✅ 그래서 왜 Pub/Sub으로 전환했나?
- 확장성: 서버가 여러 대로 늘어나더라도 이벤트 처리 유지
- 비동기 알림 처리 최적화
- 다양한 이벤트 확장 고려
이런 이유로, 단일 서버 중심의 @EventListener에서
Redis Pub/Sub 기반으로 알림 시스템을 리팩토링하게 되었습니다.
2️⃣ Redis Pub/Sub 구조 설계
✅ 기본 개념
역할 | 설명 |
Publisher | Redis 채널에 메시지 발행 |
Subscriber | 채널을 구독하고 메시지 수신 |
Redis | 중간에서 메시지를 전달 |
3️⃣ Pub/Sub 전체 흐름
4️⃣ 클래스별 역할과 설계 이유
1️⃣ PaymentAlarmMessage
📌 역할
Redis Pub/Sub을 통해 전달될 결제 알림 메시지 DTO
발행자(Publisher)와 구독자(Subscriber) 간 데이터 전달 포맷 정의
💡 만든 이유
- Redis 전송을 위한 직렬화 최적화 DTO
- 이벤트 타입과 데이터 패키징
- 네트워크 통신용으로 가벼운 구조 설계
📝 코드
@Getter
@NoArgsConstructor
public class PaymentAlarmMessage implements Serializable {
private ReservationAlarmInfo reservationAlarmInfo;
private NotificationType notificationType;
public PaymentAlarmMessage(ReservationAlarmInfo reservationAlarmInfo, NotificationType notificationType) {
this.reservationAlarmInfo = reservationAlarmInfo;
this.notificationType = notificationType;
}
}
2️⃣ PaymentAlarmPublisher
📌 역할
결제 취소/실패 발생 시 Redis 채널로 알림 메시지를 발행하는 클래스
💡 만든 이유
- 서비스 로직과 Redis 발행 로직 분리
- 채널명, 직렬화 방식 등을 중앙 관리
- 향후 다른 이벤트 발행 시 재사용 가능하도록 설계
📝 코드
@Service
@RequiredArgsConstructor
public class PaymentAlarmPublisher {
private final RedisTemplate<String, Object> redisTemplate;
public static final String CHANNEL = "payment-alarm";
public void publish(PaymentAlarmMessage message) {
redisTemplate.convertAndSend(CHANNEL, message);
}
}
3️⃣ PaymentAlarmSubscriber
📌 역할
Redis 채널에서 발행된 결제 알림 메시지를 수신(Subscribe)하여 처리하는 클래스
수신한 메시지를 해석(역직렬화)하고, 실제 알림 비즈니스 로직 호출
💡 만든 이유
- Redis Pub/Sub 구독 처리를 위한 MessageListener 구현체
- 발행된 메시지를 수신해 AsyncAlarmService 호출
- 직렬화/역직렬화 문제 방지를 위해 공통 Serializer 사용
- 확장성을 고려한 구독 로직 분리
📝 코드
@Slf4j
@RequiredArgsConstructor
@Component
public class PaymentAlarmSubscriber implements MessageListener {
private final AlarmSender alarmSender;
private final GenericJackson2JsonRedisSerializer redisSerializer;
private final ObjectMapper objectMapper;
@Override
public void onMessage(Message message, byte[] pattern) {
try {
Object rawObject = redisSerializer.deserialize(message.getBody());
PaymentAlarmMessage alarmMessage = objectMapper.convertValue(rawObject, PaymentAlarmMessage.class);
alarmSender.processPaymentAlarms(alarmMessage.getReservationAlarmInfo(), alarmMessage.getNotificationType());
} catch (Exception e) {
log.error("❌ Redis 결제 알람 처리 실패", e);
}
}
}
4️⃣ AlarmRedisPubSubConfig
📌 역할
Redis 구독 기능을 설정하는 클래스. 구독 채널과 리스너를 등록.
💡 만든 이유
- 스프링에서 Redis Pub/Sub 사용 시 필수 설정
- ListenerContainer로 구독 관리
- 여러 채널 확장 대비 중앙 설정화
📝 코드
@Configuration
@RequiredArgsConstructor
public class AlarmRedisPubSubConfig {
private final RedisConnectionFactory connectionFactory;
private final PaymentAlarmSubscriber paymentAlarmSubscriber;
@Bean
public RedisMessageListenerContainer redisMessageListenerContainer() {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(connectionFactory);
container.addMessageListener(paymentAlarmSubscriber, new PatternTopic(PaymentAlarmPublisher.CHANNEL));
return container;
}
}
5️⃣ 문제 발생! 직렬화/역직렬화 이슈
적용 후 테스트 과정에서 아래와 같은 오류가 발생했습니다.
SerializationException: missing type id property '@class' ClassCastException: LinkedHashMap cannot be cast to PaymentAlarmMessage
6️⃣ 원인 분석 & 해결
⚡ 원인
- Publisher와 Subscriber가 서로 다른 직렬화 규칙(ObjectMapper) 사용
- RedisTemplate에서는 커스텀 직렬화
- Subscriber에서는 기본 직렬화 방식 → 규칙 불일치로 역직렬화 실패
✅ 해결 방법
- 공통 직렬화 규칙을 Bean으로 등록
- Publisher와 Subscriber 모두 동일한 Serializer 주입
📝 코드
@Bean
public GenericJackson2JsonRedisSerializer redisSerializer() {
ObjectMapper redisObjectMapper = new ObjectMapper();
redisObjectMapper.registerModule(new JavaTimeModule());
redisObjectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
return new GenericJackson2JsonRedisSerializer(redisObjectMapper);
}
🎯 마무리
Pub/Sub을 적용하면서 단순한 기능 구현이 아니라,
설정 관리의 중요성과 직렬화 규칙 통일의 필요성을 절실히 느꼈습니다.