16. [Project] Redis + Docker를 사용한 FCM 알림 시스템 구성에서 FCM 기반 알림 시스템 구성으로 전환

김미숙's avatar
Jul 15, 2025
16. [Project] Redis + Docker를 사용한 FCM 알림 시스템 구성에서 FCM 기반 알림 시스템 구성으로 전환

🔄 Redis Pub/Sub → FCM 기반 알림 시스템으로의 전환 배경

📌 기존 구조: Redis Pub/Sub + Docker 기반 알림 시스템

  • Redis Pub/Sub을 활용하여 서버 간 실시간 메시지(이벤트)를 발행 및 수신
  • Docker 컨테이너 내에서 Redis 서버를 구동하고, Spring Boot 앱에서 RedisListener로 알림 수신
  • 알림 처리 흐름:
    • publish(topic, message)RedisListener에서 구독 → FCM으로 전송

⚠️ 전환 이유 및 기술적 한계

문제 항목
설명
① Redis의 비동기 특성
Redis Pub/Sub은 메시지를 실시간으로 발행하지만, 메시지를 수신하는 쪽이 반드시 처리 완료를 보장하지 않음→ 메시지 유실 가능성 존재 (특히 서버 장애/재시작 시)
② 동기적 처리 흐름과의 충돌
우리의 서버는 알림 전송을 중요 로직 흐름 중 하나로 동기적으로 수행→ Redis는 결과를 보장하지 않기 때문에 향후 확장 시 문제 발생 가능
③ Docker 운영 지식 부족
Redis를 Docker에 띄운 뒤 볼륨, 포트, 연결 등 운영 지식이 필요한 부분이 많음→ 배포/운영 환경에서 불필요한 복잡도 유발
④ 단순한 알림 요구
우리 시스템은 메시지 큐 기반의 대규모 비동기 처리가 아닌, 단건 또는 그룹 푸시 알림 중심→ Redis Pub/Sub은 과한 구조일 수 있음

✅ 전환 후: FCM 기반 직접 전송 구조

  • Redis 없이 Spring Boot 서버에서 FCM Admin SDK를 통해 직접 메시지 전송
  • 이벤트가 발생하면 바로 FirebaseMessaging.send() 호출하여 FCM으로 전송
  • RedisMessageListenerContainer, PublisherService, Subscriber 등 삭제 가능
  • 운영, 배포, 코드 구조 모두 단순화

💡 결과 요약

항목
Redis Pub/Sub 방식
FCM 직접 전송 방식
메시지 전달
비동기, 유실 가능성 존재
동기, 바로 전송
운영 복잡도
Redis 설정 및 Docker 구성 필요
Firebase 설정만 필요
구성요소
Publisher, Subscriber, RedisConfig 등
FcmConfig, FcmService 등
적합성
고성능 분산 메시지 처리에 적합
단건/소규모 그룹 푸시에 적합

📝 결론

팀의 현재 기술 스택과 실제 서비스 요구를 고려하여 운영이 간단하고 신뢰성 높은 FCM 직접 전송 방식으로 전환하였음.
향후 필요 시 Kafka, RabbitMQ 등의 큐 시스템을 검토할 수 있지만, 현 시점에서는 가장 현실적인 선택이었음.
 

🚀 Redis + Docker 기반 알림 시스템 구성 정리

1. 도커에서 Redis 띄우기

  • docker-compose.yml 예시:
    • ## crawler version: '3.8' services: redis: image: redis:7 ports: - "6379:6379" restart: always crawler: build: context: . container_name: crawler-dev depends_on: - redis environment: - SPRING_PROFILES_ACTIVE=dev - REDIS_HOST=redis - REDIS_PORT=6379 restart: always ## Backend version: '3.8' services: redis: image: redis:7 ports: - "6379:6379" restart: always backend: build: context: . container_name: backend-dev ports: - "8080:8080" depends_on: - redis environment: - SPRING_PROFILES_ACTIVE=dev - REDIS_HOST=redis - REDIS_PORT=6379 - FIREBASE_CONFIG_PATH=/app/firebase-service-key.json volumes: - C:/workspace/firebase-service-key.json:/app/firebase-service-key.json restart: always
    • image: redis:7.0 사용하여 Redis 서버 컨테이너 실행
    • ports 로 네트워크 연결, volumesappendonly 지속성을 보장
    • docker-compose.yml 에서 REDIS_HOST, REDIS_PORT를 환경변수로 지정하면, Spring Boot는 이를 자동으로 spring.redis.host, spring.redis.port로 인식해 Redis와 연결한다. Redis 인스턴스가 하나인 경우, 별도의 @Valueapplication.yml 설정 없이도 LettuceConnectionFactory()로 정상 연결된다.
    • 💡 Tip: Redis 데이터 유지를 위한 볼륨 설정
      아래와 같이 volumes를 설정해두면 docker-compose down 시에도 Redis 데이터가 유지됩니다.
      services: redis: image: redis:7 ports: - "6379:6379" volumes: - redis_data:/data volumes: redis_data:
      실무에서는 Redis 캐시뿐 아니라 데이터 영속성이 필요한 경우에도 대비해두는 것이 좋습니다.
  • 실행
    • docker-compose up -d
    • 백그라운드(detached) 모드로 Redis 컨테이너 실행
    • redis_data 볼륨을 통해 데이터 유지 가능

2. 스프링에서 Redis 연동하기

  • RedisConfig
package com.example.ballkkaye._core.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.StringRedisTemplate; @Configuration public class RedisConfig { @Bean public RedisConnectionFactory redisConnectionFactory() { return new LettuceConnectionFactory(); // 기본 localhost:6379 } @Bean public StringRedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { return new StringRedisTemplate(connectionFactory); } }
도커로 Redis 서버를 띄운 뒤, 스프링 서버에서는 RedisConfig 클래스를 통해 Redis와의 연결을 설정해줘야 한다. 이 설정을 통해 Redis Pub/Sub 통신, 캐싱, 일반 키-값 저장 등 다양한 Redis 기능을 사용할 수 있다.
💡 Spring에서 환경변수 없이도 자동 연동이 가능한 이유
docker-compose.yml에 환경변수로 REDIS_HOST=redis, REDIS_PORT=6379가 설정되어 있고, Spring BootLettuceConnectionFactory()에서 이를 기본값처럼 인식하여 Redis에 자동으로 연결됩니다.
  • 굳이 @Value("${REDIS_HOST}")를 쓰지 않아도 연결되는 이유는 바로 이 구조 덕분입니다.
  • 단, 여러 Redis 인스턴스를 사용할 경우에는 명시적으로 분리 관리가 필요합니다.
 

✅ 역할 설명

메서드/클래스
설명
LettuceConnectionFactory
Redis와의 커넥션을 관리하는 기본 객체. 도커에서 띄운 Redis에 자동 연결됨
StringRedisTemplate
Redis에서 문자열 기반으로 데이터를 pub/sub 하거나 조회/저장할 때 사용
@Configuration
해당 클래스가 스프링 설정 파일임을 명시
즉, 이 설정을 등록해두면 Redis에 메시지를 발행하거나 구독할 수 있는 기반이 마련됨.
 

 

2. Redis Pub/Sub 구조 개념

  • Publisher: 특정 이벤트 발생 시 Redis 채널에 메시지 발행 (convertAndSend)
  • Subscriber: Redis 채널을 구독하여 메시지를 수신하고 후속 처리 (FCM 발송 등)
  • 채널
    • today-game-updated
    • hitter-lineup-updated
    • starting-pitcher-lineup-updated
    • team-record-update

3. 크롤링 서버: Publisher 역할

package com.example.ballkkaye.publisher; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; @Slf4j @Service @RequiredArgsConstructor public class PublisherService { private final StringRedisTemplate redisTemplate; // 오늘의 경기 이벤트 발행 public void publishGameUpdatedEvent() { try { redisTemplate.convertAndSend("today-game-updated", "TODAY_GAME_UPDATED"); } catch (Exception e) { log.warn("Redis publishGameUpdatedEvent 실패: {}", e.getMessage()); } } // 상대 전적 이벤트 발행 public void publishHitterLineupUpdatedEvent() { try { redisTemplate.convertAndSend("hitter-lineup-updated", "TODAY_HITTER_LINEUP_UPDATED"); } catch (Exception e) { log.warn("Redis publishGameUpdatedEvent 실패: {}", e.getMessage()); } } // 선발투수 라인업 이벤트 발행 - 승리예측 public void publishStartingPitcherUpdatedEvent() { try { redisTemplate.convertAndSend("starting-pitcher-lineup-updated", "TODAY_STARTING_PITCHER_UPDATED"); } catch (Exception e) { log.warn("Redis publishGameUpdatedEvent 실패: {}", e.getMessage()); } } // 팀기록 이벤트 발행 public void publishTeamRecordUpdated() { try { redisTemplate.convertAndSend("team-record-update", "TEAM-RECORD-UPDATED"); } catch (Exception e) { log.warn("Redis publishGameUpdatedEvent 실패: {}", e.getMessage()); } } }
  • 크롤링 완료 시 publishGameUpdatedEvent(), publishHitterLineupUpdatedEvent(), publishStartingPitcherUpdatedEvent(), publishTeamRecordUpdated() 호출
  • 메서드 끝부분에서 이벤트 발행
    • copyTodayLineupFromHitterLineup()
      • package com.example.ballkkaye.player.hitterLineup.today; import com.example.ballkkaye.player.hitterLineup.HitterLineup; import com.example.ballkkaye.player.hitterLineup.HitterLineupRepository; import com.example.ballkkaye.publisher.PublisherService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; @RequiredArgsConstructor @Service public class TodayHitterLineupService { private final TodayHitterLineupRepository todayHitterLineUpRepository; private final HitterLineupRepository hitterLineupRepository; private final PublisherService publisherService; @Transactional public void copyTodayLineupFromHitterLineup() { LocalDate today = LocalDate.now(); // 1. HitterLineup에서 오늘 날짜 라인업 전체 조회 List<HitterLineup> todayLineups = hitterLineupRepository.findByGameDate(today); if (todayLineups.isEmpty()) { System.out.println("오늘 날짜의 라인업이 없습니다."); return; } // 2. 중복 제거하며 복사 List<TodayHitterLineup> toSave = new ArrayList<>(); for (HitterLineup h : todayLineups) { if (todayHitterLineUpRepository.existsByGameIdAndPlayerId( h.getGame().getId(), h.getPlayer().getId())) { continue; } toSave.add(TodayHitterLineup.builder() .game(h.getGame()) .team(h.getTeam()) .player(h.getPlayer()) .todayHitterOrder(h.getHitterOrder()) .position(h.getPosition()) .seasonAvg(h.getSeasonAvg()) .ab(h.getAb()) .h(h.getH()) .avg(h.getAvg()) .ops(h.getOps()) .build()); } if (toSave.isEmpty()) { System.out.println("이미 모든 라인업이 저장되어 있습니다."); return; } todayHitterLineUpRepository.saveAll(toSave); System.out.printf("TodayHitterLineup으로 %d개 복사 완료\n", toSave.size()); // ✅ 저장이 발생했을 경우에만 1회 이벤트 발행 publisherService.publishHitterLineupUpdatedEvent(); } }
    • syncTodayGames()
      • package com.example.ballkkaye.game.today; import com.example.ballkkaye.game.Game; import com.example.ballkkaye.game.GameRepository; import com.example.ballkkaye.player.startingPitcher.today.TodayStartingPitcherRepository; import com.example.ballkkaye.publisher.PublisherService; import com.example.ballkkaye.stadium.stadiumCorrection.StadiumCorrectionRepository; import com.example.ballkkaye.team.Team; import com.example.ballkkaye.team.record.today.TodayTeamRecord; import com.example.ballkkaye.team.record.today.TodayTeamRecordRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; import java.time.ZoneId; import java.util.*; @RequiredArgsConstructor @Service public class TodayGameService { private final TodayGameRepository todayGameRepository; private final GameRepository gameRepository; private final TodayTeamRecordRepository todayTeamRecordRepository; private final StadiumCorrectionRepository stadiumCorrectionRepository; private final TodayStartingPitcherRepository todayStartingPitcherRepository; private final PublisherService publisherService; @Transactional public void syncTodayGames() { LocalDate today = LocalDate.now(); List<Game> todayGames = gameRepository.findTodayGame(today); Double avgOps = todayTeamRecordRepository.leagueAverageOps(); Double totalR = todayTeamRecordRepository.leagueAverageR(); Double avgGames = todayTeamRecordRepository.averageGameCount(); Double avgR = totalR / avgGames; Double avgEra = todayTeamRecordRepository.leagueAverageEra(); Map<Integer, TodayTeamRecord> recordMap = new HashMap<>(); for (TodayTeamRecord record : todayTeamRecordRepository.findAll()) { if (record.getTeam() != null && record.getTeam().getId() != null) { recordMap.put(record.getTeam().getId(), record); } } List<Integer> insertedGameIds = new ArrayList<>(); // 저장된 게임 ID 추적용 리스트 추가 for (Game game : todayGames) { Team home = game.getHomeTeam(); Team away = game.getAwayTeam(); if (home == null || away == null) continue; TodayTeamRecord homeRecord = recordMap.get(home.getId()); TodayTeamRecord awayRecord = recordMap.get(away.getId()); if (homeRecord == null || awayRecord == null) continue; Integer stadiumId = game.getStadium().getId(); Double correction = stadiumCorrectionRepository .getCorrectionByStadiumIdAndYear(stadiumId, LocalDate.now(ZoneId.of("Asia/Seoul")).getYear()); Double homeOps = homeRecord.getOPS(); Double awayOps = awayRecord.getOPS(); if (homeOps == null || awayOps == null) continue; Double homePitcherEra = todayStartingPitcherRepository.getPitcherEraByGameAndTeam(game, home); Double awayPitcherEra = todayStartingPitcherRepository.getPitcherEraByGameAndTeam(game, away); if (homePitcherEra == null || homePitcherEra == 0.0) homePitcherEra = avgEra; if (awayPitcherEra == null || awayPitcherEra == 0.0) awayPitcherEra = avgEra; double rawHomeScore = (homeOps / avgOps) * avgR * (avgEra / awayPitcherEra) * correction; double rawAwayScore = (awayOps / avgOps) * avgR * (avgEra / homePitcherEra) * correction; double homeScore = Math.round(rawHomeScore * 10) / 10.0; double awayScore = Math.round(rawAwayScore * 10) / 10.0; double homeWinPer = 50.0; double awayWinPer = 50.0; double total = homeScore + awayScore; if (total > 0) { homeWinPer = Math.round((homeScore / total) * 1000) / 10.0; awayWinPer = Math.round((awayScore / total) * 1000) / 10.0; } Optional<TodayGame> existingTodayGameOptional = todayGameRepository.findByGameId(game.getId()); if (existingTodayGameOptional.isPresent()) { // 이미 TodayGame이 존재하는 경우: 경기 상태 및 결과 점수만 업데이트 TodayGame existingTodayGame = existingTodayGameOptional.get(); existingTodayGame.update( game.getGameStatus(), game.getHomeResultScore(), game.getAwayResultScore() ); todayGameRepository.update(existingTodayGame); } else { TodayGame newTodayGame = TodayGame.builder() .game(game) .stadium(game.getStadium()) .homeTeam(home) .awayTeam(away) .gameTime(game.getGameTime()) .gameStatus(game.getGameStatus()) .broadcastChannel(game.getBroadcastChannel()) .homePredictionScore(homeScore) .awayPredictionScore(awayScore) .totalPredictionScore(total) .homeWinPer(homeWinPer) .awayWinPer(awayWinPer) .homeResultScore(game.getHomeResultScore()) .awayResultScore(game.getAwayResultScore()) .build(); todayGameRepository.save(newTodayGame); insertedGameIds.add(game.getId()); } } // 루프 종료 후 저장된 게임이 하나라도 있으면 이벤트 발행 if (!insertedGameIds.isEmpty()) { publisherService.publishGameUpdatedEvent(); } }
    • copyTodayStartingPitchers()
      • package com.example.ballkkaye.player.startingPitcher.today; import com.example.ballkkaye.player.startingPitcher.StartingPitcher; import com.example.ballkkaye.player.startingPitcher.StartingPitcherRepository; import com.example.ballkkaye.publisher.PublisherService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.sql.Timestamp; import java.time.LocalDate; import java.util.List; @RequiredArgsConstructor @Service public class TodayStartingPitcherService { private final TodayStartingPitcherRepository todayStartingPitcherRepository; private final StartingPitcherRepository startingPitcherRepository; private final PublisherService publisherService; @Transactional public void copyTodayStartingPitchers() { LocalDate today = LocalDate.now(); Timestamp startOfDay = Timestamp.valueOf(today.atStartOfDay()); Timestamp endOfDay = Timestamp.valueOf(today.plusDays(1).atStartOfDay()); // 1. 오늘 날짜의 선발투수가 존재하는지 확인 List<StartingPitcher> todayPitchers = startingPitcherRepository.findByGameDate(startOfDay, endOfDay); if (todayPitchers.isEmpty()) { System.out.println("선발투수 없음 → 다음 주기 재시도"); // TODO: 로그 관리 return; } // 2. today 테이블에 이미 복사된 선발투수가 있는지 확인 boolean alreadyCopied = todayStartingPitcherRepository.existsAny(); if (alreadyCopied) { return; } // 3. 기존 today 테이블 초기화 (혹시나 있을 경우) todayStartingPitcherRepository.deleteAll(); // 4. StartingPitcher → TodayStartingPitcher 변환 및 저장 List<TodayStartingPitcher> copied = todayPitchers.stream() .map(p -> TodayStartingPitcher.builder() .game(p.getGame()) .player(p.getPlayer()) .profileUrl(p.getProfileUrl()) .ERA(p.getERA()) .gameCount(p.getGameCount()) .result(p.getResult()) .QS(p.getQS()) .WHIP(p.getWHIP()) .build()) .toList(); todayStartingPitcherRepository.saveAll(copied); System.out.println("오늘 선발투수 " + copied.size() + "명 복사 완료"); // TODO: 로그 관리 publisherService.publishStartingPitcherUpdatedEvent(); } }
    • updateBot()
      • package com.example.ballkkaye.team.record.today; import com.example.ballkkaye.fcm.FcmService; import com.example.ballkkaye.team.record.TeamRecord; import com.example.ballkkaye.team.record.TeamRecordRepository; import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import java.util.ArrayList; import java.util.List; @RequiredArgsConstructor @Service public class TodayTeamRecordService { private final TodayTeamRecordRepository todayTeamRecordRepository; private final TeamRecordRepository teamRecordRepository; private final FcmService fcmService; // TodayTeamRecord 테이블 삭제 후 갱신 @Transactional public void updateBot() { todayTeamRecordRepository.deleteAll(); List<TeamRecord> teamRecords = teamRecordRepository.findLatest10(); List<TodayTeamRecord> todayTeamRecords = new ArrayList<>(); for (TeamRecord tr : teamRecords) { TodayTeamRecordRequest.saveDto dto = new TodayTeamRecordRequest.saveDto(tr); TodayTeamRecord todayTeamRecord = dto.toEntity(tr.getTeam()); todayTeamRecords.add(todayTeamRecord); } todayTeamRecordRepository.save(todayTeamRecords); // 알림 publisherService.publishTeamRecordUpdated(); } }

4. 백엔드 서버: Subscriber 역할

A. Subscriber

package com.example.ballkkaye.subscriber; import com.example.ballkkaye.fcm.FcmService; 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.Service; @Slf4j @RequiredArgsConstructor @Service public class Subscriber implements MessageListener { private final FcmService fcmService; // Redis 메시지 수신 시 자동 호출되는 콜백 메서드 @Override public void onMessage(Message message, byte[] pattern) { // [1] 채널 이름과 메시지 내용 추출 String channel = new String(message.getChannel()); // 예: "today-game-updated" String payload = new String(message.getBody()); // 실제 publish된 메시지 내용 (예: "오늘의 경기가 업데이트 되었습니다!") log.info("Redis 메시지 수신 - 채널: {}, 메시지: {}", channel, payload); // [2] 채널 종류에 따라 푸시 메시지 전송 switch (channel) { case "today-game-updated" -> fcmService.sendToUserRoleUsers("오늘의 경기가 업데이트 되었습니다!"); case "hitter-lineup-updated" -> fcmService.sendToUserRoleUsers("오늘의 타자 라인업이 공개되었습니다!"); case "starting-pitcher-lineup-updated" -> fcmService.sendToUserRoleUsers("오늘의 승리예측이 업데이트 되었습니다!"); case "team-record-updated" -> fcmService.sendToUserRoleUsers("팀 기록이 업데이트되었습니다!"); default -> log.warn("수신한 채널을 처리하지 못했습니다: {}", channel); } } }

B. RedisConfig

package com.example.ballkkaye._core.config; import com.example.ballkkaye.subscriber.Subscriber; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.listener.ChannelTopic; import org.springframework.data.redis.listener.RedisMessageListenerContainer; @Configuration @RequiredArgsConstructor public class RedisConfig { /** * Redis Pub/Sub 리스너 컨테이너 설정 * - Redis의 여러 채널(topic)을 구독하여, 메시지가 수신되면 Subscriber 빈이 처리 * - RedisMessageListenerContainer는 백그라운드에서 Redis 메시지를 비동기 수신 * 채널별 용도: * - today-game-updated: 오늘 경기 정보 변경 알림 * - hitter-lineup-updated: 타자 라인업 갱신 알림 * - starting-pitcher-lineup-updated: 선발투수 라인업 갱신 알림 * - team-record-updated: 팀 기록 갱신 알림 */ @Bean public RedisMessageListenerContainer redisContainer(RedisConnectionFactory connectionFactory, Subscriber subscriber) { RedisMessageListenerContainer container = new RedisMessageListenerContainer(); container.setConnectionFactory(connectionFactory); // 각 채널에 대해 subscriber를 리스너로 등록 container.addMessageListener(subscriber, new ChannelTopic("today-game-updated")); container.addMessageListener(subscriber, new ChannelTopic("hitter-lineup-updated")); container.addMessageListener(subscriber, new ChannelTopic("starting-pitcher-lineup-updated")); container.addMessageListener(subscriber, new ChannelTopic("team-record-updated")); return container; } }

5. FCM 기반 유저 푸시

  • Subscriber 클래스가 "today-game-updated" 같은 채널에서 메시지를 수신
    • package com.example.ballkkaye._core.config; import com.example.ballkkaye.subscriber.Subscriber; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.listener.ChannelTopic; import org.springframework.data.redis.listener.RedisMessageListenerContainer; @Configuration @RequiredArgsConstructor public class RedisConfig { /** * Redis Pub/Sub 리스너 컨테이너 설정 * - Redis의 여러 채널(topic)을 구독하여, 메시지가 수신되면 Subscriber 빈이 처리 * - RedisMessageListenerContainer는 백그라운드에서 Redis 메시지를 비동기 수신 * 채널별 용도: * - today-game-updated: 오늘 경기 정보 변경 알림 * - hitter-lineup-updated: 타자 라인업 갱신 알림 * - starting-pitcher-lineup-updated: 선발투수 라인업 갱신 알림 * - team-record-updated: 팀 기록 갱신 알림 */ @Bean public RedisMessageListenerContainer redisContainer(RedisConnectionFactory connectionFactory, Subscriber subscriber) { RedisMessageListenerContainer container = new RedisMessageListenerContainer(); container.setConnectionFactory(connectionFactory); // 각 채널에 대해 subscriber를 리스너로 등록 container.addMessageListener(subscriber, new ChannelTopic("today-game-updated")); container.addMessageListener(subscriber, new ChannelTopic("hitter-lineup-updated")); container.addMessageListener(subscriber, new ChannelTopic("starting-pitcher-lineup-updated")); container.addMessageListener(subscriber, new ChannelTopic("team-record-updated")); return container; } }
  • Redis 메시지 수신 후 FcmService.sendToUserRoleUsers(...) 호출
    • package com.example.ballkkaye.subscriber; import com.example.ballkkaye.fcm.FcmService; 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.Service; @Slf4j @RequiredArgsConstructor @Service public class Subscriber implements MessageListener { private final FcmService fcmService; // Redis 메시지 수신 시 자동 호출되는 콜백 메서드 @Override public void onMessage(Message message, byte[] pattern) { // [1] 채널 이름과 메시지 내용 추출 String channel = new String(message.getChannel()); // 예: "today-game-updated" String payload = new String(message.getBody()); // 실제 publish된 메시지 내용 (예: "오늘의 경기가 업데이트 되었습니다!") log.info("Redis 메시지 수신 - 채널: {}, 메시지: {}", channel, payload); // [2] 채널 종류에 따라 푸시 메시지 전송 switch (channel) { case "today-game-updated" -> fcmService.sendToUserRoleUsers("오늘의 경기가 업데이트 되었습니다!"); case "hitter-lineup-updated" -> fcmService.sendToUserRoleUsers("오늘의 타자 라인업이 공개되었습니다!"); case "starting-pitcher-lineup-updated" -> fcmService.sendToUserRoleUsers("오늘의 승리예측이 업데이트 되었습니다!"); case "team-record-updated" -> fcmService.sendToUserRoleUsers("팀 기록이 업데이트되었습니다!"); default -> log.warn("수신한 채널을 처리하지 못했습니다: {}", channel); } } }
  • FCM 푸시 발송
    • package com.example.ballkkaye.fcm; import com.example.ballkkaye.common.enums.UserRole; import com.example.ballkkaye.user.UserRepository; import com.google.firebase.messaging.FirebaseMessaging; import com.google.firebase.messaging.FirebaseMessagingException; import com.google.firebase.messaging.Message; import com.google.firebase.messaging.Notification; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import java.util.List; @Slf4j @RequiredArgsConstructor @Service public class FcmService { private final UserRepository userRepository; // 일반 사용자(USER 권한)를 대상으로 FCM 푸시 알림 전송 public void sendToUserRoleUsers(String messageBody) { // [1] USER 역할을 가진 사용자의 FCM 토큰 리스트 조회 List<String> userFcmTokens = userRepository.findFcmTokensByRole(UserRole.USER); // [2] 공통 알림 내용 구성 Notification notification = Notification.builder() .setTitle("ballkkaye") // 알림 제목 .setBody(messageBody) // 알림 본문 .build(); // [3] 각 사용자에게 개별 메시지 전송 for (String token : userFcmTokens) { if (token == null || token.isBlank()) continue; // 유효하지 않은 토큰은 건너뜀 // [3-1] 개별 대상에게 보낼 메시지 구성 Message message = Message.builder() .setNotification(notification) .setToken(token) // 특정 디바이스를 식별하는 FCM 토큰 .build(); // [3-2] 메시지 전송 시도 try { String response = FirebaseMessaging.getInstance().send(message); log.info("FCM 전송 완료: {}", response); } catch (FirebaseMessagingException e) { log.error("FCM 전송 실패 ({}): {}", token, e.getMessage()); } } } }
      → 여기서 .setNotification(...), .setToken(...), .build() 로 푸시 메시지 생성 후 FirebaseMessaging.getInstance().send(...) 로 발송
      → Firebase Admin SDK 사용: Message.builder().setToken(...).build()
       

✅ 요약 정리

구성 요소
역할
Redis (Docker)
Pub/Sub 채널 제공, 이벤트 전달
크롤링 서버
Redis 채널에 이벤트 발행 (Publisher)
백엔드 서버
Redis 채널 구독 후 FCM 호출 등 후처리
FCM
사용자에게 실제 푸시 알림 발송
  • Redis로 서버 간 이벤트 비동기 전달 → FCM 연계 시 유저 대상 푸시 가능
  • 최소한의 코드 구조로 확장이 쉬움
 

🚀 FCM 기반 알림 시스템 구성 정리

✅ FCM(Firebase Cloud Messaging) 서비스 개요

  • Firebase Cloud Messaging(FCM)은 Firebase에서 제공하는 무료 푸시 알림 서비스로, Android, iOS, 웹 등 여러 플랫폼에 메시지를 전송 가능
  • Firebase Admin SDK를 통해 Spring Boot 서버에서 직접 알림을 발송할 수 있으며, 본 프로젝트에서는 사용자 역할, 채팅 메시지, 경기 정보 업데이트 등에 활용.

⚙️ 환경 설정 (FcmConfig)

FcmConfig 클래스에서 Firebase SDK를 초기화하며, 이는 앱 실행 시 한 번만 수행
@Configuration public class FcmConfig { @PostConstruct public void initFirebase() { String path = System.getenv("FIREBASE_CONFIG_PATH"); if (path == null || path.isBlank()) throw new Exception404("환경변수 없음"); try (FileInputStream serviceAccount = new FileInputStream(path)) { FirebaseOptions options = FirebaseOptions.builder() .setCredentials(GoogleCredentials.fromStream(serviceAccount)) .build(); if (FirebaseApp.getApps().isEmpty()) { FirebaseApp.initializeApp(options); log.info("Firebase 초기화 성공"); } } catch (IOException e) { throw new Exception400("Firebase 초기화 실패: " + e.getMessage()); } } }
✅ 핵심 사항
  • Firebase 인증 정보는 .json 파일로 구성
  • 해당 파일 경로는 환경변수 FIREBASE_CONFIG_PATH로 주입
  • 중복 초기화 방지를 위해 FirebaseApp.getApps().isEmpty() 체크

📨 메시지 전송 로직 (FcmService)

1. 단일 사용자 메시지 전송

public void sendMessage(String fcmToken, String title, String body) { Notification notification = Notification.builder() .setTitle(title) .setBody(body) .build(); Message message = Message.builder() .setNotification(notification) .setToken(fcmToken) .build(); FirebaseMessaging.getInstance().send(message); }

2. 특정 역할 대상 일괄 전송

public void sendToUserRoleUsers(String messageBody) { List<String> tokens = userRepository.findFcmTokensByRole(UserRole.USER); Notification notification = Notification.builder() .setTitle("ballkkaye") .setBody(messageBody) .build(); for (String token : tokens) { if (token == null || token.isBlank()) continue; Message message = Message.builder() .setNotification(notification) .setToken(token) .build(); FirebaseMessaging.getInstance().send(message); } }

💬 실제 적용 예시

✅ 채팅 메시지 수신 시 알림 전송

@Transactional public ChatMessageResponse.DTO save(ChatMessageRequest.DTO reqDTO, User sessionUser) { ChatRoom chatRoomPS = chatRoomRepository.findById(reqDTO.getChatRoomId()) .orElseThrow(() -> new RuntimeException("채팅방 없음")); User userPS = userRepository.findById(sessionUser.getId()) .orElseThrow(() -> new RuntimeException("유저 없음")); ChatRoomUser chatRoomUserPS = chatRoomUserRepository.findByUserIdAndChatRoomId(userPS.getId(), chatRoomPS.getId()) .orElseThrow(() -> new RuntimeException("채팅방에 존재하지 않음")); ChatMessage message = ChatMessage.builder() .chatRoom(chatRoomPS) .user(userPS) .content(reqDTO.getMessage()) .messageType(reqDTO.getMessageType()) .deleteStatus(DeleteStatus.NOT_DELETED) .build(); chatMessageRepository.save(message); // 여기서 바로 FCM 전송용 DTO 만들고 전송 ChatMessageResponse.ChatPublishDTO dto = new ChatMessageResponse.ChatPublishDTO( chatRoomPS.getId(), userPS.getId(), userPS.getNickname(), reqDTO.getMessage(), reqDTO.getMessageType() ); // 각 수신자에게 ["보낸 사람 닉네임] 메시지" 형식으로 전송 fcmService.sendToChatRoomUsers(dto); return new ChatMessageResponse.DTO( message.getId(), reqDTO.getChatRoomId(), userPS.getId(), userPS.getNickname(), reqDTO.getMessage(), reqDTO.getMessageType(), true, message.getCreatedAt() ); }

✅ 오늘의 경기 정보 업데이트

@Transactional public void syncTodayGames() { LocalDate today = LocalDate.now(); List<Game> todayGames = gameRepository.findTodayGame(today); Double avgOps = todayTeamRecordRepository.leagueAverageOps(); Double totalR = todayTeamRecordRepository.leagueAverageR(); Double avgGames = todayTeamRecordRepository.averageGameCount(); Double avgR = totalR / avgGames; Double avgEra = todayTeamRecordRepository.leagueAverageEra(); Map<Integer, TodayTeamRecord> recordMap = new HashMap<>(); for (TodayTeamRecord record : todayTeamRecordRepository.findAll()) { if (record.getTeam() != null && record.getTeam().getId() != null) { recordMap.put(record.getTeam().getId(), record); } } List<Integer> insertedGameIds = new ArrayList<>(); // 저장된 게임 ID 추적용 리스트 추가 for (Game game : todayGames) { Team home = game.getHomeTeam(); Team away = game.getAwayTeam(); if (home == null || away == null) continue; TodayTeamRecord homeRecord = recordMap.get(home.getId()); TodayTeamRecord awayRecord = recordMap.get(away.getId()); if (homeRecord == null || awayRecord == null) continue; Integer stadiumId = game.getStadium().getId(); Double correction = stadiumCorrectionRepository .getCorrectionByStadiumIdAndYear(stadiumId, LocalDate.now(ZoneId.of("Asia/Seoul")).getYear()); Double homeOps = homeRecord.getOPS(); Double awayOps = awayRecord.getOPS(); if (homeOps == null || awayOps == null) continue; Double homePitcherEra = todayStartingPitcherRepository.findPitcherEraByGameAndTeam(game, home); Double awayPitcherEra = todayStartingPitcherRepository.findPitcherEraByGameAndTeam(game, away); if (homePitcherEra == null || homePitcherEra == 0.0) homePitcherEra = avgEra; if (awayPitcherEra == null || awayPitcherEra == 0.0) awayPitcherEra = avgEra; double rawHomeScore = (homeOps / avgOps) * avgR * (avgEra / awayPitcherEra) * correction; double rawAwayScore = (awayOps / avgOps) * avgR * (avgEra / homePitcherEra) * correction; double homeScore = Math.round(rawHomeScore * 10) / 10.0; double awayScore = Math.round(rawAwayScore * 10) / 10.0; double homeWinPer = 50.0; double awayWinPer = 50.0; double total = homeScore + awayScore; if (total > 0) { homeWinPer = Math.round((homeScore / total) * 1000) / 10.0; awayWinPer = Math.round((awayScore / total) * 1000) / 10.0; } Optional<TodayGame> existingTodayGameOptional = todayGameRepository.findByGameId(game.getId()); if (existingTodayGameOptional.isPresent()) { // 이미 TodayGame이 존재하는 경우: 경기 상태 및 결과 점수만 업데이트 TodayGame existingTodayGame = existingTodayGameOptional.get(); existingTodayGame.update( game.getGameStatus(), game.getHomeResultScore(), game.getAwayResultScore() ); todayGameRepository.update(existingTodayGame); } else { TodayGame newTodayGame = TodayGame.builder() .game(game) .stadium(game.getStadium()) .homeTeam(home) .awayTeam(away) .gameTime(game.getGameTime()) .gameStatus(game.getGameStatus()) .broadcastChannel(game.getBroadcastChannel()) .homePredictionScore(homeScore) .awayPredictionScore(awayScore) .totalPredictionScore(total) .homeWinPer(homeWinPer) .awayWinPer(awayWinPer) .homeResultScore(game.getHomeResultScore()) .awayResultScore(game.getAwayResultScore()) .build(); todayGameRepository.save(newTodayGame); insertedGameIds.add(game.getId()); } } // 루프 종료 후 저장된 게임이 하나라도 있으면 이벤트 발행 if (!insertedGameIds.isEmpty()) { fcmService.sendToUserRoleUsers("오늘의 경기가 업데이트 되었습니다!"); } }

✅ 타자 라인업 공개 시 알림

@Transactional public void copyTodayLineupFromHitterLineup() { LocalDate today = LocalDate.now(); // 1. HitterLineup에서 오늘 날짜 라인업 전체 조회 List<HitterLineup> todayLineups = hitterLineupRepository.findByGameDate(today); if (todayLineups.isEmpty()) { System.out.println("오늘 날짜의 라인업이 없습니다."); return; } // 2. 중복 제거하며 복사 List<TodayHitterLineup> toSave = new ArrayList<>(); for (HitterLineup h : todayLineups) { if (todayHitterLineUpRepository.existsByGameIdAndPlayerId( h.getGame().getId(), h.getPlayer().getId())) { continue; } toSave.add(TodayHitterLineup.builder() .game(h.getGame()) .team(h.getTeam()) .player(h.getPlayer()) .todayHitterOrder(h.getHitterOrder()) .position(h.getPosition()) .seasonAvg(h.getSeasonAvg()) .ab(h.getAb()) .h(h.getH()) .avg(h.getAvg()) .ops(h.getOps()) .build()); } if (toSave.isEmpty()) { System.out.println("이미 모든 라인업이 저장되어 있습니다."); return; } todayHitterLineUpRepository.saveAll(toSave); System.out.printf("TodayHitterLineup으로 %d개 복사 완료\n", toSave.size()); // 알림 fcmService.sendToUserRoleUsers("오늘의 타자 라인업이 공개되었습니다!"); }

📋 FCM 핵심 구성요소 요약표

항목
설명
FcmConfig
Firebase Admin SDK 초기화 클래스
환경 변수
FIREBASE_CONFIG_PATH로 서비스 키 파일 경로 주입
FcmService.sendMessage()
단일 사용자에게 알림 전송
FcmService.sendToUserRoleUsers()
특정 역할(예: USER) 전체에게 전송
알림 구조
Notification(title, body) + Message(token) 조합
사용 위치
경기 업데이트, 채팅 알림, 타자/투수 정보 변경 등
Share article

parangdajavous