본문 바로가기

Proj/구구모

FCM 알림 구현

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));
                }
            }
        }

전송에 실패한 알림의 토큰을 저장하는 코드이다. 다음 문서를 참고하여 작성되었다.

https://firebase.google.com/docs/cloud-messaging/send-message?hl=ko&_gl=1*wnear0*_up*MQ..*_ga*MTI5Njk0ODA0My4xNzIxMTQ4NTI4*_ga_CW55HF8NVT*MTcyMTE0ODUyOC4xLjAuMTcyMTE0ODUyOC4wLjAuMA..#send-messages-to-specific-devices-legacy

 

앱 서버 전송 요청 작성  |  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에 비해 생산성이 뛰어나다라는 생각이 들었다.