프로젝트

FCM 토큰 토픽 방식을 활용하여 푸시알림 전송하기

쿠키담임선생님 2025. 9. 28. 20:51

현재 우리가 사용하는 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. 언어별로 다른 푸시내용.
  2. 채팅방을 나갔을 경우 또는 푸시알림을 꺼놨을 경우 처리.

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()으로 발송을 재사용할 수 있다.

🔹 권장 플로우

  1. 팬이 채팅방 입장 → 클라이언트에서 subscribeToTopic("chatroom_123_{lang}") 실행
    • 이 시점에 토픽이 자동 생성됨
  2. 아티스트가 메시지 발송 → 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);
        }
    }