현재 우리가 사용하는 FCM 기반으로 푸시알림 보내는 기능은 다음과 같이 구현되어있다.
// 푸시 전송
public void sendPush(Map<String, Object> param) {
String title = (String)param.getOrDefault("title", "");
String body = (String)param.getOrDefault("body", "");
String link = (String)param.getOrDefault("link", "");
Map<String, String> data = new HashMap<>();
data.put("title", title);
data.put("body", body);
data.put("link", link);
Object pushTokenObj = param.get("pushToken");
if (pushTokenObj instanceof String) {
String pushToken = (String) pushTokenObj;
List<String> tokens = new ArrayList<>();
tokens.add(pushToken);
FCM.getInstance().sendPush(tokens, data);
} else if (pushTokenObj instanceof List) {
List<String> tokens = (List<String>) pushTokenObj;
if (tokens.size() > 0) {
FCM.getInstance().sendPush(tokens, data);
}
}
}
여기 보면 해당 푸시의 타이틀, 바디, 링크를 담아서 푸시 토큰을 가진 유저들에게 보낸다는 뜻이다.
그런데 위 코드는 각각의 유저마다 커스텀되어있는 푸시알림을 보내기에 적합하다.
지금 내가 만들고 싶은 기능은, 특정 채팅방에 들어있는 유저들에게, 만약 아티스트가 채팅을 보내면 모든 사람들에게 동일한 푸시알림이 가는 구조다.
🔹 현재 방식 (토큰 기반 전송)
FCM.getInstance().sendPush(tokens, data);
- 개별 디바이스 토큰을 지정해서 푸시 발송.
- 장점: 각 유저별 맞춤형 알림(예: 개인 DM, 개인별 쿠폰, 특정 사용자 전용 알림 등).
- 단점: 채팅방 참여자가 많으면 토큰을 전부 모아서 발송해야 함 → 성능/효율 떨어짐.
🔹 토픽 기반 전송
- FCM의 topic 기능을 이용하면, 디바이스가 특정 토픽(채널)에 구독하게 하고, 서버는 토픽 이름만 지정해서 푸시 발송.
- 예시: 채팅방마다 topic_chatroom_{chatRoomId} 같은 토픽을 만들 수 있음.
- 아티스트가 메시지를 보내면:
- FirebaseMessaging.getInstance().send( Message.builder() .setTopic("chatroom_123") .putData("title", "아티스트 메시지") .putData("body", "새로운 채팅이 도착했어요") .build() );
- 장점:
- 참여자 수와 관계없이 한 번에 발송.
- 토큰 리스트 관리 필요 없음.
- 단점:
- 개별화된 알림을 보내기 힘듦 (토픽은 모든 구독자에게 동일한 메시지가 감).
- 특정 유저만 빼고 싶거나, 언어별로 다르게 보내고 싶으면 추가 로직 필요.
토픽 기반 전송으로 구현했을때 고려해야하는 상황
- 언어별로 다른 푸시내용.
- 채팅방을 나갔을 경우 또는 푸시알림을 꺼놨을 경우 처리.
1번에 대한 문제 시나리오
- 채팅방 공통 알림은 토픽 방식으로 → chatroom_{id} 토픽에 구독한 모든 기기로 발송.
- 하지만 유저별 언어가 다르다 → 같은 방이라도, ko 유저는 “채팅이 도착했습니다”, en 유저는 “New message received” 처럼 다르게 받아야 함.
✅ 해결 전략
토픽은 기본적으로 모든 구독자에게 동일한 메시지만 전송되기 때문에, 다국어 처리를 위해선 언어별 토픽을 분리하는 게 깔끔.
예시 구조:
- chatroom_123_ko
- chatroom_123_en
- chatroom_123_ja
유저가 채팅방에 입장할 때:
- 자신의 languageCode를 확인하고, 그에 맞는 토픽을 subscribe 시킨다.
아티스트가 메시지를 보내면:
- 서버에서 chatroom_{id}_{languageCode} 별로 동일한 “새 메시지가 있습니다”를 해당 언어로 번역해서 발송.
✅ 예시 코드 (Java FCM)
public void sendChatroomPush(String chatRoomId, String languageCode, String title, String body) {
String topic = "chatroom_" + chatRoomId + "_" + languageCode;
Message message = Message.builder()
.setTopic(topic)
.putData("title", title)
.putData("body", body)
.build();
try {
String response = FirebaseMessaging.getInstance().send(message);
System.out.println("Successfully sent message: " + response);
} catch (FirebaseMessagingException e) {
e.printStackTrace();
}
}
✅ 장단점
장점
- 다국어 지원 가능 (각 토픽마다 언어별 메시지).
- 서버에서 번역된 문구만 넣어주면 됨.
단점
- 토픽 수가 많아짐 (채팅방 수 × 언어 수).
- 유저가 언어 설정을 바꾸면 → 기존 언어 토픽 unsubscribe + 새 언어 토픽 subscribe 처리 필요.
2번 문제에 대한 시나리오
- 채팅방 입장 시
- 클라이언트가 FirebaseMessaging.getInstance().subscribeToTopic("chatroom_{id}_{lang}") 호출
- 유저는 해당 토픽 구독 → 서버에서 굳이 토큰을 관리할 필요 없음
- 채팅방 나가기 시
- 클라이언트가 FirebaseMessaging.getInstance().unsubscribeFromTopic("chatroom_{id}_{lang}") 호출
- 이후부터는 해당 방에서 오는 푸시를 받지 않음
즉, 채팅 구독해제, 채팅방 나가기, 푸시알림 끄기 버튼 클릭했을때 미구독하는 매서드를 호출해야함.
반대로 유저들은 해당 채팅방에 입장할때 해당 토픽을 구독하는 매서드를 호출해야함. → 이건 조인로직에 추가.
해당 토픽을 토큰과 묶는 작업은 서버에서도 가능하다.
예시코드
List<String> registrationTokens = Arrays.asList("token1", "token2");
TopicManagementResponse response =
FirebaseMessaging.getInstance().subscribeToTopic(registrationTokens, "chatroom_123_ko");
System.out.println(response.getSuccessCount() + " tokens subscribed");
토픽 아이디 명명 규칙 : chatroom_1414144_ko , chatroom_1414144_en , chatroom_1414144_ja
해당 기능을 사용하기 위한 코드
/**
* FCM 토픽에 여러 토큰을 구독시킨다.
*/
public void subscribeTokensToTopic(List<String> registrationTokens, String topic) {
try {
TopicManagementResponse response =
FirebaseMessaging.getInstance().subscribeToTopic(registrationTokens, topic);
log.info("{} tokens were subscribed successfully to topic={}",
response.getSuccessCount(), topic);
if (response.getFailureCount() > 0) {
log.warn("{} tokens failed to subscribe to topic={}",
response.getFailureCount(), topic);
}
} catch (FirebaseMessagingException e) {
log.error("Failed to subscribe tokens to topic={}", topic, e);
}
}
/**
* FCM 토픽에서 여러 토큰을 해제한다.
*/
public void unsubscribeTokensFromTopic(List<String> registrationTokens, String topic) {
try {
TopicManagementResponse response =
FirebaseMessaging.getInstance().unsubscribeFromTopic(registrationTokens, topic);
log.info("{} tokens were unsubscribed successfully from topic={}",
response.getSuccessCount(), topic);
if (response.getFailureCount() > 0) {
log.warn("{} tokens failed to unsubscribe from topic={}",
response.getFailureCount(), topic);
}
} catch (FirebaseMessagingException e) {
log.error("Failed to unsubscribe tokens from topic={}", topic, e);
}
}
/**
* 토픽으로 푸시 메시지를 보낸다.
*/
public void sendPushToTopic(String topic, String title, String body, String link) {
try {
Message message = Message.builder()
.setTopic(topic)
.putData("title", title)
.putData("body", body)
.putData("link", link)
.build();
String response = FirebaseMessaging.getInstance().send(message);
log.info("Successfully sent push to topic={}, response={}", topic, response);
} catch (FirebaseMessagingException e) {
log.error("Failed to send push to topic={}", topic, e);
}
}
위 코드들에서 아티스트가 메세지를 만들때는
/**
* 토픽으로 푸시 메시지를 보낸다.
*/
public void sendPushToTopic(String topic, String title, String body, String link) {
try {
Message message = Message.builder()
.setTopic(topic)
.putData("title", title)
.putData("body", body)
.putData("link", link)
.build();
String response = FirebaseMessaging.getInstance().send(message);
log.info("Successfully sent push to topic={}, response={}", topic, response);
} catch (FirebaseMessagingException e) {
log.error("Failed to send push to topic={}", topic, e);
}
}
위 로직을 계속해서 호출해야한다.
그런데 여기서 궁금한점이 생겼다. \
아티스트가 채팅 보낼때마다 이렇게 토픽을 새로 생성해야하는것인가 아니면 토픽은 최초1회만 만들어두고 이후 해당 토픽을 재사용할 수 있는 것인가.
🔹 FCM의 토픽 생성 규칙
- 토픽은 개발자가 “생성 API”를 호출해서 만드는 개념이 아님
- 처음 누군가가 구독(subscribeToTopic)하면 자동 생성
- 즉, 구독자가 아무도 없는 상태에서는 sendPushToTopic("chatroom_123_ko", ...)을 호출하더라도
- 토픽은 생성되지 않고,
- 메시지가 발송되더라도 받을 기기가 없으니 그냥 사라진다(소실)
🔹 정리
- sendPushToTopic()만 먼저 호출한다고 해서 토픽이 생성되진 않음.
- 반드시 최소 1명 이상이 subscribeToTopic()을 실행해야 토픽이 유효해진다.
- 그 이후에는 계속 sendPushToTopic()으로 발송을 재사용할 수 있다.
🔹 권장 플로우
- 팬이 채팅방 입장 → 클라이언트에서 subscribeToTopic("chatroom_123_{lang}") 실행
- 이 시점에 토픽이 자동 생성됨
- 아티스트가 메시지 발송 → sendPushToTopic("chatroom_123_{lang}") 실행
- 이미 구독자가 있으므로 정상적으로 푸시 전달됨
✅ 결론:
sendPushToTopic이 먼저 와도 토픽이 생기지 않는다.
👉 항상 팬이 구독(subscribeToTopic)을 먼저 실행해야 토픽이 만들어지고, 그 후에 아티스트의 발송이 유효하게 동작.
실 사용 코드
// 푸시알림 구독
// 아티스트일 경우는 고려하지 않음. 아티스트 join은 admin에서만.
pushService.subscribeTokensToTopic(List.of(loginVO.getPushToken()), "chatroom_" + chatRoomId + "_" + loginVO.getAppLanguage());
이렇게 구현하면서 팬들이 각 토픽을 구독하는 로직은 완료.
이제 아티스트가 채팅하나하나 칠때마다 3개의 토픽에 알림을 보내는 로직을 추가해야함.
/**
* 채팅방에 아티스트 메시지가 등록될 때,
* 언어별 토픽(ko/en/ja)에 동일한 알림을 발송한다.
*/
private void sendChatPushToAllLanguages(String chatRoomId, String nickName) {
String link = "/chatroom/" + chatRoomId; // 필요에 맞게 링크 구성
Map<String, String> bodyByLang = Map.of(
"ko", nickName + "메시지가 도착했어요.",
"en", "You've received a message from" + nickName + ".",
"ja", nickName + "メッセージが届きました。"
);
for (Map.Entry<String, String> entry : bodyByLang.entrySet()) {
String lang = entry.getKey();
String body = entry.getValue();
String topic = "chatroom_" + chatRoomId + "_" + lang;
pushService.sendPushToTopic(topic, "Goodduck", body, link);
}
}
해당 매서드를 통해 메세지 전송
이제 마지막 단계.
- 푸시알림을 끄거나
- 구독권이 만료가 되거나(스케줄러에 하나넣고, 웹훅에 재결제)
푸시 알림 끄고 킬때 상황
기존 알림 설정 옵션
public void updateChatNotificationOption(LoginVO loginVO, String chatRoomId, ChatNotificationUpdateRequest request) {
ChatRoomJoin chatRoomJoin = chatRoomJoinRepository
.findByChatRoomIdAndUid(chatRoomId, loginVO.getUid())
.orElseThrow(() -> new GoodduckException(ErrorCode.NOT_JOINED_CHATROOM));
chatRoomJoin.setChattingPushYn(request.getChattingPushYn());
chatRoomJoin.setUpdtId(loginVO.getUid());
chatRoomJoin.setUpdtDt(Instant.now());
chatRoomJoinRepository.save(chatRoomJoin);
}
변경해야할 내용
‘N’으로 할때는 토픽 미구독 상태로직 추가.
‘Y’로 할때 다시 토픽 구독하는 로직 추가.
public void updateChatNotificationOption(LoginVO loginVO, String chatRoomId, ChatNotificationUpdateRequest request) {
ChatRoomJoin chatRoomJoin = chatRoomJoinRepository
.findByChatRoomIdAndUid(chatRoomId, loginVO.getUid())
.orElseThrow(() -> new GoodduckException(ErrorCode.NOT_JOINED_CHATROOM));
String newPushYn = request.getChattingPushYn();
chatRoomJoin.setChattingPushYn(newPushYn);
chatRoomJoin.setUpdtId(loginVO.getUid());
chatRoomJoin.setUpdtDt(Instant.now());
chatRoomJoinRepository.save(chatRoomJoin);
// ✅ 푸시 토픽 구독/해제 처리
String appLanguage = loginVO.getAppLanguage();
if (appLanguage == null || appLanguage.isBlank()) {
appLanguage = "en"; // 기본값
}
String pushToken = loginVO.getPushToken();
if (pushToken == null || pushToken.isBlank()) {
log.warn("푸시 토큰이 없는 유저: uid={}, chatRoomId={}", loginVO.getUid(), chatRoomId);
return; // 🚫 토큰 없으면 구독/해제 불가
}
String topic = "chatroom_" + chatRoomId + "_" + appLanguage;
if ("N".equalsIgnoreCase(newPushYn)) {
// 알림 끄기 → 토픽 구독 해제
pushService.unsubscribeTokensFromTopic(List.of(pushToken), topic);
log.info("유저 {} 채팅방 {} 알림 OFF → 토픽 {} 구독 해제", loginVO.getUid(), chatRoomId, topic);
} else if ("Y".equalsIgnoreCase(newPushYn)) {
// 알림 켜기 → 토픽 재구독
pushService.subscribeTokensToTopic(List.of(pushToken), topic);
log.info("유저 {} 채팅방 {} 알림 ON → 토픽 {} 재구독", loginVO.getUid(), chatRoomId, topic);
}
}
'프로젝트' 카테고리의 다른 글
| 채팅 시스템 구현 중 대규모 트랜잭션 처리 방법 (0) | 2025.09.25 |
|---|---|
| 인텔리제이 터미널에서 깃 연동 시 다른 계정으로 연동 정보 변경 (0) | 2024.10.29 |
| 깃 아이디 로그인에서 토큰 로그인으로 바꾸는 방법 (0) | 2024.09.03 |
| 카카오톡에서 챗 GPT 연동해보기(메신저봇 R 사용) (1) | 2023.12.21 |
| ec2서버 내리기 + 탄력적 ip 제거 (0) | 2023.12.18 |