
현재 아주대학교 공지사항을 유튜브처럼 구독하여 푸시 알림을 받을 수 있는 https://www.ajouevent.com 서비스를 운영중이다.
아주대학교의 공지사항 ex) 아주대학교 일반, 소프트웨어학과, 경영대학, 기숙사 등등 다양한 학과/단과대/장학 공지사항을 구독할 수 있다.
추가로 장학, 공모전, 인턴, 수강신청 같은 키워드를 구독해서 키워드 푸시 알림을 받을 수 있는데,
ajouevent 서비스의 키워드 알림 기능을 개발하면서 겪은 시행착오를 이 글에 적어보려고 한다.
목차
개발배경
레퍼런스
생각했던 방식
시스템적 한계
해결 방법
예시 로직
코드
아직 남은 개선점
후기
결론
개발배경

키워드 알림은 https://www.ajouevent.com 서비스를 만들게 된 가장 큰 이유다.
2023년 1학기 복학 후 학교에서 일본(도쿄)을 보내준다는 해외현장연수 프로그램이 있다는 것을 알게 되었다.
겨울 방학 때 오사카, 교토를 다녀온 뒤에 도쿄를 가고 싶다는 생각이 계속 들었기 때문에 공지가 올라올 날만을 기다렸다.
여느 때와 같이 강의가 끝나고 동기들이랑 쓰리팝에서 롤을 하고 있었는데,
5인큐가 끝난 이후에 다른 친구의 카톡을 통해 선발 공지사항이 올라왔다는 걸 알게 되었다.
신청은 2인 1팀으로 나는 지석이와 같이 신청을 했다. 나와 지석이는 군대 전역 후 복학한 직후라 활동을 한 것이 없었다.
지원 신청서에도 적을만한 내용이 없어 선발 인원 중 20%를 뽑는 선착순을 노려야 했는데 이미 늦은 뒤였다.

그리고 24년 1학기 다시 공지사항이 올라올 시기가 되었다. 이때는 사실 ajouevent를 파란학기로 개발하고 있는 시점이었지만, 아직 개발이 완성되지 않았다. 그래서 시간만 나면 소프트웨어융합대학 공지사항에 들어가 선발 공지사항이 올라왔는지 확인했다.

모집공고가 2023년과 다르게 2024년에는 좀 더 늦게 올라왔고, 나의 노가다 기간도 길어졌다.
이런 비효율적인 일을 반복하면서 키워드 알림 기능이 반드시 필요하구나라는 것을 깨달았다.
아쉬운 부분이 있으면 직접 만들어야겠다고 생각해서, 기존에 파란학기제를 같이 하기로 한 동기 2명에게 아이디어를 말했고,
2024-1학기 파란학기제 도전 과제로 제출했다.
레퍼런스


당근, 번개장터, 네이버 카페의 키워드 알림을 참고했다.
3가지 앱 모두 자주 접속하기보다는, 내가 등록한 키워드에 해당하는 판매글이 올라오면 들어가서 확인하는데,
https://www.ajouevent.com 서비스도 사용자가 구독한 공지사항이나 키워드에 대한 알림이 올라오면 사용자들이 이를 확인하기 위해 주로 클릭(접속)하는 특성과 유사하다고 생각하여 구조를 비슷하게 가져갔다.
생각했던 방식
우선 https://www.ajouevent.com 서비스의 푸시 알림은 FCM의 Topic을 사용한다.
FCM Token과 Topic의 차이는 아래와 같다.
FCM Token

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

- FCM의 Topic(토픽)은 일종의 채널로서, 미리 Topic을 구독한 사용자(토큰)들에게 메시지를 전송할 수 있다.
- Topic을 구독, 구독 취소 요청시에, FCM은 구독한 유저들을 내부적으로 관리한다.
- FCM의 subscribe, unsubscribe 메서드를 통해 구독과 구독 취소 요청을 FCM에 전송할 수 있다.
- Topic을 통해서 푸시 알림을 발송하면, Topic을 구독한 사용자들에게만 메시지를 전송할 수 있도록 한다.
*Topic 이름의 유일성
Topic 이름의 경우 유일성을 가져야하는데, 만약 유일성을 확보하지 못하면, 같은 이름의 Topic으로 인해 중복 구독 문제가 발생할 수 있다.
이를 방지하기 위해 중복되지 않는 학과/단과대의 영문 표기를 Topic 이름으로 사용하여 식별 가능하도록 했다.

학교 공지사항마다 Topic을 두고 사용자가 Topic을 구독하면, 해당 Topic의 공지사항이 올라올 때 푸시 알림을 받을 수 있다.
기존에 학교 공지사항은 fcm에서 topic 이름으로 소프트웨어학과면 Software , 간호학과면 Nursing 으로 설정하여 사용자들이 topic을 구독할 수 있도록 했다.


FCM Topic (키워드)

키워드 알림도 마찬가지로 Topic을 활용해서,
사용자가 구독한 키워드가 공지사항 제목에 포함된 경우, 해당 키워드와 연계된 푸시 알림을 받을 수 있는 구조를 생각했다.
이를 위해 DB에서 소프트웨어학과 Topic에 등록된 키워드를 가져온 뒤, 공지사항의 제목에 키워드가 포함되어 있는지 확인하는 과정만 추가하면 됐기 때문에 확장성 측면에서도 좋다고 판단했다.
시스템적 한계
하지만 Fcm Topic을 구독할 때 한글이 안된다는 것이 가장 큰 한계였다. “해외현장연수”, “아르바이트”. “장학”, “대회”, “프로젝트”, “공모전” 처럼 대부분의 공지사항들이 한글로 올라오고, 사용자들이 등록하는 키워드들도 한글이라 반드시 해결해야하는 문제였다.

앱 서버 전송 요청 작성 | Firebase Cloud Messaging
앱 서버 전송 요청 작성 | Firebase Cloud Messaging
2024년 데모 데이에서, Firebase를 사용하여 AI 기반 앱을 빌드하고 실행하는 방법에 관한 데모를 시청하세요. 지금 시청하기 의견 보내기 앱 서버 전송 요청 작성 컬렉션을 사용해 정리하기 내 환경
firebase.google.com
“topics/[a-zA-Z0-9-_.~%]+" 정규표현식을 해석해보자면
해석
- [a-zA-Z0-9-_.~%]+:
- a-z: 소문자 알파벳
- A-Z: 대문자 알파벳
- 0-9: 숫자
- -: 하이픈(-)과 언더스코어()
- .: 마침표
- ~%: 물결표(~)와 퍼센트(%)
따라서 이 표현식에 따르면 /topics/ 경로 뒤에 오는 문자열에는 +는 사용할 수 없고, 지정된 문자만 포함할 수 있다.
이외에도
- 토픽 이름은 유일해야합니다. 같은 이름의 토픽이 이미 존재하면 새로운 토픽을 생성할 수 없습니다.
- 토픽 이름은 900자를 넘길 수 없습니다.
가 있었다.



가장 큰 문제는 한글을 사용할 수 없다는 점이다.
기존의 공지사항마다 구독은 공지사항별로 학과 이름을 영어로 번역하거나 공지사항 홈페이지의 이름을 따와서 Topic으로 설정했다.
예를 들어, 소프트웨어학과는 Software, 간호학과는 Nursing과 같이 Topic 이름을 부여했다.
이 방식은 새로운 학과나 단과대가 추가되더라도 해당 Topic만 추가하면 되기 때문에 큰 문제가 없었다.

문제는 키워드였다.
키워드의 경우에는 사용자들이 주로 한글로 키워드를 등록하며, 사람마다 등록하는 키워드가 모두 다르다.
그리고 한글로 등록한 키워드를 영어로 번역해서 하기에는 현실적으로 어려웠다.
해결 방법
결국 사용자가 등록하는 키워드를 어떻게 영어로 저장할 것인가에 대한 문제를 해결해야했다.
그래서 생각한 방법이 총 3가지가 있었다.
방법1 - 한글 → 영타 변환 라이브러리 사용
그냥 한글로 입력한 키워드를 그대로 영타로 바꿔주는 방식을 생각해봤다.
예를 들어, 사용자가 “해외현장연수”를 키워드로 등록하면 이걸 그대로 영타로 바꿔서 godhlguswkddustn으로 변환하는 방식이다.
또한, 한글 쌍자음의 경우에는 영어의 대문자를 활용하면 된다. ex) “빨강색” → Qkfrkdtor
문제는 이 기능을 어떻게 구현할 것인가였는데. 다행히 이와 같은 기능을 지원하는 라이브러리를 개발한 분이 계셨다.
GitHub - 738/inko: 🇰🇷영타를 한글로, 한타를 영어로 변환해주는 자바스크립트 오픈소스 라이브러
🇰🇷영타를 한글로, 한타를 영어로 변환해주는 자바스크립트 오픈소스 라이브러리. Contribute to 738/inko development by creating an account on GitHub.
github.com

영타->한타, 한타->영타 전부 다 지원하고
파이썬, 자바스크립트, 코틀린 등 다양한 곳에서 사용이 가능하다.
단점
- 한글과 영어를 같이 사용할 수 없다 - ex) ICT 학점연계 프로젝트
- 라이브러리 의존성 추가 - 특정 라이브러리에 의존하므로 추가적인 유지보수 부담이 생긴다.
- 가독성이 좋지 않음
방법2 - 해시값 사용
사용자가 입력한 키워드를 받아 해시 알고리즘(SHA-1, SHA-256 등)을 통해 고유값을 가지는 고정된 길이의 문자열로 변환해 관리하는 방식이다.
그리고 생성된 해시값을 FCM Topic의 식별자로 사용한다.
장점
- 고유성 보장: 해싱 알고리즘은 동일한 입력에 대해 항상 같은 출력을 생성하므로, 키워드 충돌 가능성이 극히 낮음.
- 보안성: 키워드가 해시값으로 변환되므로 원본 데이터가 외부로 노출되지 않음.
- 한글, 영어 혼용 지원: 원본 데이터의 언어와 무관하게 항상 동일한 방식으로 처리 가능.
문제점
- 가독성 저하
- 해시값은 사람이 읽을 수 있는 형태가 아니며, 원본 데이터를 직관적으로 알 수 없음.
- 예를 들어, “해외현장연수”가 e89f29d...로 변환되면 해당 키워드가 무엇을 의미하는지 알 수 없음.
- 추가 연산 비용
- 해시값 생성에는 알고리즘에 따라 추가적인 연산이 필요하며, 많은 키워드를 한 번에 처리할 경우 연산 부담이 커질 수 있음.
- 특히 SHA-256이나 SHA-512 같은 복잡한 알고리즘은 연산량이 많음.
- 복원 불가능
- 해시값은 일방향 암호화로, 원본 데이터를 복원할 수 없음. 원본 키워드가 필요한 경우, 별도의 데이터베이스에 키워드와 해시값을 매핑해 저장해야 함.
- 이로 인해 저장소 관리 및 추가 연산이 필요함.
키워드 원본 데이터의 암호화가 딱히 중요하지 않고,
키워드를 구독할 때 공지사항을 지정한다는 점, 중복이 되더라도 상관이 없다는 점 때문에 해시값 사용은 고려하지 않았다.
방법3 - URL 인코딩 사용
키워드를 URL-safe 방식으로 인코딩해 ASCII 문자열로 변환하는 방식이다
ex) “해외현장연수” → %ED%95%B4%EC%99%B8%ED%98%84%EC%9E%A5%EC%97%B0%EC%88%98
장점
- 간단한 구현 - 한글을 한글을 인코딩하여 ASCII 문자로 변환하므로 별도 라이브러리가 없어도 구현 가능.
- 한글과 영어 같이 사용 가능
- 가독성 유지 가능: 인코딩 전후가 유사하게 보여 사람이 직관적으로 이해 가능.
단점
- 길이 증가: 한글 문자 하나가 %ED%95%9C처럼 길게 변환되므로 결과 문자열의 길이가 길어짐.
- → 인코딩된 결과가 너무 길면 FCM 주제 제한(최대 900자)을 초과할 가능성이 있음.
- 복원 필요: 원본 데이터를 다시 사용할 때 디코딩 과정이 필요.
- 충돌 가능성
키워드의 길이는 키워드를 구독할 때 글자수를 제한을 두면 된다.
원본 데이터에 대해서는 따로 DB에 해외현장연수라는 키워드와 Topic용으로 인코딩한 키워드인 %ED%95%B4%EC%99%B8%ED%98%84%EC%9E%A5%EC%97%B0%EC%88%98_ComputingInformatics를
저장해 복원에 대해서는 문제가 없다.
충돌 가능성에 대해서는 아래에서도 설명하지만, 키워드 알림을 받을 공지사항을 지정해서(ex. 소프트웨어학과) Topic의 명칭이 유일성을 가지도록 했다.
1차 채택: 방법1 - 한글 → 영타 변환 라이브러리 사용
사실 처음에는 한글 -> 영타 변환 라이브러리를 활용하는 방식을 선택했고, 이를 적용하고 배포까지 완료했다.
그러나 이후에 URL 인코딩을 사용하면 된다는 사실을 뒤늦게 알았다.
왜냐하면 Fcm Topic을 다루는 대부분의 블로그에서 Topic의 이름에 특수문자를 사용할 수 없다고 언급했기 때문이다.
이로 인해 %가 포함되는 URL 인코딩 방식은 배제하고 생각했다.
(특수문자를 못 쓰는줄 알고 한글 -> 영타 변환 라이브러리를 사용할 때도 키워드마다 Topic을 구별하기 위해 특수문자 대신 숫자 8을 써서(ex. 장학 - wkdgkr8Software) 공백을 대체했다...)
이번 경험을 통해 공식 문서를 꼼꼼히 확인하는 것이 얼마나 중요한지, 테스트를 직접 해봐야한다는 점,
그리고 블로그 글을 무조건 신뢰해서는 안된다는 교훈을 얻었다.
나도 블로그 글을 작성하면서 잘못된 정보를 주지 않기 위해 최대한 검토를 잘해야겠다는 생각이 들었다.

최종 채택: URL 인코딩 사용

추후에 FCM 공식문서를 다시 읽으며 FCM topic의 정규식을 찾을 수 있었고, 결국 URL 인코딩 방식으로 다시 리팩토링을 했다.
이 글을 작성하면서 다시 한 번 테스트를 해보았다.
실제로 정규식에 따라 topic에 특수문자(%, _)를 넣어도 구독이 되었고, 정상적으로 알림을 받을 수 있었다.

키워드 Topic 이름 중복 관련
예를 들어 “공모전”이라는 키워드를 소프트웨어학과 학생은 소프트웨어학과 공지사항에 올라오는 “공모전” 키워드만 받고 싶을 것이다.
건축학과의 “공모전” 키워드 알림은 받고 싶지 않을 것이다.
만약, 그냥 키워드로 공모전을 등록한다면 모든 종류의 공지사항에 공모전이 들어갈 때마다 푸시 알림을 받게 된다.
따라서 FCM Topic으로 구독할 때, 단순히 공모전 → %EA%B3%B5%EB%AA%A8%EC%A0%84 으로 인코딩해서 등록하는게 아니라,
뒤에 %EA%B3%B5%EB%AA%A8%EC%A0%84_Software 식으로 키워드 알림을 받는 공지사항 종류(Topic)를 덧붙힌 뒤 Topic을 등록해서 구분을 지었다. 대신 사용자가 키워드를 등록할 때, 어느 공지사항의 키워드를 등록할지는 정해줘야 한다.

프론트에서 백엔드로 구독 요청
{
"koreanKeyword": "장학", // 알림을 받을 키워드
"topicName": "Software" // 알림을 받을 키워드의 공지사항 종류 Topic
}
프론트에서 백엔드로 구독 취소 요청
{
"encodedKeyword": "%EC%9E%A5%ED%95%99_Software" // Topic으로 등록된 키워드
}
또한 키워드 정보를 DB에 등록할 때 어느 공지사항의 키워드인지 같이 매핑을 해준다

그리고 공지사항을 크롤링하면 해당 공지사항 (ex. Software)에 등록된 키워드(ex. 공모전, 해외현장연수, 장학, 자격증)를 가져와서,
공지사항 제목에 키워드가 포함되어있는지 확인 후에 해당 키워드를 Topic으로 하여 FCM 푸시 알림을 요청한다.
예시 로직
- 사용자가 소프트웨어학과 공지사항의 해외현장연수 키워드를 구독
- → DB에 키워드를 알림 받는 공지사항(Topic, 예시에서는 Software)과 키워드인 해외현장연수, 검색용 키워드인 해외현장연수_Software, Topic 등록용 인코딩 키워드인 %ED%95%B4%EC%99%B8%ED%98%84%EC%9E%A5%EC%97%B0%EC%88%98_Software를 저장 후 인코딩 키워드 이름으로 Topic을 구독
- 소프트웨어학과 공지사항이 올라오고 크롤링을 함 - 이때 제목은 2024-하계 소프트웨어융합대학 IP-IT해외현장연수 모집 공고
- 소프트웨어학과 Topic에 등록된 키워드 확인(ex. 장학, 실전코딩, 해외현장연수.. 등등)
- 해외현장연수 키워드가 등록되어있으므로, 해당 키워드를 구독하는 사용자들에게 푸시 알림
- FCM 메시지를 구성할 때 Topic에 %ED%95%B4%EC%99%B8%ED%98%84%EC%9E%A5%EC%97%B0%EC%88%98_Software 를 지정
// FCM 푸시 알림 메시지 구성
private Message createFcmMessage(String topic, String messageTitle, String body,
String imageUrl, String url) {
return Message.builder()
.setTopic(topic)
.setNotification(Notification.builder()
.setTitle(messageTitle)
.setBody(body)
.setImage(imageUrl)
.build())
.putData("click_action", url)
.build();
}
*인코딩시 유의사항
공백 문자 +, %20
URL 인코딩을 보면 보통 공백을 + 또는 %20로 표현한다.
스프링-자바에서 인코딩을 할때 공백이 +가 되는경우가 있는데,


위의 정규식 topics/[a-zA-Z0-9-_.~%]+에서 볼 수 있듯이 +는 topic이름으로 쓸 수 없다.
따라서 encodedKeyword = encodedKeyword.replace("+", "%20"); 이런식으로 공백을 +가 아닌 %20로 대체해야한다.



코드
키워드 푸시 알림 발송
// FCM 메시지 생성 - topic
Message message = createFcmMessage(topicName, messageTitle, body, imageUrl, url);
send(message);
// 공지사항에 해당하는 토픽 찾기
Topic topic = topicRepository.findByDepartment(topicName)
.orElseThrow(() -> new CustomException(CustomErrorCode.TOPIC_NOT_FOUND));
// 공지사항에 해당하는 토픽을 구독 중인 모든 키워드 찾기
List<Keyword> keywords = keywordRepository.findByTopic(topic);
for (Keyword keyword : keywords) {
String koreanKeyword = keyword.getKoreanKeyword();
// 공지사항의 제목이나 본문에 키워드가 포함되어 있는지 확인
if (noticeDto.getTitle().contains(koreanKeyword)) {
messageTitle = koreanKeyword + "-" + messageTitle;
String encodedKeyword = keyword.getEncodedKeyword();
// FCM 메시지 생성 - keyword
Message keywordMessage = createFcmMessage(encodedKeyword, messageTitle, body, imageUrl, url);
// 비동기적으로 알림 전송
send(keywordMessage);
}
}
키워드 구독
// 키워드 구독 - 키워드 하나씩
// KeywordRequest 예시
// 소프트웨어학과(topicName) 공지사항의
// 해외현장연수(koreanKeyword) 키워드 구독
@Transactional
public void subscribeToKeyword(KeywordRequest keywordRequest) {
// URL 인코딩과 Topic ID 결합하여 고유한 formattedKeyword 생성
String encodedKeyword = URLEncoder.encode(koreanKeyword, StandardCharsets.UTF_8);
String searchKeyword = koreanKeyword + "_" + topicName;
encodedKeyword = encodedKeyword.replace("+", "%20"); // 인코딩시 공백을 +로 하는 경우를 %로 대체
String formattedKeyword = encodedKeyword + "_" + topicName;
Topic topic = topicRepository.findByDepartment(topicName)
.orElseThrow(() -> new CustomException(CustomErrorCode.TOPIC_NOT_FOUND));
// 입력된 키워드가 존재하는지 확인하고, 없다면 새로 생성
Keyword keyword = keywordRepository.findByEncodedKeyword(formattedKeyword)
.orElseGet(() -> createNewTopic(keywordRequest, searchKeyword, formattedKeyword, topic));
// 이미 해당 키워드를 구독 중인지 확인
if (keywordMemberRepository.existsByKeywordAndMember(keyword, member)) {
throw new CustomException(CustomErrorCode.ALREADY_SUBSCRIBED_KEYWORD);
}
// 사용자가 이미 구독한 키워드 개수를 확인
long subscribedKeywordCount = keywordMemberRepository.countByMember(member);
if (subscribedKeywordCount >= 10) {
throw new CustomException(CustomErrorCode.MAX_KEYWORD_LIMIT_EXCEEDED);
}
List<Token> memberTokens = member.getTokens();
KeywordMember keywordMember = KeywordMember.builder()
.keyword(keyword)
.member(member)
.isRead(false)
.lastReadAt(LocalDateTime.now())
.build();
keywordMemberRepository.save(keywordMember);
// 토픽과 토큰을 매핑하여 저장 -> 사용자가 가지고 있는 토큰들이 topic을 구독
List<KeywordToken> keywordTokens = memberTokens.stream()
.map(token -> new KeywordToken(keyword, token))
.collect(Collectors.toList());
keywordTokenBulkRepository.saveAll(keywordTokens);
// FCM 서비스를 사용하여 토픽에 대한 구독 진행
List<String> tokenValues = memberTokens.stream()
.map(Token::getTokenValue)
.collect(Collectors.toList());
fcmService.subscribeToTopic(formattedKeyword, tokenValues);
}
// 새로운 키워드 생성 메서드
private Keyword createNewTopic(KeywordRequest keywordRequest, String searchKeyword, String formattedKeyword, Topic topic) {
// 새로운 토픽 생성 로직
Keyword newKeyword = Keyword.builder()
.encodedKeyword(formattedKeyword)
.koreanKeyword(keywordRequest.getKoreanKeyword())
.searchKeyword(searchKeyword)
.topic(topic)
.build();
keywordRepository.save(newKeyword);
keywordLogger.log("새로운 키워드 생성 : " + newKeyword.getKoreanKeyword());
return newKeyword;
}
FcmService.java
public void subscribeToTopic(String topicName, List<String> tokens) {
try {
TopicManagementResponse response = FirebaseMessaging.getInstance().subscribeToTopicAsync(tokens, topicName).get();
topicLogger.log("Subscribed to topic: " + topicName);
topicLogger.log(response.getSuccessCount() + " tokens were subscribed successfully");
if (response.getFailureCount() > 0) {
topicLogger.log(response.getFailureCount() + " tokens failed to subscribe");
response.getErrors().forEach(error -> {
String failedToken = tokens.get(error.getIndex());
topicLogger.log("Error for token at index " + error.getIndex() + ": " + error.getReason() + " (Token: " + failedToken + ")");
});
}
} catch (InterruptedException | ExecutionException e) {
// 구독에 실패한 경우에 대한 처리
throw new CustomException(CustomErrorCode.SUBSCRIBE_FAILED);
}
}
키워드 구독 취소
// 키워드 구독 취소 - 키워드 하나씩
@Transactional
public void unsubscribeFromKeyword(UnsubscribeKeywordRequest unsubscribeKeywordRequest) {
String memberEmail = SecurityContextHolder.getContext().getAuthentication().getName();
String encodedKeyword = unsubscribeKeywordRequest.getEncodedKeyword();
Member member = memberRepository.findByEmailWithTokens(memberEmail)
.orElseThrow(() -> new CustomException(CustomErrorCode.USER_NOT_FOUND));
Keyword keyword = keywordRepository.findByEncodedKeyword(encodedKeyword)
.orElseThrow(() -> new CustomException(CustomErrorCode.KEYWORD_NOT_FOUND));
// 유저가 설정한 키워드를 찾아서 삭제
keywordMemberRepository.deleteByKeywordAndMember(keyword, member);
// 해당 키워드에 관련된 토큰을 찾아서 삭제
List<Token> memberTokens = member.getTokens();
keywordTokenRepository.deleteByKeywordAndTokens(keyword, memberTokens);
// FCM 서비스를 사용하여 키워드에 대한 구독 취소 진행
List<String> tokenValues = memberTokens.stream()
.map(Token::getTokenValue)
.collect(Collectors.toList());
fcmService.unsubscribeFromTopic(encodedKeyword, tokenValues);
keywordLogger.log("키워드 구독 취소 : " + keyword.getKoreanKeyword());
}
FcmService.java
public void unsubscribeFromTopic(String topicName, List<String> tokens) {
try {
TopicManagementResponse response = FirebaseMessaging.getInstance().unsubscribeFromTopicAsync(tokens, topicName).get();
topicLogger.log("Unsubscribed to topic: " + topicName);
topicLogger.log(response.getSuccessCount() + " tokens were unsubscribed successfully");
if (response.getFailureCount() > 0) {
topicLogger.log(response.getFailureCount() + " tokens failed to unsubscribe");
response.getErrors().forEach(error -> {
String failedToken = tokens.get(error.getIndex());
topicLogger.log("Error for token at index " + error.getIndex() + ": " + error.getReason() + " (Token: " + failedToken + ")");
});
}
} catch (InterruptedException | ExecutionException e) {
// 구독 해지에 실패한 경우에 대한 처리
throw new CustomException(CustomErrorCode.SUBSCRIBE_CANCEL_FAILED);
}
}
아직 남은 개선점
현재 공지사항을 크롤링할 때 키워드 알림의 로직은
- 사전에 사용자가 소프트웨어학과 공지사항의 해외연장연수 키워드를 구독
- 학교 소프트웨어학과 홈페이지 공지사항에 새로운 글이 올라옴 - 이때 제목은 2024-하계 소프트웨어융합대학 IP-IT해외현장연수 모집 공고
- 크롤링 서버에서 새로운 글이 올라왔다는 사실을 알고, 소프트웨어학과 공지사항 글을 크롤링 함
- 해당 소프트웨어학과 공지사항(Topic)을 구독하고 있는 사용자에게 푸시 알림
- 소프트웨어학과 공지사항(Topic)에 등록된 키워드 확인(ex. 장학, 실전코딩, 해외현장연수.. 등등)
- 공지사항의 제목에 키워드가 포함되어있는지 확인
- 포함되어있다면 해당 키워드를 구독하는 사용자에게 푸시 알림
- → 해외현장연수 키워드가 등록되어있으므로, 해당 키워드를 구독하는 사용자들에게 푸시 알림
구조다.
위의 5, 6, 7 과정에서 특정 공지사항에 키워드가 많이 구독되어있을 수록,
공지사항의 제목에 키워드가 포함되어있는지 확인하는 로직과 푸시 알림을 보내주는 과정에서 로직이 오래 걸린다. 이 부분에 대해서 개선이 필요하다.
또한 지금은 Topic으로 푸시 알림을 보내는 방식이라 전송 시간이 덜 걸리지만,
추후 푸시 알림 모니터링으로 인해 Token 방식으로 전환 한다면(특정 토큰에 대한 푸시 알림 성공/실패 확인을 하려면 Token 방식으로 해야한다)
성능 개선이 더욱 필요해진다.
후기


정상적으로 키워드 알림 기능이 작동하는 것을 확인할 수 있었고, ajouevent.com의 키워드 알림 기능을 통해
동기와 후배들이 유용하게 활용했다는 말을 듣고 큰 보람을 느꼈다.
나 또한 키워드 알림 기능 덕분에 학교에서 지원하는 클라이밍 클래스(선착순)와 면접 스태프를 빠르게 신청할 수 있었다.

특히, ajouevent.com에서 키워드 알림 기능을 개발한 뒤, 에브리타임에도 비슷한 키워드 알림 기능이 도입된 것을 보고 흥미로웠다. 에브리타임의 키워드 알림은 어떻게 개발되었는지 궁금하며, 에브리타임을 개발하는 VINU팀 개발 블로그가 있는걸로 아는데 빨리 관련 글이 올라왔으면 좋겠다.
결론
- FCM에서 한글 토픽(topic)을 사용하고 싶으면 인코딩을 해서 쓰자. FCM topic 이름으로 +를 쓸 수 없으니 인코딩을 할때 공백은 + 가 아닌 %20 로 replaceAll()을 해줘야한다.
- 현재 FCM topic 이름에 적용되는 정규식은 다음과 같다 (24년 12월 기준) (이것도 바뀔 수도 있으니 직접 테스트해보자)
- [a-zA-Z0-9-_.~%]+
- FCM 공식 문서를 참고하자(영어 문서를 참고하는게 좋다)
FirebaseMessaging
firebase.google.com
Firebase 클라우드 메시징 | Firebase Cloud Messaging
Firebase Cloud Messaging
Firebase 클라우드 메시징(FCM)은 무료로 메시지를 안정적으로 전송할 수 있는 크로스 플랫폼 메시징 솔루션입니다.
firebase.google.com
앱 서버 전송 요청 작성 | Firebase Cloud Messaging
앱 서버 전송 요청 작성 | Firebase Cloud Messaging
2024년 데모 데이에서, Firebase를 사용하여 AI 기반 앱을 빌드하고 실행하는 방법에 관한 데모를 시청하세요. 지금 시청하기 의견 보내기 앱 서버 전송 요청 작성 컬렉션을 사용해 정리하기 내 환경
firebase.google.com
그림 출처
https://zuminternet.github.io/FCM-PUSH/
FCM 푸시 파헤치기
파일럿부터 적용까지 진행했던 FCM 푸시를 파헤치며 기초 가이드북처럼 정리해보았습니다.
zuminternet.github.io
'ajouevent.com' 카테고리의 다른 글
| 유튜브처럼, 구독한 공지사항별 알림 설정 기능을 도입해보자 (0) | 2025.10.02 |
|---|---|
| [MySQL] URL 저장을 위한 데이터 타입 VARCHAR vs TEXT (0) | 2025.01.19 |
| ajouevent.com 소개 - 아주대 공지사항 및 이벤트 알림 웹앱(PWA, FCM) (3) | 2025.01.05 |