CODING/Project

🚀 Redis Pub/Sub 적용기 — EventListener 한계부터 직렬화 이슈 해결까지

codingTrip 2025. 4. 28. 20:30

서비스가 커지면서 단순한 이벤트 처리 방식으로는 한계를 느끼기 시작했습니다.
이번 포스팅에서는 기존 @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을 적용하면서 단순한 기능 구현이 아니라,
설정 관리의 중요성직렬화 규칙 통일의 필요성을 절실히 느꼈습니다.