FCM은 메세지를 안정적으로 전송할 수 있는 크로스 플랫폼 메시징 솔류션 입니다. 그렇기 때문에 Android, iOS, Unity, Flutter, Web 다양한 곳에서 적용 가능합니다. 그래서 이번에 Android Application 댓글 알림을 구현하기 위해 FCM을 사용하게 되었는데 이것에 대해 다뤄보고자 글을 작성하게 되었습니다.
서버에서 클라이언트로 메세지를 송신하기 위해서 Firebase Admin SDK를 사용하거나 FCM 서버 프로토콜을 통해 메세지를 전송할 수 있습니다. Firebase Admin SDK는 Admin SDK는 권한이 있는 환경에서 Firebase와 상호작용하여 여러가지 Firebase 기능들을 조작할 수 있습니다 그렇기 때문에 Admin FCM API를 통해 간단하게 메세지를 송신 할 수 있습니다. FCM 서버 프로토콜 같은 경우는 직접 Firebase 서버에 요청을 해서 메세지 송신을 처리하게 되는데요 간단하게 처리하기 위해 Firebase Admin SDK를 사용해서 구현 해보도록 하겠습니다. Firebase Admin SDK 자바 라이브러리를 사용하기 위해서는 자바 8버전 이상을 사용해야하고 Firebase 프로젝트를 생성해야합니다. 프로젝트가 존재하지 않는다면 먼저 Firbase Console 에서 프로젝트 생성을 해야합니다. 프로젝트 생성이 완료되었다면 Gradle에 의존성을 추가 해주도록 하겠습니다.
dependencies {
implementation 'com.google.firebase:firebase-admin:9.1.1'
}
이 다음으로는 SDK를 초기화 시켜줘야하는데 SDK를 초기화 하기 위한 인증방식은 두가지가 있습니다. Oauth2 갱신 토큰을 사용하거나 따로 서비스 계정에 대한 Key를 생성해서 인증할 수 있습니다. Oauth2 갱신 토큰을 사용하지 않기 때문에 별도로 서비스 계정에 대한 Key를 생성해서 인증하도록 하겠습니다.
서비스 계정 키 생성 방법
이렇게 만들어진 키 파일을 프로젝트 최상위(원하는 곳에 넣어도 되지만 편의성을 위해 최상위)에 저장하도록 합니다. 키 파일을 생성하게 되면 이름이 엄청 길게 나올텐데 편의상 firebase_admin_sdk로 이름을 변경하도록 하겠습니다.
@Slf4j
@Configuration
public class FirebaseConfiguration {
@PostConstruct
public void initFirebaseInstance() throws IOException {
FileInputStream serviceAccount = new FileInputStream("firebase_admin_sdk.json");
FirebaseOptions options = FirebaseOptions.builder()
.setCredentials(GoogleCredentials.fromStream(serviceAccount))
.build();
FirebaseApp.initializeApp(options);
log.info("Firebase Init");
}
}
다음으로는 Firebase SDK를 초기화 하기 위한 FirebaseConfiguration입니다. Application이 실행될 때 Firbase SDK도 초기화가 되어야 하기 때문에 별도의 Configuration을 만들어 PostConstruct 메서드를 생성해 주었습니다. 먼저 아까 생성해둔 키 파일을 읽어와야 하기 때문에 FileInputStream을 통해 firebase_admin_sdk.json을 읽어오도록 하겠습니다. 해당 파일은 최상위 폴더에 존재하기 때문에 파일명만 명시 해주었습니다. 그 다음에 FirebaseOptions를 통해 인증정보나 이런 여러가지 설정 정보들을 넣어줄 수 있는데 인증정보만 필요하기 때문에 Credentials만 설정해줍니다. 그 다음 FirebaseApp.initializeApp을 통해 초기화를 시켜 마무리 해줍니다.
먼저 메시지 전송을 위해 Spring ApplicationEvent를 사용할 것입니다. Spring ApplicationEvent는 어플리케이션 내에서 특정 상황이 발생할 때, 이벤트를 발생시키고, 해당 이벤트를 구독하는 다른 객체들에게 이벤트를 전달할 수 있습니다. 그래서 Spring ApplicationEvent를 이용해서 댓글 등록 로직과 FCM을 처리하는 로직을 분리 하려고 합니다. 왜냐하면 별도의 Service class를 만들어서 처리하게 되면 Service 간의 의존성이 강해져 시스템이 복잡해지기 때문에 의존성을 느슨하게 만들어서 시스템의 복잡도를 낮추기 위해 ApplicationEvent를 사용하게 되었습니다. 또한 알림 같은 경우는 댓글 등록과는 크게 상관이 없기 때문에 댓글 등록시에 알림이 어떻게 처리 되는지 까지 알 필요가 없기 때문에 사용하게 되었습니다. 댓글 구조는 링크를 참고하시기 바랍니다. 그러면 이제 댓글 알림 이벤트를 먼저 생성 해주도록 하겠습니다.
@Getter
public class PostCommentFcmEvent {
private final Long postId;
private final Long parentCommentId;
private final String content;
private final MemberDto commentWriterMember;
private MemberDto tagMember;
public PostCommentFcmEvent(PostCommentRequest request, Member commentWriterMember) {
this.postId = request.getPostId();
this.content = request.getContent();
this.parentCommentId = -1L;
this.commentWriterMember = new MemberDto(commentWriterMember);
}
public PostCommentFcmEvent(PostChildCommentRequest request, Member commentWriterMember, Member tagMember) {
this.postId = request.getPostId();
this.content = request.getContent();
this.commentWriterMember = new MemberDto(commentWriterMember);
this.parentCommentId = request.getParentCommentId();
if(tagMember != null)
this.tagMember = new MemberDto(tagMember);
}
public boolean hasCommentEvent(MindSharePost post){
return !commentWriterMember.getMemberIdx().equals(post.getMember().getId());
}
public boolean hasChildCommentEvent(MindSharePostComment parentComment){
return !commentWriterMember.getMemberIdx().equals(parentComment.getMember().getId());
}
public boolean hasTagMemberEvent(){
return tagMember != null &&
!commentWriterMember.getMemberIdx().equals(tagMember.getMemberIdx());
}
}
대댓글과 댓글 알림 Event를 하나의 Event로 만들어서 처리 해주었는데요 이렇게 해준 이유는 일반 댓글을 작성하게 되면 글 작성자한테만 알림이 가도록 되어있고 대댓글을 작성하게 되면 글 작성자 + 부모 댓글 작성자 한테 알림이 가도록 되어있습니다. 대댓글(태그) 같은 경우는 글 작성자 + 부모 댓글 작성자 + 태그한 사람한테 알림이 가도록 되어있습니다. 3개의 경우 모두 글 작성자한테 알림이 가기 때문에 중복로직을 줄이기 위해 하나로 통합하게 되었습니다. 위와 같이 알림 Event에 필요한 정보를 설정해줍니다. 그 다음으로는 이러한 Event를 처리하는 EventListener를 등록 해주도록 하겠습니다.
@Slf4j
@AllArgsConstructor
@Component
public class PostCommentEvent {
private final PostCommandRepository postCommandRepository;
private final PostCommentCommandRepository postCommentCommandRepository;
private final MessageSource ms;
@Async
@TransactionalEventListener
public void sendPostWriterPush(PostCommentFcmEvent event) throws FirebaseMessagingException {
Optional<Post> postEntity = postCommandRepository.findById(event.getPostId());
if (postEntity.isEmpty() || !event.hasCommentEvent(postEntity.get()))
return;
Post post = postEntity.get();
Member postWriter = post.getMember();
String message = ms.getMessage("post.writer.comment.push", new Object[]{postWriter.getNickname(), post.getTitle(), event.getContent()}, null);
Message commentMessage = getCommentMessage(message,post.getMember().getFcmToken());
String response = FirebaseMessaging.getInstance().send(commentMessage);
log.info("Fcm Response : {}",response);
}
@Async
@TransactionalEventListener
public void sendReplyCommentPush(PostCommentFcmEvent event) throws FirebaseMessagingException {
Optional<Post> postEntity = postCommandRepository.findById(event.getPostId());
Optional<PostComment> comment = postCommentCommandRepository.findById(event.getParentCommentId());
if (postEntity.isEmpty() || comment.isEmpty()
|| event.hasCommentEvent(postEntity.get()) || !event.hasChildCommentEvent(comment.get()))
return;
String commentWriterFcmToken = comment.get().getMember().getFcmToken();
sendReplyCommentPush(event, commentWriterFcmToken);
}
@Async
@TransactionalEventListener
public void sendTagReplyCommentPush(MindSharePostCommentFcmEvent event) throws FirebaseMessagingException {
Optional<Post> postEntity = postCommandRepository.findById(event.getPostId());
Optional<PostComment> comment = postCommentCommandRepository.findById(event.getParentCommentId());
if (postEntity.isEmpty() || comment.isEmpty()
|| event.hasCommentEvent(postEntity.get()) || event.hasChildCommentEvent(comment.get())
|| !event.hasTagMemberEvent())
return;
String tagFcmToken = event.getTagMember().getFcmToken();
sendReplyCommentPush(event, tagFcmToken);
}
private void sendReplyCommentPush(PostCommentFcmEvent event, String tagFcmToken) throws FirebaseMessagingException {
String replyCommentWriterNickname = event.getCommentWriterMember().getNickname();
String message = ms.getMessage("reply.comment.push", new Object[]{replyCommentWriterNickname, event.getContent()}, null);
Message commentMessage = getCommentMessage(message,tagFcmToken);
String response = FirebaseMessaging.getInstance().send(commentMessage);
log.info("Fcm Response : {}",response);
}
private Message getCommentMessage(String message, String fcmToken){
String title = ms.getMessage("comment.push.title",null,null);
Notification notification = Notification.builder()
.setTitle(title)
.setBody(message)
.build();
return Message.builder()
.setNotification(notification)
.setToken(fcmToken)
.build();
}
}
일단 모든 EventListener 메서드는 댓글이 정상적으로 데이터베이스에 저장이 되었을 떄 알림 요청을 보내야하기 때문에 TransactionalEventListener로 설정하였습니다. 해당 EventListener는 트랜잭션이 완료된 후 동작하기 때문에 댓글이 정상적으로 데이터베이스에 저장이 되었을 때 알림 요청 작업이 시작됩니다. 먼저 sendPostWriterPush 메서드는 댓글을 작성했을 때 글 작성자 한테 알림을 보내는 메서드입니다. 글 작성자와 댓글 작성자가 동일한 아이디가 아닌 경우에만 알림을 보내도록 작성하였습니다. 그 다음으로는 sendReplsyCommentPush 메서드 인데 해당 메서드는 대댓글 등록시에 해당 댓글 작성자에게 알림을 보내는 메서드 입니다. 해당 댓글 작성자와 대댓글 작성자가 동일하지 않는 경우에만 알림을 보내도록 설정 했습니다. 또한 댓글 작성자가 글 작성자인 경우에는 이미 sendPostWriterPush에서 메세지를 보냈기 때문에 중복으로 알림을 보내지 않도록 조건을 추가해 주었습니다. sendTagReplyCommentPush 메서드 같은 경우도 중복으로 알림을 보내지 않도록 조건을 추가해 주었습니다. message를 생성하는 메서드인 getCommentMessage를 마지막으로 보도록 하겠습니다. 해당 메서드는 FCM을 통해 보낼 메세지를 생성하는 부분인데요 알림으로 보여줄 데이터를 담기 위해 Notification를 생성해주고 Message 빌더를 통해 notification과 보내고자 하는 사용자의 FCM 토큰을 설정해 줍니다. 그 다음 FirebaseMessaging.getInstance().send 메서드를 통해 해당 Message를 FCM를 통해 유저에게 알림을 보내줍니다.
앞서 말했듯이 알림 같은 경우는 댓글 등록과는 크게 상관이 없기 때문에 동기적으로 처리할 필요가 없습니다. 이런 상황에서 굳이 동기적으로 처리하게 되면 알림 요청을 보낼때까지 댓글 등록처리가 되지 않기 때문에 알림 때문에 오래 기다려야하는 상황이 발생할 수 있습니다. 이러한 문제를 방지하고자 비동기로 처리하도록 하겠습니다. 비동기 처리를 위해 Spring의 Async Annotation을 사용하겠습니다. Spring의 Async Annotation은 아무설정을 하지 않으면 SimpleAsyncTaskExecutor을 사용하게 되는데 SimpleAsyncTaskExecutor는 쓰레드풀을 사용하는게 아니라서 쓰레드풀을 사용하고자 하면 Excutor를 빈으로 등록하고 Async 어노테이션의 value에 등록한 Executor 빈이름을 설정해 주면 됩니다. 하지만 트래픽이 많지 않은 어플리케이션이기 때문에 굳이 쓰레드풀을 생성해주진 않겠습니다.
모든 설정이 완료되었습니다. 이제 알림 Event 전송을 위한 댓글 등록 Service를 생성해 주도록 하겠습니다.
@RequiredArgsConstructor
@Transactional
@Service
public class PostCommentService {
private final MemberCommandRepository memberCommandRepository;
private final PostCommentCommandRepository commentCommandRepository;
private final PostChildCommentCommandRepository childCommentCommandRepository;
private final ApplicationEventPublisher eventPublisher;
public void insertPostComment(MindSharePostCommentRequest request) {
Member member = memberCommandRepository.findById(request.getMemberId())
.orElseThrow(() -> new ClientException("유저 정보가 없습니다."));
PostComment comment = new PostComment(member, request);
commentCommandRepository.save(comment);
eventPublisher.publishEvent(new MindSharePostCommentFcmEvent(request,member));
}
public void insertPostChildComment(MindSharePostChildCommentRequest request, Long memberId) {
Member member = memberCommandRepository.findById(memberId)
.orElseThrow(() -> new ClientException("유저 정보가 없습니다."));
PostComment parentComment = commentCommandRepository.findById(request.getParentCommentId())
.orElseThrow(() -> new ClientException("댓글 정보가 없습니다."));
if(!parentComment.getPostId().equals(request.getPostId()))
throw new ClientException("올바르지 않은 요청입니다.");
Member tagMember = null;
if (request.hasTagMember()) {
tagMember = memberCommandRepository.findById(request.getTagMemberId())
.orElse(null);
}
PostChildComment comment = new PostChildComment(member, tagMember, request);
childCommentCommandRepository.save(comment);
eventPublisher.publishEvent(new PostCommentFcmEvent(request,member,tagMember));
}
}
ApplicationEventPublisher를 주입 받아 해당 Publisher에 PostCommentFcmEvent를 생성해서 publishEvent 메서드를 통해 Event를 전송할 수 있습니다.