1. 개요
이전 게시글에서 sse 대신 FCM으로 알림을 구현하고자 하였다. 이 게시글에서는 어떤 논리를 가지고 FCM을 구현했는지 작성해보고자 한다.
2. ERD
FCM을 사용하기 위해서는 서버 측에서 FCM token을 관리해야 한다. 따라서 fcm_notification_token을 member와 1:n 관계로 연결하였다.
이 프로젝트에서 FCM으로 알림을 보내는 것 뿐 아니라 알림 내용을 웹사이트에서 확인해야 하므로 custom_noti 테이블을 만들어 member와 1:n으로 연결하였다.
3. 토큰 관리
이 프로젝트에서는 회의를 통해 토큰을 다음과 같이 관리하기로 했다.
- 토큰은 클라이언트에서 로그인 시 요청한다.
- 같은 디바이스에서 다른 계정으로의 로그인이 발생할 경우 토큰의 주인을 바꾼다
- 이미 존재하는 토큰의 최근 사용 시각을 관리한다.
- 토큰의 미사용이 2개월을 넘어갈 경우 해당 토큰을 삭제한다.
- 한 계정이 여러 개의 토큰을 보유할 수 있다.
- 알림 전송에 실패했을 경우 만료된 토큰으로 간주, 토큰을 삭제한다.
위 조건을 토대로 토큰 관리 코드를 작성하였다.
3-1 만료 토큰 삭제
/src/main/scheduler/FCMTokenScheduler
@Component
@RequiredArgsConstructor
public class FCMTokenScheduler {
private final FcmNotificationTokenRepository fcmNotificationTokenRepository;
//최근 사용 2달이 지난 토큰 삭제
@Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul")
@Transactional
public void setExpiredMeetingStatus() {
LocalDateTime expire = LocalDateTime.now().minusMonths(2);
List<FcmNotificationToken> expiredTokens = fcmNotificationTokenRepository.findByLastUsedDateBefore(expire);
for (FcmNotificationToken expiredToken : expiredTokens) {
fcmNotificationTokenRepository.deleteById(expiredToken.getId());
}
}
}
매일 자정 토큰 사용이 2개월이 지난 토큰을 삭제하는 코드이다.
3-2 토큰 등록
/src/main/request/FcmTokenDto
@Getter
public class FcmTokenDto {
@NotEmpty
private String fcmToken;
}
requestbody에 fcm 토큰이 담겨 들어온다.
/src/main/service/FcmNotificationTokenService
@Service
@RequiredArgsConstructor
public class FcmNotificationTokenService {
private final FcmNotificationTokenRepository fcmNotificationTokenRepository;
private final MemberRepository memberRepository;
//토큰 저장
@Transactional
public void subscribe(CustomUserDetails principal, FcmTokenDto fcmTokenDto) {
Member member = checkMemberValid(principal, "토큰 저장 실패: 비로그인 사용자입니다.",
"토큰 저장 실패: 권한이 없습니다.");
//token이 있으면->createDate update?
if (fcmNotificationTokenRepository.existsByToken(fcmTokenDto.getFcmToken())) {
FcmNotificationToken updateToken = fcmNotificationTokenRepository.findByToken(fcmTokenDto.getFcmToken()).get();
updateToken.updateDate();
updateToken.setMember(member);
} else {
//새로운 토큰일 경우 db에 저장
FcmNotificationToken fcmNotificationToken = FcmNotificationToken.builder()
.token(fcmTokenDto.getFcmToken())
.member(member)
.build();
fcmNotificationTokenRepository.save(fcmNotificationToken);
}
}
private Member checkMemberValid(CustomUserDetails principal, String noLoginMessage, String notValidUserMessage) {
if (principal == null) {
throw new NoAuthorizationException(noLoginMessage);
}
Member author = memberRepository.findOne(principal.getId())
.orElseThrow(
() -> new NoAuthorizationException(notValidUserMessage)
);
if (author.getStatus() != MemberStatus.active) {
throw new NoAuthorizationException(notValidUserMessage);
}
return author;
}
}
토큰을 저장하는 비즈니스 로직이다. 이미 존재하는 토큰이면 사용 기한을 갱신하여 만료를 늦추고, 그렇지 않다면 새 토큰을 db에 저장한다.
4. 알림 관리
기본적인 CRUD이므로 간단히만 적고 넘어가도록 한다.
/src/main/service/CustomNotiService
public <T extends CustomNotiDto> List<T> findNotification(@AuthenticationPrincipal CustomUserDetails principal) {
//알림 조회 코드
}
@Transactional
public void read(CustomUserDetails principal, Long id) {
Member member = checkMemberValid(principal, "알림 읽음처리 실패: 비로그인 사용자입니다.",
"알림 읽음처리 실패: 권한이 없습니다.");
CustomNoti notification = customNotiRepository.findById(id).orElseThrow(
() -> new NotificationNotFoundException("알림 읽음처리 실패: 존재하지 않는 알림입니다.")
);
if (!notification.getMember().equals(member)) {
throw new NoAuthorizationException("알림 읽음처리 실패: 권한이 없습니다.");
}
notification.read();
}
@Transactional
public void readAll(CustomUserDetails principal) {
Member member = checkMemberValid(principal, "알림 모두 읽음처리 실패: 비로그인 사용자입니다.",
"알림 모두 읽음처리 실패: 권한이 없습니다.");
List<CustomNoti> notifications = customNotiRepository.findByMemberOrderByCreateDateDesc(member);
for (CustomNoti notification : notifications) {
notification.read();
}
}
@Transactional
public void deleteNotification(CustomUserDetails principal, Long id) {
Member member = checkMemberValid(principal, "알림 삭제 실패: 비로그인 사용자입니다.",
"알림 삭제 실패: 권한이 없습니다.");
CustomNoti notification = customNotiRepository.findById(id).orElseThrow(
()->new NotificationNotFoundException("알림 삭제 실패: 존재하지 않는 알림입니다.")
);
customNotiRepository.delete(notification);
}
@Transactional
public void deleteReadNotification(CustomUserDetails principal) {
Member member = checkMemberValid(principal, "읽은 알림 삭제 실패: 비로그인 사용자입니다.", "읽은 알림 삭제 실패: 권한이 없습니다.");
customNotiRepository.deleteAllByMemberAndIsReadTrue(member);
}
알림은 읽음 상태가 존재한다. 위 UI에 있듯이 모두 읽음 버튼이 있고, 읽은 알림 삭제 버튼을 추가할 예정이다. 이에 따라 알림 읽음 처리에 관한 기능과 알림 삭제, 읽은 알림 삭제 기능을 구현하였다.
5. 전송
두 테이블에 관한 비즈니스 로직을 구현했다면 이를 전송하는 코드가 필요하다.
우선 파이어베이스에서 제공한 서비스 계정 키를 리소스에 담는다.(이는 git submodule로 private하게 관리하였다)
/src/main/config/FirebaseConfig
@Configuration
public class FirebaseConfig {
@PostConstruct
public void init() throws IOException {
InputStream serviceAccount = new ClassPathResource("firebase/gugumo-6ae1a-firebase-adminsdk-mv97o-4d98e219db.json").getInputStream();
FirebaseOptions options = FirebaseOptions.builder()
.setCredentials(GoogleCredentials.fromStream(serviceAccount))
.build();
FirebaseApp.initializeApp(options);
}
}
프로그램을 시작할 때 서비스 계정 키를 사용하여 파이어베이스를 실행하는 코드이다. 이를 Configuration으로 스프링 빈으로 등록한다.
https://runawayfromlazy.tistory.com/91
Event 처리
1. 개요구구모 프로젝트에서 FCM을 사용하여 댓글 작성시 알림이 게시글 작성자에게 전송되도록 하는 기능을 구현하게 되었다. 이를 위해 다음 게시글의 코드를 참고하였다.https://velog.io/@kjy0302014
runawayfromlazy.tistory.com
이벤트 기반 프로그래밍을 사용한 이유와 원리는 위의 글에 게시했으므로 이 게시글에서는 전송 로직만 보도록 하자.
@Component
@RequiredArgsConstructor
@Slf4j
public class CommentEventListener {
private final PostRepository postRepository;
private final FcmNotificationTokenRepository fcmNotificationTokenRepository;
private final CustomNotiRepository customNotiRepository;
private final MessageSource ms;
@Async
@TransactionalEventListener
public void sendPostWriter(CommentFcmEvent event) throws FirebaseMessagingException {
Cmnt cmnt = event.getCmnt();
Optional<Post> targetPost = postRepository.findById(cmnt.getPost().getId());
if (targetPost.isEmpty() || !event.isCmntPostAuthorEq(targetPost.get())) {
log.info("없는 게시글이거나 게시글 작성자와 댓글 작성자가 일치함");
return;
}
Post post = targetPost.get();
Member postWriter = post.getMember();
String message = cmnt.getContent();
CustomNoti noti = CustomNoti.builder()
.message(message)
.notificationType(NotificationType.COMMENT)
.member(postWriter)
.postId(post.getId())
.build();
//List로 받아서 모든 토큰에 대해 보내도록 변경
List<FcmNotificationToken> tempToken = fcmNotificationTokenRepository.findByMember(postWriter);
if (tempToken.isEmpty()) {
log.info("FCM 토큰 존재하지 않음");
return;
}
List<String> tokens = new ArrayList<>();
for (FcmNotificationToken fcmNotificationToken : tempToken) {
tokens.add(fcmNotificationToken.getToken());
}
//토큰 여러개 집어넣기->한 계정에서의 여러 디바이스 사용
MulticastMessage commentMessage = getCommentMessage(post.getTitle(), tokens, post.getId());
BatchResponse response = FirebaseMessaging.getInstance().sendMulticast(commentMessage);
//에러 발생시?
//db에서 찾기
List<String> failedTokens = new ArrayList<>();
if (response.getFailureCount() > 0) {
List<SendResponse> responses = response.getResponses();
for (int i = 0; i < responses.size(); i++) {
if (!responses.get(i).isSuccessful()) {
// The order of responses corresponds to the order of the registration tokens.
failedTokens.add(tokens.get(i));
}
}
}
//db에서 삭제
for (String failedToken : failedTokens) {
fcmNotificationTokenRepository.deleteAllByToken(failedToken);
}
customNotiRepository.save(noti);
}
private MulticastMessage getCommentMessage(String postTitle, List<String> tokens, Long postId){
//새 댓글이 작성되었습니다.
String message = ms.getMessage("push.comment.content",null,null);
Notification notification = Notification.builder()
.setTitle(postTitle)
.setBody(message)
.build();
return MulticastMessage.builder()
.setNotification(notification)
.addAllTokens(tokens)
.putData("postId", String.valueOf(postId))
.build();
}
}
코드가 좀 긴데, 댓글 알림을 게시글 작성자에게 전송하는 sendPostWriter 함수를 하나씩 뜯어보자.
Cmnt cmnt = event.getCmnt();
Optional<Post> targetPost = postRepository.findById(cmnt.getPost().getId());
if (targetPost.isEmpty() || !event.isCmntPostAuthorEq(targetPost.get())) {
log.info("없는 게시글이거나 게시글 작성자와 댓글 작성자가 일치함");
return;
}
Post post = targetPost.get();
Member postWriter = post.getMember();
String message = cmnt.getContent();
CustomNoti noti = CustomNoti.builder()
.message(message)
.notificationType(NotificationType.COMMENT)
.member(postWriter)
.postId(post.getId())
.build();
우선 이벤트에 담긴 정보를 토대로 게시글과 작성자, 댓글 내용 등의 정보를 꺼내어 CustomNoti를 생성한다.
//List로 받아서 모든 토큰에 대해 보내도록 변경
List<FcmNotificationToken> tempToken = fcmNotificationTokenRepository.findByMember(postWriter);
if (tempToken.isEmpty()) {
log.info("FCM 토큰 존재하지 않음");
return;
}
List<String> tokens = new ArrayList<>();
for (FcmNotificationToken fcmNotificationToken : tempToken) {
tokens.add(fcmNotificationToken.getToken());
}
한 사람당 여러 개의 토큰을 보유할 수 있으므로 List로 db에서 토큰을 받는다. 토큰이 존재하지 않으면 알림을 보낼 필요도, 저장할 필요도 없으므로 리턴한다.
FCM에서 여러 토큰을 대상으로 알림을 전송하기 위해서는 MulticastMessage 객체에 List<String> 형태로 토큰을 담아야 한다. db에서 꺼내온 객체에서 토큰 값만 꺼내어 리스트에 저장한다.
import com.google.firebase.messaging.*;
private MulticastMessage getCommentMessage(String postTitle, List<String> tokens, Long postId){
//새 댓글이 작성되었습니다.
String message = ms.getMessage("push.comment.content",null,null);
Notification notification = Notification.builder()
.setTitle(postTitle)
.setBody(message)
.build();
return MulticastMessage.builder()
.setNotification(notification)
.addAllTokens(tokens)
.putData("postId", String.valueOf(postId))
.build();
}
알림 전송은 다음의 양식을 따르기로 하였다.
- Title: {게시글 이름}
- Body: 새 댓글이 도착했습니다.
Body의 내용은 다음 위치에 작성하였다.
/src/main/resources/messages.properties
push.comment.content=새 댓글이 도착했습니다.
//토큰 여러개 집어넣기->한 계정에서의 여러 디바이스 사용
MulticastMessage commentMessage = getCommentMessage(post.getTitle(), tokens, post.getId());
BatchResponse response = FirebaseMessaging.getInstance().sendMulticast(commentMessage);
메시지 생성을 완료했다면 Firebase에 알림 전송 요청을 날리고, 응답을 받아 저장한다.
//에러 발생시?
//db에서 찾기
List<String> failedTokens = new ArrayList<>();
if (response.getFailureCount() > 0) {
List<SendResponse> responses = response.getResponses();
for (int i = 0; i < responses.size(); i++) {
if (!responses.get(i).isSuccessful()) {
// The order of responses corresponds to the order of the registration tokens.
failedTokens.add(tokens.get(i));
}
}
}
전송에 실패한 알림의 토큰을 저장하는 코드이다. 다음 문서를 참고하여 작성되었다.
앱 서버 전송 요청 작성 | Firebase 클라우드 메시징
Google I/O 2023에서 Firebase의 주요 소식을 확인하세요. 자세히 알아보기 의견 보내기 앱 서버 전송 요청 작성 컬렉션을 사용해 정리하기 내 환경설정을 기준으로 콘텐츠를 저장하고 분류하세요. Fire
firebase.google.com
//db에서 삭제
for (String failedToken : failedTokens) {
fcmNotificationTokenRepository.deleteAllByToken(failedToken);
}
2번 문단의 요구사항 조건에 의해 전송에 실패한 토큰은 삭제한다.
customNotiRepository.save(noti);
알림이 전송되었으므로 조회할 수 있도록 저장한다.
6. 트러블 슈팅
처음에는 토큰 및 알림 일괄 삭제를 스프링 데이터 jpa에서 제공하는 delete 함수를 그대로 사용하였다.
void deleteAllByToken(String token);
이렇게 구현할 경우 내부적으로는 모든 토큰(알림)을 찾아 제거하는 연산을 하므로 1+N 문제가 발생할 수 있다. 따라서 다음과 같이 구현하는 쪽으로 변경하였다.
@Modifying(clearAutomatically = true)
@Query("delete from FcmNotificationToken t where t.token=:token")
@Transactional
public void deleteAllByToken(@Param("token") String token);
이렇게 되면 성능 문제를 걱정할 필요도 없고, 영속성 컨텍스트와 실제 db 사이의 동기화 문제도 해결할 수 있다.
7. 후기
sse를 버리고 FCM을 사용했더니 이것저것 고려할 요소가 많이 줄어들었다. 다만 토큰을 관리하는 방식에서 머리를 좀 많이 굴렸던 것 같다. 토큰의 생성, 관리, 만료 기준 등 이 쪽도 고려할 요소들이 많았다. 그럼에도 sse에 비해 생산성이 뛰어나다라는 생각이 들었다.
'Proj > 구구모' 카테고리의 다른 글
Trouble Shooting: @RequestBody에서 데이터를 꺼내는 데 null (0) | 2024.07.08 |
---|---|
댓글 알림 구현(feat. sse) (0) | 2024.06.25 |
EC2 한국 시간으로 변경 (0) | 2024.06.08 |
스프링 스케쥴러를 이용한 게시글 마감 처리 (0) | 2024.06.07 |
추천 게시글 기능 구현 (2) | 2024.06.04 |