16. [Project] Redis + Docker를 사용한 FCM 알림 시스템 구성에서 FCM 기반 알림 시스템 구성으로 전환
Jul 15, 2025
🔄 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
로 네트워크 연결, volumes
로 appendonly
지속성을 보장 docker-compose.yml
에서 REDIS_HOST
, REDIS_PORT
를 환경변수로 지정하면, Spring Boot는 이를 자동으로 spring.redis.host
, spring.redis.port
로 인식해 Redis와 연결한다. Redis 인스턴스가 하나인 경우, 별도의 @Value
나 application.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
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 Boot
는LettuceConnectionFactory()
에서 이를 기본값처럼 인식하여 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