ajouevent.com

유튜브처럼, 구독한 공지사항별 알림 설정 기능을 도입해보자

simzard 2025. 10. 2. 15:49

 

 

 

 

 

기능 개발 배경 - 푸시 알림이 너무 많이 와요…

아주대학교-일반 공지사항은 하루에도 30~50건씩 올라온다. "구독"탭에서는 구독한 공지사항 글을 확인할 수 있다.

 

아주대학교 '일반 공지사항'을 구독한 사용자들로부터 “하루 30~50건씩 올라오는 공지로 인해 푸시 알림이 너무 자주 와서 부담스럽다”는 피드백이 왔다.

흥미로운 부분은, 사용자들이 그렇다고 해서 구독을 완전히 취소하고 싶어하지는 않는다는 것이었다.

구독을 취소하면 '구독 탭'에서 새 글을 바로 확인할 수 없고, 필요할 때마다 '검색 탭'에서 직접 찾아야 하는 불편함이 생기기 때문이다.

실제로 구독 탭의 네비게이션 바에 표시되는 '배지'를 통해 사용자가 구독한 새로운 공지사항이 올라왔다는 소식을 알려주고 있었다.

 

사용자 입장에서는 특정 공지사항에 대해 “구독은 유지하고, 알림만 끄고 싶어요”라는 니즈가 존재했다.

 

기존 구조는 Topic(공지사항) 구독 = 푸시 알림 수신이었다.

따라서 사용자가 알림을 끄려면 선택지는 두 가지뿐이었다.

1. 핸드폰 앱 설정에서 ajouevent.com 서비스의 모든 알림을 차단하기

2. 부담스러운 공지사항의 구독을 취소하기

하지만 서비스를 운영하는 나의 입장에서는 두 선택지 모두 리스크가 컸다.

특히 1번 선택지의 경우, 앱 설정에서 전체 알림을 꺼버리면 다시 켜는 경우가 드물기 때문에, 특정 공지사항 단위로 알림을 제어할 수 있도록 하는 것이 더 합리적이었다.

 

결국 서비스 차원에서 구독한 공지사항별 알림 설정 기능을 제공하는 것이 사용자 경험(UX) 측면에서도 필요하다고 판단했다.

 

목표: 구독한 공지사항의 푸시 알림 설정 기능을 추가하자

공지사항별로 알림 설정을 따로 둘 수 있어야 했다.

이 부분에서 유튜브채널별 알림 설정 기능이 생각났다.

유튜브가 채널 "구독""알림 설정"(전체/맞춤설정/끔)으로 분리하는 것에서 힌트를 얻어 동일한 UX를 목표로 했다.

아주이벤트에서도 “구독은 유지하면서 알림만 끌 수 있는 구조”를 구현하고자 했다.

 

 

유튜브 vs 아주이벤트

알림 설정 - 유튜브

 

알림 설정 - 아주이벤트

지금은 구독/구독 취소 밖에 없으며, 구독일 때만 알림을 받을 수 있다

 

 

구독 탭 - 유튜브

유튜버 채널에 들어가면 유튜버의 동영상을 볼 수 있다
자신의 구독 페이지에서는 구독한 유튜버의 업로드한 동영상을 볼 수 있다

 

구독 탭 - 아주이벤트

구독탭에서 구독한 공지사항을 볼 수 있는데, 지금은 구독을 하면 무조건 알림을 받는 구조다(따로 앱 설정에서 알림을 끄지 않는 이상)

 

*우선 FCM의 Token과 Topic이란?

우선 https://www.ajouevent.com 서비스의 푸시 알림은 FCM의 Topic을 사용한다.

FCM Token과 Topic의 차이는 아래와 같다.

FCM Token

출처:  https://zuminternet.github.io/FCM-PUSH/

  • 앱이 FCM 서버와 통신하기 위해 사용되는 고유한 식별자
  • 앱은 서버와 통신할 때 토큰을 사용하여 FCM 서버에서 앱을 식별하고, 이를 통해 메시지 전송을 할 수 있다.
  • FCM의 토큰은 앱이 설치된 디바이스마다 고유하다. 앱이 설치된 디바이스를 추가하거나 삭제할때 토큰이 변경될 수 있다. (refresh)
  • 서버는 이러한 FCM 토큰을 사용하여 특정 디바이스에 메시지를 전송할 수 있다.

즉, Token은 Firebase에서 관리하는 프로젝트별 접속하는 기기의 고유 ID로 볼 수 있다.

 

FCM Topic

출처:  https://zuminternet.github.io/FCM-PUSH/

  • FCM의 Topic(토픽)은 일종의 채널로서, 미리 Topic을 구독한 사용자(토큰)들에게 메시지를 전송할 수 있다.
  • Topic을 구독, 구독 취소 요청시에, FCM은 구독한 유저들을 내부적으로 관리한다.
  • FCM의 subscribe, unsubscribe 메서드를 통해 구독과 구독 취소 요청을 FCM에 전송할 수 있다.
  • Topic을 통해서 푸시 알림을 발송하면, Topic을 구독한 사용자들에게만 메시지를 전송할 수 있도록 한다.

 

FCM 개념 정리 (Token vs Topic)

  • FCM Token: 디바이스(앱 설치 단위) 식별자. 서버는 이 토큰으로 특정 기기에 보낸다.
  • FCM Topic: 채널 개념. 토픽을 구독한 모든 기기에 보낸다. 구독/해지는 FCM이 내부적으로 관리한다.

Topic 방식에선 서버가 반드시 FCM 라이브러리를 사용해 대상 Token과 구독할 Topic 정보를 담아 subscribeToTopic() / unsubscribeFromTopic()를 호출해 토큰-토픽 관계를 FCM에게 알려줘야 한다. 이후 푸시 메시지를 발송할 때 해당 Topic 정보를 포함해 FCM으로 요청하면, FCM이 사전에 해당 Topic을 구독한 모든 Token으로 알림을 전송하는 구조이다.

 

 

관련 테이블

Topic - 구독할 수 있는 공지사항 단위

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "topic")
public class Topic {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	@Column
	private String department;

	@Enumerated(EnumType.STRING)
	@Column(unique = true)
	private Type type;

	@Column
	private String classification;

	@Column
	private String koreanTopic;

	@Column
	private Long koreanOrder;
}

학교 공지사항마다 Topic을 두었다.

사용자가 Topic을 구독하면, 해당 Topic의 공지사항이 올라올 때 푸시 알림을 받을 수 있다.

기존에 학교 공지사항은 FCM에서 Topic 이름으로 소프트웨어학과면 Software , 간호학과면 Nursing으로 설정하여 사용자들이 Topic을 구독할 수 있도록 했다.

 

TopicMember - Topic과 Member를 매핑 (사용자가 어떤 공지사항을 구독하는지)

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "topic_member")
public class TopicMember extends BaseTimeEntity {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "topic_id")
	private Topic topic;

	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "member_id")
	private Member member;

	@Column(nullable = false, columnDefinition = "TINYINT(1)")
	private boolean isRead;

	@Column(nullable = false, columnDefinition = "TINYINT(1)") // 새로 추가하는 필드
	private boolean receiveNotification;
}

사용자마다 구독하고 있는 개별 Topic (ex. Software, Nursing …)에 대한 정보를 가지고 있는 테이블이다.

 

Token - 기기마다 부여되는 고유한 식별자, 사용자 1명이 여러개를 가질 수 있음

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Table(name = "token")
public class Token {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	@Column(unique = true)
	private String tokenValue;

	@Column(nullable = false)
	private LocalDate expirationDate;

	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "member_id")
	private Member member;

	@Column(nullable = false)
	private boolean isDeleted = false;

	public void markAsDeleted() {
		this.isDeleted = true;
	}
}

특정 공지사항 Topic(ex. Software)를 구독하기 위해 FCM 서버에 요청하면 해당 Token 단위로 보내진다.

 

TopicToken - Topic과 Token을 매핑

@Entity
@Getter
@AllArgsConstructor
@NoArgsConstructor
@Table(name = "topic_token")
public class TopicToken {
	@Id
	@GeneratedValue(strategy = GenerationType.IDENTITY)
	private Long id;

	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "topic_id")
	private Topic topic;

	@ManyToOne(fetch = FetchType.LAZY)
	@JoinColumn(name = "token_id")
	private Token token;
}

사용자가 특정 공지사항 ex. Software를 구독하면 사용자가 가지고 있는 Token을 가져와서, 각각 TopicToken을 생성한다.

 

고민한 방법들

방법1 - 알림 여부를 저장하는 필드 추가(기존 구조 유지)

  • 현재 구조에서, TopicMember에 receiveNotification 필드를 추가
    • receiveNotification 필드: 사용자가 구독하고 있는 특정 Topic에 대한 알림 수신 여부를 설정하는 필드
      • receiveNotification == true: 푸시 알림 수신
      • receiveNotification == false: 푸시 알림 거부 (구독만 한 상태, 구독한 공지사항 글은 볼 수 있음)
  • 알림 해제 시 FCM unsubscribeFromTopic()을 호출하여 푸시 자체를 차단

상황별 정리 ( A → B, 즉 A 상태에서 B 상태가 될 때 변경 사항들)

상태전환 FCM 동작 DB 필드 변화 TopicMember 변화
구독 X → 새로 구독 FCM subscribeToTopic() 호출 - Software 구독 receiveNotification = true 새로 생성
구독 상태 → 알림 해제
(알림만 끄고 싶을 때)
FCM unsubscribeFromTopic() 호출 - Software 구독 해제 receiveNotification = false 유지
알림 해제 → 알림 설정 FCM subscribeToTopic() 호출 - Software 구독 receiveNotification = true 유지
구독 상태(알림 해제) → 구독 취소 (이미 해제된 상태, FCM 호출 없음) TopicMember 삭제 삭제
구독 상태(알림 설정) → 구독 취소 FCM unsubscribeFromTopic() 호출 - Software 구독 해제 TopicMember 삭제 삭제
  • 장점
    • 기존 구조 변경 최소화 (필드 하나 추가만으로 구현 가능)
    • 여전히 Topic 기반 전송이라 대량 전송 성능 우수
  • 문제점
    1. FCM 구독 상태와 DB 간 동기화 위험(정합성 일치 X)
      • 서버(DB)의 receiveNotification = false만으로는 FCM 알림 차단이 되지 않는다
      • FCM의 unsubscribeFromTopic() 호출이 누락되거나 실패할 경우, 알림을 꺼둔 사용자에게도 알림이 전송될 수 있다
      • 스프링 서버에서 알림 수신 여부를 통제하는 것이 아니라 FCM이 알림 설정을 통제
      • 로직 자체가 복잡하다…
    2. 사용자별 맞춤 데이터 포함 불가
      • FCM 토픽 메시지는 모든 사용자에게 동일한 메시지만 전송됨
      • 사용자별 맞춤 알림 (예: 읽지 않은 개수에 따른 badge 수 전달)은 불가능
    3. 상태 검증 어려움
      • Firebase에서는 “누가 어떤 토픽에 구독돼 있는지”를 조회하는 API를 제공하지 않음
      • DB 상태와 FCM 서버 상태의 불일치를 감지하거나 복구하기 어려움

 

방법 2 - 알림 여부를 다루는 Topic 추가 & FCM 조건식

  • 각 공지사항마다 *_NoNotify Topic을 추가해, 알림을 끄고 싶을 때는 두 토픽을 동시에 구독하도록 설계.
    • 기존 Topic: Software → 모든 소프트웨어 공지사항 구독자.
    • 알림 제외 Topic: Software_NoNotify → 소프트웨어 공지사항을 구독하지만 알림은 받지 않으려는 사용자.
  • 알림 전송 시 조건식을 사용하여 NoNotify 구독자를 제외.
    {
      "condition": "'Software' in topics && !('Software_NoNotify' in topics)",
      "notification": {
        "title": "새로운 공지사항",
        "body": "소프트웨어학과 공지사항이 올라왔습니다!"
      }
    }.
    

    •  

구독 로직:

  • 알림 받기: Software만 구독
  • 알림 끄기: Software + Software_NoNotify 모두 구독

알림 해제/받기:

  • 알림 해제: Software_NoNotify를 구독
  • 알림 받기: Software_NoNotify를 구독 해제

구독 취소:

  • 알림을 받고 있는 사용자: Software만 구독해제
  • 알림을 끄고 있는 사용자: Software, Software_NoNotify를 모두 구독해제
상태 구독 Topic 조합 조건식 매칭 전송 방식 비고
구독 중 + 알림 받기 Software Condition → Topic 전송 기본
구독 중 + 알림 없음 Software + Software_NoNotify ❌ 제외 조건식에 의해 미전송 상태 표현을 토픽 조합으로
구독 취소 (둘 다 해제) (무관) 없음 관리 복잡도↑
  • 장점:
    • Topic 기반의 전송 성능 유지
  • 문제점
    • 토픽 관리 증가:
      • 사용자 수가 많아지면 토픽 관리가 복잡해질 수 있음.
      • 각 토픽마다 알림 비활성 토픽을 추가해야함
    • 조건식 복잡성:
      • 알림 전송 시 FCM 조건식이 복잡해지고, 조건식 작성 오류 시 의도치 않은 사용자에게 알림 발송 가능

 

방법 3 - 알림 여부를 저장하는 필드 추가 + Topic → Token 구조로 변경

  • 사용자가 구독하고 있는 TopicMember 엔티티에 receiveNotification 필드를 추가하여, 알림 수신 여부를 사용자 별로 설정할 수 있게 한다.
  • 공지사항 알림을 발송할 때, 구독 중이면서 receiveNotification == true인 사용자만 필터링 → Token 기반 개별 메시지를 전송한다.
  • 더 이상 Fcm subscribe / unsubscribe 호출에 의존하지 않는다

이 방식은 FCM Topic 발송과 달리, 토큰이 어떤 토픽을 구독하는지 FCM에 따로 알려줄 필요가 없다. (즉, subscribeToTopic() / unsubscribeFromTopic() 호출이 필요 없음. 전송 대상은 DB에서 직접 필터링한다.)

상태 DB TopicMember receiveNotification FCM Topic 구독 전송 방식 비고
구독 중 + 알림 받기 ✅ 존재 true (무관) Token 개별 전송 개인화 데이터 가능
(예: unread_count)
구독 중 + 알림 없음 ✅ 존재 false (무관) 미전송 FCM 구독/해지 호출
불필요
구독 취소 ❌ 삭제 (삭제) (무관) 없음 단일 소스(DB)로 정합성↑
  • 장점
    • 정합성 맞추기 용이하다.
    • 사용자별 맞춤 알림 가능
      • 읽지 않은 알림 수와 같은 사용자별 데이터를 메시지에 포함 가능
    • 발송 결과에 대한 개별 토큰 결과 확인 가능
    • FCM이 알림 설정을 통제하는 것이 아니라, 스프링 서버에서 알림 수신 여부 통제 (DB에서 알림을 받는 사용자만 필터링해서 FCM으로 전달) → 정합성 리스크가 줄어듦
      • FCM에게 구독 정보를 전달할 필요가 없다. (subscribeToTopic() / unsubscribeFromTopic() 호출 x)
  • 문제점
    • 공지사항을 Topic으로 보내지 못함 → 지금은 공지사항을 보낼 때 Topic만 담아서 보내고, 따로 사용자 Token이나 TopicMember를 확인하지 않음→ 공지사항을 보내는 로직을 Token 구조로 수정해야 함
    • Token 구조로 수정하게 되면, 공지사항 푸시 알림을 보낼 때 서버에서 검증하는 시간이 길어짐 (반면 Topic은 이미 구독을 FCM 서버에서 관리했다고 가정해서 그냥 Topic으로 보내면 됨)
    • FCM 전송 성능 저하:
      • 토픽을 사용하지 않고 FCM Token으로 개별 전송하면 다수의 사용자에게 알림을 보낼 때 비효율적.
      • 사용자 수가 많아질수록 처리 비용 증가.
    • 추가 로직:
      • 알림 전송 시 사용자 데이터를 필터링하고 토큰 리스트를 생성하는 추가 작업 필요.
    • FCM 토픽의 장점 포기:
      • FCM 토픽의 전송 효율성과 단순함을 활용하지 못함.

방식별 장단점 정리

방식 장점 단점
방법 1(Topic 유지 + receiveNotification) - 기존 구조 변경 최소화
- 구현 간단
- 여전히 Topic 기반이라 대량 전송 성능 우수
- DB ↔ FCM 상태 불일치 가능
- 사용자 맞춤 데이터 불가
- 상태 검증 어려움
방법 2(알림 전용 Topic 추가 + 조건식) - Topic 기반 성능 활용
- 알림 여부를 Topic 조합으로 단순히 표현
- 토픽 관리 복잡도 ↑
- 조건식 작성 번거로움/실수 위험
방법 3(Token 전송 + DB 필터링) - 사용자별 맞춤 데이터 가능 (unread_count 등)
- 전송 결과 추적 용이
- 단일 소스(DB)로 정합성 확보
- Topic 기반보다 성능 저하
- 토큰 필터링/조회 로직 추가 필요
- 대량 전송 시 서버 부하 ↑

 

최종 선택 및 구현 방향

1차: receiveNotification 필드 추가 → Topic 기반 유지하면서 알림 ON/OFF 기능 제공

2차: Token 기반 전송으로 전환 → 사용자별 맞춤 알림(배지 수, 통계) + 정합성 개선

 

1차 채택 - 방법1: 기존 Topic 구조 + receiveNotification 필드 추가

당장 사용자에게 기능을 빠르게 제공해야 했기 때문에, 기존 구조를 크게 바꾸지 않고도 구현 가능한 방법 1을 우선 적용했다.

TopicMember 엔티티에 receiveNotification 필드를 추가하여, 사용자가 알림을 해제하면 FCM에 토픽 구독 해제 요청(unsubscribeFromTopic)을 보내고, 해당 필드 값만 false로 바꾸도록 했다.

공지사항이 기존처럼 FCM Topic으로 전송되더라도, 알림을 해제한 사용자는 실제로 푸시를 받지 않게 된다

즉, FCM 서버에서 구독이 해제되어 있기 때문에, 동일한 Topic으로 발송하더라도 알림을 받는 사용자와 받지 않는 사용자를 구분할 수 있게 되었다.

 

2차 채택 - 방법3: Topic → Token 구조로 변경

하지만 실제 운영 중에 정합성 문제가 터졌다. 구독을 했는데도, 푸시 알림이 오지 않는 사례가 발생했다.

 

정합성 이슈의 원인

  1. FCM Token의 특성
    • FCM Token은 기기마다 발급되며, PWA 재설치나 토큰 만료 시 새로 발급된다.
  2. 구독 정보 갱신 과정
    • 새 Token이 발급되면(아이폰에서 우리 서비스를 쓰다가, 아이패드에서도 쓰기 위해 로그인하는 경우), 기존에 구독하던 Topic들을 다시 FCM 서버로 subscribeToTopic()를 호출해서 연결해야 한다.
    • 이 과정이 로그인 시점에 처리되었는데 사용자가 기존에 구독한 공지사항, 키워드가 많을수록 호출이 많아져 로그인 속도가 느려졌다.
  3. 사용자 새로고침 문제
    • 로그인 도중 사용자가 새로고침을 해버리면, 일부 subscribeToTopic() 요청이 끝나기 전에 끊겨버린다.
    • 이 때문에 DB에는 구독 상태로 기록됐지만, FCM 서버에는 구독 정보가 반영되지 않아 푸시 알림이 오지 않는 현상이 발생했다.

→ 즉, 처음 우려했던 알림 ON/OFF 정합성 문제와 유사한 문제이다. 구독 상태를 FCM에서 제어·관리하는 구조 자체가 정합성 리스크를 안고 있음을 확인할 수 있었다.

 

추가 요구사항

이와 더불어 새로운 요구사항도 생겼다.

앱 뱃지 적용

FCM 메시지에 “사용자별로 읽지 않은 알림 개수”를 data에 담아 전송한 뒤, PWA 앱 배지 UI에 띄워야 한다.

 

하지만 FCM Topic 전송 방식은 모든 사용자에게 동일한 메시지만 전송할 수 있기 때문에, 사용자별 맞춤 데이터 전송이 불가능했다. 클릭률, 수신율 같은 사용자별 통계와 개별 토큰에 대한 푸시 전송 결과와 같은 로그를 수집하는 것에도 한계가 있었다.

 

구조 전환

이 두 가지 이유(정합성 리스크 + 맞춤 데이터 요구)로 인해, 기존 Topic 중심 구조에서 Token 기반 전송 방식으로 전환했다.

항목 이전(Topic 전송) 이후(Token 전송)
전송 단위 Topic Token
알림 ON/OFF FCM 토픽 구독/해지 필요 DB receiveNotification으로 필터링 (FCM 호출 없음)
정합성 DB ↔ FCM 상태 불일치 가능 단일 소스(DB)로 정합성 확보
개인화 데이터 불가(동일 메시지) 가능(ex: unread_count)
전송 성능 대량에 유리 배치/비동기로 보완
  • 각 사용자별로 읽지 않은 알림 개수를 계산하고, 해당 값을 담은 메시지를 Token을 통해 개별 전송함으로써 프론트엔드 앱의 배지 UI에 정확한 정보를 반영할 수 있도록 했다.
  • 물론 Topic 방식이 성능적으로는 더 뛰어나지만, 전체 사용자 수가 감당 가능한 수준이고, 비동기 + 논블로킹 방식의 ThreadPool 구조를 도입하여 전송 성능을 충분히 최적화할 수 있었다.
  • 구조 변경 이후에도 TopicMember 엔티티의 receiveNotification 필드는 유지되며, 공지사항 크롤링 → 구독 사용자 조회 → 푸시 전송 로직에서 해당 필드(receiveNotification)가 false인 사용자에겐 알림을 보내지 않도록 분기 처리하고 있다.

*Topic → Token으로의 푸시 알림 구조 전환 과정은 따로 글을 작성해 보겠다.

 

 

최종 구현 정리: 알림 설정 추가 및 Token 기반 전송

알림 설정 추가

TopicMember에  receiveNotification 필드 도입

@Column(nullable = false, columnDefinition = "TINYINT(1)")
private boolean receiveNotification;
  • receiveNotification: 사용자의 알림 수신 여부를 나타내는 필드
  • 구독 시 기본값은 **true**이며, 사용자가 알림을 해제하면 **false**로 설정

 

사용자가 구독한 Topic에 대해 알림 설정을 ON/OFF할 수 있도록 API를 제공

public record NotificationPreferenceRequest(String topic, boolean receiveNotification) {
}
@PreAuthorize("isAuthenticated()")
@PostMapping("/subscriptions/notification")
public ResponseEntity<ResponseDto> updateNotificationPreference(
			@AuthenticationPrincipal CustomUserDetails userDetails,
			@RequestBody NotificationPreferenceRequest request) {
	topicService.updateNotificationPreference(request);
	return ResponseEntity.ok().body(ResponseDto.builder()
		.successStatus(HttpStatus.OK)
		.successContent(request.getTopic() + " 알림 수신 설정이 " + request.isReceiveNotification() + "로 변경되었습니다.")
		.build()
	);
}
@Transactional
public void updateNotificationPreference(
							CustomUserDetails userDetails,
							NotificationPreferenceRequest request) {
	Member member = memberRepository.findByEmail(userDetails.getEmail())
		.orElseThrow(() -> new CustomException(CustomErrorCode.USER_NOT_FOUND));

	Topic topic = topicRepository.findByDepartment(request.getTopic())
		.orElseThrow(() -> new CustomException(CustomErrorCode.TOPIC_NOT_FOUND));

	TopicMember topicMember = topicMemberRepository.findByMemberAndTopic(member, topic)
		.orElseThrow(() -> new CustomException(CustomErrorCode.SUBSCRIBE_FAILED));

	topicMember.changeReceiveNotification(request.isReceiveNotification());
}
  • 요청 시 NotificationPreferenceRequest로 topic 이름과 알림 여부를 전달받아, 해당 TopicMember의 receiveNotification 필드를 갱신한다.

 

Token 기반 발송 로직

알림 전송 전 사용자 필터링

// Topic을 구독 중이고, 알림을 수신 허용한 TopicMember 조회
List<TopicMember> topicMembers = topicMemberRepository
    .findByTopicWithNotificationEnabledAndTokens(topic);
  • receiveNotification = true인 사용자만 필터링하여 알림 전송 대상으로 지정
// PushClusterTokens 생성
List<PushClusterToken> clusterTokens = topicTokens.stream()
	.map(token -> PushClusterToken.builder()
		.pushCluster(pushCluster)
		.token(token.getToken()) // TopicToken에 연결된 Token 가져오기
		.jobStatus(JobStatus.PENDING) // 초기 상태: PENDING
		.requestTime(LocalDateTime.now()) // 요청 시간 기록
		.build())
	.collect(Collectors.toList());
pushClusterTokenBulkRepository.saveAll(clusterTokens);
  • 위에서 필터링된 사용자들의 유효한 FCM 토큰을 바탕으로, 실제 알림 전송 대상인 PushClusterToken을 생성하고 저장

 

사용자 개별 푸시 메시지를 생성

@Transactional
public void sendNoticeNotification(NoticeDto noticeDto, Long eventId, Long pushClusterId) {
    PushCluster pushCluster = pushClusterRepository.findById(pushClusterId)
        .orElseThrow(() -> new CustomException(CustomErrorCode.PUSH_CLUSTER_NOT_FOUND));

    List<PushClusterToken> clusterTokens = pushClusterTokenRepository.findAllByPushClusterWithTokenAndMember(pushCluster);

    // 사용자별 읽지 않은 알림 개수 조회
    Map<Long, Long> unreadCountMap = pushNotificationRepository.countUnreadNotificationsForTopic(noticeDto.getKoreanTopic())
        .stream()
        .collect(Collectors.toMap(UnreadNotificationCountDto::getMemberId, UnreadNotificationCountDto::getUnreadNotificationCount));

    sendPushClusterNotification(
        clusterTokens,
        pushCluster,
        composeMessageTitle(noticeDto),
        composeBody(noticeDto),
        getFirstImageUrl(noticeDto),
        getRedirectionUrl(noticeDto, eventId),
        unreadCountMap
    );
}
  • 사용자의 FCM 토큰별로 읽지 않은 알림 수를 함께 포함해 전송하기 위해, unreadCountMap을 생성
  • 이후 각 사용자에게 전송할 메시지를 PushClusterToken 기준으로 구성

 

FCM으로 전송

public void sendPushClusterNotification(List<PushClusterToken> clusterTokens, PushCluster pushCluster, String title, String body, String imageUrl, String clickUrl, Map<Long, Long> unreadCountMap) {
    List<List<PushClusterToken>> batches = splitIntoBatches(clusterTokens, 400);

    // 메시지에 알림 수를 담음
    List<Message> messages = batch.stream()
        .map(token -> buildMessage(pushCluster.getId(), token, title, body, imageUrl, clickUrl, unreadCountMap))
        .collect(Collectors.toList());

    // 푸시 알림 FCM 발송 로직
    ApiFuture<BatchResponse> responseFuture = FirebaseMessaging.getInstance().sendEachAsync(messages);
    /*
    	..
    */
}
private Message buildMessage(Long pushClusterId, PushClusterToken token, String title, String body, String imageUrl, String clickUrl, Map<Long, Long> unreadCountMap) {
    Long unreadCount = unreadCountMap.getOrDefault(token.getToken().getMember().getId(), 0L);

    return Message.builder()
        .setToken(token.getToken().getTokenValue())
        .setNotification(Notification.builder()
            .setTitle(title)
            .setBody(body)
            .setImage(imageUrl)
            .build())
        .putData("click_action", clickUrl)
        .putData("push_cluster_id", String.valueOf(pushClusterId))
        .putData("unread_count", String.valueOf(unreadCount)) // 사용자 맞춤 badge 수
        .build();
}
  • 메시지는 FCM의 data 필드에 사용자 맞춤 정보(클러스터 ID, 알림 수 등)를 담아 개별적으로 생성
  • 프론트엔드에서는 받은 메시지의 unread_count 값을 기반으로 배지를 정확히 렌더링 가능

 

UI 반영

앱 뱃지 적용
공지사항별 알림 설정 적용 / 알림 없음을 하더라도 구독 탭에서는 구독한 글을 볼 수 있다.

 

 

결론

특정 공지사항의 푸시 알림이 너무 많다는 사용자 피드백이 있었고, 이를 해결하기 위해 처음에는 구독한 공지사항의 알림 설정 필드(TopicMember.receiveNotification)를 추가해 기존 Topic 구조 내에서 알림 ON/OFF를 제어했다.

그러나 운영 과정에서 FCM Topic 방식의 정합성 리스크가 드러났고,

결국 Token 기반 전송 구조로 리팩토링하여,

  • DB에서 알림 수신 여부(receiveNotification)를 직접 관리
  • 사용자별 unread_count 등을 메시지에 담아 맞춤 푸시 제공
  • 발송 로그 및 통계 수집

부분이 가능해졌다.

사용자는 이제 앱 전체 알림을 끄지 않고도, 구독한 특정 공지사항에 대한 알림만 제어할 수 있으며, 구독 탭에서는 푸시 알림을 끈 상태에서도, 새 글을 확인할 수 있다.

 

 

느낀 점

이번 경험을 통해 사용자 경험(UX)을 세심히 이해하는 것과, 요구사항을 빠르게 반영할 수 있는 확장성 있는 코드 구조의 중요성을 다시금 깨달았다.

또한 기능 설계 시 상용 서비스를 벤치마킹하는 것이 큰 도움이 된다는 것도 배웠다.

키워드 알림은 당근마켓·번개장터, 공지사항별 알림 설정은 유튜브를 참고해 구현했는데, 실제 운영 서비스에서 얻은 아이디어가 프로젝트 완성도를 높이는 데 많은 역할을 했다.